/* global React */

// v07x — Helpers for RecentActivityCard. Converts a change_log row
// into a short human sentence and a relative timestamp.
// v07zz54 — Map internal stage keys to the display labels Hugo uses
// across the app. Keeps the activity feed consistent with the shot
// pills (FRAME HERO, VIDEO HERO, etc.) instead of leaking the legacy
// CONCEPT-APPROVED / VIDEO-APPROVED internal names.
const STAGE_DISPLAY = {
  "CONCEPT-APPROVED": "FRAME HERO",
  "VIDEO-APPROVED":   "VIDEO HERO",
  "CONCEPT-WIP":      "FRAME WIP",
  "VIDEO-WIP":        "VIDEO WIP",
  "UPSCALED":         "4K UPSCALE",
  "FIRST-PASS":       "FIRST PASS",
  "PROMPT":           "PROMPT",
  "PENDING":          "PENDING",
  "ARCHIVE":          "ARCHIVED",
};
function displayStage(s) { return STAGE_DISPLAY[String(s || "").toUpperCase()] || s; }

function humanizeChangeLogAction(l) {
  // v07z — Display name for entity_id. "overall" is the catch-all
  // project-level bucket; render as "General" so users don't see
  // the internal key. Shot ids stay as-is (SH0010 reads fine).
  // v07zz — nice entity name (shared with notifications + To-Do): assets read
  // "Character · Ponce de Leon" instead of the bare slug; shots keep "SH0010".
  const rawEnt = l.entity_id || "—";
  const ent = (typeof window !== "undefined" && window.prettyEntityLabel && l.entity_id)
    ? window.prettyEntityLabel(l.entity_type, l.entity_id)
    : (rawEnt === "overall" ? "General" : rawEnt);
  const action = String(l.action || "");
  const nv = l.new_value || {};

  if (action === "status_change" && nv.status) {
    return `${ent} → ${displayStage(nv.status)}`;
  }
  if (action === "note_added") {
    const body = (nv.note_body || nv.body || "").toString().trim();
    if (body) {
      const short = body.length > 80 ? body.slice(0, 77) + "…" : body;
      return `${ent}: ${short}`;
    }
    return `Note added on ${ent}`;
  }
  if (action === "note_resolved") return `Note resolved on ${ent}`;
  if (action === "asset_version_created") return `New version added to ${ent}`;
  if (action === "hero_changed" || action === "hero_change") return `Hero updated for ${ent}`;
  if (action === "shot_created") return `Shot ${ent} created`;
  if (action === "shot_updated") return `${ent} updated`;
  if (action === "archive") return `${ent} archived`;
  if (action === "unarchive") return `${ent} unarchived`;
  if (action === "image_started") return `${ent}: image gen started`;
  if (action === "image_completed") return `${ent}: image gen done`;
  if (action === "video_started") return `${ent}: video gen started`;
  if (action === "video_completed") return `${ent}: video gen done`;
  if (action === "shot_updated") return `${ent} updated`;
  // Fallback: humanize underscore-case actions.
  const friendly = action.replace(/_/g, " ");
  return `${ent} · ${friendly}`;
}
function fmtRelativeTime(ts) {
  if (!ts) return "";
  const t = new Date(String(ts).includes("T") ? ts : ts.replace(" ", "T") + "Z").getTime();
  if (!Number.isFinite(t)) return "";
  const delta = Math.max(0, Date.now() - t);
  const s = Math.floor(delta / 1000);
  if (s < 60) return `${s}s ago`;
  const m = Math.floor(s / 60);
  if (m < 60) return `${m}m ago`;
  const h = Math.floor(m / 60);
  if (h < 24) return `${h}h ago`;
  const d = Math.floor(h / 24);
  return `${d}d ago`;
}

const FIcon = {
  check: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="m4 12 5 5 11-12"/></svg>,
  eye: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>,
  film: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 14h4M17 9h4M17 14h4"/></svg>,
  star: <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="m12 3 2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L12 16.8 6.7 19.2l1.1-5.9L3.5 9.2l5.9-.8z"/></svg>,
  plus: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>,
  cloud: <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M7 18a4 4 0 0 1-1-7.9A6 6 0 0 1 18 10a4 4 0 0 1 0 8H7z"/><path d="M12 12v6M9 15l3-3 3 3"/></svg>,
  // v07y — note icon: pencil over a note card. Used for note-related
  // change_log rows and the bell icon's note notifications.
  note: <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h13l3 3v13H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg>,
};

// v06p — Standalone slide-over panel for the full activity log.
// Mounted ONCE at the App.jsx root so it can open from any page
// without needing the Overview's RecentActivityCard to be in the
// DOM. The Recent Activity card on the Overview page (and the
// notification bell's "View all activity" link) both call
// window.__openActivitySlide() to pop this. Hugo: previously
// clicking the bell's link from any non-Overview page forced a
// view switch first which felt wrong.
function GlobalActivitySlide() {
  const [open, setOpen] = React.useState(false);
  React.useEffect(() => {
    window.__openActivitySlide  = () => setOpen(true);
    window.__closeActivitySlide = () => setOpen(false);
    return () => {
      if (window.__openActivitySlide  === setOpen) delete window.__openActivitySlide;
      if (window.__closeActivitySlide === setOpen) delete window.__closeActivitySlide;
    };
  }, []);
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    window.addEventListener("keydown", onKey);
    document.body.style.overflow = "hidden";
    return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = ""; };
  }, [open]);
  if (!open) return null;
  // Portal so the backdrop covers the entire viewport, not just
  // the .view-page central panel.
  const portalRoot = document.getElementById("modal-root") || document.body;
  return ReactDOM.createPortal((
    <div className="activity-slide-backdrop" onClick={() => setOpen(false)}>
      <aside className="activity-slide-panel" onClick={(e) => e.stopPropagation()} role="dialog" aria-label="Full activity log">
        <button className="activity-slide-close" type="button" onClick={() => setOpen(false)} aria-label="Close">
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M5 5l14 14M19 5L5 19"/></svg>
        </button>
        {window.ActivityView ? <window.ActivityView/> : <div style={{padding: 24}}>Loading…</div>}
      </aside>
    </div>
  ), portalRoot);
}

