'use strict';

// Shared JSONL → conversation-message parser.
//
// Extracted VERBATIM from server.js so the same parser can run on the main
// thread AND inside the read-pool worker (workers/read-pool-worker.js) without
// forking behavior. The Conversation tab and the #review= page both render the
// output of this parser via public/js/message-renderer.js, so any divergence
// here would break Conversation/Review render parity — the functions below must
// stay byte-for-byte identical to what server.js used. server.js now re-exports
// these (alias) instead of defining its own copies.
//
// Dependencies are already standalone modules (no server.js coupling):
//   - parseCodexJsonlIntoMessages / parseCodexJsonlFileIntoMessages /
//     createCodexUserDeduper  (lib/session-history)  — Codex rollout format
//   - isProviderGeneratedUserContextText (lib/provider-user-context)
//   - sortTimelineMessagesInPlace (lib/timeline-order)
//   - node:fs, node:path

const fs = require('node:fs');
const path = require('node:path');
const {
  parseCodexJsonlIntoMessages,
  parseCodexJsonlFileIntoMessages,
  createCodexUserDeduper,
} = require('./session-history');
const { isZstRolloutFileName, materializeCodexRollout } = require('./codex-zst');
const { isProviderGeneratedUserContextText } = require('./provider-user-context');
const { sortTimelineMessagesInPlace } = require('./timeline-order');
const {
  structuredCaptureEnabled,
  compactBoundaryMessage,
  compactSummaryMessage,
  reasoningMessage,
  toolCallMessage,
  toolResultMessage,
} = require('./structured-capture');

// Usage fields worth persisting on assistant messages (token chips).
function _usageMetadata(usage) {
  if (!usage || typeof usage !== 'object') return null;
  const out = {};
  for (const key of ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']) {
    const value = Number(usage[key]);
    if (Number.isFinite(value) && value >= 0) out[key] = value;
  }
  return Object.keys(out).length ? out : null;
}

const _SESSION_FILE_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

// Session id a Claude JSONL file OWNS, derived from its filename stem
// (`<uuid>.jsonl` / `<uuid>.jsonl.bak`). '' when the stem isn't a UUID
// (codex rollouts, walle history files).
function claudeFileSessionId(filePath) {
  const stem = path.basename(String(filePath || '')).replace(/\.jsonl(\.bak)?$/i, '');
  return _SESSION_FILE_UUID_RE.test(stem) ? stem : '';
}

// Byte budgets for the multi-file parse. Kept here (not server.js) so the read
// pool applies the identical thresholds. Values match the originals in server.js.
const LARGE_SESSION_TAIL_THRESHOLD_BYTES = 50 * 1024 * 1024;
const LARGE_SESSION_TAIL_READ_BYTES = 10 * 1024 * 1024;
const LATENCY_SENSITIVE_SYNC_BUDGET_BYTES = 12 * 1024 * 1024;
const PARSE_MAX_SYNC_SIZE = 10 * 1024 * 1024; // 10MB total budget for non-latency parse
const PARSE_SMALL_FILE_THRESHOLD = 1024 * 1024; // always read small (subagent) files

function _jsonlContentText(content) {
  if (typeof content === 'string') return content;
  if (!Array.isArray(content)) return '';
  const parts = [];
  let imageCount = 0;
  for (const block of content) {
    if (!block || typeof block !== 'object') continue;
    if ((block.type === 'text' || block.type === 'input_text') && block.text) {
      parts.push(block.text);
    } else if (block.type === 'image' || block.type === 'input_image' || block.type === 'image_ref') {
      parts.push(`[Image #${++imageCount}]`);
    }
  }
  return parts.join('\n');
}

function _isClaudeSubagentJsonlPath(filePath) {
  return String(filePath || '').replace(/\\/g, '/').includes('/subagents/');
}

