/**
 * Job manager: one JobState per pipeline run, with the two human approval
 * gates in between the automated phases. Everything else is automatic.
 */
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { DebugSink, FeatureSpec, JobEvent, JobPhase, JobState, UsageSink } from '../core/types';
import { Substrate } from '../core/substrate';
import { runBuild, runEncodingAndIR } from '../core/pipeline';
import { applyRha } from '../core/rha';
import { recomputeFeatureSubtree } from '../core/recompute';
import { makeLlmBundle } from '../core/roleRouter';
import { getConfig } from '../core/config';
import { isProviderConnected } from './connection';
import { syncDeployable } from '../core/deploySync';
import { checkScaffoldingIntegrity } from '../core/scaffoldGuard';
import { addFeaturesToProject } from '../core/addFeatures';
import { acquireProjectLock, validateProjectFolder } from '../core/attach';
import type { Telemetry } from './telemetry';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');

interface JobRecord {
  state: JobState;
  substrate: Substrate | null;
  listeners: Set<(ev: JobEvent) => void>;
  seq: number;
}

/** Phases where an LLM call may be in flight — interruptible across restarts. */
const ACTIVE_PHASES = new Set<JobPhase>(['created', 'encoding', 'generating-ir', 'building', 'healing']);

export class JobManager {
  private jobs = new Map<string, JobRecord>();

  constructor(private telemetry?: Telemetry) {
    this.restore();
    this.restoreAttached();
  }

  /**
   * Reload persisted jobs from workspaces/<id>/.supergit/job.json. Jobs that
   * were mid-phase when the server stopped are marked `interrupted` (resumable);
   * SEV1 / genuine dead-ends stay `failed`. Gate and terminal states restore
   * fully — the substrate reopens from the on-disk ledger.
   */
  private restore(): void {
    const root = path.join(ROOT, 'workspaces');
    if (!fs.existsSync(root)) return;
    for (const id of fs.readdirSync(root)) {
      const jobFile = path.join(root, id, '.supergit', 'job.json');
      if (!fs.existsSync(jobFile)) continue;
      try {
        const state = JSON.parse(fs.readFileSync(jobFile, 'utf8')) as JobState;
        state.workspaceDir = path.join(root, id); // tolerate a moved checkout
        state.llmUsage ??= []; // jobs persisted before metering existed
        if (ACTIVE_PHASES.has(state.phase)) {
          state.resumeFrom = state.phase;
          state.phase = 'interrupted';
          state.error = 'server restarted mid-phase; workspace and ledger preserved — resume to continue';
        }
        let substrate: Substrate | null = null;
        try {
          substrate = Substrate.open(state.workspaceDir);
        } catch {
          /* no ledger yet */
        }
        const seq = state.events.length ? state.events[state.events.length - 1].seq : 0;
        this.jobs.set(state.id, { state, substrate, listeners: new Set(), seq });
      } catch {
        /* unreadable job.json — leave the workspace alone, skip */
      }
    }
  }

  private persist(rec: JobRecord): void {
    try {
      const dir = path.join(rec.state.workspaceDir, '.supergit');
      if (!fs.existsSync(dir)) return; // workspace not scaffolded yet
      fs.writeFileSync(path.join(dir, 'job.json'), JSON.stringify(rec.state, null, 2));
    } catch {
      /* persistence is best-effort; the run itself must not die on it */
    }
  }

  list(): JobState[] {
    return [...this.jobs.values()]
      .map(r => r.state)
      .sort((a, b) => {
        // Untouched list ≡ newest-first: jobs WITHOUT sortIndex sort ABOVE
        // all manually-indexed jobs; dragged jobs keep relative order below.
        const ai = a.sortIndex;
        const bi = b.sortIndex;
        if (ai == null && bi == null) return b.createdAt.localeCompare(a.createdAt);
        if (ai == null) return -1;
        if (bi == null) return 1;
        if (ai !== bi) return ai - bi;
        return b.createdAt.localeCompare(a.createdAt);
      });
  }

