/**
 * state-machine-edge-cases.test.ts — Gap-filling tests for the SF state
 * machine covering failure modes, boundary conditions, and edge cases NOT
 * covered by the existing state-machine-live-validation.test.ts suite.
 *
 * Coverage gaps filled:
 * 1. State derivation failures (file deletion races, partial DB, cache staleness,
 *    corrupt files, 0-slice ROADMAP)
 * 2. Transition boundary failures (mid-transition mutation, cascading blockers,
 *    multi-level milestone deps, blocked→unblocked recovery)
 * 3. Dispatch failures (null activeSlice, evaluating-gates without config,
 *    unhandled phase, missing task plan recovery)
 * 4. Completion & verification failures (unparseable verdict, needs-remediation
 *    blocks completion, missing SUMMARY blocks validation, UAT verdict gate,
 *    replan loop cap)
 */

// SF State Machine Edge Case Tests

import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import {
  mkdtempSync,
  mkdirSync,
  writeFileSync,
  readFileSync,
  rmSync,
  existsSync,
  unlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

// ── DB layer ──────────────────────────────────────────────────────────────
import {
  openDatabase,
  closeDatabase,
  insertMilestone,
  insertSlice,
  insertTask,
  getTask,
  getSlice,
  getMilestone,
  getSliceTasks,
  getMilestoneSlices,
  updateTaskStatus,
  updateSliceStatus,
  updateMilestoneStatus,
  insertReplanHistory,
  getReplanHistory,
  insertGateRow,
  getPendingGates,
} from "../../sf-db.ts";

// ── State derivation ──────────────────────────────────────────────────────
import {
  deriveState,
  deriveStateFromDb,
  invalidateStateCache,
  isGhostMilestone,
  isValidationTerminal,
} from "../../state.ts";

// ── Status guards ─────────────────────────────────────────────────────────
import { isClosedStatus } from "../../status-guards.ts";

// ── Cache invalidation ───────────────────────────────────────────────────
import { invalidateAllCaches } from "../../cache.ts";

// ── Dispatch ─────────────────────────────────────────────────────────────
import {
  resolveDispatch,
  DISPATCH_RULES,
  getDispatchRuleNames,
} from "../../auto-dispatch.ts";
import type { DispatchContext, DispatchAction } from "../../auto-dispatch.ts";

// ── Verdict parser ──────────────────────────────────────────────────────
import {
  extractVerdict,
  isAcceptableUatVerdict,
  isValidMilestoneVerdict,
} from "../../verdict-parser.ts";

// ── Path helpers ─────────────────────────────────────────────────────────
import { clearPathCache } from "../../paths.ts";

// ═══════════════════════════════════════════════════════════════════════════
// Fixture Helpers
// ═══════════════════════════════════════════════════════════════════════════

function makeTempDir(): string {
  return mkdtempSync(join(tmpdir(), "sf-edge-cases-"));
}

/**
 * Create a standard .sf/ fixture with M001 containing S01 (2 tasks) and S02 (1 task).
 * Same structure as state-machine-live-validation.test.ts for consistency.
 */
function createFullFixture(): string {
  const base = makeTempDir();
  const sfDir = join(base, ".sf");
  const m001Dir = join(sfDir, "milestones", "M001");
  const s01Dir = join(m001Dir, "slices", "S01");
  const s01Tasks = join(s01Dir, "tasks");
  const s02Dir = join(m001Dir, "slices", "S02");
  const s02Tasks = join(s02Dir, "tasks");

  mkdirSync(s01Tasks, { recursive: true });
  mkdirSync(s02Tasks, { recursive: true });

  writeFileSync(
    join(m001Dir, "M001-CONTEXT.md"),
    [
      "# M001: Edge Case Milestone",
      "",
      "## Purpose",
      "Test state machine edge cases.",
    ].join("\n"),
  );

  writeFileSync(
    join(m001Dir, "M001-ROADMAP.md"),
    [
      "# M001: Edge Case Milestone",
      "",
      "## Vision",
      "Prove edge case correctness.",
      "",
      "## Success Criteria",
      "- All edge cases handled",
      "",
      "## Slices",
      "",
      "- [ ] **S01: First Feature** `risk:low` `depends:[]`",
      "  - After this: First feature proven.",
      "",
      "- [ ] **S02: Second Feature** `risk:low` `depends:[]`",
      "  - After this: Second feature proven.",
      "",
      "## Boundary Map",
      "",
      "| From | To | Produces | Consumes |",
      "|------|----|----------|----------|",
      "| S01 | terminal | feature-a | nothing |",
      "| S02 | terminal | feature-b | nothing |",
    ].join("\n"),
  );

  writeFileSync(
    join(s01Dir, "S01-PLAN.md"),
    [
      "# S01: First Feature",
      "",
      "**Goal:** Implement first feature.",
      "",
      "## Tasks",
      "",
      "- [ ] **T01: Implementation** `est:30m`",
      "  - Do: Build it",
      "  - Verify: Run tests",
      "",
      "- [ ] **T02: Testing** `est:30m`",
      "  - Do: Write tests",
      "  - Verify: Run tests",
    ].join("\n"),
  );

  writeFileSync(join(s01Tasks, "T01-PLAN.md"), "# T01 Plan\nImplement.\n");
  writeFileSync(join(s01Tasks, "T02-PLAN.md"), "# T02 Plan\nTest.\n");

  writeFileSync(
    join(s02Dir, "S02-PLAN.md"),
    [
      "# S02: Second Feature",
      "",
      "**Goal:** Implement second feature.",
      "",
      "## Tasks",
      "",
      "- [ ] **T01: Implementation** `est:30m`",
      "  - Do: Build it",
      "  - Verify: Run tests",
    ].join("\n"),
  );

  writeFileSync(join(s02Tasks, "T01-PLAN.md"), "# T01 Plan\nBuild.\n");

  return base;
}

/**
 * Create a multi-milestone fixture with M001 → M002 → M003 dependency chain.
 */
function createMultiMilestoneFixture(): string {
  const base = makeTempDir();
  const sfDir = join(base, ".sf");

  for (const mid of ["M001", "M002", "M003"]) {
    const mDir = join(sfDir, "milestones", mid);
    const sDir = join(mDir, "slices", "S01", "tasks");
    mkdirSync(sDir, { recursive: true });

    writeFileSync(
      join(mDir, `${mid}-CONTEXT.md`),
      `# ${mid}: Milestone ${mid.slice(-1)}\n\n## Purpose\nTest deps.\n`,
    );

    writeFileSync(
      join(mDir, `${mid}-ROADMAP.md`),
      [
        `# ${mid}: Milestone ${mid.slice(-1)}`,
        "",
        "## Vision",
        "Test dependency chains.",
        "",
        "## Success Criteria",
        "- Works",
        "",
        "## Slices",
        "",
        "- [ ] **S01: Only Slice** `risk:low` `depends:[]`",
        "  - After this: Done.",
        "",
        "## Boundary Map",
        "",
        "| From | To | Produces | Consumes |",
        "|------|----|----------|----------|",
        "| S01 | terminal | output | nothing |",
      ].join("\n"),
    );

    writeFileSync(
      join(mDir, "slices", "S01", "S01-PLAN.md"),
      [
        "# S01: Only Slice",
        "",
        "**Goal:** Do the thing.",
        "",
        "## Tasks",
        "",
        "- [ ] **T01: Task** `est:30m`",
        "  - Do: Implement",
        "  - Verify: Run tests",
      ].join("\n"),
    );

    writeFileSync(join(sDir, "T01-PLAN.md"), "# T01 Plan\nDo it.\n");
  }

  return base;
}

function buildDispatchCtx(
  base: string,
  mid: string,
  stateOverrides: Partial<import("../../types.ts").SFState> = {},
): DispatchContext {
  return {
    basePath: base,
    mid,
    midTitle: `${mid} Test`,
    state: {
      activeMilestone: { id: mid, title: `${mid} Test` },
      activeSlice: null,
      activeTask: null,
      phase: "executing",
      recentDecisions: [],
      blockers: [],
      nextAction: "",
      registry: [],
      requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 },
      progress: { milestones: { done: 0, total: 1 } },
      ...stateOverrides,
    },
    prefs: undefined,
  };
}

