import { describe, expect, it } from 'vitest';
import { MAX_TOKEN_AGE_MS, MIN_FILL_MS, signFormToken, UsedNonces, verifyFormToken } from './form-token';

const SECRET = 'form-token-test-secret-0123456789abcdef';
const T0 = Date.UTC(2026, 9, 29, 20, 0, 0);

describe('form tokens', () => {
  it('accepts a token after the minimum fill time and returns its nonce and expiry', () => {
    const token = signFormToken(T0, SECRET, 'pilot');
    expect(token).toMatch(/^\d+\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}$/);
    const check = verifyFormToken(token, T0 + MIN_FILL_MS, SECRET, 'pilot');
    expect(check).toMatchObject({ ok: true, ageMs: MIN_FILL_MS, expiresAt: T0 + MAX_TOKEN_AGE_MS });
    if (check.ok) expect(token.split('.')[1]).toBe(check.nonce);
  });

  it('gives every token a fresh nonce', () => {
    const a = signFormToken(T0, SECRET, 'pilot');
    const b = signFormToken(T0, SECRET, 'pilot');
    expect(a).not.toBe(b);
  });

  it('refuses tokens that are too fresh or older than about two hours', () => {
    const token = signFormToken(T0, SECRET, 'pilot');
    expect(verifyFormToken(token, T0 + 1_000, SECRET, 'pilot')).toEqual({ ok: false, reason: 'too-fast' });
    expect(MAX_TOKEN_AGE_MS).toBe(2 * 3_600_000);
    expect(verifyFormToken(token, T0 + MAX_TOKEN_AGE_MS + 1, SECRET, 'pilot')).toEqual({ ok: false, reason: 'expired' });
  });

  it('refuses tampering, other purposes, other keys and the old nonce-less format', () => {
    const token = signFormToken(T0, SECRET, 'pilot', 'A'.repeat(22));
    const [issuedAt, nonce, sig] = token.split('.') as [string, string, string];
    const now = T0 + 10_000;
    expect(verifyFormToken(`${issuedAt}.${'B'.repeat(22)}.${sig}`, now, SECRET, 'pilot')).toEqual({ ok: false, reason: 'bad-signature' });
    expect(verifyFormToken(`${Number(issuedAt) - 60_000}.${nonce}.${sig}`, now, SECRET, 'pilot')).toEqual({ ok: false, reason: 'bad-signature' });
    expect(verifyFormToken(token, now, SECRET, 'other')).toEqual({ ok: false, reason: 'bad-signature' });
    expect(verifyFormToken(token, now, 'another-secret-0123456789abcdefghij', 'pilot')).toEqual({ ok: false, reason: 'bad-signature' });
    expect(verifyFormToken(`${issuedAt}.${sig}`, now, SECRET, 'pilot')).toEqual({ ok: false, reason: 'malformed' });
    expect(verifyFormToken('', now, SECRET, 'pilot')).toEqual({ ok: false, reason: 'missing' });
    expect(verifyFormToken(null, now, SECRET, 'pilot')).toEqual({ ok: false, reason: 'missing' });
  });

  it('is single-use once its nonce is claimed', () => {
    const used = new UsedNonces();
    const token = signFormToken(T0, SECRET, 'pilot');
    const now = T0 + 10_000;
    const first = verifyFormToken(token, now, SECRET, 'pilot', { used });
    expect(first.ok).toBe(true);
    if (!first.ok) return;
    expect(used.claim(first.nonce, first.expiresAt, now)).toBe(true);
    // A replay of the same token (or a racing duplicate submit) is refused.
    expect(verifyFormToken(token, now + 1_000, SECRET, 'pilot', { used })).toEqual({ ok: false, reason: 'used' });
    expect(used.claim(first.nonce, first.expiresAt, now + 1_000)).toBe(false);
    // A failed save gives the nonce back so the visitor can retry with the same page.
    used.release(first.nonce);
    expect(verifyFormToken(token, now + 2_000, SECRET, 'pilot', { used }).ok).toBe(true);
  });
});

describe('UsedNonces', () => {
  it('forgets nonces once their token has expired', () => {
    const used = new UsedNonces();
    expect(used.claim('n1', 1_000, 0)).toBe(true);
    expect(used.has('n1', 999)).toBe(true);
    expect(used.has('n1', 1_000)).toBe(false);
    expect(used.claim('n1', 5_000, 1_000)).toBe(true);
  });

  it('stays bounded, dropping the oldest and expired entries first', () => {
    const used = new UsedNonces(3);
    for (let i = 0; i < 10; i++) used.claim(`n${i}`, 1_000_000, i);
    expect(used.size).toBe(3);
    expect(used.has('n9', 10)).toBe(true);
    expect(used.has('n0', 10)).toBe(false);
    const expiring = new UsedNonces(100);
    expiring.claim('old', 10, 0);
    expiring.claim('new', 1_000, 20);
    expect(expiring.size).toBe(1);
  });
});
