/**
 * GitHub-optimized export: structured markdown summary + animated SVG preview.
 *
 * Designed for embedding in PRs, READMEs, and issue comments.
 * The SVG renders natively on GitHub (CSS animations, no JS needed).
 */

import type { ReplaySession, Scene } from "@vibe-replay/types";
import { isSystemGeneratedMessage } from "../clean-prompt.js";

// ─── Public API ────────────────────────────────────────────

export interface GitHubFormatOptions {
  /** Full replay URL (gist viewer, self-hosted, etc.) */
  replayUrl?: string;
  /** Relative path to the SVG file (for embedding in markdown) */
  svgPath?: string;
  /** Relative path to the GIF file (preferred over svgPath when both present) */
  gifPath?: string;
}

/**
 * Generate a GitHub-flavored markdown summary of a replay session.
 * Designed for PR descriptions, issue comments, and READMEs.
 */
export function generateGitHubMarkdown(
  session: ReplaySession,
  opts: GitHubFormatOptions = {},
): string {
  const { meta } = session;
  const phases = extractPhases(session.scenes);
  const filesChanged = collectFilesChanged(session.scenes);
  const toolStats = computeToolStats(session.scenes);
  const duration = formatDuration(meta.stats.durationMs);
  const title = meta.title || meta.slug;
  const condensedTitle = condenseLine(title, 80);

  const lines: string[] = [];

  // ── Preview image (clickable link to full replay) ──
  // Prefer GIF (works universally on GitHub) over SVG
  const previewPath = opts.gifPath || opts.svgPath;
  if (previewPath) {
    if (opts.replayUrl) {
      lines.push(`[![AI Session: ${escMd(condensedTitle)}](${previewPath})](${opts.replayUrl})`);
    } else {
      lines.push(`![AI Session: ${escMd(condensedTitle)}](${previewPath})`);
    }
    lines.push("");
  }

  // ── Header ──
  lines.push("### AI Coding Session");
  lines.push("");

  // Stats line
  const statParts = [
    duration,
    filesChanged.size > 0 ? `${filesChanged.size} files changed` : null,
    meta.model || null,
  ].filter(Boolean);
  lines.push(statParts.join(" · "));

  // Tool breakdown line
  const toolParts: string[] = [];
  if (toolStats.responses > 0) toolParts.push(`${toolStats.responses} responses`);
  if (toolStats.totalTools > 0) toolParts.push(`${toolStats.totalTools} tools`);
  if (toolStats.thinking > 0) toolParts.push(`${toolStats.thinking} thinking`);
  const breakdownStr = formatToolBreakdown(toolStats);
  if (breakdownStr) {
    lines.push(`${toolParts.join(" · ")} — ${breakdownStr}`);
  } else {
    lines.push(toolParts.join(" · "));
  }
  lines.push("");

  // ── Collapsible body (compact format) ──
  lines.push("<details>");
  lines.push(
    `<summary>View session details (${meta.stats.userPrompts} prompts, ${meta.stats.toolCalls} tool calls)</summary>`,
  );
  lines.push("");

  for (const phase of phases) {
    lines.push(`> **${escMd(condenseLine(phase.prompt, 120))}**`);

    // Per-phase compact stats
    const phaseStats = computeToolStats(phase.scenes);
    const phaseParts: string[] = [];
    if (phaseStats.responses > 0) phaseParts.push(`${phaseStats.responses} responses`);
    if (phaseStats.totalTools > 0) phaseParts.push(`${phaseStats.totalTools} tools`);
    if (phaseStats.thinking > 0) phaseParts.push(`${phaseStats.thinking} thinking`);
    const phaseBreakdown = formatToolBreakdown(phaseStats);
    if (phaseBreakdown) {
      lines.push(`> ${phaseParts.join(" · ")} — ${phaseBreakdown}`);
    } else if (phaseParts.length > 0) {
      lines.push(`> ${phaseParts.join(" · ")}`);
    }

    // Last meaningful text response as preview
    const lastText = findLastTextResponse(phase.scenes);
    if (lastText) {
      lines.push(">");
      lines.push(`> *${escMd(condenseLine(lastText, 120))}*`);
    }
    lines.push("");
  }

  // Risk signals
  const risks = detectRiskSignals(session, filesChanged);
  if (risks.length > 0) {
    lines.push("**Signals:**");
    for (const risk of risks) {
      lines.push(`- ${risk}`);
    }
    lines.push("");
  }

  lines.push("</details>");
  lines.push("");

  // ── Footer ──
  const footerParts = ["Generated by [vibe-replay](https://vibe-replay.com)"];
  if (opts.replayUrl) {
    footerParts.push(`[View full replay →](${opts.replayUrl})`);
  }
  lines.push(`<sub>${footerParts.join(" · ")}</sub>`);
  lines.push("");

  return lines.join("\n");
}

/**
 * Generate an animated SVG preview of a replay session.
 * Renders natively on GitHub (CSS @keyframes animation).
 */
export function generateGitHubSvg(session: ReplaySession, opts: GitHubFormatOptions = {}): string {
  const phases = extractPhases(session.scenes);
  const frames = buildSvgFrames(session, phases, opts);
  return renderSvg(frames, session, opts);
}

