// Gestion des repos enregistrés et de leurs worktrees git.
// Source de vérité des worktrees = git (dérivés à la volée + cache court par repo) ; seuls les repos
// sont persistés. Corrélation worktree ↔ sessions par cwd. Mutations sérialisées par repo.
import { EventEmitter } from 'node:events';
import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { basename, dirname, join, resolve, sep } from 'node:path';
import { existsSync, realpathSync } from 'node:fs';
import type {
  DiscoverReposResponse,
  HookRunResult,
  PostCreateHook,
  RepoSummary,
  SessionSummary,
  WorktreeBranchAction,
  WorktreeBranchMode,
  WorktreeGitStatus,
  WorktreeSummary,
} from '@arboretum/shared';
import type { Db } from '../db/index.js';
import type { PtyManager } from './pty-manager.js';
import { DiscoveryService, mergeSessions } from './discovery-service.js';
import { scanForRepos } from './repo-scanner.js';
import { preTrustProject } from './claude-trust.js';
import {
  addWorktree,
  amendCommit,
  cleanFiles,
  commitAll,
  commitStaged,
  defaultBranch,
  fetchRemote,
  fileDiff,
  isDirtyWorktreeError,
  isRepo,
  isSafeAbsolutePath,
  isSafeRelativePath,
  isUnpushed,
  isValidBranchName,
  listBranches,
  listChanges,
  listWorktrees,
  pruneWorktrees,
  pull,
  push,
  removeWorktree,
  restoreFiles,
  stageFiles,
  switchBranch,
  unstageFiles,
  worktreeStatus,
  type ParsedWorktree,
} from './git.js';
import type { FsWatcherService } from './fs-watcher.js';
import type { FileChange, FileDiffResponse } from '@arboretum/shared';

const FACTS_TTL_MS = 2500;
const HOOK_TIMEOUT_MS = 5 * 60_000;
const HOOK_OUTPUT_MAX = 64 * 1024;
// Bornes du scan de découverte (anti-explosion sur un home volumineux).
const SCAN_MAX_DEPTH = 6;
const SCAN_MAX_REPOS = 2000;
const SCAN_TIMEOUT_MS = 30_000;

interface RepoRow {
  id: string;
  path: string;
  label: string;
  default_branch: string | null;
  post_create_hooks: string;
  pre_trust: number;
  created_at: string;
  hidden: number;
}

export interface WorktreeManagerEvents {
  repo_update: [RepoSummary];
  repo_removed: [string];
  worktree_update: [{ repoId: string; worktree: WorktreeSummary }];
  worktree_removed: [{ repoId: string; path: string }];
  /** P7 : le détail (liste des changements/diff) d'un worktree regardé a changé ; relayé en push
   *  ciblé `worktree_changes` aux seules connexions ayant `watch`é cette clé. */
  worktree_changes: [{ repoId: string; path: string }];
}

/** Erreur portant un statusCode + code pour mapping HTTP direct par les routes. */
function httpError(statusCode: number, code: string, message: string): Error {
  return Object.assign(new Error(message), { statusCode, code });
}

function parseHooks(json: string): PostCreateHook[] {
  try {
    const arr = JSON.parse(json) as unknown;
    if (!Array.isArray(arr)) return [];
    return arr.filter(
      (h): h is PostCreateHook =>
        !!h && typeof h.id === 'string' && typeof h.label === 'string' && typeof h.run === 'string' && typeof h.enabled === 'boolean',
    );
  } catch {
    return [];
  }
}

function runHook(cwd: string, hook: PostCreateHook): Promise<HookRunResult> {
  return new Promise((resolveP) => {
    const t0 = Date.now();
    execFile(
      'bash',
      ['-lc', hook.run],
      { cwd, timeout: HOOK_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, env: process.env },
      (err, stdout, stderr) => {
        const output = `${stdout ?? ''}${stderr ?? ''}`.slice(-HOOK_OUTPUT_MAX);
        const e = err as (Error & { code?: number | string }) | null;
        resolveP({
          hookId: hook.id,
          label: hook.label,
          exitCode: e ? (typeof e.code === 'number' ? e.code : 1) : 0,
          output,
          durationMs: Date.now() - t0,
        });
      },
    );
  });
}

