import { describe, test, expect, afterAll } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import git from "isomorphic-git";
import {
  IsogitStore,
  initAgentRepo,
  createMailAuditStore,
} from "@intx/storage-isogit";
import type { ConversationTurn } from "@intx/types/runtime";
import type { ErrorRecord } from "@intx/types/audit";
import { reconstructTimeline } from "./timeline-reconstruction";

const tempDirs: string[] = [];

async function makeTempDir(): Promise<string> {
  const d = await fs.promises.mkdtemp(
    path.join(os.tmpdir(), "timeline-recon-"),
  );
  tempDirs.push(d);
  return d;
}

afterAll(async () => {
  for (const d of tempDirs) {
    await fs.promises.rm(d, { recursive: true, force: true });
  }
});

function userMessage(text: string, timestamp = Date.now()): ConversationTurn {
  return { role: "user", content: [{ type: "text", text }], timestamp };
}

function assistantMessage(
  text: string,
  timestamp = Date.now(),
): ConversationTurn {
  return {
    role: "assistant",
    content: [{ type: "text", text }],
    model: "test-model",
    timestamp,
  };
}

function toolCallMessage(
  callId: string,
  name: string,
  args: Record<string, unknown>,
  timestamp = Date.now(),
): ConversationTurn {
  return {
    role: "assistant",
    content: [{ type: "tool_call", id: callId, name, arguments: args }],
    model: "test-model",
    timestamp,
  };
}

function toolResultMessage(
  callId: string,
  result: string,
  timestamp = Date.now(),
): ConversationTurn {
  return {
    role: "user",
    content: [
      {
        type: "tool_result",
        callId,
        content: [{ type: "text", text: result }],
      },
    ],
    timestamp,
  };
}

function buildRawMessage(opts: {
  messageId: string;
  from?: string;
  to?: string;
  inReplyTo?: string;
  body?: string;
}): Uint8Array {
  const lines: string[] = [];
  lines.push(`Message-ID: ${opts.messageId}`);
  lines.push(`From: ${opts.from ?? "sender@example.com"}`);
  lines.push(`To: ${opts.to ?? "recipient@example.com"}`);
  lines.push(`Date: ${new Date().toUTCString()}`);
  if (opts.inReplyTo !== undefined) {
    lines.push(`In-Reply-To: ${opts.inReplyTo}`);
  }
  lines.push("");
  lines.push(opts.body ?? "test body");
  return new TextEncoder().encode(lines.join("\r\n"));
}