// ─── Tool stats breakdown ───────────────────────────────────

interface ToolStats {
  responses: number;
  thinking: number;
  totalTools: number;
  /** Sorted breakdown: [["Read", 26], ["Edit", 52], ...] */
  toolBreakdown: [string, number][];
  /** Unique Bash command names (first word of each command) */
  bashCommands: string[];
}

function computeToolStats(scenes: Scene[]): ToolStats {
  const breakdown: Record<string, number> = {};
  const bashCmds = new Set<string>();
  let responses = 0;
  let thinking = 0;
  let totalTools = 0;

  for (const scene of scenes) {
    if (scene.type === "text-response") {
      responses++;
    } else if (scene.type === "thinking") {
      thinking++;
    } else if (scene.type === "tool-call") {
      totalTools++;
      const name = shortToolName(scene.toolName);
      breakdown[name] = (breakdown[name] || 0) + 1;
      if (scene.toolName === "Bash" && scene.input?.command) {
        const cmd = scene.input.command.trim().split(/[\s|;&]/)[0];
        if (cmd) bashCmds.add(cmd);
      }
    }
  }

  // Sort: common tools first, then by count
  const order = ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "Agent"];
  const entries = Object.entries(breakdown) as [string, number][];
  entries.sort((a, b) => {
    const ai = order.indexOf(a[0]);
    const bi = order.indexOf(b[0]);
    return (ai >= 0 ? ai : 100) - (bi >= 0 ? bi : 100);
  });

  return {
    responses,
    thinking,
    totalTools,
    toolBreakdown: entries,
    bashCommands: [...bashCmds],
  };
}

/** Shorten MCP-style tool names: mcp__service__method → method */
function shortToolName(name: string): string {
  if (name.startsWith("mcp__")) {
    const parts = name.split("__");
    return parts[parts.length - 1];
  }
  return name;
}

/** Find the last meaningful text-response in a list of scenes */
function findLastTextResponse(scenes: Scene[]): string | null {
  for (let i = scenes.length - 1; i >= 0; i--) {
    const scene = scenes[i];
    if (scene.type === "text-response") {
      const text = scene.content.trim();
      if (text.length > 10) return text;
    }
  }
  return null;
}

/** Format tool breakdown as a single string: "Read 26 · Edit 52 · Bash (git, pnpm) 10" */
function formatToolBreakdown(stats: ToolStats): string {
  return stats.toolBreakdown
    .map(([name, count]) => {
      if (name === "Bash" && stats.bashCommands.length > 0) {
        const cmds = stats.bashCommands.slice(0, 4).join(", ");
        const more = stats.bashCommands.length > 4 ? ", ..." : "";
        return `${name} (${cmds}${more}) ${count}`;
      }
      return `${name} ${count}`;
    })
    .join(" · ");
}

// ─── Phase extraction ──────────────────────────────────────

interface Phase {
  prompt: string;
  actions: GroupedAction[];
  /** Raw scenes in this phase (for computing per-phase tool stats) */
  scenes: Scene[];
}

type GroupedAction =
  | { kind: "read"; files: string[] }
  | { kind: "edit"; file: string }
  | { kind: "create"; file: string }
  | { kind: "run"; command: string; passed: boolean | null }
  | { kind: "search"; description: string }
  | { kind: "text"; summary: string };

export function extractPhases(scenes: Scene[]): Phase[] {
  const phases: Phase[] = [];
  let current: { prompt: string; rawActions: RawAction[]; scenes: Scene[] } | null = null;

  for (const scene of scenes) {
    if (scene.type === "user-prompt") {
      // Skip system-generated messages (e.g. <bash-stdout>, <task-notification>)
      if (isSystemGeneratedMessage(scene.content)) continue;
      if (current) {
        phases.push({
          prompt: current.prompt,
          actions: groupActions(current.rawActions),
          scenes: current.scenes,
        });
      }
      current = { prompt: scene.content, rawActions: [], scenes: [] };
      continue;
    }

    if (!current) continue;
    current.scenes.push(scene);

    if (scene.type === "tool-call") {
      const action = parseToolCall(scene);
      if (action) current.rawActions.push(action);
    } else if (scene.type === "text-response") {
      // Only include short, meaningful assistant text (not long explanations)
      const text = scene.content.trim();
      if (
        text.length > 10 &&
        text.length <= 150 &&
        !text.startsWith("I'll ") &&
        !text.startsWith("Let me ") &&
        !text.startsWith("I've ") &&
        !text.startsWith("Now ") &&
        !text.startsWith("Sure") &&
        !text.startsWith("OK") &&
        !text.startsWith("Here")
      ) {
        current.rawActions.push({
          kind: "text",
          summary: condenseLine(text, 80),
        });
      }
    }
    // Skip thinking — internal
  }

  if (current) {
    phases.push({
      prompt: current.prompt,
      actions: groupActions(current.rawActions),
      scenes: current.scenes,
    });
  }

  return phases;
}

// ─── Raw action parsing ────────────────────────────────────

interface RawAction {
  kind: "read" | "edit" | "create" | "run" | "search" | "text";
  file?: string;
  command?: string;
  summary?: string;
  passed?: boolean | null;
}

