/**
 * Trace observation: a
 * CUDA-profiling-style two-dimensional lane chart.
 *
 * Trace records completion times; the server infers segments from "model
 * messages are produced serially (autoregressive decoding)" and groups by Task:
 * - Each Task has its own independent timeline (Tasks far apart in time would
 *   leave large gaps if sharing one axis);
 * - Bars come in only five kinds, each with a fixed color (no gray/black/white):
 *   thinking, model reply, tool-call generation (model lane), approval wait,
 *   tool-call execution (each tool gets its own row);
 * - Highlighting is always expressed as "fade out the rest", never an outline:
 *   hovering a segment highlights just that segment (by its unique key, so
 *   adjacent segments at the same timestamp aren't highlighted together), and
 *   it's linked by timestamp to the event list below (onHighlight); clicking a
 *   segment jumps to and briefly highlights the corresponding message row (onJump);
 * - Hovering a legend item highlights matching segments (fading out the rest);
 * - Time-axis zoom/pan: drag the slider below the legend (drag the body to
 *   pan, drag either handle to zoom, double-click to reset), similar to
 *   Premiere's timeline navigator. Scroll-wheel zoom is deliberately
 *   unsupported — to avoid accidentally changing zoom while scrolling the page.
 */
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react";
import type { TraceModelSegment, TraceToolSpan } from "@prismshadow/penguin-server/api";
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";

/**
 * Linked highlighting: `ts` is the anchor shared by both sides; `key` /
 * `rowKey` uniquely identify a bar / message row.
 *
 * **Only one bar and one message light up at a time**: multiple elements
 * often share the same millisecond (a tool_call and its request_end), so
 * matching by `ts` alone would highlight adjacent items together. The
 * initiating side supplies its own unique key; the other side takes the
 * first element at that timestamp.
 */
export interface TraceHighlight {
  ts?: string;
  /** Unique bar identifier (generated internally by the timeline). */
  key?: string;
  /** Unique message-row identifier (generated by the event list). */
  rowKey?: string;
}

/** Fixed color for each of the five bar kinds (solid fill, clear contrast; no gray/black/white/indigo). */
const COLORS = {
  thinking: "bg-violet-500 dark:bg-violet-400",
  text: "bg-sky-500 dark:bg-sky-400",
  toolgen: "bg-amber-500 dark:bg-amber-400",
  approvalWait: "bg-rose-400 dark:bg-rose-300",
  exec: "bg-emerald-500 dark:bg-emerald-400",
} as const;

/**
 * Left-side label column: sticky-pinned to the far left during horizontal
 * scroll/zoom, so labels don't scroll away with the timeline.
 * Must be an opaque block the **same height** as a lane (h-4) — otherwise a
 * bar scrolled underneath the label would peek out above/below the text's
 * line height, looking like the bar is overlapping the label text. Width
 * lines up with the task label row's `pl-[5.5rem]`.
 */
const LABEL_STICKY =
  "sticky left-0 z-10 flex h-4 w-[5.5rem] shrink-0 items-center justify-end bg-white pr-2 dark:bg-gray-950";
/** Label text (truncated when too long). */
const LABEL_TEXT = "truncate font-mono text-[11px]";
/** Zoom range (time-axis multiplier). */
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 24;
/** Slider window's max width corresponds to zoom=1 (window = the whole content); zoom<1 (a compressed overview) is triggered via the −/+ buttons or by dragging the slider. */
const MIN_WIN = 1 / ZOOM_MAX;

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

/** Model-lane segment color: thinking / text (model reply) / tool_call (tool-call generation). */
function segmentColor(kind: TraceModelSegment["kind"]): string {
  if (kind === "thinking") return COLORS.thinking;
  if (kind === "text") return COLORS.text;
  return COLORS.toolgen;
}

