import { createHmac } from 'node:crypto';
import { beforeEach, describe, expect, it } from 'vitest';
import { AuthService, LoginRateLimiter, type AuthContext } from '../src/auth/service.js';
import { getSetting, openDb, type Db } from '../src/db/index.js';

describe('AuthService', () => {
  let db: Db;
  let auth: AuthService;

  beforeEach(() => {
    db = openDb(':memory:');
    auth = new AuthService(db);
  });

  describe('bootstrap token', () => {
    it('créé une seule fois : le second appel renvoie null', () => {
      const token = auth.ensureBootstrapToken();
      expect(token).toMatch(/^arb_[0-9a-f]{48}$/);
      expect(auth.ensureBootstrapToken()).toBeNull();
    });

    it('recréé si tous les tokens sont révoqués', () => {
      const first = auth.ensureBootstrapToken();
      expect(first).not.toBeNull();
      db.prepare('UPDATE auth_tokens SET revoked_at = ?').run(new Date().toISOString());
      const second = auth.ensureBootstrapToken();
      expect(second).not.toBeNull();
      expect(second).not.toBe(first);
    });
  });

  describe('verifyRawToken', () => {
    it('token valide → contexte avec label', () => {
      const raw = auth.ensureBootstrapToken();
      const ctx = auth.verifyRawToken(raw as string);
      expect(ctx).not.toBeNull();
      expect(ctx?.label).toBe('initial');
      expect(typeof ctx?.tokenId).toBe('string');
    });

    it('met à jour last_used_at au passage', () => {
      const raw = auth.ensureBootstrapToken() as string;
      const ctx = auth.verifyRawToken(raw) as AuthContext;
      const row = db.prepare('SELECT last_used_at FROM auth_tokens WHERE id = ?').get(ctx.tokenId) as { last_used_at: string | null };
      expect(row.last_used_at).not.toBeNull();
    });

    it('token inconnu, trop court ou trop long → null', () => {
      auth.ensureBootstrapToken();
      expect(auth.verifyRawToken('arb_' + 'f'.repeat(48))).toBeNull(); // bon format, mauvaise valeur
      expect(auth.verifyRawToken('short')).toBeNull(); // < 8 chars
      expect(auth.verifyRawToken('a'.repeat(129))).toBeNull(); // > 128 chars
      expect(auth.verifyRawToken('')).toBeNull();
    });

    it('token révoqué → null', () => {
      const raw = auth.ensureBootstrapToken() as string;
      expect(auth.verifyRawToken(raw)).not.toBeNull();
      db.prepare('UPDATE auth_tokens SET revoked_at = ?').run(new Date().toISOString());
      expect(auth.verifyRawToken(raw)).toBeNull();
    });

    it('createToken : deux tokens coexistent avec leurs labels', () => {
      auth.ensureBootstrapToken();
      const raw2 = auth.createToken('laptop');
      expect(auth.verifyRawToken(raw2)?.label).toBe('laptop');
    });
  });

  describe('cookies', () => {
    let ctx: AuthContext;

    beforeEach(() => {
      const raw = auth.ensureBootstrapToken() as string;
      ctx = auth.verifyRawToken(raw) as AuthContext;
    });

    // Signe un payload arbitraire avec le vrai secret du serveur (lu en base),
    // pour forger des cookies à la structure valide mais au contenu choisi.
    function forge(payload: string): string {
      const secretHex = getSetting(db, 'server_secret') as string;
      const sig = createHmac('sha256', Buffer.from(secretHex, 'hex')).update(payload).digest('hex');
      return `${payload}.${sig}`;
    }

    it('cookie émis puis vérifié → même tokenId', () => {
      const cookie = auth.issueCookie(ctx);
      const back = auth.verifyCookie(cookie);
      expect(back).toEqual({ tokenId: ctx.tokenId, label: 'initial' });
    });

    it('le secret persiste : un second AuthService sur la même base vérifie le cookie', () => {
      const cookie = auth.issueCookie(ctx);
      const auth2 = new AuthService(db);
      expect(auth2.verifyCookie(cookie)?.tokenId).toBe(ctx.tokenId);
    });

    it('cookie altéré (signature ou payload) → null', () => {
      const cookie = auth.issueCookie(ctx);
      // un caractère de la signature inversé
      const last = cookie.slice(-1);
      const tamperedSig = cookie.slice(0, -1) + (last === 'a' ? 'b' : 'a');
      expect(auth.verifyCookie(tamperedSig)).toBeNull();
      // expiration repoussée dans le payload sans re-signer
      const lastDot = cookie.lastIndexOf('.');
      const [tokenId, expires] = cookie.slice(0, lastDot).split('.') as [string, string];
      const inflated = `${tokenId}.${Number(expires) + 1_000_000}.${cookie.slice(lastDot + 1)}`;
      expect(auth.verifyCookie(inflated)).toBeNull();
    });

    it('cookie malformé ou absent → null', () => {
      expect(auth.verifyCookie(undefined)).toBeNull();
      expect(auth.verifyCookie('')).toBeNull();
      expect(auth.verifyCookie('garbage')).toBeNull();
      expect(auth.verifyCookie('.sig')).toBeNull();
      expect(auth.verifyCookie('a.b.c.d')).toBeNull();
    });

    it('cookie expiré forgé avec le vrai secret → null (la forge non expirée passe, preuve que seule l’expiration rejette)', () => {
      const fresh = forge(`${ctx.tokenId}.${Date.now() + 10_000}`);
      expect(auth.verifyCookie(fresh)?.tokenId).toBe(ctx.tokenId);
      const expired = forge(`${ctx.tokenId}.${Date.now() - 1}`);
      expect(auth.verifyCookie(expired)).toBeNull();
    });

    it('cookie forgé avec tokenId vide → null', () => {
      expect(auth.verifyCookie(forge(`.${Date.now() + 10_000}`))).toBeNull();
    });

    it('cookie forgé avec expiration non numérique → null', () => {
      expect(auth.verifyCookie(forge(`${ctx.tokenId}.notanumber`))).toBeNull();
    });

    it('cookie valide mais token révoqué entre-temps → null', () => {
      const cookie = auth.issueCookie(ctx);
      db.prepare('UPDATE auth_tokens SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), ctx.tokenId);
      expect(auth.verifyCookie(cookie)).toBeNull();
    });

    it('cookie signé pour un tokenId inexistant → null', () => {
      const cookie = forge(`00000000-0000-0000-0000-000000000000.${Date.now() + 10_000}`);
      expect(auth.verifyCookie(cookie)).toBeNull();
    });
  });
});

describe('LoginRateLimiter', () => {
  it('5 tentatives par minute glissante, puis délai renvoyé', () => {
    let t = 0;
    const lim = new LoginRateLimiter(5, () => t);
    for (let i = 0; i < 5; i++) expect(lim.check()).toBeNull();
    expect(lim.check()).toBe(60_000); // toutes les tentatives à t=0
    t = 59_999;
    expect(lim.check()).toBe(1); // il reste 1 ms avant que la fenêtre glisse
    t = 60_000;
    expect(lim.check()).toBeNull(); // fenêtre vidée
  });

  it('la fenêtre glisse tentative par tentative', () => {
    let t = 0;
    const lim = new LoginRateLimiter(2, () => t);
    expect(lim.check()).toBeNull(); // t=0
    t = 30_000;
    expect(lim.check()).toBeNull(); // t=30s
    t = 40_000;
    expect(lim.check()).toBe(20_000); // bloqué par la tentative t=0
    t = 60_000;
    expect(lim.check()).toBeNull(); // t=0 expirée, t=30s reste
  });

  it('backoff exponentiel à partir de 3 échecs consécutifs', () => {
    let t = 0;
    const lim = new LoginRateLimiter(1000, () => t); // plafond haut pour isoler le backoff
    lim.recordFailure();
    lim.recordFailure();
    expect(lim.check()).toBeNull(); // 2 échecs : pas encore de lock
    lim.recordFailure(); // 3e échec → 30 s
    expect(lim.check()).toBe(30_000);
    t = 29_999;
    expect(lim.check()).toBe(1);
    t = 30_000;
    expect(lim.check()).toBeNull(); // lock levé
    lim.recordFailure(); // 4e échec → 60 s
    expect(lim.check()).toBe(60_000);
    t = 90_000;
    lim.recordFailure(); // 5e échec → 120 s
    expect(lim.check()).toBe(120_000);
  });

  it('le backoff est plafonné à 30 minutes', () => {
    let t = 0;
    const lim = new LoginRateLimiter(1000, () => t);
    for (let i = 0; i < 20; i++) lim.recordFailure();
    expect(lim.check()).toBe(30 * 60_000);
  });

  it('reset complet au succès', () => {
    let t = 0;
    const lim = new LoginRateLimiter(1000, () => t);
    for (let i = 0; i < 5; i++) lim.recordFailure();
    expect(lim.check()).not.toBeNull(); // verrouillé
    lim.recordSuccess();
    expect(lim.check()).toBeNull(); // déverrouillé immédiatement
    lim.recordFailure();
    lim.recordFailure();
    expect(lim.check()).toBeNull(); // le compteur d'échecs consécutifs est bien reparti de zéro
  });
});