// Subagent linkage: `subagents/agent-<id>.jsonl` has a sibling
// `agent-<id>.meta.json` carrying {agentType, description, toolUseId} —
// toolUseId equals the spawning Task tool_use's id (the tool_call row's
// callId), so nesting needs no prompt-matching. Cached per file path.
const _subagentFileInfoCache = new Map();
function _readSubagentFileInfo(filePath) {
  const key = String(filePath || '');
  if (_subagentFileInfoCache.has(key)) return _subagentFileInfoCache.get(key);
  const idMatch = path.basename(key).match(/^agent-(.+)\.jsonl$/i);
  const info = { id: idMatch ? idMatch[1] : '', agentType: '', description: '', parentToolUseId: '' };
  if (idMatch) {
    try {
      const meta = JSON.parse(fs.readFileSync(key.replace(/\.jsonl$/i, '.meta.json'), 'utf8'));
      if (meta && typeof meta === 'object') {
        info.agentType = typeof meta.agentType === 'string' ? meta.agentType : '';
        info.description = typeof meta.description === 'string' ? meta.description.slice(0, 200) : '';
        info.parentToolUseId = typeof meta.toolUseId === 'string' ? meta.toolUseId : '';
      }
    } catch { /* meta.json missing — linkage falls back to render-time matching */ }
  }
  if (_subagentFileInfoCache.size > 512) _subagentFileInfoCache.clear();
  _subagentFileInfoCache.set(key, info);
  return info;
}