  /** Delete one job and its workspace. Refused while an LLM call may be in flight. */
  delete(id: string): { ok: boolean; error?: string } {
    const rec = this.jobs.get(id);
    if (!rec) return { ok: false, error: 'job not found' };
    if (ACTIVE_PHASES.has(rec.state.phase)) {
      return { ok: false, error: `job is ${rec.state.phase} — wait for it to finish or fail` };
    }
    try {
      fs.rmSync(rec.state.workspaceDir, { recursive: true, force: true });
    } catch {
      /* workspace removal is best-effort; the record still goes */
    }
    this.jobs.delete(id);
    return { ok: true };
  }

  /** Clear all non-active jobs. Returns how many were deleted. */
  clearAll(): number {
    let n = 0;
    for (const id of [...this.jobs.keys()]) {
      if (this.delete(id).ok) n++;
    }
    return n;
  }

  rename(id: string, title: string): { ok: boolean; error?: string } {
    const rec = this.jobs.get(id);
    if (!rec) return { ok: false, error: 'job not found' };
    rec.state.title = title.trim().slice(0, 120);
    this.persist(rec);
    return { ok: true };
  }

  /** Part U1: set star / pin flags (partial). */
  setFlags(
    id: string,
    flags: { starred?: boolean; pinned?: boolean; title?: string },
  ): { ok: boolean; error?: string } {
    const rec = this.jobs.get(id);
    if (!rec) return { ok: false, error: 'job not found' };
    if (typeof flags.starred === 'boolean') rec.state.starred = flags.starred;
    if (typeof flags.pinned === 'boolean') rec.state.pinned = flags.pinned;
    if (typeof flags.title === 'string' && flags.title.trim()) {
      rec.state.title = flags.title.trim().slice(0, 120);
    }
    this.persist(rec);
    return { ok: true };
  }

  /** Persist a full manual ordering (drag-and-drop). Unknown ids are ignored. */
  reorder(ids: string[]): void {
    ids.forEach((id, i) => {
      const rec = this.jobs.get(id);
      if (!rec) return;
      rec.state.sortIndex = i;
      this.persist(rec);
    });
  }

  get(id: string): JobState | undefined {
    return this.jobs.get(id)?.state;
  }

  ledgerCsv(id: string): string | null {
    const rec = this.jobs.get(id);
    if (!rec?.substrate) return null;
    const rows = rec.substrate.allRows();
    const header = 'step,id,type,feature_id,parent_id,cause_id,edge_from,edge_to,artifact_path,bug_class,status,summary,ts';
    return [
      header,
      ...rows.map(r =>
        [r.step, r.id, r.type, r.featureId, r.parentId, r.causeId, r.edgeFrom, r.edgeTo, r.artifactPath, r.bugClass, r.status, JSON.stringify(r.summary), r.ts]
          .map(v => (v === null || v === undefined ? '' : String(v)))
          .join(','),
      ),
    ].join('\n');
  }

  subscribe(id: string, listener: (ev: JobEvent) => void): () => void {
    const rec = this.jobs.get(id);
    if (!rec) return () => {};
    rec.listeners.add(listener);
    return () => rec.listeners.delete(listener);
  }

  create(prompt: string, autopilot = false): JobState {
    if (!isProviderConnected() && process.env.MOCK_LLM !== '1') {
      throw new Error('connect a provider first — open Provider Vault → Set Active (or assign a role)');
    }
    const id = randomUUID().slice(0, 8);
    const state: JobState = {
      id,
      title: '',
      prompt,
      autopilot,
      phase: 'created',
      createdAt: new Date().toISOString(),
      workspaceDir: path.join(ROOT, 'workspaces', id),
      spec: null,
      manifest: null,
      buildResults: [],
      report: null,
      promptAssessment: null,
      irEscalations: [],
      gateRounds: 0,
      error: null,
      events: [],
      llmUsage: [],
      resumeFrom: null,
    };
    const rec: JobRecord = { state, substrate: null, listeners: new Set(), seq: 0 };
    this.jobs.set(id, rec);
    this.telemetry?.recordRunStart();
    void this.runIrPhase(rec);
    return state;
  }