function RecentActivityCard() {
  // v07z6 — Real change_log data rendered with the EXACT OLD visual
  // (icon + bold action + italic shot title + meta). Looks up the
  // shot's frame_title from window.__appData so rows match the
  // hardcoded mock items Hugo had originally.
  // v07zn — In-memory cache only. Items carry React SVG elements
  // for the icon, which can't survive JSON.stringify → parse —
  // they'd come back as {} and crash React. Module-level Map keeps
  // them as proper React elements across re-mounts within the same
  // tab session. A hard reload re-fetches; that's acceptable.
  const fetcher = window.authFetch || fetch;
  const [items, setItems] = React.useState(() => {
    // 15 Sep 2026 — a cache is only good for the project it was made for (Hugo: Paradise
    // Found's activity showed on Trøpé).
    if (Array.isArray(window.__recentActivityCache) && window.__recentActivityCacheProject === window.__activeProjectId) return window.__recentActivityCache;
    return [];
  });

  const load = React.useCallback(() => {
    // v07perf — exclude_user=me so the Overview's Recent Activity card
    // only shows OTHER teammates' actions. The "View all" slide
    // (ActivityView) doesn't pass this param, so it still includes
    // self for the daily-grouped recap.
    fetcher("/api/logs?limit=10&exclude_user=me")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (!d || !Array.isArray(d.logs)) return;
        // Take the first 4 unique events after filtering.
        d.logs = d.logs.slice(0, 4);
        // Shot frame_title lookup, populated by App.jsx into window.
        const shotsById = (() => {
          try {
            const shots = (window.__appData && (window.__appData.shots || window.__appData.tracker && window.__appData.tracker.shots)) || [];
            const m = new Map();
            for (const s of shots) m.set(s.id, s);
            return m;
          } catch (_) { return new Map(); }
        })();
        const mapped = d.logs.map(l => {
          const action = String(l.action || "");
          let icon = FIcon.eye;
          let color = "var(--amber)";
          if (action.includes("note")) { icon = FIcon.note; color = "var(--archive)"; }
          else if (action.includes("hero")) { icon = FIcon.star; color = "var(--leaf)"; }
          else if (action.includes("complete") || action.includes("approved")) { icon = FIcon.check; color = "var(--leaf)"; }
          else if (action.includes("generated") || action.includes("upload") || action.includes("create")) { icon = FIcon.film; color = "var(--teal)"; }
          const text = humanizeChangeLogAction(l);
          // Italic frame-title (the shot's nickname) shown after the
          // action, mirroring the OLD mock data layout. Only for
          // entity_type === "shot" where the lookup makes sense.
          let title = "";
          if (l.entity_type === "shot" && shotsById.has(l.entity_id)) {
            const s = shotsById.get(l.entity_id);
            title = s.frame_title || "";
          }
          // v07zz138 — carry the new_value payload so a row click can
          // deep-link to the actual file (references / uploads store
          // cloud_url / file_path here).
          let nv = l.new_value;
          if (typeof nv === "string") { try { nv = JSON.parse(nv); } catch (_) { nv = {}; } }
          return {
            icon, color, text, title,
            who: l.user_name || "—",
            time: fmtRelativeTime(l.timestamp),
            // v07zb — carry entity refs so row clicks can navigate.
            entity_type: l.entity_type,
            entity_id: l.entity_id,
            action,
            nv: nv || {},
          };
        });
        setItems(mapped);
        // v07zn — Persist so re-mount paints from cache (in-memory
        // only; sessionStorage would destroy the React SVG icons).
        window.__recentActivityCache = mapped;
        window.__recentActivityCacheProject = window.__activeProjectId;
      })
      .catch(() => {});
  }, [fetcher]);
  React.useEffect(() => {
    const h = () => { window.__recentActivityCache = null; setItems([]); load(); };
    window.addEventListener("filmtracker:project-changed", h);
    return () => window.removeEventListener("filmtracker:project-changed", h);
  }, [load]);

  React.useEffect(() => { load(); }, [load]);
  React.useEffect(() => {
    const fn = (e) => {
      try {
        const msg = (e && e.detail) || JSON.parse(e.data || "{}");
        if (!msg) return;
        if (msg.type === "note_added" || msg.type === "shot_status_change"
            || msg.type === "new_asset_version" || msg.type === "review_changed"
            || msg.type === "sync.applied") {
          load();
        }
      } catch (_) {}
    };
    window.addEventListener("paradise-sse", fn);
    return () => window.removeEventListener("paradise-sse", fn);
  }, [load]);

  const openSlide = () => {
    if (typeof window.__openActivitySlide === "function") window.__openActivitySlide();
  };
  // v07zb — Hugo: every row in Recent Activity should be a deep-link
  // to the thing it describes. Shot rows open the shot modal; asset
  // rows jump to the Assets view; review rows jump to Review. The
  // stopPropagation prevents the parent card's openSlide handler
  // from firing too — clicking a row goes to the entity, clicking
  // the empty card chrome (header / margins) still opens the slide.
  const navigateToEntity = (it, e) => {
    if (e) { e.stopPropagation(); }
    const t = String(it.entity_type || "");
    const id = it.entity_id;
    const nav = window.__nav || {};
    // v07zz140 — Navigate INSIDE the tracker to where the thing lives —
    // never pop the raw file in a browser tab. References live on the
    // Assets page; we deep-link the right tab there.
    const goAssets = (destTab) => {
      if (destTab) { try { window.__assetsInitialTab = destTab; } catch (_) {} }
      // v1091 — the Assets page's view id is "characters" ("assets" opened a blank page).
      if (nav.setView) nav.setView("characters");
      if (destTab) { try { window.dispatchEvent(new CustomEvent("paradise-assets-tab", { detail: { tab: destTab } })); } catch (_) {} }
    };
    // v722 — a note_added row's change_log new_value carries the version the note was
    // written on (it.nv.version_label); pass it so the modal opens on THAT version rather
    // than the shot's latest. Non-note rows have no version_label → openShot(id) as before.
    if (t === "shot" && id && nav.openShot) {
      const _t = (window.__noteVersionTarget && window.__noteVersionTarget(it.nv && it.nv.version_label)) || null;
      nav.openShot(id, _t && _t.version, _t && _t.family);
      return;
    }
    // External / general references → Assets → References tab.
    if (t === "reference") { goAssets("refs"); return; }
    // Asset-specific references / asset edits → Assets, on that category's tab.
    if (t === "asset" || t.startsWith("asset_")) {
      const cat = String(id || "").split("/")[0];
      // 15 Sep 2026 — the project's categories (the film-doc four for Paradise Found)
      goAssets((window.__projectCategories ? window.__projectCategories().map(c => c.id) : ["characters", "animals", "locations", "props"]).includes(cat) ? cat : "refs");
      return;
    }
    if (t === "video_review" || t === "review") { nav.setView && nav.setView("review"); return; }
    if (t === "document") { nav.setView && nav.setView("documents"); return; }
    if (t === "schedule_event" || t === "milestone") { nav.setView && nav.setView("schedule"); return; }
    if (t === "episode") { nav.setView && nav.setView("shots"); return; }
    // Anything else: open the full activity slide.
    openSlide();
  };
  return (
    <section
      className="rc-card glass-dark activity-card activity-card--clickable"
      role="button"
      tabIndex={0}
      onClick={openSlide}
      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSlide(); } }}
      title="Open full activity log"
    >
      <div className="rc-head">
        <div className="rc-title">RECENT ACTIVITY</div>
        <span className="activity-card-cta" aria-hidden="true">View all →</span>
      </div>
      <div className="activity-list">
        {/* v07zp — Render 4 ghost rows while the first /api/logs
            fetch is in flight so the card occupies the same height
            from the moment the page loads (no empty→stretch shift
            on hard refresh). Once items arrive, ghosts are
            replaced. */}
        {items.length === 0 && [0, 1, 2, 3].map(i => (
          <div className="activity-row activity-row--ghost" key={`ghost-${i}`} aria-hidden="true">
            <span className="activity-icon activity-icon--ghost"/>
            <div className="activity-body">
              <div className="activity-text activity-text--ghost"/>
              <div className="activity-meta activity-meta--ghost"/>
            </div>
          </div>
        ))}
        {items.map((it, i) => (
          <div
            className="activity-row activity-row--click"
            key={i}
            role="button"
            tabIndex={0}
            onClick={(e) => navigateToEntity(it, e)}
            onKeyDown={(e) => {
              if (e.key === "Enter" || e.key === " ") {
                e.preventDefault();
                navigateToEntity(it, e);
              }
            }}
            title={`Open ${it.entity_type || "item"} ${it.entity_id || ""}`.trim()}
          >
            <span className="activity-icon" style={{
              background: `color-mix(in oklab, ${it.color} 18%, transparent)`,
              color: it.color,
              borderColor: `color-mix(in oklab, ${it.color} 50%, transparent)`,
            }}>{it.icon}</span>
            <div className="activity-body">
              <div className="activity-text">
                {it.text}{it.title ? <> · <span className="activity-frame">{it.title}</span></> : null}
              </div>
              <div className="activity-meta">by {it.who} · {it.time}</div>
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}

// 16 Sep 2026 — the three placeholder tiles derive from the PRESET accents (card surface +
// accent / second accent / accent-strong). The third one used to read --ok, the semantic
// success green, so it stayed green in every palette that recoloured the other two. --accent
// and --accent-strong are var(--leaf) in the forest look and in every preset, so nothing moves
// here; the mixes still differ per tile so the three stay visibly distinct in every palette.
const THUMB_TINTS = [
  "linear-gradient(155deg, color-mix(in srgb, var(--card-bg-solid) 70%, var(--mix-dark)), color-mix(in srgb, var(--accent) 25%, var(--card-bg-solid)))",
  "linear-gradient(155deg, color-mix(in srgb, var(--card-bg-solid) 62%, var(--mix-dark)), color-mix(in srgb, var(--amber) 35%, var(--card-bg-solid)))",
  "linear-gradient(155deg, color-mix(in srgb, var(--card-bg-solid) 55%, var(--mix-dark)), color-mix(in srgb, var(--accent-strong) 45%, var(--card-bg-solid)))",
];

// v06a — Media Uploads card. Clicking the dropzone (or dragging files
// onto it) opens an upload modal where the user picks asset type
// (Edit / Frame / Reference) and the target shot for each file, then
// uploads everything to /api/upload. The server drops each file into
// WATCH_PATH at the correct shot-folder/filename per the folder watcher
// convention and fires an R2 upload in the background.
function MediaUploadsCard() {
  const fileInputRef = React.useRef(null);
  const [dragOver, setDragOver]         = React.useState(false);
  const [pendingFiles, setPendingFiles] = React.useState(null);
  const [recentUploads, setRecentUploads] = React.useState([]);

  const openPicker = () => fileInputRef.current && fileInputRef.current.click();
  const handleFiles = (files) => {
    if (!files || !files.length) return;
    setPendingFiles(Array.from(files));
  };

  // Drag/drop wiring. The dropzone is the visible target but in the
  // future we could promote this to the whole card surface.
  const onDragOver = (e)  => { e.preventDefault(); e.stopPropagation(); setDragOver(true);  };
  const onDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragOver(true);  };
  const onDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); setDragOver(false); };
  const onDrop = (e) => {
    e.preventDefault(); e.stopPropagation();
    setDragOver(false);
    if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files);
  };

  // When an upload finishes, prepend the new asset into the thumbnail
  // row (cap 3) so the user sees confirmation in-place. Also carries
  // the uploader's display name for the tooltip and (for Edit uploads)
  // the video_reviews row id so the thumb can deep-link into Review.
  const onUploadComplete = (result) => {
    if (!result || !result.asset_version) return;
    const av = result.asset_version;
    setRecentUploads(prev => {
      const next = [{
        assetId:    av.asset_id,
        kind:       av.kind,
        label:      av.version_label,
        targetKind: av.target_kind || "shot",
        description: av.description || null,
        uploadedBy: av.uploaded_by_name || null,
        reviewId:   result.review_id || null,    // populated only for edits
        url:        result.local_url,
      }, ...prev];
      return next.slice(0, 3);
    });
  };

  // v06e — clicking an Edit thumb deep-links into the Review page with
  // that specific edit pre-selected. The pending review id is parked on
  // window for ReviewPage to consume during its mount (cleared on read).
  const openInReview = (reviewId) => {
    if (!reviewId) return;
    window.__pendingReviewId = reviewId;
    if (window.__nav && window.__nav.setView) window.__nav.setView("review");
  };

  return (
    <section className="rc-card glass-dark media-card">
      <div className="rc-head">
        <div className="rc-title">MEDIA UPLOADS</div>
      </div>
      <div className="thumb-row">
        {Array.from({length: 3}).map((_, i) => {
          const item = recentUploads[i];
          if (item) {
            const isVideoKind = item.kind === "video" || item.kind === "edit" || item.kind === "upscale";
            // Build a multi-line title:
            //   <asset/shot id> · <kind> · <version>
            //   <description, if any>
            //   Uploaded by <name>
            //   Click to open in Review  ← only when reviewId is set
            const isClickable = !!item.reviewId;
            const tipLines = [
              `${item.assetId} · ${item.kind} · ${item.label}`,
              item.description ? item.description : null,
              item.uploadedBy ? `Uploaded by ${item.uploadedBy}` : null,
              isClickable ? "Click to open in Review" : null,
            ].filter(Boolean);
            const handleClick = isClickable ? () => openInReview(item.reviewId) : undefined;
            return (
              <div
                className={"media-thumb media-thumb--real" + (isClickable ? " media-thumb--clickable" : "")}
                key={`real-${item.assetId}-${item.kind}-${item.label}-${i}`}
                title={tipLines.join("\n")}
                onClick={handleClick}
                role={isClickable ? "button" : undefined}
                tabIndex={isClickable ? 0 : undefined}
                onKeyDown={isClickable ? (e) => {
                  if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleClick(); }
                } : undefined}
              >
                {isVideoKind
                  ? <div className="media-thumb-vid">▶</div>
                  : <img src={window.thumbUrl ? window.thumbUrl(item.url, 240) : item.url} alt={`${item.assetId} ${item.label}`} loading="lazy"/>}
              </div>
            );
          }
          return (
            <div className="media-thumb" key={`ph-${i}`} style={{
              background: THUMB_TINTS[i % THUMB_TINTS.length]
            }}/>
          );
        })}
      </div>
      <div
        className={"dropzone" + (dragOver ? " is-dragover" : "")}
        onClick={openPicker}
        onDragOver={onDragOver}
        onDragEnter={onDragEnter}
        onDragLeave={onDragLeave}
        onDrop={onDrop}
        role="button"
        tabIndex={0}
        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openPicker(); } }}
        title="Click to pick files, or drag and drop"
      >
        <div className="dz-icon">{FIcon.cloud}</div>
        <div className="dz-text">{dragOver ? "Drop to upload" : "Drag & drop, or click to upload"}</div>
        <div className="dz-sub">Edits (MP4/MOV) · Frames + References (JPG/PNG/WebP) · max 3 GB</div>
      </div>
      <input
        ref={fileInputRef}
        type="file"
        multiple
        accept=".mp4,.mov,.png,.jpg,.jpeg,.webp,.tiff,video/*,image/*"
        style={{display: "none"}}
        onChange={(e) => { handleFiles(e.target.files); e.target.value = ""; }}
      />
      {pendingFiles && (
        <UploadModal
          files={pendingFiles}
          onClose={() => setPendingFiles(null)}
          onComplete={onUploadComplete}
        />
      )}
    </section>
  );
}

