import { readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, extname, join } from "node:path";
import type { ContentBlock, ParsedTurn, SessionInfo } from "@vibe-replay/provider-contract";
import type { DataSourceInfo, ProviderParseResult } from "@vibe-replay/provider-contract";
import {
  sanitizeCursorAssistantText,
  sanitizeCursorReasoningText,
  sanitizeCursorUserText,
} from "./sanitize.js";
import {
  applySdkEnrichmentToTurns,
  findSdkAgentById,
  loadSdkAgentEnrichment,
  type SdkAgentEnrichment,
} from "./sdk-reader.js";
import {
  isSystemContextText,
  mapCursorToolName,
  mapToolArgs,
  parseCursorSqlite,
} from "./sqlite-reader.js";
import { addParseWarning } from "@vibe-replay/provider-contract/warnings";

export interface CursorParserDependencies {
  isSystemContextText: (text: string) => boolean;
  mapCursorToolName: (name: string) => string;
  mapToolArgs: (toolName: string, args: unknown, resultText?: string) => Record<string, any>;
  parseCursorSqlite: (
    workspacePath: string,
    sessionId: string,
  ) => Promise<ProviderParseResult | null>;
}

const defaultDependencies: CursorParserDependencies = {
  isSystemContextText,
  mapCursorToolName,
  mapToolArgs,
  parseCursorSqlite,
};

export function createCursorParser(deps: Partial<CursorParserDependencies> = {}) {
  const resolved: CursorParserDependencies = { ...defaultDependencies, ...deps };
  return (filePaths: string | string[], sessionInfo?: SessionInfo): Promise<ProviderParseResult> =>
    parseCursorSessionWithDependencies(filePaths, sessionInfo, resolved);
}

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

function toErrorMessage(err: unknown): string {
  if (err instanceof Error && err.message) return err.message;
  return String(err);
}

function compactErrorMessage(err: unknown, max = 180): string {
  const cleaned = toErrorMessage(err).replace(/\s+/g, " ").trim();
  if (cleaned.length <= max) return cleaned;
  return `${cleaned.slice(0, max)}...`;
}

export const parseCursorSession = createCursorParser();

