/**
 * SET 89 §11 — ActionLedger acceptance suite.
 *
 * Fourteen tests, each locking in one clause of the canonical append-only
 * action ledger contract:
 *
 *   1.  startAction returns a monotonic actionId
 *   2.  finishAction with 'applied' terminalizes and clears pending
 *   3.  double terminal is a strict-mode invariant violation
 *   4.  finish without start throws
 *   5.  runAction wraps fn + terminalizes with 'applied' by default
 *   6.  runAction rethrows and terminalizes with 'failed' fatal on throw
 *   7.  runNonfatalAction swallows the throw and terminalizes 'failed' nonfatal
 *   8.  runNonfatalAction returns ok:false and does NOT rethrow
 *   9.  recordDecision appends a decision row (no start) with kind `rejected|noop`
 *   10. cancelPending closes every open action as 'cancelled'
 *   11. assertConserved rejects pending, accepts fully-terminalized ledger
 *   12. metrics counters partition dispositions (applied+rejected+noop+cancelled+failed = terminals)
 *   13. renderTerminalLine emits a structured line (used to replace `[action:…]` strings)
 *   14. Source ownership: runner has no empty `catch {}` on load-bearing paths
 */
import { describe, expect, it } from 'vitest';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { ActionLedger } from '../src/tier1/actionLedger';
import { ArtifactState } from '../src/tier1/artifactState';
import { MockLlm } from '../src/mock/mockLlm';
import { runTier1Job } from '../src/tier1/runner';
import { makeEvalSuiteRunner } from '../src/tier1/testExec';

function fixedClock(): () => string {
  let n = 0;
  return () => {
    n += 1;
    return `2026-07-28T00:00:${String(n).padStart(2, '0')}.000Z`;
  };
}

// ---------------------------------------------------------------------------
// 1. startAction monotonicity
// ---------------------------------------------------------------------------

describe('SET 89 §11.1 — startAction returns monotonic ids', () => {
  it('actionIds are A000001, A000002, … regardless of kind interleaving', () => {
    const led = new ActionLedger(fixedClock());
    const a = led.startAction({ kind: 'x', target: 't', phase: 'build' });
    const b = led.startAction({ kind: 'y', target: 't', phase: 'rha' });
    const c = led.startAction({ kind: 'x', target: 't', phase: 'build' });
    expect(a).toBe('A000001');
    expect(b).toBe('A000002');
    expect(c).toBe('A000003');
    // All still pending until finished.
    expect(led.metrics().actionPending).toBe(3);
  });
});

// ---------------------------------------------------------------------------
// 2. finishAction transitions pending→terminal
// ---------------------------------------------------------------------------

describe('SET 89 §11.1 — finishAction clears pending and appends a terminal', () => {
  it('applied disposition clears pending and increments the corresponding counter', () => {
    const led = new ActionLedger(fixedClock());
    const id = led.startAction({ kind: 'artifact-write', target: 'F1', phase: 'build' });
    expect(led.metrics().actionPending).toBe(1);
    led.finishAction({ actionId: id, disposition: 'applied' });
    const m = led.metrics();
    expect(m.actionPending).toBe(0);
    expect(m.actionApplied).toBe(1);
    expect(m.actionTerminals).toBe(1);
    expect(m.actionStarts).toBe(m.actionTerminals);
  });
});

// ---------------------------------------------------------------------------
// 3. double-terminal is a strict-mode violation
// ---------------------------------------------------------------------------