function parseToolCall(scene: Extract<Scene, { type: "tool-call" }>): RawAction | null {
  const { toolName, input, result } = scene;

  switch (toolName) {
    case "Read":
    case "View":
      return { kind: "read", file: baseName(input.file_path || input.path || "") };

    case "Edit":
      return { kind: "edit", file: baseName(firstFilePath(input) || "") };

    case "Write":
      return { kind: "create", file: baseName(firstFilePath(input) || "") };

    case "Bash": {
      const cmd = (input.command || "").split("\n")[0];
      const lower = result.toLowerCase();
      const failed =
        lower.includes("error") ||
        lower.includes("failed") ||
        lower.includes("fail ") ||
        lower.includes("exit code");
      const passed =
        lower.includes("passed") || lower.includes("success") || lower.includes("all tests");
      return {
        kind: "run",
        command: condenseLine(cmd, 50),
        passed: failed ? false : passed ? true : null,
      };
    }

    case "Glob":
    case "Grep": {
      const pat = input.pattern || input.query || "";
      return {
        kind: "search",
        summary: pat ? `Search \`${condenseLine(pat, 30)}\`` : "Search codebase",
      };
    }

    // Skip internal/noisy tools
    case "TodoWrite":
    case "TodoRead":
    case "Agent":
      return null;

    default:
      return null;
  }
}

function groupActions(raw: RawAction[]): GroupedAction[] {
  const grouped: GroupedAction[] = [];
  let pendingReads: string[] = [];

  const flushReads = () => {
    if (pendingReads.length > 0) {
      grouped.push({ kind: "read", files: [...pendingReads] });
      pendingReads = [];
    }
  };

  for (const action of raw) {
    if (action.kind === "read") {
      if (action.file) pendingReads.push(action.file);
      continue;
    }

    flushReads();

    switch (action.kind) {
      case "edit":
        grouped.push({ kind: "edit", file: action.file! });
        break;
      case "create":
        grouped.push({ kind: "create", file: action.file! });
        break;
      case "run":
        grouped.push({
          kind: "run",
          command: action.command!,
          passed: action.passed ?? null,
        });
        break;
      case "search":
        // Skip consecutive searches
        if (grouped.length === 0 || grouped[grouped.length - 1].kind !== "search") {
          grouped.push({ kind: "search", description: action.summary! });
        }
        break;
      case "text":
        grouped.push({ kind: "text", summary: action.summary! });
        break;
    }
  }

  flushReads();
  return grouped;
}

// ─── Risk signals ──────────────────────────────────────────

function detectRiskSignals(session: ReplaySession, filesChanged: Map<string, number>): string[] {
  const risks: string[] = [];
  const { scenes } = session;

  // Files modified many times (possible struggle)
  for (const [file, count] of filesChanged) {
    if (count >= 4) {
      risks.push(`\`${file}\` modified ${count}x`);
    }
  }

  // Test failures
  let testFailures = 0;
  let testPasses = 0;
  for (const scene of scenes) {
    if (scene.type !== "tool-call" || scene.toolName !== "Bash") continue;
    const cmd = scene.input.command || "";
    const isTest =
      cmd.includes("test") ||
      cmd.includes("jest") ||
      cmd.includes("vitest") ||
      cmd.includes("pytest");
    if (!isTest) continue;
    const r = scene.result.toLowerCase();
    if (r.includes("failed") || r.includes("fail ") || r.includes("error")) {
      testFailures++;
    }
    if (r.includes("passed") || r.includes("success") || r.includes("all tests")) {
      testPasses++;
    }
  }
  if (testFailures > 0) {
    const resolved = testPasses > 0 ? " (resolved)" : "";
    risks.push(`${testFailures} test failure(s)${resolved}`);
  }

  // Compactions
  if (
    scenes.some((s) => s.type === "compaction-summary") ||
    (session.meta.compactions?.length || 0) > 0
  ) {
    risks.push("Session was compacted (very long conversation)");
  }

  return risks;
}

// ─── SVG generation ────────────────────────────────────────

// GitHub dark palette — feels native, looks good on both light/dark backgrounds
// Colors aligned with viewer design system (packages/viewer/src/styles/index.css)
const C = {
  bg: "#0d1117",
  headerBg: "#161b22",
  border: "#30363d",
  text: "#e6edf3",
  dim: "#7d8590",
  blue: "#79b8ff",
  green: "#3fb950",
  red: "#f85149",
  purple: "#b392f0",
  orange: "#d29922",
  dotRed: "#ff7b72",
  dotYellow: "#d29922",
  dotGreen: "#3fb950",
};

/** Max conversation turns shown in GIF/SVG export. Sync with ExportView.tsx UI strings. */
export const MAX_EXPORT_TURNS = 8;

const SVG_W = 960;
const SVG_H = 540;
const HEADER_H = 40;
const FOOTER_H = 40;
const CONTENT_TOP = HEADER_H + 16;
const MARGIN = 40;
// CJK fonts MUST come before generic families (monospace, sans-serif) — generic names are terminal in CSS font matching
const FONT = `'SF Mono','Cascadia Code','Fira Code',Menlo,Consolas,'Liberation Mono','PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans CJK SC',monospace,sans-serif`;