describe("reconstructTimeline", () => {
  test("reconstructs a single-turn conversation", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    const t = 1700000000000;
    const messages: ConversationTurn[] = [
      userMessage("Hello", t),
      assistantMessage("Hi there", t + 1000),
    ];
    await store.writeTurns(messages);

    await store.commit({ message: "checkpoint: inference-done" });

    const result = await reconstructTimeline(dir);

    const turns = result.events.filter((e) => e.kind === "turn");
    expect(turns).toHaveLength(1);
    expect(turns[0]?.content).toBe("Hi there");
    // Should use the per-message timestamp, not the git commit timestamp
    expect(turns[0]?.timestamp).toBe(t + 1000);
    // Status derived from checkpoint reason
    expect(turns[0]?.kind === "turn" && turns[0].status).toBe("completed");
  });

  test("surfaces refusal-only assistant turns in the timeline summary", async () => {
    // A turn whose only content is a RefusalBlock (OpenAI strict-
    // mode policy decline) must appear on the timeline with the
    // refusal text as the turn content. Filtering to text-only
    // blocks would render the turn invisible even though the model
    // produced coherent output.
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    const t = 1700000000000;
    const messages: ConversationTurn[] = [
      userMessage("Tell me how to do something policy-tripping.", t),
      {
        role: "assistant",
        content: [{ type: "refusal", reason: "I cannot help with that." }],
        model: "gpt-test",
        timestamp: t + 1000,
      },
    ];
    await store.writeTurns(messages);

    await store.commit({ message: "checkpoint: inference-done" });

    const result = await reconstructTimeline(dir);
    const turns = result.events.filter((e) => e.kind === "turn");
    expect(turns).toHaveLength(1);
    expect(turns[0]?.content).toBe("I cannot help with that.");
    expect(turns[0]?.timestamp).toBe(t + 1000);
  });

  test("surfaces safety-only assistant turns in the timeline summary", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    const t = 1700000000000;
    const messages: ConversationTurn[] = [
      userMessage("Describe prohibited content.", t),
      {
        role: "assistant",
        content: [{ type: "safety_rating", blockReason: "PROHIBITED_CONTENT" }],
        model: "gemini-2.5-flash",
        timestamp: t + 1000,
      },
    ];
    await store.writeTurns(messages);

    await store.commit({ message: "checkpoint: inference-done" });

    const result = await reconstructTimeline(dir);
    const turns = result.events.filter((e) => e.kind === "turn");
    expect(turns).toHaveLength(1);
    expect(turns[0]?.content).toBe("Request blocked: PROHIBITED_CONTENT");
    expect(turns[0]?.timestamp).toBe(t + 1000);
  });

  test("reconstructs multi-turn conversations across checkpoints", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    const t = 1700000000000;

    // First turn
    const messages1: ConversationTurn[] = [
      userMessage("Hello", t),
      assistantMessage("Hi there", t + 1000),
    ];
    await store.writeTurns(messages1);

    await store.commit({ message: "checkpoint: inference-done" });

    // Second turn (appends to the same message array)
    const messages2: ConversationTurn[] = [
      ...messages1,
      userMessage("How are you?", t + 5000),
      assistantMessage("I'm doing well", t + 6000),
    ];
    await store.writeTurns(messages2);

    await store.commit({ message: "checkpoint: inference-done" });

    const result = await reconstructTimeline(dir);

    const turns = result.events.filter((e) => e.kind === "turn");
    expect(turns).toHaveLength(2);
    expect(turns[0]?.content).toBe("Hi there");
    expect(turns[1]?.content).toBe("I'm doing well");

    // Should use per-message timestamps
    expect(turns[0]?.timestamp).toBe(t + 1000);
    expect(turns[1]?.timestamp).toBe(t + 6000);
  });

  test("treats a tool-use loop as a single turn", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    const messages: ConversationTurn[] = [
      userMessage("What's the weather?"),
      toolCallMessage("call-1", "get_weather", { city: "SF" }),
      toolResultMessage("call-1", "72F and sunny"),
      assistantMessage("The weather in SF is 72F and sunny."),
    ];
    await store.writeTurns(messages);

    await store.commit({ message: "checkpoint: inference-done" });

    const result = await reconstructTimeline(dir);

    const turns = result.events.filter((e) => e.kind === "turn");
    expect(turns).toHaveLength(1);
    expect(turns[0]?.content).toBe("The weather in SF is 72F and sunny.");
  });

  test("reconstructs mail events", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);

    const mailStore = await createMailAuditStore(dir);

    const inbound = buildRawMessage({
      messageId: "<msg-1@test>",
      from: "alice@example.com",
      to: "agent@example.com",
      body: "Please help me",
    });
    await mailStore.commitMail(inbound, "in");

    const outbound = buildRawMessage({
      messageId: "<msg-2@test>",
      from: "agent@example.com",
      to: "alice@example.com",
      inReplyTo: "<msg-1@test>",
      body: "Sure, how can I help?",
    });
    await mailStore.commitMail(outbound, "out");

    const result = await reconstructTimeline(dir);

    const mailEvents = result.events.filter((e) => e.kind === "mail");
    expect(mailEvents).toHaveLength(2);

    const inboundEvent = mailEvents.find(
      (e) => e.kind === "mail" && e.direction === "in",
    );
    expect(inboundEvent).toBeDefined();
    if (inboundEvent !== undefined && inboundEvent.kind === "mail") {
      expect(inboundEvent.messageId).toBe("<msg-1@test>");
    }

    const outboundEvent = mailEvents.find(
      (e) => e.kind === "mail" && e.direction === "out",
    );
    expect(outboundEvent).toBeDefined();
    if (outboundEvent !== undefined && outboundEvent.kind === "mail") {
      expect(outboundEvent.messageId).toBe("<msg-2@test>");
    }
  });

  test("links outbound mail to checkpoint via Checkpoint trailer", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    const t = 1700000000000;
    const messages: ConversationTurn[] = [
      userMessage("Hello", t),
      assistantMessage("Hi there", t + 1000),
    ];
    await store.writeTurns(messages);

    const commit = await store.commit({
      message: "checkpoint: inference-done",
    });

    const mailStore = await createMailAuditStore(dir);
    const outbound = buildRawMessage({
      messageId: "<reply@test>",
      from: "agent@example.com",
      to: "alice@example.com",
      body: "Hi there",
    });
    await mailStore.commitMail(outbound, "out", {
      checkpointHash: commit.hash,
    });

    const result = await reconstructTimeline(dir);

    const mailEvents = result.events.filter((e) => e.kind === "mail");
    expect(mailEvents).toHaveLength(1);
    const mailEvent = mailEvents[0];
    expect(mailEvent).toBeDefined();
    if (mailEvent !== undefined && mailEvent.kind === "mail") {
      expect(mailEvent.checkpointHash).toBe(commit.hash);
    }
  });

  test("associates error records from git with the preceding turn", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    // Write a conversation first
    const messages: ConversationTurn[] = [
      userMessage("Do something risky"),
      assistantMessage("Attempting..."),
    ];
    await store.writeTurns(messages);

    await store.commit({ message: "checkpoint: inference-done" });

    // Write error records (committed to git by the store)
    const errors: ErrorRecord[] = [
      {
        source: "inference",
        category: "rate_limit",
        message: "Rate limit exceeded",
        fatal: false,
        timestamp: new Date().toISOString(),
        sessionId: "test-session",
        seq: 0,
      },
    ];
    await store.commitErrors(errors);

    const result = await reconstructTimeline(dir);

    // Errors should be attached to the preceding turn, not a synthetic one
    const turns = result.events.filter((e) => e.kind === "turn");
    expect(turns).toHaveLength(1);
    const turn = turns[0];
    expect(turn?.kind === "turn" && turn.isError).toBe(true);
    expect(turn?.kind === "turn" && turn.errors).toHaveLength(1);
    if (turn?.kind === "turn") {
      expect(turn.errors?.[0]?.category).toBe("rate_limit");
    }
  });

  test("handles message-count regression gracefully", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    // First checkpoint with 4 messages
    const messages1: ConversationTurn[] = [
      userMessage("Hello"),
      assistantMessage("Hi"),
      userMessage("More"),
      assistantMessage("Sure"),
    ];
    await store.writeTurns(messages1);

    await store.commit({ message: "checkpoint: inference-done" });

    // Second checkpoint with only 2 messages (regression)
    const messages2: ConversationTurn[] = [
      userMessage("Fresh start"),
      assistantMessage("OK"),
    ];
    await store.writeTurns(messages2);

    await store.commit({ message: "checkpoint: inference-done" });

    const result = await reconstructTimeline(dir);

    // Should not crash — should produce a gap record
    const regressionGap = result.gaps.find(
      (g) => g.kind === "message-count-regression",
    );
    expect(regressionGap).toBeDefined();

    // Should still produce turn events from what it can reconstruct
    const turns = result.events.filter((e) => e.kind === "turn");
    expect(turns.length).toBeGreaterThan(0);
  });

  test("interleaves mail and turn events by timestamp", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);
    const mailStore = await createMailAuditStore(dir);

    // Inbound mail first
    const inbound = buildRawMessage({
      messageId: "<msg-1@test>",
      body: "Hello agent",
    });
    await mailStore.commitMail(inbound, "in");

    // Then a turn
    const messages: ConversationTurn[] = [
      userMessage("Hello"),
      assistantMessage("Hi there"),
    ];
    await store.writeTurns(messages);

    await store.commit({ message: "checkpoint: inference-done" });

    const result = await reconstructTimeline(dir);

    // Both kinds should be present
    const mailEvents = result.events.filter((e) => e.kind === "mail");
    const turnEvents = result.events.filter((e) => e.kind === "turn");
    expect(mailEvents).toHaveLength(1);
    expect(turnEvents).toHaveLength(1);

    // Mail should come before turn (committed first)
    if (result.events[0] !== undefined && result.events[1] !== undefined) {
      expect(result.events[0].timestamp).toBeLessThanOrEqual(
        result.events[1].timestamp,
      );
    }
  });

  test("produces no gaps for a well-formed checkpoint", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    const t = 1700000000000;
    const messages: ConversationTurn[] = [
      userMessage("Hello", t),
      assistantMessage("Hi", t + 1000),
    ];
    await store.writeTurns(messages);

    await store.commit({ message: "checkpoint: inference-done" });

    const result = await reconstructTimeline(dir);

    expect(result.gaps).toHaveLength(0);
  });

  test("marks turns as error when checkpoint reason is inference-error", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    const t = 1700000000000;
    const messages: ConversationTurn[] = [
      userMessage("Hello", t),
      assistantMessage("Something went wrong", t + 1000),
    ];
    await store.writeTurns(messages);

    await store.commit({ message: "checkpoint: inference-error" });

    const result = await reconstructTimeline(dir);

    const turns = result.events.filter((e) => e.kind === "turn");
    expect(turns).toHaveLength(1);
    expect(turns[0]?.kind === "turn" && turns[0].status).toBe("error");
    expect(turns[0]?.kind === "turn" && turns[0].isError).toBe(true);
  });

  test("marks turns as in-progress for mid-turn checkpoints", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    const t = 1700000000000;
    const messages: ConversationTurn[] = [
      userMessage("What's the weather?", t),
      toolCallMessage("call-1", "get_weather", { city: "SF" }, t + 1000),
    ];
    await store.writeTurns(messages);

    await store.commit({ message: "checkpoint: tool-execution" });

    // Add tool result and final response
    const messages2: ConversationTurn[] = [
      ...messages,
      toolResultMessage("call-1", "72F", t + 2000),
      assistantMessage("It's 72F in SF", t + 3000),
    ];
    await store.writeTurns(messages2);

    await store.commit({ message: "checkpoint: inference-done" });

    const result = await reconstructTimeline(dir);

    const turns = result.events.filter((e) => e.kind === "turn");
    // tool-execution checkpoint has no text content, so no turn emitted
    // inference-done checkpoint has the final response
    expect(turns).toHaveLength(1);
    expect(turns[0]?.kind === "turn" && turns[0].status).toBe("completed");
  });

  test("handles empty repo with no checkpoints", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);

    const result = await reconstructTimeline(dir);

    expect(result.events).toHaveLength(0);
    expect(result.gaps).toBeDefined();
  });

  test("records a gap when a checkpoint commit has corrupt context", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    // Write a valid checkpoint first
    const messages: ConversationTurn[] = [
      userMessage("Hello"),
      assistantMessage("Hi"),
    ];
    await store.writeTurns(messages);

    await store.commit({ message: "checkpoint: inference-done" });

    // Write a corrupt checkpoint directly via git
    const turnsPath = path.join(dir, "turns.jsonl");
    await fs.promises.writeFile(turnsPath, "NOT VALID JSON");
    await git.add({ fs, dir, filepath: "turns.jsonl" });
    await git.commit({
      fs,
      dir,
      message: "checkpoint: inference-done",
      author: { name: "test", email: "test@test.dev" },
    });

    const result = await reconstructTimeline(dir);

    // Should still have the valid turn from the first checkpoint
    const turns = result.events.filter((e) => e.kind === "turn");
    expect(turns).toHaveLength(1);

    // Should record a gap for the corrupt checkpoint
    const corruptGaps = result.gaps.filter(
      (g) => g.kind === "corrupt-checkpoint",
    );
    expect(corruptGaps).toHaveLength(1);
  });

  test("records gaps for corrupt error record files in git", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);

    await store.writeTurns([userMessage("Hello"), assistantMessage("Hi")]);
    await store.commit({ message: "checkpoint: inference-done" });

    // Commit a valid error record via the store
    await store.commitErrors([
      {
        source: "inference",
        category: "rate_limit",
        message: "Rate limited",
        fatal: false,
        timestamp: new Date().toISOString(),
        sessionId: "test-session",
        seq: 0,
      },
    ]);

    // Manually commit a corrupt error file directly via git
    const errorsDir = path.join(dir, "state/errors/test-session");
    await fs.promises.writeFile(
      path.join(errorsDir, "00000001-corrupt.json"),
      "NOT VALID JSON",
    );
    await git.add({
      fs,
      dir,
      filepath: "state/errors/test-session/00000001-corrupt.json",
    });
    await git.commit({
      fs,
      dir,
      message: "Record 1 error record",
      author: { name: "test", email: "test@test.dev" },
    });

    const result = await reconstructTimeline(dir);

    // Valid error should be associated with the preceding turn
    const turns = result.events.filter((e) => e.kind === "turn");
    expect(turns).toHaveLength(1);
    const turn = turns[0];
    expect(turn?.kind === "turn" && turn.isError).toBe(true);
    expect(turn?.kind === "turn" && turn.errors?.length).toBeGreaterThanOrEqual(
      1,
    );

    // Corrupt file should produce a gap
    const corruptGaps = result.gaps.filter(
      (g) => g.kind === "corrupt-error-record",
    );
    expect(corruptGaps).toHaveLength(1);
    expect(corruptGaps[0]?.description).toContain("00000001-corrupt.json");
  });

  test("reconstructs a full multi-step session from git", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);
    const store = new IsogitStore(dir);
    const mailStore = await createMailAuditStore(dir);

    const t = 1700000000000;

    // 1. Inbound mail arrives
    const inbound = buildRawMessage({
      messageId: "<inbound-1@test>",
      from: "alice@example.com",
      to: "agent@example.com",
      body: "What is the weather in SF and NYC?",
    });
    await mailStore.commitMail(inbound, "in");

    // 2. First inference: agent decides to call a tool
    const msgs1: ConversationTurn[] = [
      userMessage("What is the weather in SF and NYC?", t),
      toolCallMessage("call-1", "get_weather", { city: "SF" }, t + 1000),
    ];
    await store.writeTurns(msgs1);

    await store.commit({ message: "checkpoint: tool-execution" });

    // 3. Tool result comes back
    const msgs2: ConversationTurn[] = [
      ...msgs1,
      toolResultMessage("call-1", "72F and sunny", t + 2000),
    ];
    await store.writeTurns(msgs2);

    await store.commit({ message: "checkpoint: tool-done" });

    // 4. Second inference: agent calls another tool
    const msgs3: ConversationTurn[] = [
      ...msgs2,
      toolCallMessage("call-2", "get_weather", { city: "NYC" }, t + 3000),
    ];
    await store.writeTurns(msgs3);

    await store.commit({ message: "checkpoint: tool-execution" });

    // 5. Second tool result
    const msgs4: ConversationTurn[] = [
      ...msgs3,
      toolResultMessage("call-2", "55F and rainy", t + 4000),
    ];
    await store.writeTurns(msgs4);

    await store.commit({ message: "checkpoint: tool-done" });

    // 6. Final inference: agent composes reply
    const msgs5: ConversationTurn[] = [
      ...msgs4,
      assistantMessage("SF is 72F and sunny. NYC is 55F and rainy.", t + 5000),
    ];
    await store.writeTurns(msgs5);

    const finalCommit = await store.commit({
      message: "checkpoint: inference-done",
    });

    // 7. Outbound mail sent with checkpoint linkage
    const outbound = buildRawMessage({
      messageId: "<outbound-1@test>",
      from: "agent@example.com",
      to: "alice@example.com",
      inReplyTo: "<inbound-1@test>",
      body: "SF is 72F and sunny. NYC is 55F and rainy.",
    });
    await mailStore.commitMail(outbound, "out", {
      checkpointHash: finalCommit.hash,
    });

    // 8. An error occurs on a follow-up inference
    const msgs6: ConversationTurn[] = [
      ...msgs5,
      userMessage("What about London?", t + 10000),
      assistantMessage("Let me check London weather.", t + 11000),
    ];
    await store.writeTurns(msgs6);

    await store.commit({ message: "checkpoint: inference-error" });

    // 9. Error record committed
    await store.commitErrors([
      {
        source: "inference",
        category: "rate_limit",
        message: "Rate limited by provider",
        fatal: false,
        timestamp: new Date(t + 11000).toISOString(),
        sessionId: "test-session",
        seq: 0,
      },
    ]);

    // --- Reconstruct and verify ---
    const result = await reconstructTimeline(dir);

    // No gaps — all data is well-formed and linked
    expect(result.gaps).toHaveLength(0);

    // Mail events
    const mailEvents = result.events.filter((e) => e.kind === "mail");
    expect(mailEvents).toHaveLength(2);

    const inboundMail = mailEvents.find(
      (e) => e.kind === "mail" && e.direction === "in",
    );
    expect(inboundMail).toBeDefined();
    if (inboundMail !== undefined && inboundMail.kind === "mail") {
      expect(inboundMail.messageId).toBe("<inbound-1@test>");
    }

    const outboundMail = mailEvents.find(
      (e) => e.kind === "mail" && e.direction === "out",
    );
    expect(outboundMail).toBeDefined();
    if (outboundMail !== undefined && outboundMail.kind === "mail") {
      expect(outboundMail.messageId).toBe("<outbound-1@test>");
      expect(outboundMail.checkpointHash).toBe(finalCommit.hash);
    }

    // Turn events
    const turns = result.events.filter((e) => e.kind === "turn");

    // Tool-only checkpoints (tool-execution, tool-done) produce no text
    // content, so no turn events. Only inference-done and inference-error
    // checkpoints that add assistant text produce turns.
    expect(turns).toHaveLength(2);

    const completedTurn = turns.find(
      (e) => e.kind === "turn" && e.status === "completed",
    );
    expect(completedTurn).toBeDefined();
    if (completedTurn !== undefined && completedTurn.kind === "turn") {
      expect(completedTurn.content).toBe(
        "SF is 72F and sunny. NYC is 55F and rainy.",
      );
    }

    const errorTurn = turns.find(
      (e) => e.kind === "turn" && e.status === "error",
    );
    expect(errorTurn).toBeDefined();
    if (errorTurn !== undefined && errorTurn.kind === "turn") {
      expect(errorTurn.content).toBe("Let me check London weather.");
      expect(errorTurn.isError).toBe(true);
      expect(errorTurn.errors).toBeDefined();
      expect(errorTurn.errors?.length).toBe(1);
      expect(errorTurn.errors?.[0]?.category).toBe("rate_limit");
    }

    // Events are sorted by timestamp — mail and turns interleaved correctly
    for (let i = 1; i < result.events.length; i++) {
      const prev = result.events[i - 1];
      const curr = result.events[i];
      if (prev !== undefined && curr !== undefined) {
        expect(curr.timestamp).toBeGreaterThanOrEqual(prev.timestamp);
      }
    }
  });

  test("records multiple gaps for multiple corrupt checkpoints", async () => {
    const dir = await makeTempDir();
    await initAgentRepo(dir);

    // Write two corrupt checkpoints
    for (let i = 0; i < 2; i++) {
      const turnsPath = path.join(dir, "turns.jsonl");
      await fs.promises.writeFile(turnsPath, `CORRUPT ${String(i)}`);
      await git.add({ fs, dir, filepath: "turns.jsonl" });
      await git.commit({
        fs,
        dir,
        message: "checkpoint: inference-done",
        author: { name: "test", email: "test@test.dev" },
      });
    }

    const result = await reconstructTimeline(dir);

    const corruptGaps = result.gaps.filter(
      (g) => g.kind === "corrupt-checkpoint",
    );
    expect(corruptGaps).toHaveLength(2);
  });
});
