import { useEffect, useMemo, useRef, useState } from "react";
import type { JSX } from "react";
import {
  Check,
  ChevronRight,
  Columns2,
  Copy,
  Equal,
  Minus,
  Pencil,
  Plus,
  Rows3,
  X,
} from "lucide-react";
import { cn, formatTokens } from "../../lib/utils";
import type { CapturedLog } from "../../proxy/schemas";
import {
  type DiffOp,
  type JsonNode,
  diffTrees,
  normalizeRequest,
  previewNode,
} from "./requestDiff";
import { getConversationId } from "./ConversationHeader";
import { JsonViewerFromString } from "../ui/json-viewer";
import { Badge } from "../ui/badge";
import { getLogFormatAdapter, resolveLogFormat } from "./log-formats";

export type CompareDrawerProps = {
  /** Log selected first (shown on the left). */
  left: CapturedLog;
  /** Log selected second (shown on the right). */
  right: CapturedLog;
  onClose: () => void;
};

type EqualOp = Extract<DiffOp, { kind: "equal" }>;
type AddedOp = Extract<DiffOp, { kind: "added" }>;
type RemovedOp = Extract<DiffOp, { kind: "removed" }>;
type ChangedOp = Extract<DiffOp, { kind: "changed" }>;

type DiffMode = "unified" | "split";

/** Walk the JsonNode tree and pretty-print it back to a JSON string for the
 *  expanded-equal-subtree view. The node is a plain object structure so
 *  `JSON.stringify` produces correct output. */
function nodeToJsonString(node: JsonNode, indent = 2): string {
  return JSON.stringify(nodeToJsonValue(node), null, indent);
}

function nodeToJsonValue(node: JsonNode): unknown {
  switch (node.kind) {
    case "primitive":
      return node.value;
    case "array":
      return node.value.map(nodeToJsonValue);
    case "object": {
      const out: Record<string, unknown> = {};
      for (const [k, v] of Object.entries(node.value)) {
        out[k] = nodeToJsonValue(v);
      }
      return out;
    }
  }
}

/** The parent path of a JSON path string. E.g. `messages[3].content` →
 *  `messages[3]`, `messages[3]` → `messages`, `messages` → `""`. */
function parentPath(path: string): string {
  if (path === "") return "";
  for (let i = path.length - 1; i >= 0; i--) {
    const ch = path[i];
    if (ch === "." || ch === "[") {
      return path.substring(0, i);
    }
  }
  return "";
}

/** Group contiguous deep-equal ops (object/array, not primitive) that share
 *  a common parent into a single collapsed row, so an unchanged block of N
 *  sibling subtrees renders as one row instead of N. Primitive equals
 *  always render as their own row (they're 1 line each). */
type GroupedOp = { kind: "single"; op: DiffOp } | { kind: "equal-run"; ops: EqualOp[] };

function isDeepEqual(op: DiffOp): op is EqualOp {
  return op.kind === "equal" && (op.value.kind === "object" || op.value.kind === "array");
}

function groupContiguousEquals(ops: DiffOp[]): GroupedOp[] {
  const out: GroupedOp[] = [];
  let i = 0;
  while (i < ops.length) {
    const op = ops[i];
    if (op !== undefined && isDeepEqual(op)) {
      const startParent = parentPath(op.path);
      let j = i + 1;
      while (j < ops.length) {
        const next = ops[j];
        if (next === undefined) break;
        if (!isDeepEqual(next)) break;
        if (parentPath(next.path) !== startParent) break;
        j++;
      }
      if (j - i > 1) {
        const equalOps: EqualOp[] = [];
        for (let k = i; k < j; k++) {
          const eop = ops[k];
          if (eop !== undefined && eop.kind === "equal") {
            equalOps.push(eop);
          }
        }
        out.push({ kind: "equal-run", ops: equalOps });
        i = j;
        continue;
      }
    }
    if (op !== undefined) {
      out.push({ kind: "single", op });
    }
    i++;
  }
  return out;
}

/** Visual configuration for each diff kind. Centralized so the unified row,
 *  summary chips, and split columns all use the same colors / icons. */
type DiffKind = "added" | "removed" | "changed" | "equal";

