/**
 * Run manager: the single source of truth for orchestrator runs.
 *
 * Each run works one kanban card inside an (optional) isolated git worktree.
 * The manager persists run state to SQLite, keeps active `Runner` instances in
 * memory (so routes can steer/stop/stream them), and finalizes the run record
 * when a runner settles — capturing the head commit and a work summary.
 */

import { randomUUID } from 'node:crypto';
import { db, type AgentRunRow } from '../db.js';
import type { AgentRun, AgentRunEvent, Card, RunStatus } from '../types.js';
import { appendRunEvent } from './events.js';
import { newToken, REPO_ROOT } from './config.js';
import { buildPrompt, buildConflictResolutionPrompt } from './prompt.js';
import { Runner, type PersistedEvent } from './runner.js';
import { BevyProcess } from './bevy.js';
import { attemptMerge, abortMerge, createRecoveryBranch, createWorktree, currentBranch, dirtySummary, hasMergeInProgress, headSha, isMergedIntoHead, isWorktreePresent, mainDirtySummary, removeWorktree, resolveRef, type BatchMergeItem, type BatchMergeResult, type Worktree } from './worktrees.js';

export interface StartRunInput {
  cardId: string;
  /** Extra operator instructions appended to the agent's prompt. */
  prompt?: string;
  /** Default true: run inside an isolated git worktree. */
  useWorktree?: boolean;
  /** If true, delete the worktree + branch when the run settles. */
  cleanupOnFinish?: boolean;
  /**
   * If set, start a refinement run: a fresh worktree branched from this prior
   * run's branch (so its commits/work are present), seeded with `prompt` as the
   * operator's refinement feedback. Only valid when the prior run is settled
   * and its worktree is still present.
   */
  refineRunId?: string;
}

class RunManager {
  /** Active agent runners keyed by run id. */
  private active = new Map<string, { runner: Runner; worktree: Worktree | null; cleanup: boolean }>();
  /** Active Bevy test processes keyed by run id (independent of agent runs). */
  private bevy = new Map<string, BevyProcess>();
  /** Guards against two merge-all batches running at once. */
  private mergeBatchInProgress = false;

