import { mkdtempSync, mkdirSync, writeFileSync, 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 { munge } from '../src/core/jsonl-discovery.js';
import { readProcStart } from '../src/core/session-registry.js';
import type { Config } from '../src/config.js';
import type { DiscoverReposResponse, RepoResponse, ReposListResponse } from '@arboretum/shared';

// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));

// Aucun vrai PTY en CI : le module node-pty est remplacé par un faux inerte.
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(_cb: (d: string) => void): { dispose: () => void } {
      return { dispose: () => {} };
    }
    onExit(_cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } {
      return { dispose: () => {} };
    }
  }
  return { default: { spawn: (): FakePty => new FakePty() } };
});

process.env.ARBORETUM_LOG = 'silent';

const PORT = 7317;
const ALLOWED_ORIGIN = 'https://arboretum.test-tailnet.example';

interface TestApp {
  bundle: AppBundle;
  db: Db;
  token: string;
}

let dir: string;
const apps: TestApp[] = [];

function makeApp(name: string): TestApp {
  const dbPath = join(dir, `${name}.db`);
  const db = openDb(dbPath);
  const config: Config = {
    port: PORT,
    bind: '127.0.0.1',
    dbPath,
    dataDir: dir,
    allowedOrigins: [ALLOWED_ORIGIN],
    printToken: false,
    // ~/.claude jetable, propre à chaque app (jamais le vrai HOME).
    claudeProjectsDir: join(dir, `${name}-claude`, 'projects'),
    claudeSessionsDir: join(dir, `${name}-claude`, 'sessions'),
  };
  const bundle = buildApp(config, db, '0.0.0-test');
  const token = bundle.auth.ensureBootstrapToken();
  if (!token) throw new Error('bootstrap token attendu sur une base vierge');
  const app: TestApp = { bundle, db, token };
  apps.push(app);
  return app;
}

beforeAll(() => {
  dir = mkdtempSync(join(tmpdir(), 'arboretum-e2e-'));
});

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

