'use strict';

// Structured capture: provider-neutral structured messages for coding-agent
// transcripts (Claude Code JSONL, Codex rollouts). A structured message is a
// normal {role, text, timestamp} message plus a `metadata` object describing
// what the text represents (tool call, tool result, reasoning, patch, ...).
//
// Contract notes the parsers and renderer rely on:
// - role is ALWAYS 'system' so every role-based heuristic (turn starts,
//   exclusions, prompt index, title pickers) keeps working unchanged.
// - `text` is a human-readable fallback: legacy clients and FTS index it, and
//   the renderer uses it when it doesn't recognize `metadata.kind`.
// - metadata NEVER carries parentUuid/uuid — conversation-tail-merge reads
//   those as dedup identity keys and would collapse structured messages into
//   their sibling assistant message.
// - tool_call / tool_result are separate messages paired at RENDER time via
//   metadata.callId; pairing at parse time would require mutating
//   already-persisted rows, breaking the append-only row-store fast path.

const STRUCTURED_META_VERSION = 1;

const ARGS_PREVIEW_CAP = 2048;
const TOOL_RESULT_TEXT_CAP = 4096;
const REASONING_TEXT_CAP = 8192;
const PATCH_TEXT_CAP = 16384;
const COMPACT_SUMMARY_CAP = 8192;
const STRUCTURED_PATCH_HUNK_LINE_CAP = 400;
const RAW_JSON_CAP = 4096;
const SHELL_COMMAND_CAP = 2048;

function structuredCaptureEnabled() {
  return process.env.CTM_STRUCTURED_CAPTURE !== '0';
}

function capText(value, cap) {
  const text = String(value == null ? '' : value);
  const limit = Number.isFinite(cap) && cap > 0 ? Math.floor(cap) : text.length;
  if (text.length <= limit) {
    return { text, truncated: false, originalBytes: Buffer.byteLength(text, 'utf8') };
  }
  return {
    text: text.slice(0, limit),
    truncated: true,
    originalBytes: Buffer.byteLength(text, 'utf8'),
  };
}

const PATCH_SENTINEL = '*** Begin Patch';
const PATCH_FILE_RE = /^\*\*\* (?:Update|Add|Delete) File: (.+)$/gm;

function patchFilesFromText(patchText) {
  const files = [];
  const seen = new Set();
  let match;
  PATCH_FILE_RE.lastIndex = 0;
  while ((match = PATCH_FILE_RE.exec(patchText)) !== null) {
    const file = match[1].trim();
    if (file && !seen.has(file)) { seen.add(file); files.push(file); }
  }
  return files;
}

// Detect an apply_patch payload in any of the three forms Codex emits:
// 1. function_call name="apply_patch" whose `arguments` is a JSON string
//    (or pre-parsed object) shaped {"input": "*** Begin Patch..."}.
// 2. custom_tool_call with the raw patch text as `input`.
// 3. a shell call whose argv is ["apply_patch", "*** Begin Patch..."] or
//    whose command string embeds the patch sentinel.
function detectApplyPatch({ name, argumentsJson, input, command } = {}) {
  const fromText = (text) => {
    const patchText = String(text == null ? '' : text);
    if (!patchText.includes(PATCH_SENTINEL)) return null;
    const start = patchText.indexOf(PATCH_SENTINEL);
    const trimmed = patchText.slice(start);
    return { patchText: trimmed, files: patchFilesFromText(trimmed) };
  };

  if (String(name || '').trim() === 'apply_patch' && argumentsJson != null) {
    let args = argumentsJson;
    if (typeof args === 'string') {
      try { args = JSON.parse(args); } catch { return fromText(args); }
    }
    if (args && typeof args === 'object') {
      const found = fromText(args.input != null ? args.input : args.patch);
      if (found) return found;
    }
  }

  if (input != null) {
    const found = fromText(input);
    if (found) return found;
  }

  if (Array.isArray(command)) {
    if (String(command[0] || '').trim().endsWith('apply_patch')) {
      for (const part of command.slice(1)) {
        const found = fromText(part);
        if (found) return found;
      }
    }
    const joined = command.join(' ');
    if (joined.includes(PATCH_SENTINEL)) return fromText(joined);
  } else if (typeof command === 'string' && command.includes(PATCH_SENTINEL)) {
    return fromText(command);
  }

  return null;
}