describe('SET 89 §11.1 — double terminal is refused in strict mode', () => {
  it('finishing the same actionId twice throws when strict:true', () => {
    const led = new ActionLedger(fixedClock(), { strict: true });
    const id = led.startAction({ kind: 'k', target: 't', phase: 'build' });
    led.finishAction({ actionId: id, disposition: 'applied' });
    expect(() => led.finishAction({ actionId: id, disposition: 'applied' }))
      .toThrow(/double terminal/);
  });

  it('non-strict mode returns the existing terminal (production tolerance)', () => {
    const led = new ActionLedger(fixedClock(), { strict: false });
    const id = led.startAction({ kind: 'k', target: 't', phase: 'build' });
    const first = led.finishAction({ actionId: id, disposition: 'applied' });
    const second = led.finishAction({ actionId: id, disposition: 'rejected' });
    // The FIRST terminal wins — a late duplicate cannot rewrite history.
    expect(second).toEqual(first);
    expect(led.metrics().actionTerminals).toBe(1);
  });
});

// ---------------------------------------------------------------------------
// 4. finish without start throws (no orphan terminals)
// ---------------------------------------------------------------------------

describe('SET 89 §11.1 — orphan terminal is refused', () => {
  it('finishing an unknown actionId throws', () => {
    const led = new ActionLedger(fixedClock());
    expect(() => led.finishAction({ actionId: 'A000999', disposition: 'applied' }))
      .toThrow(/finish without start/);
  });
});

// ---------------------------------------------------------------------------
// 5. runAction: happy path
// ---------------------------------------------------------------------------

describe('SET 89 §11.2 — runAction wraps fn and terminalizes automatically', () => {
  it('a successful fn produces disposition="applied" by default and returns the result', async () => {
    const led = new ActionLedger(fixedClock());
    const { actionId, result, terminal } = await led.runAction({
      kind: 'measure', target: 'F1', phase: 'rha',
      fn: async () => 42,
    });
    expect(result).toBe(42);
    expect(actionId).toBe('A000001');
    expect(terminal.disposition).toBe('applied');
    expect(led.metrics().actionPending).toBe(0);
  });

  it('dispositionOf mapper can force `rejected` for a legitimate no-match outcome', async () => {
    const led = new ActionLedger(fixedClock());
    const { terminal } = await led.runAction({
      kind: 'candidate-trial', target: 'F1', phase: 'rha',
      fn: async () => ({ improved: false }),
      dispositionOf: (r) => (r.improved ? 'applied' : 'rejected'),
    });
    expect(terminal.disposition).toBe('rejected');
    expect(led.metrics().actionRejected).toBe(1);
  });
});

// ---------------------------------------------------------------------------
// 6. runAction: fatal path rethrows and marks failed:fatal
// ---------------------------------------------------------------------------

describe('SET 89 §11.2 — runAction propagates fatal throws', () => {
  it('an fn that throws produces disposition=failed (fatal) and rethrows', async () => {
    const led = new ActionLedger(fixedClock());
    await expect(led.runAction({
      kind: 'measure', target: 'F1', phase: 'rha',
      fn: () => { throw new Error('boom'); },
    })).rejects.toThrow(/boom/);
    const m = led.metrics();
    expect(m.actionFailed).toBe(1);
    expect(m.actionFailedNonfatal).toBe(0);
    expect(m.actionPending).toBe(0);
  });
});

// ---------------------------------------------------------------------------
// 7 + 8. runNonfatalAction swallows errors — host-callback contract
// ---------------------------------------------------------------------------

describe('SET 89 §11.3 — runNonfatalAction contains host-callback failures', () => {
  it('a throwing host callback yields ok:false, disposition=failed nonfatal, and does NOT rethrow', async () => {
    const led = new ActionLedger(fixedClock());
    const { ok, terminal } = await led.runNonfatalAction({
      kind: 'host-callback:onEvent', target: 'notifyPhase', phase: 'ir',
      fn: () => { throw new Error('host consumer exploded'); },
    });
    expect(ok).toBe(false);
    expect(terminal.disposition).toBe('failed');
    expect(terminal.failureMode).toBe('nonfatal');
    const m = led.metrics();
    expect(m.actionFailed).toBe(1);
    expect(m.actionFailedNonfatal).toBe(1);
  });

  it('a successful host callback yields ok:true, disposition=applied', async () => {
    const led = new ActionLedger(fixedClock());
    let called = 0;
    const { ok } = await led.runNonfatalAction({
      kind: 'host-callback:onMetrics', target: 'notifyPhase', phase: 'rha',
      fn: () => { called += 1; },
    });
    expect(ok).toBe(true);
    expect(called).toBe(1);
    expect(led.metrics().actionApplied).toBe(1);
  });
});