// Card layout constants
const CARD_X = 24;
const CARD_W = SVG_W - 2 * CARD_X;
const CARD_R = 10;
const CARD_PAD = 20;
const TEXT_X = CARD_X + CARD_PAD;
const TEXT_RIGHT = CARD_X + CARD_W - CARD_PAD;
// Max visual columns for text within a card at different font sizes
// Card text width = SVG_W - 2*CARD_X - 2*CARD_PAD = 960 - 48 - 40 = 872px
// At 16px monospace ~9.6px/char → 90 cols; at 15px ~9.0px/char → 96 cols
const MAX_COLS_16 = 88;
const MAX_COLS_15 = 94;

export interface SvgFrame {
  label: string;
  title: string;
  titleLine2?: string;
  lines: SvgTextLine[];
  // Conversation turn fields (compact-view style)
  promptIndex?: number;
  totalPrompts?: number;
  userPrompt?: string;
  responsePreview?: string;
  responseFullLength?: number;
  turnStats?: { responses: number; tools: number; breakdown: string };
}

export interface SvgTextLine {
  text: string;
  color: string;
  bold?: boolean;
}

export function buildSvgFrames(
  session: ReplaySession,
  phases: Phase[],
  opts: GitHubFormatOptions = {},
): SvgFrame[] {
  const { meta } = session;
  const frames: SvgFrame[] = [];
  const task = phases[0]?.prompt || meta.title || meta.slug;
  const duration = formatDuration(meta.stats.durationMs);
  const toolStats = computeToolStats(session.scenes);
  const filesChanged = collectFilesChanged(session.scenes);
  const breakdown = formatToolBreakdown(toolStats);

  // ── Conversation turn frames (compact-view style) ──
  // Start directly with conversation turns — no abstract overview
  const selected = selectKeyPhases(phases, MAX_EXPORT_TURNS);
  for (const phase of selected) {
    const phaseIdx = phases.indexOf(phase) + 1;
    const phaseStats = computeToolStats(phase.scenes);
    const responseText = collectTextResponses(phase.scenes);
    frames.push({
      label: `#${String(phaseIdx).padStart(2, "0")}`,
      title: "",
      lines: [],
      promptIndex: phaseIdx,
      totalPrompts: phases.length,
      userPrompt: phase.prompt,
      responsePreview: responseText,
      responseFullLength: responseText.length,
      turnStats: {
        responses: phaseStats.responses,
        tools: phaseStats.totalTools,
        breakdown: formatToolBreakdown(phaseStats),
      },
    });
  }

  // ── Summary frame (last) — title + key insights ──
  const title = meta.title || condenseLine(task, 60);
  const statParts: string[] = [];
  if (filesChanged.size > 0) statParts.push(`${filesChanged.size} files changed`);
  statParts.push(`${meta.stats.userPrompts} prompts`);
  if (duration) statParts.push(duration);
  const toolParts: string[] = [];
  if (toolStats.responses > 0) toolParts.push(`${toolStats.responses} responses`);
  if (toolStats.totalTools > 0) toolParts.push(`${toolStats.totalTools} tools`);

  const summaryLines: SvgTextLine[] = [];
  if (meta.model) summaryLines.push({ text: meta.model, color: C.purple });
  summaryLines.push({ text: statParts.join("  ·  "), color: C.green });
  if (toolParts.length > 0) summaryLines.push({ text: toolParts.join("  ·  "), color: C.dim });
  if (breakdown) summaryLines.push({ text: condenseLine(breakdown, 80), color: C.orange });
  const hiddenTurns = phases.length - selected.length;
  if (hiddenTurns > 0) {
    summaryLines.push({ text: "", color: C.dim });
    summaryLines.push({
      text: `+ ${hiddenTurns} more turn${hiddenTurns === 1 ? "" : "s"} not shown`,
      color: C.dim,
    });
  }
  summaryLines.push({ text: "", color: C.dim });
  if (opts.replayUrl) {
    summaryLines.push({ text: "View full replay  →", color: C.blue, bold: true });
  }
  summaryLines.push({ text: "vibe-replay.com", color: C.dim });

  frames.push({
    label: "SUMMARY",
    title,
    lines: summaryLines,
  });

  return frames;
}

/** Collect all text responses in a phase into a single preview string */
function collectTextResponses(scenes: Scene[]): string {
  const texts: string[] = [];
  for (const scene of scenes) {
    if (scene.type === "text-response") {
      const text = scene.content.trim();
      if (text.length > 10) texts.push(text);
    }
  }
  return stripMarkdown(texts.join("\n\n"));
}

