// 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 } from 'node:path';
import { existsSync } from 'node:fs';
import type {
  DiscoverReposResponse,
  HookRunResult,
  PostCreateHook,
  RepoSummary,
  SessionSummary,
  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,
  defaultBranch,
  isDirtyWorktreeError,
  isRepo,
  isSafeAbsolutePath,
  isValidBranchName,
  listWorktrees,
  pruneWorktrees,
  removeWorktree,
  worktreeStatus,
  type ParsedWorktree,
} from './git.js';

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 }];
}

/** 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,
  ) {
    super();
  }

  // ---- 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);
    return summary;
  }

  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);
    return summary;
  }

  removeRepo(id: string): boolean {
    const res = this.db.prepare('DELETE FROM repos WHERE id = ?').run(id);
    if (res.changes === 0) return false;
    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
      }
    }
    return { scanned: paths.length, added, durationMs: Date.now() - t0, truncated };
  }

  // ---- worktrees ----

  /** Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. */
  private sessionsForCwd(path: string): SessionSummary[] {
    const rp = resolve(path);
    return mergeSessions(this.ptyManager.list(), this.discovery.list()).filter((s) => resolve(s.cwd) === rp);
  }

  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; newBranch: boolean; baseRef?: string; path?: string; runHooks?: boolean; preTrust?: boolean; startSession?: 'claude' | 'bash' | null },
  ): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[]; session: SessionSummary | null }> {
    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 () => {
      try {
        await addWorktree(row.path, { path, branch: req.branch, newBranch: req.newBranch, ...(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 };
    });
  }

  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).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 });
      }
    });
  }
}
