import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { type Db, getSetting, setSetting } from '../db/index.js';

const COOKIE_NAME = 'arb_session';
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;

export interface AuthContext {
  tokenId: string;
  label: string;
}

export class AuthService {
  private readonly secret: Buffer;

  constructor(private readonly db: Db) {
    let secretHex = getSetting(db, 'server_secret');
    if (!secretHex) {
      secretHex = randomBytes(32).toString('hex');
      setSetting(db, 'server_secret', secretHex);
    }
    this.secret = Buffer.from(secretHex, 'hex');
  }

  get cookieName(): string {
    return COOKIE_NAME;
  }

  /** Crée le token initial au premier démarrage et renvoie sa valeur en clair (affichée une seule fois). */
  ensureBootstrapToken(): string | null {
    const row = this.db
      .prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL')
      .get() as { n: number };
    if (row.n > 0) return null;
    return this.createToken('initial');
  }

  createToken(label: string): string {
    const raw = `arb_${randomBytes(24).toString('hex')}`;
    this.db
      .prepare('INSERT INTO auth_tokens (id, label, token_hash, created_at) VALUES (?, ?, ?, ?)')
      .run(randomUUID(), label, sha256(raw), new Date().toISOString());
    return raw;
  }

  verifyRawToken(raw: string): AuthContext | null {
    if (typeof raw !== 'string' || raw.length < 8 || raw.length > 128) return null;
    const row = this.db
      .prepare('SELECT id, label, token_hash FROM auth_tokens WHERE revoked_at IS NULL AND token_hash = ?')
      .get(sha256(raw)) as { id: string; label: string; token_hash: string } | undefined;
    if (!row) return null;
    // double vérification en temps constant (le lookup par hash est déjà non-oracle, ceinture+bretelles)
    if (!timingSafeEqual(Buffer.from(row.token_hash), Buffer.from(sha256(raw)))) return null;
    this.db.prepare('UPDATE auth_tokens SET last_used_at = ? WHERE id = ?').run(new Date().toISOString(), row.id);
    return { tokenId: row.id, label: row.label };
  }

  issueCookie(ctx: AuthContext): string {
    const expires = Date.now() + COOKIE_TTL_MS;
    const payload = `${ctx.tokenId}.${expires}`;
    return `${payload}.${this.sign(payload)}`;
  }

  verifyCookie(value: string | undefined): AuthContext | null {
    if (!value) return null;
    const lastDot = value.lastIndexOf('.');
    if (lastDot <= 0) return null;
    const payload = value.slice(0, lastDot);
    const sig = value.slice(lastDot + 1);
    const expected = this.sign(payload);
    if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
    const [tokenId, expiresStr] = payload.split('.');
    if (!tokenId || !expiresStr) return null;
    const expires = Number(expiresStr);
    if (!Number.isFinite(expires) || expires < Date.now()) return null;
    const row = this.db
      .prepare('SELECT id, label FROM auth_tokens WHERE id = ? AND revoked_at IS NULL')
      .get(tokenId) as { id: string; label: string } | undefined;
    return row ? { tokenId: row.id, label: row.label } : null;
  }

  private sign(payload: string): string {
    return createHmac('sha256', this.secret).update(payload).digest('hex');
  }
}

function sha256(s: string): string {
  return createHash('sha256').update(s).digest('hex');
}

/**
 * Rate limit GLOBAL du login (pas par IP : derrière Tailscale Serve toutes les
 * requêtes arrivent de 127.0.0.1) + backoff exponentiel sur échecs consécutifs.
 */
export class LoginRateLimiter {
  private attempts: number[] = [];
  private consecutiveFailures = 0;
  private lockedUntil = 0;

  constructor(
    private readonly maxPerMinute = 5,
    private readonly now: () => number = Date.now,
  ) {}

  /** null = autorisé ; sinon délai d'attente en ms */
  check(): number | null {
    const t = this.now();
    if (t < this.lockedUntil) return this.lockedUntil - t;
    this.attempts = this.attempts.filter((a) => t - a < 60_000);
    if (this.attempts.length >= this.maxPerMinute) return 60_000 - (t - (this.attempts[0] ?? t));
    this.attempts.push(t);
    return null;
  }

  recordFailure(): void {
    this.consecutiveFailures += 1;
    if (this.consecutiveFailures >= 3) {
      const backoff = Math.min(2 ** (this.consecutiveFailures - 3) * 30_000, 30 * 60_000);
      this.lockedUntil = this.now() + backoff;
    }
  }

  recordSuccess(): void {
    this.consecutiveFailures = 0;
    this.lockedUntil = 0;
  }
}