/** Strip markdown syntax for cleaner plain-text rendering in SVG */
function stripMarkdown(s: string): string {
  return s
    .replace(/^#{1,6}\s+/gm, "") // headers: ## Title → Title
    .replace(/^---+$/gm, "") // horizontal rules
    .replace(/\*\*([^*]+)\*\*/g, "$1") // bold: **text** → text
    .replace(/\*([^*]+)\*/g, "$1") // italic: *text* → text
    .replace(/`([^`]+)`/g, "$1") // inline code: `code` → code
    .replace(/^>\s?/gm, "") // blockquotes: > text → text
    .replace(/^[-*]\s+/gm, "- ") // normalize list markers
    .replace(/^\d+\.\s+/gm, "") // numbered lists: 1. text → text
    .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // links: [text](url) → text
    .replace(/\n{3,}/g, "\n\n") // collapse multiple blank lines
    .trim();
}

function selectKeyPhases(phases: Phase[], max: number): Phase[] {
  // Take the first N phases in order for narrative continuity
  return phases.slice(0, max);
}

function buildSvgFooter(
  session: ReplaySession,
  opts: GitHubFormatOptions,
): { footerLeft: string; footerRight: string } {
  const duration = formatDuration(session.meta.stats.durationMs);
  const tStats = computeToolStats(session.scenes);
  const parts: (string | null)[] = [duration];
  if (tStats.responses > 0) parts.push(`${tStats.responses} responses`);
  if (tStats.totalTools > 0) parts.push(`${tStats.totalTools} tools`);
  return {
    footerLeft: parts.filter(Boolean).join(" · "),
    footerRight: opts.replayUrl ? "View full replay →" : "vibe-replay.com",
  };
}

function renderSvg(
  frames: SvgFrame[],
  session: ReplaySession,
  opts: GitHubFormatOptions = {},
): string {
  const N = frames.length;
  const secPerFrame = 5;
  const cycleDuration = N * secPerFrame;

  // Each frame occupies 1/N of the cycle
  const pct = 100 / N;
  // Fade transitions as percentage of total cycle
  const fadeIn = (0.5 / cycleDuration) * 100; // 0.5s fade
  const holdEnd = pct - fadeIn;
  const fadeOut = pct;

  // Per-frame animation classes — frame-0 starts visible (no blank flash)
  const animClasses = frames
    .map((_, i) => {
      const delay = i * secPerFrame;
      const fill = i === 0 ? " animation-fill-mode: backwards;" : "";
      return `    .frame-${i} { animation: fadeInOut ${cycleDuration}s ${delay}s infinite;${fill} }`;
    })
    .join("\n");

  const { footerLeft, footerRight } = buildSvgFooter(session, opts);

  // Build frame SVG content
  const framesSvg = frames.map((f, i) => renderFrame(f, i)).join("\n\n");

  return `<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_W}" height="${SVG_H}" viewBox="0 0 ${SVG_W} ${SVG_H}">
  <style>
    text { font-family: ${FONT}; }
    @keyframes fadeInOut {
      0% { opacity: 0 }
      ${n2(fadeIn)}% { opacity: 1 }
      ${n2(holdEnd)}% { opacity: 1 }
      ${n2(fadeOut)}% { opacity: 0 }
      100% { opacity: 0 }
    }
    .frame { opacity: 0; }
${animClasses}
  </style>

  <defs>
    <clipPath id="vr-clip-${session.meta.sessionId.slice(0, 8)}">
      <rect x="0" y="${HEADER_H}" width="${SVG_W}" height="${SVG_H - HEADER_H - FOOTER_H}"/>
    </clipPath>
  </defs>

  <!-- Background -->
  <rect width="${SVG_W}" height="${SVG_H}" rx="12" fill="${C.bg}"/>

  <!-- Header bar -->
  <path d="M0,12 Q0,0 12,0 H${SVG_W - 12} Q${SVG_W},0 ${SVG_W},12 V${HEADER_H} H0 Z" fill="${C.headerBg}"/>
  <circle cx="24" cy="${HEADER_H / 2}" r="5" fill="${C.dotRed}" opacity="0.9"/>
  <circle cx="42" cy="${HEADER_H / 2}" r="5" fill="${C.dotYellow}" opacity="0.9"/>
  <circle cx="60" cy="${HEADER_H / 2}" r="5" fill="${C.dotGreen}" opacity="0.9"/>
  <text x="${SVG_W / 2}" y="${HEADER_H / 2 + 5}" text-anchor="middle" fill="${C.dim}" font-size="12" letter-spacing="0.5">vibe-replay</text>

  <!-- Footer bar -->
  <path d="M0,${SVG_H - FOOTER_H} H${SVG_W} V${SVG_H - 12} Q${SVG_W},${SVG_H} ${SVG_W - 12},${SVG_H} H12 Q0,${SVG_H} 0,${SVG_H - 12} Z" fill="${C.headerBg}"/>
  <line x1="0" y1="${SVG_H - FOOTER_H}" x2="${SVG_W}" y2="${SVG_H - FOOTER_H}" stroke="${C.border}" stroke-width="1"/>
  <text x="${MARGIN}" y="${SVG_H - FOOTER_H / 2 + 4}" fill="${C.dim}" font-size="11">${escXml(footerLeft)}</text>
  <text x="${SVG_W - MARGIN}" y="${SVG_H - FOOTER_H / 2 + 4}" text-anchor="end" fill="${C.dim}" font-size="11">${escXml(footerRight)}</text>

  <!-- Animated content frames (clipped to content area) -->
  <g clip-path="url(#vr-clip-${session.meta.sessionId.slice(0, 8)})">
${framesSvg}
  </g>