// ═══════════════════════════════════════════════════════════════════════════
// Test Suite
// ═══════════════════════════════════════════════════════════════════════════

// ─────────────────────────────────────────────────────────────────────────
// SECTION 1: State Derivation Failure Modes
// ─────────────────────────────────────────────────────────────────────────

describe("state derivation failures", () => {
  let base: string;

  afterEach(() => {
    try { closeDatabase(); } catch { /* may not be open */ }
    if (base) rmSync(base, { recursive: true, force: true });
  });

  test("file deleted between deriveState calls produces consistent result", async () => {
    // Simulates race condition: PLAN file exists on first derive, deleted before second
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });

    invalidateAllCaches();
    const stateBefore = await deriveStateFromDb(base);
    assert.equal(stateBefore.phase, "executing");

    // Delete the task plan file mid-flow
    const planPath = join(base, ".sf", "milestones", "M001", "slices", "S01", "tasks", "T01-PLAN.md");
    if (existsSync(planPath)) unlinkSync(planPath);

    invalidateAllCaches();
    const stateAfter = await deriveStateFromDb(base);
    // State machine should still function — either executing (DB says task exists)
    // or planning (missing plan file triggers replan). Should NOT throw.
    assert.ok(
      ["executing", "planning"].includes(stateAfter.phase),
      `expected executing or planning after plan deletion, got: ${stateAfter.phase}`,
    );
  });

  test("partial DB write: milestone inserted but no slices → pre-planning", async () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    mkdirSync(mDir, { recursive: true });
    writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Test\n\n## Purpose\nTest.\n");

    openDatabase(join(base, ".sf", "sf.db"));
    // Only insert milestone — no slices, no roadmap
    insertMilestone({ id: "M001", title: "Partial", status: "active" });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);
    // No roadmap → pre-planning (milestone exists but no structure yet)
    assert.equal(state.phase, "pre-planning");
    assert.equal(state.activeMilestone?.id, "M001");
  });

  test("cache staleness: derive within TTL returns same result after DB mutation", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });

    // First call populates cache
    invalidateStateCache();
    const state1 = await deriveState(base);
    assert.equal(state1.phase, "executing");

    // Mutate DB WITHOUT invalidating cache
    updateTaskStatus("M001", "S01", "T01", "complete", new Date().toISOString());

    // Second call within 100ms TTL should return cached (stale) result
    const state2 = await deriveState(base);
    assert.equal(state2.phase, "executing", "cached result should still show executing");

    // After explicit invalidation, should reflect the DB mutation
    invalidateStateCache();
    const state3 = await deriveState(base);
    assert.equal(state3.phase, "summarizing", "after cache invalidation should show summarizing");
  });

  test("corrupt ROADMAP: binary content does not crash deriveState", async () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    mkdirSync(mDir, { recursive: true });
    writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Corrupt\n\n## Purpose\nTest.\n");
    // Write binary garbage as ROADMAP
    writeFileSync(join(mDir, "M001-ROADMAP.md"), Buffer.from([0x00, 0xFF, 0xFE, 0x89, 0x50, 0x4E, 0x47]));

    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Corrupt", status: "active" });

    invalidateAllCaches();
    // Should NOT throw — should degrade gracefully
    const state = await deriveStateFromDb(base);
    assert.ok(state.phase, "should produce a valid phase even with corrupt ROADMAP");
  });

  test("0-byte ROADMAP file is treated as no roadmap (pre-planning)", async () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    mkdirSync(mDir, { recursive: true });
    writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Empty\n\n## Purpose\nTest.\n");
    writeFileSync(join(mDir, "M001-ROADMAP.md"), "");

    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Empty", status: "active" });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);
    assert.equal(state.phase, "pre-planning", "empty ROADMAP should result in pre-planning");
  });

  test("ROADMAP with no ## Slices section derives pre-planning", async () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    mkdirSync(mDir, { recursive: true });
    writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: No Slices\n\n## Purpose\nTest.\n");
    writeFileSync(
      join(mDir, "M001-ROADMAP.md"),
      [
        "# M001: No Slices",
        "",
        "## Vision",
        "Test zero slices.",
        "",
        "## Success Criteria",
        "- Works",
        "",
        "## Slices",
        "",
        "## Boundary Map",
        "",
        "| From | To | Produces | Consumes |",
        "|------|----|----------|----------|",
      ].join("\n"),
    );

    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "No Slices", status: "active" });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);
    // 0-slice ROADMAP guard: should NOT derive validating-milestone (#2667)
    assert.notEqual(
      state.phase,
      "validating-milestone",
      "0-slice ROADMAP must NOT produce validating-milestone",
    );
  });

  test("corrupt VALIDATION frontmatter: extractVerdict returns undefined", () => {
    // Test the verdict parser directly with malformed content
    assert.equal(extractVerdict(""), undefined, "empty string → undefined");
    assert.equal(extractVerdict("---\n\n---\n# No verdict"), undefined, "empty frontmatter → undefined");
    assert.equal(extractVerdict("---\nverdict:\n---"), undefined, "verdict with no value → undefined");
    assert.equal(
      extractVerdict("random text without frontmatter"),
      undefined,
      "no frontmatter → undefined",
    );
  });

  test("VALIDATION with binary/garbage content: isValidationTerminal returns false", () => {
    assert.equal(isValidationTerminal(""), false, "empty → not terminal");
    assert.equal(isValidationTerminal("\x00\xFF\xFE"), false, "binary → not terminal");
    assert.equal(
      isValidationTerminal("---\ngarbage: yes\n---\nNo verdict here."),
      false,
      "no verdict field → not terminal",
    );
  });
});