function segmentLabel(kind: TraceModelSegment["kind"]): string {
  if (kind === "thinking") return S.traces.kindThinking;
  if (kind === "text") return S.traces.kindModelReply;
  return S.traces.kindToolGen;
}

/** Legend key a segment belongs to (used for hover-linked fade-out). */
function segmentLegendKey(kind: TraceModelSegment["kind"]): string {
  if (kind === "thinking") return "thinking";
  if (kind === "text") return "text";
  return "toolgen";
}

/** Normalize an invalid/missing timestamp to null (treated as unclosed), so a single NaN doesn't propagate. */
function msOf(ts: string | undefined): number | null {
  if (ts === undefined) return null;
  const ms = Date.parse(ts);
  return Number.isFinite(ms) ? ms : null;
}

interface PlacedSegment {
  kind: TraceModelSegment["kind"];
  name?: string;
  ts: string;
  startMs: number;
  endMs: number;
}

interface PlacedSpan {
  name: string;
  toolCallId: string;
  callTs: string;
  approvalTsRaw?: string;
  outputTsRaw?: string;
  callMs: number;
  approvalMs: number | null;
  outputMs: number | null;
  failed: boolean;
  status?: string;
  decision?: string;
}

interface TaskGroup {
  taskIndex: number;
  segs: PlacedSegment[];
  spans: PlacedSpan[];
  t0: number;
  total: number;
  toolNames: string[];
}

function buildGroups(segments: TraceModelSegment[], toolSpans: TraceToolSpan[]): TaskGroup[] {
  const byTask = new Map<number, { segs: PlacedSegment[]; spans: PlacedSpan[] }>();
  const bucket = (i: number) => {
    let b = byTask.get(i);
    if (!b) {
      b = { segs: [], spans: [] };
      byTask.set(i, b);
    }
    return b;
  };

  for (const s of segments) {
    const startMs = msOf(s.startTs);
    const endMs = msOf(s.endTs);
    if (startMs === null || endMs === null) continue;
    bucket(s.taskIndex).segs.push({
      kind: s.kind,
      ...(s.name !== undefined ? { name: s.name } : {}),
      ts: s.endTs,
      startMs,
      endMs,
    });
  }
  for (const t of toolSpans) {
    const callMs = msOf(t.callTs);
    if (callMs === null) continue;
    bucket(t.taskIndex).spans.push({
      name: t.name || t.toolCallId,
      toolCallId: t.toolCallId,
      callTs: t.callTs,
      ...(t.approvalTs !== undefined ? { approvalTsRaw: t.approvalTs } : {}),
      ...(t.outputTs !== undefined ? { outputTsRaw: t.outputTs } : {}),
      callMs,
      approvalMs: msOf(t.approvalTs),
      outputMs: msOf(t.outputTs),
      failed: t.stopReason !== undefined && t.stopReason !== "completed",
      ...(t.stopReason !== undefined ? { status: t.stopReason } : {}),
      ...(t.decision !== undefined ? { decision: t.decision } : {}),
    });
  }

  const groups: TaskGroup[] = [];
  for (const [taskIndex, { segs, spans }] of [...byTask.entries()].sort((a, b) => a[0] - b[0])) {
    if (segs.length === 0 && spans.length === 0) continue;
    const starts = [...segs.map((s) => s.startMs), ...spans.map((s) => s.callMs)];
    const ends = [
      ...segs.map((s) => s.endMs),
      ...spans.map((s) => s.outputMs ?? s.approvalMs ?? s.callMs),
    ];
    const t0 = Math.min(...starts);
    const tEnd = Math.max(...ends, ...starts);
    const total = Math.max(1, tEnd - t0);
    spans.sort((a, b) => a.callMs - b.callMs);
    const toolNames = [...new Set(spans.map((s) => s.name))];
    groups.push({ taskIndex, segs, spans, t0, total, toolNames });
  }
  return groups;
}

