import { constantTimeEqual } from "../crypto.ts";

const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const MIN_DIGITS = 6;
const MAX_DIGITS = 10;
const MAX_WINDOW = 20;

function assertPeriod(period: number): number {
  if (!Number.isInteger(period) || period <= 0 || period > 86_400) {
    throw new RangeError("TOTP period must be an integer between 1 and 86400 seconds");
  }
  return period;
}

function assertDigits(digits: number): number {
  if (!Number.isInteger(digits) || digits < MIN_DIGITS || digits > MAX_DIGITS) {
    throw new RangeError(`TOTP digits must be an integer between ${MIN_DIGITS} and ${MAX_DIGITS}`);
  }
  return digits;
}

function assertTimestamp(timestamp: number): number {
  if (!Number.isFinite(timestamp) || timestamp < 0) {
    throw new RangeError("TOTP timestamp must be a finite non-negative number");
  }
  return timestamp;
}

function assertWindow(window: number): number {
  if (!Number.isInteger(window) || window < 0 || window > MAX_WINDOW) {
    throw new RangeError(`TOTP window must be an integer between 0 and ${MAX_WINDOW}`);
  }
  return window;
}

export function encodeBase32(bytes: Uint8Array): string {
  let bits = 0;
  let value = 0;
  let output = "";
  for (const byte of bytes) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      output += ALPHABET[(value >>> (bits - 5)) & 31];
      bits -= 5;
    }
  }
  if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31];
  return output;
}

export function decodeBase32(value: string): Uint8Array {
  const compact = value.toUpperCase().replace(/[\s-]/g, "");
  if (!compact || !/^[A-Z2-7]+={0,6}$/.test(compact)) {
    throw new TypeError("Invalid base32 secret");
  }
  const firstPadding = compact.indexOf("=");
  const normalized = firstPadding < 0 ? compact : compact.slice(0, firstPadding);
  if (!normalized) throw new TypeError("Invalid base32 secret");

  let bits = 0;
  let buffer = 0;
  const output: number[] = [];
  for (const character of normalized) {
    const index = ALPHABET.indexOf(character);
    if (index < 0) throw new TypeError("Invalid base32 secret");
    buffer = (buffer << 5) | index;
    bits += 5;
    if (bits >= 8) {
      output.push((buffer >>> (bits - 8)) & 255);
      bits -= 8;
    }
  }
  if (!output.length) throw new TypeError("Invalid base32 secret");
  return new Uint8Array(output);
}

function counterBytes(counter: number): Uint8Array {
  if (!Number.isSafeInteger(counter) || counter < 0) {
    throw new RangeError("HOTP counter must be a non-negative safe integer");
  }
  const bytes = new Uint8Array(8);
  let value = BigInt(counter);
  for (let index = 7; index >= 0; index -= 1) {
    bytes[index] = Number(value & 255n);
    value >>= 8n;
  }
  return bytes;
}

async function hotp(secret: string, counter: number, digits = 6): Promise<string> {
  const normalizedDigits = assertDigits(digits);
  const key = await crypto.subtle.importKey(
    "raw",
    decodeBase32(secret) as BufferSource,
    { name: "HMAC", hash: "SHA-1" },
    false,
    ["sign"],
  );
  const digest = new Uint8Array(
    await crypto.subtle.sign("HMAC", key, counterBytes(counter) as BufferSource),
  );
  const offset = digest[digest.length - 1] & 15;
  const binary =
    ((digest[offset] & 127) << 24) |
    ((digest[offset + 1] & 255) << 16) |
    ((digest[offset + 2] & 255) << 8) |
    (digest[offset + 3] & 255);
  return String(binary % 10 ** normalizedDigits).padStart(normalizedDigits, "0");
}

export interface TotpOptions {
  period?: number;
  digits?: number;
  timestamp?: number;
}

export function generateTotpSecret(randomBytes?: (length: number) => Uint8Array): string {
  const bytes = randomBytes ? randomBytes(20) : crypto.getRandomValues(new Uint8Array(20));
  if (!(bytes instanceof Uint8Array) || bytes.length !== 20) {
    throw new TypeError("TOTP random byte provider must return exactly 20 bytes");
  }
  return encodeBase32(bytes);
}

export async function generateTotp(secret: string, options: TotpOptions = {}): Promise<string> {
  const period = assertPeriod(options.period ?? 30);
  const timestamp = assertTimestamp(options.timestamp ?? Date.now());
  const digits = assertDigits(options.digits ?? 6);
  return hotp(secret, Math.floor(timestamp / 1000 / period), digits);
}

export async function verifyTotp(
  secret: string,
  token: string,
  options: TotpOptions & { window?: number; lastCounter?: number } = {},
): Promise<{ valid: boolean; counter?: number }> {
  const period = assertPeriod(options.period ?? 30);
  const timestamp = assertTimestamp(options.timestamp ?? Date.now());
  const digits = assertDigits(options.digits ?? 6);
  const window = assertWindow(options.window ?? 1);
  const normalizedToken = token.replace(/\s/g, "");
  if (!new RegExp(`^\\d{${digits}}$`).test(normalizedToken)) return { valid: false };

  const counter = Math.floor(timestamp / 1000 / period);
  const lastCounter = options.lastCounter ?? -1;
  if (!Number.isSafeInteger(lastCounter) || lastCounter < -1) {
    throw new RangeError("TOTP lastCounter must be a safe integer greater than or equal to -1");
  }

  for (let offset = -window; offset <= window; offset += 1) {
    const candidateCounter = counter + offset;
    if (candidateCounter < 0 || candidateCounter <= lastCounter) continue;
    const candidate = await hotp(secret, candidateCounter, digits);
    if (await constantTimeEqual(candidate, normalizedToken)) {
      return { valid: true, counter: candidateCounter };
    }
  }
  return { valid: false };
}

export function totpUri(input: {
  issuer: string;
  accountName: string;
  secret: string;
  period?: number;
  digits?: number;
}): string {
  const issuer = input.issuer.trim();
  const accountName = input.accountName.trim();
  if (!issuer || !accountName) {
    throw new TypeError("TOTP issuer and account name are required");
  }
  decodeBase32(input.secret);
  const period = assertPeriod(input.period ?? 30);
  const digits = assertDigits(input.digits ?? 6);
  const label = encodeURIComponent(`${issuer}:${accountName}`);
  const params = new URLSearchParams({
    secret: input.secret.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/g, ""),
    issuer,
    period: String(period),
    digits: String(digits),
    algorithm: "SHA1",
  });
  return `otpauth://totp/${label}?${params.toString()}`;
}