// ─────────────────────────────────────────────────────────────────────────
// SECTION 2: Transition Boundary Failures
// ─────────────────────────────────────────────────────────────────────────

describe("transition boundary failures", () => {
  let base: string;

  afterEach(() => {
    try { closeDatabase(); } catch { /* may not be open */ }
    if (base) rmSync(base, { recursive: true, force: true });
  });

  test("mid-transition: CONTEXT.md created between derives transitions needs-discussion → pre-planning correctly", async () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    mkdirSync(mDir, { recursive: true });

    // Start with only CONTEXT-DRAFT → needs-discussion
    writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\nSome draft.\n");

    openDatabase(join(base, ".sf", "sf.db"));
    invalidateAllCaches();
    const state1 = await deriveState(base);
    assert.equal(state1.phase, "needs-discussion");

    // Now write the full CONTEXT (simulates discussion completion)
    writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Resolved\n\n## Purpose\nDone.\n");

    invalidateAllCaches();
    const state2 = await deriveState(base);
    // Should advance to pre-planning (has context but no roadmap yet)
    assert.equal(state2.phase, "pre-planning");
  });

  test("cascading slice dependencies: S02 depends S01, S03 depends S02 — only S01 eligible", async () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");

    // Create 3 slices with chain deps
    for (const sid of ["S01", "S02", "S03"]) {
      const sDir = join(mDir, "slices", sid, "tasks");
      mkdirSync(sDir, { recursive: true });
      writeFileSync(
        join(mDir, "slices", sid, `${sid}-PLAN.md`),
        [
          `# ${sid}: Feature`,
          "",
          "**Goal:** Do the thing.",
          "",
          "## Tasks",
          "",
          "- [ ] **T01: Task** `est:30m`",
          "  - Do: Implement",
          "  - Verify: Run tests",
        ].join("\n"),
      );
      writeFileSync(join(sDir, "T01-PLAN.md"), "# T01 Plan\nDo it.\n");
    }

    writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Chain\n\n## Purpose\nTest deps.\n");
    writeFileSync(
      join(mDir, "M001-ROADMAP.md"),
      [
        "# M001: Chain Deps",
        "",
        "## Vision",
        "Test cascading.",
        "",
        "## Success Criteria",
        "- Works",
        "",
        "## Slices",
        "",
        "- [ ] **S01: Base** `risk:low` `depends:[]`",
        "  - After this: Base done.",
        "",
        "- [ ] **S02: Middle** `risk:low` `depends:[S01]`",
        "  - After this: Middle done.",
        "",
        "- [ ] **S03: Top** `risk:low` `depends:[S02]`",
        "  - After this: Top done.",
        "",
        "## Boundary Map",
        "",
        "| From | To | Produces | Consumes |",
        "|------|----|----------|----------|",
        "| S01 | S02 | base | nothing |",
        "| S02 | S03 | middle | base |",
        "| S03 | terminal | top | middle |",
      ].join("\n"),
    );

    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Chain", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "Base", status: "pending", depends: [] });
    insertSlice({ id: "S02", milestoneId: "M001", title: "Middle", status: "pending", depends: ["S01"] });
    insertSlice({ id: "S03", milestoneId: "M001", title: "Top", status: "pending", depends: ["S02"] });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
    insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
    insertTask({ id: "T01", sliceId: "S03", milestoneId: "M001", status: "pending" });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);

    // Only S01 should be active — S02 and S03 are dep-blocked
    assert.equal(state.activeSlice?.id, "S01", "S01 should be the active slice (no deps)");
    assert.equal(state.phase, "executing", "should be executing S01");
  });

  test("cascading deps: completing S01 unblocks S02 (not S03)", async () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    for (const sid of ["S01", "S02", "S03"]) {
      const sDir = join(mDir, "slices", sid, "tasks");
      mkdirSync(sDir, { recursive: true });
      writeFileSync(
        join(mDir, "slices", sid, `${sid}-PLAN.md`),
        `# ${sid}\n\n**Goal:** Do.\n\n## Tasks\n\n- [ ] **T01: Task** \`est:30m\`\n  - Do: Impl\n  - Verify: Test\n`,
      );
      writeFileSync(join(sDir, "T01-PLAN.md"), `# T01 Plan\nDo it.\n`);
    }
    // Write slice SUMMARY for S01
    writeFileSync(
      join(mDir, "slices", "S01", "S01-SUMMARY.md"),
      "---\n---\n# S01 Summary\nDone.\n",
    );

    writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Chain\n\n## Purpose\nTest.\n");
    writeFileSync(
      join(mDir, "M001-ROADMAP.md"),
      [
        "# M001: Chain",
        "",
        "## Vision",
        "Test.",
        "",
        "## Success Criteria",
        "- Works",
        "",
        "## Slices",
        "",
        "- [x] **S01: Base** `risk:low` `depends:[]`",
        "  - After this: Done.",
        "",
        "- [ ] **S02: Middle** `risk:low` `depends:[S01]`",
        "  - After this: Done.",
        "",
        "- [ ] **S03: Top** `risk:low` `depends:[S02]`",
        "  - After this: Done.",
        "",
        "## Boundary Map",
        "",
        "| From | To | Produces | Consumes |",
        "|------|----|----------|----------|",
        "| S01 | S02 | x | nothing |",
        "| S02 | S03 | y | x |",
        "| S03 | terminal | z | y |",
      ].join("\n"),
    );

    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Chain", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "Base", status: "complete", depends: [] });
    insertSlice({ id: "S02", milestoneId: "M001", title: "Middle", status: "pending", depends: ["S01"] });
    insertSlice({ id: "S03", milestoneId: "M001", title: "Top", status: "pending", depends: ["S02"] });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
    insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
    insertTask({ id: "T01", sliceId: "S03", milestoneId: "M001", status: "pending" });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);

    // S01 complete → S02 unblocked → S02 should be active
    assert.equal(state.activeSlice?.id, "S02", "S02 should be active after S01 completes");
    assert.equal(state.phase, "executing");
  });

  test("multi-milestone deps: M002 depends M001, M003 depends M002 — blocked correctly", async () => {
    base = createMultiMilestoneFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "First", status: "active" });
    insertMilestone({ id: "M002", title: "Second", status: "active", depends_on: ["M001"] });
    insertMilestone({ id: "M003", title: "Third", status: "active", depends_on: ["M002"] });

    insertSlice({ id: "S01", milestoneId: "M001", title: "S01", status: "pending" });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
    insertSlice({ id: "S01", milestoneId: "M002", title: "S01", status: "pending" });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M002", status: "pending" });
    insertSlice({ id: "S01", milestoneId: "M003", title: "S01", status: "pending" });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M003", status: "pending" });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);

    // Only M001 should be active — M002 and M003 are blocked
    assert.equal(state.activeMilestone?.id, "M001", "M001 should be active (no deps)");
  });

  test("blocker_discovered in task transitions to replanning-slice", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", blockerDiscovered: true });
    insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);
    assert.equal(state.phase, "replanning-slice", "blocker_discovered should trigger replanning");
    assert.ok(state.blockers.length > 0, "should report blocker");
  });

  test("replan loop protection: replan already done skips replanning-slice", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", blockerDiscovered: true });
    insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });

    // Record that a replan was already done for this slice
    insertReplanHistory({
      milestoneId: "M001",
      sliceId: "S01",
      summary: "Already replanned once",
    });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);
    // With replan history, should NOT re-enter replanning-slice
    assert.notEqual(
      state.phase,
      "replanning-slice",
      "replan loop protection: should not re-enter replanning after replan was done",
    );
  });

  test("blocked state: all slices have unmet deps → fallback picks slice", async () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    mkdirSync(join(mDir, "slices", "S01", "tasks"), { recursive: true });
    mkdirSync(join(mDir, "slices", "S02", "tasks"), { recursive: true });

    writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\n## Purpose\nTest.\n");
    writeFileSync(
      join(mDir, "M001-ROADMAP.md"),
      [
        "# M001: Blocked",
        "",
        "## Vision",
        "Test blocked.",
        "",
        "## Success Criteria",
        "- Works",
        "",
        "## Slices",
        "",
        "- [ ] **S01: A** `risk:low` `depends:[S02]`",
        "  - After this: Done.",
        "",
        "- [ ] **S02: B** `risk:low` `depends:[S01]`",
        "  - After this: Done.",
        "",
        "## Boundary Map",
        "",
        "| From | To | Produces | Consumes |",
        "|------|----|----------|----------|",
        "| S01 | S02 | a | b |",
        "| S02 | S01 | b | a |",
      ].join("\n"),
    );

    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Blocked", status: "active" });
    // Circular deps: S01→S02 and S02→S01 — both blocked
    insertSlice({ id: "S01", milestoneId: "M001", title: "A", status: "pending", depends: ["S02"] });
    insertSlice({ id: "S02", milestoneId: "M001", title: "B", status: "pending", depends: ["S01"] });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
    insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);
    // With partial-dep fallback, circular deps no longer block — fallback picks first eligible slice
    assert.equal(state.phase, "planning", "circular deps: fallback picks a slice instead of blocking");
    assert.ok(state.activeSlice !== null, "activeSlice set via fallback");
  });
});

