// NotesPage — v07zz369 (full rewrite)
// The browsable home for EVERY note ever made on the production. Was a
// project-notes-only stream; now it unions all three note sources via
// GET /api/notes/all:
//   • notes left in a shot / asset modal          (source "modal")
//   • comments left on a PDF review               (source "pdf")
//   • comments left on an edit in Review          (source "edit")
//   • general production notes                    (source "general")
// Each row shows the IMAGE the note refers to, WHAT it belongs to, WHO wrote
// it, and WHERE it came from. Filter by kind (shots / characters / locations /
// …), author, source and status, or search. Click any row to open a modal with
// the image big and the note beside it.
function NotesPage() {
  const fetcher = window.authFetch || fetch;
  const [notes, setNotes] = React.useState(null);          // null = loading
  const [status, setStatus] = React.useState("all");        // all | open | resolved
  const [kind, setKind] = React.useState("all");
  const [source, setSource] = React.useState("all");
  const [author, setAuthor] = React.useState("all");
  const [repliesMode, setRepliesMode] = React.useState(false);   // v919 — ↩ Replies to me
  // v966 — Hugo: "i need something in the notes page so i can see only MY comments and
  // their respective reply right under it." v919 was the mirror image of this (their
  // replies, with my original quoted); this is MY notes, each with the answers nested.
  const [mineMode, setMineMode] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const [active, setActive] = React.useState(null);         // note open in the detail modal
  const [draft, setDraft] = React.useState("");
  const [draftRange, setDraftRange] = React.useState("");
  const [saving, setSaving] = React.useState(false);
  const [busy, setBusy] = React.useState({});
  const [focus, setFocus] = React.useState(() => {
    const id = window.__pendingNoteId || null;
    window.__pendingNoteId = null;
    return id ? Number(id) : null;
  });
  const focusDoneRef = React.useRef(false);

  const role = window.__effectiveRole || (window.__currentUser && window.__currentUser.role) || null;
  const canResolve = role === "admin" || role === "producer";

  // ── load ────────────────────────────────────────────────────────────────
  const loadSeqRef = React.useRef(0);
  const load = React.useCallback(() => {
    const seq = ++loadSeqRef.current;
    fetcher("/api/notes/all")
      .then(r => r.ok ? r.json() : null)
      .then(d => { if (seq === loadSeqRef.current) setNotes((d && Array.isArray(d.notes)) ? d.notes : []); })
      .catch(() => { if (seq === loadSeqRef.current) setNotes(n => n || []); });
  }, []);
  React.useEffect(() => { load(); }, [load]);
  React.useEffect(() => {
    const onSse = (e) => {
      const t = e.detail && e.detail.type;
      if (t === "note_added" || t === "note_resolved" || t === "note_reply" || t === "review_changed") load();
    };
    window.addEventListener("paradise-sse", onSse);
    const onFocusNote = (e) => {
      const id = e.detail && e.detail.id;
      if (id != null) { window.__pendingNoteId = null; focusDoneRef.current = false; setFocus(Number(id)); }
    };
    window.addEventListener("paradise-focus-note", onFocusNote);
    return () => { window.removeEventListener("paradise-sse", onSse); window.removeEventListener("paradise-focus-note", onFocusNote); };
  }, [load]);

  // ── time helpers (match Today's Notes panel UTC parse) ────────────────────
  const parseTime = (t) => {
    if (!t) return null;
    const d = new Date(String(t).replace(" ", "T") + (String(t).includes("T") ? "" : "Z"));
    return Number.isFinite(d.getTime()) ? d : null;
  };
  const relTime = (t) => {
    const d = parseTime(t); if (!d) return "";
    const s = (Date.now() - d.getTime()) / 1000;
    if (s < 60) return "just now";
    if (s < 3600) return Math.floor(s / 60) + "m ago";
    if (s < 86400) return Math.floor(s / 3600) + "h ago";
    if (s < 86400 * 7) return Math.floor(s / 86400) + "d ago";
    return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
  };
  const absTime = (t) => { const d = parseTime(t); return d ? d.toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; };
  const initials = (name) => {
    const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
    if (!parts.length) return "?";
    return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : "")).toUpperCase();
  };
  const tu = (src, w) => (src ? (window.thumbUrl ? window.thumbUrl(src, w) : src) : null);

  // ── kind + source vocab ──────────────────────────────────────────────────
  // 17 Sep 2026 — the tints are the --note-kind-* / --note-src-* tokens (styles/tokens.css), so a
  // skin can re-point them to a colour that reads on its own chips (skin-glass.css does).
  const KIND_META = {
    shot:      { label: "Shot",      tint: "var(--note-kind-shot)" },
    character: { label: "Character", tint: "var(--note-kind-character)" },
    animal:    { label: "Animal",    tint: "var(--note-kind-animal)" },
    location:  { label: "Location",  tint: "var(--note-kind-location)" },
    prop:      { label: "Prop",      tint: "var(--note-kind-prop)" },
    ref:       { label: "Reference", tint: "var(--note-kind-ref)" },
    edit:      { label: "Edit",      tint: "var(--note-kind-edit)" },
    project:   { label: "General",   tint: "var(--note-kind-project)" },
  };
  const SOURCE_META = {
    modal:   { label: "Shot / Asset", tint: "var(--note-src-modal)" },
    pdf:     { label: "Review PDF",   tint: "var(--note-src-pdf)" },
    edit:    { label: "Edit review",  tint: "var(--note-src-edit)" },
    general: { label: "General",      tint: "var(--note-src-general)" },
  };

  // ── filtering ─────────────────────────────────────────────────────────────
  const all = notes || [];
  const openCount = all.filter(n => !n.resolved).length;
  const authors = React.useMemo(() => Array.from(new Set(all.map(n => n.author).filter(Boolean))).sort(), [notes]);
  const kindCounts = React.useMemo(() => { const m = {}; for (const n of all) m[n.kind] = (m[n.kind] || 0) + 1; return m; }, [notes]);
  const q = query.trim().toLowerCase();
  // v919 — "Replies to me": every note by SOMEONE ELSE answering one of MINE, paired
  // with the original. Edit comments have real threading (parent_comment_id); shot /
  // asset notes have no parent link — there a reply is a later note by someone else on
  // the same entity, paired with my latest earlier note on that entity.
  const myId = (window.__currentUser && window.__currentUser.id) || null;
  const replyOrigByUid = React.useMemo(() => {
    const m = new Map();
    if (!myId) return m;
    const byUid = new Map(all.map(n => [n.uid, n]));
    for (const n of all) {
      if (!String(n.uid).startsWith("vc:") || !n.parent_comment_id || n.author_id === myId) continue;
      const orig = byUid.get("vc:" + n.parent_comment_id);
      if (orig && orig.author_id === myId) m.set(n.uid, orig);
    }
    const groups = new Map();
    for (const n of all) {
      if (!String(n.uid).startsWith("note:")) continue;
      const k = n.entity_type + "|" + n.entity_id;
      if (!groups.has(k)) groups.set(k, []);
      groups.get(k).push(n);
    }
    for (const g of groups.values()) {
      g.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
      for (let i = 0; i < g.length; i++) {
        const n = g[i];
        if (n.author_id === myId) continue;
        for (let j = i - 1; j >= 0; j--) {
          if (g[j].author_id === myId) { m.set(n.uid, g[j]); break; }
        }
      }
    }
    return m;
  }, [notes, myId]);
  // v966 — flip replyOrigByUid (reply -> my original) into (my original -> replies),
  // so a row can render its own thread underneath. Oldest first, the way a thread reads.
  const repliesByMine = React.useMemo(() => {
    const byUid = new Map(all.map(n => [n.uid, n]));
    const m = new Map();
    for (const [replyUid, orig] of replyOrigByUid) {
      const r = byUid.get(replyUid);
      if (!r || !orig) continue;
      const arr = m.get(orig.uid) || [];
      arr.push(r);
      m.set(orig.uid, arr);
    }
    for (const arr of m.values()) arr.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
    return m;
  }, [replyOrigByUid, notes]);
  const shown = all.filter(n => {
    if (repliesMode && !replyOrigByUid.has(n.uid)) return false;   // v919
    if (mineMode && !(myId && n.author_id === myId)) return false;   // v966 — only MY notes
    if (status === "open" && n.resolved) return false;
    if (status === "resolved" && !n.resolved) return false;
    if (kind !== "all" && n.kind !== kind) return false;
    if (source !== "all" && n.source !== source) return false;
    if (author !== "all" && n.author !== author) return false;
    // v944 — Hugo: "i need to be able to search for shots and see all the notes
    // related to that shot". entity_id catches shot notes (labels already carry the
    // id, but ids on assets/refs don't); shot_id catches edit-review comments whose
    // burned-in SH#### was detected on that shot — those carry the EDIT's title as
    // their label, so a shot search used to miss them entirely.
    if (q && !(String(n.body || "").toLowerCase().includes(q)
      || String(n.author || "").toLowerCase().includes(q)
      || String(n.entity_label || "").toLowerCase().includes(q)
      || String(n.entity_id || "").toLowerCase().includes(q)
      || String(n.shot_id || "").toLowerCase().includes(q))) return false;
    return true;
  });

  // kind chips: All + every kind present (in a stable order), each with a count
  const KIND_ORDER = ["shot", "character", "animal", "location", "prop", "ref", "edit", "project"];
  const kindChips = [["all", "All", all.length]].concat(
    KIND_ORDER.filter(k => kindCounts[k]).map(k => [k, KIND_META[k].label + (k === "shot" || k === "edit" ? "s" : (k === "character" || k === "location" || k === "animal" || k === "prop" || k === "ref") ? "s" : ""), kindCounts[k]])
  );

  // ── focus a note arriving from a notification / activity row → open it ─────
  React.useEffect(() => {
    if (focus == null || focusDoneRef.current || !notes) return;
    const hit = all.find(n => n.uid === "note:" + focus);
    if (!hit) return;            // not loaded / filtered
    focusDoneRef.current = true;
    setActive(hit);
  }, [focus, notes]);

  // ── actions ────────────────────────────────────────────────────────────────
  const submit = (e) => {
    e.preventDefault();
    if (!draft.trim() || saving) return;
    setSaving(true);
    fetcher("/api/notes", { method: "POST", body: JSON.stringify({ entity_type: "project", entity_id: "overall", body: draft.trim(), version_label: draftRange.trim() || null }) })
      .then(r => { if (r.ok) { setDraft(""); setDraftRange(""); load(); } })
      .catch(() => {})
      .finally(() => setSaving(false));
  };
  const toggleResolve = (n) => {
    if (!canResolve || busy[n.uid]) return;
    const next = !n.resolved;
    const url = String(n.uid).startsWith("vc:") ? `/api/video-comments/${n.id}/resolve` : `/api/notes/${n.id}/resolve`;
    setBusy(b => ({ ...b, [n.uid]: true }));
    setNotes(list => (list || []).map(x => x.uid === n.uid ? { ...x, resolved: next ? 1 : 0 } : x));
    setActive(a => (a && a.uid === n.uid) ? { ...a, resolved: next ? 1 : 0 } : a);
    fetcher(url, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ resolved: next }) })
      .then(() => load()).catch(() => load())
      .finally(() => setBusy(b => { const c = { ...b }; delete c[n.uid]; return c; }));
  };

  // ── render ─────────────────────────────────────────────────────────────────
  return (
    <section className="view-page view-page--scroll notes-page">
      <div className="np-head">
        <div className="np-eyebrow">NOTES</div>
        <div className="np-title">Production notes</div>
        <div className="np-subtitle">
          {notes === null ? "Loading…" : `${all.length} note${all.length === 1 ? "" : "s"} across the whole production · ${openCount} open`}
        </div>
      </div>

      <form className="np-compose" onSubmit={submit}>
        <textarea className="np-compose-body" placeholder="Write a production note… (visible to the whole team)" value={draft} onChange={(e) => setDraft(e.target.value)} rows={2}/>
        <div className="np-compose-row">
          {/* 15 Sep 2026 — "Episode 00, Twain" is Paradise Found's example; other projects get their container word */}
          <input className="np-compose-range" placeholder={(!window.__isDefaultProject || window.__isDefaultProject()) ? "Scope (optional — e.g. SH0110 — SH0240, Episode 00, Twain)" : `Scope (optional — e.g. SH0110 — SH0240, a ${(window.__containerWord ? window.__containerWord(false) : "container").toLowerCase()}, an asset)`} value={draftRange} onChange={(e) => setDraftRange(e.target.value)}/>
          <button type="submit" className="np-compose-save" disabled={!draft.trim() || saving}>{saving ? "Saving…" : "Add note"}</button>
        </div>
      </form>

      {/* filter bar */}
      <div className="np-filters">
        <div className="np-seg" role="tablist" aria-label="Filter by status">
          {[["all", "All"], ["open", "Open"], ["resolved", "Resolved"]].map(([id, label]) => (
            <button key={id} type="button" className={"np-seg-btn" + (status === id ? " is-active" : "")} onClick={() => setStatus(id)}>{label}</button>
          ))}
        </div>
        {/* v919 — Hugo: "click on a filter and it shows me every response to my own
            notes, with the original note". The chip filters to replies; each row then
            carries the original underneath (see .np-orig in the table). */}
        <button type="button" className={"np-replies-btn" + (repliesMode ? " is-active" : "")}
          onClick={() => { setRepliesMode(v => !v); setMineMode(false); }}
          title="Only notes and comments other people wrote in answer to YOUR notes — each shown with your original note">
          ↩ Replies to me{replyOrigByUid.size ? ` (${replyOrigByUid.size})` : ""}</button>
        {/* v966 — the other direction: MY notes, each with its replies nested under it. */}
        <button type="button" className={"np-replies-btn" + (mineMode ? " is-active" : "")}
          onClick={() => { setMineMode(v => !v); setRepliesMode(false); }}
          disabled={!myId}
          title="Only the notes YOU wrote — any replies to them appear underneath">
          👤 My notes</button>
        <select className="np-author" value={author} onChange={(e) => setAuthor(e.target.value)} aria-label="Filter by author">
          <option value="all">Everyone</option>
          {authors.map(a => <option key={a} value={a}>{a}</option>)}
        </select>
        <select className="np-author" value={source} onChange={(e) => setSource(e.target.value)} aria-label="Filter by source">
          <option value="all">Any source</option>
          <option value="modal">Shot / Asset</option>
          <option value="pdf">Review PDF</option>
          <option value="edit">Edit review</option>
          <option value="general">General</option>
        </select>
        <input className="np-search" type="search" placeholder="Search notes, people, shots (e.g. SH0710)…" value={query} onChange={(e) => setQuery(e.target.value)}/>
      </div>

      {/* kind chips */}
      <div className="np-kindbar">
        {kindChips.map(([id, label, count]) => (
          <button key={id} type="button" className={"np-kind-chip" + (kind === id ? " is-active" : "")} onClick={() => setKind(id)}
            style={id !== "all" && KIND_META[id] ? { "--chip-tint": KIND_META[id].tint } : null}>
            {label}<span className="np-kind-count">{count}</span>
          </button>
        ))}
      </div>

      {/* table */}
      <div className="np-table-wrap">
        {notes === null ? (
          <div className="np-empty">Loading notes…</div>
        ) : shown.length === 0 ? (
          <div className="np-empty">{all.length === 0 ? "No notes yet." : "No notes match the current filters."}</div>
        ) : (
          <table className="np-table">
            <thead>
              <tr>
                <th className="np-col-img"></th>
                <th className="np-col-note">Note</th>
                <th className="np-col-stage">Status</th>
                <th className="np-col-belong">Belongs to</th>
                <th className="np-col-src">From</th>
                <th className="np-col-time">When</th>
                <th className="np-col-status"></th>
              </tr>
            </thead>
            <tbody>
              {shown.map(n => {
                const km = KIND_META[n.kind] || { label: n.kind, tint: "var(--note-kind-other)" };
                const sm = SOURCE_META[n.source] || { label: n.source, tint: "var(--note-src-other)" };
                // v07zz603 — an edit-review note opens the REAL thing: the Edit Review
                // modal seeked to the note's exact timecode (Hugo: "the Edit Review
                // modal opening with the edit at the right time stamp"), via the same
                // __pendingReviewId/__pendingReviewSeek hand-off the Todo page uses.
                // Every other kind keeps the image-and-note detail modal.
                const openRow = () => {
                  if (n.kind === "edit" && n.review_id) {
                    try {
                      window.__pendingReviewId = n.review_id;
                      if (n.timecode != null) window.__pendingReviewSeek = { id: n.review_id, t: Number(n.timecode) || 0 };
                    } catch (_) {}
                    ((window.__nav && window.__nav.setView) || window.__navigate)("review");
                    return;
                  }
                  setActive(n);
                };
                // v963 — the KIND chip opens the thing the note is about (Hugo: "the prop
                // needs to open the prop modal"). Assets go through the always-mounted
                // GlobalAssetModal, the same hand-off the Todo page uses; shots open the
                // shot modal. Returns false when the kind has nothing to open.
                const ASSET_KINDS = { character: "character", animal: "animal", location: "location", prop: "prop", ref: "ref" };
                const openEntity = () => {
                  try {
                    if (n.kind === "shot" && n.entity_id) { window.__nav && window.__nav.openShot && window.__nav.openShot(n.entity_id); return true; }
                    const ak = ASSET_KINDS[n.kind];
                    if (ak && n.entity_id && typeof window.__openAssetItem === "function") { window.__openAssetItem(ak, n.entity_id); return true; }
                  } catch (_) {}
                  return false;
                };
                const canOpenEntity = !!(ASSET_KINDS[n.kind] || (n.kind === "shot" && n.entity_id));
                // v963 — the SOURCE chip opens the deck a PDF-review note came from, in a
                // NEW TAB (Hugo: "opens the pdf presentation in another tab"). A deck that
                // was exported opens as the real PDF; a live deck_json one opens the app's
                // Presentations page instead, since there is no file to show.
                const openDeck = () => {
                  try {
                    if (n.pdf_url) { window.open(n.pdf_url, "_blank", "noopener"); return; }
                    const base = location.origin + location.pathname;
                    window.open(base + "#/presentations", "_blank", "noopener");
                  } catch (_) {}
                };
                const canOpenDeck = n.source === "pdf";
                const tcLabel = (n.timecode != null && isFinite(Number(n.timecode)))
                  ? (() => { const s = Math.max(0, Math.floor(Number(n.timecode))); return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); })()
                  : null;
                return (
                  <tr key={n.uid} className={"np-row" + (n.resolved ? " is-resolved" : "")} onClick={openRow} role="button" tabIndex={0}
                    onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openRow(); } }}>
                    <td className="np-col-img">
                      <div className="np-thumb" style={{ "--kind-tint": km.tint }}>
                        {n.image ? <img src={tu(n.image, 160)} alt="" loading="lazy"/> : <span className="np-thumb-ph">{km.label[0]}</span>}
                        {n.video && <span className="np-thumb-play" aria-hidden="true">▶</span>}
                      </div>
                    </td>
                    <td className="np-col-note">
                      <div className="np-row-body">{n.body}</div>
                      {/* v919 — with the Replies filter on, every row carries the note of
                          YOURS it answers, so the pair reads together at a glance. */}
                      {repliesMode && replyOrigByUid.has(n.uid) && (() => {
                        const o = replyOrigByUid.get(n.uid);
                        return (
                          <div className="np-orig" title={o.body}>
                            <span className="np-orig-tag">↩ your note · {relTime(o.created_at)}</span>
                            <span className="np-orig-body">“{o.body}”</span>
                          </div>
                        );
                      })()}
                      {/* v966 — in My-notes mode each row carries the answers it got. */}
                      {mineMode && (repliesByMine.get(n.uid) || []).length > 0 && (
                        <div className="np-replies">
                          {(repliesByMine.get(n.uid) || []).map(r => (
                            <div key={r.uid} className="np-reply" title={r.body}>
                              <span className="np-reply-who">↳ {r.author}{r.author_role ? " · " + r.author_role : ""} · {relTime(r.created_at)}</span>
                              <span className="np-reply-body">{r.body}</span>
                            </div>
                          ))}
                        </div>
                      )}
                      <div className="np-row-byline"><span className="np-row-author">{n.author}</span>{n.author_role && <span className="np-row-role">{n.author_role}</span>}</div>
                    </td>
                    {/* v972 — the shot list's own quick-action buttons, in their own
                        column: First Pass / Frame WIP / Frame Hero / Video WIP / Video
                        Hero / 4K Upscale, plus Retake. Same component the shot rows
                        render, so the two can never drift apart. */}
                    <td className="np-col-stage">
                      {(() => {
                        const sid = n.kind === "shot" ? n.entity_id : (n.kind === "edit" ? n.shot_id : null);
                        if (!sid || !window.ShotQuickActions) return null;
                        const shot = ((window.__appData && window.__appData.shots) || []).find(x => x.id === sid);
                        if (!shot) return null;
                        // v973 — the status PILL is always on show; the buttons appear on row
                        // hover exactly like the shot list. Their space is reserved either way
                        // so hovering never moves anything (invariant #20).
                        return (
                          <span className="np-stage-wrap">
                            {window.StatusPill && <window.StatusPill shot={shot}/>}
                            <window.ShotQuickActions shot={shot} withRetake={true}/>
                          </span>
                        );
                      })()}
                    </td>
                    <td className="np-col-belong">
                      {canOpenEntity ? (
                        <button type="button" className="np-kindtag np-kindtag--link" style={{ color: km.tint, borderColor: km.tint }}
                          title={`Open ${n.entity_label || km.label}`}
                          onClick={(e) => { e.stopPropagation(); openEntity(); }}>{km.label}</button>
                      ) : (
                        <span className="np-kindtag" style={{ color: km.tint, borderColor: km.tint }}>{km.label}</span>
                      )}
                      {canOpenEntity ? (
                        <button type="button" className="np-belong-name np-belong-name--link" title={`Open ${n.entity_label}`}
                          onClick={(e) => { e.stopPropagation(); openEntity(); }}>{n.entity_label}</button>
                      ) : (
                        <span className="np-belong-name" title={n.entity_label}>{n.entity_label}</span>
                      )}
                      {/* v972 — the status control moved OUT to its own column (below), as
                          the shot list's icon bar rather than a dropdown. */}
                      {/* v07zz603 — timecode + Gemini-detected shot chip on edit-review notes.
                          The chip opens the SHOT modal; stopPropagation keeps the row's
                          open-review click out of it. */}
                      {n.kind === "edit" && (tcLabel || n.shot_id) && (
                        <span className="np-belong-extra">
                          {tcLabel && <span className="np-tc">@ {tcLabel}</span>}
                          {n.shot_id && (
                            <button type="button" className="np-shot-chip" title={"Open " + n.shot_id}
                              onClick={(e) => { e.stopPropagation(); try { window.__nav && window.__nav.openShot && window.__nav.openShot(n.shot_id); } catch (_) {} }}>
                              {n.shot_id}
                            </button>
                          )}
                        </span>
                      )}
                    </td>
                    <td className="np-col-src">{canOpenDeck ? (
                      <button type="button" className="np-srctag np-srctag--link" style={{ color: sm.tint, borderColor: sm.tint }}
                        title={n.pdf_url ? `Open ${n.presentation_title || "the deck"} in a new tab` : "Open the Presentations page in a new tab"}
                        onClick={(e) => { e.stopPropagation(); openDeck(); }}>{sm.label}</button>
                    ) : (
                      <span className="np-srctag" style={{ color: sm.tint, borderColor: sm.tint }}>{sm.label}</span>
                    )}</td>
                    <td className="np-col-time" title={absTime(n.created_at)}>{relTime(n.created_at)}</td>
                    <td className="np-col-status">
                      <span className={"np-pill " + (n.resolved ? "np-pill--resolved" : "np-pill--open")}>{n.resolved ? "Resolved" : "Open"}</span>
                      {/* v07zz584 — Hugo: "I need to be able to Resolve a note with one click."
                          One-click ✓ right on the row (was only inside the detail modal).
                          Resolved rows get a subtle ↩ re-open. stopPropagation so the row
                          click doesn't also open the modal. */}
                      {canResolve && (
                        <button type="button"
                          className={"np-quickresolve" + (n.resolved ? " np-quickresolve--reopen" : "")}
                          disabled={!!busy[n.uid]}
                          title={n.resolved ? "Re-open this note" : "Resolve this note"}
                          aria-label={n.resolved ? "Re-open note" : "Resolve note"}
                          onClick={(e) => { e.stopPropagation(); toggleResolve(n); }}>
                          {n.resolved
                            ? <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg>
                            : <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="m4 12 5 5 11-12"/></svg>}
                        </button>
                      )}
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        )}
      </div>

      {active && (() => {
        const idx = shown.findIndex(n => n.uid === active.uid);
        return (
          <NoteDetailModal
            note={active}
            km={KIND_META[active.kind] || { label: active.kind, tint: "var(--note-kind-other)" }}
            sm={SOURCE_META[active.source] || { label: active.source, tint: "var(--note-src-other)" }}
            absTime={absTime}
            canResolve={canResolve}
            busy={!!busy[active.uid]}
            onResolve={() => toggleResolve(active)}
            onClose={() => setActive(null)}
            onPrev={idx > 0 ? () => setActive(shown[idx - 1]) : null}
            onNext={(idx >= 0 && idx < shown.length - 1) ? () => setActive(shown[idx + 1]) : null}
            pos={idx >= 0 ? idx + 1 : null}
            total={shown.length}
            onReplied={load}
          />
        );
      })()}
    </section>
  );
}