</svg>`;
}

function renderFrame(frame: SvgFrame, index: number): string {
  if (frame.userPrompt != null) {
    return renderTurnFrame(frame, index, `class="frame frame-${index}"`);
  }
  return renderInfoFrame(frame, index, `class="frame frame-${index}"`);
}

function renderInfoFrame(frame: SvgFrame, _index: number, gAttr: string): string {
  const parts: string[] = [];
  parts.push(`  <g ${gAttr}>`);

  let y = CONTENT_TOP;

  // ── Title card ──
  const titleLines = frame.titleLine2 ? [frame.title, frame.titleLine2] : [frame.title];
  const titleCardH = 20 + titleLines.length * 28 + 16;
  parts.push(
    `    <rect x="${CARD_X}" y="${y}" width="${CARD_W}" height="${titleCardH}" rx="${CARD_R}" fill="${C.headerBg}" stroke="${C.border}" stroke-width="1"/>`,
  );

  y += 20;

  // Title text (no label — just the session title)
  for (const line of titleLines) {
    parts.push(
      `    <text x="${TEXT_X}" y="${y}" fill="${C.blue}" font-size="18" font-weight="600">${escXml(line)}</text>`,
    );
    y += 28;
  }

  // ── Details card ──
  y = CONTENT_TOP + titleCardH + 14;

  const visibleLines = frame.lines.filter((l) => l.text);
  const spacerCount = frame.lines.filter((l) => !l.text).length;
  const detailCardH = 18 + visibleLines.length * 26 + spacerCount * 14 + 16;
  parts.push(
    `    <rect x="${CARD_X}" y="${y}" width="${CARD_W}" height="${detailCardH}" rx="${CARD_R}" fill="${C.headerBg}" stroke="${C.border}" stroke-width="1"/>`,
  );

  y += 18;

  for (const line of frame.lines) {
    if (!line.text) {
      y += 14;
      continue;
    }
    const weight = line.bold ? ' font-weight="600"' : "";
    parts.push(
      `    <text x="${TEXT_X}" y="${y}" fill="${line.color}" font-size="16"${weight}>${escXml(line.text)}</text>`,
    );
    y += 26;
  }

  parts.push("  </g>");
  return parts.join("\n");
}

function renderTurnFrame(frame: SvgFrame, _index: number, gAttr: string): string {
  const parts: string[] = [];
  parts.push(`  <g ${gAttr}>`);

  const y0 = CONTENT_TOP;
  const promptLines = wrapMultiLine(frame.userPrompt!, MAX_COLS_16, 2);
  const hasStats = frame.turnStats && (frame.turnStats.responses > 0 || frame.turnStats.tools > 0);
  const hasBreakdown = !!frame.turnStats?.breakdown;

  // Heights for each section
  const promptSectionH = 28 + promptLines.length * 26 + 16;

  // Calculate how many response lines fit to fill the remaining space
  const fixedH = 20 + promptSectionH + 24 + 28 + (hasStats ? 34 : 0) + 8 + 20; // card padding + prompt + divider + label + stats + gap + bottom
  const availableForText = SVG_H - FOOTER_H - CONTENT_TOP - fixedH - 8;
  const maxResponseLines = Math.max(3, Math.floor(availableForText / 24));
  const responseLines = frame.responsePreview
    ? wrapMultiLine(frame.responsePreview, MAX_COLS_15, maxResponseLines)
    : [];
  const dividerGap = 24;
  const statsSectionH = 28 + (hasStats ? 34 : 0) + (hasBreakdown && !hasStats ? 24 : 0);
  const responseSectionH = responseLines.length > 0 ? 8 + responseLines.length * 24 : 0;
  const cardH = 20 + promptSectionH + dividerGap + statsSectionH + responseSectionH + 20;

  // ── Single unified card ──
  parts.push(
    `    <rect x="${CARD_X}" y="${y0}" width="${CARD_W}" height="${cardH}" rx="${CARD_R}" fill="${C.headerBg}" stroke="${C.border}" stroke-width="1"/>`,
  );

  let y = y0 + 24;

  // ── YOU section ──
  parts.push(
    `    <text x="${TEXT_X}" y="${y}" fill="${C.green}" font-size="13" font-weight="700">YOU</text>`,
  );
  parts.push(
    `    <text x="${TEXT_RIGHT}" y="${y}" text-anchor="end" fill="${C.dim}" font-size="13">#${String(frame.promptIndex!).padStart(2, "0")}</text>`,
  );
  y += 22;

  for (const line of promptLines) {
    parts.push(
      `    <text x="${TEXT_X}" y="${y}" fill="${C.green}" font-size="16">${escXml(line)}</text>`,
    );
    y += 26;
  }

  // ── Divider ──
  y += 4;
  parts.push(
    `    <line x1="${TEXT_X}" y1="${y}" x2="${TEXT_RIGHT}" y2="${y}" stroke="${C.border}" stroke-width="1"/>`,
  );
  y += 20;

  // ── ASSISTANT section ──
  parts.push(
    `    <text x="${TEXT_X}" y="${y}" fill="${C.text}" font-size="13" font-weight="700">ASSISTANT</text>`,
  );
  y += 24;

  // Stats badges + separator + tool breakdown (matches compact view)
  if (hasStats) {
    let bx = TEXT_X;

    // "N responses" blue pill
    if (frame.turnStats!.responses > 0) {
      const txt = `${frame.turnStats!.responses} responses`;
      const w = 10 + visualWidth(txt) * 7.8 + 10;
      parts.push(
        `    <rect x="${bx}" y="${y - 12}" width="${Math.ceil(w)}" height="20" rx="4" fill="rgba(88,166,255,0.15)"/>`,
      );
      parts.push(
        `    <text x="${bx + w / 2}" y="${y + 1}" text-anchor="middle" fill="${C.blue}" font-size="12">${txt}</text>`,
      );
      bx += Math.ceil(w) + 6;
    }

    // "N tools" orange pill (tool-call color)
    if (frame.turnStats!.tools > 0) {
      const txt = `${frame.turnStats!.tools} tools`;
      const w = 10 + visualWidth(txt) * 7.8 + 10;
      parts.push(
        `    <rect x="${bx}" y="${y - 12}" width="${Math.ceil(w)}" height="20" rx="4" fill="rgba(210,153,34,0.15)"/>`,
      );
      parts.push(
        `    <text x="${bx + w / 2}" y="${y + 1}" text-anchor="middle" fill="${C.orange}" font-size="12">${txt}</text>`,
      );
      bx += Math.ceil(w) + 6;
    }

    // Separator + tool breakdown (orange — tool-call color)
    if (hasBreakdown) {
      parts.push(
        `    <line x1="${bx + 4}" y1="${y - 10}" x2="${bx + 4}" y2="${y + 6}" stroke="${C.border}" stroke-width="1"/>`,
      );
      bx += 14;
      parts.push(
        `    <text x="${bx}" y="${y + 1}" fill="${C.orange}" font-size="12">${escXml(condenseLine(frame.turnStats!.breakdown, 50))}</text>`,
      );
    }

    y += 24;
  }

  // Response text
  for (const line of responseLines) {
    parts.push(
      `    <text x="${TEXT_X}" y="${y}" fill="${C.text}" font-size="15">${escXml(line)}</text>`,
    );
    y += 24;
  }

  // "Show more (N chars)" if response was truncated
  if (frame.responseFullLength && frame.responsePreview) {
    const displayedChars = responseLines.join("").replace(/…$/, "").length;
    const remaining = frame.responseFullLength - displayedChars;
    if (remaining > 50) {
      parts.push(
        `    <text x="${TEXT_X}" y="${y}" fill="${C.orange}" font-size="13">Show more (${remaining} chars)</text>`,
      );
    }
  }

  parts.push("  </g>");
  return parts.join("\n");
}