// v06g — single file row inside the UploadModal. Right-hand field is
// type-aware:
//   Edit       → a read-only info pill saying "fromEditor / auto-versioned"
//                (no questions; the file just lands as the next edit version)
//   Frame      → shot picker
//   Reference  → free-form category tag (visual style / vibe-mood / etc.)
//                OR a category PLUS a specific asset slug (Mark Twain,
//                Calusa Village, etc.). Asset-bound refs land in the
//                asset's prompting folder; the rest land in /refs/<cat>/.
//                Includes inline "+ Create new asset" flow that POSTs
//                /api/assets/create and adds the result to the
//                modal's local catalog without a full reload.
const REFERENCE_CATEGORY_OPTIONS = [
  { key: "visual_style", label: "Visual style" },
  { key: "vibe_mood",    label: "Vibe / Mood" },
  { key: "character",    label: "Character" },
  { key: "animal",       label: "Animal" },
  { key: "location",     label: "Location" },
  { key: "prop",         label: "Prop" },
  // v07zz189 — drop into a named External / Historical reference FOLDER
  // (the same foldered libraries shown on the Assets → References / Historical
  // Refs tabs). Pick a folder name below; it's created if it doesn't exist.
  { key: "external",     label: "External reference folder" },
  { key: "historical",   label: "Historical reference folder" },
  { key: "other",        label: "Other" },
];

