/**
 * @file TranscriptCache class for efficient extraction of token usage and compaction data from JSONL transcript files, with stat-based caching and incremental reads to handle append-only growth without re-reading the entire file. Also extracts API error entries and turn duration system messages for enhanced analytics.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const fs = require("fs");
const {
  bucketKey,
  emptyBucket,
  extractUsageFields,
  normalizeSpeed,
  normalizeGeo,
  normalizeTier,
  accumulateBucket,
} = require("./token-usage");

const MAX_CACHE_ENTRIES = 200;

// Marker text Claude Code writes into the transcript when a turn is cancelled
// by the user (Esc). The synthetic entry is `type:"user"` and also carries an
// `interruptedMessageId` field; we accept either signal so detection survives
// minor format drift. No hook fires on interrupt, so this is the only on-disk
// evidence the watchdog can use to un-stick a session left in "working".
const INTERRUPT_RE = /\[Request interrupted by user/i;

// True when the transcript's tail is a user-interrupt that was never followed
// by real turn activity (a new prompt or model output). Both timestamps come
// from Claude Code's clock, so the comparison is immune to the server/transcript
// skew that breaks a sub-second pre-output Esc. `>=` so an interrupt that ties
// the last activity (interrupt written in the same instant) still counts.
function computePendingInterrupt(lastInterruptTs, lastTurnTs) {
  if (!lastInterruptTs) return false;
  if (!lastTurnTs) return true;
  return lastInterruptTs >= lastTurnTs;
}

function hasInterruptText(message) {
  if (!message || typeof message !== "object") return false;
  const c = message.content;
  if (typeof c === "string") return INTERRUPT_RE.test(c);
  if (Array.isArray(c)) {
    for (const block of c) {
      if (block && typeof block.text === "string" && INTERRUPT_RE.test(block.text)) return true;
    }
  }
  return false;
}

// Hard cap on the length of each per-entry growable array (turnDurations,
// errors, compaction.entries, usageExtras.{service_tiers,speeds,inference_geos}).
// Past this point we keep the *tail* — the most recent N items — so the
// cache reflects current state. Older items are NOT lost from the system:
// they are already persisted to the events table by routes/hooks.js, with
// dedup logic that prevents re-insertion when the cache re-reads them.
// Configurable via TRANSCRIPT_CACHE_MAX_ARRAY_LEN env var.
const MAX_ARRAY_LEN = (() => {
  const raw = parseInt(process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN, 10);
  return Number.isFinite(raw) && raw > 0 ? raw : 1000;
})();

// Watermark for in-flight trimming during _consumeLine. We trim back to
// MAX_ARRAY_LEN whenever an array reaches 2*MAX_ARRAY_LEN, so a full-file
// parse cannot accumulate an unbounded transient before _finalizeState runs.
// Amortized O(N): each item is touched by a splice at most ~once.
const PARSE_TRIM_WATERMARK = MAX_ARRAY_LEN * 2;

// Cap on the captured first-user-message text. 500 chars matches the task
// truncation the hook ingestor already applies to subagent prompts, so the
// descriptor can be reused verbatim as an agent task downstream.
const FIRST_USER_MESSAGE_MAX_LEN = 500;

// Synthetic user entries whose text is CLI plumbing, not something the human
// typed: local slash-command invocations/output and the caveat preamble
// Claude Code writes before locally-generated messages. These must never
// become a session descriptor.
const SYNTHETIC_USER_TEXT_RE =
  /^<(?:command-name|command-message|local-command-stdout|local-command-caveat)>/;

/**
 * Extract the human-typed text of a user transcript entry, or null when the
 * entry is not a real prompt: tool-result entries, meta/caveat lines, local
 * slash-command plumbing, compact summaries, and user-interrupt markers are
 * all skipped. Shared with scripts/import-history.js so imported and live
 * sessions derive the identical descriptor.
 */