/**
 * Render a single frame as a complete, static SVG document.
 * No CSS animation — suitable for rasterization to PNG/GIF.
 */
export function renderStaticFrameSvg(
  frame: SvgFrame,
  session: ReplaySession,
  opts: GitHubFormatOptions = {},
): string {
  const { footerLeft, footerRight } = buildSvgFooter(session, opts);

  // Reuse shared rendering logic (no animation class needed)
  let frameContent: string;
  if (frame.userPrompt != null) {
    frameContent = renderTurnFrame(frame, 0, "");
  } else {
    frameContent = renderInfoFrame(frame, 0, "");
  }

  return `<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_W}" height="${SVG_H}" viewBox="0 0 ${SVG_W} ${SVG_H}">
  <style>
    text { font-family: ${FONT}; }
  </style>

  <!-- Background -->
  <rect width="${SVG_W}" height="${SVG_H}" rx="12" fill="${C.bg}"/>

  <!-- Header bar -->
  <path d="M0,12 Q0,0 12,0 H${SVG_W - 12} Q${SVG_W},0 ${SVG_W},12 V${HEADER_H} H0 Z" fill="${C.headerBg}"/>
  <circle cx="24" cy="${HEADER_H / 2}" r="5" fill="${C.dotRed}" opacity="0.9"/>
  <circle cx="42" cy="${HEADER_H / 2}" r="5" fill="${C.dotYellow}" opacity="0.9"/>
  <circle cx="60" cy="${HEADER_H / 2}" r="5" fill="${C.dotGreen}" opacity="0.9"/>
  <text x="${SVG_W / 2}" y="${HEADER_H / 2 + 5}" text-anchor="middle" fill="${C.dim}" font-size="12" letter-spacing="0.5">vibe-replay</text>

  <!-- Footer bar -->
  <path d="M0,${SVG_H - FOOTER_H} H${SVG_W} V${SVG_H - 12} Q${SVG_W},${SVG_H} ${SVG_W - 12},${SVG_H} H12 Q0,${SVG_H} 0,${SVG_H - 12} Z" fill="${C.headerBg}"/>
  <line x1="0" y1="${SVG_H - FOOTER_H}" x2="${SVG_W}" y2="${SVG_H - FOOTER_H}" stroke="${C.border}" stroke-width="1"/>
  <text x="${MARGIN}" y="${SVG_H - FOOTER_H / 2 + 4}" fill="${C.dim}" font-size="11">${escXml(footerLeft)}</text>
  <text x="${SVG_W - MARGIN}" y="${SVG_H - FOOTER_H / 2 + 4}" text-anchor="end" fill="${C.dim}" font-size="11">${escXml(footerRight)}</text>

  <!-- Static content -->
${frameContent}
</svg>`;
}