// Map singular ref-category → plural assets-catalog key.
const REF_CATEGORY_TO_ASSET_KEY = {
  character: "characters",
  animal:    "animals",
  location:  "locations",
  prop:      "props",
};
// 15 Sep 2026 — on a templated project the option VALUE is already the category id
// (characters / instruments …); the film-doc singulars above stay for Paradise Found.
function _refAssetKey(cat) {
  if (REF_CATEGORY_TO_ASSET_KEY[cat]) return REF_CATEGORY_TO_ASSET_KEY[cat];
  if (window.__isDefaultProject && !window.__isDefaultProject() && window.__projectCategories && window.__projectCategories().some(c => c.id === cat)) return cat;
  return undefined;
}

function UploadRow({ item, shots, assetCatalog, refFolders, onChange, onCreateAsset, disabled }) {
  const sizeKB = item.file.size / 1024;
  const sizeStr = sizeKB > 1024 ? `${(sizeKB/1024).toFixed(1)} MB` : `${Math.max(1, Math.round(sizeKB))} KB`;

  return (
    <div className={"upload-row" + (item.status === "done" ? " is-done" : "") + (item.status === "error" ? " is-error" : "")}>
      <div className="upload-row-top">
        <div className="upload-row-name" title={item.file.name}>{item.file.name}</div>
        <div className="upload-row-size">{sizeStr}</div>
      </div>

      <div className="upload-row-fields">
        <label className="upload-field">
          <span className="upload-field-label">Type</span>
          <select
            className="upload-field-select"
            value={item.kind}
            onChange={(e) => onChange({ kind: e.target.value })}
            disabled={disabled}
          >
            <option value="edit">Edit (Video)</option>
            <option value="frame">Frame</option>
            <option value="reference">Reference</option>
          </select>
        </label>

        {/* Right-column field swaps based on Type. */}
        {item.kind === "frame" && (
          <label className="upload-field upload-field--shot">
            <span className="upload-field-label">Shot</span>
            <select
              className="upload-field-select"
              value={item.shot_id || ""}
              onChange={(e) => onChange({ shot_id: e.target.value })}
              disabled={disabled}
            >
              {shots.length === 0 && <option value="">— no shots loaded —</option>}
              {shots.map(s => (
                <option key={s.id} value={s.id}>{s.id} · {s.frame_title || s.action || "—"}</option>
              ))}
            </select>
          </label>
        )}

        {item.kind === "edit" && (
          <div className="upload-field upload-field--info">
            <span className="upload-field-label">Destination</span>
            <div className="upload-info-pill" title="Lands in /fromEditor as edit_vNNN with the next free version number — no questions needed.">
              <span className="upload-info-icon" aria-hidden="true">↗</span>
              <span>fromEditor · auto-versioned</span>
            </div>
          </div>
        )}

        {item.kind === "reference" && (
          <label className="upload-field upload-field--ref">
            <span className="upload-field-label">Reference of</span>
            <select
              className="upload-field-select"
              value={item.asset_category}
              onChange={(e) => onChange({
                asset_category: e.target.value,
                // Reset asset selection + create form when the category
                // changes so we don't carry a Calusa-King slug into a
                // freshly-picked "Visual style".
                asset_slug: "",
                creating: false,
                newAssetName: "",
              })}
              disabled={disabled}
            >
              {/* 15 Sep 2026 — a templated project offers its own asset categories (plus the
                  style / mood / external / other buckets); Animal + the Historical folder are
                  Paradise Found's. The default project keeps the full list. */}
              {((!window.__isDefaultProject || window.__isDefaultProject())
                ? REFERENCE_CATEGORY_OPTIONS
                : [
                    ...REFERENCE_CATEGORY_OPTIONS.filter(c => ["visual_style", "vibe_mood"].includes(c.key)),
                    ...(window.__projectCategories ? window.__projectCategories() : []).map(c => ({ key: c.id, label: (window.__projectCategoryLabel ? window.__projectCategoryLabel(c.id, true) : c.label) })),
                    ...REFERENCE_CATEGORY_OPTIONS.filter(c => ["external", "other"].includes(c.key)),
                  ]).map(c => (
                <option key={c.key} value={c.key}>{c.label}</option>
              ))}
            </select>
          </label>
        )}
      </div>

      {/* Asset-bound reference picker. Only appears when the reference
          category is one of the four asset types (character / animal /
          location / prop). Lets the user pick an existing asset OR open
          an inline "+ Create new asset" form whose Create button hits
          POST /api/assets/create. */}
      {item.kind === "reference" && _refAssetKey(item.asset_category) && (
        <UploadRefAssetPicker
          item={item}
          assetCatalog={assetCatalog}
          onChange={onChange}
          onCreateAsset={onCreateAsset}
          disabled={disabled}
        />
      )}

      {/* v07zz189 — External / Historical reference FOLDER name. The image is
          dropped into references/<scope>-refs/<folder>/ (created if missing). */}
      {item.kind === "reference" && (item.asset_category === "external" || item.asset_category === "historical") && (
        <label className="upload-field upload-field--ref" style={{ marginTop: 8 }}>
          <span className="upload-field-label">Folder</span>
          {/* v07zz214 — pick an EXISTING folder from the list, or type a new
              name (datalist allows both). Fixes "I can add historical refs but
              I cant add it to existing folders." */}
          <input
            className="upload-field-select"
            type="text"
            list={`ref-folders-${item.asset_category}`}
            value={item.ref_folder || ""}
            placeholder={
              ((refFolders && refFolders[item.asset_category]) || []).length
                ? "Pick a folder or type a new name…"
                : (item.asset_category === "historical" ? "e.g. Columbus, 1492 Ships…" : "e.g. Costumes, Mood board…")
            }
            onChange={(e) => onChange({ ref_folder: e.target.value })}
            disabled={disabled}
          />
          <datalist id={`ref-folders-${item.asset_category}`}>
            {((refFolders && refFolders[item.asset_category]) || []).map(name => (
              <option key={name} value={name} />
            ))}
          </datalist>
        </label>
      )}

      {item.status === "uploading" && (
        <div className="upload-row-progress">
          <div className="upload-row-progress-bar" style={{width: `${item.progress}%`}}/>
          <span className="upload-row-progress-text">{item.progress}%</span>
        </div>
      )}
      {item.status === "done" && item.result && (
        <div className="upload-row-status upload-row-status--ok">
          {/* v07zz237 — folder uploads (External/Historical refs) hit
              /api/refs/folder-upload which returns { ok, scope, folder, saved }
              with NO asset_version. Reading .version_label unconditionally threw
              during render and crashed the whole app to the background image. */}
          {item.result.asset_version ? (
            <>✓ Uploaded as {item.result.asset_version.version_label}{item.result.asset_version.uploaded_by_name ? ` · by ${item.result.asset_version.uploaded_by_name}` : ""}</>
          ) : (
            <>✓ Uploaded{item.result.folder ? ` to ${item.result.folder}` : ""}{Array.isArray(item.result.saved) && item.result.saved.length > 1 ? ` · ${item.result.saved.length} files` : ""}</>
          )}
        </div>
      )}
      {item.status === "error" && (
        <div className="upload-row-status upload-row-status--err">✗ {item.error}</div>
      )}
    </div>
  );
}