/** Track: the gray-background container for bars (no rounded corners; overflow-hidden keeps bars from overlapping adjacent text). */
function Track({ children }: { children: ReactNode }) {
  return (
    <div className="relative h-4 min-w-0 flex-1 overflow-hidden bg-gray-100 dark:bg-gray-800/60">
      {children}
    </div>
  );
}

function Lane({ label, children }: { label: string; children: ReactNode }) {
  return (
    <div className="flex items-center">
      <span className={`${LABEL_STICKY} text-gray-400 dark:text-gray-500`} title={label}>
        <span className={LABEL_TEXT}>{label}</span>
      </span>
      <Track>{children}</Track>
    </div>
  );
}

/**
 * Time-tick row: the first tick left-aligns, the last right-aligns, the rest
 * center — otherwise `-translate-x-1/2` would overflow the first and last
 * labels by half a character width, and the horizontal scroll container
 * would clip the last label (`591ms` truncated to `591`).
 * This way the default width (zoom=1) already fits the rightmost coordinate, with no extra padding needed.
 */
function Ticks({ total }: { total: number }) {
  const fracs = [0, 0.25, 0.5, 0.75, 1];
  return (
    <div className="flex items-center">
      <span className={LABEL_STICKY} />
      <div className="relative h-4 min-w-0 flex-1">
        {fracs.map((f, i) => (
          <span
            key={i}
            className={`absolute top-0 font-mono text-[11px] text-gray-400 dark:text-gray-500 ${
              i === 0 ? "" : i === fracs.length - 1 ? "-translate-x-full" : "-translate-x-1/2"
            }`}
            style={{ left: `${f * 100}%` }}
          >
            {humanizeDuration(f * total)}
          </span>
        ))}
      </div>
    </div>
  );
}