// The image + note detail modal: big image on the left, the exact note + meta
// on the right. Portaled into #modal-root so the backdrop covers the viewport.
function NoteDetailModal({ note, km, sm, absTime, canResolve, busy, onResolve, onClose, onPrev, onNext, pos, total, onReplied }) {
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === "Escape") onClose();
      else if (e.key === "ArrowLeft" && onPrev) onPrev();
      else if (e.key === "ArrowRight" && onNext) onNext();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose, onPrev, onNext]);
  const big = note.image ? (window.thumbUrl ? window.thumbUrl(note.image, 1600) : note.image) : null;
  // v901 — reply right from the note. Hugo: "i need to be able to reply to the notes
  // right here and then". A note:* reply posts a NEW note on the same entity + version
  // (it lands in the same thread the shot/asset modal shows); a vc:* reply posts a
  // threaded video_comments reply at the same timecode.
  const [reply, setReply] = React.useState("");
  const [replyBusy, setReplyBusy] = React.useState(false);
  const [replySent, setReplySent] = React.useState(false);
  React.useEffect(() => { setReply(""); setReplyBusy(false); setReplySent(false); }, [note.uid]);
  const canReply = String(note.uid || "").startsWith("note:") || (String(note.uid || "").startsWith("vc:") && note.review_id);
  const sendReply = () => {
    const body = reply.trim();
    if (!body || replyBusy) return;
    setReplyBusy(true);
    const f = window.authFetch || fetch;
    const req = String(note.uid).startsWith("vc:")
      ? f("/api/video-comments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_review_id: note.review_id, body, timecode_seconds: note.timecode != null ? note.timecode : 0, parent_comment_id: note.id }) })
      : f("/api/notes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entity_type: note.entity_type, entity_id: note.entity_id, body, version_label: note.version_label || null }) });
    req.then(r => { if (!r.ok) return r.json().catch(() => ({})).then(j => Promise.reject(new Error(j.error || "HTTP " + r.status))); return r.json().catch(() => ({})); })
      .then(() => { setReply(""); setReplySent(true); if (onReplied) onReplied(); setTimeout(() => setReplySent(false), 3000); })
      .catch(() => { setReplySent(false); })
      .finally(() => setReplyBusy(false));
  };
  const initials = (name) => { const p = String(name || "").trim().split(/\s+/).filter(Boolean); return p.length ? (p[0][0] + (p.length > 1 ? p[p.length - 1][0] : "")).toUpperCase() : "?"; };
  const portalRoot = document.getElementById("modal-root") || document.body;
  return ReactDOM.createPortal((
    <div className="ndm-backdrop" onClick={onClose}>
      {onPrev && (
        <button type="button" className="ndm-nav ndm-nav--prev" onClick={(e) => { e.stopPropagation(); onPrev(); }} aria-label="Previous note" title="Previous note (←)">
          <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m15 6-6 6 6 6"/></svg>
        </button>
      )}
      {onNext && (
        <button type="button" className="ndm-nav ndm-nav--next" onClick={(e) => { e.stopPropagation(); onNext(); }} aria-label="Next note" title="Next note (→)">
          <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m9 6 6 6-6 6"/></svg>
        </button>
      )}
      <div className={"ndm-panel" + (big ? " has-img" : " no-img")} onClick={(e) => e.stopPropagation()} role="dialog" aria-label="Note detail">
        <button className="ndm-close" type="button" onClick={onClose} aria-label="Close">
          <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        </button>
        <div className="ndm-media" style={{ "--kind-tint": km.tint }}>
          {/* v901 — a note left on a VIDEO shows that clip, playable, not the shot's frame. */}
          {note.video
            ? <video src={note.video} controls preload="metadata" playsInline poster={note.image || undefined}/>
            : big ? <img src={big} alt={note.entity_label}/> : <div className="ndm-media-ph">No image attached</div>}
        </div>
        <div className="ndm-side">
          <div className="ndm-kindrow">
            <span className="np-kindtag" style={{ color: km.tint, borderColor: km.tint }}>{km.label}</span>
            <span className="np-srctag" style={{ color: sm.tint, borderColor: sm.tint }}>{sm.label}</span>
          </div>
          <div className="ndm-entity" title={note.entity_label}>{note.entity_label}</div>
          {note.version_label && <div className="ndm-version">{note.version_label}</div>}
          {/* v722 — Hugo: "when I click on the note, it doesnt seem to send me to that exact
              version the note was sent on… i have to dig through to find the one." There was
              no way to reach the shot from here AT ALL, so this is the direct jump: it opens
              the shot modal already showing the version the note was written on. */}
          {note.entity_type === "shot" && note.entity_id && (() => {
            const tgt = (window.__noteVersionTarget && window.__noteVersionTarget(note.version_label)) || null;
            const shown = tgt ? String(tgt.version).replace(/^video-/, "") : null;
            return (
              <button type="button" className="ndm-openshot"
                title={shown ? `Open ${note.entity_id} showing ${shown}` : `Open ${note.entity_id}`}
                onClick={() => {
                  try { window.__nav && window.__nav.openShot && window.__nav.openShot(note.entity_id, tgt && tgt.version, tgt && tgt.family); } catch (_) {}
                  onClose();
                }}>
                Open {note.entity_id}{shown ? " at " + shown : ""} ↗
              </button>
            );
          })()}
          {note.timecode != null && <div className="ndm-version">@ {Math.floor(note.timecode / 60)}:{String(Math.floor(note.timecode % 60)).padStart(2, "0")}</div>}
          <div className="ndm-byline">
            <span className="ndm-avatar">{initials(note.author)}</span>
            <span className="ndm-author">{note.author}</span>
            {note.author_role && <span className="np-row-role">{note.author_role}</span>}
            <span className="ndm-time">{absTime(note.created_at)}</span>
          </div>
          <div className="ndm-body">{note.body}</div>
          {canReply && (
            <div className="ndm-reply">
              <textarea className="ndm-reply-box" rows={2} placeholder={`Reply to ${note.author || "this note"}…`}
                value={reply} onChange={(e) => setReply(e.target.value)}
                onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); sendReply(); } }}/>
              <button type="button" className="ndm-reply-send" disabled={replyBusy || !reply.trim()} onClick={sendReply}
                title="Posts into the same thread — the shot / asset modal shows it too (Ctrl+Enter)">
                {replyBusy ? "Sending…" : replySent ? "Sent ✓" : "Reply"}
              </button>
            </div>
          )}
          <div className="ndm-foot">
            <span className={"np-pill " + (note.resolved ? "np-pill--resolved" : "np-pill--open")}>{note.resolved ? "Resolved" : "Open"}</span>
            {note.resolved && note.resolved_by_name && <span className="ndm-resolvedby">by {note.resolved_by_name}</span>}
            {canResolve && (
              <button type="button" className="ndm-resolve" disabled={busy} onClick={onResolve}>
                {busy ? "…" : note.resolved ? "Re-open" : "Mark resolved"}
              </button>
            )}
          </div>
          {pos != null && total > 1 && <div className="ndm-counter">{pos} of {total} · use ← → to move</div>}
        </div>
      </div>
    </div>
  ), portalRoot);
}
window.NotesPage = NotesPage;