// ---------------------------------------------------------------------------
// 9. recordDecision — no fabricated start
// ---------------------------------------------------------------------------

describe('SET 89 §11.4 — recordDecision appends a decision row without a start', () => {
  it('a skipped repair records a `noop` decision and increments the decision counter', () => {
    const led = new ActionLedger(fixedClock());
    led.recordDecision({
      kind: 'repair-skip', target: 'F1',
      phase: 'rha', disposition: 'noop',
      reason: 'no candidates available',
    });
    const m = led.metrics();
    expect(m.actionDecisions).toBe(1);
    // A decision does NOT fabricate a start/terminal pair.
    expect(m.actionStarts).toBe(0);
    expect(m.actionTerminals).toBe(0);
  });
});

// ---------------------------------------------------------------------------
// 10. cancelPending closes every open action
// ---------------------------------------------------------------------------

describe('SET 89 §11.5 — cancelPending closes every open action', () => {
  it('after cancelPending, actionPending drops to 0 and the closed actions are `cancelled`', () => {
    const led = new ActionLedger(fixedClock());
    led.startAction({ kind: 'a', target: 't', phase: 'build' });
    led.startAction({ kind: 'b', target: 't', phase: 'rha' });
    led.cancelPending('user cancelled');
    const m = led.metrics();
    expect(m.actionPending).toBe(0);
    expect(m.actionCancelled).toBe(2);
    // Conservation holds after cancel.
    expect(m.actionStarts).toBe(m.actionTerminals);
  });
});

// ---------------------------------------------------------------------------
// 11. assertConserved — pending violates, empty accepts
// ---------------------------------------------------------------------------

describe('SET 89 §11.6 — assertConserved is the run-end conservation gate', () => {
  it('an unfinished action fails the conservation assertion', () => {
    const led = new ActionLedger(fixedClock());
    led.startAction({ kind: 'orphan', target: 't', phase: 'build' });
    // starts != terminals is the earliest-fail predicate; the wording is
    // "starts N !== terminals N" — a pending action shows up here first.
    expect(() => led.assertConserved())
      .toThrow(/starts \d+ !== terminals \d+|pending action/);
  });

  it('a fully-terminalized ledger passes assertConserved', () => {
    const led = new ActionLedger(fixedClock());
    const id = led.startAction({ kind: 'x', target: 't', phase: 'build' });
    led.finishAction({ actionId: id, disposition: 'applied' });
    expect(() => led.assertConserved()).not.toThrow();
  });
});

// ---------------------------------------------------------------------------
// 12. dispositions partition terminals exactly
// ---------------------------------------------------------------------------

describe('SET 89 §11.4 — disposition counters partition terminals', () => {
  it('applied + rejected + noop + cancelled + failed === actionTerminals', () => {
    const led = new ActionLedger(fixedClock());
    // A representative mix.
    const a = led.startAction({ kind: 'k', target: 't', phase: 'p' });
    const b = led.startAction({ kind: 'k', target: 't', phase: 'p' });
    const c = led.startAction({ kind: 'k', target: 't', phase: 'p' });
    const d = led.startAction({ kind: 'k', target: 't', phase: 'p' });
    led.startAction({ kind: 'k', target: 't', phase: 'p' });
    led.finishAction({ actionId: a, disposition: 'applied' });
    led.finishAction({ actionId: b, disposition: 'rejected' });
    led.finishAction({ actionId: c, disposition: 'noop' });
    led.finishAction({ actionId: d, disposition: 'failed', failureMode: 'fatal', error: new Error('x') });
    led.cancelPending('run cancelled');
    const m = led.metrics();
    const sum = m.actionApplied + m.actionRejected + m.actionNoop
      + m.actionCancelled + m.actionFailed;
    expect(sum).toBe(m.actionTerminals);
    expect(m.actionTerminals).toBe(5);
  });
});