function _parseJsonlIntoMessages(content, messages, opts = {}) {
  if (content.includes('"type":"session_meta"') && content.includes('"originator":"codex-tui"')) {
    parseCodexJsonlIntoMessages(content, messages);
    return;
  }
  const sourceFile = opts && opts.sourceFile ? path.resolve(opts.sourceFile) : '';
  const sourceIsSubagent = _isClaudeSubagentJsonlPath(sourceFile);
  const subagentFileInfo = sourceIsSubagent ? _readSubagentFileInfo(sourceFile) : null;
  const summaries = opts && Array.isArray(opts.summaries) ? opts.summaries : null;
  // Line-level sessionId of the entry currently being parsed. Stamped on each
  // message so parseSessionFiles can drop inherited post-compact prefixes
  // (a new Claude session file starts with the PARENT file's lines, old
  // sessionId at the top, switching mid-file).
  let entrySessionId = '';
  // Per-line sidechain identity (inline Task-subagent turns carry
  // isSidechain/agentId/slug on the entry itself).
  let entrySidechain = null;
  let entrySlug = '';
  // Last contiguous run of messages emitted for one assistant parentUuid
  // (structured emission replaces the whole run when the row re-streams).
  let lastAssistantRun = null;
  const pushMessage = (msg, lineIdx) => {
    if (sourceFile) {
      msg._sourceFile = sourceFile;
      msg._sourceLine = lineIdx;
    }
    if (entrySessionId) msg._entrySessionId = entrySessionId;
    if (sourceIsSubagent || entrySidechain) {
      const metadata = msg.metadata && typeof msg.metadata === 'object' ? msg.metadata : {};
      msg.metadata = { ...metadata, sourceKind: 'subagent' };
      // Nesting contract: which subagent this row belongs to and which Task
      // tool_use spawned it (parentToolUseId == the Task tool_call's callId).
      msg.metadata.subagent = {
        id: (subagentFileInfo && subagentFileInfo.id) || (entrySidechain && entrySidechain.id) || '',
        slug: entrySlug || '',
        agentType: (subagentFileInfo && subagentFileInfo.agentType) || '',
        description: (subagentFileInfo && subagentFileInfo.description) || '',
        parentToolUseId: (subagentFileInfo && subagentFileInfo.parentToolUseId) || '',
      };
      if (msg.role === 'user') {
        msg.metadata.originalRole = msg.metadata.originalRole || 'user';
        msg.metadata.promptKind = msg.metadata.promptKind || 'subagent';
        msg.role = 'system';
        msg.roleLabel = msg.roleLabel || 'Subagent prompt';
        msg.agentLabel = msg.agentLabel || 'Subagent prompt';
      } else if (msg.role === 'assistant') {
        msg.roleLabel = msg.roleLabel || 'Subagent';
        msg.agentLabel = msg.agentLabel || 'Subagent';
      }
    }
    messages.push(msg);
    return msg;
  };
  const lines = content.split('\n');

  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
    const line = lines[lineIdx];
    if (!line.trim()) continue;
    try {
      const entry = JSON.parse(line);
      entrySessionId = typeof entry.sessionId === 'string' ? entry.sessionId : '';
      // Inline sidechain rows (Task subagents written into the SAME file by
      // older Claude Code versions). Gated: relabeling user→system changes
      // turn starts, so only new imports get it.
      entrySlug = typeof entry.slug === 'string' ? entry.slug : '';
      entrySidechain = !sourceIsSubagent && entry.isSidechain === true && structuredCaptureEnabled()
        ? { id: typeof entry.agentId === 'string' ? entry.agentId : '' }
        : null;
      if (entry.type === 'summary' && typeof entry.summary === 'string') {
        // Async-generated session title ({summary, leafUuid}); not a turn.
        if (summaries && entry.summary.trim()) {
          summaries.push({ summary: entry.summary.trim(), leafUuid: entry.leafUuid || '' });
        }
        continue;
      }
      if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
        if (structuredCaptureEnabled()) {
          const compactMeta = entry.compactMetadata || {};
          pushMessage(compactBoundaryMessage({
            provider: 'claude',
            trigger: compactMeta.trigger,
            preTokens: Number(compactMeta.preTokens),
            logicalParentUuid: entry.logicalParentUuid,
            timestamp: entry.timestamp,
          }), lineIdx);
        }
        continue;
      }
      if (entry.type === 'user' && entry.message?.role === 'user') {
        const c = entry.message.content;
        let text = _jsonlContentText(c);
        // Detect system/tool messages masquerading as user messages
        const isToolResult = Array.isArray(c) && c.some(b => b.type === 'tool_result');
        // _jsonlContentText only reads text/image blocks, so a pure tool_result
        // entry has empty text — structured capture still wants its blocks.
        if (!text && !(isToolResult && structuredCaptureEnabled())) continue;
        // Post-compact synthetic context summary: render as a collapsed
        // structured row under the compact divider, never as a user prompt.
        if (structuredCaptureEnabled() && (entry.isCompactSummary || entry.isVisibleInTranscriptOnly)) {
          pushMessage(compactSummaryMessage({ provider: 'claude', text, timestamp: entry.timestamp }), lineIdx);
          continue;
        }
        const isTaskNotification = text.includes('<task-notification>') || (text.includes('toolu_') && text.includes('.output'));
        const isSystemReminder = text.includes('<system-reminder>') && !text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
        if (!isToolResult && !isTaskNotification && isProviderGeneratedUserContextText(text)) {
          continue;
        }
        if (isToolResult && structuredCaptureEnabled()) {
          // One structured tool_result message per block, paired to its
          // tool_use at render time via callId. The top-level toolUseResult
          // carries the rich result (structuredPatch for Edit, filePath,
          // durationMs) — attach it when the entry has a single result block.
          const resultBlocks = c.filter(b => b && b.type === 'tool_result');
          const tur = resultBlocks.length === 1 && entry.toolUseResult && typeof entry.toolUseResult === 'object'
            ? entry.toolUseResult : null;
          for (const block of resultBlocks) {
            const blockText = typeof block.content === 'string' ? block.content : _jsonlContentText(block.content);
            pushMessage(toolResultMessage({
              provider: 'claude',
              callId: block.tool_use_id,
              output: blockText || (tur && typeof tur.stdout === 'string' ? tur.stdout : ''),
              isError: block.is_error === true,
              filePath: tur ? (tur.filePath || tur.file_path) : undefined,
              durationMs: tur ? Number(tur.durationMs) : undefined,
              structuredPatch: tur ? tur.structuredPatch : undefined,
              timestamp: entry.timestamp,
            }), lineIdx);
          }
        } else if (isToolResult || isTaskNotification || isSystemReminder) {
          pushMessage({ role: 'system', text, timestamp: entry.timestamp }, lineIdx);
        } else {
          // Strip system-reminder tags from real user messages
          const cleaned = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
          pushMessage({ role: 'user', text: cleaned || text, timestamp: entry.timestamp }, lineIdx);
        }
      } else if (entry.type === 'user' && entry.provider === 'walle' && typeof entry.content === 'string') {
        pushMessage({ role: 'user', text: entry.content, timestamp: entry.timestamp }, lineIdx);
      } else if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
        const c = entry.message.content;
        if (!Array.isArray(c)) continue;
        if (structuredCaptureEnabled()) {
          // Structured emission: thinking → reasoning rows, tool_use → tool_call
          // rows (text stays exactly `[Tool: name]`, no longer inlined into the
          // assistant text), text blocks → ONE assistant message carrying
          // parentUuid + usage/model metadata.
          const run = [];
          const textParts = [];
          let textInsertIndex = -1; // keep block order: thinking → text → tool_use
          for (const block of c) {
            if (!block || typeof block !== 'object') continue;
            if (block.type === 'text' && block.text) {
              if (textInsertIndex === -1) textInsertIndex = run.length;
              textParts.push(block.text);
            } else if (block.type === 'thinking' && (block.thinking || block.text)) {
              run.push(reasoningMessage({ provider: 'claude', text: block.thinking || block.text, timestamp: entry.timestamp }));
            } else if (block.type === 'tool_use') {
              const callMsg = toolCallMessage({
                provider: 'claude', tool: block.name, callId: block.id, args: block.input, timestamp: entry.timestamp,
              });
              // Task spawns: surface the subagent identity on the tool_call
              // row so subagent rows (metadata.subagent.parentToolUseId ==
              // this callId) can nest under it at render time.
              if (block.name === 'Task' && block.input && typeof block.input === 'object') {
                if (typeof block.input.subagent_type === 'string') callMsg.metadata.taskAgentType = block.input.subagent_type;
                if (typeof block.input.description === 'string') callMsg.metadata.taskDescription = block.input.description.slice(0, 200);
              }
              run.push(callMsg);
            }
          }
          if (textParts.length) {
            // parentUuid rides ONLY on the assistant text message — it is the
            // stream-tail merge's dedup identity; putting it on tool/reasoning
            // rows would collapse them into this message.
            const msg = { role: 'assistant', text: textParts.join('\n'), timestamp: entry.timestamp, parentUuid: entry.parentUuid };
            const usage = _usageMetadata(entry.message.usage);
            const model = typeof entry.message.model === 'string' ? entry.message.model : '';
            if (usage || model) {
              msg.metadata = {};
              if (usage) msg.metadata.usage = usage;
              if (model) msg.metadata.model = model;
            }
            run.splice(textInsertIndex === -1 ? run.length : textInsertIndex, 0, msg);
          }
          if (run.length) {
            // Streaming increments re-emit the SAME provider row (same
            // parentUuid) with more content. Replace the previous contiguous
            // run for that parentUuid — but only when nothing else landed in
            // between, so interleaved entries are never spliced away.
            if (entry.parentUuid && lastAssistantRun
                && lastAssistantRun.parentUuid === entry.parentUuid
                && lastAssistantRun.end === messages.length) {
              messages.splice(lastAssistantRun.start);
            }
            const start = messages.length;
            for (const m of run) pushMessage(m, lineIdx);
            lastAssistantRun = { parentUuid: entry.parentUuid, start, end: messages.length };
          }
          continue;
        }
        const parts = [];
        for (const block of c) {
          if (block.type === 'text' && block.text) {
            parts.push(block.text);
          } else if (block.type === 'tool_use') {
            parts.push(`[Tool: ${block.name}]`);
          }
        }
        if (parts.length > 0) {
          // Deduplicate: assistant messages arrive incrementally, keep only the last one with same parentUuid.
          // We surface parentUuid on the stored message (not the internal _parent) so the stream-tail merge
          // (lib/conversation-tail-merge.js) and the client renderer can dedup a still-streaming partial in the
          // live ring against the finalized copy in the conversation cache — the strong, timestamp-independent key.
          const lastMsg = messages[messages.length - 1];
          if (lastMsg && lastMsg.role === 'assistant' && lastMsg.parentUuid === entry.parentUuid) {
            lastMsg.text = parts.join('\n');
            if (sourceFile) {
              lastMsg._sourceFile = sourceFile;
              lastMsg._sourceLine = lineIdx;
            }
          } else {
            pushMessage({ role: 'assistant', text: parts.join('\n'), timestamp: entry.timestamp, parentUuid: entry.parentUuid }, lineIdx);
          }
        }
      } else if (entry.type === 'assistant' && entry.provider === 'walle' && typeof entry.content === 'string') {
        if (entry.content) pushMessage({ role: 'assistant', text: entry.content, timestamp: entry.timestamp }, lineIdx);
      } else if (entry.type === 'walle_part') {
        const data = entry.data || {};
        let text = '';
        if (entry.partType === 'tool_call') {
          text = `[Tool: ${data.name || 'tool'}]`;
        } else if (entry.partType === 'tool_result') {
          const result = typeof data.result === 'string' ? data.result : '';
          text = `[Tool result: ${data.name || 'tool'}]${result ? '\n' + result : ''}`;
        } else if (entry.partType === 'error') {
          text = `[Wall-E error] ${data.message || ''}`.trim();
        } else if (entry.partType === 'cancelled') {
          text = '[Wall-E cancelled]';
        }
        if (text) pushMessage({ role: 'system', text, timestamp: entry.timestamp }, lineIdx);
      }
    } catch { /* skip malformed line */ }
  }
}

