import type { MailAdapter } from '@platform-modules/mail'
import { createMail } from '@platform-modules/mail'
import { hashToken, verifyToken } from '@platform-modules/util/tokens'

export type OtpRecord = {
  codeHash: string
  expiresAt: number
  attempts: number
}

export type OtpStore = {
  get(key: string): Promise<OtpRecord | null>
  set(key: string, record: OtpRecord): Promise<void>
  delete(key: string): Promise<void>
}

export type OtpEmailConfig = {
  mail: MailAdapter
  store: OtpStore
  from: string
  ttlMs?: number
  maxAttempts?: number
  rateLimit?: (email: string) => Promise<boolean>
}

export class OtpError extends Error {}

export class OtpExpiredError extends OtpError {
  override readonly name = 'OtpExpiredError'
}

export class OtpAttemptsExceededError extends OtpError {
  override readonly name = 'OtpAttemptsExceededError'
}

// Largest multiple of 1e6 that fits in a u32 — rejection-sample above it to avoid modulo bias.
const CODE_REJECT_LIMIT = 4_294_000_000

function randomCode(): string {
  let n: number
  do {
    n = crypto.getRandomValues(new Uint32Array(1))[0]!
  } while (n >= CODE_REJECT_LIMIT)
  return String(n % 1_000_000).padStart(6, '0')
}

export function createOtpEmail(config: OtpEmailConfig) {
  const mail = createMail(config.mail)
  const ttlMs = config.ttlMs ?? 10 * 60 * 1000
  const maxAttempts = config.maxAttempts ?? 5

  return {
    async issueAndSend(
      email: string,
      message: { subject: string; text: string },
    ): Promise<{ expiresAt: number }> {
      if (config.rateLimit && !(await config.rateLimit(email))) {
        throw new OtpError('rate limited')
      }
      const code = randomCode()
      const codeHash = await hashToken(code)
      const expiresAt = Date.now() + ttlMs
      await config.store.set(email.toLowerCase(), { codeHash, expiresAt, attempts: 0 })
      await mail.send({
        from: config.from,
        to: email,
        subject: message.subject,
        text: message.text.replace(/\{\{code\}\}/g, code),
      })
      return { expiresAt }
    },

    async verify(email: string, code: string): Promise<boolean> {
      const key = email.toLowerCase()
      const record = await config.store.get(key)
      if (!record) return false
      if (Date.now() > record.expiresAt) {
        await config.store.delete(key)
        throw new OtpExpiredError('otp expired')
      }
      if (record.attempts >= maxAttempts) {
        throw new OtpAttemptsExceededError('otp attempts exceeded')
      }
      // Charge the attempt BEFORE comparing — an aborted/crashed verify must not be a free guess.
      await config.store.set(key, { ...record, attempts: record.attempts + 1 })
      const ok = await verifyToken(code, record.codeHash)
      if (!ok) return false
      await config.store.delete(key)
      return true
    },
  }
}