// ─── Shared helpers ────────────────────────────────────────

function collectFilesChanged(scenes: Scene[]): Map<string, number> {
  const files = new Map<string, number>();
  for (const scene of scenes) {
    if (scene.type !== "tool-call") continue;
    if (scene.toolName === "Edit" || scene.toolName === "Write") {
      for (const fp of filePathsFromInput(scene.input)) {
        const name = shortPath(fp);
        files.set(name, (files.get(name) || 0) + 1);
      }
    }
  }
  return files;
}

function firstFilePath(input: Record<string, any>): string | undefined {
  return filePathsFromInput(input)[0];
}

function filePathsFromInput(input: Record<string, any>): string[] {
  const plural = input.file_paths || input.filePaths || input.paths;
  const paths = Array.isArray(plural)
    ? plural.filter((fp): fp is string => typeof fp === "string" && fp.trim().length > 0)
    : [];
  const singular = input.file_path || input.filePath || input.path || input.relativeWorkspacePath;
  if (typeof singular === "string" && singular.trim()) paths.unshift(singular);
  return [...new Set(paths)];
}

/** Show last 2 path segments for disambiguation (avoids basename collisions) */
function shortPath(fullPath: string): string {
  const parts = fullPath.split("/");
  if (parts.length <= 2) return parts.join("/");
  return parts.slice(-2).join("/");
}

function formatDuration(ms?: number): string | null {
  if (ms == null) return null;
  const sec = Math.round(ms / 1000);
  if (sec < 60) return `${sec}s`;
  const min = Math.floor(sec / 60);
  if (min >= 60) {
    const hr = Math.floor(min / 60);
    const rm = min % 60;
    return rm > 0 ? `${hr}h ${rm}m` : `${hr}h`;
  }
  return `${min} min`;
}

/** Visual width: CJK characters count as 2, others as 1 */
function visualWidth(s: string): number {
  let w = 0;
  for (const ch of s) {
    const code = ch.codePointAt(0) || 0;
    w += isCjk(code) ? 2 : 1;
  }
  return w;
}

function isCjk(code: number): boolean {
  return (
    (code >= 0x3000 && code <= 0x9fff) || // CJK Unified + Symbols + Kana
    (code >= 0xac00 && code <= 0xd7af) || // Hangul
    (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility
    (code >= 0xff00 && code <= 0xff60) || // Fullwidth forms
    (code >= 0x20000 && code <= 0x2fa1f) // CJK Extension B–F
  );
}

/** Truncate string to fit within maxWidth visual columns */
function condenseLine(s: string, maxWidth: number): string {
  const cleaned = s.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
  if (visualWidth(cleaned) <= maxWidth) return cleaned;
  // Truncate character by character
  let w = 0;
  let i = 0;
  for (const ch of cleaned) {
    const cw = isCjk(ch.codePointAt(0) || 0) ? 2 : 1;
    if (w + cw > maxWidth - 1) break;
    w += cw;
    i += ch.length;
  }
  return `${cleaned.slice(0, i)}…`;
}

/** Wrap text into multiple lines, respecting visual width. Last line truncates with ellipsis. */
function wrapMultiLine(s: string, maxWidth: number, maxLines: number): string[] {
  const cleaned = s.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
  if (visualWidth(cleaned) <= maxWidth) return [cleaned];

  const result: string[] = [];
  let remaining = cleaned;

  for (let i = 0; i < maxLines && remaining; i++) {
    if (i === maxLines - 1 || visualWidth(remaining) <= maxWidth) {
      result.push(condenseLine(remaining, maxWidth));
      break;
    }

    let w = 0;
    let lastSpace = -1;
    let charIdx = 0;
    for (const ch of remaining) {
      const cw = isCjk(ch.codePointAt(0) || 0) ? 2 : 1;
      if (w + cw > maxWidth) break;
      if (ch === " ") lastSpace = charIdx;
      w += cw;
      charIdx += ch.length;
    }

    const breakAt = lastSpace > charIdx * 0.3 ? lastSpace : charIdx;
    result.push(remaining.slice(0, breakAt).trim());
    remaining = remaining.slice(breakAt).trim();
  }

  return result;
}

function baseName(p: string): string {
  return p.split("/").pop() || p;
}

function escMd(s: string): string {
  return s.replace(/[[\]()*_`\\]/g, "\\$&");
}

function escXml(s: string): string {
  return stripEmoji(s)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

/** Strip emoji, PUA (Nerd Font/Powerline icons), and other chars resvg cannot render */
function stripEmoji(s: string): string {
  return s
    .replace(
      /[\u{1F600}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B50}\u{2B55}\u{1F1E0}-\u{1F1FF}\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}]|\u{FE0F}|\u{200D}|\u{20E3}|[\u{E0020}-\u{E007F}]/gu,
      "",
    )
    .replace(/\s+/g, " ");
}

/** Format a number with max 2 decimal places, stripping trailing zeros */
function n2(n: number): string {
  return Number(n.toFixed(2)).toString();
}