function extractFirstUserText(entry) {
  if (entry.isMeta || entry.isCompactSummary) return null;
  if (entry.interruptedMessageId != null || hasInterruptText(entry.message)) return null;
  const msg = entry.message;
  if (!msg || typeof msg !== "object" || msg.role !== "user") return null;
  const content = msg.content;
  let text = null;
  if (typeof content === "string") {
    text = content;
  } else if (Array.isArray(content)) {
    // Tool-result entries are `role:"user"` too — skip any entry carrying a
    // tool_result block rather than mining text out of a mixed payload.
    if (content.some((b) => b && b.type === "tool_result")) return null;
    text = content
      .filter((b) => b && b.type === "text" && typeof b.text === "string")
      .map((b) => b.text)
      .join(" ");
  }
  if (typeof text !== "string") return null;
  // Collapse newlines/runs of whitespace so the descriptor reads as one line.
  text = text.replace(/\s+/g, " ").trim();
  if (!text || SYNTHETIC_USER_TEXT_RE.test(text)) return null;
  return text.length > FIRST_USER_MESSAGE_MAX_LEN
    ? text.slice(0, FIRST_USER_MESSAGE_MAX_LEN)
    : text;
}

class TranscriptCache {
  constructor(maxEntries = MAX_CACHE_ENTRIES) {
    this._cache = new Map();
    this._maxEntries = maxEntries;
    this._hits = 0;
    this._misses = 0;
  }

