/**
 * TwoFactorService unit tests.
 *
 * P0-6 deferred sub-item: tighten TOTP `window` from 1 → 0 so the
 * verifier only accepts codes from the current 30-second window. With
 * window=1 the previous and next windows were also accepted (effective
 * 90s of validity), which roughly triples brute-force surface area.
 */
import { TOTP } from 'otpauth';
import { TwoFactorService } from '../two-factor.service';

describe('TwoFactorService.verifyCode (auditor 2026-06-01 window tolerance)', () => {
    // Valid base32 (RFC 4648 alphabet: A-Z, 2-7). otpauth treats string
    // secrets as base32-encoded, so hex literals would throw on '0'/'1'.
    const SECRET = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP';

    afterEach(() => {
        jest.useRealTimers();
    });

    it('accepts a code generated in the current 30s window', () => {
        jest.useFakeTimers();
        jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));

        const code = new TOTP({ secret: SECRET, period: 30, digits: 6 }).generate();
        expect(TwoFactorService.verifyCode(SECRET, code, 1)).toBe(true);
    });

    it('accepts a code from the previous 30s window (window=1 drift tolerance)', () => {
        jest.useFakeTimers();

        // Generate at T=0, verify 35s later — code is from the previous
        // window. window=1 accepts ±1 period of clock drift, so this
        // must succeed (was the P0-6 "rejects" behavior; auditor flagged
        // it as the source of support tickets for users whose phone
        // clocks lag the server).
        jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
        const oldCode = new TOTP({ secret: SECRET, period: 30, digits: 6 }).generate();
        jest.setSystemTime(new Date('2026-01-01T00:00:35Z'));
        expect(TwoFactorService.verifyCode(SECRET, oldCode, 1)).toBe(true);
    });

    it('accepts a code from the next 30s window (window=1 forward drift)', () => {
        jest.useFakeTimers();

        // Generate at T=+35s (future window), verify at T=0. window=1
        // accepts a one-window-ahead code — the user's phone clock is
        // running fast vs the server.
        jest.setSystemTime(new Date('2026-01-01T00:00:35Z'));
        const futureCode = new TOTP({ secret: SECRET, period: 30, digits: 6 }).generate();
        jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
        expect(TwoFactorService.verifyCode(SECRET, futureCode, 1)).toBe(true);
    });

    it('rejects a code from TWO 30s windows ago (boundary check on window=1)', () => {
        jest.useFakeTimers();

        // window=1 accepts the current ±1 period (3 codes valid at any
        // time). A code two windows old must be rejected — this is the
        // boundary test that catches an accidental window: 2 or higher.
        jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
        const veryOldCode = new TOTP({ secret: SECRET, period: 30, digits: 6 }).generate();
        jest.setSystemTime(new Date('2026-01-01T00:01:05Z')); // +65s = 2+ windows later
        expect(TwoFactorService.verifyCode(SECRET, veryOldCode, 1)).toBe(false);
    });

    it('rejects an arbitrary garbage code', () => {
        expect(TwoFactorService.verifyCode(SECRET, '000000', 1)).toBe(false);
        expect(TwoFactorService.verifyCode(SECRET, 'not-a-code', 1)).toBe(false);
    });
});

describe('TwoFactorService.generateSecret (A.1 base32 fix)', () => {
    it('produces a base32-RFC4648 secret (no 0, 1, 8, 9, no lowercase)', () => {
        const { secret } = TwoFactorService.generateSecret('test@local');
        // Only the base32 alphabet (A-Z + 2-7) is allowed. Old hex output
        // contained 0/1/8/9 which break otpauth's Secret.fromBase32.
        expect(secret).toMatch(/^[A-Z2-7]+$/);
        expect(secret.length).toBeGreaterThanOrEqual(16);
    });

    it('produces a TOTP URI whose secret matches what was generated', () => {
        const { secret, uri } = TwoFactorService.generateSecret('alice@evidenceaction.org');
        // The Authenticator app reads `secret=` from the URI. If the URI's
        // secret doesn't match what we stored, the codes the user types
        // will not validate against the stored secret.
        const match = uri.match(/secret=([A-Z2-7]+)/);
        expect(match).not.toBeNull();
        expect(match![1]).toBe(secret);
    });

    it('verifyCode succeeds for a code generated from generateSecret output', () => {
        // End-to-end: secret round-trips through verifyCode without
        // "Invalid character found" errors.
        const { secret } = TwoFactorService.generateSecret('roundtrip@local');
        const code = new TOTP({ secret, period: 30, digits: 6 }).generate();
        expect(TwoFactorService.verifyCode(secret, code, 1)).toBe(true);
    });
});