export function TimelineChart({
  segments,
  toolSpans,
  highlight,
  onHighlight,
  onJump,
  hideTaskLabel = false,
}: {
  segments: TraceModelSegment[];
  toolSpans: TraceToolSpan[];
  highlight?: TraceHighlight | null;
  onHighlight?: (h: TraceHighlight | null) => void;
  /** Click a bar: jump to and briefly highlight the message at that moment. */
  onJump?: (ts: string) => void;
  /** Hide the "Round N" label when embedded in the "grouped by Task" view (the group header already shows it). */
  hideTaskLabel?: boolean;
}) {
  const groups = useMemo(() => buildGroups(segments, toolSpans), [segments, toolSpans]);
  /**
   * This chart's bar index: timestamp → the **first** bar's key (in render
   * order), plus the set of every bar's key.
   * - Highlights initiated by the event list carry only `ts`; `firstBarKeyByTs`
   *   degrades that into "only one bar lights up", so adjacent bars at the
   *   same instant aren't dragged along;
   * - `barKeys` decides whether the highlight target **belongs to this
   *   chart**: when grouped by Task, each round renders its own TimelineChart
   *   but they share the same highlight — without this scope check, hovering
   *   a bar in round 1 would fade out round 2's entire timeline too.
   */
  const { firstBarKeyByTs, barKeys } = useMemo(() => {
    const m = new Map<string, string>();
    const keys = new Set<string>();
    const put = (ts: string, key: string) => {
      keys.add(key);
      if (!m.has(ts)) m.set(ts, key);
    };
    for (const g of groups) {
      g.segs.forEach((s, i) => put(s.ts, `s-${g.taskIndex}-${i}`));
      for (const s of g.spans) {
        if (s.approvalMs !== null && s.approvalMs > s.callMs) {
          put(s.approvalTsRaw ?? s.callTs, `w-${s.toolCallId}`);
        }
        if (s.approvalMs === null && s.outputMs === null) put(s.callTs, `p-${s.toolCallId}`);
        else put(s.outputTsRaw ?? s.callTs, `e-${s.toolCallId}`);
      }
    }
    return { firstBarKeyByTs: m, barKeys: keys };
  }, [groups]);
  // Hovering the legend highlights matching segments; null = none.
  const [legendKey, setLegendKey] = useState<string | null>(null);
  // Time-axis zoom multiplier + visible window (derived from scroll).
  const [zoom, setZoom] = useState(1);
  const [view, setView] = useState({ left: 0, width: 1 });
  const scrollRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const viewRef = useRef(view);
  viewRef.current = view;
  const zoomRef = useRef(zoom);
  zoomRef.current = zoom;
  // Target scrollLeft to apply after reflow once zoom changes the content width (keeps the anchor point).
  const pendingScroll = useRef<((el: HTMLDivElement) => number) | null>(null);
  // Cleanup for an in-progress drag (removes the window listener as a fallback on unmount/interruption).
  const dragCleanup = useRef<(() => void) | null>(null);

  const updateView = useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;
    const sw = el.scrollWidth || 1;
    setView({ left: el.scrollLeft / sw, width: Math.min(1, el.clientWidth / sw) });
  }, []);

  useLayoutEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    if (pendingScroll.current) {
      const max = el.scrollWidth - el.clientWidth;
      el.scrollLeft = clamp(pendingScroll.current(el), 0, Math.max(0, max));
      pendingScroll.current = null;
    }
    updateView();
  }, [zoom, groups, updateView]);

  // Recompute the slider window after the container size changes (window resize / sidebar collapse), so the thumb's position/width don't get distorted.
  useLayoutEffect(() => {
    const el = scrollRef.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => updateView());
    ro.observe(el);
    return () => ro.disconnect();
  }, [updateView]);

  // Fallback cleanup of an in-progress drag listener on unmount (pointercancel / mid-drag unmount don't fire pointerup).
  useLayoutEffect(() => () => dragCleanup.current?.(), []);

  if (groups.length === 0) return <p className="text-xs text-gray-400">{S.common.none}</p>;

  const hlTs = highlight?.ts;
  const hlKey = highlight?.key;
  const enter = (key: string, ts: string | undefined) => onHighlight?.(ts ? { ts, key } : null);
  const leave = () => onHighlight?.(null);

  // The one bar that lights up: use the highlight's own key if it has one;
  // otherwise, with only ts (from the event list), take the first bar at that instant.
  // If the target isn't in this chart (the highlight belongs to another Task), treat it as no highlight — otherwise this chart would fade out entirely.
  const target = hlKey ?? (hlTs !== undefined ? firstBarKeyByTs.get(hlTs) : undefined);
  const activeBarKey = target !== undefined && barKeys.has(target) ? target : undefined;
  const hlOn = activeBarKey !== undefined;
  const isActive = (key: string): boolean => activeBarKey === key;

  /** Highlight/legend filtering is always expressed as "fade out the rest", never an outline. */
  const dimClass = (active: boolean, legendMatch: boolean) =>
    (legendKey !== null && !legendMatch) || (hlOn && !active) ? "opacity-15" : "";

  // Position strictly by exact timestamp (no minimum width, no right-edge
  // pull-back): segments sit flush against each other with no overlap, and
  // highlighting lands only on the current segment without bleeding into
  // neighbors (per the user's requirement). left/width are clamped to [0,100] to prevent overflow.
  const placeExact = (startMs: number, endMs: number, t0: number, total: number): CSSProperties => {
    const left = clamp(((startMs - t0) / total) * 100, 0, 100);
    const width = clamp(((endMs - startMs) / total) * 100, 0, 100 - left);
    // A zero-duration segment landing exactly on the right edge: min-w-[2px]
    // would push it out of the track, and the track's overflow-hidden would
    // clip it entirely (invisible, unhoverable). Pull that 2px back into the
    // track with a -2px margin, while the start point stays exactly aligned.
    if (left >= 100) return { left: "100%", width: "0%", marginLeft: "-2px" };
    return { left: `${left}%`, width: `${width}%` };
  };

  const legendChips: Array<{ key: string; className: string; label: string }> = [
    { key: "thinking", className: COLORS.thinking, label: S.traces.kindThinking },
    { key: "text", className: COLORS.text, label: S.traces.kindModelReply },
    { key: "toolgen", className: COLORS.toolgen, label: S.traces.kindToolGen },
    { key: "approvalWait", className: COLORS.approvalWait, label: S.traces.legendApprovalWait },
    { key: "exec", className: COLORS.exec, label: S.traces.legendToolExec },
  ];

  // —— Slider (Premiere-style): drag the body to pan, drag either handle to zoom, double-click to reset ——
  const applyWindow = (left: number, width: number) => {
    const w = clamp(width, MIN_WIN, 1);
    const next = clamp(1 / w, ZOOM_MIN, ZOOM_MAX);
    const l = clamp(left, 0, 1 - w);
    pendingScroll.current = (el) => l * el.scrollWidth;
    setZoom(next);
  };

  const beginPan = (e: ReactPointerEvent) => {
    e.preventDefault();
    const track = trackRef.current;
    const el = scrollRef.current;
    if (!track || !el) return;
    const trackW = track.getBoundingClientRect().width || 1;
    const startX = e.clientX;
    const startScroll = el.scrollLeft;
    const max = Math.max(0, el.scrollWidth - el.clientWidth);
    const move = (ev: PointerEvent) => {
      const dxFrac = (ev.clientX - startX) / trackW;
      el.scrollLeft = clamp(startScroll + dxFrac * el.scrollWidth, 0, max);
      updateView();
    };
    const up = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", up);
      dragCleanup.current = null;
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
    dragCleanup.current = up;
  };

  const beginResize = (side: "left" | "right") => (e: ReactPointerEvent) => {
    e.preventDefault();
    e.stopPropagation();
    const track = trackRef.current;
    if (!track) return;
    const rect = track.getBoundingClientRect();
    const move = (ev: PointerEvent) => {
      const frac = clamp((ev.clientX - rect.left) / (rect.width || 1), 0, 1);
      const cur = viewRef.current;
      if (side === "right") {
        applyWindow(cur.left, frac - cur.left);
      } else {
        const right = cur.left + cur.width;
        applyWindow(frac, right - frac);
      }
    };
    const up = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", up);
      dragCleanup.current = null;
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
    dragCleanup.current = up;
  };

  const resetZoom = () => {
    pendingScroll.current = () => 0;
    setZoom(1);
  };

  /** −/+ buttons: zoom anchored at the viewport center (covers the full range from <1 overview to >1 zoomed-in). */
  const zoomStep = (factor: number) => {
    const el = scrollRef.current;
    const next = clamp(zoomRef.current * factor, ZOOM_MIN, ZOOM_MAX);
    if (next === zoomRef.current) return;
    if (el) {
      const px = el.clientWidth / 2;
      const contentFrac = (el.scrollLeft + px) / (el.scrollWidth || 1);
      pendingScroll.current = (node) => contentFrac * node.scrollWidth - px;
    }
    setZoom(next);
  };

  return (
    <div className="space-y-3">
      {/* Timeline (horizontal scroll container; native scrollbar hidden in favor of the slider below). overflow-y-hidden avoids a spurious vertical scrollbar. */}
      <div ref={scrollRef} className="no-scrollbar overflow-x-auto overflow-y-hidden">
        <div className="space-y-4" style={{ width: `${zoom * 100}%` }}>
          {groups.map((g) => {
            return (
              <div key={g.taskIndex} className="space-y-1">
                {!hideTaskLabel && (
                  <div className="flex items-center gap-2 pl-[5.5rem]">
                    <span className="rounded bg-gray-100 px-1.5 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-gray-800 dark:text-gray-400">
                      {S.traces.task(g.taskIndex + 1)}
                    </span>
                    <span className="font-mono text-[11px] text-gray-400">
                      {humanizeDuration(g.total)}
                    </span>
                  </div>
                )}

                {/* Model lane: serial segments (thinking / model reply / tool-call generation) */}
                <Lane label={S.traces.laneLLM}>
                  {g.segs.map((s, i) => {
                    const lk = segmentLegendKey(s.kind);
                    const legendMatch = legendKey === null || legendKey === lk;
                    const barKey = `s-${g.taskIndex}-${i}`;
                    const active = isActive(barKey);
                    return (
                      <span
                        key={i}
                        onMouseEnter={() => enter(barKey, s.ts)}
                        onMouseLeave={leave}
                        onClick={() => onJump?.(s.ts)}
                        title={`${segmentLabel(s.kind)}${s.name ? ` ${s.name}` : ""} · ${humanizeDuration(s.endMs - s.startMs)}`}
                        className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${segmentColor(s.kind)} ${dimClass(active, legendMatch)}`}
                        style={placeExact(s.startMs, s.endMs, g.t0, g.total)}
                      />
                    );
                  })}
                </Lane>

                {/* Each tool gets its own row: approval wait → execution */}
                {g.spans.map((s) => {
                  const execStart = s.approvalMs ?? s.callMs;
                  const open = s.outputMs === null;
                  const endMs = s.outputMs ?? g.t0 + g.total;
                  const approvalTs = s.approvalTsRaw ?? s.callTs;
                  const execTs = s.outputTsRaw ?? s.callTs;
                  return (
                    <div key={s.toolCallId} className="flex items-center">
                      <span
                        className={`${LABEL_STICKY} text-gray-500 dark:text-gray-400`}
                        title={s.name}
                      >
                        <span className={LABEL_TEXT}>{s.name}</span>
                      </span>
                      <Track>
                        {/* Approval-wait segment (positioned exactly, flush against the execution segment) */}
                        {s.approvalMs !== null && s.approvalMs > s.callMs && (
                          <span
                            onMouseEnter={() => enter(`w-${s.toolCallId}`, approvalTs)}
                            onMouseLeave={leave}
                            onClick={() => onJump?.(approvalTs)}
                            title={`${s.name} · ${S.traces.legendApprovalWait}${s.decision ? ` (${s.decision})` : ""} · ${humanizeDuration(s.approvalMs - s.callMs)}`}
                            className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${COLORS.approvalWait} ${dimClass(
                              isActive(`w-${s.toolCallId}`),
                              legendKey === null || legendKey === "approvalWait",
                            )}`}
                            style={placeExact(s.callMs, s.approvalMs, g.t0, g.total)}
                          />
                        )}
                        {/* Whole-segment pulse while approval is pending / execution segment */}
                        {s.approvalMs === null && open
                          ? (() => {
                              // animate-pulse's keyframes override the static opacity: the pulse animation must be removed when fading out.
                              const dim = dimClass(
                                isActive(`p-${s.toolCallId}`),
                                legendKey === null || legendKey === "approvalWait",
                              );
                              return (
                                <span
                                  onMouseEnter={() => enter(`p-${s.toolCallId}`, s.callTs)}
                                  onMouseLeave={leave}
                                  onClick={() => onJump?.(s.callTs)}
                                  title={`${s.name} · ${S.traces.legendApprovalWait} · ${S.traces.inProgress}`}
                                  className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${COLORS.approvalWait} ${
                                    dim || "animate-pulse"
                                  }`}
                                  style={placeExact(s.callMs, endMs, g.t0, g.total)}
                                />
                              );
                            })()
                          : (() => {
                              // Fade-out and the "running" translucency share the same opacity property: fade-out takes priority to avoid the two overriding each other.
                              const dim = dimClass(
                                isActive(`e-${s.toolCallId}`),
                                legendKey === null || legendKey === "exec",
                              );
                              const running = open && !dim;
                              return (
                                <span
                                  onMouseEnter={() => enter(`e-${s.toolCallId}`, execTs)}
                                  onMouseLeave={leave}
                                  onClick={() => onJump?.(execTs)}
                                  title={`${s.name} · ${S.traces.legendToolExec} · ${
                                    open ? S.traces.inProgress : humanizeDuration(endMs - execStart)
                                  }${s.failed ? ` · ${s.status}` : ""}`}
                                  className={`absolute inset-y-0 min-w-[2px] cursor-pointer ${COLORS.exec} ${
                                    s.failed ? "ring-1 ring-red-500" : ""
                                  } ${running ? "animate-pulse opacity-70" : dim}`}
                                  style={placeExact(execStart, endMs, g.t0, g.total)}
                                />
                              );
                            })()}
                      </Track>
                    </div>
                  );
                })}

                {/* Time-axis ticks */}
                <Ticks total={g.total} />
              </div>
            );
          })}
        </div>
      </div>

      {/* Legend: hovering highlights matching segments — same convention as the bars, non-matching items fade out, no background color */}
      <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-gray-100 pt-2.5 dark:border-gray-800">
        {legendChips.map((c) => (
          <button
            key={c.key}
            type="button"
            onMouseEnter={() => setLegendKey(c.key)}
            onMouseLeave={() => setLegendKey(null)}
            className={`flex items-center gap-1 px-1 font-mono text-[11px] text-gray-500 transition-opacity duration-150 dark:text-gray-400 ${
              legendKey !== null && legendKey !== c.key ? "opacity-30" : ""
            }`}
          >
            <span className={`inline-block h-2 w-3 rounded-sm ${c.className}`} />
            {c.label}
          </button>
        ))}
      </div>

      {/* Premiere-style zoom/pan slider: drag the body to pan, drag either handle to zoom, double-click to reset; −/+ covers the full range from <1 to >1 */}
      <div className="flex items-center gap-2">
        <span className="shrink-0 text-[11px] text-gray-400">{S.traces.zoom}</span>
        <button
          type="button"
          aria-label={S.traces.zoomOut}
          title={S.traces.zoomOut}
          onClick={() => zoomStep(1 / 1.4)}
          className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-gray-200 text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
        >
          <span className="text-[11px] leading-none">−</span>
        </button>
        <div
          ref={trackRef}
          onDoubleClick={resetZoom}
          title={S.traces.zoomReset}
          className="relative h-3 min-w-0 flex-1 rounded bg-gray-100 dark:bg-gray-800"
        >
          <div
            role="scrollbar"
            aria-label={S.traces.zoom}
            onPointerDown={beginPan}
            className="absolute inset-y-0 flex cursor-grab items-center justify-between rounded bg-gray-400/70 active:cursor-grabbing dark:bg-gray-500/70"
            style={{ left: `${view.left * 100}%`, width: `${Math.max(view.width * 100, 3)}%` }}
          >
            <span
              onPointerDown={beginResize("left")}
              className="h-full w-1.5 shrink-0 cursor-ew-resize rounded-l bg-gray-500/80 dark:bg-gray-400/80"
            />
            <span
              onPointerDown={beginResize("right")}
              className="h-full w-1.5 shrink-0 cursor-ew-resize rounded-r bg-gray-500/80 dark:bg-gray-400/80"
            />
          </div>
        </div>
        <button
          type="button"
          aria-label={S.traces.zoomIn}
          title={S.traces.zoomIn}
          onClick={() => zoomStep(1.4)}
          className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-gray-200 text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
        >
          <span className="text-[11px] leading-none">＋</span>
        </button>
        <span className="w-9 shrink-0 text-right font-mono text-[11px] tabular-nums text-gray-400">
          {zoom.toFixed(2)}×
        </span>
      </div>

      {onHighlight && (
        <p className="text-[11px] text-gray-400 dark:text-gray-600">{S.traces.linkHint}</p>
      )}
    </div>
  );
}