  /**
   * Extract token usage and compaction data from a JSONL transcript file.
   * Uses stat-based caching with incremental reads for append-only growth.
   * Returns null if file doesn't exist or has no data.
   */
  extract(transcriptPath) {
    if (!transcriptPath) return null;
    try {
      let stat;
      try {
        stat = fs.statSync(transcriptPath);
      } catch {
        return null;
      }
      const key = transcriptPath;
      const cached = this._cache.get(key);

      // Cache hit: file unchanged (same mtime + size)
      if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
        this._hits++;
        return cached.result;
      }

      this._misses++;
      // File shrunk or first read → full re-read
      if (!cached || stat.size < cached.bytesRead) {
        const result = this._fullRead(transcriptPath);
        this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
        return result;
      }

      // File grew → incremental read from last position
      if (stat.size > cached.bytesRead) {
        const incremental = this._streamRange(transcriptPath, cached.bytesRead, stat.size);
        if (incremental) {
          const merged = this._merge(cached, incremental);
          const hasTokens = Object.keys(merged.tokensByModel).length > 0;
          const hasTurnDurations = merged.turnDurations && merged.turnDurations.length > 0;
          const hasUsageExtras =
            merged.usageExtras &&
            (merged.usageExtras.service_tiers.length > 0 ||
              merged.usageExtras.speeds.length > 0 ||
              merged.usageExtras.inference_geos.length > 0);
          const result = {
            tokensByModel: hasTokens ? merged.tokensByModel : null,
            compaction: merged.compaction,
            errors: merged.errors,
            turnDurations: hasTurnDurations ? merged.turnDurations : null,
            thinkingBlockCount: merged.thinkingBlockCount || 0,
            usageExtras: hasUsageExtras ? merged.usageExtras : null,
            latestModel: merged.latestModel || null,
            customTitle: merged.customTitle || null,
            aiTitle: merged.aiTitle || null,
            firstUserMessage: merged.firstUserMessage || null,
            lastInterruptTs: merged.lastInterruptTs || null,
            lastTurnTs: merged.lastTurnTs || null,
            pendingInterrupt: computePendingInterrupt(merged.lastInterruptTs, merged.lastTurnTs),
          };
          if (
            !result.tokensByModel &&
            !result.compaction &&
            !result.errors &&
            !result.turnDurations &&
            !result.thinkingBlockCount &&
            !result.usageExtras &&
            !result.latestModel &&
            !result.customTitle &&
            !result.aiTitle &&
            !result.firstUserMessage &&
            !result.lastInterruptTs &&
            !result.lastTurnTs
          ) {
            this._set(key, {
              mtimeMs: stat.mtimeMs,
              size: stat.size,
              bytesRead: stat.size,
              result: null,
            });
            return null;
          }
          this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
          return result;
        }

        // Only whitespace/newlines appended
        this._set(key, {
          ...cached,
          mtimeMs: stat.mtimeMs,
          size: stat.size,
          bytesRead: stat.size,
        });
        return cached.result;
      }

      // Same size, different mtime — content may have been rewritten (compaction)
      const result = this._fullRead(transcriptPath);
      this._set(key, { mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result });
      return result;
    } catch {
      return null;
    }
  }

  /**
   * Extract only compaction entries from a JSONL file.
   * Replacement for findCompactionsInFile — uses the same cache, no duplicate reads.
   */
  extractCompactions(transcriptPath) {
    const result = this.extract(transcriptPath);
    if (!result || !result.compaction) return [];
    return result.compaction.entries.map((e) => ({ ...e }));
  }

  /**
   * Full re-read using chunked streaming. Avoids materializing the whole file
   * as a single JS string, so files larger than V8's max string length
   * (~512 MiB on 64-bit Node) parse without aborting the process with
   * "FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal".
   */
  _fullRead(filePath) {
    let size;
    try {
      size = fs.statSync(filePath).size;
    } catch {
      return null;
    }
    return this._streamRange(filePath, 0, size);
  }

  /**
   * Sync chunked range reader + line parser.
   * Reads [startOffset, endOffset) in fixed-size chunks, splits on 0x0A bytes,
   * decodes each complete line as UTF-8 (safe: 0x0A never appears inside a
   * UTF-8 multibyte sequence), and feeds it to _consumeLine. Partial trailing
   * bytes between chunks are held in a byte buffer so multibyte characters
   * straddling a chunk boundary are not corrupted. Never builds a string
   * larger than a single line, so V8 string-length limits cannot be hit.
   */
  _streamRange(filePath, startOffset, endOffset) {
    const state = this._initParseState();
    if (endOffset <= startOffset) return this._finalizeState(state);

    const CHUNK = 4 * 1024 * 1024; // 4 MiB
    const MAX_PENDING = 64 * 1024 * 1024; // hard cap on a single line
    const buf = Buffer.allocUnsafe(CHUNK);
    let pending = null; // bytes of partial trailing line not yet terminated by \n
    let pendingLen = 0;
    let pos = startOffset;
    let fd;
    try {
      try {
        fd = fs.openSync(filePath, "r");
      } catch {
        return this._finalizeState(state);
      }

      while (pos < endOffset) {
        const want = Math.min(CHUNK, endOffset - pos);
        let got;
        try {
          got = fs.readSync(fd, buf, 0, want, pos);
        } catch {
          break;
        }
        if (got <= 0) break;
        pos += got;

        let lineStart = 0;
        for (let i = 0; i < got; i++) {
          if (buf[i] !== 0x0a) continue;

          let line;
          if (pendingLen) {
            const need = pendingLen + (i - lineStart);
            const lineBuf = Buffer.allocUnsafe(need);
            pending.copy(lineBuf, 0, 0, pendingLen);
            buf.copy(lineBuf, pendingLen, lineStart, i);
            line = lineBuf.toString("utf8");
            pending = null;
            pendingLen = 0;
          } else {
            line = buf.toString("utf8", lineStart, i);
          }
          if (line.length && line.charCodeAt(line.length - 1) === 13) {
            line = line.slice(0, -1); // strip CR
          }
          if (line) this._consumeLine(line, state);
          lineStart = i + 1;
        }

        if (lineStart < got) {
          const tailLen = got - lineStart;
          const newLen = pendingLen + tailLen;
          if (newLen > MAX_PENDING) {
            // Pathological single line — drop accumulated bytes and skip
            // forward to the next newline rather than OOM. Loss is bounded
            // to one malformed line.
            pending = null;
            pendingLen = 0;
          } else {
            if (!pending) {
              pending = Buffer.allocUnsafe(Math.max(newLen, 8192));
            } else if (pending.length < newLen) {
              const grow = Buffer.allocUnsafe(Math.max(newLen, pending.length * 2));
              pending.copy(grow, 0, 0, pendingLen);
              pending = grow;
            }
            buf.copy(pending, pendingLen, lineStart, got);
            pendingLen = newLen;
          }
        }
      }

      if (pendingLen) {
        let line = pending.toString("utf8", 0, pendingLen);
        if (line.length && line.charCodeAt(line.length - 1) === 13) {
          line = line.slice(0, -1);
        }
        if (line) this._consumeLine(line, state);
      }
    } finally {
      if (fd !== undefined) {
        try {
          fs.closeSync(fd);
        } catch {
          /* ignore */
        }
      }
    }

    return this._finalizeState(state);
  }

  _initParseState() {
    return {
      tokensByModel: {},
      compaction: null,
      errors: [],
      turnDurations: [],
      thinkingBlockCount: 0,
      usageExtras: {
        service_tiers: new Set(),
        speeds: new Set(),
        inference_geos: new Set(),
      },
      // Track the model of the most recent assistant entry. JSONL is
      // append-only and parsed in file order, so the last value seen here is
      // the user's *current* model — used downstream to keep session.model in
      // sync when the user invokes /model mid-session.
      latestModel: null,
      // Track the latest human-readable session title. Two sources, both
      // append-only metadata lines: `custom-title` (explicit /rename, claude
      // -n, picker Ctrl+R) and `ai-title` (auto-generated / plan-accept).
      // Last value wins. Used downstream to keep session.name in sync in real
      // time — custom titles take precedence over ai titles.
      customTitle: null,
      aiTitle: null,
      // First real user prompt of the session (tool-result / meta / command
      // entries skipped), whitespace-collapsed and length-capped. Used
      // downstream as a fallback descriptor for placeholder-named sessions
      // and their main agent — first value wins (it describes what the
      // session set out to do), unlike the last-wins titles above.
      firstUserMessage: null,
      // Timestamps (ISO 8601, all from Claude Code's clock) used to recover a
      // turn cancelled with no hook. `lastInterruptTs` is the most recent
      // user-interrupt (Esc) entry; `lastTurnTs` is the most recent real turn
      // activity (assistant output or a genuine user prompt). Comparing the
      // two — both same-clock — tells us whether the transcript TAIL is an
      // unrecovered interrupt. This holds even when Esc is pressed before any
      // output (a sub-second interrupt), which a server-vs-transcript clock
      // comparison cannot, since the UserPromptSubmit event is stamped later.
      lastInterruptTs: null,
      lastTurnTs: null,
    };
  }

  _consumeLine(line, state) {
    if (!line) return;
    let entry;
    try {
      entry = JSON.parse(line);
    } catch {
      return;
    }

    // Session title metadata lines — sparse, no usage payload. Capture the
    // latest value of each kind (append-only → last wins) and bail early.
    if (entry.type === "custom-title") {
      if (typeof entry.customTitle === "string" && entry.customTitle.trim()) {
        state.customTitle = entry.customTitle;
      }
      return;
    }
    if (entry.type === "ai-title") {
      if (typeof entry.aiTitle === "string" && entry.aiTitle.trim()) {
        state.aiTitle = entry.aiTitle;
      }
      return;
    }

    // User-interrupt (Esc) marker. No hook fires for cancellation, so capture
    // the timestamp here for the watchdog to move a stuck session back to
    // waiting-for-input. The entry carries no usage/model, so return early.
    if (
      entry.type === "user" &&
      (entry.interruptedMessageId != null || hasInterruptText(entry.message))
    ) {
      if (entry.timestamp) state.lastInterruptTs = entry.timestamp;
      return;
    }

    // Real turn activity — assistant output or a genuine (non-interrupt) user
    // prompt. Tracking its latest timestamp lets _finalizeState decide whether
    // a later interrupt was superseded by the user resuming (new prompt /
    // model output) or is still the unrecovered tail of the transcript.
    if ((entry.type === "assistant" || entry.type === "user") && entry.timestamp) {
      if (!state.lastTurnTs || entry.timestamp > state.lastTurnTs)
        state.lastTurnTs = entry.timestamp;
    }

    // First real user prompt — captured once (first wins; the file is parsed
    // in order). extractFirstUserText filters out tool-result, meta, and
    // slash-command plumbing entries so only human-typed text qualifies.
    if (state.firstUserMessage === null && entry.type === "user") {
      const firstText = extractFirstUserText(entry);
      if (firstText) state.firstUserMessage = firstText;
    }

    if (entry.isCompactSummary) {
      if (!state.compaction) state.compaction = { count: 0, entries: [] };
      state.compaction.count++;
      state.compaction.entries.push({
        uuid: entry.uuid || null,
        timestamp: entry.timestamp || null,
      });
      if (state.compaction.entries.length >= PARSE_TRIM_WATERMARK) {
        this._trimArray(state.compaction.entries);
      }
    }

    if (entry.type === "system" && entry.subtype === "turn_duration" && entry.durationMs) {
      const turnTs = entry.timestamp
        ? typeof entry.timestamp === "number"
          ? new Date(entry.timestamp).toISOString()
          : entry.timestamp
        : null;
      state.turnDurations.push({ durationMs: entry.durationMs, timestamp: turnTs });
      if (state.turnDurations.length >= PARSE_TRIM_WATERMARK) {
        this._trimArray(state.turnDurations);
      }
    }

    const msg = entry.message || entry;
    if (msg.type === "error" && msg.error) {
      state.errors.push({
        type: msg.error.type || "unknown_error",
        message: msg.error.message || "Unknown API error",
        timestamp: entry.timestamp || null,
      });
      if (state.errors.length >= PARSE_TRIM_WATERMARK) {
        this._trimArray(state.errors);
      }
      return;
    }

    if (entry.isApiErrorMessage) {
      const errContent = Array.isArray(entry.message?.content) ? entry.message.content : [];
      const errText = errContent[0]?.text ? errContent[0].text.slice(0, 500) : "Unknown error";
      state.errors.push({
        type: entry.error || "unknown_error",
        message: errText,
        timestamp: entry.timestamp || null,
      });
      if (state.errors.length >= PARSE_TRIM_WATERMARK) {
        this._trimArray(state.errors);
      }
      return;
    }

    const model = msg.model;
    if (!model || model === "<synthetic>" || !msg.usage) return;
    state.latestModel = model;
    // Bucket tokens by the pricing dimensions (speed / geo / tier) so cost can
    // apply fast-mode, data-residency, and Batch modifiers per bucket. The value
    // carries those dimensions so the DB writer can key the row correctly.
    const speed = normalizeSpeed(msg.usage);
    const geo = normalizeGeo(msg.usage);
    const tier = normalizeTier(msg.usage);
    const key = bucketKey(model, speed, geo, tier);
    if (!state.tokensByModel[key]) {
      state.tokensByModel[key] = emptyBucket(model, speed, geo, tier);
    }
    accumulateBucket(state.tokensByModel[key], extractUsageFields(msg.usage));

    if (msg.usage.service_tier) state.usageExtras.service_tiers.add(msg.usage.service_tier);
    if (msg.usage.speed) state.usageExtras.speeds.add(msg.usage.speed);
    if (msg.usage.inference_geo && msg.usage.inference_geo !== "not_available") {
      state.usageExtras.inference_geos.add(msg.usage.inference_geo);
    }

    const msgContent = msg.content || [];
    if (Array.isArray(msgContent)) {
      for (const block of msgContent) {
        if (block.type === "thinking") state.thinkingBlockCount++;
      }
    }
  }

  _finalizeState(state) {
    const hasTokens = Object.keys(state.tokensByModel).length > 0;
    const hasErrors = state.errors.length > 0;
    const hasTurnDurations = state.turnDurations.length > 0;
    const hasUsageExtras =
      state.usageExtras.service_tiers.size > 0 ||
      state.usageExtras.speeds.size > 0 ||
      state.usageExtras.inference_geos.size > 0;
    if (
      !hasTokens &&
      !state.compaction &&
      !hasErrors &&
      !hasTurnDurations &&
      !state.thinkingBlockCount &&
      !hasUsageExtras &&
      !state.latestModel &&
      !state.customTitle &&
      !state.aiTitle &&
      !state.firstUserMessage &&
      !state.lastInterruptTs &&
      !state.lastTurnTs
    ) {
      return null;
    }

    this._trimArray(state.errors);
    this._trimArray(state.turnDurations);
    if (state.compaction) this._trimArray(state.compaction.entries);

    // usageExtras are accumulated as Sets and serialized as arrays here, with
    // the same MAX_ARRAY_LEN tail cap applied via _capArrayFromSet.
    const serializedExtras = hasUsageExtras
      ? {
          service_tiers: this._capArrayFromSet(state.usageExtras.service_tiers),
          speeds: this._capArrayFromSet(state.usageExtras.speeds),
          inference_geos: this._capArrayFromSet(state.usageExtras.inference_geos),
        }
      : null;

    return {
      tokensByModel: hasTokens ? state.tokensByModel : null,
      compaction: state.compaction,
      errors: hasErrors ? state.errors : null,
      turnDurations: hasTurnDurations ? state.turnDurations : null,
      thinkingBlockCount: state.thinkingBlockCount,
      usageExtras: serializedExtras,
      latestModel: state.latestModel,
      customTitle: state.customTitle,
      aiTitle: state.aiTitle,
      firstUserMessage: state.firstUserMessage,
      lastInterruptTs: state.lastInterruptTs,
      lastTurnTs: state.lastTurnTs,
      pendingInterrupt: computePendingInterrupt(state.lastInterruptTs, state.lastTurnTs),
    };
  }

  /**
   * Parse an in-memory JSONL string. Retained for callers that already have
   * the content as a string. Internal extraction paths now use _streamRange
   * directly to avoid the V8 string-length limit on multi-hundred-MiB files.
   */
  _parseContent(content) {
    const state = this._initParseState();
    let start = 0;
    for (let i = 0; i < content.length; i++) {
      if (content.charCodeAt(i) !== 10) continue;
      let line = content.slice(start, i);
      if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
      if (line) this._consumeLine(line, state);
      start = i + 1;
    }
    if (start < content.length) {
      let line = content.slice(start);
      if (line.length && line.charCodeAt(line.length - 1) === 13) line = line.slice(0, -1);
      if (line) this._consumeLine(line, state);
    }
    return this._finalizeState(state);
  }

  _merge(cached, incremental) {
    const tokensByModel = cached.result?.tokensByModel
      ? this._cloneTokens(cached.result.tokensByModel)
      : {};
    if (incremental && incremental.tokensByModel) {
      for (const [key, tokens] of Object.entries(incremental.tokensByModel)) {
        if (!tokensByModel[key]) {
          tokensByModel[key] = emptyBucket(tokens.model, tokens.speed, tokens.geo, tokens.tier);
        }
        accumulateBucket(tokensByModel[key], tokens);
      }
    }

    let compaction = cached.result?.compaction
      ? this._cloneCompaction(cached.result.compaction)
      : null;
    if (incremental && incremental.compaction) {
      if (!compaction) compaction = { count: 0, entries: [] };
      compaction.count += incremental.compaction.count;
      compaction.entries.push(...incremental.compaction.entries);
      this._trimArray(compaction.entries);
    }

    let errors = cached.result?.errors ? [...cached.result.errors] : null;
    if (incremental && incremental.errors) {
      if (!errors) errors = [];
      errors.push(...incremental.errors);
      this._trimArray(errors);
    }

    let turnDurations = cached.result?.turnDurations ? [...cached.result.turnDurations] : null;
    if (incremental && incremental.turnDurations) {
      if (!turnDurations) turnDurations = [];
      turnDurations.push(...incremental.turnDurations);
      this._trimArray(turnDurations);
    }

    const thinkingBlockCount =
      (cached.result?.thinkingBlockCount || 0) + (incremental?.thinkingBlockCount || 0);

    let usageExtras = cached.result?.usageExtras
      ? this._cloneUsageExtras(cached.result.usageExtras)
      : null;
    if (incremental && incremental.usageExtras) {
      if (!usageExtras) {
        usageExtras = { service_tiers: [], speeds: [], inference_geos: [] };
      }
      // Merge and deduplicate
      const merged = {
        service_tiers: new Set([
          ...usageExtras.service_tiers,
          ...incremental.usageExtras.service_tiers,
        ]),
        speeds: new Set([...usageExtras.speeds, ...incremental.usageExtras.speeds]),
        inference_geos: new Set([
          ...usageExtras.inference_geos,
          ...incremental.usageExtras.inference_geos,
        ]),
      };
      usageExtras = {
        service_tiers: this._capArrayFromSet(merged.service_tiers),
        speeds: this._capArrayFromSet(merged.speeds),
        inference_geos: this._capArrayFromSet(merged.inference_geos),
      };
    }

    // JSONL is append-only and parsed in order, so the incremental block's
    // latestModel (when present) is the newest reading — fall back to the
    // previously-cached value when the new chunk had no assistant entries.
    const latestModel =
      (incremental && incremental.latestModel) || cached.result?.latestModel || null;

    // Same append-only logic for the session titles: the newest title line in
    // the incremental chunk wins, else keep what was cached.
    const customTitle =
      (incremental && incremental.customTitle) || cached.result?.customTitle || null;
    const aiTitle = (incremental && incremental.aiTitle) || cached.result?.aiTitle || null;

    // First user message is first-wins (the opposite of the titles): the
    // cached value was parsed from earlier in the file, so it stays; the
    // incremental chunk only fills it when nothing was captured before.
    const firstUserMessage =
      cached.result?.firstUserMessage || (incremental && incremental.firstUserMessage) || null;

    // Append-only: a newer interrupt / turn-activity timestamp in the
    // incremental chunk supersedes the cached one, otherwise keep what was
    // already known. pendingInterrupt is derived from the two by the caller.
    const lastInterruptTs =
      (incremental && incremental.lastInterruptTs) || cached.result?.lastInterruptTs || null;
    const lastTurnTs = (incremental && incremental.lastTurnTs) || cached.result?.lastTurnTs || null;

    return {
      tokensByModel,
      compaction,
      errors,
      turnDurations,
      thinkingBlockCount,
      usageExtras,
      latestModel,
      customTitle,
      aiTitle,
      firstUserMessage,
      lastInterruptTs,
      lastTurnTs,
    };
  }

  _cloneTokens(tokensByModel) {
    if (!tokensByModel) return null;
    const clone = {};
    for (const [model, t] of Object.entries(tokensByModel)) {
      clone[model] = { ...t };
    }
    return clone;
  }

  _cloneCompaction(compaction) {
    if (!compaction) return null;
    return { count: compaction.count, entries: compaction.entries.map((e) => ({ ...e })) };
  }

  _cloneUsageExtras(extras) {
    if (!extras) return null;
    return {
      service_tiers: [...(extras.service_tiers || [])],
      speeds: [...(extras.speeds || [])],
      inference_geos: [...(extras.inference_geos || [])],
    };
  }

  /** Set cache entry with LRU eviction when at capacity */
  _set(key, entry) {
    // Delete first so re-insertion moves key to end of Map iteration order
    this._cache.delete(key);
    this._cache.set(key, entry);
    // Evict oldest entries (first in Map iteration order) if over limit
    while (this._cache.size > this._maxEntries) {
      const oldest = this._cache.keys().next().value;
      this._cache.delete(oldest);
    }
  }

  /** Trim an array in-place to keep only the last `maxLen` items. No-op on falsy. */
  _trimArray(arr, maxLen = MAX_ARRAY_LEN) {
    if (!arr || !Array.isArray(arr) || arr.length <= maxLen) return;
    arr.splice(0, arr.length - maxLen);
  }

  /** Convert Set to array with the same MAX_ARRAY_LEN tail cap. */
  _capArrayFromSet(set) {
    const arr = [...set];
    this._trimArray(arr);
    return arr;
  }

  /** Number of entries currently cached */
  get size() {
    return this._cache.size;
  }

  /** Remove a specific path from cache */
  invalidate(transcriptPath) {
    this._cache.delete(transcriptPath);
  }

  /** Clear all cached entries */
  clear() {
    this._cache.clear();
  }

  /** Return cache stats for diagnostics */
  stats() {
    const total = this._hits + this._misses;
    return {
      size: this._cache.size,
      maxSize: this._maxEntries,
      hits: this._hits,
      misses: this._misses,
      hitRate: total > 0 ? +((this._hits / total) * 100).toFixed(1) : 0,
      keys: [...this._cache.keys()],
    };
  }
}

module.exports = TranscriptCache;
module.exports.extractFirstUserText = extractFirstUserText;