// ─────────────────────────────────────────────────────────────────────────
// SECTION 3: Dispatch Failure Modes
// ─────────────────────────────────────────────────────────────────────────

describe("dispatch failure modes", () => {
  let base: string;

  afterEach(() => {
    try { closeDatabase(); } catch { /* may not be open */ }
    if (base) rmSync(base, { recursive: true, force: true });
  });

  test("dispatch with null activeSlice in executing phase → stop (error)", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "executing",
      activeSlice: null,
      activeTask: { id: "T01", title: "Task" },
    });

    // The "executing → execute-task (recover missing task plan)" rule checks activeSlice
    // and returns missingSliceStop when null
    const result = await resolveDispatch(ctx);
    assert.equal(result.action, "stop", "null activeSlice in executing should stop");
  });

  test("dispatch for unhandled phase → stop with diagnostic", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "paused" as any,
      activeSlice: null,
      activeTask: null,
    });

    const result = await resolveDispatch(ctx);
    assert.equal(result.action, "stop", "unhandled phase should produce stop action");
  });

  test("dispatch: summarizing with null activeSlice → stop (error)", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "summarizing",
      activeSlice: null,
      activeTask: null,
    });

    const result = await resolveDispatch(ctx);
    assert.equal(result.action, "stop", "summarizing without activeSlice should stop");
    assert.ok(
      (result as any).reason?.includes("no active slice"),
      "stop reason should mention missing slice",
    );
  });

  test("dispatch: evaluating-gates without gate config → skip (gates omitted)", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "evaluating-gates",
      activeSlice: { id: "S01", title: "First" },
      activeTask: null,
    });
    ctx.prefs = undefined; // No prefs → gate_evaluation not enabled

    const result = await resolveDispatch(ctx);
    // Without gate config, the rule should skip (gates omitted)
    assert.ok(
      result.action === "skip" || result.action === "stop",
      `evaluating-gates without config should skip or stop, got: ${result.action}`,
    );
  });

  test("dispatch: needs-discussion → discuss-milestone dispatch", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "needs-discussion",
      activeSlice: null,
      activeTask: null,
    });

    const result = await resolveDispatch(ctx);
    assert.equal(result.action, "dispatch");
    assert.equal((result as any).unitType, "discuss-milestone");
  });

  test("dispatch: incomplete milestone roadmap re-runs plan-milestone instead of missing-slice stop", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "planning",
      activeSlice: null,
      activeTask: null,
      nextAction: "Milestone M001 roadmap is incomplete (missing vision alignment meeting). Re-run plan-milestone with a weighted vision alignment meeting before execution.",
    });

    const result = await resolveDispatch(ctx);
    assert.equal(result.action, "dispatch");
    assert.equal((result as any).unitType, "plan-milestone");
    assert.equal((result as any).unitId, "M001");
    assert.equal((result as any).matchedRule, "planning (roadmap incomplete) → plan-milestone");
  });

  test("dispatch: complete phase → stop with info level", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "complete",
      activeSlice: null,
      activeTask: null,
    });

    const result = await resolveDispatch(ctx);
    assert.equal(result.action, "stop");
    assert.equal((result as any).level, "info");
    assert.ok((result as any).reason?.includes("complete"), "reason should mention completion");
  });

  test("dispatch rule order: first match wins for overlapping rules", () => {
    const ruleNames = getDispatchRuleNames();
    // Verify critical ordering constraints
    const summarizeIdx = ruleNames.indexOf("summarizing → complete-slice");
    const runUatIdx = ruleNames.indexOf("run-uat (post-completion)");
    const uatGateIdx = ruleNames.indexOf("uat-verdict-gate (non-PASS blocks progression)");
    const executeIdx = ruleNames.indexOf("executing → execute-task");
    const repairIdx = ruleNames.indexOf("planning (roadmap incomplete) → plan-milestone");
    const planSliceIdx = ruleNames.indexOf("planning → plan-slice");

    // summarizing should come before execute-task
    assert.ok(summarizeIdx < executeIdx, "summarizing rule should precede execute-task");
    // run-uat should come before uat-verdict-gate
    assert.ok(runUatIdx < uatGateIdx, "run-uat should precede uat-verdict-gate");
    assert.ok(repairIdx < planSliceIdx, "milestone-plan repair should precede slice planning");
  });
});