  /**
   * Attach a synced project folder ('full' sync: substrate + git) as a live
   * workspace. Validation + the single-writer lock live in core/attach.
   * Idempotent: the same folder resolves to the same job id, and a persisted
   * .supergit/job.json in the folder restores the project's full prior state.
   */
  attach(folderPath: string): { ok: boolean; id?: string; warning?: string; error?: string } {
    const v = validateProjectFolder(folderPath);
    if (!v.ok) return { ok: false, error: v.error };
    for (const [id, rec] of this.jobs) {
      if (path.resolve(rec.state.workspaceDir) === v.dir) {
        // U3: re-check integrity on re-attach — do not silently continue if edited.
        const guard = checkScaffoldingIntegrity(rec.state.workspaceDir, rec.state.manifest);
        if (!guard.ok) {
          rec.state.scaffoldWarning = guard.message ?? 'scaffolding interrupted';
          this.persist(rec);
          return { ok: true, id, warning: rec.state.scaffoldWarning };
        }
        rec.state.scaffoldWarning = null;
        this.persist(rec);
        return { ok: true, id, warning: 'already attached' };
      }
    }
    const lock = acquireProjectLock(v.dir);
    if (!lock.ok) return { ok: false, error: lock.error };

    const id = createHash('sha256').update(v.dir).digest('hex').slice(0, 8);
    let state: JobState | null = null;
    const jobFile = path.join(v.dir, '.supergit', 'job.json');
    if (fs.existsSync(jobFile)) {
      try {
        state = JSON.parse(fs.readFileSync(jobFile, 'utf8')) as JobState;
        state.id = id;
        state.workspaceDir = v.dir;
        state.llmUsage ??= [];
        if (ACTIVE_PHASES.has(state.phase)) {
          state.resumeFrom = state.phase;
          state.phase = 'interrupted';
          state.error = 'attached mid-phase; resume to continue';
        }
      } catch { state = null; }
    }
    state ??= {
      id,
      title: v.spec?.title || path.basename(v.dir),
      prompt: v.spec && 'prompt' in v.spec ? String((v.spec as { prompt?: string }).prompt ?? '') : '',
      autopilot: false,
      phase: 'complete',
      createdAt: new Date().toISOString(),
      workspaceDir: v.dir,
      spec: v.spec ?? null,
      manifest: v.manifest ?? null,
      buildResults: [],
      report: null,
      promptAssessment: null,
      irEscalations: [],
      gateRounds: 0,
      error: null,
      events: [],
      llmUsage: [],
      resumeFrom: null,
    };
    state.attached = true;

    // U3: integrity check on attach — warn when freeze hashes diverge from disk.
    const guard = checkScaffoldingIntegrity(v.dir, state.manifest);
    if (!guard.ok) {
      state.scaffoldWarning = guard.message ?? 'scaffolding interrupted';
    } else {
      state.scaffoldWarning = null;
    }

    const rec: JobRecord = { state, substrate: null, listeners: new Set(), seq: state.events.length };
    try {
      rec.substrate = Substrate.open(v.dir);
    } catch (e) {
      return { ok: false, error: `ledger unreadable: ${e instanceof Error ? e.message : String(e)}` };
    }
    this.jobs.set(id, rec);
    this.registerAttached(v.dir);
    this.persist(rec);
    const warn = [lock.warning, state.scaffoldWarning].filter(Boolean).join(' — ') || undefined;
    return { ok: true, id, warning: warn };
  }

  private attachedRegistryPath = path.join(ROOT, 'data', 'attached.json');

