/**
 * T6 · TOTP fallback (RFC 6238, HMAC-SHA1). NEVER SMS (SIM-swap → shell = catastrophic). The
 * secret is stored ENCRYPTED at rest (INV5) and decrypted only in-process. A 6-digit code has only
 * 10^6 space, so a per-account failed-attempt lockout (Finding-6, via T11's TokenBucketStore keyed
 * on the authenticated accountId, INV3) is MANDATORY — it closes the brute-force hole.
 */
import { createHmac, randomBytes } from 'node:crypto'
import type { RateLimitPolicy, TokenBucketStore } from '../../types.js'

const DIGITS = 6
const STEP_SECONDS = 30
const SECRET_BYTES = 20
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'

function base32Encode(bytes: Uint8Array): string {
  let bits = 0
  let value = 0
  let out = ''
  for (const b of bytes) {
    value = (value << 8) | b
    bits += 8
    while (bits >= 5) {
      out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]
      bits -= 5
    }
  }
  if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31]
  return out
}

function counterBytes(counter: number): Buffer {
  const buf = Buffer.alloc(8)
  buf.writeBigUInt64BE(BigInt(counter))
  return buf
}

function hotp(secret: Uint8Array, counter: number): string {
  const mac = createHmac('sha1', Buffer.from(secret)).update(counterBytes(counter)).digest()
  const offset = mac[mac.length - 1]! & 0x0f
  const bin =
    ((mac[offset]! & 0x7f) << 24) |
    ((mac[offset + 1]! & 0xff) << 16) |
    ((mac[offset + 2]! & 0xff) << 8) |
    (mac[offset + 3]! & 0xff)
  return (bin % 10 ** DIGITS).toString().padStart(DIGITS, '0')
}

export function generateTotpSecret(): { secret: Uint8Array; otpauthUri: string } {
  const secret = new Uint8Array(randomBytes(SECRET_BYTES))
  const b32 = base32Encode(secret)
  const otpauthUri = `otpauth://totp/relay?secret=${b32}&algorithm=SHA1&digits=${DIGITS}&period=${STEP_SECONDS}`
  return { secret, otpauthUri }
}

/** Verify a code within ±`window` steps (default ±1). Malformed codes → false. */
export function verifyTotp(secret: Uint8Array, code: string, now: number, window = 1): boolean {
  if (!/^\d{6}$/.test(code)) return false
  const step = Math.floor(now / STEP_SECONDS)
  for (let w = -window; w <= window; w++) {
    // constant-time-ish compare not required here (codes are 6 digits, rate-limited)
    if (hotp(secret, step + w) === code) return true
  }
  return false
}

const BUCKET_KEY = (accountId: string) => `totp:fail:${accountId}`

/**
 * Attempt gate (Finding-6): consumes one unit per verification attempt; false = locked out (deny
 * WITHOUT checking the code). burst = policy.totpMaxFailsPerWindow, refilling over the window.
 */
export function checkTotpAttemptRate(
  accountId: string,
  policy: RateLimitPolicy,
  store: TokenBucketStore,
  now: number,
): Promise<boolean> {
  const burst = policy.totpMaxFailsPerWindow
  const refill = burst / policy.totpLockoutWindowSec
  return store.take(BUCKET_KEY(accountId), refill, burst, now)
}

/**
 * Record a failed attempt — drains an extra unit so bad guesses count more heavily toward lockout
 * (escalating backoff). Uses the same failure bucket (already sized by `checkTotpAttemptRate`).
 */
export async function recordTotpFailure(
  accountId: string,
  store: TokenBucketStore,
  now: number,
): Promise<void> {
  // Params here are ignored by an already-initialized bucket; a fresh key defaults conservatively.
  await store.take(BUCKET_KEY(accountId), 5 / 300, 5, now)
}