// v06g — secondary row inside UploadRow when the reference category is
// an asset type. Shows an asset picker dropdown PLUS an optional
// descriptor (face / body / etc.) field used in the saved filename.
// "+ Create new" expands an inline mini-form whose Create button hits
// /api/assets/create.
function UploadRefAssetPicker({ item, assetCatalog, onChange, onCreateAsset, disabled }) {
  const catKey = _refAssetKey(item.asset_category);
  // v07zz172 — Hugo: the "Reference of → Character" picker was listing deleted /
  // archived / duplicate assets (Twain dupe, Pocahontas, John Smith, etc.). Apply
  // the SAME filter the Generate page uses: drop deleted/archived rows + dedupe by
  // name so only the real, live assets appear.
  const assets = React.useMemo(() => {
    const raw = (assetCatalog && Array.isArray(assetCatalog[catKey])) ? assetCatalog[catKey] : [];
    const seen = new Set();
    const out = [];
    for (const a of raw) {
      if (!a || a.deleted === true || a.archived === true) continue;
      const nameKey = (a.name || "").trim().toLowerCase();
      if (nameKey && seen.has(nameKey)) continue;
      if (nameKey) seen.add(nameKey);
      out.push(a);
    }
    return out;
  }, [assetCatalog, catKey]);

  const [creating, setCreating] = React.useState(false);
  const [draftName, setDraftName] = React.useState("");
  const [creatingErr, setCreatingErr] = React.useState(null);
  const [busy, setBusy] = React.useState(false);

  const onSelectChange = (e) => {
    const v = e.target.value;
    if (v === "__new__") {
      setCreating(true);
      setDraftName("");
      setCreatingErr(null);
    } else {
      onChange({ asset_slug: v });
    }
  };

  const handleCreate = () => {
    const name = draftName.trim();
    if (!name || busy) return;
    setBusy(true);
    setCreatingErr(null);
    onCreateAsset({ category: catKey, name })
      .then((createdAsset) => {
        setBusy(false);
        setCreating(false);
        setDraftName("");
        // Auto-select the new asset on the current upload row.
        onChange({ asset_slug: createdAsset.id });
      })
      .catch((err) => {
        setBusy(false);
        setCreatingErr(err.message || "Could not create asset.");
      });
  };

  return (
    <div className="upload-ref-asset-row">
      <label className="upload-field upload-field--ref-asset">
        <span className="upload-field-label">For</span>
        <select
          className="upload-field-select"
          value={item.asset_slug || ""}
          onChange={onSelectChange}
          disabled={disabled || creating}
        >
          <option value="">— general (no specific asset) —</option>
          {assets.map(a => (
            <option key={a.id} value={a.id}>{a.name}</option>
          ))}
          <option value="__new__">+ Create new {item.asset_category}…</option>
        </select>
      </label>

      {/* Optional filename descriptor — face / body / etc. Saved as
          part of the filename instead of a numeric version suffix. */}
      {!creating && item.asset_slug && (
        <label className="upload-field upload-field--ref-desc">
          <span className="upload-field-label">Descriptor <span className="upload-field-hint">(optional)</span></span>
          <input
            type="text"
            className="upload-field-input"
            placeholder="e.g. face, body, jungle"
            value={item.descriptor || ""}
            onChange={(e) => onChange({ descriptor: e.target.value })}
            disabled={disabled}
            maxLength={30}
          />
        </label>
      )}

      {creating && (
        <div className="upload-new-asset">
          <input
            type="text"
            className="upload-field-input upload-new-asset-name"
            placeholder={`New ${item.asset_category} name`}
            value={draftName}
            onChange={(e) => setDraftName(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") { e.preventDefault(); handleCreate(); }
              if (e.key === "Escape") { setCreating(false); setDraftName(""); setCreatingErr(null); }
            }}
            disabled={busy}
            autoFocus
          />
          <button
            type="button"
            className="upload-new-asset-create"
            onClick={handleCreate}
            disabled={!draftName.trim() || busy}
          >
            {busy ? "Creating…" : "Create"}
          </button>
          <button
            type="button"
            className="upload-new-asset-cancel"
            onClick={() => { setCreating(false); setDraftName(""); setCreatingErr(null); }}
            disabled={busy}
          >
            Cancel
          </button>
          {creatingErr && <div className="upload-new-asset-err">{creatingErr}</div>}
        </div>
      )}
    </div>
  );
}