// ---------------------------------------------------------------------------
// 13. renderTerminalLine produces a stable format
// ---------------------------------------------------------------------------

describe('SET 89 §11.4 — renderTerminalLine emits a structured line', () => {
  it('rendered line contains kind:target, actionId, and disposition', () => {
    const led = new ActionLedger(fixedClock());
    const id = led.startAction({ kind: 'measure', target: 'F1', phase: 'rha' });
    led.finishAction({ actionId: id, disposition: 'applied' });
    const line = led.renderTerminalLine(id);
    expect(line).toContain('measure:F1');
    expect(line).toContain(id);
    expect(line).toContain('applied');
  });

  it('rendered start line uses the START prefix (no more raw string ledgers)', () => {
    const led = new ActionLedger(fixedClock());
    const id = led.startAction({ kind: 'measure', target: 'F1', phase: 'rha' });
    expect(led.renderStartLine(id)).toMatch(/START$/);
  });
});

// ---------------------------------------------------------------------------
// 14. Source ownership — runner has no empty catches on load-bearing paths
// ---------------------------------------------------------------------------

async function readTierSource(rel: string): Promise<string> {
  const here = fileURLToPath(new URL('.', import.meta.url));
  return readFile(resolve(here, '..', rel), 'utf8');
}

describe('SET 89 §11.3 — host callback safety: no silent catches on load-bearing paths', () => {
  it('runner.ts has no bare `catch {}` on emit / onEvent / onMetrics / onObservation / onLesson callback paths', async () => {
    const src = await readTierSource('src/tier1/runner.ts');
    // Extract every window that mentions a callback name and inspect the
    // 200-char follow-through for an empty catch. This scanning shape is
    // deliberately permissive: nonempty catches or catches that call
    // `runNonfatalAction`/`emit(...)` pass, so an honest recorder is fine.
    const patterns = [
      /opts\.onEvent\?\.\(/g,
      /opts\.onMetrics\?\.\(/g,
      /opts\.onObservation\?\.\(/g,
      /opts\.onLesson\?\.\(/g,
      /opts\.onCladeCandidate\?\.\(/g,
      /opts\.onPhaseGate\?\.\(/g,
      /opts\.onJudgment\?\.\(/g,
    ];
    for (const pat of patterns) {
      const matches = [...src.matchAll(pat)];
      for (const m of matches) {
        const start = m.index ?? 0;
        const window = src.slice(start, start + 400);
        // If a nearby `catch { }` block is present and is truly empty
        // (only whitespace inside), fail.
        expect(window, `empty catch near ${pat}`)
          .not.toMatch(/catch\s*\(?[^{]*\)?\s*\{\s*\}/);
      }
    }
  });

  it('runner.ts records host-callback failures in the ledger (not silently)', async () => {
    const src = await readTierSource('src/tier1/runner.ts');
    // At least one host-callback path is wrapped by a helper whose contract
    // is to record failures in the ledger. Either the async runNonfatalAction
    // wrapper or the synchronous `runHostCallback` shim satisfies this — but
    // some form of ledger-recording wrapper MUST be present, or the run has
    // reverted to raw silent-catch shape.
    const usesLedgerCallbackWrap =
      /actionLedger\.runNonfatalAction/.test(src)
      || /runHostCallback\(/.test(src);
    expect(usesLedgerCallbackWrap).toBe(true);
    // Additionally, the shim MUST record via the ledger — not just log.
    expect(src).toMatch(/failureMode:\s*['"]nonfatal['"]/);
  });
});

// ---------------------------------------------------------------------------
// ArtifactState.evaluateCandidateTrial — restore + separate adoption
// ---------------------------------------------------------------------------

describe('SET 89 §11 — ArtifactState candidate trial restores; adopt is separate', () => {
  it('rejected trial restores the original state signature', async () => {
    const led = new ActionLedger(fixedClock());
    const art = new ArtifactState({ ledger: led, clock: fixedClock() });
    art.commitWrites({
      files: [{ path: 'src/a.ts', content: 'export const a = 1;\n' }],
      phase: 'build', target: 'F1', recordAction: true,
    });
    const before = art.signature();
    const candidateId = art.allocateCandidateId();
    const trial = await art.evaluateCandidateTrial({
      phase: 'rha', target: 'F1', candidateId,
      trialFiles: [{ path: 'src/a.ts', content: 'export const a = 2;\n' }],
      measure: async () => ({ improved: false }),
      dispositionOf: () => 'rejected',
    });
    expect(trial.beforeSignature).toBe(before);
    expect(trial.restoredSignature).toBe(before);
    expect(art.signature()).toBe(before);
    expect(art.get('src/a.ts')?.content).toContain('a = 1');
  });

  it('accepted adoption is a separate action and changes the final signature', async () => {
    const led = new ActionLedger(fixedClock());
    const art = new ArtifactState({ ledger: led, clock: fixedClock() });
    art.commitWrites({
      files: [{ path: 'src/a.ts', content: 'export const a = 1;\n' }],
      phase: 'build', target: 'F1',
    });
    const before = art.signature();
    const candidateId = art.allocateCandidateId();
    const winner = [{ path: 'src/a.ts', content: 'export const a = 9;\n' }];
    await art.evaluateCandidateTrial({
      phase: 'rha', target: 'F1', candidateId,
      trialFiles: winner,
      measure: async () => ({ improved: true }),
      dispositionOf: () => 'applied',
    });
    // Trial restored — signature unchanged until adopt.
    expect(art.signature()).toBe(before);
    const adopt = art.adoptCandidate({
      phase: 'rha', target: 'F1', candidateId, files: winner,
    });
    expect(adopt.signature).not.toBe(before);
    expect(art.get('src/a.ts')?.content).toContain('a = 9');
    const kinds = led.snapshot().starts.map(s => s.kind);
    expect(kinds).toContain('candidate-trial');
    expect(kinds).toContain('candidate-adopt');
  });
});

// ---------------------------------------------------------------------------
// PRODUCTION-SHAPED RUNNER TESTS (SET 89 Gap F)
//
// These tests exercise `runTier1Job` end-to-end and inspect the resulting
// `actionLedger` snapshot. They lock in the properties that make the
// ledger a HONEST run record — conservation, structure, host-callback
// wrapping, and export round-trip.
// ---------------------------------------------------------------------------

describe('SET 89 §11.4 — ActionLedger rides on real runTier1Job results', () => {
  it('a normal run reaches terminal with zero pending actions', async () => {
    const res = await runTier1Job({
      prompt: 'URL shortener library',
      llm: new MockLlm(),
      runSuite: makeEvalSuiteRunner(),
    });
    // The ledger MUST be attached to the result (Gap F item 4).
    expect(res.actionLedger).toBeDefined();
    const snap = res.actionLedger!;
    expect(snap.metrics.actionPending).toBe(0);
    // Every start has a terminal (§11.1 conservation).
    expect(snap.metrics.actionStarts).toBe(snap.metrics.actionTerminals);
    expect(snap.metrics.actionStarts).toBeGreaterThan(0);
  });

  it('a real run records structured actions for BUILD writes, frozen-suite commits, and authoritative measurements', async () => {
    // Gap F item 5 — a representative run must produce more than
    // metadata: at minimum, we expect action kinds covering write /
    // frozen-suite-commit / authoritative-measurement plus at least one
    // token-spending model call (encodeFeatures, generateImplementation,
    // etc.). Kinds are the ledger's replay contract.
    const res = await runTier1Job({
      prompt: 'URL shortener library',
      llm: new MockLlm(),
      runSuite: makeEvalSuiteRunner(),
    });
    const snap = res.actionLedger!;
    const kinds = new Set(snap.starts.map(s => s.kind));
    // The `frozen-suite-commit` kind is emitted by the gateway when the
    // runner passes it an actionLedger — Gap C item 10.
    expect(kinds.has('frozen-suite-commit')).toBe(true);
    // At least one token-spending model call was recorded (Gap C 1-5).
    const hasLlmKind = [...kinds].some(k => k.startsWith('llm:'));
    expect(hasLlmKind).toBe(true);
    // The terminal certification action closes the run (§10.6).
    expect(kinds.has('terminal-certification')).toBe(true);
    // Authoritative measurements MAY not have run if the mock had no
    // failures worth measuring authoritatively; the runner only records
    // `authoritative-measurement` when needed. We assert its ABSENCE is
    // benign — but if any exists, its shape is honest.
    for (const s of snap.starts.filter(s => s.kind === 'authoritative-measurement')) {
      expect(s.phase).toMatch(/build|rha|auth/);
    }
  });

  it('a thrown host callback is recorded as `failed` with `failureMode: nonfatal`', async () => {
    // Gap F item 3 — the run must survive host-callback failures; the
    // ledger must show the throw as a NONFATAL failure, not silence.
    let onEventCalls = 0;
    const res = await runTier1Job({
      prompt: 'URL shortener library',
      llm: new MockLlm(),
      runSuite: makeEvalSuiteRunner(),
      onEvent: () => {
        onEventCalls += 1;
        // Throw once — the runner must not crash, and the throw must be
        // recorded in the action ledger via `runNonfatalAction` /
        // `runHostCallback`.
        if (onEventCalls === 2) throw new Error('injected onEvent failure');
      },
    });
    // The run reached terminal.
    expect(res.actionLedger).toBeDefined();
    // At LEAST one nonfatal failure recorded — either the ledger metric
    // is non-zero, or a terminal row carries `failureMode: 'nonfatal'`.
    const snap = res.actionLedger!;
    const nonfatalCount =
      snap.metrics.actionFailedNonfatal
      + snap.terminals.filter(t => t.failureMode === 'nonfatal').length;
    expect(nonfatalCount).toBeGreaterThan(0);
    // The run itself still completed — the throw did NOT propagate.
    expect(res.problems).toEqual([]);
  });

  it('actionLedger snapshot round-trips through JSON.stringify unchanged', async () => {
    // Gap F item 7 — export round-trip. The snapshot rides on the
    // result object and passes through offscreen `cloneSafeForPersist`
    // (JSON.parse(JSON.stringify(...)) with a Map/Set/Error replacer).
    // Because ActionLedger's `snapshot()` returns plain JSON already,
    // the round-trip is a no-op — this test enforces that invariant.
    const res = await runTier1Job({
      prompt: 'URL shortener library',
      llm: new MockLlm(),
      runSuite: makeEvalSuiteRunner(),
    });
    const snap = res.actionLedger;
    expect(snap).toBeDefined();
    const rt = JSON.parse(JSON.stringify(snap));
    expect(rt).toEqual(snap);
    // Ownership grep: the exportRepo path serializes to defects/actions
    // .jsonl. If the runner attaches the snapshot correctly, exportRepo
    // gets the same value on the same object shape — enforced by the
    // fact that we import the same type on both sides.
  });

  it('exportRunLog (web) receives defectRegistry and actionLedger on the input', async () => {
    // Gap F item 7 — the export path in `web/src/exportRunLog.ts`
    // accepts optional `defectRegistry` / `actionLedger` fields. Prove
    // they are actually present on `Tier1Result` so a UI layer can
    // wire them through without a bridge.
    const res = await runTier1Job({
      prompt: 'URL shortener library',
      llm: new MockLlm(),
      runSuite: makeEvalSuiteRunner(),
    });
    expect(res.defectRegistry).toBeDefined();
    expect(res.actionLedger).toBeDefined();
    // exportRunLog only cares about shape existence; the types confirm
    // that the shapes match.
  });
});

