import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useShallow } from "zustand/react/shallow";
import {
  Plus, Scissors, Magnet, Mic, Play, Pause, ZoomIn, ZoomOut, Maximize2, Eye, EyeOff,
  Volume2, VolumeX, Trash2, ArrowRightLeft, Lock, Unlock, ChevronUp, ChevronDown, Copy,
  Video, AudioLines, Square, FoldHorizontal, MoreHorizontal, Music2, X, Link2, Unlink, Monitor, Combine,
} from "lucide-react";
import { useStore, selectStats, type MotionItem, type Track } from "../store";
import { ScreenRecorder } from "./ScreenRecorder";
import { sfxForScene } from "../remotion/mg/catalog.mjs";
import { CaptionMenu } from "./CaptionMenu";
import { AutoPreview } from "./TemplateGallery";
import { TRANSITIONS, DEFAULT_TRANSITION_MS, type ClipTransition } from "../lib/transitions";
import { fmtTime } from "../lib/time";

const LABEL_W = 150;
const tickInterval = (pxps: number) => [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300].find((n) => n * pxps >= 64) || 300;

function IconBtn({ children, title, onClick, active, disabled }: { children: React.ReactNode; title?: string; onClick?: () => void; active?: boolean; disabled?: boolean }) {
  return <button title={title} disabled={disabled} onClick={onClick} className={"grid h-7 w-7 place-items-center rounded hover:bg-white/5 disabled:opacity-30 disabled:hover:bg-transparent " + (active ? "text-primary" : "text-fg-muted hover:text-fg")}>{children}</button>;
}

// Subscribes to playheadMs alone so playback doesn't re-render the whole timeline.
function Playhead({ pxps, onScrub }: { pxps: number; onScrub: (e: React.PointerEvent) => void }) {
  const playheadMs = useStore((s) => s.playheadMs);
  return (
    <div className="absolute top-0 bottom-0 z-30" style={{ left: LABEL_W + (playheadMs / 1000) * pxps }}>
      <div className="pointer-events-none absolute top-0 bottom-0 -left-px w-0.5 bg-[var(--tl-playhead)]" />
      <div onPointerDown={onScrub} title="Drag to scrub" className="absolute -left-[7px] top-0 flex h-6 w-[15px] cursor-ew-resize justify-center">
        <div className="h-3 w-3 rounded-b-[3px] bg-[var(--tl-playhead)] shadow-sm" />
      </div>
    </div>
  );
}
// the magnet guide line: a bright vertical line spanning every lane at the locked time while dragging —
// the "you snapped" affordance every pro NLE shows. Colour-codes the anchor (playhead vs clip-edge).
function SnapGuide({ snap, pxps }: { snap: { ms: number; type: "playhead" | "edge" | "bound" }; pxps: number }) {
  const color = snap.type === "playhead" ? "var(--tl-playhead)" : snap.type === "bound" ? "#9CA3FF" : "#2DE3C0";
  return (
    <div className="pointer-events-none absolute top-0 bottom-0 z-40" style={{ left: LABEL_W + (snap.ms / 1000) * pxps }}>
      <div className="absolute top-0 bottom-0 -left-px w-0.5" style={{ background: color, boxShadow: `0 0 7px ${color}, 0 0 2px ${color}` }} />
      <div className="absolute -left-[3px] -top-px h-1.5 w-1.5 rotate-45" style={{ background: color }} />
      <div className="absolute -left-[3px] -bottom-px h-1.5 w-1.5 rotate-45" style={{ background: color }} />
    </div>
  );
}
function TimeReadout({ durationMs }: { durationMs: number }) {
  const playheadMs = useStore((s) => s.playheadMs);
  return <span className="num text-xs">{fmtTime(playheadMs)} / {fmtTime(durationMs)}</span>;
}
function TransportButton() {
  const playing = useStore((s) => s.playing);
  const toggleFn = useStore((s) => s.toggleFn);
  return <button title={playing ? "Pause (Space)" : "Play (Space)"} onClick={() => toggleFn?.()} className="grid h-7 w-7 place-items-center rounded text-fg hover:bg-white/5">{playing ? <Pause size={15} /> : <Play size={15} />}</button>;
}

const TRACK_COLOR = (t: Track) => (t.kind === "audio" ? "var(--color-tl-audio)" : "var(--color-tl-video)");

// scroll `el` horizontally when a drag's pointer is near its left/right edge → returns px actually
// scrolled (0 if none). Lets clip-drags and box-select keep going past the visible edge with the cursor.
function edgeScrollStep(el: HTMLElement, clientX: number): number {
  const EDGE = 64, MAX = 24;
  const r = el.getBoundingClientRect();
  let dx = 0;
  if (clientX > r.right - EDGE) dx = Math.min(MAX, ((clientX - (r.right - EDGE)) / EDGE) * MAX);
  else if (clientX < r.left + EDGE) dx = -Math.min(MAX, (((r.left + EDGE) - clientX) / EDGE) * MAX);
  if (!dx) return 0;
  const before = el.scrollLeft;
  el.scrollLeft = Math.max(0, before + dx);
  return el.scrollLeft - before;
}

function requestProgramFullscreen() {
  const el = document.getElementById("program-monitor");
  if (!el) return;
  if (document.fullscreenElement) document.exitFullscreen?.();
  else el.requestFullscreen?.().catch(() => {});
}