  private registerAttached(dir: string): void {
    try {
      const list: string[] = fs.existsSync(this.attachedRegistryPath)
        ? (JSON.parse(fs.readFileSync(this.attachedRegistryPath, 'utf8')) as string[])
        : [];
      if (!list.includes(dir)) {
        list.push(dir);
        fs.mkdirSync(path.dirname(this.attachedRegistryPath), { recursive: true });
        fs.writeFileSync(this.attachedRegistryPath, JSON.stringify(list, null, 2) + '\n');
      }
    } catch { /* registry is best-effort */ }
  }

  /** Re-attach previously attached folders at boot (best-effort, never fatal). */
  private restoreAttached(): void {
    try {
      if (!fs.existsSync(this.attachedRegistryPath)) return;
      const list = JSON.parse(fs.readFileSync(this.attachedRegistryPath, 'utf8')) as string[];
      for (const dir of list) {
        const r = this.attach(dir);
        // eslint-disable-next-line no-console
        if (!r.ok) console.warn(`[attach] ${dir}: ${r.error}`);
      }
    } catch { /* best-effort */ }
  }

  private emitFor(rec: JobRecord) {
    return (kind: string, data: Record<string, unknown> = {}) => {
      const ev: JobEvent = { seq: ++rec.seq, ts: new Date().toISOString(), kind, data };
      rec.state.events.push(ev);
      for (const l of rec.listeners) l(ev);
      this.persist(rec);
    };
  }

  private usageSinkFor(rec: JobRecord): UsageSink {
    const emit = this.emitFor(rec);
    return record => {
      rec.state.llmUsage.push(record);
      emit('llm-usage', {
        op: record.op,
        featureId: record.featureId,
        inputTokens: record.inputTokens,
        outputTokens: record.outputTokens,
      });
    };
  }

  /**
   * Dense machine-readable LLM I/O trace → `<workspace>/.rha-debug.jsonl`
   * (one JSON object per call: exact prompts + raw response + metadata).
   * Best-effort — a debug-write failure must never break a run.
   */
  private debugSinkFor(rec: JobRecord): DebugSink {
    const file = path.join(rec.state.workspaceDir, '.rha-debug.jsonl');
    return entry => {
      try {
        fs.mkdirSync(path.dirname(file), { recursive: true });
        fs.appendFileSync(file, JSON.stringify(entry) + '\n');
      } catch { /* debug log is best-effort */ }
    };
  }

  private async runIrPhase(rec: JobRecord, opts: { skipGate?: boolean; overrideBy?: 'human' | 'autopilot' } = {}): Promise<void> {
    const emit = this.emitFor(rec);
    try {
      const { client } = await makeLlmBundle(this.usageSinkFor(rec), this.debugSinkFor(rec));
      const { substrate } = await runEncodingAndIR(rec.state, client, emit, opts);
      rec.substrate = substrate;
      if (rec.state.autopilot) this.autopilotAdvance(rec);
    } catch (e) {
      this.fail(rec, e);
    }
  }

  /**
   * Unsupervised E2E: convert each human hard-stop into a RECORDED autopilot
   * decision and keep going. The gates are not deleted — every bypass is a
   * ledger decision node, and escalations ride to the final report for
   * post-hoc human authentication. Supervised remains the default.
   */
  private autopilotAdvance(rec: JobRecord): void {
    const emit = this.emitFor(rec);
    const { state } = rec;
    if (state.phase === 'insufficient-input') {
      emit('autopilot', { gate: 'input', action: 'override' });
      state.error = null;
      state.promptAssessment = null;
      state.phase = 'created';
      // Pipeline records the override node on the reopened substrate (overrideBy).
      void this.runIrPhase(rec, { skipGate: true, overrideBy: 'autopilot' });
      return;
    }
    if (state.phase === 'awaiting-ir-approval') {
      emit('autopilot', { gate: 'ir', action: 'approve' });
      if (rec.substrate) {
        const n = rec.substrate.recordNode({
          type: 'decision', status: 'info',
          summary: `autopilot: IR contract auto-approved — unsupervised E2E (${rec.state.irEscalations.length} escalated feature(s) ride to the final report)`,
        });
        rec.substrate.commit(n, 'autopilot: IR auto-approved');
      }
      void this.runBuildPhase(rec);
    }
  }