// ─────────────────────────────────────────────────────────────────────────
// SECTION 4: Completion & Verification Failures
// ─────────────────────────────────────────────────────────────────────────

describe("completion and verification failures", () => {
  let base: string;

  afterEach(() => {
    try { closeDatabase(); } catch { /* may not be open */ }
    if (base) rmSync(base, { recursive: true, force: true });
  });

  test("needs-remediation VALIDATION blocks milestone completion dispatch", async () => {
    base = createFullFixture();
    const mDir = join(base, ".sf", "milestones", "M001");
    writeFileSync(
      join(mDir, "M001-VALIDATION.md"),
      [
        "---",
        "verdict: needs-remediation",
        "remediation_round: 1",
        "---",
        "",
        "# Validation",
        "",
        "Needs remediation work.",
      ].join("\n"),
    );

    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
    insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "completing-milestone",
      activeSlice: null,
      activeTask: null,
    });

    const result = await resolveDispatch(ctx);
    assert.equal(result.action, "stop", "needs-remediation should block completion");
    assert.ok(
      (result as any).reason?.includes("needs-remediation"),
      "stop reason should mention needs-remediation",
    );
  });

  test("missing slice SUMMARY blocks milestone validation dispatch", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });
    // Use "pending" status — closed slices (complete/done/skipped) are
    // excluded from SUMMARY checks per #3620.
    insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "pending" });
    insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "pending" });
    // No S01-SUMMARY.md or S02-SUMMARY.md on disk

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "validating-milestone",
      activeSlice: null,
      activeTask: null,
    });

    const result = await resolveDispatch(ctx);
    assert.equal(result.action, "stop", "missing SUMMARY files should block validation");
    assert.ok(
      (result as any).reason?.includes("missing SUMMARY"),
      "stop reason should mention missing SUMMARY",
    );
  });

  test("VALIDATION with pass verdict: isValidationTerminal returns true", () => {
    const content = "---\nverdict: pass\nremediation_round: 0\n---\n# Pass\n";
    assert.equal(isValidationTerminal(content), true);
  });

  test("VALIDATION with needs-attention: isValidationTerminal returns true", () => {
    const content = "---\nverdict: needs-attention\n---\n# Attention\n";
    assert.equal(isValidationTerminal(content), true);
  });

  test("VALIDATION with needs-remediation: isValidationTerminal returns true (terminal for loop prevention)", () => {
    // Per #832: needs-remediation IS terminal to prevent validate-milestone loops
    const content = "---\nverdict: needs-remediation\nremediation_round: 1\n---\n# Remediate\n";
    assert.equal(isValidationTerminal(content), true);
  });

  test("UAT verdict gate: non-PASS verdict blocks progression", () => {
    assert.equal(isAcceptableUatVerdict("pass", undefined), true);
    assert.equal(isAcceptableUatVerdict("passed", undefined), true);
    assert.equal(isAcceptableUatVerdict("fail", undefined), false);
    assert.equal(isAcceptableUatVerdict("needs-remediation", undefined), false);
    assert.equal(isAcceptableUatVerdict("partial", undefined), false, "partial without eligible type → not acceptable");
    assert.equal(isAcceptableUatVerdict("partial", "mixed"), true, "partial with mixed type → acceptable");
    assert.equal(isAcceptableUatVerdict("partial", "human-experience"), true, "partial with human-experience → acceptable");
    assert.equal(isAcceptableUatVerdict("partial", "artifact-driven"), false, "partial with artifact-driven → not acceptable");
  });

  test("milestone validation verdict schema validation", () => {
    assert.equal(isValidMilestoneVerdict("pass"), true);
    assert.equal(isValidMilestoneVerdict("needs-attention"), true);
    assert.equal(isValidMilestoneVerdict("needs-remediation"), true);
    assert.equal(isValidMilestoneVerdict("fail"), false, "fail is not a valid milestone verdict");
    assert.equal(isValidMilestoneVerdict(""), false);
    assert.equal(isValidMilestoneVerdict("unknown"), false);
  });

  test("all slices done + no VALIDATION → validating-milestone (not completing)", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
    insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
    insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete" });
    insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "complete" });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);
    assert.equal(
      state.phase,
      "validating-milestone",
      "all slices done without VALIDATION should be validating-milestone",
    );
  });

  test("all slices done + terminal VALIDATION + no SUMMARY → completing-milestone", async () => {
    base = createFullFixture();
    writeFileSync(
      join(base, ".sf", "milestones", "M001", "M001-VALIDATION.md"),
      "---\nverdict: pass\n---\n# Validation\nPassed.\n",
    );

    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
    insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
    insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
    insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete" });
    insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "complete" });

    invalidateAllCaches();
    const state = await deriveStateFromDb(base);
    assert.equal(
      state.phase,
      "completing-milestone",
      "terminal VALIDATION without SUMMARY should be completing-milestone",
    );
  });

  test("extractVerdict: markdown body fallback works", () => {
    // When LLM writes verdict in body instead of frontmatter (#2960)
    assert.equal(extractVerdict("# Validation\n\n**Verdict:** PASS"), "pass");
    assert.equal(extractVerdict("# Validation\n\n**Verdict:** ✅ PASS"), "pass");
    assert.equal(extractVerdict("# Validation\n\n**Verdict** needs-remediation"), "needs-remediation");
  });

  test("extractVerdict: normalizes 'passed' to 'pass'", () => {
    assert.equal(extractVerdict("---\nverdict: passed\n---"), "pass");
    assert.equal(extractVerdict("**Verdict:** passed"), "pass");
  });

  test("isClosedStatus: boundary values", () => {
    assert.equal(isClosedStatus("complete"), true);
    assert.equal(isClosedStatus("done"), true);
    assert.equal(isClosedStatus("skipped"), true);
    assert.equal(isClosedStatus("active"), false);
    assert.equal(isClosedStatus("pending"), false);
    assert.equal(isClosedStatus("in_progress"), false);
    assert.equal(isClosedStatus(""), false);
    assert.equal(isClosedStatus("COMPLETE"), false, "case-sensitive: uppercase should be false");
  });
});