export function Timeline() {
  const edl = useStore((s) => s.edl);
  const motion = useStore((s) => s.motion);
  const videos = useStore((s) => s.videos);
  const tracks = useStore((s) => s.tracks);
  const activeTrackId = useStore((s) => s.activeTrackId);
  const setMusic = useStore((s) => s.setMusic);
  const sfxStyle = useStore((s) => s.sfxStyle);
  const removeMotion = useStore((s) => s.removeMotion);
  const updateMotion = useStore((s) => s.updateMotion);
  const selectMotion = useStore((s) => s.selectMotion);
  const selectVideo = useStore((s) => s.selectVideo);
  const selectedVideoId = useStore((s) => s.selectedVideoId);
  const toggleSelect = useStore((s) => s.toggleSelect);
  const selectRange = useStore((s) => s.selectRange);
  const selectedIds = useStore((s) => s.selectedIds);
  const removeSelected = useStore((s) => s.removeSelected);
  const clearSelection = useStore((s) => s.clearSelection);
  const setSelection = useStore((s) => s.setSelection);
  const groupSelectedAction = useStore((s) => s.groupSelected);
  const ungroupSelectedAction = useStore((s) => s.ungroupSelected);
  const combineSelectedAction = useStore((s) => s.combineSelected);
  // how many selected clips are combinable media (video/image, not audio) — gates the Combine button
  const selectedMediaCount = useStore((s) => s.selectedIds.reduce((n, id) => { const v = s.videos.find((x) => x.id === id); return v && v.kind !== "audio" ? n + 1 : n; }, 0));
  const setStarts = useStore((s) => s.setStarts);

  // ── grouping helpers ──
  const clipById = (id: string) => { const st = useStore.getState(); return st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id); };
  const groupMemberIds = (g: string) => { const st = useStore.getState(); return [...st.motion.filter((m) => m.group === g), ...st.videos.filter((v) => v.group === g)].map((c) => c.id); };
  // a stable, vivid colour per group id so a group's members read as visually linked
  const groupColor = (g: string) => { let h = 0; for (let i = 0; i < g.length; i++) h = (h * 31 + g.charCodeAt(i)) % 360; return `hsl(${h} 88% 64%)`; };
  // which clips should move together when dragging `id`: its whole group, else the multi-selection, else just it
  const resolveMoveSet = (id: string): string[] => {
    const st = useStore.getState();
    const c = st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id);
    if (c?.group) return groupMemberIds(c.group);
    if (st.selectedIds.includes(id) && st.selectedIds.length > 1) return st.selectedIds.slice();
    return [id];
  };
  const startsOf = (ids: string[]): Record<string, number> => { const st = useStore.getState(); const out: Record<string, number> = {}; for (const id of ids) { const c = st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id); if (c) out[id] = c.startMs; } return out; };
  const multiMove = { resolve: resolveMoveSet, startsOf, apply: setStarts, select: (ids: string[]) => setSelection(ids, false) };
  const selectedGrouped = selectedIds.some((id) => !!clipById(id)?.group);

  // plain-click selection that EXPANDS to the whole group when the clicked clip is grouped
  const onSceneSelect = (id: string, mods: { meta: boolean; shift: boolean }) => {
    if (mods.meta) return toggleSelect(id);
    if (mods.shift) return selectRange(id);
    const s = useStore.getState();
    const c = s.motion.find((m) => m.id === id);
    if (c?.group) return setSelection(groupMemberIds(c.group), false);
    // in multi-select mode (≥1 selected, nothing open in the inspector) a plain click toggles; else open
    if (s.inspectId == null && s.selectedIds.length > 0) toggleSelect(id);
    else selectMotion(id);
  };
  const onVideoSelect = (id: string, mods: { meta: boolean; shift: boolean }) => {
    if (mods.meta) return toggleSelect(id);
    const s = useStore.getState();
    const c = s.videos.find((v) => v.id === id);
    if (c?.group) return setSelection(groupMemberIds(c.group), false);
    selectVideo(id);
  };
  // ⌘/Ctrl+G groups the selected clips, ⌘/Ctrl+Shift+G ungroups
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const tg = e.target as HTMLElement | null;
      if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.isContentEditable)) return;
      if ((e.metaKey || e.ctrlKey) && (e.key === "g" || e.key === "G")) {
        e.preventDefault();
        if (e.shiftKey) ungroupSelectedAction(); else groupSelectedAction();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [groupSelectedAction, ungroupSelectedAction]);
  const removeVideo = useStore((s) => s.removeVideo);
  const updateVideo = useStore((s) => s.updateVideo);
  const duplicateVideo = useStore((s) => s.duplicateVideo);
  const duplicateMotion = useStore((s) => s.duplicateMotion);
  const setTransition = useStore((s) => s.setTransition);
  const pushHistory = useStore((s) => s.pushHistory);
  const setPlayhead = useStore((s) => s.setPlayhead);
  const splitAtPlayhead = useStore((s) => s.splitAtPlayhead);
  const closeGaps = useStore((s) => s.closeGaps);
  const splitClip = useStore((s) => s.splitClip);
  const stats = useStore(useShallow(selectStats));
  const [pxps, setPxps] = useState(16);
  const [snap, setSnap] = useState(true);
  // the active magnet guide line while dragging/trimming (the time + what it locked onto) — drawn over the lanes
  const [snapLine, setSnapLine] = useState<{ ms: number; type: "playhead" | "edge" | "bound" } | null>(null);
  const snapRef = useRef<{ ms: number; type: string } | null>(null);
  const setLine = (next: { ms: number; type: "playhead" | "edge" | "bound" } | null) => {
    const a = snapRef.current, same = (!a && !next) || (!!a && !!next && a.ms === next.ms && a.type === next.type);
    if (!same) { snapRef.current = next; setSnapLine(next); }
  };
  const [addMenu, setAddMenu] = useState(false);
  const [recording, setRecording] = useState(false);
  const [screenRec, setScreenRec] = useState(false);
  const [editingTrack, setEditingTrack] = useState<string | null>(null);

  const rulerLaneRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const scrollRef = useRef<HTMLDivElement>(null); // the horizontal scroll viewport — for edge auto-scroll while dragging
  const [marquee, setMarquee] = useState<{ left: number; top: number; w: number; h: number } | null>(null);
  const marqueeActive = useRef(false);
  const [hoverPv, setHoverPv] = useState<{ item: MotionItem; rect: DOMRect } | null>(null);
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const px = (ms: number) => (Number.isFinite(ms) ? (ms / 1000) * pxps : 0);
  const contentW = px(stats.durationMs) + 80;
  const seek = (ms: number) => { const c = Math.min(Math.max(0, ms), stats.durationMs); setPlayhead(c); useStore.getState().seekFn?.(c); };
  // the magnet guide line is transient — clear it whenever any drag/trim gesture ends
  useEffect(() => {
    const clear = () => setLine(null);
    window.addEventListener("pointerup", clear);
    window.addEventListener("pointercancel", clear);
    return () => { window.removeEventListener("pointerup", clear); window.removeEventListener("pointercancel", clear); };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // split a specific clip: at the playhead if it's inside the clip, else at the clip's midpoint
  const splitClipSmart = (item: { id: string; startMs: number; durationMs: number }) => {
    const ph = useStore.getState().playheadMs;
    const inside = ph > item.startMs + 100 && ph < item.startMs + item.durationMs - 100;
    if (!inside) seek(item.startMs + item.durationMs / 2);
    splitClip(item.id);
  };

  const startScrub = (e: React.PointerEvent) => {
    const lane = rulerLaneRef.current;
    if (!lane) return;
    // live rect so the position stays correct as the viewport auto-scrolls under the cursor
    const toMs = (cx: number) => ((cx - lane.getBoundingClientRect().left) / pxps) * 1000;
    // touch: don't hijack the native scroll gesture — just seek on a stationary tap
    if (e.pointerType === "touch") {
      const dx0 = e.clientX, dy0 = e.clientY;
      const up = (ev: PointerEvent) => {
        window.removeEventListener("pointerup", up);
        if (Math.abs(ev.clientX - dx0) < 8 && Math.abs(ev.clientY - dy0) < 8) seek(toMs(ev.clientX));
      };
      window.addEventListener("pointerup", up);
      return;
    }
    e.preventDefault();
    e.stopPropagation();
    seek(toMs(e.clientX));
    let lastX = e.clientX;
    const onMove = (ev: PointerEvent) => { lastX = ev.clientX; seek(toMs(ev.clientX)); };
    // drag the playhead/cursor past the edge → keep scrolling so it follows the cursor
    let raf = 0;
    const tick = () => {
      const sc = scrollRef.current;
      if (sc && edgeScrollStep(sc, lastX)) seek(toMs(lastX));
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    const onUp = () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onUp);
    };
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onUp);
  };

  // The magnet. Aligns a dragged/trimmed clip to the STRONGEST nearby anchor — any other clip's start OR
  // end (across EVERY lane, so stacked players line up), the playhead/cursor, or the timeline start/end.
  // Both the clip's own start AND end are candidates, so two clips can be made to start OR end together.
  // Returns the snapped ms and lights a guide line at the locked time (cleared on pointer-up).
  const snapMs = (ms: number, selfId?: string, dur = 0, exclude?: Set<string>): number => {
    if (!snap) { setLine(null); return ms; }
    const st = useStore.getState();
    const skip = (id: string) => id === selfId || (!!exclude && exclude.has(id)); // a moving group must not snap to its own members
    const ph = Math.round(st.playheadMs), end = Math.round(stats.durationMs);
    // a comfortable ~10px catch zone that stays catchable at any zoom (pro NLEs feel "sticky", not pixel-perfect)
    const thresh = Math.max(45, Math.min(420, (10 / pxps) * 1000));
    // typed targets so the guide line can colour-code WHAT it locked onto (clip edge vs playhead vs bound)
    const targets: { at: number; type: "playhead" | "edge" | "bound" }[] = [
      { at: ph, type: "playhead" }, { at: 0, type: "bound" }, { at: end, type: "bound" },
    ];
    for (const m of st.motion) if (!skip(m.id)) targets.push({ at: m.startMs, type: "edge" }, { at: m.startMs + m.durationMs, type: "edge" });
    for (const v of st.videos) if (!skip(v.id)) targets.push({ at: v.startMs, type: "edge" }, { at: v.startMs + v.durationMs, type: "edge" });
    // pick the closest hit among "my start → target" and "my end → target"; clip edges win ties over the playhead
    let best: { result: number; at: number; type: "playhead" | "edge" | "bound"; dist: number } | null = null;
    for (const tg of targets) {
      const bias = tg.type === "edge" ? -0.5 : 0; // a hair of preference for clip edges so flush-cuts win ties
      const dStart = Math.abs(ms - tg.at);
      if (dStart < thresh && (!best || dStart + bias < best.dist)) best = { result: Math.round(tg.at), at: tg.at, type: tg.type, dist: dStart + bias };
      if (dur) { const dEnd = Math.abs(ms + dur - tg.at); if (dEnd < thresh && (!best || dEnd + bias < best.dist)) best = { result: Math.round(tg.at - dur), at: tg.at, type: tg.type, dist: dEnd + bias }; }
    }
    if (best) { setLine({ ms: best.at, type: best.type }); return best.result; }
    setLine(null);
    return ms;
  };

  // marquee box-select on a visual lane: drag to highlight a time range → select every scene ON THAT
  // LANE that it touches. Shift extends. A plain click (no drag) seeks + deselects.
  const startMarqueeFor = (trackId: string) => (e: React.PointerEvent) => {
    if (e.button !== 0) return;
    const content = contentRef.current;
    if (!content) return;
    // touch: a box-select drag would fight native scrolling — tap to seek + clear instead
    if (e.pointerType === "touch") {
      const dx0 = e.clientX, dy0 = e.clientY, additive0 = e.shiftKey;
      const up = (ev: PointerEvent) => {
        window.removeEventListener("pointerup", up);
        if (Math.abs(ev.clientX - dx0) < 8 && Math.abs(ev.clientY - dy0) < 8) {
          const r = content.getBoundingClientRect();
          seek(((ev.clientX - r.left - LABEL_W) / pxps) * 1000);
          if (!additive0) clearSelection();
        }
      };
      window.addEventListener("pointerup", up);
      return;
    }
    const additive = e.shiftKey;
    const rect0 = content.getBoundingClientRect();
    const x0 = e.clientX - rect0.left, y0 = e.clientY - rect0.top;
    let moved = false;
    marqueeActive.current = true;
    document.body.style.userSelect = "none"; // never let a box-select drag highlight page text
    if (hoverTimer.current) clearTimeout(hoverTimer.current);
    setHoverPv(null);
    let lastEv = e.nativeEvent as PointerEvent;
    const apply = (ev: PointerEvent) => {
      lastEv = ev;
      const r = content.getBoundingClientRect();
      const x1 = ev.clientX - r.left, y1 = ev.clientY - r.top;
      const left = Math.min(x0, x1), top = Math.min(y0, y1), w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
      if (!moved && (w > 4 || h > 4)) moved = true;
      if (!moved) return;
      setMarquee({ left, top, w, h });
      // CROSS-TRACK box-select: pick ANY clip (scene or media, on ANY lane) whose rendered rect intersects
      // the box — using the live DOM rects so it works across stacked players, not just the lane you started on.
      const bx0 = r.left + left, by0 = r.top + top, bx1 = bx0 + w, by1 = by0 + h;
      const ids: string[] = [];
      content.querySelectorAll("[data-clip-id]").forEach((el) => {
        const cr = (el as HTMLElement).getBoundingClientRect();
        if (cr.right > bx0 && cr.left < bx1 && cr.bottom > by0 && cr.top < by1) { const id = el.getAttribute("data-clip-id"); if (id) ids.push(id); }
      });
      setSelection(ids, additive);
    };
    // keep scrolling while the pointer hovers near a horizontal edge, re-applying so the box follows
    let raf = 0;
    const tick = () => {
      const sc = scrollRef.current;
      if (sc && moved) { if (edgeScrollStep(sc, lastEv.clientX)) apply(lastEv); }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    const onUp = (ev: PointerEvent) => {
      cancelAnimationFrame(raf);
      window.removeEventListener("pointermove", apply);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onUp);
      marqueeActive.current = false;
      document.body.style.userSelect = "";
      setMarquee(null);
      if (!moved) { const r = content.getBoundingClientRect(); seek(((ev.clientX - r.left - LABEL_W) / pxps) * 1000); if (!additive) clearSelection(); }
    };
    window.addEventListener("pointermove", apply);
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onUp);
  };

  const onClipHover = (item: ClipLike, el: HTMLElement | null) => {
    if (hoverTimer.current) clearTimeout(hoverTimer.current);
    const m = useStore.getState().motion.find((x) => x.id === item.id);
    if (!el || !m || !m.template || marqueeActive.current) { setHoverPv(null); return; }
    const rect = el.getBoundingClientRect();
    hoverTimer.current = setTimeout(() => { if (!marqueeActive.current) setHoverPv({ item: m, rect }); }, 260);
  };

  const visualTracks = tracks.filter((t) => t.kind === "video"); // index 0 = top lane = top z
  const audioTracks = tracks.filter((t) => t.kind === "audio");
  // background-music drag-and-drop (from the Music panel) → place it as a clip on the Music lane
  const hasMusicClip = videos.some((v) => v.music);
  const onMusicDragOver = (e: React.DragEvent) => { if (e.dataTransfer.types.includes("application/x-elg-music")) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; } };
  const onMusicDrop = (e: React.DragEvent) => { const id = e.dataTransfer.getData("application/x-elg-music"); if (id) { e.preventDefault(); setMusic(id); } };

  // render one track's lane (header controls + its clips)
  const renderTrack = (track: Track, i: number, group: Track[]) => {
    const locked = !!track.locked;
    const media = videos.filter((v) => v.trackId === track.id && v.kind !== "audio");
    const scenes = motion.filter((m) => m.trackId === track.id);
    const audioClips = videos.filter((v) => v.trackId === track.id && v.kind === "audio");
    const onLaneDown = track.kind === "video" && !locked ? startMarqueeFor(track.id) : startScrub;
    return (
      <TrackLane key={track.id} track={track} active={activeTrackId === track.id} contentW={contentW} canUp={i > 0} canDown={i < group.length - 1}
        editing={editingTrack === track.id} onEdit={(v) => setEditingTrack(v ? track.id : null)} onLaneDown={onLaneDown}>
        {track.kind === "video" && media.map((v) => (
          <ClipBlock key={v.id} item={v} color="var(--color-tl-video)" hasSource={v.kind !== "image"} locked={locked} pxps={pxps} px={px} snapMs={snapMs} pushHistory={pushHistory} scrollRef={scrollRef}
            trackId={track.id} laneKind="video" onMoveTrack={(id, tid) => updateVideo(id, { trackId: tid })} onSelect={onVideoSelect} selected={selectedIds.includes(v.id) || selectedVideoId === v.id}
            groupColor={v.group ? groupColor(v.group) : undefined} multiMove={multiMove}
            onUpdate={updateVideo} onRemove={removeVideo} onTransition={setTransition} onSplit={() => splitClipSmart(v)} onDuplicate={() => duplicateVideo(v.id)} />
        ))}
        {track.kind === "video" && scenes.map((m) => (
          <ClipBlock key={m.id} item={m} color="var(--color-tl-motion)" locked={locked} pxps={pxps} px={px} snapMs={snapMs} pushHistory={pushHistory} scrollRef={scrollRef}
            trackId={track.id} laneKind="video" onMoveTrack={(id, tid) => updateMotion(id, { trackId: tid })}
            sfx={m.sfx !== undefined ? m.sfx : sfxForScene(sfxStyle, m.template)}
            groupColor={m.group ? groupColor(m.group) : undefined} multiMove={multiMove}
            onUpdate={updateMotion} onRemove={removeMotion} onSelect={onSceneSelect} onHover={onClipHover} onTransition={setTransition}
            onSplit={() => splitClipSmart(m)} onDuplicate={() => duplicateMotion(m.id)} selected={selectedIds.includes(m.id)} />
        ))}
        {track.kind === "audio" && audioClips.map((v) => (
          <ClipBlock key={v.id} item={v} color="var(--color-tl-audio)" hasSource locked={locked} pxps={pxps} px={px} snapMs={snapMs} pushHistory={pushHistory} scrollRef={scrollRef}
            trackId={track.id} laneKind="audio" onMoveTrack={(id, tid) => updateVideo(id, { trackId: tid })} onSelect={onVideoSelect} selected={selectedIds.includes(v.id) || selectedVideoId === v.id}
            groupColor={v.group ? groupColor(v.group) : undefined} multiMove={multiMove}
            onUpdate={updateVideo} onRemove={removeVideo} onTransition={setTransition} onSplit={() => splitClipSmart(v)} onDuplicate={() => duplicateVideo(v.id)} audio />
        ))}
      </TrackLane>
    );
  };

  return (
    <section data-tour="ed-timeline" className="flex min-w-0 shrink-0 select-none flex-col overflow-hidden border-t border-border bg-surface">
      <div className="flex items-center gap-1 border-b border-border px-2 py-1.5 text-fg-muted">
        <div className="relative">
          <IconBtn title="Add a track" active={addMenu} onClick={() => setAddMenu((o) => !o)}><Plus size={15} /></IconBtn>
          {addMenu && <AddTrackMenu onClose={() => setAddMenu(false)} />}
        </div>
        <IconBtn title="Split at playhead (S)" onClick={() => splitAtPlayhead()}><Scissors size={15} /></IconBtn>
        <IconBtn title="Snapping" active={snap} onClick={() => setSnap((s) => !s)}><Magnet size={15} /></IconBtn>
        <IconBtn title="Close gaps — pull every clip flush" onClick={() => closeGaps()}><FoldHorizontal size={15} /></IconBtn>
        <IconBtn title={recording ? "Recording…" : "Record voiceover"} active={recording} onClick={() => setRecording((r) => !r)}><Mic size={15} /></IconBtn>
        <IconBtn title={screenRec ? "Recording screen…" : "Record screen"} active={screenRec} onClick={() => setScreenRec((r) => !r)}><Monitor size={15} /></IconBtn>
        <div className="flex-1" />
        <TransportButton />
        <TimeReadout durationMs={stats.durationMs} />
        <div className="flex-1" />
        <IconBtn title="Zoom out" onClick={() => setPxps((p) => Math.max(4, p / 1.4))}><ZoomOut size={15} /></IconBtn>
        <IconBtn title="Zoom in" onClick={() => setPxps((p) => Math.min(120, p * 1.4))}><ZoomIn size={15} /></IconBtn>
        <CaptionMenu />
        <IconBtn title="Fullscreen preview" onClick={requestProgramFullscreen}><Maximize2 size={14} /></IconBtn>
      </div>

      {recording && <VoiceRecorder onClose={() => setRecording(false)} />}
      {screenRec && <ScreenRecorder onClose={() => setScreenRec(false)} />}

      {selectedIds.length > 1 && (
        <div className="flex items-center gap-2 border-b border-primary/30 bg-primary/10 px-3 py-1.5 text-[12px]">
          <span className="font-semibold text-fg">{selectedIds.length} clips selected</span>
          <span className="hidden text-fg-subtle sm:inline">— {selectedGrouped ? "grouped: drag any to move all" : "Group to move together, or Combine into one clip"} · ⌘/Shift-click to adjust</span>
          <div className="flex-1" />
          {selectedMediaCount > 1 && <button onClick={() => combineSelectedAction()} title="Combine into one video — the clips play back-to-back and become a single clip, so an overlay / crop / effect applies to all of them at once" className="flex items-center gap-1 rounded bg-primary px-2 py-1 text-[11px] font-semibold text-white hover:brightness-110"><Combine size={12} /> Combine</button>}
          <button onClick={() => groupSelectedAction()} title="Group — move & select together, stays editable (⌘G)" className="flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg"><Link2 size={12} /> Group</button>
          {selectedGrouped && <button onClick={() => ungroupSelectedAction()} title="Ungroup (⌘⇧G)" className="flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg"><Unlink size={12} /> Ungroup</button>}
          <button onClick={removeSelected} className="flex items-center gap-1 rounded bg-destructive px-2 py-1 text-[11px] font-semibold text-white hover:brightness-110"><Trash2 size={12} /> Delete {selectedIds.length}</button>
          <button onClick={clearSelection} className="rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg">Clear</button>
        </div>
      )}

      {/* height fits the tracks (no dead space below), scrolls when there are many; caps so the preview stays large */}
      <div ref={scrollRef} className="overflow-auto bg-[var(--color-surface-sunken)]" style={{ maxHeight: "42vh" }}>
        <div ref={contentRef} style={{ width: LABEL_W + contentW, minWidth: "100%" }} className="relative" onDragOver={onMusicDragOver} onDrop={onMusicDrop}>
          <Ruler pxps={pxps} durationMs={stats.durationMs} px={px} onScrub={startScrub} laneRef={rulerLaneRef} />

          {/* visual tracks (top → bottom) then audio tracks; the top visual lane composites on top */}
          {visualTracks.map((t, i) => renderTrack(t, i, visualTracks))}
          {audioTracks.map((t, i) => renderTrack(t, i, audioTracks))}

          {/* Music drop hint — shown only until a track is placed; once placed it's a REAL clip on the
              Music audio lane (rendered by the audio tracks above) that you can move, trim, cut & fade. */}
          {!hasMusicClip && (
            <Lane label="♫" name="Music" color="var(--color-tl-audio)" contentW={contentW} onPointerDown={startScrub}>
              <div className="absolute inset-0" onDragOver={onMusicDragOver} onDrop={onMusicDrop}>
                <div className="pointer-events-none absolute inset-0 flex items-center px-3 text-[10px] text-fg-subtle"><Music2 size={11} className="mr-1.5 opacity-60" /> Drag a track here from the <span className="mx-1 text-fg-muted">Music</span> panel — it becomes a clip you can move, trim &amp; fade.</div>
              </div>
            </Lane>
          )}

          {/* the transcript dialogue lane (driven by the script) — only shown once there's a transcript,
              so script/scene-only projects aren't padded with an empty lane */}
          {edl.length > 0 && (
            <Lane label="A1" name="Dialogue" color="var(--color-tl-audio)" contentW={contentW} onPointerDown={startScrub}>
              {edl.map((it) => (
                <div key={it.id} className="absolute top-1 h-8 rounded border" title={`${Math.round(it.durationMs / 1000)}s`}
                  style={{ left: px(it.timelineStartMs), width: Math.max(2, px(it.durationMs) - 1), background: "color-mix(in oklch, var(--color-tl-audio) 26%, transparent)", borderColor: "var(--color-tl-audio)" }} />
              ))}
            </Lane>
          )}

          {marquee && <div className="pointer-events-none absolute z-20 rounded-sm border border-primary bg-primary/20" style={{ left: marquee.left, top: marquee.top, width: marquee.w, height: marquee.h }} />}
          {snapLine && <SnapGuide snap={snapLine} pxps={pxps} />}
          <Playhead pxps={pxps} onScrub={startScrub} />
        </div>
      </div>

      {hoverPv && hoverPv.item.template && (
        <div className="pointer-events-none fixed z-50" style={{ left: Math.min(Math.max(8, hoverPv.rect.left + hoverPv.rect.width / 2 - 120), (typeof window !== "undefined" ? window.innerWidth : 1280) - 248), top: hoverPv.rect.top - 164 }}>
          <div className="overflow-hidden rounded-lg border border-border bg-black shadow-xl ring-1 ring-white/40">
            <AutoPreview template={hoverPv.item.template} props={(hoverPv.item.props as Record<string, unknown>) || {}} theme={hoverPv.item.theme || "dark"} accent={hoverPv.item.accent || "#E7723B"} bg={hoverPv.item.bg} anim={hoverPv.item.anim} font={hoverPv.item.font}
              durationInFrames={Math.max(1, Math.round((hoverPv.item.durationMs / 1000) * 30))} style={{ width: 240, aspectRatio: "16 / 9", display: "block" }} />
            <div className="truncate bg-black px-2 py-1 text-[10px] font-medium text-white/80">{hoverPv.item.label}</div>
          </div>
        </div>
      )}
    </section>
  );
}

