import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseTale } from "@taleseal/core";
import { parseClaudeCode } from "./capture/claude-code";
import { parseCodex } from "./capture/codex";
import type { RunData } from "./capture/types";
import { composeTale } from "./compose";

const fixture = (name: string): string =>
  readFileSync(fileURLToPath(new URL(`../fixtures/${name}`, import.meta.url)), "utf8");

const BEAT_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;

describe("composeTale (claude-code)", () => {
  const run = parseClaudeCode(fixture("claude-code/basic.jsonl"));
  const tale = composeTale(run);

  test("passes parseTale", () => {
    expect(() => parseTale(tale)).not.toThrow();
  });

  test("projects the run into the v2 tale fields", () => {
    expect(tale.version).toBe(2);
    expect(tale.agent).toBe("claude-code");
    expect(tale.runId).toBe("11111111-2222-3333-4444-555555555555");
    expect(tale.title).toBe("Fixing the flaky checkout test"); // the transcript's summary line
    expect(tale.trigger.source).toBe("claude-code");
    expect(tale.trigger.event).toContain("checkout test is flaky");
    expect(tale.beats).toHaveLength(3);
    expect(tale.outcome.status).toBe("succeeded"); // the default — no --status flag
    expect(tale.outcome.summary).toContain("passed five consecutive re-runs");
    expect(tale.metrics).toEqual({
      durationMs: 18_000,
      steps: 3,
      tokensIn: 3062,
      tokensOut: 270,
      model: "claude-fable-5",
    });
  });

  test("auto-fills provenance: tool with version, extent fully_ai", () => {
    expect(tale.provenance).toEqual({ tool: "claude-code 2.1.0", extent: "fully_ai" });
  });

  test("--status lands on the outcome", () => {
    expect(composeTale(run, { status: "failed" }).outcome.status).toBe("failed");
    expect(composeTale(run, { status: "partial" }).outcome.status).toBe("partial");
  });

  test("--publisher and --transcript-url land in provenance", () => {
    const withFlags = composeTale(run, {
      publisher: "goran",
      transcriptUrl: "https://example.com/sessions/1234",
    });
    expect(withFlags.provenance?.publisher).toBe("goran");
    expect(withFlags.provenance?.transcriptUrl).toBe("https://example.com/sessions/1234");
  });

  test("beats carry timestamp, title from the first sentence, and tool call lines", () => {
    const first = tale.beats[0];
    expect(first?.at).toBe("2026-07-01T09:00:03.000Z");
    expect(first?.title).toBe("I will reproduce the flake first, then trace the race.");
    expect(first?.prose).toContain("→ Bash: Re-run the checkout suite five times");
  });

  test("beats get stable slug ids", () => {
    for (const beat of tale.beats) {
      expect(beat.id).toMatch(BEAT_ID);
    }
    expect(tale.beats[0]?.id).toBe("i-will-reproduce-the-flake-first-then-tr");
    // deterministic: composing again yields the same ids
    expect(composeTale(run).beats.map((b) => b.id)).toEqual(tale.beats.map((b) => b.id));
  });

  test("the test run becomes test evidence with passed decided by output markers", () => {
    const first = tale.beats[0]?.evidence?.[0];
    expect(first).toMatchObject({
      kind: "test",
      command: "bun test checkout --rerun-each 5",
      passed: false, // "3 pass, 2 fail" — the explicit fail count decides
    });
    expect(first?.kind === "test" && first.output).toContain("3 pass, 2 fail");
  });

  test("the Edit call becomes one aggregated diff evidence", () => {
    expect(tale.beats[1]?.evidence).toEqual([
      {
        kind: "diff",
        summary: "1 file touched",
        files: [{ path: "/home/dev/example-app/src/checkout.test.ts" }],
      },
    ]);
  });

  test("a text-only closing beat has no evidence", () => {
    expect(tale.beats[2]?.evidence).toBeUndefined();
  });

  test("redacts secrets that leaked into tool output before they enter the tale", () => {
    const serialised = JSON.stringify(tale);
    expect(serialised).not.toContain("sk-synthetic");
    expect(serialised).not.toContain("AKIAAAAABBBBCCCCDDDD");
    expect(serialised).toContain("[REDACTED:aws-access-key]");
  });

  test("evidence output is redacted too — receipts never carry the raw secret", () => {
    const first = tale.beats[0]?.evidence?.[0];
    const output = first?.kind === "test" ? first.output : "";
    expect(output).toContain("[REDACTED:api-key]");
    expect(output).not.toContain("sk-synthetic");
  });

  test("--agent override lands in the tale but not in provenance.tool", () => {
    const overridden = composeTale(run, { agent: "release-bot" });
    expect(overridden.agent).toBe("release-bot");
    expect(overridden.provenance?.tool).toBe("claude-code 2.1.0");
  });
});

describe("composeTale (codex)", () => {
  const tale = composeTale(parseCodex(fixture("codex/basic.jsonl")));

  test("passes parseTale and projects metadata", () => {
    expect(() => parseTale(tale)).not.toThrow();
    expect(tale.agent).toBe("codex");
    expect(tale.title).toBe("Rename the config loader to loadSettings and update every call site.");
    expect(tale.metrics?.model).toBe("gpt-5.5");
    expect(tale.metrics?.tokensIn).toBe(5200);
    expect(tale.beats[0]?.prose).toContain("→ web_search: typescript rename exported function codemod");
    expect(tale.provenance).toEqual({ tool: "codex 0.99.0", extent: "fully_ai" });
  });

  test("extracts command evidence with the recorded exit code and a diff from apply_patch", () => {
    const evidence = tale.beats[0]?.evidence ?? [];
    expect(evidence[0]).toMatchObject({ kind: "command", command: "rg -n loadConfig src", exitCode: 0 });
    // the web_search has no URL, so no citation — the diff comes next
    expect(evidence[1]).toEqual({
      kind: "diff",
      summary: "1 file touched",
      files: [{ path: "src/config.ts" }],
    });
    expect(evidence).toHaveLength(2);
  });
});