// Read only the last `maxBytes` of a large file and parse the (line-aligned)
// tail. Moved VERBATIM from server.js so the pool tails identically.
function _parseJsonlTailIntoMessages(filePath, size, messages, opts = {}) {
  const readSize = Math.min(
    Math.max(1, Number(opts.maxBytes) || LARGE_SESSION_TAIL_READ_BYTES),
    Math.max(0, Number(size) || 0)
  );
  if (!readSize) return { bytesRead: 0, partial: true };
  const fd = fs.openSync(filePath, 'r');
  try {
    const buf = Buffer.alloc(readSize);
    fs.readSync(fd, buf, 0, readSize, Math.max(0, size - readSize));
    let content = buf.toString('utf8');
    const nlIdx = content.indexOf('\n');
    if (nlIdx > 0) content = content.slice(nlIdx + 1);
    if (opts.isCodexRollout) {
      const parsed = parseCodexJsonlIntoMessages(content, messages, { codexUserDeduper: opts.codexUserDeduper || opts.seenUsers });
      return { ...parsed, bytesRead: readSize, partial: true };
    }
    _parseJsonlIntoMessages(content, messages, { sourceFile: filePath, summaries: opts.summaries });
    return { bytesRead: readSize, partial: true };
  } finally {
    fs.closeSync(fd);
  }
}