const KIND_VISUAL: Record<
  DiffKind,
  {
    icon: typeof Plus;
    /** Accent color for the icon + label. */
    accent: string;
    /** Background tint applied to the row. */
    bg: string;
    /** Thick left-border color. */
    border: string;
    /** Short text label (uppercase, used in the row badge). */
    label: string;
  }
> = {
  added: {
    icon: Plus,
    accent: "text-emerald-600 dark:text-emerald-400",
    bg: "bg-emerald-500/5 hover:bg-emerald-500/10",
    border: "border-l-emerald-500",
    label: "ADDED",
  },
  removed: {
    icon: Minus,
    accent: "text-rose-600 dark:text-rose-400",
    bg: "bg-rose-500/5 hover:bg-rose-500/10",
    border: "border-l-rose-500",
    label: "REMOVED",
  },
  changed: {
    icon: Pencil,
    accent: "text-amber-600 dark:text-amber-400",
    bg: "bg-amber-500/5 hover:bg-amber-500/10",
    border: "border-l-amber-500",
    label: "CHANGED",
  },
  equal: {
    icon: Equal,
    accent: "text-muted-foreground/70",
    bg: "bg-muted/20 hover:bg-muted/30",
    border: "border-l-muted-foreground/20",
    label: "EQUAL",
  },
};