// ─────────────────────────────────────────────────────────────────────────
// SECTION 5: Ghost Milestone Edge Cases
// ─────────────────────────────────────────────────────────────────────────

describe("ghost milestone edge cases", () => {
  let base: string;

  afterEach(() => {
    try { closeDatabase(); } catch { /* may not be open */ }
    if (base) rmSync(base, { recursive: true, force: true });
  });

  test("empty directory with DB row is NOT a ghost (#2921)", () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    mkdirSync(mDir, { recursive: true });

    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Queued", status: "active" });

    assert.equal(isGhostMilestone(base, "M001"), false, "DB row means not a ghost");
  });

  test("empty directory with worktree is NOT a ghost (#2921)", () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    mkdirSync(mDir, { recursive: true });
    // Simulate worktree existence
    mkdirSync(join(base, ".sf", "worktrees", "M001"), { recursive: true });

    assert.equal(isGhostMilestone(base, "M001"), false, "worktree means not a ghost");
  });

  test("empty directory without DB or worktree IS a ghost", () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    mkdirSync(mDir, { recursive: true });

    assert.equal(isGhostMilestone(base, "M001"), true, "no DB, no worktree, no files → ghost");
  });

  test("directory with only META.json is still a ghost", () => {
    base = makeTempDir();
    const mDir = join(base, ".sf", "milestones", "M001");
    mkdirSync(mDir, { recursive: true });
    writeFileSync(join(mDir, "META.json"), '{"created":"2026-01-01"}');

    assert.equal(isGhostMilestone(base, "M001"), true, "META.json alone → ghost");
  });

  test("ghost milestones are skipped in state derivation", async () => {
    base = makeTempDir();
    const sfDir = join(base, ".sf", "milestones");

    // M001 is ghost — empty dir
    mkdirSync(join(sfDir, "M001"), { recursive: true });

    // M002 is real — has CONTEXT-DRAFT
    mkdirSync(join(sfDir, "M002"), { recursive: true });
    writeFileSync(join(sfDir, "M002", "M002-CONTEXT-DRAFT.md"), "# Draft\nContent.\n");

    invalidateAllCaches();
    const state = await deriveState(base);
    assert.equal(state.activeMilestone?.id, "M002", "ghost M001 skipped, M002 is active");
  });
});