describe('app e2e — auth, origin et sessions', () => {
  let t: TestApp;
  let cookieValue: string;

  // Le rate limiter du login est GLOBAL à l'app : un seul login réussi ici,
  // toutes les variantes d'échec vivent dans le describe rate limit (app dédiée).
  beforeAll(async () => {
    t = makeApp('main');
    const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ ok: true, label: 'initial' });
    const cookie = res.cookies.find((c) => c.name === 'arb_session');
    expect(cookie).toBeDefined();
    expect(cookie?.httpOnly).toBe(true);
    expect(cookie?.sameSite).toBe('Strict');
    // Login local (http, pas de x-forwarded-proto) → pas de Secure, sinon le cookie casserait en localhost.
    expect(cookie?.secure).toBeFalsy();
    cookieValue = cookie!.value;
  });

  it('cookie Secure posé derrière un front HTTPS (x-forwarded-proto)', async () => {
    const res = await t.bundle.app.inject({
      method: 'POST',
      url: '/api/v1/auth/login',
      headers: { 'x-forwarded-proto': 'https' },
      payload: { token: t.token },
    });
    expect(res.statusCode).toBe(200);
    const cookie = res.cookies.find((c) => c.name === 'arb_session');
    expect(cookie?.secure).toBe(true);
    expect(cookie?.httpOnly).toBe(true);
    expect(cookie?.sameSite).toBe('Strict');
  });

  it('routes API sans auth → 401', async () => {
    for (const url of ['/api/v1/sessions', '/api/v1/auth/me']) {
      const res = await t.bundle.app.inject({ method: 'GET', url });
      expect(res.statusCode).toBe(401);
      expect(res.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } });
    }
    const post = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions', payload: { cwd: tmpdir() } });
    expect(post.statusCode).toBe(401);
  });

  it('cookie émis au login → /me identifié', async () => {
    const me = await t.bundle.app.inject({
      method: 'GET',
      url: '/api/v1/auth/me',
      cookies: { arb_session: cookieValue },
    });
    expect(me.statusCode).toBe(200);
    expect(me.json()).toMatchObject({ ok: true, tokenLabel: 'initial', serverVersion: '0.0.0-test' });
    expect(typeof (me.json() as { tokenId: string }).tokenId).toBe('string');
  });

  it('cookie altéré → 401', async () => {
    const tampered = cookieValue.slice(0, -1) + (cookieValue.endsWith('a') ? 'b' : 'a');
    const me = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', cookies: { arb_session: tampered } });
    expect(me.statusCode).toBe(401);
  });

  it('Bearer token accepté sur les routes API', async () => {
    const res = await t.bundle.app.inject({
      method: 'GET',
      url: '/api/v1/sessions',
      headers: { authorization: `Bearer ${t.token}` },
    });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ sessions: [] });
  });

  it('Origin interdite → 403 même avec un Bearer valide, et même sur la route publique de login', async () => {
    const res = await t.bundle.app.inject({
      method: 'GET',
      url: '/api/v1/sessions',
      headers: { authorization: `Bearer ${t.token}`, origin: 'https://evil.example' },
    });
    expect(res.statusCode).toBe(403);
    expect(res.json()).toMatchObject({ error: { code: 'BAD_ORIGIN' } });

    const login = await t.bundle.app.inject({
      method: 'POST',
      url: '/api/v1/auth/login',
      headers: { origin: 'https://evil.example' },
      payload: { token: t.token },
    });
    expect(login.statusCode).toBe(403);
  });

  it('Origins autorisées : loopback du port configuré et --allow-origin', async () => {
    for (const origin of [`http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`, ALLOWED_ORIGIN]) {
      const res = await t.bundle.app.inject({
        method: 'GET',
        url: '/api/v1/sessions',
        headers: { authorization: `Bearer ${t.token}`, origin },
      });
      expect(res.statusCode, `origin ${origin}`).toBe(200);
    }
  });

  it('POST /sessions : cwd relatif → 400', async () => {
    const res = await t.bundle.app.inject({
      method: 'POST',
      url: '/api/v1/sessions',
      headers: { authorization: `Bearer ${t.token}` },
      payload: { cwd: 'relative/path' },
    });
    expect(res.statusCode).toBe(400);
    expect(res.json()).toMatchObject({ error: { code: 'BAD_REQUEST' } });
  });

  it('POST /sessions : cwd absent ou non-string → 400', async () => {
    const res1 = await t.bundle.app.inject({
      method: 'POST',
      url: '/api/v1/sessions',
      headers: { authorization: `Bearer ${t.token}` },
      payload: {},
    });
    expect(res1.statusCode).toBe(400);
    const res2 = await t.bundle.app.inject({
      method: 'POST',
      url: '/api/v1/sessions',
      headers: { authorization: `Bearer ${t.token}` },
      payload: { cwd: 42 },
    });
    expect(res2.statusCode).toBe(400);
  });

  it('POST /sessions : cwd inexistant → 400 SPAWN_FAILED', async () => {
    const res = await t.bundle.app.inject({
      method: 'POST',
      url: '/api/v1/sessions',
      headers: { authorization: `Bearer ${t.token}` },
      payload: { cwd: '/definitely/not/a/dir' },
    });
    expect(res.statusCode).toBe(400);
    expect(res.json()).toMatchObject({ error: { code: 'SPAWN_FAILED' } });
  });

  it('POST /sessions : command inconnue → 400', async () => {
    const res = await t.bundle.app.inject({
      method: 'POST',
      url: '/api/v1/sessions',
      headers: { authorization: `Bearer ${t.token}` },
      payload: { cwd: tmpdir(), command: 'zsh' },
    });
    expect(res.statusCode).toBe(400);
  });

  it('POST /sessions valide (bash, pty mocké) → 201 puis listée', async () => {
    const res = await t.bundle.app.inject({
      method: 'POST',
      url: '/api/v1/sessions',
      headers: { authorization: `Bearer ${t.token}` },
      payload: { cwd: tmpdir(), command: 'bash' },
    });
    expect(res.statusCode).toBe(201);
    const { session } = res.json() as { session: { id: string; cwd: string; command: string; live: boolean; status: string } };
    expect(session).toMatchObject({ cwd: tmpdir(), command: 'bash', live: true, status: 'running' });

    const list = await t.bundle.app.inject({
      method: 'GET',
      url: '/api/v1/sessions',
      headers: { authorization: `Bearer ${t.token}` },
    });
    const sessions = (list.json() as { sessions: Array<{ id: string }> }).sessions;
    expect(sessions.some((s) => s.id === session.id)).toBe(true);
  });

  it('DELETE /sessions/:id inconnu → 404', async () => {
    const res = await t.bundle.app.inject({
      method: 'DELETE',
      url: '/api/v1/sessions/unknown-id',
      headers: { authorization: `Bearer ${t.token}` },
    });
    expect(res.statusCode).toBe(404);
  });

  it('logout → cookie effacé', async () => {
    const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/logout', cookies: { arb_session: cookieValue } });
    expect(res.statusCode).toBe(200);
    const cleared = res.cookies.find((c) => c.name === 'arb_session');
    expect(cleared?.value).toBe('');
  });
});

