// Lot 5 — sécurité enterprise : en-têtes de sécurité, journal d'audit, RGPD (export/suppression),
// garde Content-Type. Vérifie le câblage de bout en bout via buildApp + token bootstrap.
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { buildApp, type AppBundle } from '../src/app.js';
import { openDb, type Db } from '../src/db/index.js';
import type { Config } from '../src/config.js';
import type { AuditLogsResponse, DataExportResponse, DeleteMyDataResponse } from '@arboretum/shared';

vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
  class FakePty {
    pid = 424242;
    write = vi.fn();
    resize = vi.fn();
    pause = vi.fn();
    resume = vi.fn();
    kill = vi.fn();
    onData(): { dispose: () => void } {
      return { dispose: () => {} };
    }
    onExit(): { dispose: () => void } {
      return { dispose: () => {} };
    }
  }
  return { default: { spawn: (): FakePty => new FakePty() } };
});

process.env.ARBORETUM_LOG = 'silent';

let dir: string;
let bundle: AppBundle;
let db: Db;
let token: string;
const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` });

beforeAll(() => {
  dir = mkdtempSync(join(tmpdir(), 'arboretum-audit-'));
  const dbPath = join(dir, 'audit.db');
  db = openDb(dbPath);
  const config: Config = {
    port: 9998,
    bind: '127.0.0.1',
    dbPath,
    dataDir: dir,
    allowedOrigins: ['https://host.tailnet.ts.net'],
    printToken: false,
    claudeProjectsDir: join(dir, 'claude', 'projects'),
    claudeSessionsDir: join(dir, 'claude', 'sessions'),
    vapidContact: 'mailto:test@localhost',
    autoDiscover: false,
  };
  bundle = buildApp(config, db, '1.2.3-test');
  const t = bundle.auth.ensureBootstrapToken();
  if (!t) throw new Error('bootstrap token attendu');
  token = t;
});

afterAll(async () => {
  await bundle.app.close();
  db.close();
  rmSync(dir, { recursive: true, force: true });
});

describe('En-têtes de sécurité', () => {
  it('pose les en-têtes durs + no-store sur /api, sans header Server', async () => {
    const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() });
    expect(res.statusCode).toBe(200);
    expect(res.headers['x-content-type-options']).toBe('nosniff');
    expect(res.headers['x-frame-options']).toBe('DENY');
    expect(res.headers['referrer-policy']).toBe('no-referrer');
    expect(res.headers['content-security-policy']).toContain("default-src 'self'");
    expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'");
    expect(String(res.headers['cache-control'])).toContain('no-store');
    expect(res.headers['server']).toBeUndefined();
  });

  it('pose HSTS uniquement derrière HTTPS (x-forwarded-proto)', async () => {
    const http = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() });
    expect(http.headers['strict-transport-security']).toBeUndefined();
    const https = await bundle.app.inject({
      method: 'GET',
      url: '/api/v1/auth/me',
      headers: { ...auth(), 'x-forwarded-proto': 'https' },
    });
    expect(String(https.headers['strict-transport-security'])).toContain('max-age=');
  });

  it('rejette une mutation avec corps non-JSON (415)', async () => {
    const res = await bundle.app.inject({
      method: 'POST',
      url: '/api/v1/auth/tokens',
      headers: { ...auth(), 'content-type': 'text/plain' },
      payload: 'label=pwned',
    });
    expect(res.statusCode).toBe(415);
  });
});

describe('Journal d’audit', () => {
  it('journalise la création de token et l’expose via GET /audit-logs', async () => {
    const create = await bundle.app.inject({
      method: 'POST',
      url: '/api/v1/auth/tokens',
      headers: auth(),
      payload: { label: 'ci-extra' },
    });
    expect(create.statusCode).toBe(201);

    const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/audit-logs', headers: auth() });
    expect(res.statusCode).toBe(200);
    const body = res.json() as AuditLogsResponse;
    const actions = body.entries.map((e) => e.action);
    expect(actions).toContain('token.create');
    expect(actions).toContain('secret.generate'); // généré au bootstrap (system)
    // aucune VALEUR secrète en clair : pas de chaîne hex de 64 caractères (server_secret / clé).
    // (les ids sont des UUID avec tirets → non concernés ; 'server_secret' est un nom de ressource.)
    expect(res.body).not.toMatch(/[a-f0-9]{64}/i);
  });

  it('journalise les échecs de login (actor anonymous)', async () => {
    await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_invalid_xxx' } });
    const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/audit-logs?limit=200', headers: auth() });
    const body = res.json() as AuditLogsResponse;
    expect(body.entries.some((e) => e.action === 'login.failure' && e.actor === 'anonymous')).toBe(true);
  });
});

describe('RGPD', () => {
  it('exporte les données du token authentifié', async () => {
    const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/data/export', headers: auth() });
    expect(res.statusCode).toBe(200);
    const body = res.json() as DataExportResponse;
    expect(Array.isArray(body.tokens)).toBe(true);
    expect(body.tokens.some((t) => t.current)).toBe(true);
    expect(Array.isArray(body.pushSubscriptions)).toBe(true);
    expect(Array.isArray(body.sessions)).toBe(true);
    expect(body.settings).toHaveProperty('scanRoots');
  });

  it('supprime en deux temps (pending → confirm → done) et révoque le token', async () => {
    const pending = await bundle.app.inject({ method: 'POST', url: '/api/v1/data/delete-my-data', headers: auth(), payload: {} });
    const p = pending.json() as DeleteMyDataResponse;
    expect(p.status).toBe('pending');
    expect(p.confirm).toBeTruthy();

    // un 2e token existe (créé plus haut) → révoquer le token courant est autorisé.
    const done = await bundle.app.inject({
      method: 'POST',
      url: '/api/v1/data/delete-my-data',
      headers: auth(),
      payload: { confirm: p.confirm },
    });
    const d = done.json() as DeleteMyDataResponse;
    expect(d.status).toBe('done');
    expect(d.summary.tokenRevoked).toBe(true);

    // le token courant est désormais révoqué → 401.
    const after = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() });
    expect(after.statusCode).toBe(401);
  });
});
