import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';

/**
 * Signed, single-use "form issued at" tokens. The pilot page is static, so the form asks the server
 * for a token when it mounts; on submit we check the signature, that a human-plausible time has
 * passed, and that the token's nonce has not already produced a stored submission.
 * Format: `<issuedAtMs>.<nonce>.<base64url(HMAC-SHA256(secret, purpose:issuedAtMs:nonce))>`.
 */

export const MIN_FILL_MS = 3_000;
/** Long enough to fill the form at leisure, short enough that a minted token is not a day pass. */
export const MAX_TOKEN_AGE_MS = 2 * 60 * 60 * 1000;
const NONCE_BYTES = 16;

export type FormTokenCheck =
  | { ok: true; ageMs: number; nonce: string; expiresAt: number }
  | { ok: false; reason: 'missing' | 'malformed' | 'bad-signature' | 'too-fast' | 'expired' | 'used' };

function signature(secret: string, purpose: string, issuedAt: number, nonce: string): Buffer {
  return createHmac('sha256', secret).update(`${purpose}:${issuedAt}:${nonce}`).digest();
}

export function signFormToken(issuedAt: number, secret: string, purpose: string, nonce: string = randomBytes(NONCE_BYTES).toString('base64url')): string {
  return `${issuedAt}.${nonce}.${signature(secret, purpose, issuedAt, nonce).toString('base64url')}`;
}

/**
 * Check a token's shape, signature and age. Pass `used` to also refuse tokens whose nonce has
 * already been spent (see UsedNonces); spending it is up to the caller, once the submission counts.
 */
export function verifyFormToken(
  token: unknown,
  now: number,
  secret: string,
  purpose: string,
  { minAgeMs = MIN_FILL_MS, maxAgeMs = MAX_TOKEN_AGE_MS, used }: { minAgeMs?: number; maxAgeMs?: number; used?: Pick<UsedNonces, 'has'> } = {},
): FormTokenCheck {
  if (typeof token !== 'string' || token.length === 0) return { ok: false, reason: 'missing' };
  const match = /^(\d{1,16})\.([A-Za-z0-9_-]{22})\.([A-Za-z0-9_-]{43})$/.exec(token);
  if (!match) return { ok: false, reason: 'malformed' };
  const issuedAt = Number(match[1]);
  const nonce = match[2] ?? '';
  const given = Buffer.from(match[3] ?? '', 'base64url');
  const expected = signature(secret, purpose, issuedAt, nonce);
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) return { ok: false, reason: 'bad-signature' };
  const ageMs = now - issuedAt;
  if (ageMs < minAgeMs) return { ok: false, reason: 'too-fast' };
  if (ageMs > maxAgeMs) return { ok: false, reason: 'expired' };
  if (used?.has(nonce, now)) return { ok: false, reason: 'used' };
  return { ok: true, ageMs, nonce, expiresAt: issuedAt + maxAgeMs };
}

/**
 * Nonces of tokens that already produced a submission, kept until the token would have expired
 * anyway. Bounded (oldest entries go first), in process memory: with several web instances a token
 * could be replayed once per instance, which the database checks behind it still cap.
 */
export class UsedNonces {
  private readonly expiry = new Map<string, number>();

  constructor(private readonly maxEntries = 50_000) {}

  has(nonce: string, now: number): boolean {
    const until = this.expiry.get(nonce);
    if (until === undefined) return false;
    if (until <= now) {
      this.expiry.delete(nonce);
      return false;
    }
    return true;
  }

  /**
   * Spend a nonce. Returns false if it was already spent (two submissions racing with one token),
   * so exactly one of them goes ahead.
   */
  claim(nonce: string, expiresAt: number, now: number): boolean {
    if (this.has(nonce, now)) return false;
    this.expiry.set(nonce, expiresAt);
    // Insertion order ≈ expiry order (one fixed max age), so the first keys are the stalest.
    for (const [key, until] of this.expiry) {
      if (this.expiry.size <= this.maxEntries && until > now) break;
      this.expiry.delete(key);
    }
    return true;
  }

  /** Give a nonce back when the submission it was spent on could not be saved. */
  release(nonce: string): void {
    this.expiry.delete(nonce);
  }

  get size(): number {
    return this.expiry.size;
  }
}