function EqualRunRow({
  ops,
  expanded,
  onToggle,
}: {
  ops: EqualOp[];
  expanded: boolean;
  onToggle: () => void;
}): JSX.Element {
  const first = ops[0];
  const last = ops[ops.length - 1];
  if (first === undefined || last === undefined) {
    return <div className="text-muted-foreground/40 text-xs">—</div>;
  }
  const firstPath = first.path;
  const lastPath = last.path;
  const label = ops.length === 1 ? firstPath : `${firstPath} … ${lastPath}`;
  const summary =
    first.value.kind === "array"
      ? `${ops.length} equal arrays`
      : first.value.kind === "object"
        ? `${ops.length} equal objects`
        : "equal";

  const v = KIND_VISUAL.equal;

  return (
    <div className={cn("border-l-4 rounded-sm", v.border, v.bg)}>
      <button
        type="button"
        onClick={onToggle}
        className="w-full text-left flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground cursor-pointer"
      >
        <ChevronRight
          className={cn("size-3 transition-transform shrink-0", expanded && "rotate-90")}
        />
        <v.icon className={cn("size-3 shrink-0", v.accent)} />
        <span className="font-mono truncate flex-1" title={`${firstPath} … ${lastPath}`}>
          {label}
        </span>
        <span className={cn("text-[10px] uppercase tracking-wider shrink-0", v.accent)}>
          {v.label}
        </span>
        <span className="text-muted-foreground/60 shrink-0">({summary})</span>
      </button>
      {expanded && (
        <div className="ml-5 mt-1 mb-2 space-y-2 pr-2">
          {ops.map((op) => (
            <div key={op.path} className="border border-border/50 rounded p-2 bg-muted/20">
              <div className="font-mono text-xs text-muted-foreground mb-1">{op.path}</div>
              <JsonViewerFromString text={nodeToJsonString(op.value)} defaultExpandDepth={0} />
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

/** A single diff row in the unified view. Renders the icon, path, kind label,
 *  value preview, and a copy-path button. `data-diff-idx` lets the parent
 *  scroll the row into view when a summary chip is clicked.
 *
 *  When `expanded` is true (controlled by the parent), renders the full
 *  added / removed / changed subtree below the row. Primitive changes and
 *  primitive equal values are not expandable (the value is already fully
 *  shown on the row). */
function UnifiedOpRow({
  op,
  idx,
  copiedPath,
  onCopyPath,
  expanded,
  onToggle,
}: {
  op: AddedOp | RemovedOp | ChangedOp | EqualOp;
  idx: number;
  copiedPath: string | null;
  onCopyPath: (path: string) => void;
  expanded: boolean;
  onToggle: () => void;
}): JSX.Element {
  const v = KIND_VISUAL[op.kind];
  const Icon = v.icon;

  // True iff this row has a useful subtree to drill into. Primitives (and
  // the root "equal" pseudo-op) have no extra detail beyond the preview.
  const isExpandable =
    op.kind === "added" || op.kind === "removed"
      ? op.value.kind === "object" || op.value.kind === "array"
      : op.kind === "changed"
        ? op.left.kind === "object" ||
          op.left.kind === "array" ||
          op.right.kind === "object" ||
          op.right.kind === "array"
        : false; // equal (single primitive) — preview is the whole value

  // For changed rows, show the left and right previews stacked. For added /
  // removed / equal, one preview is enough.
  const preview =
    op.kind === "changed"
      ? [
          {
            text: previewNode(op.left, 400),
            tone: "text-rose-700 dark:text-rose-300 line-through",
          },
          { text: previewNode(op.right, 400), tone: "text-emerald-700 dark:text-emerald-300" },
        ]
      : op.kind === "removed"
        ? [
            {
              text: previewNode(op.value, 400),
              tone: "text-rose-700 dark:text-rose-300 line-through",
            },
          ]
        : op.kind === "added"
          ? [{ text: previewNode(op.value, 400), tone: "text-emerald-700 dark:text-emerald-300" }]
          : [{ text: previewNode(op.value, 400), tone: "text-muted-foreground" }];

  const justCopied = copiedPath === op.path && op.path !== "";

  return (
    <div
      data-diff-idx={idx}
      data-diff-kind={op.kind}
      className={cn("border-l-4 rounded-sm px-3 py-2 my-0.5 transition-colors", v.border, v.bg)}
    >
      <button
        type="button"
        onClick={onToggle}
        disabled={!isExpandable}
        className={cn(
          "w-full flex items-center gap-2 text-xs text-left rounded-sm",
          isExpandable ? "cursor-pointer" : "cursor-default",
        )}
        aria-expanded={isExpandable ? expanded : undefined}
        aria-label={
          isExpandable
            ? expanded
              ? `Collapse ${op.path || "root"}`
              : `Expand ${op.path || "root"}`
            : undefined
        }
      >
        {isExpandable ? (
          <ChevronRight
            className={cn(
              "size-3 shrink-0 transition-transform",
              v.accent,
              expanded && "rotate-90",
            )}
          />
        ) : (
          <span className="size-3 shrink-0" aria-hidden="true" />
        )}
        <Icon className={cn("size-3.5 shrink-0", v.accent)} strokeWidth={2.5} />
        <span className="font-mono truncate flex-1 min-w-0" title={op.path || "(root)"}>
          {op.path === "" ? "(root)" : op.path}
        </span>
        <span
          className={cn(
            "text-[9px] font-bold uppercase tracking-wider shrink-0 px-1.5 py-0.5 rounded",
            v.accent,
            op.kind === "equal" ? "bg-muted/40" : "bg-background/60",
          )}
        >
          {v.label}
        </span>
        {op.path !== "" && (
          <span
            role="button"
            tabIndex={0}
            onClick={(e) => {
              e.stopPropagation();
              onCopyPath(op.path);
            }}
            onKeyDown={(e) => {
              if (e.key === "Enter" || e.key === " ") {
                e.stopPropagation();
                e.preventDefault();
                onCopyPath(op.path);
              }
            }}
            className={cn(
              "shrink-0 p-1 rounded transition-colors cursor-pointer inline-flex items-center justify-center",
              justCopied
                ? "text-emerald-500"
                : "text-muted-foreground/50 hover:text-foreground hover:bg-muted",
            )}
            aria-label={justCopied ? "Copied" : "Copy"}
            title={justCopied ? "Copied!" : "Copy"}
          >
            {justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
          </span>
        )}
      </button>
      {preview.map((p, i) => (
        // biome-ignore lint/suspicious/noArrayIndexKey: preview list is rebuilt on every render and is positional
        <div key={i} className={cn("font-mono text-xs mt-1 break-all pl-5", p.tone)}>
          {p.text}
        </div>
      ))}
      <div
        className="overflow-hidden transition-all duration-200"
        style={{ maxHeight: expanded && isExpandable ? "2000px" : "0" }}
        aria-hidden={!expanded}
      >
        {expanded && isExpandable && op.kind !== "equal" ? <ExpandedSubtree op={op} /> : null}
      </div>
    </div>
  );
}

/** Render the full subtree for an expanded diff row.
 *  - added / removed: a single JsonViewerFromString for the value.
 *  - changed (object/array on either side): two viewers side by side.
 *  - changed (both primitives): a small "values are shown above" note. */
function ExpandedSubtree({ op }: { op: AddedOp | RemovedOp | ChangedOp }): JSX.Element {
  if (op.kind === "added" || op.kind === "removed") {
    return (
      <div className="pl-5 mt-2 border border-border/50 rounded p-2 bg-muted/20">
        <JsonViewerFromString text={nodeToJsonString(op.value)} defaultExpandDepth={0} />
      </div>
    );
  }
  // changed
  const leftIsStructured = op.left.kind === "object" || op.left.kind === "array";
  const rightIsStructured = op.right.kind === "object" || op.right.kind === "array";
  if (!leftIsStructured && !rightIsStructured) {
    return (
      <div className="pl-5 mt-2 text-xs text-muted-foreground/70 italic">
        Primitive values are shown inline above.
      </div>
    );
  }
  return (
    <div className="pl-5 mt-2 grid grid-cols-1 md:grid-cols-2 gap-2">
      <div className="border border-rose-500/30 rounded p-2 bg-rose-500/5">
        <div className="text-[10px] uppercase tracking-wider text-rose-500 mb-1">Old</div>
        <JsonViewerFromString text={nodeToJsonString(op.left)} defaultExpandDepth={0} />
      </div>
      <div className="border border-emerald-500/30 rounded p-2 bg-emerald-500/5">
        <div className="text-[10px] uppercase tracking-wider text-emerald-500 mb-1">New</div>
        <JsonViewerFromString text={nodeToJsonString(op.right)} defaultExpandDepth={0} />
      </div>
    </div>
  );
}

/** Summary chips at the top of the body. Each chip is a button that scrolls
 *  the body to the first op of that kind. Disabled when the count is 0. */
function SummaryChips({
  counts,
  onJumpTo,
}: {
  counts: { added: number; removed: number; changed: number };
  onJumpTo: (kind: "added" | "removed" | "changed") => void;
}): JSX.Element {
  const total = counts.added + counts.removed + counts.changed;
  return (
    <div className="px-4 py-2 border-b border-border bg-muted/20 flex items-center gap-2 text-xs flex-wrap">
      <span className="text-muted-foreground font-medium">
        {total} {total === 1 ? "change" : "changes"}
      </span>
      <button
        type="button"
        onClick={() => onJumpTo("removed")}
        disabled={counts.removed === 0}
        className={cn(
          "inline-flex items-center gap-1 px-2 py-0.5 rounded-full border cursor-pointer transition-colors",
          counts.removed > 0
            ? "border-rose-500/40 text-rose-600 dark:text-rose-400 bg-rose-500/10 hover:bg-rose-500/20"
            : "border-border text-muted-foreground/40 cursor-not-allowed",
        )}
        title={counts.removed > 0 ? "Jump to first removed" : "No removals"}
      >
        <Minus className="size-3" />
        {counts.removed} removed
      </button>
      <button
        type="button"
        onClick={() => onJumpTo("added")}
        disabled={counts.added === 0}
        className={cn(
          "inline-flex items-center gap-1 px-2 py-0.5 rounded-full border cursor-pointer transition-colors",
          counts.added > 0
            ? "border-emerald-500/40 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20"
            : "border-border text-muted-foreground/40 cursor-not-allowed",
        )}
        title={counts.added > 0 ? "Jump to first added" : "No additions"}
      >
        <Plus className="size-3" />
        {counts.added} added
      </button>
      <button
        type="button"
        onClick={() => onJumpTo("changed")}
        disabled={counts.changed === 0}
        className={cn(
          "inline-flex items-center gap-1 px-2 py-0.5 rounded-full border cursor-pointer transition-colors",
          counts.changed > 0
            ? "border-amber-500/40 text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/20"
            : "border-border text-muted-foreground/40 cursor-not-allowed",
        )}
        title={counts.changed > 0 ? "Jump to first changed" : "No changes"}
      >
        <Pencil className="size-3" />
        {counts.changed} changed
      </button>
    </div>
  );
}

/** Mode toggle in the header: unified (single column) or split (path | left | right). */
function ModeToggle({
  mode,
  onChange,
}: {
  mode: DiffMode;
  onChange: (mode: DiffMode) => void;
}): JSX.Element {
  return (
    <div className="inline-flex rounded-md border border-border overflow-hidden">
      <button
        type="button"
        onClick={() => onChange("unified")}
        aria-pressed={mode === "unified"}
        className={cn(
          "flex items-center gap-1 px-2 py-1 text-xs transition-colors cursor-pointer",
          mode === "unified"
            ? "bg-muted text-foreground"
            : "hover:bg-muted/50 text-muted-foreground",
        )}
        title="Unified view (single column, emphasized diffs)"
      >
        <Rows3 className="size-3" />
        Unified
      </button>
      <button
        type="button"
        onClick={() => onChange("split")}
        aria-pressed={mode === "split"}
        className={cn(
          "flex items-center gap-1 px-2 py-1 text-xs transition-colors border-l border-border cursor-pointer",
          mode === "split" ? "bg-muted text-foreground" : "hover:bg-muted/50 text-muted-foreground",
        )}
        title="Split view (path | left | right)"
      >
        <Columns2 className="size-3" />
        Split
      </button>
    </div>
  );
}

function SideSummary({ log, side }: { log: CapturedLog; side: "left" | "right" }): JSX.Element {
  const conversationId = getConversationId(log);
  return (
    <div className="flex-1 min-w-0 space-y-1 text-xs">
      <div className="flex items-center gap-2">
        <Badge
          variant="outline"
          className={cn(
            "text-[10px] px-1.5 py-0 h-5 font-mono shrink-0",
            side === "left"
              ? "border-rose-500/40 text-rose-400"
              : "border-emerald-500/40 text-emerald-400",
          )}
        >
          {side === "left" ? "← Left" : "Right →"}
        </Badge>
        <span className="font-mono text-blue-400/80">#{log.id}</span>
        {log.model !== null && (
          <span className="font-mono text-muted-foreground truncate">{log.model}</span>
        )}
      </div>
      <div className="flex items-center gap-3 text-muted-foreground font-mono">
        {log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
          <span className="text-emerald-400">
            Cache +{formatTokens(log.cacheCreationInputTokens)}
          </span>
        )}
        {log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
          <span className="text-purple-400">Cache ~{formatTokens(log.cacheReadInputTokens)}</span>
        )}
        <span className="truncate" title={log.timestamp}>
          {log.timestamp}
        </span>
      </div>
      <div className="text-muted-foreground/70 font-mono truncate" title={conversationId}>
        session: {conversationId}
      </div>
    </div>
  );
}

export function CompareDrawer({ left, right, onClose }: CompareDrawerProps): JSX.Element {
  // Memoize the diff so re-renders (e.g. parent re-renders) don't recompute.
  const ops = useMemo<DiffOp[]>(() => {
    const leftRequest = getLogFormatAdapter(resolveLogFormat(left)).analyzeRequest(
      left.rawRequestBody,
    );
    const rightRequest = getLogFormatAdapter(resolveLogFormat(right)).analyzeRequest(
      right.rawRequestBody,
    );
    const l = normalizeRequest(leftRequest.comparisonValue);
    const r = normalizeRequest(rightRequest.comparisonValue);
    return diffTrees(l, r);
  }, [
    left.apiFormat,
    left.path,
    left.rawRequestBody,
    right.apiFormat,
    right.path,
    right.rawRequestBody,
  ]);

  const grouped = useMemo(() => groupContiguousEquals(ops), [ops]);

  // Count diff ops by kind (ignoring equal-run groups, which are always
  // collapsed and not actionable). Drives the summary chips.
  const counts = useMemo(() => {
    let added = 0;
    let removed = 0;
    let changed = 0;
    for (const g of grouped) {
      if (g.kind !== "single") continue;
      switch (g.op.kind) {
        case "added":
          added++;
          break;
        case "removed":
          removed++;
          break;
        case "changed":
          changed++;
          break;
        case "equal":
          break;
      }
    }
    return { added, removed, changed };
  }, [grouped]);

  // Track which collapsed equal runs are expanded.
  const [expandedRuns, setExpandedRuns] = useState<Set<number>>(new Set());
  const toggleRun = (idx: number) => {
    setExpandedRuns((prev) => {
      const next = new Set(prev);
      if (next.has(idx)) next.delete(idx);
      else next.add(idx);
      return next;
    });
  };

  // Track which individual diff rows (added / removed / changed) are
  // expanded. Independent of `expandedRuns` (which is for equal-run groups)
  // so the two expand affordances don't interfere.
  const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
  const toggleRow = (idx: number) => {
    setExpandedRows((prev) => {
      const next = new Set(prev);
      if (next.has(idx)) next.delete(idx);
      else next.add(idx);
      return next;
    });
  };

  // Reset per-row expand state when the comparison pair changes.
  useEffect(() => {
    setExpandedRows(new Set());
  }, [left.id, right.id]);

  const [mode, setMode] = useState<DiffMode>("unified");

  // Body scroll container — used to scroll a target row into view when a
  // summary chip is clicked.
  const bodyRef = useRef<HTMLDivElement>(null);

  // Most-recently-copied path. Used to flash the icon from Copy → Check.
  const [copiedPath, setCopiedPath] = useState<string | null>(null);
  const copyResetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const onCopyPath = (path: string) => {
    void window.navigator.clipboard.writeText(path).then(() => {
      setCopiedPath(path);
      if (copyResetTimer.current !== null) clearTimeout(copyResetTimer.current);
      copyResetTimer.current = setTimeout(() => setCopiedPath(null), 1500);
    });
  };
  useEffect(() => {
    return () => {
      if (copyResetTimer.current !== null) clearTimeout(copyResetTimer.current);
    };
  }, []);

  // Find the grouped-index of the first op matching `kind`, and scroll to it.
  const jumpToKind = (kind: "added" | "removed" | "changed") => {
    const idx = grouped.findIndex((g) => g.kind === "single" && g.op.kind === kind);
    if (idx === -1) return;
    const root = bodyRef.current;
    if (root === null) return;
    const el = root.querySelector(`[data-diff-idx="${idx}"]`);
    if (el !== null) {
      el.scrollIntoView({ behavior: "smooth", block: "center" });
    }
  };

  // Esc keybinding + body scroll lock while the drawer is open.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("keydown", onKey);
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.removeEventListener("keydown", onKey);
      document.body.style.overflow = prevOverflow;
    };
  }, [onClose]);

  const sameSession = getConversationId(left) === getConversationId(right);
  const allEqual = ops.length === 1 && ops[0]?.kind === "equal";

  return (
    <div
      className="fixed inset-0 z-50 flex justify-end"
      role="dialog"
      aria-modal="true"
      aria-label="Compare two log requests"
    >
      {/* Backdrop */}
      <button
        type="button"
        onClick={onClose}
        aria-label="Close compare drawer"
        className="absolute inset-0 bg-black/40 cursor-default"
        tabIndex={-1}
      />

      {/* Drawer panel */}
      <div
        className={cn(
          "relative bg-background border-l border-border shadow-xl",
          "w-full md:w-[70vw] max-w-[1100px] flex flex-col h-full",
        )}
        onClick={(e) => e.stopPropagation()}
        onKeyDown={(e) => e.stopPropagation()}
      >
        {/* Header */}
        <div className="flex items-start gap-4 px-4 py-3 border-b border-border">
          <div className="flex-1 flex gap-4 min-w-0">
            <SideSummary log={left} side="left" />
            <SideSummary log={right} side="right" />
          </div>
          <div className="flex items-center gap-2 shrink-0">
            <ModeToggle mode={mode} onChange={setMode} />
            <button
              type="button"
              onClick={onClose}
              aria-label="Close"
              className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer"
            >
              <X className="size-4" />
            </button>
          </div>
        </div>

        {!sameSession && (
          <div className="px-4 py-1.5 text-xs text-amber-400 bg-amber-500/10 border-b border-border">
            Heads up: the two selected logs are from different sessions.
          </div>
        )}

        {allEqual ? (
          <div className="flex-1 min-h-0 overflow-y-auto flex items-center justify-center text-muted-foreground text-sm">
            The two Request payloads are identical.
          </div>
        ) : (
          <>
            <SummaryChips counts={counts} onJumpTo={jumpToKind} />
            <div ref={bodyRef} className="flex-1 min-h-0 overflow-y-auto">
              {mode === "unified" ? (
                <div className="px-3 py-2 space-y-0.5">
                  {grouped.map((g, i) => {
                    if (g.kind === "equal-run") {
                      return (
                        <EqualRunRow
                          key={`r${i}`}
                          ops={g.ops}
                          expanded={expandedRuns.has(i)}
                          onToggle={() => toggleRun(i)}
                        />
                      );
                    }
                    const op = g.op;
                    return (
                      <UnifiedOpRow
                        key={`o${i}`}
                        op={op}
                        idx={i}
                        copiedPath={copiedPath}
                        onCopyPath={onCopyPath}
                        expanded={expandedRows.has(i)}
                        onToggle={() => toggleRow(i)}
                      />
                    );
                  })}
                </div>
              ) : (
                <SplitBody grouped={grouped} left={left} right={right} />
              )}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

/** Legacy 3-column (path | left | right) view, retained as a toggle option. */
function SplitBody({
  grouped,
  left,
  right,
}: {
  grouped: GroupedOp[];
  left: CapturedLog;
  right: CapturedLog;
}): JSX.Element {
  return (
    <div className="grid grid-cols-[200px_1fr_1fr] gap-x-2 gap-y-0.5 px-3 py-2 text-xs">
      {/* Column headers */}
      <div className="grid grid-cols-[200px_1fr_1fr] gap-x-2 col-span-3 pb-2 mb-2 border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
        <span>Path</span>
        <span>Left (Log #{left.id})</span>
        <span>Right (Log #{right.id})</span>
      </div>

      {grouped.map((g, i) => {
        if (g.kind === "equal-run") {
          return (
            <div
              // biome-ignore lint/suspicious/noArrayIndexKey: positional in the grouped list
              key={i}
              className="col-span-3 px-2 py-1 text-xs text-muted-foreground/60"
            >
              {g.ops.length} equal siblings collapsed — switch to Unified to expand
            </div>
          );
        }
        const op = g.op;
        if (op.kind === "equal") {
          return (
            <div
              // biome-ignore lint/suspicious/noArrayIndexKey: positional in the grouped list
              key={i}
              className="col-span-3 grid grid-cols-[200px_1fr_1fr] gap-x-2 px-2 py-0.5 text-muted-foreground"
            >
              <span className="font-mono text-xs truncate" title={op.path}>
                {op.path}
              </span>
              <span className="font-mono text-xs break-all opacity-60">
                {previewNode(op.value, 200)}
              </span>
              <span className="font-mono text-xs break-all opacity-60">
                {previewNode(op.value, 200)}
              </span>
            </div>
          );
        }
        if (op.kind === "added") {
          return (
            <div
              // biome-ignore lint/suspicious/noArrayIndexKey: positional in the grouped list
              key={i}
              className="col-span-3 px-2 py-1 rounded text-xs border-l-2 border-l-emerald-400/70 bg-emerald-500/5"
            >
              <div className="font-mono text-xs text-muted-foreground mb-0.5">{op.path}</div>
              <div className="font-mono break-all text-emerald-300/90">
                + {previewNode(op.value, 400)}
              </div>
            </div>
          );
        }
        if (op.kind === "removed") {
          return (
            <div
              // biome-ignore lint/suspicious/noArrayIndexKey: positional in the grouped list
              key={i}
              className="col-span-3 px-2 py-1 rounded text-xs border-l-2 border-l-rose-400/70 bg-rose-500/5"
            >
              <div className="font-mono text-xs text-muted-foreground mb-0.5">{op.path}</div>
              <div className="font-mono break-all text-rose-300/90 line-through">
                − {previewNode(op.value, 400)}
              </div>
            </div>
          );
        }
        return (
          <div
            // biome-ignore lint/suspicious/noArrayIndexKey: positional in the grouped list
            key={i}
            className="col-span-3 px-2 py-1 rounded text-xs border-l-2 border-l-amber-400/70 bg-amber-500/5"
          >
            <div className="font-mono text-xs text-muted-foreground mb-1">{op.path}</div>
            <div className="grid grid-cols-2 gap-2">
              <div className="font-mono text-rose-300/90 break-all line-through">
                {previewNode(op.left, 400)}
              </div>
              <div className="font-mono text-emerald-300/90 break-all">
                {previewNode(op.right, 400)}
              </div>
            </div>
          </div>
        );
      })}
    </div>
  );
}