  /**
   * Human operator levers on a blocked input gate:
   *  - reassess: re-run the gate (the judge resamples; borderline features
   *    may clear). This is "pushback" — the operator disagrees, look again.
   *  - override: skip the gate and proceed to build on manual authentication,
   *    recorded in the ledger. The economical final say, since the whole
   *    artifact is authenticated downstream anyway.
   */
  reassess(id: string): { ok: boolean; error?: string } {
    return this.rerunGate(id, { skipGate: false });
  }

  overrideGate(id: string): { ok: boolean; error?: string } {
    return this.rerunGate(id, { skipGate: true, overrideBy: 'human' });
  }

  /**
   * K2: mark named features clear in place (no re-probe). When all clear,
   * proceed past the gate with skipGate (zero new LLM gate calls).
   */
  approveGateFeatures(id: string, opts: { features?: string[]; all?: boolean }): { ok: boolean; error?: string } {
    const rec = this.jobs.get(id);
    if (!rec) return { ok: false, error: 'job not found' };
    if (rec.state.phase !== 'insufficient-input') {
      return { ok: false, error: `can only act on an insufficient-input job (is ${rec.state.phase})` };
    }
    const pa = rec.state.promptAssessment;
    if (!pa) return { ok: false, error: 'no prompt assessment' };

    const accept = new Set(
      (opts.all ? pa.deficiencies.map(d => d.aspect) : (opts.features ?? []))
        .map(s => s.trim())
        .filter(Boolean),
    );
    if (!accept.size) return { ok: false, error: 'no features selected' };

    const verdicts = [...(pa.verdicts ?? [])];
    for (const name of accept) {
      const idx = verdicts.findIndex(v => v.name === name);
      if (idx >= 0) {
        verdicts[idx] = {
          ...verdicts[idx],
          clear: true,
          assertion: verdicts[idx].assertion?.trim()
            || '(operator accepted — best-effort intent; spot-check recommended)',
        };
      } else {
        verdicts.push({
          name,
          scope: '',
          clear: true,
          assertion: '(operator accepted — best-effort intent; spot-check recommended)',
        });
      }
      if (!pa.featuresIdentified.includes(name)) pa.featuresIdentified.push(name);
    }
    pa.verdicts = verdicts;
    pa.deficiencies = pa.deficiencies.filter(d => !accept.has(d.aspect));
    pa.sufficient = pa.deficiencies.length === 0;
    pa.suggestedRewrite = pa.sufficient
      ? ''
      : verdicts
          .map(v => v.clear
            ? `Feature: ${v.name} — ${v.assertion}`
            : `Feature: ${v.name} — [DECIDE: clarify the observable behavior]`)
          .join('\n');

    this.emitFor(rec)('gate-approve-features', {
      accepted: [...accept],
      remaining: pa.deficiencies.length,
      sufficient: pa.sufficient,
    });
    this.persist(rec);

    if (!pa.sufficient) return { ok: true };

    // All clear — proceed without re-enumeration / re-probe.
    return this.rerunGate(id, { skipGate: true, overrideBy: 'human' });
  }

  private rerunGate(id: string, opts: { skipGate?: boolean; overrideBy?: 'human' | 'autopilot' }): { ok: boolean; error?: string } {
    const rec = this.jobs.get(id);
    if (!rec) return { ok: false, error: 'job not found' };
    if (rec.state.phase !== 'insufficient-input') {
      return { ok: false, error: `can only act on an insufficient-input job (is ${rec.state.phase})` };
    }
    if (!opts.skipGate) {
      const cap = getConfig().gateRounds;
      const next = (rec.state.gateRounds ?? 0) + 1;
      if (next > cap) {
        return {
          ok: false,
          error: 'input gate round cap reached — override, or revise the prompt text (a revised prompt resets the loop)',
        };
      }
      rec.state.gateRounds = next;
    }
    rec.state.error = null;
    rec.state.phase = 'created';
    // Call-site overrideBy wins (human override / reassess set it explicitly;
    // never clobber a future autopilot caller into a human label).
    void this.runIrPhase(rec, { overrideBy: 'human', ...opts });
    return { ok: true };
  }