describe('app e2e — rate limit du login', () => {
  it('mauvais token et bodies invalides → 401, puis 429 dès le 4e essai (backoff après 3 échecs consécutifs)', async () => {
    const t = makeApp('ratelimit-failures');
    const bad = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_badbadbadbad' } });
    expect(bad.statusCode).toBe(401);
    expect(bad.json()).toMatchObject({ error: { code: 'BAD_TOKEN' } });
    const noToken = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: {} });
    expect(noToken.statusCode).toBe(401);
    const numToken = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 42 } });
    expect(numToken.statusCode).toBe(401);

    // 3 échecs consécutifs → verrou exponentiel : le backoff bloque AVANT la fenêtre 5/min
    const blocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_badbadbadbad' } });
    expect(blocked.statusCode).toBe(429);
    expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
    // même un login avec le BON token est bloqué pendant le backoff
    const goodButLocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
    expect(goodButLocked.statusCode).toBe(429);
  });

  it('limite 5 tentatives/minute : la 6e → 429 même avec le bon token', async () => {
    const t = makeApp('ratelimit-window');
    for (let i = 0; i < 5; i++) {
      const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
      expect(res.statusCode, `tentative ${i + 1}`).toBe(200);
    }
    const blocked = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: t.token } });
    expect(blocked.statusCode).toBe(429);
    expect(blocked.json()).toMatchObject({ error: { code: 'RATE_LIMITED' } });
  });
});