async function parseCursorSessionWithDependencies(
  filePaths: string | string[],
  sessionInfo: SessionInfo | undefined,
  deps: CursorParserDependencies,
): Promise<ProviderParseResult> {
  const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
  const transcriptPaths = paths.filter((p) => p.endsWith(".jsonl"));
  const explicitToolPaths = paths.filter((p) => p.endsWith(".txt"));
  const inferredSqliteSession = inferCursorSqliteSession(paths);
  let sqliteError: string | undefined;
  let sqliteFallbackNote: string | undefined;
  let sqliteAttempted = false;

  // Try SQLite first if session info is available
  const sqliteSessionId = sessionInfo?.sessionId || inferredSqliteSession?.sessionId;
  if (sqliteSessionId) {
    sqliteAttempted = true;
    let sqliteResult: ProviderParseResult | null = null;
    try {
      const preferredWorkspacePath = sessionInfo?.workspacePath || sessionInfo?.cwd || "";
      sqliteResult = await deps.parseCursorSqlite(preferredWorkspacePath, sqliteSessionId);
    } catch (err) {
      // Cursor DB schemas can vary across versions/hosts; fall back to JSONL when available.
      sqliteError = compactErrorMessage(err);
      sqliteFallbackNote = `cursor SQLite parse failed (${sqliteError}); fell back to JSONL transcript`;
      sqliteResult = null;
    }
    if (sqliteResult) {
      // Keep SQLite/global-state as source of truth, but supplement missing
      // thinking markers and user images from JSONL when transcript files are available.
      if (transcriptPaths.length > 0) {
        const thinkingBefore = countThinkingBlocks(sqliteResult.turns);
        const userImagesBefore = countUserImages(sqliteResult.turns);
        const jsonlThinking = await parseCursorJsonl(
          transcriptPaths,
          [],
          {
            inferToolPaths: false,
          },
          deps,
        );
        sqliteResult.parseWarnings = mergeParseWarnings(
          sqliteResult.parseWarnings,
          jsonlThinking.parseWarnings,
        );
        sqliteResult.turns = mergeJsonlSupplementsIntoCursorTurns(
          sqliteResult.turns,
          jsonlThinking.turns,
        );
        const thinkingAfter = countThinkingBlocks(sqliteResult.turns);
        const userImagesAfter = countUserImages(sqliteResult.turns);
        const supplementedThinkingBlocks = Math.max(0, thinkingAfter - thinkingBefore);
        const supplementedUserImages = Math.max(0, userImagesAfter - userImagesBefore);
        sqliteResult.dataSourceInfo = withSupplement(
          sqliteResult.dataSourceInfo || defaultDataSourceInfo(sqliteResult.dataSource),
          `cursor/projects/agent-transcripts/*.jsonl (thinking +${supplementedThinkingBlocks}, images +${supplementedUserImages})`,
        );
      }
      return sqliteResult;
    }
  }

  // Fallback to JSONL parsing
  if (transcriptPaths.length === 0) {
    if (sqliteError) {
      throw new Error(
        `Cursor parse failed: ${sqliteError}. No transcript .jsonl fallback is available.`,
      );
    }
    if (sqliteAttempted) {
      throw new Error(
        "Cursor parse failed: SQLite data unavailable for this session and no transcript .jsonl fallback is available.",
      );
    }
    throw new Error("Cursor parse requires at least one transcript .jsonl path");
  }
  const jsonlResult = await parseCursorJsonl(
    transcriptPaths,
    explicitToolPaths,
    {
      inferToolPaths: true,
    },
    deps,
  );
  const preferredWorkspacePath = sessionInfo?.workspacePath || sessionInfo?.cwd || "";
  if (!jsonlResult.cwd && preferredWorkspacePath) {
    jsonlResult.cwd = preferredWorkspacePath;
  }
  if (sqliteFallbackNote) {
    jsonlResult.dataSourceInfo = withNote(
      jsonlResult.dataSourceInfo || defaultDataSourceInfo(jsonlResult.dataSource),
      sqliteFallbackNote,
    );
  }

  // Cursor SDK enrichment — Cursor SDK agents only have a JSONL transcript
  // (no IDE chat store.db), so the SDK index.db is the only place tool *results*
  // are recorded. Apply now so the replay has results, durations, and per-turn model.
  // If Cursor ever also backfills SDK agents into IDE store.db, keep that path
  // SQLite-first and only add SDK enrichment after deciding how to reconcile
  // duplicate tool streams.
  // We accept the sessionId from explicit sessionInfo OR from the transcript filename
  // (Cursor names SDK transcripts `<agentId>.jsonl`, which is exactly what the SDK
  // store keys agents on).
  const sdkSessionId = sessionInfo?.sessionId || deriveSessionIdFromTranscript(transcriptPaths);
  if (sdkSessionId) {
    const enrichment = await tryLoadSdkEnrichment(sdkSessionId);
    if (enrichment) {
      const { toolCallsEnriched, assistantTurnsModelTagged } = applySdkEnrichmentToTurns(
        jsonlResult.turns,
        enrichment,
      );
      if (toolCallsEnriched > 0 || assistantTurnsModelTagged > 0) {
        jsonlResult.dataSourceInfo = withSupplement(
          jsonlResult.dataSourceInfo || defaultDataSourceInfo(jsonlResult.dataSource),
          `cursor-sdk index.db (tool results +${toolCallsEnriched}, model tags +${assistantTurnsModelTagged})`,
        );
      }
      if (enrichment.latestModel && !jsonlResult.model) {
        jsonlResult.model = enrichment.latestModel;
      }
      if (enrichment.startedAt && !jsonlResult.startTime) {
        jsonlResult.startTime = enrichment.startedAt;
      }
      if (enrichment.finishedAt && !jsonlResult.endTime) {
        jsonlResult.endTime = enrichment.finishedAt;
      }
      if (enrichment.totalDurationMs && !jsonlResult.totalDurationMs) {
        jsonlResult.totalDurationMs = enrichment.totalDurationMs;
      }
    }
  }
  return jsonlResult;
}

function inferCursorSqliteSession(paths: string[]): { sessionId: string } | undefined {
  for (const path of paths) {
    const sessionId = sessionIdFromGlobalStatePath(path) || sessionIdFromStoreDbPath(path);
    if (sessionId) return { sessionId };
  }
  return undefined;
}