  approve(id: string, gate: 'ir' | 'build'): { ok: boolean; error?: string } {
    const rec = this.jobs.get(id);
    if (!rec) return { ok: false, error: 'job not found' };
    const { state } = rec;
    if (gate === 'ir') {
      if (state.phase !== 'awaiting-ir-approval') {
        return { ok: false, error: `cannot approve IR in phase ${state.phase}` };
      }
      void this.runBuildPhase(rec);
      return { ok: true };
    }
    if (state.phase !== 'awaiting-build-approval') {
      return { ok: false, error: `cannot approve build in phase ${state.phase}` };
    }
    void this.runRhaPhase(rec);
    return { ok: true };
  }

  private async runBuildPhase(rec: JobRecord): Promise<void> {
    const emit = this.emitFor(rec);
    try {
      const { client } = await makeLlmBundle(this.usageSinkFor(rec), this.debugSinkFor(rec));
      await runBuild(rec.state, rec.substrate!, client, emit);
      if (rec.state.autopilot && rec.state.phase === 'awaiting-build-approval') {
        emit('autopilot', { gate: 'build', action: 'approve' });
        if (rec.substrate) {
          const failing = rec.state.buildResults.filter(r => r.failed > 0).length;
          const n = rec.substrate.recordNode({
            type: 'decision', status: 'info',
            summary: `autopilot: build auto-approved — unsupervised E2E (${failing} failing node(s) handed to RHA)`,
          });
          rec.substrate.commit(n, 'autopilot: build auto-approved');
        }
        void this.runRhaPhase(rec);
      }
    } catch (e) {
      this.fail(rec, e);
    }
  }

  private async runRhaPhase(rec: JobRecord): Promise<void> {
    const emit = this.emitFor(rec);
    try {
      const { client, supervisor, operators, modes } = await makeLlmBundle(this.usageSinkFor(rec), this.debugSinkFor(rec));
      rec.state.phase = 'healing';
      emit('phase', { phase: 'healing' });
      const report = await applyRha(
        rec.state.workspaceDir,
        rec.state.spec!,
        rec.state.manifest!,
        rec.substrate!,
        client,
        emit,
        rec.state,
        { supervisor, operators, modes },
      );
      rec.state.report = report;
      rec.state.phase = report.failed === 0 ? 'complete' : 'failed';
      if (report.failed > 0) {
        rec.state.error = `RHA left ${report.failed} failing test(s) — escalated for review`;
      }
      this.telemetry?.recordRunEnd(report.failed === 0);
      emit('phase', { phase: rec.state.phase });
      emit('complete', { report: report as unknown as Record<string, unknown> });
      if (rec.state.phase === 'complete') this.maybeSyncDeploy(rec);
    } catch (e) {
      this.fail(rec, e);
    }
  }