// Bound a Claude `toolUseResult.structuredPatch` (array of jsdiff-style hunks
// {oldStart, oldLines, newStart, newLines, lines}) to a total line budget so a
// giant edit can't bloat a stored row.
function summarizeStructuredPatch(structuredPatch) {
  if (!Array.isArray(structuredPatch) || !structuredPatch.length) return null;
  const out = [];
  let budget = STRUCTURED_PATCH_HUNK_LINE_CAP;
  let truncated = false;
  for (const hunk of structuredPatch) {
    if (!hunk || typeof hunk !== 'object') continue;
    if (budget <= 0) { truncated = true; break; }
    const lines = Array.isArray(hunk.lines) ? hunk.lines : [];
    let kept = lines;
    if (lines.length > budget) {
      kept = lines.slice(0, budget);
      truncated = true;
    }
    budget -= kept.length;
    out.push({
      oldStart: hunk.oldStart,
      oldLines: hunk.oldLines,
      newStart: hunk.newStart,
      newLines: hunk.newLines,
      lines: kept.map(l => String(l)),
    });
  }
  if (!out.length) return null;
  return { structuredPatch: out, truncated };
}

function buildMessage(kind, provider, text, timestamp, fields) {
  const metadata = { v: STRUCTURED_META_VERSION, kind };
  if (provider) metadata.provider = provider;
  for (const [key, value] of Object.entries(fields || {})) {
    if (value === undefined || value === null || value === '') continue;
    metadata[key] = value;
  }
  const message = { role: 'system', text };
  if (timestamp !== undefined && timestamp !== null && timestamp !== '') {
    message.timestamp = timestamp;
  }
  message.metadata = metadata;
  return message;
}

function toolCallMessage({ provider, tool, callId, args, timestamp } = {}) {
  const toolName = String(tool || '').trim() || 'tool';
  let argsText = '';
  if (typeof args === 'string') argsText = args;
  else if (args !== undefined && args !== null) {
    try { argsText = JSON.stringify(args); } catch { argsText = String(args); }
  }
  const preview = capText(argsText, ARGS_PREVIEW_CAP);
  return buildMessage('tool_call', provider, `[Tool: ${toolName}]`, timestamp, {
    tool: toolName,
    callId,
    argsPreview: preview.text,
    truncated: preview.truncated || undefined,
    originalBytes: preview.truncated ? preview.originalBytes : undefined,
  });
}

function toolResultMessage({
  provider, tool, callId, output, exitCode, durationMs,
  filePath, structuredPatch, isError, timestamp,
} = {}) {
  const capped = capText(output, TOOL_RESULT_TEXT_CAP);
  const label = String(tool || '').trim();
  const text = `[Tool result${label ? `: ${label}` : ''}]${capped.text ? `\n${capped.text}` : ''}`;
  const patch = summarizeStructuredPatch(structuredPatch);
  return buildMessage('tool_result', provider, text, timestamp, {
    tool: label || undefined,
    callId,
    exitCode: Number.isFinite(exitCode) ? exitCode : undefined,
    durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
    filePath,
    structuredPatch: patch ? patch.structuredPatch : undefined,
    isError: isError === true || undefined,
    truncated: (capped.truncated || (patch && patch.truncated)) || undefined,
    originalBytes: capped.truncated ? capped.originalBytes : undefined,
  });
}

function shellMessage({ provider, command, callId, timestamp } = {}) {
  const commandText = Array.isArray(command) ? command.join(' ') : String(command == null ? '' : command);
  const capped = capText(commandText, SHELL_COMMAND_CAP);
  return buildMessage('shell', provider, `[Shell] ${capped.text}`, timestamp, {
    command: capped.text,
    callId,
    truncated: capped.truncated || undefined,
    originalBytes: capped.truncated ? capped.originalBytes : undefined,
  });
}