function UploadModal({ files, onClose, onComplete, defaultKind, defaultCategory, defaultSlug }) {
  // v07zz279 — permission backstop. Every upload entry point (Media panel, the
  // asset Refs +, asset-modal uploads) routes through this modal, so gating it
  // here gives a clean "no permission" message instead of a silent 403 for any
  // trigger we haven't individually hidden yet. (The Media panel is hidden
  // outright; this catches the rest.)
  if (window.hasPerm && !window.hasPerm("upload_assets")) {
    return ReactDOM.createPortal(
      <div className="modal-backdrop" onClick={onClose}>
        <div className="modal-card glass" style={{ maxWidth: 420, padding: 24 }} onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
          <div className="auth-modal-eyebrow">UPLOAD</div>
          <div className="auth-modal-title" style={{ marginBottom: 8 }}>Upload not available</div>
          <div style={{ fontSize: "var(--fs-13)", opacity: 0.8, marginBottom: 18, lineHeight: 1.45 }}>You don’t have permission to upload assets. Ask an admin to enable “Upload assets” for your role.</div>
          <button type="button" className="auth-submit" onClick={onClose}>Close</button>
        </div>
      </div>,
      document.getElementById("modal-root") || document.body
    );
  }
  // Source data for the dropdowns lives on window.__appData (shots).
  // The asset catalog lives in component state because the user can
  // create new assets from inside this modal — we want those to appear
  // in the dropdown without round-tripping through App's data fetch.
  const appData = window.__appData || {};
  const shots = (appData.shots || []).filter(s => !s.is_archive);
  const [assetCatalog, setAssetCatalog] = React.useState(() => {
    const a = appData.assets || {};
    return {
      characters: Array.isArray(a.characters) ? a.characters : [],
      animals:    Array.isArray(a.animals)    ? a.animals    : [],
      locations:  Array.isArray(a.locations)  ? a.locations  : [],
      props:      Array.isArray(a.props)      ? a.props      : [],
    };
  });

  const getAuthToken = () => {
    try { return localStorage.getItem("filmtracker.token") || null; }
    catch (e) { return null; }
  };

  // v07zz214 — Hugo: "I can add historical refs but I cant add it to existing
  // folders." Fetch the existing Historical / External reference folders so the
  // per-file "Folder" field can offer them (pick an existing one OR type a new
  // name). The real on-disk subfolder lives on items[].batchFolder; the batch's
  // `folder` field is only the parsed display tag.
  const [refFolders, setRefFolders] = React.useState({ historical: [], external: [] });
  React.useEffect(() => {
    const fetcher = window.authFetch || fetch;
    const pull = (url, scope) => fetcher(url)
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        const names = [...new Set(((d && d.batches) || [])
          .map(b => (b.items && b.items[0] && b.items[0].batchFolder) || b.folder || b.name)
          .filter(Boolean))];
        setRefFolders(prev => ({ ...prev, [scope]: names }));
      })
      .catch(() => {});
    // 15 Sep 2026 — the historical / external folder scans are Paradise Found's watch path.
    if (!window.__isDefaultProject || window.__isDefaultProject()) {
      pull("/api/historical-refs", "historical");
      pull("/api/external-refs", "external");
    }
  }, []);

  // v06g — createAsset is passed down to UploadRefAssetPicker which
  // calls it when the user clicks Create in the inline "+ new asset"
  // form. Returns a Promise that resolves with the new asset row so
  // the picker can auto-select it on the upload row.
  const createAsset = ({ category, name }) => {
    return fetch("/api/assets/create", {
      method:  "POST",
      headers: { "Content-Type": "application/json",
                 ...(getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}) },
      body: JSON.stringify({ category, name }),
    })
      .then(r => r.json().then(body => ({ status: r.status, body })))
      .then(({ status, body }) => {
        if (status >= 200 && status < 300 && body && body.ok) {
          // Patch the asset into our local catalog so the dropdown
          // re-renders immediately.
          setAssetCatalog(prev => ({
            ...prev,
            [body.category]: [...(prev[body.category] || []), body.asset],
          }));
          // Also keep the global window.__appData.assets in sync so
          // anything else that reads it (Assets view) sees the new
          // entry without a refresh.
          if (window.__appData && window.__appData.assets) {
            const a = window.__appData.assets;
            a[body.category] = [...(a[body.category] || []), body.asset];
          }
          return body.asset;
        }
        throw new Error((body && body.error) || `HTTP ${status}`);
      });
  };

  // Seed each file row with sensible defaults.
  // v06d — Hugo: Edit is the default type for every dropped file, since
  // that's the most common upload here (full director / editor cuts).
  // Image files dropped this way will fail validation server-side until
  // the user flips the Type to Frame or Reference — that's a deliberate
  // nudge to confirm the type rather than silently mis-classifying.
  // We still pre-fill shot_id from the filename (SH####…) so flipping
  // to Frame is one click rather than two.
  const [items, setItems] = React.useState(() => Array.from(files).map(f => {
    const shotMatch = f.name.match(/SH(\d{4})/i);
    const guessShot = shotMatch ? "SH" + shotMatch[1] : (shots[0] && shots[0].id) || "";
    return {
      file: f,
      // v07zz280 — callers can pre-target the upload (e.g. the Assets-page /
      // asset-modal "Bulk upload" opens this as reference uploads, optionally
      // already pointed at one asset) so the user just confirms + uploads.
      kind: defaultKind || "edit",
      // type-specific fields, only the matching one is actually sent:
      shot_id: guessShot,
      asset_category: defaultCategory || "visual_style",   // default reference tag / pre-targeted asset category
      asset_slug: defaultSlug || "",                        // populated when user picks an asset (or pre-targeted)
      descriptor: "",                   // optional filename suffix (face/body/…)
      // upload state:
      status: "pending",
      progress: 0,
      error: null,
      result: null,
    };
  }));
  const [submitting, setSubmitting] = React.useState(false);
  // v07zz288 — Hugo: dropping a batch should categorise ALL files at once by
  // default (one Type / Reference-of / Folder set applied to the whole bunch),
  // with a toggle to switch to per-file categorising. Bulk is the default only
  // when there's more than one file (or always when pre-targeted as a bulk upload).
  const [bulkMode, setBulkMode] = React.useState(files.length > 1 || !!defaultKind);

  const updateItem = (idx, patch) =>
    setItems(prev => prev.map((it, i) => i === idx ? { ...it, ...patch } : it));
  // Apply a field patch to EVERY not-yet-uploaded row at once (bulk mode).
  const applyToAll = (patch) =>
    setItems(prev => prev.map(it => it.status === "done" ? it : { ...it, ...patch }));

  // Per-item validation. Edits have no required field (auto-versioned).
  // Frames need a shot; references need a category (always defaulted).
  const validateItem = (it) => {
    if (it.kind === "edit")      return null;
    if (it.kind === "frame")     return it.shot_id ? null : "Pick a shot.";
    if (it.kind === "reference") return it.asset_category ? null : "Pick a reference category.";
    return "Unknown type.";
  };

  const uploadOne = (idx, overwrite) => new Promise(resolve => {
    setItems(prev => prev.map((it, i) => i === idx ? { ...it, status: "uploading", progress: 0, error: null } : it));
    const item = items[idx];
    // v07zz189 — External / Historical folder refs go to the dedicated
    // folder-upload endpoint (drops into references/<scope>-refs/<folder>/),
    // not the general /api/upload path.
    const isFolderRef = item.kind === "reference" && (item.asset_category === "external" || item.asset_category === "historical");
    const fd = new FormData();
    let uploadUrl = "/api/upload";
    if (isFolderRef) {
      uploadUrl = "/api/refs/folder-upload";
      fd.append("files", item.file);
      fd.append("scope", item.asset_category);
      fd.append("folder", (item.ref_folder || "General").trim() || "General");
    } else {
      fd.append("file", item.file);
      fd.append("kind", item.kind);
      if (overwrite) fd.append("overwrite", "true");
      if (item.kind === "frame") {
        fd.append("shot_id", item.shot_id);
      } else if (item.kind === "reference") {
        fd.append("asset_category", item.asset_category);
        // Optional fields — only sent when the user picked a specific
        // asset / typed a descriptor. Their presence flips the server
        // into asset-folder mode (vs. /refs/<cat>/ general mode).
        if (item.asset_slug) fd.append("asset_slug", item.asset_slug);
        if (item.descriptor) fd.append("descriptor", item.descriptor);
      }
    }
    // edit: no extra fields — server auto-versions globally.

    const xhr = new XMLHttpRequest();
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        updateItem(idx, { progress: Math.round((e.loaded / e.total) * 100) });
      }
    };
    xhr.onload = () => {
      let body = null;
      try { body = JSON.parse(xhr.responseText); } catch (e) {}
      if (xhr.status >= 200 && xhr.status < 300 && body && body.ok) {
        updateItem(idx, { status: "done", progress: 100, result: body });
        try { onComplete && onComplete(body); } catch (e) {}
        resolve();
      } else if (xhr.status === 409 && body && body.code === "FILE_EXISTS" && !overwrite) {
        // v07zz184 — file already exists; ask before replacing.
        if (window.confirm(`${body.error}\n\nOverwrite it?`)) {
          uploadOne(idx, true).then(resolve);
        } else {
          updateItem(idx, { status: "error", error: "Skipped — a file with that name already exists." });
          resolve();
        }
      } else {
        const msg = (body && body.error) || `HTTP ${xhr.status}`;
        updateItem(idx, { status: "error", error: msg });
        resolve();
      }
    };
    xhr.onerror = () => {
      updateItem(idx, { status: "error", error: "Network error." });
      resolve();
    };
    xhr.open("POST", uploadUrl);
    const tok = getAuthToken();
    if (tok) xhr.setRequestHeader("Authorization", `Bearer ${tok}`);
    xhr.send(fd);
  });

  const handleSubmit = async () => {
    if (submitting) return;
    if (items.some(it => validateItem(it))) return;
    setSubmitting(true);
    for (let i = 0; i < items.length; i++) {
      if (items[i].status !== "done") await uploadOne(i);
    }
    setSubmitting(false);
  };

  const allDone   = items.length > 0 && items.every(it => it.status === "done");
  const firstInvalid = items.map(validateItem).find(Boolean);
  const canSubmit = !firstInvalid && !submitting;

  return ReactDOM.createPortal(
    <div className="modal-backdrop" onClick={() => { if (!submitting) onClose(); }}>
      <div className="modal-card upload-modal-card glass" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
        <button className="modal-close-btn" aria-label="Close" onClick={() => { if (!submitting) onClose(); }}>×</button>
        <div className="upload-modal-head">
          <div className="upload-modal-head-titles">
            <div className="upload-modal-eyebrow">MEDIA UPLOAD</div>
            <div className="upload-modal-title">
              {files.length} file{files.length === 1 ? "" : "s"}
            </div>
          </div>
          {/* v07zz288 — bulk vs individual toggle (only with >1 file, before upload) */}
          {files.length > 1 && !allDone && (
            <div className="upload-mode-toggle" role="tablist" aria-label="How to categorise the files">
              <button type="button" role="tab" aria-selected={bulkMode}
                className={"upload-mode-tab" + (bulkMode ? " is-active" : "")}
                onClick={() => setBulkMode(true)} disabled={submitting}>Same for all</button>
              <button type="button" role="tab" aria-selected={!bulkMode}
                className={"upload-mode-tab" + (!bulkMode ? " is-active" : "")}
                onClick={() => setBulkMode(false)} disabled={submitting}>Set individually</button>
            </div>
          )}
        </div>
        <div className="upload-modal-body">
          {bulkMode && items.length > 1 ? (
            <>
              {/* one control set drives every file; the list below just shows
                  what it applies to (with per-file upload status). */}
              <UploadRow
                item={{ ...items[0],
                        file: { name: `All ${items.length} files`, size: items.reduce((s, it) => s + (it.file.size || 0), 0) },
                        status: undefined }}
                shots={shots}
                assetCatalog={assetCatalog}
                refFolders={refFolders}
                onChange={applyToAll}
                onCreateAsset={createAsset}
                disabled={submitting}
              />
              <div className="upload-bulk-files">
                {items.map((item, idx) => {
                  const kb = (item.file.size || 0) / 1024;
                  const sz = kb > 1024 ? `${(kb/1024).toFixed(1)} MB` : `${Math.max(1, Math.round(kb))} KB`;
                  return (
                    <div key={idx} className={"upload-bulk-file" + (item.status === "done" ? " is-done" : "") + (item.status === "error" ? " is-error" : "")}>
                      <span className="upload-bulk-file-name" title={item.file.name}>{item.file.name}</span>
                      {item.status === "uploading" ? <span className="upload-bulk-file-stat">{item.progress}%</span>
                       : item.status === "done"   ? <span className="upload-bulk-file-stat is-ok">✓</span>
                       : item.status === "error"  ? <span className="upload-bulk-file-stat is-err" title={item.error || ""}>✗</span>
                       : <span className="upload-bulk-file-size">{sz}</span>}
                    </div>
                  );
                })}
              </div>
            </>
          ) : (
            items.map((item, idx) => (
              <UploadRow
                key={idx}
                item={item}
                shots={shots}
                assetCatalog={assetCatalog}
                refFolders={refFolders}
                onChange={(patch) => updateItem(idx, patch)}
                onCreateAsset={createAsset}
                disabled={submitting || item.status === "done"}
              />
            ))
          )}
        </div>
        <div className="upload-modal-foot">
          {allDone ? (
            <button className="upload-modal-submit" onClick={onClose}>Done</button>
          ) : (
            <>
              <button className="upload-modal-cancel" onClick={onClose} disabled={submitting}>Cancel</button>
              <button
                className="upload-modal-submit"
                onClick={handleSubmit}
                disabled={!canSubmit}
                title={firstInvalid || ""}
              >
                {submitting ? "Uploading…" : `Upload ${items.length} file${items.length === 1 ? "" : "s"}`}
              </button>
            </>
          )}
        </div>
      </div>
    </div>,
    document.getElementById("modal-root") || document.body
  );
}