  /** Create and start a run for a card. Returns the persisted run. */
  start(card: Card, input: StartRunInput): AgentRun {
    const id = randomUUID();
    const token = newToken();
    const useWorktree = input.useWorktree ?? true;
    const now = new Date().toISOString();

    // A refinement appends to a prior run's chat: it resumes that run's pi
    // session in the SAME worktree (so the working tree matches the
    // conversation) and is itself NOT the worktree's owner. Resolve it first so
    // any error surfaces before we create anything.
    let refineOf: { worktree: Worktree; sessionFile: string } | null = null;
    if (input.refineRunId) {
      if (!useWorktree) throw new Error('a refinement run requires a worktree');
      const prior = this.get(input.refineRunId);
      if (!prior) throw new Error(`refinement source run not found: ${input.refineRunId}`);
      if (prior.cardId !== card.id) throw new Error('refinement source run belongs to a different card');
      if (
        !prior.ownsWorktree ||
        !prior.worktreePath ||
        !prior.branch ||
        !isWorktreePresent(prior.worktreePath)
      ) {
        throw new Error('refinement source run has no worktree (it was cleaned up); start a fresh run instead');
      }
      if (!prior.sessionFile) {
        throw new Error('refinement source run has no recorded session to resume');
      }
      refineOf = {
        worktree: { path: prior.worktreePath, branch: prior.branch },
        sessionFile: prior.sessionFile,
      };
    }

    // Provision the worktree. Refinement runs inherit the prior run's worktree
    // (and never own it); fresh runs create their own (or fall back to repo root).
    let worktree: Worktree | null = null;
    let branch: string | null = null;
    let worktreePath: string | null = null;
    if (refineOf) {
      worktree = refineOf.worktree;
      branch = refineOf.worktree.branch;
      worktreePath = refineOf.worktree.path;
    } else if (useWorktree) {
      worktree = createWorktree(id, card.id);
      branch = worktree.branch;
      worktreePath = worktree.path;
    }
    const ownsWorktree = !refineOf;

    // Fresh runs get the full agent contract prompt; refinement runs just send
    // the operator's feedback as a new user turn in the resumed session (the
    // prior system prompt + conversation are already in the session history).
    const prompt = refineOf
      ? input.prompt?.trim() || '(no specific changes requested — review the work so far and improve it).'
      : buildPrompt(card, { token, runId: id }, input.prompt);

    try {
      db.prepare(
        `INSERT INTO agent_runs
           (id, card_id, status, use_worktree, owns_worktree, branch, worktree_path, session_file, prompt, token, created_at, started_at)
         VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      ).run(
        id,
        card.id,
        useWorktree ? 1 : 0,
        ownsWorktree ? 1 : 0,
        branch,
        worktreePath,
        refineOf?.sessionFile ?? null,
        prompt,
        token,
        now,
        now,
      );

      const cwd = worktreePath ?? REPO_ROOT;
      const runner = new Runner({
        runId: id,
        cwd,
        prompt,
        resumeSession: refineOf?.sessionFile,
        onSettled: (status, summary) => this.settle(id, status, summary),
        // Only fresh runs need to discover + persist their session file; resumed
        // runs already point at the prior session via session_file above.
        onSessionResolved: refineOf
          ? undefined
          : (sessionFile) => {
              db.prepare('UPDATE agent_runs SET session_file = ? WHERE id = ?').run(sessionFile, id);
            },
      });
      // A refinement run never cleans up the worktree it inherited.
      this.active.set(id, {
        runner,
        worktree,
        cleanup: ownsWorktree && (input.cleanupOnFinish ?? false),
      });
      runner.start();
    } catch (err) {
      // Persisting or starting failed — reclaim only a worktree we created, and
      // never leave a half-created row stuck at 'running'.
      const message = err instanceof Error ? err.message : String(err);
      if (worktree && ownsWorktree) removeWorktree(id, worktree.branch);
      this.active.delete(id);
      this.forceSettle(id, 'failed', `failed to start agent: ${message}`);
      throw err;
    }

    return this.get(id)!;
  }

  /** Whether a run is currently active (steerable / stoppable). */
  isActive(id: string): boolean {
    return this.active.has(id);
  }

  /** Send a steer/follow-up message to an active run. */
  message(id: string, text: string, mode: 'steer' | 'followUp'): void {
    const entry = this.active.get(id);
    if (!entry) throw new RunNotFoundError(id);
    entry.runner.message(text, mode);
  }

  /** Abort an active run, or force-settle a wedged/orphaned one (recovery). */
  stop(id: string): void {
    const entry = this.active.get(id);
    if (entry) {
      entry.runner.stop();
      return;
    }
    // No live runner. If the DB still shows it running, force-settle so the
    // operator can recover a wedged/orphaned run from the UI instead of it
    // being stuck at 'running' with no process behind it.
    const run = this.rawGet(id);
    if (!run) throw new RunNotFoundError(id);
    if (run.status === 'running') {
      this.forceSettle(id, 'stopped', 'stopped by operator (no live agent process)');
    }
  }

  /** Subscribe to a run's live slim-event stream (best-effort; may be inactive). */
  subscribe(id: string, fn: (event: PersistedEvent) => void): () => void {
    const entry = this.active.get(id);
    if (entry) return entry.runner.subscribe(fn);
    return () => {};
  }

  /** Delete a run record (and its worktree if still present). */
  remove(id: string): void {
    const entry = this.active.get(id);
    if (entry) throw new Error('cannot delete an active run; stop it first');
    // Stop any live Bevy test before tearing down its worktree.
    this.bevy.get(id)?.stop();
    this.bevy.delete(id);
    const row = this.get(id);
    // Only the run that created a worktree may reclaim it; refinement runs
    // inherit theirs and must never delete the owning run's worktree.
    if (row?.ownsWorktree && row.worktreePath && row.branch) removeWorktree(id, row.branch);
    db.prepare('DELETE FROM agent_run_events WHERE run_id = ?').run(id);
    db.prepare('DELETE FROM agent_runs WHERE id = ?').run(id);
  }

  // --- Bevy playtesting ----------------------------------------------------

  /** Whether a Bevy test is currently running for a run. */
  bevyRunning(id: string): boolean {
    return this.bevy.get(id)?.running ?? false;
  }

  /** Spawn `cargo run` in a run's worktree to playtest its branch. */
  startBevy(id: string): void {
    const run = this.get(id);
    if (!run) throw new RunNotFoundError(id);
    if (!run.worktreePath || !isWorktreePresent(run.worktreePath)) {
      throw new Error('run has no worktree to run Bevy in');
    }
    let bp = this.bevy.get(id);
    if (bp?.running) throw new Error('Bevy is already running for this run');
    if (!bp) {
      bp = new BevyProcess(id);
      this.bevy.set(id, bp);
    }
    bp.start(run.worktreePath);
  }

  /** Stop a run's Bevy test (best-effort). */
  stopBevy(id: string): void {
    this.bevy.get(id)?.stop();
  }

  // --- reads ---------------------------------------------------------------

  get(id: string): AgentRun | undefined {
    return this.reconcile(this.rawGet(id));
  }

  /** Read a run WITHOUT orphan-reconciliation (used where we inspect status
   * before deciding to force-settle, e.g. `stop()`). */
  private rawGet(id: string): AgentRun | undefined {
    const row = db.prepare('SELECT * FROM agent_runs WHERE id = ?').get(id) as AgentRunRow | undefined;
    return row ? hydrateRun(row) : undefined;
  }

  /** Resolve a run by its agent token (used to authorize internal calls). */
  getByToken(token: string): AgentRun | undefined {
    const row = db
      .prepare('SELECT * FROM agent_runs WHERE token = ? ORDER BY created_at DESC LIMIT 1')
      .get(token) as AgentRunRow | undefined;
    return row ? hydrateRun(row) : undefined;
  }

  /** Recent runs, newest first. */
  list(limit = 50): AgentRun[] {
    const rows = db
      .prepare('SELECT * FROM agent_runs ORDER BY created_at DESC LIMIT ?')
      .all(limit) as AgentRunRow[];
    return rows.map((r) => this.reconcile(hydrateRun(r)) as AgentRun);
  }

  /** Runs for a given card (newest first). */
  listForCard(cardId: string): AgentRun[] {
    const rows = db
      .prepare('SELECT * FROM agent_runs WHERE card_id = ? ORDER BY created_at DESC')
      .all(cardId) as AgentRunRow[];
    return rows.map((r) => this.reconcile(hydrateRun(r)) as AgentRun);
  }

  /**
   * Merge all reviewable agent branches into the main branch sequentially.
   *
   * Candidates are settled runs that own their worktree and have commits not
   * yet in HEAD, oldest first. Each branch is attempted with a non-aborting
   * merge; on conflict a resolution agent is spawned in the main worktree
   * (where the merge is mid-conflict), awaited, and verified — if it resolves
   * and commits cleanly the batch continues, otherwise that merge is aborted
   * and the batch halts. A recovery ref snapshots pre-batch HEAD so the whole
   * batch is undoable. Only one batch may run at a time.
   */
  async mergeAll(): Promise<BatchMergeResult> {
    const target = currentBranch();
    if (this.mergeBatchInProgress) {
      return { target, items: [], haltedOn: null, aborted: true, reason: 'a merge-all batch is already in progress', recoveryRef: null };
    }
    const dirty = mainDirtySummary();
    if (dirty) {
      return { target, items: [], haltedOn: null, aborted: true, reason: `Cannot merge: ${dirty}`, recoveryRef: null };
    }

    const rows = db
      .prepare(
        `SELECT * FROM agent_runs
          WHERE status IN ('completed','failed','stopped')
            AND owns_worktree = 1
            AND branch IS NOT NULL
          ORDER BY created_at ASC`,
      )
      .all() as AgentRunRow[];

    const seenBranch = new Set<string>();
    const candidates: { branch: string; runId: string; cardId: string }[] = [];
    const stale: { branch: string; runId: string }[] = [];
    for (const row of rows) {
      const branch = row.branch!;
      if (seenBranch.has(branch)) continue; // dedup (e.g. multiple runs same branch)
      seenBranch.add(branch);
      // A branch that no longer resolves to a commit means the worktree+branch
      // were cleaned up out-of-band but the row lingered. Skip it (don't halt),
      // and null the stale branch so it stops reappearing as a candidate.
      if (!resolveRef(branch)) {
        stale.push({ branch, runId: row.id });
        continue;
      }
      if (isMergedIntoHead(branch)) continue; // nothing to merge
      candidates.push({ branch, runId: row.id, cardId: row.card_id });
    }
    if (stale.length > 0) {
      const setNull = stale.map((s) => '?').join(',');
      db.prepare(`UPDATE agent_runs SET branch = NULL WHERE id IN (${setNull})`).run(...stale.map((s) => s.runId));
    }

    // Snapshot HEAD so the operator can reset the entire batch if a resolution
    // misfires (`git reset --hard <recoveryRef>`).
    const recoveryRef = `merge-all-recovery-${Date.now()}`;
    createRecoveryBranch(recoveryRef);

    this.mergeBatchInProgress = true;
    const items: BatchMergeItem[] = stale.map(({ branch, runId }) => ({
      ok: false,
      alreadyMerged: false,
      conflict: false,
      skipped: true,
      target,
      branch,
      output: 'branch no longer resolves (worktree cleaned up) — skipped',
      runId,
      resolutionRunId: null,
    }));
    let haltedOn: string | null = null;
    let reason: string | null = null;

    try {
      for (const { branch, runId, cardId } of candidates) {
        const attempt = attemptMerge(branch);
        if (attempt.ok) {
          // Clean merge (or already-merged no-op) — nothing more to do.
          items.push({ ok: true, alreadyMerged: attempt.alreadyMerged, conflict: false, skipped: false, target, branch, output: attempt.output, runId, resolutionRunId: null });
          continue;
        }
        if (!attempt.conflict) {
          // Non-conflict failure (e.g. dirty mid-batch): halt, tree already clean.
          haltedOn = branch;
          reason = attempt.output;
          items.push({ ok: false, alreadyMerged: false, conflict: false, skipped: false, target, branch, output: attempt.output, runId, resolutionRunId: null });
          break;
        }

        // Conflict — spin up a resolution agent in the main worktree.
        const prompt = buildConflictResolutionPrompt(target, branch, attempt.conflictedPaths);
        const { runId: resRunId, settled } = this.startResolutionRun({ cardId, branch, prompt });
        const { status, summary } = await settled;

        // Success only if the agent completed AND finalized the merge (no
        // MERGE_HEAD, clean tree). Otherwise abort and halt so main stays clean.
        const clean = status === 'completed' && !hasMergeInProgress() && mainDirtySummary() === '';
        if (clean) {
          items.push({
            ok: true,
            alreadyMerged: false,
            conflict: true,
            skipped: false,
            target,
            branch,
            output: `Conflict resolved by agent${summary ? `: ${summary}` : '.'}`,
            runId,
            resolutionRunId: resRunId,
          });
          continue;
        }
        abortMerge();
        haltedOn = branch;
        reason = `Conflict resolution agent did not complete the merge (status: ${status}). Aborted; ${target} left clean.`;
        items.push({ ok: false, alreadyMerged: false, conflict: true, skipped: false, target, branch, output: reason, runId, resolutionRunId: resRunId });
        break;
      }
    } finally {
      this.mergeBatchInProgress = false;
    }

    return { target, items, haltedOn, aborted: false, reason, recoveryRef };
  }

  /** Replayable event history for a run, optionally after a sequence number. */
  events(id: string, sinceSeq = 0): AgentRunEvent[] {
    const rows = db
      .prepare('SELECT * FROM agent_run_events WHERE run_id = ? AND seq > ? ORDER BY seq ASC')
      .all(id, sinceSeq) as { id: number; run_id: string; seq: number; type: string; data: string; created_at: string }[];
    return rows.map((r) => ({
      id: r.id,
      runId: r.run_id,
      seq: r.seq,
      type: r.type,
      data: safeParse(r.data),
      createdAt: r.created_at,
    }));
  }

  // --- internal: finalize a run -------------------------------------------

  /**
   * Force-settle a run straight in the DB (no live runner). Used to recover
   * runs stranded at 'running' — orphans with no live subprocess (crash,
   * server restart mid-run, spawn failure) — and to honor an operator stop on a
   * wedged run. Emits a persisted `status` event so any open stream reflects
   * the new status immediately, and a `log` line with the reason.
   */
  private forceSettle(id: string, status: RunStatus, reason: string): void {
    const run = this.rawGet(id);
    if (!run) return;
    const now = new Date().toISOString();
    // Best-effort: capture the head commit if the worktree is still around, so
    // a recovered run can still be reviewed/diffed. Never wipe an existing one.
    let commitSha: string | null = null;
    if (run.worktreePath && isWorktreePresent(run.worktreePath)) {
      commitSha = headSha(run.worktreePath);
    }
    appendRunEvent(id, 'status', { status });
    appendRunEvent(id, 'log', { level: 'warn', text: reason });
    db.prepare(
      `UPDATE agent_runs
          SET status = ?, error = ?, commit_sha = COALESCE(?, commit_sha), finished_at = ?
        WHERE id = ?`,
    ).run(status, reason, commitSha, now, id);
    this.active.delete(id);
  }

  /**
   * Self-heal a run stranded at 'running' with no live runner in this process.
   * Called on every read (get/list) so the board recovers automatically on the
   * next poll — no manual action or server restart required. Returns the
   * (possibly updated) run, or undefined if the run never existed.
   */
  private reconcile(run: AgentRun | undefined): AgentRun | undefined {
    if (run && run.status === 'running' && !this.active.has(run.id)) {
      this.forceSettle(run.id, 'stopped', 'interrupted: no live agent process (server restarted or run crashed)');
      return this.rawGet(run.id);
    }
    return run;
  }

  /**
   * Spawn a conflict-resolution agent run in the main worktree (where a merge is
   * mid-conflict) and return a promise that resolves when it settles. The run
   * is a real, streamable agent_run tied to the conflicted branch's card; the
   * caller decides success by checking the merge state after settle.
   */
  private startResolutionRun(input: {
    cardId: string;
    branch: string;
    prompt: string;
  }): { runId: string; settled: Promise<{ status: RunStatus; summary: string | null }> } {
    const id = randomUUID();
    const token = newToken();
    const now = new Date().toISOString();
    db.prepare(
      `INSERT INTO agent_runs
         (id, card_id, status, use_worktree, owns_worktree, branch, worktree_path, session_file, prompt, token, created_at, started_at)
       VALUES (?, ?, 'running', 0, 0, ?, NULL, NULL, ?, ?, ?, ?)`,
    ).run(id, input.cardId, input.branch, input.prompt, token, now, now);

    let resolveSettled!: (v: { status: RunStatus; summary: string | null }) => void;
    const settled = new Promise<{ status: RunStatus; summary: string | null }>((resolve) => {
      resolveSettled = resolve;
    });

    const runner = new Runner({
      runId: id,
      cwd: REPO_ROOT,
      prompt: input.prompt,
      onSettled: (status, summary) => {
        this.settle(id, status, summary);
        resolveSettled({ status, summary });
      },
      onSessionResolved: (sessionFile) => {
        db.prepare('UPDATE agent_runs SET session_file = ? WHERE id = ?').run(sessionFile, id);
      },
    });
    // Resolution runs own no worktree (they run in the main checkout) and never
    // clean anything up.
    this.active.set(id, { runner, worktree: null, cleanup: false });
    runner.start();
    return { runId: id, settled };
  }

  private settle(id: string, status: RunStatus, summary: string | null): void {
    const entry = this.active.get(id);
    const now = new Date().toISOString();

    let commitSha: string | null = null;
    let extraError: string | null = null;
    if (entry?.worktree) {
      commitSha = headSha(entry.worktree.path);
      const dirty = dirtySummary(entry.worktree.path);
      if (dirty) {
        // Uncommitted changes left behind — surface as an error note + event.
        extraError = `worktree has uncommitted changes:\n${dirty}`;
        appendRunEvent(id, 'log', { level: 'warn', text: extraError });
      }
    }

    db.prepare(
      `UPDATE agent_runs
          SET status = ?, summary = ?, commit_sha = ?, error = COALESCE(?, error), finished_at = ?
        WHERE id = ?`,
    ).run(status, summary, commitSha, extraError, now, id);

    this.active.delete(id);

    // Optional cleanup of the worktree once the run is done.
    if (entry?.cleanup && entry.worktree) {
      removeWorktree(id, entry.worktree.branch);
    }
  }
}

export class RunNotFoundError extends Error {
  constructor(id: string) {
    super(`agent run not found: ${id}`);
    this.name = 'RunNotFoundError';
  }
}

// --- helpers ---------------------------------------------------------------

function hydrateRun(row: AgentRunRow): AgentRun {  return {
    id: row.id,
    cardId: row.card_id,
    status: row.status as RunStatus,
    useWorktree: row.use_worktree === 1,
    ownsWorktree: row.owns_worktree === 1,
    branch: row.branch,
    worktreePath: row.worktree_path,
    sessionFile: row.session_file,
    prompt: row.prompt,
    summary: row.summary,
    commitSha: row.commit_sha,
    error: row.error,
    createdAt: row.created_at,
    startedAt: row.started_at,
    finishedAt: row.finished_at,
  };
}

function safeParse(s: string): Record<string, unknown> {
  try {
    return JSON.parse(s) as Record<string, unknown>;
  } catch {
    return {};
  }
}

/** Process-wide singleton. */
export const runManager = new RunManager();