function sessionIdFromGlobalStatePath(path: string): string | undefined {
  const marker = "#composerData:";
  const markerIndex = path.indexOf(marker);
  if (markerIndex < 0) return undefined;
  const rawSessionId = path
    .slice(markerIndex + marker.length)
    .split(/[/?#]/, 1)[0]
    ?.trim();
  return rawSessionId || undefined;
}

function sessionIdFromStoreDbPath(path: string): string | undefined {
  const normalized = path.replaceAll("\\", "/");
  const parts = normalized.split("/");
  if (parts.at(-1) !== "store.db") return undefined;
  const rawSessionId = parts.at(-2)?.trim();
  if (!rawSessionId || !CURSOR_UUID_SESSION_ID_RE.test(rawSessionId)) return undefined;
  return rawSessionId;
}

async function tryLoadSdkEnrichment(sessionId: string): Promise<SdkAgentEnrichment | null> {
  // SDK agent IDs are prefixed `agent-`. Skip the lookup for plain UUID sessions
  // (Cursor IDE chats) to avoid touching SDK SQLite when we know it won't match.
  if (!sessionId.startsWith("agent-")) return null;
  try {
    const agent = await findSdkAgentById(sessionId);
    if (!agent) return null;
    return await loadSdkAgentEnrichment(agent);
  } catch {
    return null;
  }
}

function deriveSessionIdFromTranscript(transcriptPaths: string[]): string | null {
  // Pick the latest transcript by name (sortedTranscriptPaths inside parseCursorJsonl
  // sorts by mtime, but here we only need *some* transcript to extract the agent id;
  // for SDK sessions every transcript filename is identical to the agent id).
  for (let i = transcriptPaths.length - 1; i >= 0; i--) {
    const p = transcriptPaths[i];
    const base = basename(p, ".jsonl");
    if (base.startsWith("agent-")) return base;
  }
  return null;
}

function mergeParseWarnings(
  base: ProviderParseResult["parseWarnings"],
  extra: ProviderParseResult["parseWarnings"],
): ProviderParseResult["parseWarnings"] {
  const merged: NonNullable<ProviderParseResult["parseWarnings"]> = [];
  for (const warning of [...(base || []), ...(extra || [])]) {
    addParseWarning(merged, warning);
  }
  return merged.length > 0 ? merged : undefined;
}

interface ParseJsonlOptions {
  inferToolPaths: boolean;
}

interface CursorToolResult {
  result: string;
  timestamp?: string;
}

function defaultDataSourceInfo(
  dataSource?: ProviderParseResult["dataSource"],
): DataSourceInfo | undefined {
  if (!dataSource) return undefined;
  return {
    primary: dataSource,
    sources: [],
  };
}

function withSupplement(
  info: DataSourceInfo | undefined,
  supplement: string,
): DataSourceInfo | undefined {
  if (!info) return undefined;
  const supplements = [...(info.supplements || [])];
  if (!supplements.includes(supplement)) supplements.push(supplement);
  return { ...info, supplements };
}

function withNote(info: DataSourceInfo | undefined, note: string): DataSourceInfo | undefined {
  if (!info) return undefined;
  const notes = [...(info.notes || [])];
  if (!notes.includes(note)) notes.push(note);
  return { ...info, notes };
}

async function parseCursorJsonl(
  transcriptPaths: string[],
  explicitToolPaths: string[],
  options: ParseJsonlOptions,
  deps: CursorParserDependencies,
): Promise<ProviderParseResult> {
  const allTurns: ParsedTurn[] = [];
  let syntheticToolId = 0;
  const toolResults = new Map<string, CursorToolResult>();
  const toolErrors = new Map<string, boolean>();
  const toolImages = new Map<string, string[]>();
  const parseWarnings: NonNullable<ProviderParseResult["parseWarnings"]> = [];
  const sortedTranscriptPaths = await sortByMtime(transcriptPaths);
  const sessionId = basename(sortedTranscriptPaths[sortedTranscriptPaths.length - 1], ".jsonl");

  for (const filePath of sortedTranscriptPaths) {
    const content = await readFile(filePath, "utf-8");
    const lines = content.split("\n");

    for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
      const line = lines[lineIndex];
      if (!line.trim()) continue;
      let obj: any;
      try {
        obj = JSON.parse(line);
      } catch {
        addParseWarning(parseWarnings, {
          kind: "malformed-json",
          source: "cursor transcript JSONL",
          firstLine: lineIndex + 1,
          message: "Skipped malformed JSONL line",
          sample: line,
        });
        continue;
      }

      const role = obj.role as "user" | "assistant";
      const contentBlocks = obj.message?.content;
      if (!Array.isArray(contentBlocks)) continue;

      const textParts: string[] = [];
      const userImages: string[] = [];
      const imageFilePaths = new Set<string>();
      for (const block of contentBlocks) {
        if (block.type === "tool_result") {
          const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
          if (toolUseId) {
            toolResults.set(toolUseId, {
              result: extractToolResultText(block),
              timestamp: typeof obj.timestamp === "string" ? obj.timestamp : undefined,
            });
            if (block.is_error) toolErrors.set(toolUseId, true);
            const images = extractToolResultImages(block);
            if (images.length > 0) toolImages.set(toolUseId, images);
          }
        } else if (block.type === "text" && block.text) {
          let text = stripUserQueryWrapper(block.text);
          if (role === "user" && deps.isSystemContextText(text)) continue;
          const extracted = extractImageFilePathsFromText(text);
          text = normalizeImagePlaceholderLines(extracted.cleanedText);
          for (const imagePath of extracted.paths) imageFilePaths.add(imagePath);
          if (text) textParts.push(text);
        } else if (block.type === "image") {
          if (block.source?.data) {
            const mediaType = block.source.media_type || "image/png";
            userImages.push(`data:${mediaType};base64,${block.source.data}`);
          }
        }
      }

      for (const imagePath of imageFilePaths) {
        const dataUrl = await readImageFileAsDataUrl(imagePath);
        if (dataUrl) {
          userImages.push(dataUrl);
        } else {
          addParseWarning(parseWarnings, {
            kind: "missing-image",
            source: "cursor transcript image reference",
            firstLine: lineIndex + 1,
            message: "Skipped image reference because the file could not be read",
            sample: imagePath,
          });
        }
      }

      if (role === "user" && textParts.length === 0 && userImages.length === 0) continue;

      if (role === "user") {
        const blocks: ContentBlock[] = [];
        const fullText = textParts.join("\n");
        if (fullText) blocks.push({ type: "text", text: fullText });
        const dedupedImages = [...new Set(userImages)];
        if (dedupedImages.length > 0) {
          blocks.push({ type: "_user_images", images: dedupedImages });
        }
        if (blocks.length > 0) {
          allTurns.push({
            role,
            ...(typeof obj.timestamp === "string" ? { timestamp: obj.timestamp } : {}),
            blocks,
          });
        }
        continue;
      }

      const hasInlineToolUse = contentBlocks.some((block) => block?.type === "tool_use");
      const blocks: ContentBlock[] = [];
      for (const block of contentBlocks) {
        if (block.type === "text" && block.text) {
          const cleanedText = sanitizeAssistantTextForReplay(block.text, hasInlineToolUse);
          if (!cleanedText) continue;
          const markerParsed = splitToolMarker(cleanedText);
          if (markerParsed?.markerName) {
            if (markerParsed.textBody) blocks.push({ type: "text", text: markerParsed.textBody });
            blocks.push({
              type: "tool_use",
              id: `cursor-marker-${syntheticToolId++}`,
              name: markerParsed.markerName,
              input: { marker: markerParsed.markerName },
              _isPendingMarker: true,
            });
          } else {
            blocks.push({ type: "text", text: cleanedText });
          }
        } else if (block.type === "thinking" || block.type === "reasoning") {
          const rawThinking =
            typeof block.thinking === "string"
              ? block.thinking
              : typeof block.text === "string"
                ? block.text
                : "";
          const thinking = sanitizeCursorReasoningText(rawThinking);
          if (thinking) blocks.push({ type: "thinking", thinking });
        } else if (block.type === "tool_use") {
          const rawName =
            typeof block.name === "string" && block.name.trim() ? block.name.trim() : "Tool";
          const name = deps.mapCursorToolName(rawName);
          blocks.push({
            type: "tool_use",
            id:
              typeof block.id === "string" && block.id.trim()
                ? block.id
                : `cursor-inline-${syntheticToolId++}`,
            name,
            // Keep the raw name here because Cursor raw tools carry different arg
            // schemas even when they map to the same canonical replay tool.
            input: deps.mapToolArgs(rawName, block.input),
          });
        }
      }

      if (blocks.length > 0) {
        allTurns.push({
          role,
          ...(typeof obj.timestamp === "string" ? { timestamp: obj.timestamp } : {}),
          blocks,
        });
      }
    }
  }

  const toolPaths =
    explicitToolPaths.length > 0
      ? await sortByMtime(explicitToolPaths)
      : options.inferToolPaths
        ? await inferToolPaths(sortedTranscriptPaths)
        : [];
  const toolEvents = await loadToolEvents(toolPaths);
  attachToolEvents(allTurns, toolEvents);
  attachToolResults(allTurns, toolResults, toolErrors, toolImages, deps);

  // Derive slug from session ID
  const slug = sessionId.slice(0, 8);

  // Try to extract a meaningful title from first user prompt
  const firstUser = allTurns.find((t) => t.role === "user");
  const firstBlock = firstUser?.blocks[0];
  const firstText = firstBlock?.type === "text" ? firstBlock.text?.slice(0, 80) : undefined;

  const hasToolData = toolPaths.length > 0;
  return {
    sessionId,
    slug,
    title: firstText,
    cwd: "",
    turns: allTurns,
    dataSource: hasToolData ? "jsonl+tools" : "jsonl",
    dataSourceInfo: {
      primary: hasToolData ? "jsonl+tools" : "jsonl",
      sources: [
        "cursor/projects/agent-transcripts/*.jsonl",
        ...(hasToolData ? ["cursor/projects/agent-tools/*.txt"] : []),
      ],
    },
    parseWarnings: parseWarnings.length > 0 ? parseWarnings : undefined,
  };
}

function stripUserQueryWrapper(text: string): string {
  return sanitizeCursorUserText(text);
}

function sanitizeAssistantTextForReplay(text: string, hasInlineToolUse: boolean): string {
  return sanitizeCursorAssistantText(stripUserQueryWrapper(text), hasInlineToolUse);
}

function normalizeImagePlaceholderLines(text: string): string {
  const lines = text.split("\n");
  const filtered = lines.filter((line) => !/^\s*\[Image\]\s*$/i.test(line.trim()));
  return filtered.join("\n").trim();
}

function resolveImagePath(pathValue: string): string {
  if (pathValue.startsWith("~/") || pathValue.startsWith("~\\")) {
    return join(homedir(), pathValue.slice(2));
  }
  return pathValue;
}

function imageMediaType(pathValue: string): string {
  const lower = pathValue.toLowerCase();
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
  if (lower.endsWith(".webp")) return "image/webp";
  if (lower.endsWith(".gif")) return "image/gif";
  return "image/png";
}

function extractImageFilePathsFromText(text: string): { cleanedText: string; paths: string[] } {
  const blockPattern = /<image_files>([\s\S]*?)<\/image_files>/gi;
  const paths = new Set<string>();
  let match: RegExpExecArray | null;
  while ((match = blockPattern.exec(text)) !== null) {
    const blockContent = match[1];
    // Match POSIX (`~/`, `/`) and Windows (`~\`, `\`, `C:\`) absolute image paths.
    const pathRegex =
      /(?:^|\n)\s*(?:\d+\.\s*)?((?:~[\\/]|[\\/]|[A-Za-z]:[\\/])[^\n]+\.(?:png|jpe?g|gif|webp))/gi;
    let pathMatch: RegExpExecArray | null;
    while ((pathMatch = pathRegex.exec(blockContent)) !== null) {
      const pathValue = pathMatch[1].trim();
      if (pathValue) paths.add(resolveImagePath(pathValue));
    }
  }
  const cleanedText = text.replace(blockPattern, "").trim();
  return { cleanedText, paths: [...paths] };
}

async function readImageFileAsDataUrl(pathValue: string): Promise<string | null> {
  try {
    const data = await readFile(pathValue);
    return `data:${imageMediaType(pathValue)};base64,${data.toString("base64")}`;
  } catch {
    return null;
  }
}

function collectThinkingTexts(turn: ParsedTurn): string[] {
  const texts: string[] = [];
  for (const block of turn.blocks) {
    if (block.type !== "thinking") continue;
    const text = block.thinking.trim();
    if (text) texts.push(text);
  }
  return texts;
}

function buildThinkingBlocks(texts: string[]): ContentBlock[] {
  return texts.map((thinking) => ({ type: "thinking" as const, thinking }));
}

function countThinkingBlocks(turns: ParsedTurn[]): number {
  let count = 0;
  for (const turn of turns) {
    for (const block of turn.blocks) {
      if (block.type === "thinking") count++;
    }
  }
  return count;
}

function collectUserImages(turn: ParsedTurn): string[] {
  const images: string[] = [];
  for (const block of turn.blocks) {
    if (block.type !== "_user_images") continue;
    for (const image of block.images) {
      if (typeof image === "string" && image.trim()) images.push(image);
    }
  }
  return images;
}

function countUserImages(turns: ParsedTurn[]): number {
  let count = 0;
  for (const turn of turns) {
    count += collectUserImages(turn).length;
  }
  return count;
}

function extractToolResultText(block: Extract<ContentBlock, { type: "tool_result" }>): string {
  if (typeof block.content === "string") return block.content;
  if (!Array.isArray(block.content)) return "";
  return block.content
    .map((item) => {
      if (item.type === "text") return item.text;
      if (item.type === "tool_result") return item.content;
      return "";
    })
    .filter(Boolean)
    .join("\n");
}

function extractToolResultImages(block: Extract<ContentBlock, { type: "tool_result" }>): string[] {
  if (!Array.isArray(block.content)) return [];
  return block.content
    .map((item) => {
      if (item?.type !== "image" || !item.source?.data) return null;
      const source = item.source;
      const mediaType = source.media_type || "image/png";
      return `data:${mediaType};base64,${source.data}`;
    })
    .filter((value): value is string => typeof value === "string");
}

function attachToolResults(
  turns: ParsedTurn[],
  toolResults: Map<string, CursorToolResult>,
  toolErrors: Map<string, boolean>,
  toolImages: Map<string, string[]>,
  deps: CursorParserDependencies,
): void {
  for (const turn of turns) {
    if (turn.role !== "assistant") continue;
    for (const block of turn.blocks) {
      if (block.type !== "tool_use" || typeof block.id !== "string") continue;
      const result = toolResults.get(block.id);
      if (result !== undefined) {
        block._result = result.result;
        block.input = deps.mapToolArgs(block.name, block.input, result.result);
        const durationMs = durationBetween(turn.timestamp, result.timestamp);
        if (durationMs !== undefined) block._durationMs = durationMs;
      }
      const images = toolImages.get(block.id);
      if (images?.length) block._images = images;
      if (toolErrors.get(block.id)) block._isError = true;
    }
  }
}

/**
 * Merge JSONL-only thinking markers into DB-derived turns.
 * We align assistant turns by index and only add missing thinking blocks.
 */
export function mergeJsonlThinkingIntoCursorTurns(
  primaryTurns: ParsedTurn[],
  jsonlTurns: ParsedTurn[],
): ParsedTurn[] {
  if (primaryTurns.length === 0 || jsonlTurns.length === 0) return primaryTurns;

  const merged = primaryTurns.map((turn) => ({
    ...turn,
    blocks: [...turn.blocks],
  }));

  const primaryAssistantIndices = merged
    .map((turn, index) => ({ turn, index }))
    .filter(({ turn }) => turn.role === "assistant")
    .map(({ index }) => index);
  const jsonlAssistantTurns = jsonlTurns.filter((turn) => turn.role === "assistant");

  const paired = Math.min(primaryAssistantIndices.length, jsonlAssistantTurns.length);
  for (let i = 0; i < paired; i++) {
    const targetTurn = merged[primaryAssistantIndices[i]];
    const candidateThinking = collectThinkingTexts(jsonlAssistantTurns[i]);
    if (candidateThinking.length === 0) continue;

    const existingThinking = new Set(collectThinkingTexts(targetTurn));
    const missingThinking = candidateThinking.filter((text) => !existingThinking.has(text));
    if (missingThinking.length === 0) continue;

    targetTurn.blocks = [...buildThinkingBlocks(missingThinking), ...targetTurn.blocks];
  }

  // If JSONL has extra assistant thinking turns (common with marker-only lines),
  // preserve them as standalone assistant thinking turns.
  for (let i = paired; i < jsonlAssistantTurns.length; i++) {
    const extraThinking = collectThinkingTexts(jsonlAssistantTurns[i]);
    if (extraThinking.length === 0) continue;
    merged.push({
      role: "assistant",
      timestamp: jsonlAssistantTurns[i].timestamp,
      blocks: buildThinkingBlocks(extraThinking),
    });
  }

  return merged;
}

function mergeJsonlUserImagesIntoCursorTurns(
  primaryTurns: ParsedTurn[],
  jsonlTurns: ParsedTurn[],
): ParsedTurn[] {
  if (primaryTurns.length === 0 || jsonlTurns.length === 0) return primaryTurns;

  const merged = primaryTurns.map((turn) => ({
    ...turn,
    blocks: [...turn.blocks],
  }));

  const primaryUserIndices = merged
    .map((turn, index) => ({ turn, index }))
    .filter(({ turn }) => turn.role === "user")
    .map(({ index }) => index);
  const jsonlUserTurns = jsonlTurns.filter((turn) => turn.role === "user");
  const paired = Math.min(primaryUserIndices.length, jsonlUserTurns.length);

  for (let i = 0; i < paired; i++) {
    const targetTurn = merged[primaryUserIndices[i]];
    const candidateImages = collectUserImages(jsonlUserTurns[i]);
    if (candidateImages.length === 0) continue;

    const existingImages = collectUserImages(targetTurn);
    const mergedImages = [...new Set([...existingImages, ...candidateImages])];
    if (mergedImages.length === existingImages.length) continue;

    const nonImageBlocks = targetTurn.blocks.filter((block) => block?.type !== "_user_images");
    targetTurn.blocks = [...nonImageBlocks, { type: "_user_images", images: mergedImages }];
  }

  return merged;
}

export function mergeJsonlSupplementsIntoCursorTurns(
  primaryTurns: ParsedTurn[],
  jsonlTurns: ParsedTurn[],
): ParsedTurn[] {
  const withThinking = mergeJsonlThinkingIntoCursorTurns(primaryTurns, jsonlTurns);
  return mergeJsonlUserImagesIntoCursorTurns(withThinking, jsonlTurns);
}

interface TimestampedPath {
  path: string;
  mtimeMs: number;
}

interface ToolEvent {
  id: string;
  name: string;
  input: Record<string, any>;
  result: string;
  timestamp?: string;
}

async function sortByMtime(paths: string[]): Promise<string[]> {
  const entries: TimestampedPath[] = [];
  for (const path of paths) {
    const st = await stat(path).catch(() => null);
    if (!st?.isFile()) continue;
    entries.push({ path, mtimeMs: st.mtimeMs });
  }
  entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
  return entries.map((e) => e.path);
}

async function inferToolPaths(transcriptPaths: string[]): Promise<string[]> {
  const toolPaths: string[] = [];
  const seen = new Set<string>();
  const projects = new Map<string, Set<string>>();

  for (const transcriptPath of transcriptPaths) {
    const projectRoot = getCursorProjectRoot(transcriptPath);
    if (!projectRoot) continue;
    if (!projects.has(projectRoot)) projects.set(projectRoot, new Set());
    projects.get(projectRoot)?.add(transcriptPath);
  }

  for (const [projectRoot, selectedPaths] of projects.entries()) {
    const transcriptsDir = join(projectRoot, "agent-transcripts");
    const toolDir = join(projectRoot, "agent-tools");
    const transcriptEntries = await collectTranscriptEntries(transcriptsDir);
    const toolEntries = await collectToolEntries(toolDir);
    if (transcriptEntries.length === 0 || toolEntries.length === 0) continue;
    transcriptEntries.sort((a, b) => a.mtimeMs - b.mtimeMs);
    toolEntries.sort((a, b) => a.mtimeMs - b.mtimeMs);

    for (let i = 0; i < transcriptEntries.length; i++) {
      const transcript = transcriptEntries[i];
      if (!selectedPaths.has(transcript.path)) continue;
      const prevMtimeMs = i === 0 ? Number.NEGATIVE_INFINITY : transcriptEntries[i - 1].mtimeMs;
      for (const tool of toolEntries) {
        if (tool.mtimeMs <= prevMtimeMs || tool.mtimeMs > transcript.mtimeMs) continue;
        if (seen.has(tool.path)) continue;
        seen.add(tool.path);
        toolPaths.push(tool.path);
      }
    }
  }

  return sortByMtime(toolPaths);
}

async function collectTranscriptEntries(transcriptsDir: string): Promise<TimestampedPath[]> {
  let entries: string[];
  try {
    entries = await readdir(transcriptsDir);
  } catch {
    return [];
  }

  const transcripts: TimestampedPath[] = [];
  for (const entry of entries) {
    const entryPath = join(transcriptsDir, entry);
    const st = await stat(entryPath).catch(() => null);
    if (!st) continue;

    if (entry.endsWith(".jsonl") && st.isFile()) {
      transcripts.push({ path: entryPath, mtimeMs: st.mtimeMs });
      continue;
    }
    if (!st.isDirectory()) continue;
    const nested = join(entryPath, `${entry}.jsonl`);
    const nestedStat = await stat(nested).catch(() => null);
    if (!nestedStat?.isFile()) continue;
    transcripts.push({ path: nested, mtimeMs: nestedStat.mtimeMs });
  }
  return transcripts;
}

async function collectToolEntries(toolDir: string): Promise<TimestampedPath[]> {
  let entries: string[];
  try {
    entries = await readdir(toolDir);
  } catch {
    return [];
  }

  const tools: TimestampedPath[] = [];
  for (const entry of entries) {
    if (!entry.endsWith(".txt")) continue;
    const entryPath = join(toolDir, entry);
    const st = await stat(entryPath).catch(() => null);
    if (!st?.isFile()) continue;
    tools.push({ path: entryPath, mtimeMs: st.mtimeMs });
  }
  return tools;
}

function getCursorProjectRoot(transcriptPath: string): string | null {
  const parts = transcriptPath.split(/[/\\]agent-transcripts[/\\]/);
  if (parts.length < 2) return null;
  return parts[0];
}

async function loadToolEvents(toolPaths: string[]): Promise<ToolEvent[]> {
  const events: ToolEvent[] = [];
  for (const path of toolPaths) {
    const content = await readFile(path, "utf-8").catch(() => "");
    const result = content.trim();
    if (!result) continue;
    const st = await stat(path).catch(() => null);
    const id = basename(path, extname(path));
    events.push({
      id,
      name: inferToolName(result),
      input: { source: basename(path) },
      result,
      timestamp: st ? new Date(st.mtimeMs).toISOString() : undefined,
    });
  }
  return events;
}

function inferToolName(result: string): string {
  const firstLine = result.split("\n", 1)[0] || "";
  if (result.startsWith("diff --git")) return "Diff";
  if (firstLine.startsWith("http://") || firstLine.startsWith("https://")) return "WebFetch";
  if (firstLine.startsWith("{") || firstLine.startsWith("[")) return "API";
  if (/\$ |\bCommand\b|^\w+(\s+\w+){0,3}\s+-/.test(firstLine)) return "Bash";
  return "ToolOutput";
}

type ToolUseBlock = Extract<ContentBlock, { type: "tool_use" }>;

function attachToolEvents(turns: ParsedTurn[], tools: ToolEvent[]): void {
  const markerBlocks: Array<{ block: ToolUseBlock; turn: ParsedTurn; blockIndex: number }> = [];
  for (const turn of turns) {
    if (turn.role !== "assistant") continue;
    for (let bi = 0; bi < turn.blocks.length; bi++) {
      const block = turn.blocks[bi];
      if (block.type === "tool_use" && block._isPendingMarker) {
        markerBlocks.push({ block, turn, blockIndex: bi });
      }
    }
  }

  // Pair markers with real tool outputs (chronological order)
  const paired = Math.min(markerBlocks.length, tools.length);
  for (let i = 0; i < paired; i++) {
    const marker = markerBlocks[i];
    const tool = tools[i];
    marker.block.name = tool.name;
    marker.block.input = {
      ...marker.block.input,
      ...tool.input,
    };
    marker.block._result = tool.result;
    const durationMs = durationBetween(marker.turn.timestamp, tool.timestamp);
    if (durationMs !== undefined) marker.block._durationMs = durationMs;
    marker.block._isPendingMarker = undefined;
    if (!marker.turn.timestamp && tool.timestamp) {
      marker.turn.timestamp = tool.timestamp;
    }
  }

  // Unpaired markers → convert to thinking (they're just status text, not tool calls)
  for (let i = paired; i < markerBlocks.length; i++) {
    const { turn, blockIndex, block } = markerBlocks[i];
    const markerText = typeof block.input?.marker === "string" ? block.input.marker : "";
    const thinkingBlock: ContentBlock = {
      type: "thinking",
      thinking: block.name || markerText,
    };
    turn.blocks[blockIndex] = thinkingBlock;
  }

  // Extra tool outputs with no matching marker → append as real tool calls
  for (let i = paired; i < tools.length; i++) {
    const tool = tools[i];
    turns.push({
      role: "assistant",
      timestamp: tool.timestamp,
      blocks: [toToolUseBlock(tool)],
    });
  }
}

function toToolUseBlock(tool: ToolEvent): ContentBlock {
  return {
    type: "tool_use",
    id: tool.id,
    name: tool.name,
    input: tool.input,
    _result: tool.result,
  };
}

function durationBetween(start?: string, end?: string): number | undefined {
  if (!start || !end) return undefined;
  const startMs = Date.parse(start);
  const endMs = Date.parse(end);
  if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return undefined;
  return Math.round(endMs - startMs);
}

function splitToolMarker(text: string): { markerName: string; textBody?: string } | undefined {
  const trimmed = text.trim();
  const single = trimmed.match(/^\*\*([^*\n]{2,120})\*\*$/);
  if (single) return { markerName: single[1].trim() };

  const trailing = trimmed.match(/^([\s\S]*?)\n+\*\*([^*\n]{2,120})\*\*$/);
  if (trailing) {
    const textBody = trailing[1].trim();
    return {
      markerName: trailing[2].trim(),
      ...(textBody ? { textBody } : {}),
    };
  }
  return undefined;
}