function patchMessage({ provider, patchText, files, structuredPatch, filePath, callId, timestamp } = {}) {
  const capped = capText(patchText, PATCH_TEXT_CAP);
  const fileList = Array.isArray(files) && files.length
    ? files
    : (capped.text ? patchFilesFromText(capped.text) : []);
  const header = fileList.length ? `[Patch] ${fileList.join(', ')}` : '[Patch]';
  const patch = summarizeStructuredPatch(structuredPatch);
  return buildMessage('patch', provider, `${header}${capped.text ? `\n${capped.text}` : ''}`, timestamp, {
    files: fileList.length ? fileList : undefined,
    filePath,
    callId,
    structuredPatch: patch ? patch.structuredPatch : undefined,
    truncated: (capped.truncated || (patch && patch.truncated)) || undefined,
    originalBytes: capped.truncated ? capped.originalBytes : undefined,
  });
}

function reasoningMessage({ provider, text, encrypted, timestamp } = {}) {
  if (encrypted && !String(text || '').trim()) {
    return buildMessage('reasoning', provider, '[Reasoning (encrypted)]', timestamp, {
      encrypted: true,
    });
  }
  const capped = capText(text, REASONING_TEXT_CAP);
  return buildMessage('reasoning', provider, `[Reasoning]${capped.text ? `\n${capped.text}` : ''}`, timestamp, {
    truncated: capped.truncated || undefined,
    originalBytes: capped.truncated ? capped.originalBytes : undefined,
  });
}

function webSearchMessage({ provider, query, timestamp } = {}) {
  const queryText = capText(query, ARGS_PREVIEW_CAP);
  return buildMessage('web_search', provider, `[Web search] ${queryText.text}`.trim(), timestamp, {
    query: queryText.text || undefined,
  });
}

function compactBoundaryMessage({ provider, trigger, preTokens, logicalParentUuid, timestamp } = {}) {
  return buildMessage('compact_boundary', provider, '[Conversation compacted]', timestamp, {
    trigger: String(trigger || '').trim() || undefined,
    preTokens: Number.isFinite(preTokens) ? preTokens : undefined,
    logicalParentUuid: String(logicalParentUuid || '').trim() || undefined,
  });
}

function compactSummaryMessage({ provider, text, timestamp } = {}) {
  const capped = capText(text, COMPACT_SUMMARY_CAP);
  return buildMessage('compact_summary', provider, capped.text, timestamp, {
    truncated: capped.truncated || undefined,
    originalBytes: capped.truncated ? capped.originalBytes : undefined,
  });
}

// Forward-compat: an entry type the parser does not recognize. The capped raw
// JSON keeps it inspectable in the UI instead of silently dropped.
function unknownMessage({ provider, kindHint, raw, timestamp } = {}) {
  let rawText = '';
  if (typeof raw === 'string') rawText = raw;
  else if (raw !== undefined && raw !== null) {
    try { rawText = JSON.stringify(raw); } catch { rawText = String(raw); }
  }
  const capped = capText(rawText, RAW_JSON_CAP);
  const hint = String(kindHint || '').trim();
  return buildMessage('unknown', provider, `[Unrecorded event${hint ? `: ${hint}` : ''}]`, timestamp, {
    kindHint: hint || undefined,
    raw: capped.text || undefined,
    truncated: capped.truncated || undefined,
    originalBytes: capped.truncated ? capped.originalBytes : undefined,
  });
}

module.exports = {
  STRUCTURED_META_VERSION,
  ARGS_PREVIEW_CAP,
  TOOL_RESULT_TEXT_CAP,
  REASONING_TEXT_CAP,
  PATCH_TEXT_CAP,
  COMPACT_SUMMARY_CAP,
  STRUCTURED_PATCH_HUNK_LINE_CAP,
  RAW_JSON_CAP,
  SHELL_COMMAND_CAP,
  structuredCaptureEnabled,
  capText,
  detectApplyPatch,
  summarizeStructuredPatch,
  toolCallMessage,
  toolResultMessage,
  shellMessage,
  patchMessage,
  reasoningMessage,
  webSearchMessage,
  compactBoundaryMessage,
  compactSummaryMessage,
  unknownMessage,
};