/**
 * Read + parse a session's JSONL file set into a combined, chronologically
 * sorted message list. This is the body of apiSessionMessages' cache-miss path,
 * extracted VERBATIM so it can run EITHER on the main thread or inside the read
 * pool with byte-identical behavior (parity by construction — same code, not a
 * fork). DB-free: it returns `codexParsedFiles` so the caller can write the
 * Codex conversation cache on the main thread.
 *
 * @param {string[]} filePaths
 * @param {Object} opts
 * @param {boolean} [opts.latencySensitiveLargeParse] — tail-read large files under a budget
 * @returns {{ messages, partialLoad, bytesRead, totalFiles, codexParsedFiles }}
 */
function parseSessionFiles(filePaths, opts = {}) {
  const latencySensitiveLargeParse = !!opts.latencySensitiveLargeParse;
  const messages = [];
  const summaries = [];
  let partialLoad = false;

  // Sort files by creation time (earliest first) so messages are chronological.
  // `.jsonl.zst` archives (cold Codex rollouts) are materialized to the disk
  // cache first so size budgets and reads apply to the decompressed bytes;
  // a failed materialization (no zstd / too large) marks a partial load.
  let zstUnavailable = false;
  const filesWithTime = (Array.isArray(filePaths) ? filePaths : []).map(fp => {
    let readPath = fp;
    if (isZstRolloutFileName(fp)) {
      const mat = materializeCodexRollout(fp);
      if (!mat.ok) { zstUnavailable = true; return null; }
      readPath = mat.path;
    }
    try {
      const st = fs.statSync(readPath);
      return { fp: readPath, originalFp: fp, birthtimeMs: st.birthtimeMs, size: st.size };
    } catch { return null; }
  }).filter(Boolean).sort((a, b) => a.birthtimeMs - b.birthtimeMs);
  if (zstUnavailable) partialLoad = true;
  const filesToParse = latencySensitiveLargeParse
    ? [...filesWithTime].sort((a, b) => b.birthtimeMs - a.birthtimeMs)
    : filesWithTime;

  let bytesRead = 0;
  let latencySensitiveBytesRead = 0;
  const codexUserDeduper = createCodexUserDeduper();
  const codexParsedFiles = [];
  for (const { fp, size, originalFp } of filesToParse) {
    // A materialized .zst lives in the cache dir — detect codex-ness from the
    // ORIGINAL path.
    const isCodexRollout = (originalFp || fp).includes(`${path.sep}.codex${path.sep}sessions${path.sep}`)
      || (originalFp ? isZstRolloutFileName(originalFp) : false);
    if (!latencySensitiveLargeParse && !isCodexRollout && bytesRead > 0 && bytesRead + size > PARSE_MAX_SYNC_SIZE && size > PARSE_SMALL_FILE_THRESHOLD) {
      // Budget exceeded — skip large files, but keep going for small subagent files
      partialLoad = true;
      continue; // continue instead of break — small files after this will still be read
    }
    try {
      let fileBytesRead = 0;
      const shouldTailForLatency = latencySensitiveLargeParse && size > PARSE_SMALL_FILE_THRESHOLD;
      if (shouldTailForLatency) {
        const remainingBudget = LATENCY_SENSITIVE_SYNC_BUDGET_BYTES - latencySensitiveBytesRead;
        if (remainingBudget <= 0) {
          partialLoad = true;
          continue;
        }
        const parsed = _parseJsonlTailIntoMessages(fp, size, messages, {
          isCodexRollout,
          codexUserDeduper,
          summaries,
          maxBytes: Math.min(LARGE_SESSION_TAIL_READ_BYTES, remainingBudget),
        });
        fileBytesRead = parsed.bytesRead || 0;
        latencySensitiveBytesRead += fileBytesRead;
        partialLoad = true;
      } else if (isCodexRollout) {
        // Codex rollouts are large because they include tool/event payloads.
        // The semantic user/assistant transcript is much smaller, so stream
        // the whole JSONL file and keep old eval output visible after restart.
        const before = messages.length;
        const parsed = parseCodexJsonlFileIntoMessages(fp, messages, { codexUserDeduper });
        fileBytesRead = parsed.bytesRead || size;
        codexParsedFiles.push({
          fp,
          size,
          parsed,
          messages: messages.slice(before),
        });
      } else if (size > LARGE_SESSION_TAIL_THRESHOLD_BYTES) {
        // For very large individual files (>50MB), read only the last 10MB
        // to show the most recent conversation without blocking the event loop
        const parsed = _parseJsonlTailIntoMessages(fp, size, messages, { summaries });
        fileBytesRead = parsed.bytesRead || 0;
        partialLoad = true; // Signal that we didn't load the full file
      } else {
        const content = fs.readFileSync(fp, 'utf8');
        fileBytesRead = size;
        _parseJsonlIntoMessages(content, messages, { sourceFile: fp, summaries });
      }
      bytesRead += fileBytesRead;
    } catch (e) { console.error(`[session-messages] error reading ${path.basename(fp).slice(0, 8)}:`, e.message); }
  }

  // Cross-file stitching: after /compact, Claude Code starts a NEW session
  // file that INHERITS the parent file's lines (old sessionId at the top,
  // switching mid-file). When the owning file is also in this set — or still
  // on disk next to the child — keep only the owner's copy so merged project
  // dirs don't render the inherited prefix twice. Orphan prefixes (owner file
  // deleted) are kept: they are the only surviving copy.
  const ownedIds = new Set();
  for (const { fp } of filesWithTime) {
    const owned = claudeFileSessionId(fp);
    if (owned) ownedIds.add(owned);
  }
  const ownerOnDiskCache = new Map();
  const ownerExistsOnDisk = (sourceFile, entryId) => {
    const dir = path.dirname(sourceFile);
    const key = `${dir}|${entryId}`;
    if (!ownerOnDiskCache.has(key)) {
      let exists = false;
      try { exists = fs.existsSync(path.join(dir, `${entryId}.jsonl`)); } catch {}
      ownerOnDiskCache.set(key, exists);
    }
    return ownerOnDiskCache.get(key);
  };
  const stitched = messages.filter(m => {
    const entryId = m._entrySessionId;
    if (!entryId || !m._sourceFile) return true;
    const fileId = claudeFileSessionId(m._sourceFile);
    if (!fileId || entryId === fileId) return true;
    if (!_SESSION_FILE_UUID_RE.test(entryId)) return true;
    return !(ownedIds.has(entryId) || ownerExistsOnDisk(m._sourceFile, entryId));
  });

  // Clean internal fields. NOTE: parentUuid is intentionally retained — it is the
  // stream-tail merge's strongest dedup key (collapses a live-ring streaming partial
  // against the cached finalized turn), and the client renderer reads it for data-parent-uuid.
  stitched.forEach(m => {
    delete m._sourceFile;
    delete m._sourceLine;
    delete m._entrySessionId;
  });

  // Sort all messages by timestamp across files
  sortTimelineMessagesInPlace(stitched);

  return { messages: stitched, partialLoad, bytesRead, totalFiles: filesWithTime.length, codexParsedFiles, summaries };
}

module.exports = {
  _jsonlContentText,
  _isClaudeSubagentJsonlPath,
  _parseJsonlIntoMessages,
  _parseJsonlTailIntoMessages,
  parseSessionFiles,
  claudeFileSessionId,
  _usageMetadata,
  // thresholds exported so server.js can keep its own MAX_SYNC_SIZE checks aligned
  LARGE_SESSION_TAIL_THRESHOLD_BYTES,
  LARGE_SESSION_TAIL_READ_BYTES,
  LATENCY_SENSITIVE_SYNC_BUDGET_BYTES,
};