  /**
   * Part O: resume an interrupted job from ledger state. Reopens substrate
   * (never truncates). Fresh attempt/stall budgets on RHA re-entry (O3 note).
   */
  resume(id: string): { ok: boolean; error?: string } {
    const rec = this.jobs.get(id);
    if (!rec) return { ok: false, error: 'job not found' };
    if (rec.state.phase !== 'interrupted') {
      return { ok: false, error: `can only resume an interrupted job (is ${rec.state.phase})` };
    }
    if (!isProviderConnected() && process.env.MOCK_LLM !== '1') {
      return { ok: false, error: 'connect a provider before resuming' };
    }
    // U3: refuse to resume silently over external edits.
    if (rec.state.manifest) {
      const guard = checkScaffoldingIntegrity(rec.state.workspaceDir, rec.state.manifest);
      if (!guard.ok) {
        rec.state.scaffoldWarning = guard.message ?? 'scaffolding interrupted';
        this.persist(rec);
        return { ok: false, error: rec.state.scaffoldWarning };
      }
      rec.state.scaffoldWarning = null;
    }
    const from = rec.state.resumeFrom ?? 'created';
    rec.state.error = null;
    rec.state.resumeFrom = null;
    try {
      if (fs.existsSync(path.join(rec.state.workspaceDir, '.supergit', 'ledger.csv'))) {
        rec.substrate = Substrate.open(rec.state.workspaceDir);
      }
    } catch {
      /* open may fail if ledger corrupt — pipeline will init if needed */
    }
    this.emitFor(rec)('resume', { from });

    // encode/IR mid-flight → restart encode (ledger reopen is idempotent)
    if (from === 'created' || from === 'encoding' || from === 'generating-ir') {
      rec.state.phase = 'created';
      void this.runIrPhase(rec, {
        skipGate: !!rec.state.spec && !!rec.state.manifest,
      });
      return { ok: true };
    }
    if (from === 'building') {
      if (!rec.state.spec || !rec.state.manifest || !rec.substrate) {
        return { ok: false, error: 'cannot resume build — missing frozen IR/substrate' };
      }
      void this.runBuildPhase(rec);
      return { ok: true };
    }
    if (from === 'healing') {
      if (!rec.state.spec || !rec.state.manifest || !rec.substrate) {
        return { ok: false, error: 'cannot resume RHA — missing frozen IR/substrate' };
      }
      // applyRha re-sweeps from disk; healed nodes already pass.
      void this.runRhaPhase(rec);
      return { ok: true };
    }
    return { ok: false, error: `no resume path for stage ${from}` };
  }

  /**
   * Part O4: incremental subtree recompute. Reopens substrate (append-only),
   * re-freezes + rebuilds ONLY `featureId`, then applyRha heals dependents.
   */
  recompute(
    id: string,
    body: { featureId?: string; spec?: FeatureSpec },
  ): { ok: boolean; error?: string } {
    const rec = this.jobs.get(id);
    if (!rec) return { ok: false, error: 'job not found' };
    const phase = rec.state.phase;
    if (phase !== 'complete' && phase !== 'interrupted') {
      return { ok: false, error: `can only recompute a complete or interrupted job (is ${phase})` };
    }
    if (!body?.featureId || typeof body.featureId !== 'string') {
      return { ok: false, error: 'featureId required' };
    }
    if (!body.spec || !Array.isArray(body.spec.features)) {
      return { ok: false, error: 'spec with features[] required' };
    }
    if (!rec.state.spec || !rec.state.manifest) {
      return { ok: false, error: 'cannot recompute — missing frozen IR' };
    }
    if (!isProviderConnected() && process.env.MOCK_LLM !== '1') {
      return { ok: false, error: 'connect a provider before recomputing' };
    }
    try {
      if (fs.existsSync(path.join(rec.state.workspaceDir, '.supergit', 'ledger.csv'))) {
        rec.substrate = Substrate.open(rec.state.workspaceDir);
      }
    } catch {
      /* open may fail if ledger corrupt */
    }
    if (!rec.substrate) {
      return { ok: false, error: 'cannot recompute — no substrate/ledger' };
    }
    const guard = checkScaffoldingIntegrity(rec.state.workspaceDir, rec.state.manifest);
    if (!guard.ok) {
      rec.state.scaffoldWarning = guard.message ?? 'scaffolding interrupted';
      this.persist(rec);
      return { ok: false, error: rec.state.scaffoldWarning };
    }
    rec.state.scaffoldWarning = null;
    rec.state.error = null;
    rec.state.resumeFrom = null;
    this.emitFor(rec)('recompute', { featureId: body.featureId, phase: 'accepted' });
    void this.runRecomputePhase(rec, body.featureId, body.spec);
    return { ok: true };
  }