// "Add a track" dropdown: a video lane (composites above) or a voice/audio lane (mixes in).
function AddTrackMenu({ onClose }: { onClose: () => void }) {
  const addTrack = useStore((s) => s.addTrack);
  return (
    <>
      <div className="fixed inset-0 z-30" onClick={onClose} />
      <div className="absolute left-0 top-full z-40 mt-1 w-52 rounded-lg border border-border bg-surface-raised p-1 shadow-xl">
        <button onClick={() => { addTrack("video"); onClose(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-primary/10">
          <span className="grid h-7 w-7 place-items-center rounded bg-[var(--color-tl-video)]/15 text-[var(--color-tl-video)]"><Video size={15} /></span>
          <span><span className="block text-[13px] font-medium text-fg">Video track</span><span className="block text-[11px] text-fg-subtle">Stacks on top — covers lanes below</span></span>
        </button>
        <button onClick={() => { addTrack("audio"); onClose(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-primary/10">
          <span className="grid h-7 w-7 place-items-center rounded bg-[var(--color-tl-audio)]/15 text-[var(--color-tl-audio)]"><AudioLines size={15} /></span>
          <span><span className="block text-[13px] font-medium text-fg">Voice / audio track</span><span className="block text-[11px] text-fg-subtle">For voiceover or music — mixes in</span></span>
        </button>
      </div>
    </>
  );
}

// Record a voiceover from the mic → upload as an audio asset → drop it on a voice track at the playhead.
function VoiceRecorder({ onClose }: { onClose: () => void }) {
  const projectId = useStore((s) => s.projectId);
  const addAsset = useStore((s) => s.addAsset);
  const addVideo = useStore((s) => s.addVideo);
  const [state, setState] = useState<"idle" | "recording" | "saving">("idle");
  const [elapsed, setElapsed] = useState(0);
  const [err, setErr] = useState("");
  const rec = useRef<MediaRecorder | null>(null);
  const stream = useRef<MediaStream | null>(null); // held so the mic is ALWAYS released, even if MediaRecorder() throws
  const chunks = useRef<Blob[]>([]);
  const startedAt = useRef(0);
  const timer = useRef<ReturnType<typeof setInterval> | null>(null);

  const releaseMic = () => { stream.current?.getTracks().forEach((t) => t.stop()); stream.current = null; };
  // teardown (unmount / Cancel / Mic re-toggle): stop the timer and free the mic, discarding any in-flight take
  useEffect(() => () => { if (timer.current) clearInterval(timer.current); releaseMic(); }, []);

  async function start() {
    setErr("");
    try {
      stream.current = await navigator.mediaDevices.getUserMedia({ audio: true });
      const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg"].find((m) => MediaRecorder.isTypeSupported?.(m)) || "";
      const mr = new MediaRecorder(stream.current, mime ? { mimeType: mime } : undefined);
      chunks.current = [];
      mr.ondataavailable = (e) => { if (e.data.size) chunks.current.push(e.data); };
      mr.onstop = () => { releaseMic(); upload(new Blob(chunks.current, { type: mr.mimeType || "audio/webm" }), Date.now() - startedAt.current); };
      rec.current = mr;
      startedAt.current = Date.now();
      mr.start();
      setState("recording");
      timer.current = setInterval(() => setElapsed(Date.now() - startedAt.current), 200);
    } catch (e) {
      releaseMic(); // construction failed after getUserMedia → don't leave the mic open
      setErr(e instanceof Error && e.name === "NotAllowedError" ? "Microphone permission denied." : "Couldn't access the microphone.");
    }
  }

  function stop() {
    if (timer.current) clearInterval(timer.current);
    const mr = rec.current;
    if (!mr || mr.state === "inactive") { releaseMic(); return; }
    setState("saving");
    mr.stop(); // onstop releases the mic + uploads the take
  }

  async function upload(blob: Blob, durationMs: number) {
    if (!projectId || blob.size === 0) { onClose(); return; }
    try {
      const ext = (blob.type.includes("ogg") ? "ogg" : "webm");
      const name = `Voiceover ${new Date().toLocaleTimeString()}.${ext}`;
      const measured = Math.max(500, Math.round(durationMs));
      const res = await fetch(`/api/projects/${projectId}/assets?name=${encodeURIComponent(name)}&kind=audio&durationMs=${measured}`, { method: "POST", headers: { "Content-Type": blob.type || "audio/webm" }, body: blob });
      const a = await res.json();
      if (!a.id) throw new Error(a.error || "upload failed");
      const asset = { ...a, kind: "audio" as const, durationMs: Math.max(500, Math.round(durationMs)) }; // trust the measured length
      addAsset(asset);
      addVideo(asset); // routes to a voice track (created if none) at the playhead
      onClose();
    } catch (e) {
      setErr(e instanceof Error ? e.message : "Couldn't save the recording.");
      setState("idle");
    }
  }

  const secs = (elapsed / 1000).toFixed(1);
  return (
    <div className="flex items-center gap-3 border-b border-border bg-surface-raised px-3 py-2 text-[13px]">
      <Mic size={15} className={state === "recording" ? "text-destructive" : "text-primary"} />
      {state === "idle" && <><span className="text-fg">Record a voiceover from your mic</span>
        <button onClick={start} className="rounded-md bg-primary px-2.5 py-1 text-[12px] font-semibold text-white hover:brightness-105">Start</button></>}
      {state === "recording" && <>
        <span className="flex items-center gap-1.5 text-fg"><span className="h-2 w-2 animate-pulse rounded-full bg-destructive" /> Recording <span className="num">{secs}s</span></span>
        <button onClick={stop} className="flex items-center gap-1 rounded-md bg-destructive px-2.5 py-1 text-[12px] font-semibold text-white hover:brightness-110"><Square size={11} /> Stop & place</button></>}
      {state === "saving" && <span className="text-fg-subtle">Saving…</span>}
      {err && <span className="text-[12px] text-destructive">{err}</span>}
      <div className="flex-1" />
      <button onClick={onClose} className="rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg">{state === "recording" ? "Cancel" : "Close"}</button>
    </div>
  );
}

function Ruler({ pxps, durationMs, px, onScrub, laneRef }: { pxps: number; durationMs: number; px: (ms: number) => number; onScrub: (e: React.PointerEvent) => void; laneRef: React.Ref<HTMLDivElement> }) {
  const interval = tickInterval(pxps);
  const count = Math.ceil(durationMs / 1000 / interval) + 1;
  return (
    <div className="sticky top-0 z-10 flex h-6 select-none bg-surface">
      <div className="shrink-0 border-b border-r border-border bg-surface" style={{ width: LABEL_W }} />
      <div ref={laneRef} className="relative flex-1 cursor-ew-resize border-b border-border" onPointerDown={onScrub}>
        {Array.from({ length: count }, (_, i) => {
          const t = i * interval;
          return (
            <div key={i} className="absolute top-0 bottom-0 border-l border-white/15" style={{ left: px(t * 1000) }}>
              <span className="num absolute left-1 top-0.5 text-[9px] text-fg-subtle">{fmtTime(t * 1000).replace(/\.\d+$/, "")}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

// A real, controllable track lane: click to target it for new clips; toggle visibility/mute/lock,
// adjust audio volume, reorder (which restacks the picture), rename, or delete.
function TrackLane({ track, active, contentW, canUp, canDown, editing, onEdit, onLaneDown, children }: {
  track: Track; active: boolean; contentW: number; canUp: boolean; canDown: boolean;
  editing: boolean; onEdit: (v: boolean) => void; onLaneDown?: (e: React.PointerEvent) => void; children?: React.ReactNode;
}) {
  const moveTrack = useStore((s) => s.moveTrack);
  const removeTrack = useStore((s) => s.removeTrack);
  const renameTrack = useStore((s) => s.renameTrack);
  const toggleTrackFlag = useStore((s) => s.toggleTrackFlag);
  const setTrackVolume = useStore((s) => s.setTrackVolume);
  const setActiveTrack = useStore((s) => s.setActiveTrack);
  const color = TRACK_COLOR(track);
  const isAudio = track.kind === "audio";
  return (
    <div className={"group/lane flex border-b border-border/60 bg-surface " + (active ? "ring-1 ring-inset ring-primary/40" : "")}>
      <div className={"relative flex h-10 shrink-0 items-center gap-1 border-r px-2 " + (active ? "border-primary/40" : "border-border")} style={{ width: LABEL_W }}
        onClick={() => setActiveTrack(track.id)} title="Click to target this track for new clips">
        {active && <span className="absolute left-0 top-0 bottom-0 w-0.5" style={{ background: color }} />}
        <span className="num shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold text-white" style={{ background: color }}>{track.kind === "audio" ? "A" : "V"}</span>
        {editing ? (
          <input autoFocus defaultValue={track.name} onBlur={(e) => { renameTrack(track.id, e.target.value || track.name); onEdit(false); }}
            onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") onEdit(false); }}
            className="min-w-0 flex-1 rounded border border-primary bg-surface px-1 text-[11px] outline-none" />
        ) : (
          <span className="min-w-0 flex-1 truncate text-[11px] text-fg-muted" onDoubleClick={(e) => { e.stopPropagation(); onEdit(true); }} title={track.name + " — double-click to rename"}>{track.name}</span>
        )}
        {/* always-visible state toggle: hide (video) / mute (audio) */}
        {isAudio ? (
          <button title={track.muted ? "Unmute" : "Mute"} onClick={(e) => { e.stopPropagation(); toggleTrackFlag(track.id, "muted"); }} className="shrink-0 text-fg-subtle hover:text-fg">{track.muted ? <VolumeX size={13} className="text-destructive" /> : <Volume2 size={13} />}</button>
        ) : (
          <button title={track.hidden ? "Show" : "Hide"} onClick={(e) => { e.stopPropagation(); toggleTrackFlag(track.id, "hidden"); }} className="shrink-0 text-fg-subtle hover:text-fg">{track.hidden ? <EyeOff size={13} className="text-destructive" /> : <Eye size={13} />}</button>
        )}
        {isAudio && <input type="range" min={0} max={1} step={0.05} value={track.volume ?? 1} onClick={(e) => e.stopPropagation()} onChange={(e) => setTrackVolume(track.id, +e.target.value)} title="Volume" className="h-1 w-9 shrink-0 cursor-pointer accent-[var(--color-tl-audio)]" />}
        {/* hover cluster: lock, reorder, delete (always shown on touch — there's no hover) */}
        <div className="ml-auto flex shrink-0 items-center opacity-0 transition group-hover/lane:opacity-100 pointer-coarse:opacity-100">
          <button title={track.locked ? "Unlock" : "Lock"} onClick={(e) => { e.stopPropagation(); toggleTrackFlag(track.id, "locked"); }} className="grid h-5 w-4 place-items-center text-fg-subtle hover:text-fg">{track.locked ? <Lock size={11} className="text-primary" /> : <Unlock size={11} />}</button>
          <button title="Move up" disabled={!canUp} onClick={(e) => { e.stopPropagation(); moveTrack(track.id, -1); }} className="grid h-5 w-3.5 place-items-center text-fg-subtle hover:text-fg disabled:opacity-20"><ChevronUp size={12} /></button>
          <button title="Move down" disabled={!canDown} onClick={(e) => { e.stopPropagation(); moveTrack(track.id, 1); }} className="grid h-5 w-3.5 place-items-center text-fg-subtle hover:text-fg disabled:opacity-20"><ChevronDown size={12} /></button>
          <button title="Delete track" onClick={(e) => { e.stopPropagation(); removeTrack(track.id); }} className="grid h-5 w-4 place-items-center text-fg-subtle hover:text-destructive"><Trash2 size={11} /></button>
        </div>
      </div>
      <div data-track-id={track.id} data-track-kind={track.kind} className={"relative h-10 flex-1 " + (track.hidden ? "opacity-40" : "")} style={{ minWidth: contentW }} onPointerDown={onLaneDown}>
        {children}
      </div>
    </div>
  );
}

// Static lane (used for the transcript dialogue row).
function Lane({ label, name, color, contentW, children, onPointerDown }: { label: string; name: string; color: string; contentW: number; children?: React.ReactNode; onPointerDown?: (e: React.PointerEvent) => void }) {
  return (
    <div className="flex border-b border-border/60 bg-surface">
      <div className="flex h-10 shrink-0 items-center gap-1.5 border-r border-border px-2" style={{ width: LABEL_W }}>
        <span className="num rounded px-1.5 py-0.5 text-[10px] font-semibold text-white" style={{ background: color }}>{label}</span>
        <span className="truncate text-[11px] text-fg-muted">{name}</span>
        <Volume2 size={11} className="ml-auto text-fg-subtle" />
      </div>
      <div className="relative h-10 flex-1" style={{ minWidth: contentW }} onPointerDown={onPointerDown}>
        {children}
      </div>
    </div>
  );
}

interface ClipLike { id: string; label: string; startMs: number; durationMs: number; sourceInMs?: number; srcDurationMs?: number; transition?: ClipTransition; fadeInMs?: number; fadeOutMs?: number; group?: string }
interface MultiMove { resolve: (id: string) => string[]; startsOf: (ids: string[]) => Record<string, number>; apply: (starts: { id: string; startMs: number }[]) => void; select: (ids: string[]) => void }

function TransitionBadge({ value, onChange }: { value?: ClipTransition; onChange: (t: ClipTransition | null) => void }) {
  const [open, setOpen] = useState(false);
  const [pos, setPos] = useState({ left: 0, top: 0 });
  const btnRef = useRef<HTMLButtonElement>(null);
  const dur = value?.durationMs ?? DEFAULT_TRANSITION_MS;
  const cur = value ? TRANSITIONS.find((t) => t.id === value.type) : null;
  useEffect(() => {
    if (!open) return;
    const onDown = (e: MouseEvent) => { if (!(e.target as HTMLElement)?.closest?.("[data-trans-menu]")) setOpen(false); };
    const onScroll = () => setOpen(false);
    window.addEventListener("mousedown", onDown);
    window.addEventListener("scroll", onScroll, true);
    return () => { window.removeEventListener("mousedown", onDown); window.removeEventListener("scroll", onScroll, true); };
  }, [open]);
  const toggle = (e: React.MouseEvent) => {
    e.stopPropagation();
    const r = btnRef.current!.getBoundingClientRect();
    const menuH = 250, menuW = 236;
    const top = r.bottom + menuH > window.innerHeight ? r.top - menuH - 4 : r.bottom + 4;
    setPos({ left: Math.min(Math.max(8, r.left), window.innerWidth - menuW - 8), top: Math.max(8, top) });
    setOpen((o) => !o);
  };
  return (
    <>
      <button ref={btnRef} onPointerDown={(e) => e.stopPropagation()} onClick={toggle}
        title={value ? `Transition: ${cur?.label ?? value.type} · ${(dur / 1000).toFixed(1)}s — click to change` : "Add an in-transition"}
        className={"absolute left-2 top-0.5 z-20 grid h-[15px] min-w-[15px] place-items-center rounded px-0.5 text-[10px] font-bold leading-none " + (value ? "bg-surface-raised text-fg shadow" : "bg-black/45 text-white opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto hover:bg-black/75")}>
        {value ? (cur?.glyph ?? "⇥") : <ArrowRightLeft size={9} />}
      </button>
      {open && createPortal(
        <div data-trans-menu className="fixed z-[200] w-[236px] rounded-lg border border-border bg-surface p-2 shadow-2xl" style={{ left: pos.left, top: pos.top }} onClick={(e) => e.stopPropagation()}>
          <div className="mb-1.5 flex items-center justify-between px-1">
            <span className="text-[11px] font-semibold text-fg">Transition in</span>
            {value && <button onClick={() => { onChange(null); setOpen(false); }} className="text-[10px] text-fg-subtle hover:text-destructive">Remove</button>}
          </div>
          <div className="grid grid-cols-3 gap-1">
            {TRANSITIONS.map((t) => (
              <button key={t.id} title={t.blurb} onClick={() => { onChange({ type: t.id, durationMs: dur }); setOpen(false); }}
                className={"flex flex-col items-center gap-0.5 rounded px-1 py-1.5 " + (value?.type === t.id ? "bg-primary text-white" : "text-fg-muted hover:bg-white/5 hover:text-fg")}>
                <span className="text-[14px] leading-none">{t.glyph}</span>
                <span className="truncate text-[9px]">{t.label}</span>
              </button>
            ))}
          </div>
          <div className="mt-2 flex items-center gap-1 px-1">
            <span className="mr-auto text-[10px] text-fg-subtle">Length</span>
            {[300, 500, 800, 1200].map((d) => (
              <button key={d} onClick={() => onChange({ type: value?.type || "fade", durationMs: d })}
                className={"rounded px-1.5 py-0.5 text-[10px] " + (dur === d ? "bg-fg text-surface" : "text-fg-muted hover:bg-white/5")}>{(d / 1000).toFixed(1)}s</button>
            ))}
          </div>
        </div>,
        document.body,
      )}
    </>
  );
}

// Generic timeline clip — drag to move, trim both edges, split, duplicate, delete. For source media
// (video/audio), left-trim advances sourceInMs and right-trim is clamped to the source length.
function ClipBlock({ item, color, hasSource, locked, audio, pxps, px, snapMs, pushHistory, onUpdate, onRemove, onSelect, onHover, onTransition, onSplit, onDuplicate, selected, trackId, laneKind, onMoveTrack, scrollRef, sfx, groupColor, multiMove }: {
  item: ClipLike; color: string; hasSource?: boolean; locked?: boolean; audio?: boolean; pxps: number; px: (ms: number) => number;
  snapMs: (ms: number, selfId?: string, dur?: number, exclude?: Set<string>) => number;
  pushHistory: () => void; onUpdate: (id: string, p: Record<string, number>) => void; onRemove: (id: string) => void;
  onSelect?: (id: string, mods: { meta: boolean; shift: boolean }) => void; onHover?: (item: ClipLike, el: HTMLElement | null) => void;
  onTransition?: (id: string, t: ClipTransition | null) => void; onSplit?: () => void; onDuplicate?: () => void; selected?: boolean;
  trackId?: string; laneKind?: "video" | "audio"; onMoveTrack?: (id: string, trackId: string) => void; scrollRef?: React.RefObject<HTMLDivElement | null>; sfx?: string;
  groupColor?: string; multiMove?: MultiMove;
}) {
  // the lane this clip currently lives on — drag updates it live as the pointer crosses into a same-kind lane
  const curTrack = useRef(trackId);
  curTrack.current = trackId;

  // Destructive actions (split/duplicate/delete) are tucked behind a deliberate menu — opened only by a
  // clean click on the ⋯ pill or a right-click on the clip — so they can NEVER fire from an accidental
  // press while you're dragging, trimming, or selecting. (Old design fired them on pointer-DOWN.)
  const [menuOpen, setMenuOpen] = useState(false);
  const [menuPos, setMenuPos] = useState({ left: 0, top: 0 });
  const moreRef = useRef<HTMLButtonElement>(null);
  useEffect(() => {
    if (!menuOpen) return;
    const onDown = (ev: MouseEvent) => { if (!(ev.target as HTMLElement).closest("[data-clip-actions]")) setMenuOpen(false); };
    const onScroll = () => setMenuOpen(false);
    const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") setMenuOpen(false); };
    window.addEventListener("mousedown", onDown);
    window.addEventListener("scroll", onScroll, true);
    window.addEventListener("keydown", onKey);
    return () => { window.removeEventListener("mousedown", onDown); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("keydown", onKey); };
  }, [menuOpen]);
  const openMenuAt = (left: number, top: number) => {
    const w = 176, h = 150;
    setMenuPos({ left: Math.min(Math.max(8, left), window.innerWidth - w - 8), top: Math.min(Math.max(8, top), window.innerHeight - h - 8) });
    setMenuOpen(true);
  };

  const drag = (mode: "move" | "left" | "right") => (e: React.PointerEvent) => {
    if (e.button !== 0) return; // ignore right/middle click → lets onContextMenu open the actions menu
    e.stopPropagation();
    e.preventDefault();
    const mods = { meta: e.metaKey || e.ctrlKey, shift: e.shiftKey };
    if (locked) { // locked clips can still be selected, just not moved/trimmed
      const onUpLocked = () => { window.removeEventListener("pointerup", onUpLocked); window.removeEventListener("pointercancel", onUpLocked); onSelect?.(item.id, mods); };
      window.addEventListener("pointerup", onUpLocked);
      window.addEventListener("pointercancel", onUpLocked);
      return;
    }
    let startX = e.clientX; // mutable: edge auto-scroll shifts it so the screen-delta math stays correct
    const o = { start: item.startMs, dur: item.durationMs, srcIn: item.sourceInMs ?? 0, srcDur: item.srcDurationMs ?? Infinity };
    const maxDur = hasSource ? o.srcDur - o.srcIn : Infinity;
    // a GROUP / multi-select move: drag the whole set together (resolved once at gesture start)
    const set = mode === "move" && multiMove ? multiMove.resolve(item.id) : [item.id];
    const multi = set.length > 1;
    const origins = multi ? multiMove!.startsOf(set) : null;
    const minOrigin = origins ? Math.min(...Object.values(origins)) : 0;
    const exclude = multi ? new Set(set) : undefined;
    let selectedSet = false;
    let moved = false;
    let lastX = e.clientX, lastY = e.clientY;
    const applyMove = (clientX: number, clientY: number) => {
      if (!moved) { if (Math.abs(clientX - startX) < 2) return; moved = true; pushHistory(); }
      const dms = ((clientX - startX) / pxps) * 1000;
      if (mode === "move") {
        if (multi) {
          if (!selectedSet) { multiMove!.select(set); selectedSet = true; } // reflect the moving set in the highlight
          const snapped = snapMs(o.start + dms, item.id, o.dur, exclude); // snap the dragged clip; exclude the rest of the set
          let delta = snapped - o.start;
          delta = Math.max(delta, -minOrigin); // never push any member below 0
          multiMove!.apply(set.map((id) => ({ id, startMs: (origins![id] ?? 0) + delta })));
        } else {
          onUpdate(item.id, { startMs: Math.round(Math.max(0, snapMs(o.start + dms, item.id, o.dur))) });
          // vertical drag → hop the clip to whatever same-kind lane is under the cursor (V1 ↔ V2, A1 ↔ A2)
          if (onMoveTrack && laneKind) {
            const lane = (document.elementFromPoint(clientX, clientY) as HTMLElement | null)?.closest<HTMLElement>("[data-track-id]");
            const destId = lane?.dataset.trackId;
            if (destId && lane?.dataset.trackKind === laneKind && destId !== curTrack.current) {
              curTrack.current = destId;
              onMoveTrack(item.id, destId);
            }
          }
        }
      } else if (mode === "left") {
        let ns = snapMs(o.start + dms, item.id);
        const minStart = hasSource ? o.start - o.srcIn : 0;
        ns = Math.min(Math.max(minStart, ns), o.start + o.dur - 100);
        const s = Math.round(ns);
        const delta = s - o.start;
        const patch: Record<string, number> = { startMs: s, durationMs: o.dur - delta };
        if (hasSource) patch.sourceInMs = o.srcIn + delta;
        onUpdate(item.id, patch);
      } else {
        let nd = snapMs(o.start + Math.max(100, o.dur + dms), item.id) - o.start;
        nd = Math.min(Math.max(100, nd), maxDur);
        onUpdate(item.id, { durationMs: Math.round(nd) });
      }
    };
    const onMove = (ev: PointerEvent) => { lastX = ev.clientX; lastY = ev.clientY; applyMove(ev.clientX, ev.clientY); };
    // while a drag's pointer hovers near the viewport edge, keep scrolling so it follows the cursor
    let raf = 0;
    const tick = () => {
      const sc = scrollRef?.current;
      if (sc && moved) { const d = edgeScrollStep(sc, lastX); if (d) { startX -= d; applyMove(lastX, lastY); } }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    const onUp = () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onUp);
      if (mode === "move" && !moved) onSelect?.(item.id, mods);
    };
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onUp);
  };
  return (
    <div data-clip-id={item.id} className={"group absolute top-1 flex h-8 items-center overflow-hidden rounded border text-[10px] font-medium text-white " + (selected ? "ring-2 ring-white ring-offset-1 ring-offset-black/20 " : "") + (menuOpen ? "ring-2 ring-primary " : "") + (locked ? "opacity-70" : "")}
      title={locked ? `${item.label} — track locked` : `${item.label}${groupColor ? " — grouped: drag to move the whole group" : " — drag to move (up/down to change lane), edges to trim"} · right-click for actions`}
      onPointerDown={drag("move")}
      onContextMenu={locked ? undefined : (e) => { e.preventDefault(); e.stopPropagation(); openMenuAt(e.clientX, e.clientY); }}
      onMouseEnter={(e) => onHover?.(item, e.currentTarget)}
      onMouseLeave={() => onHover?.(item, null)}
      style={{ left: px(item.startMs), width: Math.max(10, px(item.durationMs) - 1), background: `color-mix(in oklch, ${color} 72%, black)`, borderColor: groupColor || color, cursor: locked ? "default" : "grab", touchAction: locked ? undefined : "none", ...(groupColor ? { boxShadow: `inset 0 0 0 1.5px ${groupColor}` } : {}) }}>
      {/* group marker: a coloured left bar + link glyph so grouped clips read as linked across tracks */}
      {groupColor && <div className="pointer-events-none absolute inset-y-0 left-0 z-[1] flex w-3 items-center justify-center" style={{ background: groupColor }}><Link2 size={8} className="text-black/70" /></div>}
      {!locked && <div onPointerDown={drag("left")} style={{ touchAction: "none" }} className="absolute left-0 top-0 z-0 h-full w-1.5 cursor-ew-resize hover:bg-white/40" />}
      {/* fade ramps (audio): show the fade-in / fade-out like a pro NLE */}
      {audio && item.fadeInMs ? <div className="pointer-events-none absolute inset-y-0 left-0" style={{ width: Math.min(px(item.fadeInMs), px(item.durationMs) * 0.5), background: "linear-gradient(90deg, rgba(0,0,0,0.5), transparent)" }} /> : null}
      {audio && item.fadeOutMs ? <div className="pointer-events-none absolute inset-y-0 right-0" style={{ width: Math.min(px(item.fadeOutMs), px(item.durationMs) * 0.5), background: "linear-gradient(270deg, rgba(0,0,0,0.5), transparent)" }} /> : null}
      {onTransition && !locked && <TransitionBadge value={item.transition} onChange={(t) => onTransition(item.id, t)} />}
      <span className="pointer-events-none flex items-center gap-1 truncate px-2" style={{ paddingLeft: item.transition ? 22 : groupColor ? 16 : undefined }}>{audio && <AudioLines size={11} className="shrink-0 opacity-80" />}<span className="truncate">{item.label}</span>{sfx && <span title={`Sound: ${sfx === "none" ? "muted" : sfx}`} className="ml-0.5 inline-flex shrink-0 items-center gap-0.5 rounded bg-black/45 px-1 text-[8.5px] font-normal text-white/85">{sfx === "none" ? "🔇" : `🔊 ${sfx}`}</span>}</span>
      {/* one calm, non-destructive affordance: the ⋯ pill opens the actions menu (so does right-click). */}
      {!locked && (
        <button ref={moreRef} title="Clip actions — split, duplicate, delete (or right-click)"
          onPointerDown={(e) => e.stopPropagation()}
          onClick={(e) => { e.stopPropagation(); const r = moreRef.current!.getBoundingClientRect(); openMenuAt(r.right - 176, r.bottom + 4); }}
          className={"absolute right-0.5 top-0.5 z-10 grid h-4 w-5 place-items-center rounded bg-black/45 text-white transition hover:bg-black/75 " + (menuOpen ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto")}>
          <MoreHorizontal size={11} />
        </button>
      )}
      {!locked && <div onPointerDown={drag("right")} style={{ touchAction: "none" }} className="absolute right-0 top-0 z-0 h-full w-1.5 cursor-ew-resize hover:bg-white/40" />}
      {menuOpen && createPortal(
        <div data-clip-actions className="fixed z-[200] w-44 rounded-lg border border-border bg-surface p-1 shadow-2xl" style={{ left: menuPos.left, top: menuPos.top }} onClick={(e) => e.stopPropagation()}>
          <div className="truncate px-2 py-1 text-[10px] font-medium text-fg-subtle">{item.label}</div>
          {onSplit && <button onClick={() => { setMenuOpen(false); onSplit(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] text-fg-muted hover:bg-white/5 hover:text-fg"><Scissors size={13} /> Split at playhead</button>}
          {onDuplicate && <button onClick={() => { setMenuOpen(false); onDuplicate(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] text-fg-muted hover:bg-white/5 hover:text-fg"><Copy size={13} /> Duplicate</button>}
          <div className="my-1 h-px bg-border" />
          <button onClick={() => { setMenuOpen(false); onRemove(item.id); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] text-destructive hover:bg-destructive/15"><Trash2 size={13} /> Delete clip</button>
        </div>,
        document.body,
      )}
    </div>
  );
}
