/**
 * @file Unit tests for the TranscriptCache class, which extracts token usage from Claude transcript JSONL files and caches results for performance. Tests cover cache hits/misses, compaction detection, multiple models, and edge cases like malformed files and eviction behavior.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");

let tmpDir;
let TranscriptCache;

function writeJsonl(filePath, entries) {
  fs.writeFileSync(filePath, entries.map((e) => JSON.stringify(e)).join("\n") + "\n");
}

describe("TranscriptCache", () => {
  beforeEach(() => {
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tc-test-"));
    delete require.cache[require.resolve("../../lib/transcript-cache")];
    TranscriptCache = require("../../lib/transcript-cache");
  });

  afterEach(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  });

  it("should extract tokens on first read (cache miss)", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 100, output_tokens: 50 },
        },
      },
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 200, output_tokens: 75 },
        },
      },
    ]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);

    assert.deepStrictEqual(result.tokensByModel, {
      "claude-sonnet-4-20250514": { input: 300, output: 125, cacheRead: 0, cacheWrite: 0 },
    });
    assert.strictEqual(result.compaction, null);
  });

  it("should return cached result when file is unchanged", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 100, output_tokens: 50 },
        },
      },
    ]);

    const cache = new TranscriptCache();
    const r1 = cache.extract(file);
    const r2 = cache.extract(file);

    assert.deepStrictEqual(r1, r2);
    // Same object reference proves cache hit (no re-parse)
    assert.strictEqual(r1, r2);
  });

  it("should detect new data when file grows", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 100, output_tokens: 50 },
        },
      },
    ]);

    const cache = new TranscriptCache();
    const r1 = cache.extract(file);
    assert.strictEqual(r1.tokensByModel["claude-sonnet-4-20250514"].input, 100);

    // Append more data (simulates Claude writing to transcript)
    fs.appendFileSync(
      file,
      JSON.stringify({
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 200, output_tokens: 75 },
        },
      }) + "\n"
    );

    const r2 = cache.extract(file);
    assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].input, 300);
    assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].output, 125);
  });

  it("should do full re-read when file shrinks (compaction rewrite)", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 500, output_tokens: 200 },
        },
      },
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 300, output_tokens: 100 },
        },
      },
    ]);

    const cache = new TranscriptCache();
    cache.extract(file);

    // Simulate compaction — file is rewritten with fewer entries + summary
    writeJsonl(file, [
      { isCompactSummary: true, uuid: "abc-123", timestamp: "2026-03-20T10:00:00Z" },
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 50, output_tokens: 20 },
        },
      },
    ]);

    const r2 = cache.extract(file);
    assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].input, 50);
    assert.strictEqual(r2.compaction.count, 1);
    assert.strictEqual(r2.compaction.entries[0].uuid, "abc-123");
  });

  it("should return null for non-existent file", () => {
    const cache = new TranscriptCache();
    assert.strictEqual(cache.extract("/nonexistent/file.jsonl"), null);
    assert.strictEqual(cache.extract(null), null);
    assert.strictEqual(cache.extract(""), null);
  });

  it("should expose compaction entries via extractCompactions()", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 100, output_tokens: 50 },
        },
      },
      { isCompactSummary: true, uuid: "c1", timestamp: "2026-03-20T09:00:00Z" },
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 50, output_tokens: 20 },
        },
      },
      { isCompactSummary: true, uuid: "c2", timestamp: "2026-03-20T10:00:00Z" },
    ]);

    const cache = new TranscriptCache();
    const compactions = cache.extractCompactions(file);

    assert.strictEqual(compactions.length, 2);
    assert.strictEqual(compactions[0].uuid, "c1");
    assert.strictEqual(compactions[1].uuid, "c2");
  });

  it("should handle multiple models in same file", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 100, output_tokens: 50 },
        },
      },
      {
        message: {
          model: "claude-opus-4-20250514",
          usage: { input_tokens: 500, output_tokens: 200 },
        },
      },
    ]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);

    assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].input, 100);
    assert.strictEqual(result.tokensByModel["claude-opus-4-20250514"].input, 500);
  });

  it("should skip <synthetic> model entries", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      { message: { model: "<synthetic>", usage: { input_tokens: 999, output_tokens: 999 } } },
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: { input_tokens: 100, output_tokens: 50 },
        },
      },
    ]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);

    assert.strictEqual(Object.keys(result.tokensByModel).length, 1);
    assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].input, 100);
  });

  it("should handle cache_read and cache_write tokens", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        message: {
          model: "claude-sonnet-4-20250514",
          usage: {
            input_tokens: 100,
            output_tokens: 50,
            cache_read_input_tokens: 30,
            cache_creation_input_tokens: 15,
          },
        },
      },
    ]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);

    assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].cacheRead, 30);
    assert.strictEqual(result.tokensByModel["claude-sonnet-4-20250514"].cacheWrite, 15);
  });

  it("should remove entry on invalidate()", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      { message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } } },
    ]);

    const cache = new TranscriptCache();
    cache.extract(file);
    assert.strictEqual(cache.size, 1);

    cache.invalidate(file);
    assert.strictEqual(cache.size, 0);
  });

  it("should clear all entries", () => {
    const file1 = path.join(tmpDir, "s1.jsonl");
    const file2 = path.join(tmpDir, "s2.jsonl");
    writeJsonl(file1, [
      { message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
    ]);
    writeJsonl(file2, [
      { message: { model: "m1", usage: { input_tokens: 20, output_tokens: 10 } } },
    ]);

    const cache = new TranscriptCache();
    cache.extract(file1);
    cache.extract(file2);
    assert.strictEqual(cache.size, 2);

    cache.clear();
    assert.strictEqual(cache.size, 0);
  });

  it("should return correct stats()", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);

    const cache = new TranscriptCache();
    cache.extract(file);

    const stats = cache.stats();
    assert.strictEqual(stats.entries, 1);
    assert.strictEqual(stats.paths.length, 1);
    assert.strictEqual(stats.paths[0], file);
  });

  it("should only read new bytes on incremental update", () => {
    const file = path.join(tmpDir, "session.jsonl");
    const line1 =
      JSON.stringify({
        message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } },
      }) + "\n";
    fs.writeFileSync(file, line1);

    const cache = new TranscriptCache();
    cache.extract(file);

    // Append a second line
    const line2 =
      JSON.stringify({
        message: { model: "m1", usage: { input_tokens: 200, output_tokens: 75 } },
      }) + "\n";
    fs.appendFileSync(file, line2);

    const r2 = cache.extract(file);
    assert.strictEqual(r2.tokensByModel["m1"].input, 300);

    // Verify bytesRead advanced to full file size
    const entry = cache._cache.get(file);
    assert.strictEqual(entry.bytesRead, Buffer.byteLength(line1 + line2, "utf8"));
  });

  it("should handle incremental read adding new compaction entries", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      { message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } } },
    ]);

    const cache = new TranscriptCache();
    const r1 = cache.extract(file);
    assert.strictEqual(r1.compaction, null);

    // Append a compaction entry
    fs.appendFileSync(
      file,
      JSON.stringify({ isCompactSummary: true, uuid: "new-c", timestamp: "2026-03-20T12:00:00Z" }) +
        "\n"
    );

    const r2 = cache.extract(file);
    assert.strictEqual(r2.compaction.count, 1);
    assert.strictEqual(r2.compaction.entries[0].uuid, "new-c");
  });

  it("should return null for empty file", () => {
    const file = path.join(tmpDir, "empty.jsonl");
    fs.writeFileSync(file, "");

    const cache = new TranscriptCache();
    assert.strictEqual(cache.extract(file), null);
  });

  it("should skip malformed JSON lines gracefully", () => {
    const file = path.join(tmpDir, "session.jsonl");
    fs.writeFileSync(
      file,
      [
        "not valid json",
        JSON.stringify({
          message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } },
        }),
        "{broken",
      ].join("\n") + "\n"
    );

    const cache = new TranscriptCache();
    const result = cache.extract(file);
    assert.strictEqual(result.tokensByModel["m1"].input, 100);
  });

  it("should evict oldest entries when exceeding maxEntries", () => {
    const cache = new TranscriptCache(3); // max 3 entries
    const files = [];
    for (let i = 0; i < 5; i++) {
      const file = path.join(tmpDir, `s${i}.jsonl`);
      writeJsonl(file, [
        { message: { model: "m1", usage: { input_tokens: i * 10, output_tokens: i * 5 } } },
      ]);
      files.push(file);
    }

    // Fill cache with 5 entries, but max is 3
    for (const f of files) cache.extract(f);

    assert.strictEqual(cache.size, 3);
    // Oldest two (s0, s1) should be evicted; newest three (s2, s3, s4) remain
    const stats = cache.stats();
    assert.ok(!stats.paths.includes(files[0]), "oldest entry s0 should be evicted");
    assert.ok(!stats.paths.includes(files[1]), "second-oldest entry s1 should be evicted");
    assert.ok(stats.paths.includes(files[2]), "s2 should remain");
    assert.ok(stats.paths.includes(files[3]), "s3 should remain");
    assert.ok(stats.paths.includes(files[4]), "s4 should remain");
  });

  it("should refresh LRU order on access", () => {
    const cache = new TranscriptCache(3);
    const files = [];
    for (let i = 0; i < 3; i++) {
      const file = path.join(tmpDir, `lru${i}.jsonl`);
      writeJsonl(file, [
        { message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
      ]);
      files.push(file);
      cache.extract(file);
    }

    // Access file[0] again (moves it to most-recently-used)
    fs.appendFileSync(
      files[0],
      JSON.stringify({ message: { model: "m1", usage: { input_tokens: 5, output_tokens: 2 } } }) +
        "\n"
    );
    cache.extract(files[0]);

    // Add a new file — should evict file[1] (now the oldest), not file[0]
    const newFile = path.join(tmpDir, "lru_new.jsonl");
    writeJsonl(newFile, [
      { message: { model: "m1", usage: { input_tokens: 1, output_tokens: 1 } } },
    ]);
    cache.extract(newFile);

    assert.strictEqual(cache.size, 3);
    const stats = cache.stats();
    assert.ok(stats.paths.includes(files[0]), "recently accessed file should remain");
    assert.ok(!stats.paths.includes(files[1]), "oldest untouched file should be evicted");
    assert.ok(stats.paths.includes(files[2]), "file[2] should remain");
    assert.ok(stats.paths.includes(newFile), "new file should be present");
  });

  it("should return defensive copy from extractCompactions", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      { isCompactSummary: true, uuid: "c1", timestamp: "2026-03-20T09:00:00Z" },
      { message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
    ]);

    const cache = new TranscriptCache();
    const compactions = cache.extractCompactions(file);
    assert.strictEqual(compactions.length, 1);

    // Mutate returned array — should NOT affect cache
    compactions.push({ uuid: "fake", timestamp: null });
    compactions[0].uuid = "mutated";

    const compactions2 = cache.extractCompactions(file);
    assert.strictEqual(compactions2.length, 1);
    assert.strictEqual(compactions2[0].uuid, "c1");
  });

  it("should return empty array from extractCompactions for file with no compactions", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);

    const cache = new TranscriptCache();
    const compactions = cache.extractCompactions(file);
    assert.deepStrictEqual(compactions, []);
  });

  it("should return empty array from extractCompactions for non-existent file", () => {
    const cache = new TranscriptCache();
    const compactions = cache.extractCompactions("/nonexistent.jsonl");
    assert.deepStrictEqual(compactions, []);
  });

  it("should capture lastInterruptTs from an Esc-interrupt entry (text marker)", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      { message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } },
      {
        type: "user",
        message: {
          role: "user",
          content: [{ type: "text", text: "[Request interrupted by user]" }],
        },
        timestamp: "2026-06-28T12:00:00.000Z",
      },
    ]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);
    assert.strictEqual(result.lastInterruptTs, "2026-06-28T12:00:00.000Z");
    assert.strictEqual(result.pendingInterrupt, true);
  });

  it("should capture lastInterruptTs via the interruptedMessageId field", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        type: "user",
        interruptedMessageId: "msg_123",
        message: {
          role: "user",
          content: [{ type: "text", text: "[Request interrupted by user for tool use]" }],
        },
        timestamp: "2026-06-28T13:30:00.000Z",
      },
    ]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);
    assert.strictEqual(result.lastInterruptTs, "2026-06-28T13:30:00.000Z");
    assert.strictEqual(result.pendingInterrupt, true);
  });

  it("should flag pendingInterrupt for an Esc pressed BEFORE any output (prompt then interrupt)", () => {
    // The hard case: user submits, then cancels before the model emits anything.
    // Transcript order is [user prompt, interrupt] — no assistant entry between.
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        type: "user",
        message: { role: "user", content: [{ type: "text", text: "do a big refactor" }] },
        timestamp: "2026-06-28T15:00:00.000Z",
      },
      {
        type: "user",
        interruptedMessageId: "m",
        message: {
          role: "user",
          content: [{ type: "text", text: "[Request interrupted by user]" }],
        },
        timestamp: "2026-06-28T15:00:00.001Z", // 1ms later — the real-world skew case
      },
    ]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);
    assert.strictEqual(result.pendingInterrupt, true, "pre-output Esc must still be detected");
  });

  it("should flag pendingInterrupt when Esc follows assistant output", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        type: "assistant",
        message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } },
        timestamp: "2026-06-28T16:00:00.000Z",
      },
      {
        type: "user",
        interruptedMessageId: "m",
        message: {
          role: "user",
          content: [{ type: "text", text: "[Request interrupted by user]" }],
        },
        timestamp: "2026-06-28T16:00:05.000Z",
      },
    ]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);
    assert.strictEqual(result.pendingInterrupt, true);
  });

  it("should NOT flag pendingInterrupt when the user resumed after the interrupt", () => {
    // [interrupt, new prompt] — the user came back and submitted again.
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        type: "user",
        interruptedMessageId: "m",
        message: {
          role: "user",
          content: [{ type: "text", text: "[Request interrupted by user]" }],
        },
        timestamp: "2026-06-28T17:00:00.000Z",
      },
      {
        type: "user",
        message: { role: "user", content: [{ type: "text", text: "actually do this instead" }] },
        timestamp: "2026-06-28T17:00:30.000Z",
      },
    ]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);
    assert.strictEqual(result.lastInterruptTs, "2026-06-28T17:00:00.000Z");
    assert.strictEqual(result.pendingInterrupt, false, "resuming with a new prompt clears it");
  });

  it("should keep the latest interrupt timestamp (append-only, last wins)", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        type: "user",
        interruptedMessageId: "a",
        message: {
          role: "user",
          content: [{ type: "text", text: "[Request interrupted by user]" }],
        },
        timestamp: "2026-06-28T10:00:00.000Z",
      },
      {
        type: "user",
        interruptedMessageId: "b",
        message: {
          role: "user",
          content: [{ type: "text", text: "[Request interrupted by user]" }],
        },
        timestamp: "2026-06-28T11:00:00.000Z",
      },
    ]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);
    assert.strictEqual(result.lastInterruptTs, "2026-06-28T11:00:00.000Z");
    assert.strictEqual(result.pendingInterrupt, true);
  });

  it("should carry a newer interrupt across an incremental read", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);

    const cache = new TranscriptCache();
    const first = cache.extract(file);
    assert.strictEqual(first.lastInterruptTs, null);
    assert.strictEqual(first.pendingInterrupt, false);

    // Append an interrupt entry → incremental read path must surface it.
    fs.appendFileSync(
      file,
      JSON.stringify({
        type: "user",
        interruptedMessageId: "x",
        message: {
          role: "user",
          content: [{ type: "text", text: "[Request interrupted by user]" }],
        },
        timestamp: "2026-06-28T14:00:00.000Z",
      }) + "\n"
    );
    const second = cache.extract(file);
    assert.strictEqual(second.lastInterruptTs, "2026-06-28T14:00:00.000Z");
    assert.strictEqual(second.pendingInterrupt, true);
  });

  it("should clear pendingInterrupt across an incremental read when the user resumes", () => {
    // First read sees a tail interrupt; a later prompt appended in the next
    // chunk must flip pendingInterrupt back to false via the merge path.
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [
      {
        type: "user",
        interruptedMessageId: "x",
        message: {
          role: "user",
          content: [{ type: "text", text: "[Request interrupted by user]" }],
        },
        timestamp: "2026-06-28T18:00:00.000Z",
      },
    ]);

    const cache = new TranscriptCache();
    assert.strictEqual(cache.extract(file).pendingInterrupt, true);

    fs.appendFileSync(
      file,
      JSON.stringify({
        type: "user",
        message: { role: "user", content: [{ type: "text", text: "resume" }] },
        timestamp: "2026-06-28T18:01:00.000Z",
      }) + "\n"
    );
    assert.strictEqual(cache.extract(file).pendingInterrupt, false, "incremental resume clears it");
  });

  it("should leave lastInterruptTs null and pendingInterrupt false when there is no interrupt", () => {
    const file = path.join(tmpDir, "session.jsonl");
    writeJsonl(file, [{ message: { model: "m1", usage: { input_tokens: 10, output_tokens: 5 } } }]);

    const cache = new TranscriptCache();
    const result = cache.extract(file);
    assert.strictEqual(result.lastInterruptTs, null);
    assert.strictEqual(result.pendingInterrupt, false);
  });
});
