// Couche git sûre : tout passe par execFile (JAMAIS de shell), arguments en tableau, `--` avant
// les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant.
import { execFile, spawn } from 'node:child_process';
import { resolve, sep } from 'node:path';
import type { WorktreeGitStatus, WorktreeBranchAction, WorktreeBranchMode, FileChange } from '@arboretum/shared';

const GIT_TIMEOUT_MS = 10_000;
// `push` peut dialoguer avec un remote (réseau) : on lui laisse une marge bien plus large.
const GIT_PUSH_TIMEOUT_MS = 120_000;
const GIT_MAX_BUFFER = 8 * 1024 * 1024;

interface GitError extends Error {
  stderr?: string;
  code?: number | string;
}

function git(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise<string> {
  return new Promise((resolveP, reject) => {
    execFile(
      'git',
      args,
      {
        cwd,
        timeout: timeoutMs,
        maxBuffer: GIT_MAX_BUFFER,
        // GIT_OPTIONAL_LOCKS=0 : pas de prise de verrou par `status` (perf + concurrence avec une
        // session claude active) ; LC_ALL=C : sortie stable pour le parsing.
        env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' },
      },
      (err, stdout, stderr) => {
        if (err) {
          (err as GitError).stderr = String(stderr);
          reject(err);
        } else {
          resolveP(stdout.toString());
        }
      },
    );
  });
}

/**
 * Variante tolérante : résout `{ stdout, code }` au lieu de rejeter sur code de sortie non nul.
 * Utile pour `git diff --no-index` (code 1 = « les fichiers diffèrent », pas une erreur).
 */
function gitRaw(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise<{ stdout: string; code: number }> {
  return new Promise((resolveP) => {
    execFile(
      'git',
      args,
      { cwd, timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' } },
      (err, stdout) => {
        const code = err ? ((err as GitError).code as number) ?? 1 : 0;
        resolveP({ stdout: stdout.toString(), code: typeof code === 'number' ? code : 1 });
      },
    );
  });
}

export interface ParsedWorktree {
  path: string;
  head: string | null;
  branch: string | null;
  detached: boolean;
  locked: boolean;
  prunable: boolean;
  bare: boolean;
}

/** Parse la sortie `git worktree list --porcelain` (testable isolément, sans repo réel). */
export function parseWorktreePorcelain(stdout: string): ParsedWorktree[] {
  const out: ParsedWorktree[] = [];
  let cur: Partial<ParsedWorktree> | null = null;
  const flush = (): void => {
    if (cur?.path) {
      out.push({
        path: cur.path,
        head: cur.head ?? null,
        branch: cur.branch ?? null,
        detached: cur.detached ?? false,
        locked: cur.locked ?? false,
        prunable: cur.prunable ?? false,
        bare: cur.bare ?? false,
      });
    }
    cur = null;
  };
  for (const line of stdout.split('\n')) {
    if (line === '') {
      flush();
      continue;
    }
    const sp = line.indexOf(' ');
    const key = sp === -1 ? line : line.slice(0, sp);
    const val = sp === -1 ? '' : line.slice(sp + 1);
    switch (key) {
      case 'worktree':
        flush();
        cur = { path: val };
        break;
      case 'HEAD':
        if (cur) cur.head = val;
        break;
      case 'branch':
        if (cur) cur.branch = val.replace(/^refs\/heads\//, '');
        break;
      case 'detached':
        if (cur) cur.detached = true;
        break;
      case 'bare':
        if (cur) cur.bare = true;
        break;
      case 'locked':
        if (cur) cur.locked = true;
        break;
      case 'prunable':
        if (cur) cur.prunable = true;
        break;
    }
  }
  flush();
  return out;
}

/** Refuse les noms de branche dangereux (défense en profondeur ; git valide déjà côté lui). */
export function isValidBranchName(name: string): boolean {
  return (
    /^[A-Za-z0-9._/-]+$/.test(name) &&
    !name.startsWith('-') &&
    !name.startsWith('/') &&
    !name.endsWith('/') &&
    !name.endsWith('.lock') &&
    !name.includes('..') &&
    !name.includes('//')
  );
}

/** Chemin absolu sans segment `..` après résolution (évite l'échappement d'arborescence). */
export function isSafeAbsolutePath(p: string): boolean {
  return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..');
}

/**
 * Pathspec relatif sûr passé à git (`git add/restore/diff -- <p>`) : non vide, non absolu, sans
 * segment `..`, ne commençant pas par `-` (anti-flag). Le `--` avant le pathspec reste obligatoire.
 */
export function isSafeRelativePath(p: string): boolean {
  if (p.length === 0 || p.startsWith('/') || p.startsWith('-')) return false;
  const parts = p.split(/[\\/]/);
  return !parts.includes('..') && !parts.includes('.git');
}

/** Initialise un dépôt git dans `dir` (déjà créé et validé par l'appelant). `git init` est idempotent. */
export async function gitInit(dir: string): Promise<void> {
  await git(dir, ['init']);
}

/** true si `path` est la racine d'un dépôt git (main worktree) accessible. */
export async function isRepo(path: string): Promise<boolean> {
  try {
    const top = (await git(path, ['rev-parse', '--show-toplevel'])).trim();
    return resolve(top) === resolve(path);
  } catch {
    return false;
  }
}

/** Branche par défaut (origin/HEAD) en nom court, ou null si indéterminée. */
export async function defaultBranch(repoPath: string): Promise<string | null> {
  try {
    const ref = (await git(repoPath, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])).trim();
    return ref.replace(/^refs\/remotes\/origin\//, '') || null;
  } catch {
    return null;
  }
}

/** Branche courante (nom court) du checkout en `repoPath`, ou null si HEAD détaché. */
export async function currentBranch(repoPath: string): Promise<string | null> {
  try {
    const b = (await git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
    return b === 'HEAD' ? null : b;
  } catch {
    return null;
  }
}

/** Existence d'une branche, en local (`refs/heads`) et/ou suivie du remote (`refs/remotes/origin`). */
export async function branchExists(repoPath: string, branch: string): Promise<{ local: boolean; remote: boolean }> {
  const verify = async (ref: string): Promise<boolean> => {
    try {
      await git(repoPath, ['rev-parse', '--verify', '--quiet', ref]);
      return true;
    } catch {
      return false;
    }
  };
  const [local, remote] = await Promise.all([verify(`refs/heads/${branch}`), verify(`refs/remotes/origin/${branch}`)]);
  return { local, remote };
}

/** Branches locales + suivies de `origin` (noms courts) + branche par défaut : pour un sélecteur de base. */
export async function listBranches(repoPath: string): Promise<{ local: string[]; remote: string[]; default: string | null }> {
  const local: string[] = [];
  const remote: string[] = [];
  try {
    const out = await git(repoPath, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes/origin']);
    for (const line of out.split('\n')) {
      const name = line.trim();
      if (!name) continue;
      if (name.startsWith('origin/')) {
        const short = name.slice('origin/'.length);
        if (short && short !== 'HEAD') remote.push(short);
      } else {
        local.push(name);
      }
    }
  } catch {
    /* dépôt sans refs encore (premier commit absent) → listes vides */
  }
  return { local, remote, default: await defaultBranch(repoPath) };
}

export async function listWorktrees(repoPath: string): Promise<ParsedWorktree[]> {
  return parseWorktreePorcelain(await git(repoPath, ['worktree', 'list', '--porcelain']));
}

/** État git d'un worktree : ahead/behind vs upstream + nombre de fichiers modifiés. */
export async function worktreeStatus(worktreePath: string): Promise<WorktreeGitStatus> {
  let upstream: string | null = null;
  let ahead = 0;
  let behind = 0;
  try {
    upstream = (await git(worktreePath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])).trim() || null;
  } catch {
    upstream = null;
  }
  if (upstream) {
    try {
      // left = commits de l'upstream absents de HEAD (behind) ; right = HEAD non poussés (ahead).
      const out = (await git(worktreePath, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).trim();
      const [left, right] = out.split(/\s+/);
      behind = Number(left) || 0;
      ahead = Number(right) || 0;
    } catch {
      /* refs inaccessibles : on laisse 0/0 */
    }
  }
  let dirtyCount = 0;
  let stagedCount = 0;
  let unstagedCount = 0;
  let conflictCount = 0;
  try {
    // Un SEUL passage porcelain v2 -z : dirtyCount + compteurs fins (staged/unstaged/conflit).
    const out = await git(worktreePath, ['status', '--porcelain=v2', '-z', '--untracked-files=all']);
    const entries = parsePorcelainV2(out);
    dirtyCount = entries.length;
    for (const e of entries) {
      if (e.conflicted) conflictCount++;
      if (e.staged) stagedCount++;
      if (e.unstaged) unstagedCount++;
    }
  } catch {
    /* ignore */
  }
  let lastCommitHash: string | null = null;
  let lastCommitSubject: string | null = null;
  try {
    const lc = await lastCommit(worktreePath);
    if (lc) {
      lastCommitHash = lc.hash;
      lastCommitSubject = lc.subject;
    }
  } catch {
    /* repo sans commit : on laisse null */
  }
  return { ahead, behind, dirtyCount, upstream, stagedCount, unstagedCount, conflictCount, lastCommitHash, lastCommitSubject };
}

// ---- P7 : statut détaillé / diff / staging / commit sélectif / fetch-pull (IDE worktree) ----

/** Entrée brute de `git status --porcelain=v2` (avant enrichissement numstat). */
export interface PorcelainV2Entry {
  path: string;
  indexStatus: string;
  worktreeStatus: string;
  staged: boolean;
  unstaged: boolean;
  untracked: boolean;
  conflicted: boolean;
  renamedFrom?: string;
}

/**
 * Parse `git status --porcelain=v2 -z --untracked-files=all` (testable sans repo réel). Format -z :
 * champs séparés par NUL ; une entrée de renommage (type `2`) consomme un champ supplémentaire
 * (l'ancien chemin). Les chemins ne sont jamais entre guillemets en mode -z (pas d'échappement).
 */
export function parsePorcelainV2(stdout: string): PorcelainV2Entry[] {
  const fields = stdout.split('\0');
  if (fields.length && fields[fields.length - 1] === '') fields.pop();
  const out: PorcelainV2Entry[] = [];
  for (let i = 0; i < fields.length; i++) {
    const f = fields[i];
    if (!f) continue;
    const kind = f[0];
    if (kind === '1') {
      const t = f.split(' ');
      const xy = t[1] ?? '..';
      out.push(entryFromXy(t.slice(8).join(' '), xy, { conflicted: false }));
    } else if (kind === '2') {
      const t = f.split(' ');
      const xy = t[1] ?? '..';
      const path = t.slice(9).join(' ');
      const renamedFrom = fields[++i]; // l'ancien chemin suit dans le champ NUL suivant
      out.push(entryFromXy(path, xy, { conflicted: false, ...(renamedFrom ? { renamedFrom } : {}) }));
    } else if (kind === 'u') {
      const t = f.split(' ');
      const xy = t[1] ?? '..';
      out.push(entryFromXy(t.slice(10).join(' '), xy, { conflicted: true }));
    } else if (kind === '?') {
      out.push({ path: f.slice(2), indexStatus: '?', worktreeStatus: '?', staged: false, unstaged: true, untracked: true, conflicted: false });
    }
    // '!' (ignored) : exclu de la liste des changements.
  }
  return out;
}

function entryFromXy(path: string, xy: string, opts: { conflicted: boolean; renamedFrom?: string }): PorcelainV2Entry {
  const indexStatus = xy[0] ?? '.';
  const worktreeStatus = xy[1] ?? '.';
  return {
    path,
    indexStatus,
    worktreeStatus,
    staged: !opts.conflicted && indexStatus !== '.',
    unstaged: opts.conflicted || worktreeStatus !== '.',
    untracked: false,
    conflicted: opts.conflicted,
    ...(opts.renamedFrom ? { renamedFrom: opts.renamedFrom } : {}),
  };
}

/**
 * Parse `git diff --numstat -z` → map chemin → {insertions, deletions, binary}. Les fichiers
 * binaires sont marqués `'-'` par git. Les renommages ont un champ chemin vide suivi de
 * deux champs NUL (ancien, nouveau) ; on indexe par le nouveau chemin.
 */
export function parseNumstatZ(stdout: string): Map<string, { insertions: number | null; deletions: number | null; binary: boolean }> {
  const fields = stdout.split('\0');
  if (fields.length && fields[fields.length - 1] === '') fields.pop();
  const map = new Map<string, { insertions: number | null; deletions: number | null; binary: boolean }>();
  for (let i = 0; i < fields.length; i++) {
    const f = fields[i];
    if (!f) continue;
    const parts = f.split('\t');
    if (parts.length < 3) continue;
    const addRaw = parts[0] ?? '';
    const delRaw = parts[1] ?? '';
    let path = parts[2] ?? '';
    if (path === '') {
      // renommage : ancien chemin = champ suivant, nouveau chemin = champ d'après.
      i++; // saute l'ancien chemin
      path = fields[++i] ?? '';
      if (path === '') continue;
    }
    const binary = addRaw === '-' || delRaw === '-';
    map.set(path, {
      insertions: binary ? null : Number(addRaw) || 0,
      deletions: binary ? null : Number(delRaw) || 0,
      binary,
    });
  }
  return map;
}

const MAX_CHANGES = 5000;

/** Liste les fichiers modifiés d'un worktree (statut porcelain v2 enrichi des stats numstat). */
export async function listChanges(worktreePath: string): Promise<{ changes: FileChange[]; truncated: boolean }> {
  const [statusOut, unstagedOut, stagedOut] = await Promise.all([
    git(worktreePath, ['status', '--porcelain=v2', '-z', '--untracked-files=all']),
    git(worktreePath, ['diff', '--numstat', '-z']),
    git(worktreePath, ['diff', '--numstat', '-z', '--cached']),
  ]);
  const entries = parsePorcelainV2(statusOut);
  const unstaged = parseNumstatZ(unstagedOut);
  const staged = parseNumstatZ(stagedOut);
  const truncated = entries.length > MAX_CHANGES;
  const slice = truncated ? entries.slice(0, MAX_CHANGES) : entries;
  const changes: FileChange[] = slice.map((e) => {
    const ns = unstaged.get(e.path) ?? staged.get(e.path);
    const binary = ns?.binary ?? false;
    // insertions/deletions = somme staged+unstaged quand disponible ; null pour binaire/untracked.
    const u = unstaged.get(e.path);
    const s = staged.get(e.path);
    const sum = (a: number | null | undefined, b: number | null | undefined): number | null => {
      if (a == null && b == null) return null;
      return (a ?? 0) + (b ?? 0);
    };
    return {
      path: e.path,
      indexStatus: e.indexStatus,
      worktreeStatus: e.worktreeStatus,
      staged: e.staged,
      unstaged: e.unstaged,
      untracked: e.untracked,
      conflicted: e.conflicted,
      insertions: binary ? null : sum(u?.insertions, s?.insertions),
      deletions: binary ? null : sum(u?.deletions, s?.deletions),
      binary,
      ...(e.renamedFrom ? { renamedFrom: e.renamedFrom } : {}),
    };
  });
  return { changes, truncated };
}

const MAX_DIFF_BYTES = 512 * 1024;

/** Diff unifié d'un fichier. `staged` → diff de l'index ; `untracked` → diff vs /dev/null. */
export async function fileDiff(
  worktreePath: string,
  file: string,
  opts: { staged?: boolean; untracked?: boolean } = {},
): Promise<{ diff: string; binary: boolean; tooLarge: boolean }> {
  let raw: string;
  if (opts.untracked) {
    // --no-index sort en code 1 quand les fichiers diffèrent : on tolère via gitRaw.
    const r = await gitRaw(worktreePath, ['diff', '--no-index', '--no-color', '--', '/dev/null', file]);
    raw = r.stdout;
  } else {
    raw = await git(worktreePath, ['diff', '--no-color', ...(opts.staged ? ['--cached'] : []), '--', file]);
  }
  const binary = /^Binary files .* differ$/m.test(raw) || raw.includes('GIT binary patch');
  if (binary) return { diff: '', binary: true, tooLarge: false };
  if (raw.length > MAX_DIFF_BYTES) return { diff: raw.slice(0, MAX_DIFF_BYTES), binary: false, tooLarge: true };
  return { diff: raw, binary: false, tooLarge: false };
}

/** Indexe des fichiers (`git add -- <files>`). Chaque chemin validé par l'appelant (isSafeRelativePath). */
export async function stageFiles(worktreePath: string, files: string[]): Promise<void> {
  if (files.length === 0) return;
  await git(worktreePath, ['add', '--', ...files]);
}

/** Désindexe des fichiers (`git restore --staged -- <files>`). */
export async function unstageFiles(worktreePath: string, files: string[]): Promise<void> {
  if (files.length === 0) return;
  await git(worktreePath, ['restore', '--staged', '--', ...files]);
}

/** Annule les modifications de l'arbre de travail de fichiers SUIVIS (`git restore -- <files>`). */
export async function restoreFiles(worktreePath: string, files: string[]): Promise<void> {
  if (files.length === 0) return;
  await git(worktreePath, ['restore', '--', ...files]);
}

/** Supprime des fichiers NON SUIVIS (`git clean -f -- <files>`). Destructif : opt-in côté appelant. */
export async function cleanFiles(worktreePath: string, files: string[]): Promise<void> {
  if (files.length === 0) return;
  await git(worktreePath, ['clean', '-f', '--', ...files]);
}

/** Commit de l'index uniquement (contraste avec `commitAll` = `git add -A` + commit). */
export async function commitStaged(worktreePath: string, message: string): Promise<void> {
  await git(worktreePath, ['commit', '-m', message]);
}

/** Réécrit le dernier commit. L'appelant garantit qu'il n'est pas déjà poussé. */
export async function amendCommit(worktreePath: string, message?: string): Promise<void> {
  await git(worktreePath, message ? ['commit', '--amend', '-m', message] : ['commit', '--amend', '--no-edit']);
}

/** `git fetch --all --prune` (réseau → timeout élargi). */
export async function fetchRemote(worktreePath: string): Promise<void> {
  await git(worktreePath, ['fetch', '--all', '--prune'], GIT_PUSH_TIMEOUT_MS);
}

/** `git pull` : `ff-only` par défaut (jamais de merge surprise) ; `rebase` optionnel. */
export async function pull(worktreePath: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise<void> {
  const args = mode === 'rebase' ? ['-c', 'rebase.autoStash=false', 'pull', '--rebase'] : ['pull', '--ff-only'];
  await git(worktreePath, args, GIT_PUSH_TIMEOUT_MS);
}

/**
 * Répertoire git absolu d'un worktree (`git rev-parse --absolute-git-dir`). Pour le checkout
 * principal : `<path>/.git` ; pour un worktree LIÉ : `<repo>/.git/worktrees/<n>` (le `.git` du
 * worktree est un fichier pointeur). Sert au watcher FS pour surveiller le bon `HEAD`/`index`.
 */
export async function resolveGitDir(worktreePath: string): Promise<string | null> {
  const r = await gitRaw(worktreePath, ['rev-parse', '--absolute-git-dir']);
  if (r.code !== 0) return null;
  return r.stdout.trim() || null;
}

/** Dernier commit (HEAD) : hash court + sujet. null si le dépôt n'a aucun commit. */
export async function lastCommit(worktreePath: string): Promise<{ hash: string; subject: string } | null> {
  const r = await gitRaw(worktreePath, ['log', '-1', '--format=%h%x00%s']);
  if (r.code !== 0) return null;
  const idx = r.stdout.indexOf('\0');
  if (idx === -1) return null;
  return { hash: r.stdout.slice(0, idx), subject: r.stdout.slice(idx + 1).replace(/\n$/, '') };
}

/** true si le HEAD courant n'est pas encore poussé (amend autorisé). Sans upstream → true. */
export async function isUnpushed(worktreePath: string): Promise<boolean> {
  try {
    await git(worktreePath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
  } catch {
    return true; // pas d'upstream → rien n'est « partagé »
  }
  try {
    const out = (await git(worktreePath, ['rev-list', '--count', '@{u}..HEAD'])).trim();
    return (Number(out) || 0) > 0;
  } catch {
    return true;
  }
}

/** Point de départ d'une branche créée : `baseRef` explicite, sinon la branche par défaut du dépôt
 * (locale ou suivie de `origin`), sinon undefined (git part alors du HEAD courant). */
async function resolveStartPoint(repoPath: string, baseRef?: string): Promise<string | undefined> {
  if (baseRef) return baseRef;
  const def = await defaultBranch(repoPath);
  if (!def) return undefined;
  const { local, remote } = await branchExists(repoPath, def);
  if (local) return def;
  if (remote) return `origin/${def}`;
  return undefined;
}

/**
 * Crée un worktree en résolvant la branche selon `mode` (voir `WorktreeBranchMode`). Renvoie l'action
 * effective. En mode `auto`, on choisit checkout / suivi-remote / création selon l'existence réelle de
 * la branche : indispensable pour les groupes hétérogènes (branche présente dans certains dépôts seulement).
 */
export async function addWorktree(
  repoPath: string,
  opts: { path: string; branch: string; mode: WorktreeBranchMode; baseRef?: string },
): Promise<WorktreeBranchAction> {
  const { local, remote } = await branchExists(repoPath, opts.branch);
  let action: WorktreeBranchAction;
  if (opts.mode === 'create') action = 'created';
  else if (opts.mode === 'checkout') action = local ? 'reused' : 'tracked';
  else action = local ? 'reused' : remote ? 'tracked' : 'created'; // auto

  const args = ['worktree', 'add'];
  if (action === 'reused') {
    args.push('--', opts.path, opts.branch);
  } else if (action === 'tracked') {
    args.push('--track', '-b', opts.branch, '--', opts.path, `origin/${opts.branch}`);
  } else {
    const start = await resolveStartPoint(repoPath, opts.baseRef);
    args.push('-b', opts.branch, '--', opts.path);
    if (start) args.push(start);
  }
  await git(repoPath, args);
  return action;
}

/** `git add -A` puis commit. L'appelant garantit qu'il y a quelque chose à committer. */
export async function commitAll(repoPath: string, message: string): Promise<void> {
  await git(repoPath, ['add', '-A']);
  await git(repoPath, ['commit', '-m', message]);
}

/** Pousse la branche courante. Si aucun upstream n'est configuré : `git push -u origin <branche>`. */
export async function push(repoPath: string): Promise<void> {
  let hasUpstream = false;
  try {
    await git(repoPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
    hasUpstream = true;
  } catch {
    hasUpstream = false;
  }
  if (hasUpstream) {
    await git(repoPath, ['push'], GIT_PUSH_TIMEOUT_MS);
  } else {
    const branch = await currentBranch(repoPath);
    if (!branch) throw new Error('cannot push a detached HEAD');
    await git(repoPath, ['push', '-u', 'origin', branch], GIT_PUSH_TIMEOUT_MS);
  }
}

/**
 * Crée/bascule une branche dans le checkout (worktree) en `repoPath` : utilisé pour démarrer une
 * session sur la branche principale sans worktree dédié. `create` → `git switch -c <branch>` (échoue
 * si la branche existe) ; sinon `git switch <branch>` (branche existante). Pas de `--` : l'argument
 * est une réf (pas un pathspec) et le nom est déjà filtré en amont par `isValidBranchName` (anti-flag).
 */
export async function switchBranch(repoPath: string, opts: { branch: string; create: boolean }): Promise<void> {
  await git(repoPath, opts.create ? ['switch', '-c', opts.branch] : ['switch', opts.branch]);
}

export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise<void> {
  await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]);
}

export async function pruneWorktrees(repoPath: string): Promise<void> {
  await git(repoPath, ['worktree', 'prune']);
}

/** true si l'erreur git d'un `worktree remove` est due à des changements non sauvegardés. */
export function isDirtyWorktreeError(err: unknown): boolean {
  const msg = `${(err as GitError)?.stderr ?? ''} ${(err as Error)?.message ?? ''}`;
  return /contains modified or untracked files|is dirty|use --force/i.test(msg);
}

const GIT_CLONE_TIMEOUT_MS = 10 * 60_000; // 10 min : un clone réseau peut être long.

export interface CloneProgress {
  phase: string;
  /** pourcentage 0-100 si git le rapporte, sinon null. */
  percent: number | null;
}

/**
 * Clone un dépôt via `git clone --progress` (P12). `spawn` (et non execFile) pour streamer la
 * progression depuis stderr. `env` éphémère (cf. withGitAuth) porte l'auth : JAMAIS dans l'URL.
 * `--` sépare l'URL/dest des options. L'appelant valide `dest` (sous scanRoots, non existant).
 */
export function cloneRepo(opts: {
  url: string;
  dest: string;
  env?: NodeJS.ProcessEnv;
  branch?: string;
  onProgress?: (p: CloneProgress) => void;
  signal?: AbortSignal;
}): Promise<void> {
  return new Promise((resolveP, reject) => {
    const args = ['clone', '--progress'];
    if (opts.branch) args.push('--branch', opts.branch);
    args.push('--', opts.url, opts.dest);
    const child = spawn('git', args, {
      env: { ...(opts.env ?? process.env), GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
      stdio: ['ignore', 'ignore', 'pipe'],
      timeout: GIT_CLONE_TIMEOUT_MS,
      ...(opts.signal ? { signal: opts.signal } : {}),
    });
    let stderr = '';
    child.stderr.on('data', (d: Buffer) => {
      const s = d.toString();
      stderr += s;
      if (stderr.length > 64 * 1024) stderr = stderr.slice(-64 * 1024); // borne mémoire
      const m = /([A-Za-z][A-Za-z ]+):\s+(\d+)%/.exec(s);
      if (m && m[1] && m[2] && opts.onProgress) opts.onProgress({ phase: m[1].trim(), percent: Number(m[2]) });
    });
    child.on('error', (err) => reject(err));
    child.on('close', (code) => {
      if (code === 0) resolveP();
      else reject(new Error(stderr.trim().split('\n').pop() || `git clone exited with code ${code}`));
    });
  });
}