describe("composeTale folding and truncation", () => {
  const tale = composeTale(parseClaudeCode(fixture("claude-code/long.jsonl")));

  test("folds long runs within the schema's 20-beat ceiling, first and last always kept", () => {
    expect(() => parseTale(tale)).not.toThrow();
    expect(tale.beats.length).toBeLessThanOrEqual(20);
    expect(tale.beats[0]?.title).toContain("Starting the sweep");
    expect(tale.beats.at(-1)?.title).toContain("All fifteen route handlers");
    expect(tale.beats.some((b) => b.title.includes("folded"))).toBe(true);
  });

  test("folds by salience, not position: receipt-bearing beats outrank prose", () => {
    // every kept beat but the closing summary carries a receipt
    const withEvidence = tale.beats.filter((b) => (b.evidence?.length ?? 0) > 0);
    expect(withEvidence.length).toBeGreaterThanOrEqual(8);
    // route 10 sits mid-run: a positional first-6/last-5 fold would have dropped it
    expect(tale.beats.some((b) => b.title.includes("Route 10"))).toBe(true);
  });

  test("beats stay chronological across the folds", () => {
    const stamps = tale.beats.map((b) => b.at).filter((at): at is string => at !== undefined);
    expect([...stamps].sort()).toEqual(stamps);
  });

  test("every beat id is a valid slug and unique, including folding markers", () => {
    const ids = tale.beats.map((b) => b.id);
    for (const id of ids) {
      expect(id).toMatch(BEAT_ID);
    }
    expect(new Set(ids).size).toBe(ids.length);
    expect(ids.some((id) => id?.includes("folded"))).toBe(true);
  });

  test("truncates: beat titles ≤ 80, prose excerpts ≤ ~400, trigger event ≤ 120", () => {
    for (const beat of tale.beats) {
      expect(beat.title.length).toBeLessThanOrEqual(80);
      expect(beat.prose.length).toBeLessThanOrEqual(2000);
    }
    const firstProseLine = tale.beats[0]?.prose.split("\n")[0] ?? "";
    expect(firstProseLine.length).toBeLessThanOrEqual(400);
    expect(firstProseLine.endsWith("…")).toBe(true);
    expect(tale.trigger.event.length).toBeLessThanOrEqual(120);
    expect(tale.trigger.event.endsWith("…")).toBe(true);
    expect(tale.title.length).toBeLessThanOrEqual(200);
  });
});

describe("composeTale guards", () => {
  test("synthesises one beat for a transcript with no step content", () => {
    const run: RunData = {
      agent: "claude-code",
      runId: "empty-run",
      startedAt: "2026-07-01T09:00:00.000Z",
      endedAt: "2026-07-01T09:00:01.000Z",
      userMessages: ["Say hello."],
      steps: [],
    };
    const tale = composeTale(run);
    expect(() => parseTale(tale)).not.toThrow();
    expect(tale.beats).toHaveLength(1);
    expect(tale.beats[0]?.id).toBe("the-run");
    expect(tale.title).toBe("Say hello.");
    expect(tale.outcome).toEqual({ status: "succeeded", summary: "The transcript recorded no closing summary." });
    expect(tale.provenance).toEqual({ tool: "claude-code", extent: "fully_ai" });
  });

  test("beats with identical titles get deduplicated ids", () => {
    const step = (at: string): RunData["steps"][number] => ({
      at,
      assistantText: "Guarding the null return and adding the typed 404 body to the handler now.",
      toolCalls: [],
    });
    const run: RunData = {
      agent: "claude-code",
      runId: "dupe-run",
      userMessages: ["Fix them all."],
      steps: [step("2026-07-01T09:00:00.000Z"), step("2026-07-01T09:01:00.000Z"), step("2026-07-01T09:02:00.000Z")],
    };
    const ids = composeTale(run).beats.map((b) => b.id);
    expect(new Set(ids).size).toBe(3);
    for (const id of ids) {
      expect(id).toMatch(BEAT_ID);
    }
    expect(ids[1]).toMatch(/-2$/);
    expect(ids[2]).toMatch(/-3$/);
  });

  test("more than six receipts in a beat: capped with a folding note in prose", () => {
    const bash = (n: number): RunData["steps"][number]["toolCalls"][number] => ({
      name: "Bash",
      inputSummary: `step ${n}`,
      command: `echo ${n}`,
      outputSummary: `${n}`,
    });
    const run: RunData = {
      agent: "claude-code",
      runId: "busy-run",
      userMessages: ["Run everything."],
      steps: [
        {
          assistantText: "Running the whole battery of checks before the release goes anywhere.",
          toolCalls: [1, 2, 3, 4, 5, 6, 7, 8].map(bash),
        },
      ],
    };
    const beat = composeTale(run).beats[0];
    expect(beat?.evidence).toHaveLength(6);
    expect(beat?.prose).toContain("… 2 more receipts folded — kept the first 6");
  });
});