describe('app e2e — découverte, resume & fork (P2)', () => {
  let t: TestApp;
  let projectsDir: string;
  let sessionsDir: string;

  const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });

  function writeDiscoveredJsonl(cwd: string, sid: string): void {
    const d = join(projectsDir, munge(cwd));
    mkdirSync(d, { recursive: true });
    writeFileSync(join(d, `${sid}.jsonl`), `${JSON.stringify({ type: 'user', sessionId: sid, cwd, message: { content: `prompt ${sid}` } })}\n`);
  }

  beforeAll(() => {
    t = makeApp('resume');
    projectsDir = join(dir, 'resume-claude', 'projects');
    sessionsDir = join(dir, 'resume-claude', 'sessions');
    mkdirSync(sessionsDir, { recursive: true });
  });

  it('resume / fork d’un id inconnu → 404', async () => {
    const r = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/resume', headers: bearer() });
    expect(r.statusCode).toBe(404);
    const f = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/fork', headers: bearer() });
    expect(f.statusCode).toBe(404);
  });

  it('session morte découverte → listée puis resume 201 (PTY claude dans son cwd)', async () => {
    writeDiscoveredJsonl(dir, 'dead-e2e'); // cwd = dir (existe), donc le spawn de resume réussit
    await t.bundle.discovery.refresh();

    const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/sessions', headers: bearer() });
    const found = (list.json() as { sessions: Array<{ id: string; source: string; resumable: boolean }> }).sessions.find(
      (s) => s.id === 'dead-e2e',
    );
    expect(found).toMatchObject({ source: 'discovered', resumable: true });

    const res = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/dead-e2e/resume', headers: bearer() });
    expect(res.statusCode).toBe(201);
    expect((res.json() as { session: { command: string; source: string } }).session).toMatchObject({ command: 'claude', source: 'managed' });
  });

  it('session vivante découverte → resume refusé (409), fork autorisé (201)', async () => {
    const sid = 'live-e2e';
    writeDiscoveredJsonl(dir, sid);
    const ps = readProcStart(process.pid) as string;
    writeFileSync(
      join(sessionsDir, `${process.pid}.json`),
      JSON.stringify({ pid: process.pid, procStart: ps, sessionId: sid, cwd: dir, status: 'busy' }),
    );
    await t.bundle.discovery.refresh();

    const resume = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/resume`, headers: bearer() });
    expect(resume.statusCode).toBe(409);
    expect(resume.json()).toMatchObject({ error: { code: 'SESSION_LIVE' } });

    const fork = await t.bundle.app.inject({ method: 'POST', url: `/api/v1/sessions/${sid}/fork`, headers: bearer() });
    expect(fork.statusCode).toBe(201);
  });
});

describe('app e2e — découverte auto des repos & masquage', () => {
  let t: TestApp;
  let scanRoot: string;
  const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });

  beforeAll(() => {
    t = makeApp('repos-discover');
    // un « repo » côté scanner = un dossier avec .git (le scanner ne lance pas git).
    scanRoot = join(dir, 'scan-root');
    mkdirSync(join(scanRoot, 'alpha', '.git'), { recursive: true });
    mkdirSync(join(scanRoot, 'beta', '.git'), { recursive: true });
  });

  it('POST /repos/discover enregistre les repos sous les racines configurées', async () => {
    // configure la racine de scan via l'allow-list settings, désactive le périodique
    const patch = await t.bundle.app.inject({
      method: 'PATCH',
      url: '/api/v1/settings',
      headers: bearer(),
      payload: { scanRoots: [scanRoot], scanIntervalMin: 0 },
    });
    expect(patch.statusCode).toBe(200);

    const disc = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/repos/discover', headers: bearer() });
    expect(disc.statusCode).toBe(200);
    const body = disc.json() as DiscoverReposResponse;
    expect(body.added).toBe(2);
    expect(body.scanned).toBe(2);

    const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
    const repos = (list.json() as ReposListResponse).repos;
    expect(repos.map((r) => r.label).sort()).toEqual(['alpha', 'beta']);
    expect(repos.every((r) => r.hidden === false)).toBe(true);

    // re-scan : idempotent (aucun nouveau)
    const disc2 = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/repos/discover', headers: bearer() });
    expect((disc2.json() as DiscoverReposResponse).added).toBe(0);
  });

  it('PATCH /repos/:id { hidden } masque le repo', async () => {
    const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
    const repo = (list.json() as ReposListResponse).repos[0];
    const patch = await t.bundle.app.inject({
      method: 'PATCH',
      url: `/api/v1/repos/${repo.id}`,
      headers: bearer(),
      payload: { hidden: true },
    });
    expect(patch.statusCode).toBe(200);
    expect((patch.json() as RepoResponse).repo.hidden).toBe(true);
    // toujours listé (les masqués restent récupérables) mais avec hidden=true
    const after = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
    expect((after.json() as ReposListResponse).repos.find((r) => r.id === repo.id)?.hidden).toBe(true);
  });
});