  /**
   * Part U2: append new features from a prompt into a pinned/complete project.
   */
  addFeatures(
    id: string,
    addPrompt: string,
  ): { ok: boolean; error?: string } {
    const rec = this.jobs.get(id);
    if (!rec) return { ok: false, error: 'job not found' };
    if (rec.state.phase !== 'complete' && rec.state.phase !== 'interrupted') {
      return { ok: false, error: `can only add features to a complete or interrupted job (is ${rec.state.phase})` };
    }
    if (!rec.state.pinned && rec.state.phase === 'complete') {
      // Allow without pin too, but prefer pinned — still ok for complete
    }
    if (!addPrompt?.trim()) return { ok: false, error: 'prompt required' };
    if (!rec.state.spec || !rec.state.manifest) {
      return { ok: false, error: 'cannot add features — missing frozen IR' };
    }
    if (!isProviderConnected() && process.env.MOCK_LLM !== '1') {
      return { ok: false, error: 'connect a provider before adding features' };
    }
    try {
      if (fs.existsSync(path.join(rec.state.workspaceDir, '.supergit', 'ledger.csv'))) {
        rec.substrate = Substrate.open(rec.state.workspaceDir);
      }
    } catch { /* ignore */ }
    if (!rec.substrate) return { ok: false, error: 'cannot add features — no substrate/ledger' };
    const guard = checkScaffoldingIntegrity(rec.state.workspaceDir, rec.state.manifest);
    if (!guard.ok) {
      rec.state.scaffoldWarning = guard.message ?? 'scaffolding interrupted';
      this.persist(rec);
      return { ok: false, error: rec.state.scaffoldWarning };
    }
    rec.state.scaffoldWarning = null;
    rec.state.error = null;
    this.emitFor(rec)('add-features', { phase: 'accepted' });
    void this.runAddFeaturesPhase(rec, addPrompt.trim());
    return { ok: true };
  }

  private async runRecomputePhase(
    rec: JobRecord,
    featureId: string,
    spec: FeatureSpec,
  ): Promise<void> {
    const emit = this.emitFor(rec);
    try {
      const { client, supervisor, operators, modes } = await makeLlmBundle(
        this.usageSinkFor(rec),
        this.debugSinkFor(rec),
      );
      await recomputeFeatureSubtree(
        rec.state,
        rec.substrate!,
        client,
        emit,
        { featureId, spec },
        { supervisor, operators, modes },
      );
      this.telemetry?.recordRunEnd(rec.state.phase === 'complete');
      if (rec.state.phase === 'complete') this.maybeSyncDeploy(rec);
    } catch (e) {
      this.fail(rec, e);
    }
  }

  private async runAddFeaturesPhase(rec: JobRecord, addPrompt: string): Promise<void> {
    const emit = this.emitFor(rec);
    try {
      const { client, supervisor, operators, modes } = await makeLlmBundle(
        this.usageSinkFor(rec),
        this.debugSinkFor(rec),
      );
      await addFeaturesToProject(
        rec.state,
        rec.substrate!,
        client,
        emit,
        addPrompt,
        { supervisor, operators, modes },
      );
      this.telemetry?.recordRunEnd(rec.state.phase === 'complete');
      if (rec.state.phase === 'complete') this.maybeSyncDeploy(rec);
    } catch (e) {
      this.fail(rec, e);
    }
  }

  private maybeSyncDeploy(rec: JobRecord): void {
    const cfg = getConfig();
    if (!cfg.outputDir?.trim()) return;
    const result = syncDeployable(rec.state.workspaceDir, cfg.outputDir, {
      mode: cfg.syncMode,
    });
    this.emitFor(rec)('deploy-sync', {
      ok: result.ok,
      copied: result.copied.length,
      skipped: result.skipped,
      outputDir: cfg.outputDir,
      error: result.error,
    });
  }

  private fail(rec: JobRecord, e: unknown): void {
    rec.state.phase = 'failed';
    rec.state.error = e instanceof Error ? e.message : String(e);
    this.telemetry?.recordRunEnd(false);
    this.emitFor(rec)('phase', { phase: 'failed', error: rec.state.error });
  }
}
