/**
 * v2.6 Phase 3 (3.4) — orphan worktree reaper.
 *
 * Reclaims on-disk session worktree dirs under WORKTREE_BASE that have NO live
 * (`status='active'`) row in the `worktrees` table — leaks from a crash between
 * `git worktree add` and the DB insert, a missed chat-close hook, or a manual rm
 * of the DB row. Extends the periodic-sweeper pattern (apps/server's truncation +
 * stale-streaming reaper).
 *
 * SAFETY (Paseo worktree-archive cascade + superset destroy-saga lift): before
 * removing ANY dir, run `checkWorktreeWorkAtRisk` — a dirty / unpushed / unmerged
 * worktree is SKIPPED (logged), never force-removed. The pure orphan-target
 * selection (which dirs are candidates) lives in
 * `backends/lifecycle-decisions.ts:selectOrphanWorktreeTargets` and is unit-tested;
 * this module does the DB read + fs stat + git preflight + removal side-effects.
 *
 * The mtime grace (default 1h) means a dir mid-`ensureSessionWorktree` (created on
 * disk, row not yet committed) is never swept — the grace window covers the gap.
 */
import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js';
import { WORKTREE_BASE } from './worktrees.js';
import { checkWorktreeWorkAtRisk } from './worktree-risk.js';
import { hostExec } from './host-exec.js';
import {
  selectOrphanWorktreeTargets,
  DEFAULT_ORPHAN_WORKTREE_GRACE_MS,
} from './backends/lifecycle-decisions.js';

export interface OrphanWorktreeReaperDeps {
  sql: Sql;
  log: FastifyBaseLogger;
  intervalMs: number;
  graceMs?: number;
}

export interface OrphanReaperResult {
  scanned: number;
  candidates: number;
  reaped: string[];
  skippedAtRisk: string[];
}

/** Single-pass reap: select orphan candidates, preflight at-risk, remove the safe. */
export async function reapOrphanWorktrees(
  sql: Sql,
  log: FastifyBaseLogger,
  graceMs: number = DEFAULT_ORPHAN_WORKTREE_GRACE_MS,
  now: number = Date.now(),
): Promise<OrphanReaperResult> {
  // Enumerate on-disk session worktree dirs (`sess-*`). Per-task worktrees
  // (arena/new_task/MCP) are cleaned up inline by the one-shot path, so we only
  // own the persistent session dirs the warm paths leave behind.
  let dirents: string[];
  try {
    dirents = await readdir(WORKTREE_BASE);
  } catch {
    return { scanned: 0, candidates: 0, reaped: [], skippedAtRisk: [] }; // base absent → nothing to do
  }
  const onDisk: { path: string; mtimeMs: number }[] = [];
  for (const name of dirents) {
    if (!name.startsWith('sess-')) continue; // only persistent session worktrees
    const path = join(WORKTREE_BASE, name);
    try {
      const s = await stat(path);
      if (!s.isDirectory()) continue;
      onDisk.push({ path, mtimeMs: s.mtimeMs });
    } catch {
      // vanished between readdir and stat — skip
    }
  }

  // Live worktree paths from the DB (active rows only — archived/removed rows are
  // not "live", so their leftover dirs are reapable orphans).
  const liveRows = await sql<{ path: string }[]>`
    SELECT path FROM worktrees WHERE status = 'active'
  `;
  const live = new Set(liveRows.map((r) => r.path));

  const candidates = selectOrphanWorktreeTargets(onDisk, live, now, graceMs);
  const reaped: string[] = [];
  const skippedAtRisk: string[] = [];

  for (const path of candidates) {
    // Preflight: never reap work at risk. A git error forces atRisk=true (fail
    // closed), so a half-broken worktree is kept, not silently destroyed.
    const risk = await checkWorktreeWorkAtRisk(path);
    if (risk.atRisk) {
      skippedAtRisk.push(path);
      log.warn({ path, dirty: risk.dirty, unmerged: risk.unmerged, error: risk.error }, 'orphan-reaper: skipping at-risk orphan worktree');
      continue;
    }
    const removed = await removeOrphanDir(path);
    if (removed) reaped.push(path);
  }

  if (reaped.length > 0 || skippedAtRisk.length > 0) {
    log.info({ scanned: onDisk.length, candidates: candidates.length, reaped, skippedAtRisk }, 'orphan-reaper: pass complete');
  }
  return { scanned: onDisk.length, candidates: candidates.length, reaped, skippedAtRisk };
}

/**
 * Remove a single orphan worktree dir. Resolve its main repo via the git
 * common-dir, run `worktree remove --force` from there + prune, then rm the dir as
 * a backstop. Best-effort: every step is independently fault-tolerant so a partial
 * state (dir present, git untracked) still gets reclaimed.
 */
async function removeOrphanDir(path: string): Promise<boolean> {
  // Find the owning repo (the common git dir's parent). When the dir isn't a valid
  // worktree anymore, this fails and we fall back to a plain rm.
  const common = await hostExec(
    `git -C ${shellEscape(path)} rev-parse --path-format=absolute --git-common-dir`,
    { timeoutMs: 10_000 },
  ).catch(() => null);
  const commonDir = common && common.exitCode === 0 ? common.stdout.trim() : '';
  // The repo worktree root is the parent of the .git common dir (strip trailing /.git).
  const repoRoot = commonDir.replace(/\/\.git\/?$/, '').replace(/\/\.git$/, '');

  if (repoRoot && repoRoot !== commonDir) {
    await hostExec(
      `git -C ${shellEscape(repoRoot)} worktree remove ${shellEscape(path)} --force`,
      { timeoutMs: 15_000 },
    ).catch(() => {});
    await hostExec(
      `git -C ${shellEscape(repoRoot)} worktree prune`,
      { timeoutMs: 10_000 },
    ).catch(() => {});
  }
  // Backstop: ensure the dir is gone even if the git remove no-op'd.
  const rm = await hostExec(`rm -rf ${shellEscape(path)}`, { timeoutMs: 15_000 }).catch(() => null);
  return rm != null && rm.exitCode === 0;
}

/** Minimal single-quote shell escape (mirrors worktrees.ts). */
function shellEscape(s: string): string {
  return "'" + s.replace(/'/g, "'\\''") + "'";
}

/** Periodic orphan-worktree reaper, started/stopped by the bootstrap. Unref'd. */
export function createOrphanWorktreeReaper(deps: OrphanWorktreeReaperDeps): { start(): void; stop(): void } {
  const { sql, log, intervalMs } = deps;
  const graceMs = deps.graceMs ?? DEFAULT_ORPHAN_WORKTREE_GRACE_MS;
  let timer: ReturnType<typeof setInterval> | null = null;
  let running = false;

  return {
    start() {
      if (timer) return;
      timer = setInterval(() => {
        if (running) return; // a slow pass must not overlap the next tick
        running = true;
        void reapOrphanWorktrees(sql, log, graceMs)
          .catch((err) => log.warn({ err: err instanceof Error ? err.message : String(err) }, 'orphan-reaper: pass error'))
          .finally(() => {
            running = false;
          });
      }, intervalMs);
      timer.unref?.();
      log.info({ intervalMs, graceMs }, 'orphan-reaper: started');
    },
    stop() {
      if (timer) {
        clearInterval(timer);
        timer = null;
      }
    },
  };
}