function NotesCard() {
  // v07w — Hugo: notes left here didn't sync because the panel was
  // backed by hardcoded React state, not /api/notes. Convert to a
  // real backed-by-DB list: entity_type="project", entity_id=
  // "overall" — these are project-wide director notes, not tied to
  // a specific shot. Range string is stored in `version_label` for
  // now (the column is loose text, perfect for "SH118 — SH124").
  // v07zn — Cache notes in window/sessionStorage so the panel paints
  // populated instantly on Overview revisits instead of empty-→fill.
  const [notes, setNotes] = React.useState(() => {
    if (Array.isArray(window.__todaysNotesCache) && window.__todaysNotesCacheProject === window.__activeProjectId) return window.__todaysNotesCache;
    try {
      const raw = sessionStorage.getItem("todays-notes");
      if (raw) {
        const parsed = JSON.parse(raw);
        if (Array.isArray(parsed)) {
          window.__todaysNotesCache = parsed; window.__todaysNotesCacheProject = window.__activeProjectId;
          return parsed;
        }
      }
    } catch (_) {}
    return [];
  });
  const [active, setActive] = React.useState(0);
  const [composing, setComposing] = React.useState(false);
  const [draft, setDraft] = React.useState("");
  const [draftRange, setDraftRange] = React.useState("");
  const fetcher = window.authFetch || fetch;

  const load = React.useCallback(() => {
    fetcher("/api/notes?entity_type=project&entity_id=overall&include_resolved=true")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        const rows = (d && Array.isArray(d.notes) ? d.notes : []).map(n => ({
          id: n.id,
          body: n.body,
          author: (n.user_name || "—") + (n.user_role === "admin" ? ", Director" : ""),
          time: n.created_at,
          range: n.version_label || "—",
        }));
        setNotes(rows);
        window.__todaysNotesCache = rows; window.__todaysNotesCacheProject = window.__activeProjectId;
        try { sessionStorage.setItem("todays-notes", JSON.stringify(rows)); } catch (_) {}
      })
      .catch(() => {});
  }, [fetcher]);
  React.useEffect(() => { load(); }, [load]);
  React.useEffect(() => {
    const fn = (e) => {
      try {
        const msg = (e && e.detail) || JSON.parse(e.data || "{}");
        if (msg && (msg.type === "note_added" || msg.type === "note_resolved" || msg.type === "note_reply")) {
          load();
        }
      } catch (_) {}
    };
    window.addEventListener("paradise-sse", fn);
    return () => window.removeEventListener("paradise-sse", fn);
  }, [load]);

  const submit = (e) => {
    e.preventDefault();
    if (!draft.trim()) return;
    fetcher("/api/notes", {
      method: "POST",
      body: JSON.stringify({
        entity_type: "project",
        entity_id: "overall",
        body: draft.trim(),
        version_label: draftRange.trim() || null,
      }),
    })
      .then(() => {
        setDraft(""); setDraftRange(""); setComposing(false); setActive(0); load();
        // v07zb — Notify other footer cards (Recent Activity) that a
        // note was added so they refetch /api/logs and the new note
        // appears in the activity feed without a page reload.
        try {
          window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "note_added" } }));
        } catch (_) {}
      })
      .catch(() => {});
  };

  // v07y — Show ALL of today's notes in a scrollable list (was a
  // single-note pagination dot UI). "Today" = local-time today
  // boundary applied client-side so DST + tz quirks don't surprise.
  const todayStart = new Date();
  todayStart.setHours(0, 0, 0, 0);
  const todayNotes = notes.filter(n => {
    if (!n.time) return false;
    const t = new Date(String(n.time).replace(" ", "T") + (String(n.time).includes("T") ? "" : "Z"));
    return Number.isFinite(t.getTime()) && t.getTime() >= todayStart.getTime();
  });
  // Newest first
  todayNotes.sort((a, b) => String(b.time).localeCompare(String(a.time)));

  // v07zz279 — every note row click-throughs to the dedicated Notes page
  // (landing focused + gold-highlighted on that exact note). Hidden when
  // the role's nav_notes permission is off so we never navigate somewhere
  // the sidebar doesn't offer.
  const canOpenNotes = window.hasPerm ? !!window.hasPerm("nav_notes") : true;
  // v07zz278 — hide the Add-note composer for roles without comment_on_shots
  // (server gates POST /api/notes on that key). The notes list stays visible.
  const canComment = !window.hasPerm || window.hasPerm("comment_on_shots");
  const openNotesPage = (noteId) => {
    if (!canOpenNotes) return;
    // Selecting text to copy a note must NOT navigate away — a drag-select
    // released inside the row fires a click on it.
    try { if (String(window.getSelection && window.getSelection() || "").length) return; } catch (_) {}
    if (noteId != null) window.__pendingNoteId = noteId;
    try { window.dispatchEvent(new CustomEvent("paradise-focus-note", { detail: { id: noteId } })); } catch (_) {}
    ((window.__nav && window.__nav.setView) || window.__navigate || (() => {}))("notes");
  };

  return (
    <section className="rc-card glass-dark notes-card">
      <div className="rc-head">
        <div className="rc-title">TODAY'S NOTES{todayNotes.length > 0 ? ` · ${todayNotes.length}` : ""}</div>
        <div className="notes-head-actions">
          {canOpenNotes && (
            <button className="notes-viewall-btn" onClick={() => openNotesPage(null)} title="Open the Notes page">
              View all
            </button>
          )}
          {canComment && (
          <button className="notes-add-btn" onClick={() => setComposing(c => !c)} aria-label={composing ? "Cancel" : "Add note"}>
            {composing ? "✕" : FIcon.plus}
          </button>
          )}
        </div>
      </div>

      {composing ? (
        <form className="notes-compose" onSubmit={submit}>
          <textarea
            className="notes-compose-body"
            placeholder="Write a note…"
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            autoFocus
            rows={3}
          />
          <input
            className="notes-compose-range"
            placeholder="Shot range (e.g. SH118 — SH124)"
            value={draftRange}
            onChange={(e) => setDraftRange(e.target.value)}
          />
          <div className="notes-compose-actions">
            <button type="button" className="notes-compose-cancel" onClick={() => { setComposing(false); setDraft(""); setDraftRange(""); }}>Cancel</button>
            <button type="submit" className="notes-compose-save">Save note</button>
          </div>
        </form>
      ) : todayNotes.length === 0 ? (
        <div className="notes-empty">No notes today — write the first one with the + button.</div>
      ) : (
        /* v07z7 — Hugo: up to 3 notes visible at once, each in the
           EXACT old quote-style (amber glyph + italic body + meta).
           Stack vertically, no shrinkage. Panel height fixed. */
        <div className="notes-stack">
          {todayNotes.slice(0, 3).map(n => (
            <div
              className={"notes-stack-item" + (canOpenNotes ? " notes-stack-item--link" : "")}
              key={n.id}
              onClick={() => openNotesPage(n.id)}
              role={canOpenNotes ? "button" : undefined}
              tabIndex={canOpenNotes ? 0 : undefined}
              title={canOpenNotes ? "Open in Notes" : undefined}
              onKeyDown={canOpenNotes ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openNotesPage(n.id); } } : undefined}
            >
              <blockquote className="notes-quote">
                <span className="notes-glyph">“</span>{n.body}
              </blockquote>
              <div className="notes-meta">
                <span>— {n.author} · {fmtRelativeTime(n.time)}</span>
                {n.range && n.range !== "—" && <span className="notes-range">{n.range}</span>}
              </div>
            </div>
          ))}
        </div>
      )}
    </section>
  );
}