// ─────────────────────────────────────────────────────────────────────────
// SECTION 6: Dispatch Guard Integration
// ─────────────────────────────────────────────────────────────────────────

describe("dispatch guard integration", () => {
  let base: string;

  afterEach(() => {
    try { closeDatabase(); } catch { /* may not be open */ }
    if (base) rmSync(base, { recursive: true, force: true });
  });

  test("skip_milestone_validation preference writes pass-through VALIDATION", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });
    insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
    insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
    // Write slice SUMMARYs so the missing SUMMARY guard doesn't fire
    writeFileSync(
      join(base, ".sf", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"),
      "# S01 Summary\nDone.\n",
    );
    writeFileSync(
      join(base, ".sf", "milestones", "M001", "slices", "S02", "S02-SUMMARY.md"),
      "# S02 Summary\nDone.\n",
    );

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "validating-milestone",
      activeSlice: null,
      activeTask: null,
    });
    ctx.prefs = { phases: { skip_milestone_validation: true } } as any;

    const result = await resolveDispatch(ctx);
    assert.equal(result.action, "skip", "skip_milestone_validation should produce skip action");

    // Should have written a pass-through VALIDATION file
    const validationPath = join(base, ".sf", "milestones", "M001", "M001-VALIDATION.md");
    assert.ok(existsSync(validationPath), "VALIDATION file should be written");
    const content = readFileSync(validationPath, "utf-8");
    assert.ok(content.includes("verdict: pass"), "should contain pass verdict");
    assert.ok(content.includes("skipped by preference"), "should note it was skipped");
  });

  test("rewrite-docs circuit breaker: exceeding MAX attempts resolves all overrides", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));
    insertMilestone({ id: "M001", title: "Active", status: "active" });

    // Write a rewrite count at the max
    const runtimeDir = join(base, ".sf", "runtime");
    mkdirSync(runtimeDir, { recursive: true });
    writeFileSync(
      join(runtimeDir, "rewrite-count.json"),
      JSON.stringify({ count: 3, updatedAt: new Date().toISOString() }),
    );

    // Import and check
    const { getRewriteCount } = await import("../../auto-dispatch.ts");
    assert.equal(getRewriteCount(base), 3, "rewrite count should be 3");
  });

  test("replanning-slice with null activeSlice → stop (error)", async () => {
    base = createFullFixture();
    openDatabase(join(base, ".sf", "sf.db"));

    const ctx = buildDispatchCtx(base, "M001", {
      phase: "replanning-slice",
      activeSlice: null,
      activeTask: null,
    });

    const result = await resolveDispatch(ctx);
    assert.equal(result.action, "stop", "replanning without activeSlice should stop");
  });
});