export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
  private readonly factsCache = new Map<string, { facts: Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>; at: number }>();
  private readonly locks = new Map<string, Promise<unknown>>();
  /** Scan de découverte en cours : coalesce boot + bouton + périodique sur un seul scan. */
  private scanInFlight: Promise<DiscoverReposResponse> | null = null;

  constructor(
    private readonly db: Db,
    private readonly ptyManager: PtyManager,
    private readonly discovery: DiscoveryService,
    private readonly fsWatcher?: FsWatcherService,
  ) {
    super();
    // P7 : un changement FS sur un worktree regardé invalide le cache, rediffuse le status frais
    // (worktree_update : compteurs légers pour tout le dashboard) et signale aux clients qui le
    // regardent de re-fetcher le détail (worktree_changes ciblé). On résout repoId → row à la volée.
    this.fsWatcher?.on('worktree_fs_change', ({ repoId, path }) => {
      this.factsCache.delete(repoId);
      const row = this.getRepoRow(repoId);
      if (row) void this.emitWorktree(row, path).catch(() => {});
      this.emit('worktree_changes', { repoId, path });
    });
  }

  // ---- repos ----

  private getRepoRow(id: string): RepoRow | null {
    return (this.db.prepare('SELECT * FROM repos WHERE id = ?').get(id) as unknown as RepoRow | undefined) ?? null;
  }

  private async rowToSummary(row: RepoRow): Promise<RepoSummary> {
    return {
      id: row.id,
      path: row.path,
      label: row.label,
      defaultBranch: row.default_branch,
      postCreateHooks: parseHooks(row.post_create_hooks),
      preTrust: row.pre_trust === 1,
      createdAt: row.created_at,
      valid: await isRepo(row.path),
      hidden: row.hidden === 1,
    };
  }

  async listRepos(): Promise<RepoSummary[]> {
    const rows = this.db.prepare('SELECT * FROM repos ORDER BY created_at ASC').all() as unknown as RepoRow[];
    return Promise.all(rows.map((r) => this.rowToSummary(r)));
  }

  async addRepo(opts: { path: string; label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> {
    const path = opts.path;
    if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_REQUEST', 'path must be an absolute, normalized path');
    if (!(await isRepo(path))) throw httpError(400, 'NOT_A_REPO', `Not a git repository root: ${path}`);
    const existing = this.db.prepare('SELECT id FROM repos WHERE path = ?').get(path) as { id: string } | undefined;
    if (existing) throw httpError(409, 'ALREADY_REGISTERED', 'This repository is already registered');
    const row: RepoRow = {
      id: randomUUID(),
      path,
      label: opts.label?.trim() || basename(path),
      default_branch: await defaultBranch(path),
      post_create_hooks: JSON.stringify(opts.postCreateHooks ?? []),
      pre_trust: opts.preTrust ? 1 : 0,
      created_at: new Date().toISOString(),
      hidden: 0,
    };
    try {
      this.db
        .prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
        .run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at, row.hidden);
    } catch (err) {
      // Course possible avec un scan concurrent qui aurait inséré le même path entre le SELECT
      // d'unicité et cet INSERT (contrainte UNIQUE sur path) → on rend le même 409 explicite.
      if (String((err as { code?: string }).code).includes('CONSTRAINT')) {
        throw httpError(409, 'ALREADY_REGISTERED', 'This repository is already registered');
      }
      throw err;
    }
    const summary = await this.rowToSummary(row);
    this.emit('repo_update', summary);
    this.armRepoWatcher(row); // P11 : temps réel du checkout principal dès l'enregistrement
    return summary;
  }

  /**
   * P11 : arme un watcher PERMANENT sur le checkout principal de chaque repo visible : un
   * `git checkout`/`switch` en CLI sur le principal est ainsi rediffusé en temps réel sans qu'un
   * client ne l'ait « regardé ». Appelé depuis runDaemon (jamais buildApp → pas d'effet en tests purs).
   */
  armMainCheckoutWatchers(): void {
    const rows = this.db.prepare('SELECT id, path FROM repos WHERE hidden = 0').all() as Array<{ id: string; path: string }>;
    for (const r of rows) this.armRepoWatcher(r);
  }

  private armRepoWatcher(row: { id: string; path: string }): void {
    this.fsWatcher?.pinRepo(row.id, resolve(row.path));
  }

  async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; hidden?: boolean }): Promise<RepoSummary> {
    const row = this.getRepoRow(id);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
    if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
    if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
    if (patch.hidden !== undefined) row.hidden = patch.hidden ? 1 : 0;
    this.db
      .prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ?, hidden = ? WHERE id = ?')
      .run(row.label, row.post_create_hooks, row.pre_trust, row.hidden, id);
    const summary = await this.rowToSummary(row);
    this.emit('repo_update', summary);
    // P11 : masqué → on libère le watcher permanent du principal ; ré-affiché → on le réarme.
    if (patch.hidden !== undefined) {
      if (row.hidden === 1) this.fsWatcher?.unpinRepo(id, resolve(row.path));
      else this.armRepoWatcher(row);
    }
    return summary;
  }

  removeRepo(id: string): boolean {
    const row = this.getRepoRow(id);
    const res = this.db.prepare('DELETE FROM repos WHERE id = ?').run(id);
    if (res.changes === 0) return false;
    if (row) this.fsWatcher?.unpinRepo(id, resolve(row.path)); // P11 : libère le watcher permanent
    this.factsCache.delete(id);
    this.emit('repo_removed', id);
    return true;
  }

  /**
   * Découvre les repos git sous `roots` et auto-enregistre les NOUVEAUX (path absent de la DB).
   * Idempotent et anti-résurrection : un path déjà présent (visible OU masqué) n'est jamais
   * réécrit (INSERT ... ON CONFLICT DO NOTHING). Les scans concurrents sont coalescés. Tolérant :
   * ne lève pas (le scanner avale les erreurs FS). N'appelle aucun git pendant le scan
   * (default_branch=NULL, résolu paresseusement par rowToSummary à l'affichage).
   */
  discoverRepos(opts: { roots: string[]; maxDepth?: number; maxRepos?: number }): Promise<DiscoverReposResponse> {
    if (this.scanInFlight) return this.scanInFlight;
    this.scanInFlight = this.runDiscovery(opts).finally(() => {
      this.scanInFlight = null;
    });
    return this.scanInFlight;
  }

  private async runDiscovery(opts: { roots: string[]; maxDepth?: number; maxRepos?: number }): Promise<DiscoverReposResponse> {
    const t0 = Date.now();
    const { paths, truncated } = await scanForRepos(
      opts.roots,
      { maxDepth: opts.maxDepth ?? SCAN_MAX_DEPTH, maxRepos: opts.maxRepos ?? SCAN_MAX_REPOS },
      AbortSignal.timeout(SCAN_TIMEOUT_MS),
    );
    const insert = this.db.prepare(
      `INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden)
       VALUES (?, ?, ?, NULL, '[]', 0, ?, 0) ON CONFLICT(path) DO NOTHING`,
    );
    let added = 0;
    for (const path of paths) {
      const row: RepoRow = {
        id: randomUUID(),
        path,
        label: basename(path),
        default_branch: null,
        post_create_hooks: '[]',
        pre_trust: 0,
        created_at: new Date().toISOString(),
        hidden: 0,
      };
      const res = insert.run(row.id, row.path, row.label, row.created_at);
      if (res.changes === 1) {
        added++;
        this.emit('repo_update', await this.rowToSummary(row)); // nouveaux uniquement
        this.armRepoWatcher(row); // P11 : temps réel du checkout principal du repo découvert
      }
    }
    return { scanned: paths.length, added, durationMs: Date.now() - t0, truncated };
  }

  // ---- worktrees ----

  /**
   * Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree.
   * Les sessions explicitement masquées (`hidden`) sont exclues, cohérent avec `/api/v1/sessions`
   * (sans quoi le masquage était ignoré dans les fiches worktree). Le tri managées/externes est laissé
   * au client (interrupteur « afficher les externes »), qui dispose du champ `source`. La garde de
   * suppression réclame en revanche TOUTES les sessions vivantes (`includeHidden`) pour rester sûre.
   */
  private sessionsForCwd(path: string, opts?: { includeHidden?: boolean }): SessionSummary[] {
    const rp = resolve(path);
    return mergeSessions(this.ptyManager.list(), this.discovery.list())
      .filter((s) => resolve(s.cwd) === rp)
      .filter((s) => opts?.includeHidden || !s.hidden);
  }

  private toSummary(repoId: string, repoPath: string, w: ParsedWorktree, status: WorktreeGitStatus): WorktreeSummary {
    return {
      repoId,
      path: w.path,
      branch: w.branch,
      head: w.head ?? '',
      detached: w.detached,
      locked: w.locked,
      prunable: w.prunable,
      isMain: resolve(w.path) === resolve(repoPath),
      git: status,
      sessions: this.sessionsForCwd(w.path),
    };
  }

  private async repoFacts(row: RepoRow, noCache = false): Promise<Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>> {
    const cached = this.factsCache.get(row.id);
    if (!noCache && cached && Date.now() - cached.at < FACTS_TTL_MS) return cached.facts;
    const parsed = (await listWorktrees(row.path)).filter((w) => !w.bare);
    const facts = await Promise.all(parsed.map(async (w) => ({ w, status: await worktreeStatus(w.path) })));
    this.factsCache.set(row.id, { facts, at: Date.now() });
    return facts;
  }

  async listRepoWorktrees(repoId: string, noCache = false): Promise<WorktreeSummary[]> {
    const row = this.getRepoRow(repoId);
    if (!row) return [];
    const facts = await this.repoFacts(row, noCache);
    return facts.map(({ w, status }) => this.toSummary(row.id, row.path, w, status));
  }

  async listAllWorktrees(): Promise<WorktreeSummary[]> {
    // Les repos masqués sont exclus du dashboard : inutile de calculer leurs worktrees (sous-process
    // git par repo). Le front charge paresseusement ceux d'un repo masqué via listRepoWorktrees
    // quand l'utilisateur active « afficher les masqués ».
    const rows = this.db.prepare('SELECT id FROM repos WHERE hidden = 0 ORDER BY created_at ASC').all() as Array<{ id: string }>;
    // Tolérance par repo : avec la découverte auto, un repo douteux (git en échec, chemin disparu,
    // permission) ne doit JAMAIS faire planter tout l'endpoint, il ne contribue alors aucun worktree.
    const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id).catch(() => [])));
    return lists.flat();
  }

  /** Sérialise les mutations d'un même repo (évite les courses sur .git/worktrees). */
  private withLock<T>(repoId: string, fn: () => Promise<T>): Promise<T> {
    const prev = this.locks.get(repoId) ?? Promise.resolve();
    const next = prev.then(fn, fn);
    this.locks.set(
      repoId,
      next.then(
        () => undefined,
        () => undefined,
      ),
    );
    return next;
  }

  private async findWorktree(row: RepoRow, path: string): Promise<ParsedWorktree | null> {
    const rp = resolve(path);
    return (await listWorktrees(row.path)).find((w) => resolve(w.path) === rp) ?? null;
  }

  private async emitWorktree(row: RepoRow, path: string): Promise<WorktreeSummary | null> {
    const w = await this.findWorktree(row, path);
    if (!w) return null;
    const summary = this.toSummary(row.id, row.path, w, await worktreeStatus(w.path));
    this.emit('worktree_update', { repoId: row.id, worktree: summary });
    return summary;
  }

  async createWorktree(
    repoId: string,
    req: { branch: string; mode?: WorktreeBranchMode; baseRef?: string; path?: string; runHooks?: boolean; preTrust?: boolean; startSession?: 'claude' | 'bash' | null },
  ): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[]; session: SessionSummary | null; action: WorktreeBranchAction }> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    if (!isValidBranchName(req.branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${req.branch}`);
    const path = req.path ?? join(dirname(row.path), `${basename(row.path)}-wt-${req.branch.replace(/\//g, '-')}`);
    if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_PATH', 'Worktree path must be absolute and normalized');
    if (existsSync(path)) throw httpError(409, 'PATH_EXISTS', `Path already exists: ${path}`);

    return this.withLock(repoId, async () => {
      let action: WorktreeBranchAction;
      try {
        action = await addWorktree(row.path, { path, branch: req.branch, mode: req.mode ?? 'auto', ...(req.baseRef ? { baseRef: req.baseRef } : {}) });
      } catch (err) {
        throw httpError(400, 'WORKTREE_ADD_FAILED', (err as Error).message);
      }
      this.factsCache.delete(repoId);

      if (req.preTrust ?? row.pre_trust === 1) preTrustProject(path);

      const hookResults: HookRunResult[] = [];
      if (req.runHooks ?? true) {
        for (const hook of parseHooks(row.post_create_hooks)) {
          if (hook.enabled) hookResults.push(await runHook(path, hook));
        }
      }

      let session: SessionSummary | null = null;
      if (req.startSession) session = this.ptyManager.spawn({ cwd: path, command: req.startSession });

      const worktree = (await this.emitWorktree(row, path)) ?? this.toSummary(row.id, row.path, { path, head: null, branch: req.branch, detached: false, locked: false, prunable: false, bare: false }, { ahead: 0, behind: 0, dirtyCount: 0, upstream: null });
      return { worktree, hookResults, session, action };
    });
  }

  /** Branches locales/remote + branche par défaut d'un repo : alimente le sélecteur de base côté UI. */
  async listRepoBranches(repoId: string): Promise<{ local: string[]; remote: string[]; default: string | null }> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    return listBranches(row.path);
  }

  /**
   * Commit dans le worktree visé. `mode='all'` (défaut, rétrocompat) = `git add -A` + commit ;
   * `mode='staged'` = commit de l'index uniquement (staging sélectif préalable) ; `amend` réécrit
   * le dernier commit (refusé s'il est déjà poussé). Le checkout principal est un worktree valide ici.
   */
  async commitWorktree(
    repoId: string,
    path: string,
    opts: { message: string; mode?: 'all' | 'staged'; amend?: boolean },
  ): Promise<WorktreeSummary> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    const w = await this.findWorktree(row, path);
    if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
    const mode = opts.mode ?? 'all';
    return this.withLock(repoId, async () => {
      if (opts.amend && !(await isUnpushed(w.path))) {
        throw httpError(409, 'ALREADY_PUSHED', 'Last commit is already pushed: amend would rewrite shared history');
      }
      const st = await worktreeStatus(w.path);
      // Garde-fou « rien à committer » (sauf amend, qui peut ne changer que le message).
      if (!opts.amend) {
        if (mode === 'all' && st.dirtyCount === 0) {
          throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit: working tree is clean');
        }
        if (mode === 'staged' && (st.stagedCount ?? 0) === 0) {
          throw httpError(409, 'NOTHING_STAGED', 'Nothing staged: stage files first');
        }
      }
      try {
        if (mode === 'all' && !opts.amend) await commitAll(w.path, opts.message);
        else if (opts.amend) await amendCommit(w.path, opts.message);
        else await commitStaged(w.path, opts.message);
      } catch (err) {
        throw httpError(400, 'COMMIT_FAILED', (err as Error).message);
      }
      this.factsCache.delete(repoId);
      return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
    });
  }

  // ---- P7 : changes / diff / staging / fetch-pull / contenu fichier / watch ----

  /** Résout et valide un worktree enregistré ; lève 404 si le repo/worktree n'existe pas. */
  private async requireWorktree(repoId: string, path: string): Promise<{ row: RepoRow; w: ParsedWorktree }> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    const w = await this.findWorktree(row, path);
    if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
    return { row, w };
  }

  /** Liste des fichiers modifiés d'un worktree (lecture, hors lock). */
  async getWorktreeChanges(repoId: string, path: string): Promise<{ changes: FileChange[]; truncated: boolean }> {
    const { w } = await this.requireWorktree(repoId, path);
    return listChanges(w.path);
  }

  /** Diff unifié d'un fichier (détecte untracked → `git diff --no-index`). Lecture, hors lock. */
  async getFileDiff(repoId: string, path: string, file: string, staged: boolean): Promise<FileDiffResponse> {
    const { w } = await this.requireWorktree(repoId, path);
    if (!isSafeRelativePath(file)) throw httpError(400, 'BAD_PATH', 'Invalid file path');
    const { changes } = await listChanges(w.path);
    const rec = changes.find((c) => c.path === file);
    const untracked = !staged && (rec?.untracked ?? false);
    const d = await fileDiff(w.path, file, { staged, untracked });
    return { path: w.path, file, staged, binary: d.binary, tooLarge: d.tooLarge, diff: d.diff };
  }

  private validateFiles(files: string[]): void {
    if (!Array.isArray(files) || files.length === 0) throw httpError(400, 'BAD_REQUEST', 'files (non-empty array) is required');
    for (const f of files) {
      if (typeof f !== 'string' || !isSafeRelativePath(f)) throw httpError(400, 'BAD_PATH', `Invalid file path: ${String(f)}`);
    }
  }

  /** Indexe des fichiers, puis rediffuse le statut. */
  async stage(repoId: string, path: string, files: string[]): Promise<WorktreeSummary> {
    const { row, w } = await this.requireWorktree(repoId, path);
    this.validateFiles(files);
    return this.withLock(repoId, async () => {
      await stageFiles(w.path, files).catch((err) => { throw httpError(400, 'STAGE_FAILED', (err as Error).message); });
      this.factsCache.delete(repoId);
      this.emit('worktree_changes', { repoId, path: w.path });
      return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
    });
  }

  /** Désindexe des fichiers, puis rediffuse le statut. */
  async unstage(repoId: string, path: string, files: string[]): Promise<WorktreeSummary> {
    const { row, w } = await this.requireWorktree(repoId, path);
    this.validateFiles(files);
    return this.withLock(repoId, async () => {
      await unstageFiles(w.path, files).catch((err) => { throw httpError(400, 'UNSTAGE_FAILED', (err as Error).message); });
      this.factsCache.delete(repoId);
      this.emit('worktree_changes', { repoId, path: w.path });
      return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
    });
  }

  /**
   * Annule les changements locaux. Les fichiers SUIVIS sont restaurés (`git restore`) ; les fichiers
   * NON SUIVIS ne sont supprimés (`git clean`) QUE si `includeUntracked` (destructif, opt-in).
   */
  async discard(repoId: string, path: string, files: string[], includeUntracked: boolean): Promise<WorktreeSummary> {
    const { row, w } = await this.requireWorktree(repoId, path);
    this.validateFiles(files);
    return this.withLock(repoId, async () => {
      const { changes } = await listChanges(w.path);
      const set = new Set(files);
      const untracked = changes.filter((c) => set.has(c.path) && c.untracked).map((c) => c.path);
      const tracked = files.filter((f) => !untracked.includes(f));
      try {
        await restoreFiles(w.path, tracked);
        if (includeUntracked && untracked.length > 0) await cleanFiles(w.path, untracked);
      } catch (err) {
        throw httpError(400, 'DISCARD_FAILED', (err as Error).message);
      }
      this.factsCache.delete(repoId);
      this.emit('worktree_changes', { repoId, path: w.path });
      return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
    });
  }

  /** `git fetch --all --prune` puis rediffuse le statut (ahead/behind à jour). */
  async fetch(repoId: string, path: string): Promise<WorktreeSummary> {
    const { row, w } = await this.requireWorktree(repoId, path);
    return this.withLock(repoId, async () => {
      await fetchRemote(w.path).catch((err) => { throw httpError(400, 'FETCH_FAILED', (err as Error).message); });
      this.factsCache.delete(repoId);
      return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
    });
  }

  /** `git pull` (ff-only par défaut). */
  async pull(repoId: string, path: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<WorktreeSummary> {
    const { row, w } = await this.requireWorktree(repoId, path);
    return this.withLock(repoId, async () => {
      await pull(w.path, mode).catch((err) => { throw httpError(409, 'PULL_FAILED', (err as Error).message); });
      this.factsCache.delete(repoId);
      this.emit('worktree_changes', { repoId, path: w.path });
      return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
    });
  }

  /**
   * Valide qu'un chemin relatif désigne bien un fichier DANS un worktree enregistré et renvoie son
   * chemin absolu résolu. Défense en profondeur (anti `..`, anti symlink sortant), même si un
   * terminal web est déjà du RCE par conception. Utilisé par l'API fichiers (lecture/écriture Monaco).
   */
  async assertPathInWorktree(repoId: string, worktreeAbsPath: string, relPath: string): Promise<string> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    if (!isSafeAbsolutePath(worktreeAbsPath)) throw httpError(400, 'BAD_PATH', 'Worktree path must be absolute and normalized');
    const w = await this.findWorktree(row, worktreeAbsPath);
    if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
    if (!isSafeRelativePath(relPath)) throw httpError(400, 'BAD_PATH', 'Invalid file path');
    const base = resolve(w.path);
    const abs = resolve(join(base, relPath));
    if (abs !== base && !abs.startsWith(base + sep)) throw httpError(403, 'PATH_OUTSIDE_WORKTREE', 'Path escapes the worktree');
    // Anti symlink-escape : pour un fichier existant, le realpath doit rester sous le worktree.
    if (existsSync(abs)) {
      let real: string;
      try {
        real = realpathSync(abs);
      } catch {
        throw httpError(400, 'BAD_PATH', 'Cannot resolve path');
      }
      const realBase = realpathSync(base);
      if (real !== realBase && !real.startsWith(realBase + sep)) throw httpError(403, 'PATH_OUTSIDE_WORKTREE', 'Path escapes the worktree (symlink)');
    }
    return abs;
  }

  /** Arme le watcher FS sur un worktree (validé) pour le push temps réel du détail. */
  async watch(repoId: string, path: string): Promise<void> {
    if (!this.fsWatcher) return;
    await this.requireWorktree(repoId, path);
    this.fsWatcher.watch(repoId, resolve(path));
  }

  unwatch(repoId: string, path: string): void {
    this.fsWatcher?.unwatch(repoId, resolve(path));
  }

  /** Pousse la branche du worktree visé (upstream auto si absent). */
  async pushWorktree(repoId: string, path: string): Promise<WorktreeSummary> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    const w = await this.findWorktree(row, path);
    if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
    return this.withLock(repoId, async () => {
      try {
        await push(w.path);
      } catch (err) {
        throw httpError(400, 'PUSH_FAILED', (err as Error).message);
      }
      this.factsCache.delete(repoId);
      return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
    });
  }

  /**
   * « Passer en principal » : la branche du worktree devient le checkout principal du dépôt. Une branche
   * ne pouvant être extraite qu'à un seul endroit, on retire d'abord le worktree (libère la branche) puis
   * on bascule le checkout principal dessus. Sans merge ni conflit possible. L'ancienne branche principale
   * est conservée (jamais supprimée). Garde-fous d'arbre sale outrepassables par `force`.
   */
  async promoteWorktree(repoId: string, path: string, force = false): Promise<WorktreeSummary | null> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    const w = await this.findWorktree(row, path);
    if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
    if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'This is already the main checkout');
    if (!w.branch || w.detached) throw httpError(400, 'DETACHED_WORKTREE', 'Worktree has no branch to promote (detached HEAD)');
    const branch = w.branch;
    return this.withLock(repoId, async () => {
      if (!force) {
        if ((await worktreeStatus(w.path)).dirtyCount > 0) {
          throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: commit or pass force');
        }
        if ((await worktreeStatus(row.path)).dirtyCount > 0) {
          throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes: commit/stash or pass force');
        }
      }
      try {
        await removeWorktree(row.path, w.path, force);
      } catch (err) {
        if (!force && isDirtyWorktreeError(err)) {
          throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: pass force to promote anyway');
        }
        throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
      }
      try {
        await switchBranch(row.path, { branch, create: false });
      } catch (err) {
        throw httpError(500, 'SWITCH_FAILED', (err as Error).message);
      }
      this.factsCache.delete(repoId);
      this.emit('worktree_removed', { repoId, path: w.path });
      return this.emitWorktree(row, row.path); // le checkout principal est désormais sur `branch`
    });
  }

  /**
   * Lance UNE session dans le checkout principal du repo (`repo.path`) : pour « bosser sur la branche
   * principale » sans créer de worktree. Si `branch` est fourni, crée/bascule d'abord cette branche
   * dans ce checkout (`git switch[-c]`), refusé si l'arbre est sale (on n'écrase pas un HEAD modifié).
   * Volontairement SANS hooks ni pré-trust (contraste avec createWorktree) : le checkout principal
   * est le dépôt réel de l'utilisateur, déjà configuré/approuvé. Sérialisé par repo (withLock).
   */
  async startMainSession(
    repoId: string,
    req: { command?: 'claude' | 'bash'; branch?: string; newBranch?: boolean },
  ): Promise<{ session: SessionSummary; worktree: WorktreeSummary | null }> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    const branch = req.branch?.trim() || undefined;
    if (branch !== undefined && !isValidBranchName(branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${branch}`);

    return this.withLock(repoId, async () => {
      if (branch !== undefined) {
        // garde-fou : ne pas basculer le HEAD du checkout principal s'il a des changements non sauvegardés.
        if ((await worktreeStatus(row.path)).dirtyCount > 0) {
          throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes: commit or stash before switching branch');
        }
        try {
          await switchBranch(row.path, { branch, create: req.newBranch ?? true });
        } catch (err) {
          throw httpError(400, 'SWITCH_FAILED', (err as Error).message);
        }
        this.factsCache.delete(repoId);
      }
      const session = this.ptyManager.spawn({ cwd: row.path, command: req.command ?? 'claude' });
      const worktree = await this.emitWorktree(row, row.path);
      return { session, worktree };
    });
  }

  async adoptWorktree(repoId: string, req: { path: string; runHooks?: boolean; preTrust?: boolean }): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[] }> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    const w = await this.findWorktree(row, req.path);
    if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');

    if (req.preTrust ?? row.pre_trust === 1) preTrustProject(w.path);
    const hookResults: HookRunResult[] = [];
    if (req.runHooks) {
      for (const hook of parseHooks(row.post_create_hooks)) {
        if (hook.enabled) hookResults.push(await runHook(w.path, hook));
      }
    }
    const worktree = (await this.emitWorktree(row, w.path)) as WorktreeSummary;
    return { worktree, hookResults };
  }

  async deleteWorktree(repoId: string, path: string, force: boolean): Promise<void> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    const w = await this.findWorktree(row, path);
    if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
    if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree');
    // garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite.
    if (!force && this.sessionsForCwd(w.path, { includeHidden: true }).some((s) => s.live)) {
      throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree: pass force to delete anyway');
    }
    return this.withLock(repoId, async () => {
      try {
        await removeWorktree(row.path, w.path, force);
      } catch (err) {
        if (!force && isDirtyWorktreeError(err)) {
          throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes: pass force to delete anyway');
        }
        throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
      }
      this.factsCache.delete(repoId);
      this.emit('worktree_removed', { repoId, path: w.path });
    });
  }

  async prune(repoId: string): Promise<void> {
    const row = this.getRepoRow(repoId);
    if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
    return this.withLock(repoId, async () => {
      const before = await listWorktrees(row.path);
      await pruneWorktrees(row.path);
      this.factsCache.delete(repoId);
      const after = new Set((await listWorktrees(row.path)).map((w) => resolve(w.path)));
      for (const w of before) {
        if (!after.has(resolve(w.path))) this.emit('worktree_removed', { repoId, path: w.path });
      }
    });
  }
}