const FOOTER_DEFAULT = ["activity", "media", "notes"];

function FooterRow() {
  const [bump, setBump] = React.useState(0);
  React.useEffect(() => {
    const fn = () => setBump(b => b + 1);
    window.addEventListener("paradise-layout-reset", fn);
    return () => window.removeEventListener("paradise-layout-reset", fn);
  }, []);

  const PANELS = {
    "activity": <RecentActivityCard/>,
    "media":    <MediaUploadsCard/>,
    "notes":    <NotesCard/>,
  };

  const { order, getProps } = window.useSortableZone("footer", FOOTER_DEFAULT, { axis: "x" });
  // v07zz279 — hide the Media Uploads panel for users without upload_assets so
  // they don't see an Upload dropzone the server would 403.
  const canUpload = !window.hasPerm || window.hasPerm("upload_assets");

  return (
    <div className="footer-row sortable-zone-footer" key={bump}>
      {order.map(id => (id === "media" && !canUpload) ? null : (
        <window.SortablePanel key={id} {...getProps(id)}>
          {PANELS[id]}
        </window.SortablePanel>
      ))}
    </div>
  );
}

Object.assign(window, { FooterRow, RecentActivityCard, MediaUploadsCard, NotesCard, GlobalActivitySlide, UploadModal });