describe('TwoFactorService AAD binding (auditor T1C, 2026-06-01)', () => {
    // The audit found that TOTP secrets were encrypted WITHOUT AAD —
    // an attacker who could swap one user's ciphertext onto another
    // user's totpSecret column would have a valid envelope for the
    // wrong row. The fix binds the AAD to `user:<id>:totp`; the auth
    // tag verification fails when the AAD doesn't match.
    //
    // We can't drive TwoFactorService.enable2FA without Prisma here,
    // so we exercise the underlying crypto primitive directly with
    // the same AAD shape the service produces.
    const SECRET = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP';

    beforeAll(() => {
        process.env.CHANNEL_ENCRYPTION_KEY = process.env.CHANNEL_ENCRYPTION_KEY
            || Buffer.alloc(32, 1).toString('base64');
    });

    it('a TOTP envelope encrypted with userId=10 decrypts ONLY with the matching AAD', async () => {
        const { encrypt, decryptIfNeeded, _resetKeyCacheForTests } = await import('@/lib/crypto/secret-vault');
        _resetKeyCacheForTests();

        const envelope = encrypt(SECRET, 'user:10:totp');

        // Correct AAD → returns the original plaintext.
        expect(decryptIfNeeded(envelope, 'user:10:totp')).toBe(SECRET);

        // Different userId → auth tag verification fails, throws.
        expect(() => decryptIfNeeded(envelope, 'user:99:totp')).toThrow();
    });

    it('plaintext secrets still verify (back-compat with un-migrated rows)', () => {
        // The service uses decryptIfNeeded which is a no-op on plaintext.
        // AAD is ignored when the stored value isn't an envelope.
        const code = new TOTP({ secret: SECRET, period: 30, digits: 6 }).generate();
        expect(TwoFactorService.verifyCode(SECRET, code, 42)).toBe(true);
        expect(TwoFactorService.verifyCode(SECRET, code, 99)).toBe(true);
    });
});

describe('TwoFactorService.isLegacyHexSecret (A.2 migration predicate)', () => {
    it('flags a hex secret containing 0/1/8/9 as legacy', () => {
        expect(TwoFactorService.isLegacyHexSecret('0123456789ABCDEF0123456789ABCDEF')).toBe(true);
        expect(TwoFactorService.isLegacyHexSecret('FFFF000011112222')).toBe(true);
    });

    it('does NOT flag a valid base32 secret', () => {
        expect(TwoFactorService.isLegacyHexSecret('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP')).toBe(false);
        expect(TwoFactorService.isLegacyHexSecret('ABCDEFGHJKLMNPQRSTUVWXYZ234567')).toBe(false);
    });

    it('does NOT flag a hex secret that happens to only use A-F + 2-7 (looks like base32)', () => {
        // Edge case: a hex secret like 'ABCDEF234567' is also valid base32.
        // We cannot disambiguate, so we conservatively leave it alone.
        // Only secrets containing 0/1/8/9 can be unambiguously identified.
        expect(TwoFactorService.isLegacyHexSecret('ABCDEF234567')).toBe(false);
    });

    it('does NOT flag a string with lowercase or other non-hex chars', () => {
        expect(TwoFactorService.isLegacyHexSecret('abcdef0123')).toBe(false); // lowercase
        expect(TwoFactorService.isLegacyHexSecret('not-a-secret')).toBe(false);
    });
});
