/* global React */

const QIcon = {
  spark: <svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor"><path d="M12 2l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>,
};

// v815 — Hugo: "expand the shot queue small panel to be much taller and show at least
// the last 10 shots." Was 2 image rows + 4 video rows. Beyond this the list scrolls
// inside its own box and a "+N more" button opens the full modal, so a 40-job batch
// makes the card tall but never unbounded.
const QUEUE_ROWS = 10;

// v07zw — Real queue, sourced from /api/generation (the generation_queue
// SQLite table). Was hardcoded sample data; now updates live as jobs
// move through the pipeline.
//
// status mapping (DB → UI):
//   "generating" → "rendering"   (actively rendering)
//   "queued"     → "waiting"     (queued behind earlier jobs)
//   "queued" + just-created (<30s ago) → "submitting"
//   "completed" / "failed"       → not shown (terminal)
//
// IMAGE_QUEUE / VIDEO_QUEUE start empty and get rebuilt from the
// server response. window.__imageQueue / __videoQueue stay as the
// single source of truth so TopBar's video-rendering counter and
// the Sidebar shot-queue card both read live data automatically.
const IMAGE_QUEUE = [];
const VIDEO_QUEUE = [];
window.__videoQueue = VIDEO_QUEUE;
window.__imageQueue = IMAGE_QUEUE;

// Pretty model labels — DB stores raw provider strings but the
// UI shows brand-cased names. Includes the tracker /api/generate
// values (banana / banana-2 / gpt) so the queue panel reports
// "Nano Banana Pro" not the bare slug.
const _MODEL_LABEL = {
  "claude":          "Claude",
  "tracker":         "Tracker",          // pre-v07zw rows
  "banana":          "Nano Banana Pro",
  "banana-2":        "Nano Banana 2",
  "codex-image":     "GPT Image (Subscription)",
  "gpt":             "GPT Image 2",
  "gpt-2.5":         "GPT Image 2.5 Flare",   // v1063 — its real name
  "gpt-2.5-sunburst": "GPT 2.5 Sunburst",
  "gpt-image-1":     "GPT-Image-1",
  "gemini":          "Gemini",
  "gemini-3-pro":    "Gemini 3 Pro",
  "kling":           "Kling",
  "kling-2.0":       "Kling 2.0",
  "kling-2.1":       "Kling 2.1",
  "seedance":        "Seedance",
  "seedance-2.0":    "Seedance 2.0",
  "seedance-2.5":    "Seedance 2.5",   /* v946 — new dispatch slug */
  "kling-3.0":       "Kling 3.0",
  "minimax-h3":      "MiniMax H3",
  "manual":          "Manual",
  "depth-anything-v2": "Depth Anything v2",   // v1063 — was shown as the raw slug
};
function _prettyModel(raw) {
  if (!raw) return "—";
  const lc = String(raw).toLowerCase();
  return _MODEL_LABEL[lc] || raw;
}
function _eta(job) {
  if (!job.expected_at) return "—";
  const ms = new Date(job.expected_at).getTime() - Date.now();
  if (!Number.isFinite(ms)) return "—";
  if (ms < 0) return "soon";
  const min = Math.floor(ms / 60000);
  if (min < 1) return `~${Math.round(ms / 1000)}s`;
  if (min < 60) return `~${min}m`;
  const h = Math.floor(min / 60), m = min % 60;
  return `~${h}h${String(m).padStart(2, "0")}`;
}
function _submitted(job) {
  if (!job.started_at) return "—";
  const ms = Date.now() - new Date(job.started_at).getTime();
  if (ms < 60000)  return `${Math.max(1, Math.round(ms / 10000) * 10)}s ago`;
  if (ms < 3600000) return `${Math.floor(ms / 60000)} min ago`;
  return `${Math.floor(ms / 3600000)}h ago`;
}
// v07zz16 — Normalise SQLite-format timestamps ("YYYY-MM-DD HH:MM:SS",
// no T, no Z) to ISO so the browser doesn't interpret them as local
// time. Same kind of fix as the 99% progress-bar bug (commit
// 80980a4). Without this, completed_at written by datetime('now')
// (which is UTC) was being read as local on Hugo's UK browser,
// inflating the "X ago" reading by his timezone offset (e.g. a row
// done 30 s ago looked like "1h ago" in summer, more if the row
// went through a non-UK Railway server). Numbers also rounded to
// 10-second increments under a minute, per Hugo's spec.
function _toMs(iso) {
  if (!iso) return 0;
  const s = String(iso);
  const fixed = s.includes("T") ? s : s.replace(" ", "T") + "Z";
  const t = new Date(fixed).getTime();
  return Number.isFinite(t) ? t : 0;
}
// v07zw — For terminal rows, use completed_at instead of started_at
// so "10h ago" doesn't show next to a row that just finished
// (started_at can be old if the row sat queued for a while).
function _whenFinished(job) {
  const ref = job.completed_at || job.started_at;
  const t = _toMs(ref);
  if (!t) return "—";
  const ms = Date.now() - t;
  if (ms < 0) return "just now";
  if (ms < 60000)   return `${Math.max(1, Math.round(ms / 10000) * 10)}s ago`;
  if (ms < 3600000) return `${Math.floor(ms / 60000)} min ago`;
  if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`;
  return `${Math.floor(ms / 86400000)}d ago`;
}
function _pct(job) {
  if (!job.expected_at || !job.started_at) return null;
  const total = new Date(job.expected_at).getTime() - new Date(job.started_at).getTime();
  const done  = Date.now() - new Date(job.started_at).getTime();
  if (!Number.isFinite(total) || total <= 0) return null;
  return Math.max(0, Math.min(99, Math.round((done / total) * 100)));
}
// v07zz112 — A combined-grid job packs all 4 quadrant shot ids into
// worker_payload.grid_shots, but the row's shot_id is just the FIRST
// shot (that's where the grid file is saved). Build a label that
// reflects the real coverage: "SH0010–SH0040" for a contiguous step-10
// range, else a compact "SH0010, SH0020 +2". Returns null for a single-
// shot grid (every quadrant the same shot) so the caller falls back to
// the plain shot_id.
function _gridShotLabel(shots) {
  if (!Array.isArray(shots)) return null;
  const uniq = [...new Set(shots.map(s => String(s || "")).filter(Boolean))];
  if (uniq.length <= 1) return null;
  const withNum = uniq.map(s => {
    const m = String(s).match(/(\d+)/);
    return { s, n: m ? parseInt(m[1], 10) : null };
  });
  const allNum = withNum.every(o => o.n != null);
  const sorted = allNum ? withNum.slice().sort((a, b) => a.n - b.n) : withNum;
  let contiguous = allNum && sorted.length >= 2;
  if (contiguous) {
    for (let i = 1; i < sorted.length; i++) {
      if (sorted[i].n - sorted[i - 1].n !== 10) { contiguous = false; break; }
    }
  }
  if (contiguous) return sorted[0].s + "–" + sorted[sorted.length - 1].s;
  const ids = sorted.map(o => o.s);
  if (ids.length <= 2) return ids.join(", ");
  return ids.slice(0, 2).join(", ") + " +" + (ids.length - 2);
}

// Map a DB row → the shape the panels expect.
function _toQueueRow(job) {
  // Try to pull the actual mode (grid / single / upscale) + the grid's
  // quadrant shot list out of the worker payload. Falls back to
  // job.kind for the high-level bucket.
  let mode = null;
  let gridShots = null;
  let seedanceMode = null;   // v07zz309 — "relax" / "standard" for Seedance video jobs
  // v1063 — a depth job's SOURCE: which version it is a depth map of, for the Depth
  // section's "DEPTH OF" cell (the same thing the depth file's name now carries).
  let depthOf = null, depthSource = null, depthIsVideo = false;
  try {
    if (job.worker_payload) {
      const p = JSON.parse(job.worker_payload);
      if (p && p.mode) mode = p.mode;
      if (job.kind === "depth" && p) {
        depthOf = p.source_label || null;
        depthSource = p.source ? String(p.source).split(/[\\/]/).pop() : null;
        depthIsVideo = /\.(mp4|mov|webm|m4v)$/i.test(String(p.source || ""));
      }
      if (p && Array.isArray(p.grid_shots)) gridShots = p.grid_shots.filter(Boolean);
      if (p && p.seedance_mode) seedanceMode = String(p.seedance_mode).toLowerCase();
    }
  } catch (_) {}
  const stageLabel = mode === "grid"   ? "Grid (2×2)"
                   : mode === "single" ? "Single image"
                   : mode === "upscale" ? "Upscale"
                   : job.kind === "video"   ? "Video"
                   : job.kind === "upscale" ? "Upscale"
                   : job.kind === "depth"   ? "Depth map"   /* v07zz594 */
                   : null;
  const isTerminal = job.status === "completed" || job.status === "failed" || job.status === "cancelled";
  const justQueued = job.status === "queued"
    && (Date.now() - new Date(job.started_at).getTime()) < 30000;
  // v07perf — Hugo: "shouldn't say failed, should say pending."
  // Manual/external-render jobs (video to Kling/Seedance) live in a
  // different state machine than the Python-pipeline image jobs:
  // Hugo drives the transitions by clicking the pill. Map queued →
  // "pending" specifically for that family; image jobs keep the old
  // submitting/waiting labels.
  const isManualExternal = job.kind === "video"
    || /^(manual|kling|seedance|minimax)/i.test(String(job.generator || ""));
  const uiStatus = job.status === "completed" ? "completed"
                 : job.status === "failed"    ? "failed"
                 : job.status === "cancelled" ? "cancelled"
                 : job.status === "generating" ? "rendering"
                 : (isManualExternal && job.status === "queued") ? "pending"
                 : justQueued                  ? "submitting"
                 :                                "waiting";
  const _shotLabel = _gridShotLabel(gridShots);
  const _uniqGrid = Array.isArray(gridShots) ? [...new Set(gridShots.filter(Boolean))] : [];
  const row = {
    id: job.shot_id,
    queue_id: job.id,
    seq: job.shot_seq || null,
    // v07zz309 — Hugo: surface Seedance's render tier in the MODEL line —
    // "Seedance 2.0 · Relax" when relax, nothing extra when standard/normal.
    model: _prettyModel(job.generator) + (seedanceMode === "relax" ? " · Relax" : ""),
    // v07zz452 — keep the RAW generator id (banana / banana-2 / gpt / …) so the
    // Generate-page drawer can group its progress bars by each job's OWN model,
    // not by whatever model is currently selected in the picker.
    generator: String(job.generator || ""),
    stage: stageLabel,
    mode,
    // v07zz112 — Combined-grid coverage. shotLabel is the "SH0010–SH0040"
    // style range (null for single-shot jobs); gridShots is the raw list;
    // shotTitle is the full comma list for a hover tooltip.
    gridShots,
    shotLabel: _shotLabel,
    shotTitle: _uniqGrid.length > 1 ? _uniqGrid.join(", ") : null,
    kind: job.kind,
    depthOf, depthSource, depthIsVideo,   // v1063 — Depth section
    status: uiStatus,
    submitted: _submitted(job),
    // v07zw — Terminal rows show "Xm ago" relative to completed_at so
    // the "WHEN" column doesn't read 10h ago if the row had been
    // sitting queued for a long time before completing.
    when_finished: isTerminal ? _whenFinished(job) : null,
    eta: isTerminal ? null : _eta(job),
    retry_count: job.retry_count || 0,
    error: job.error_message || null,
    // v07zz17 — Who dispatched. Used by the SidebarShotQueueCard +
    // QueueBreakdownModal to render a "BY" column / chip.
    submitted_by: job.submitted_by_name || null,
    // v07zz36 — Raw sortable timestamp for the Archive section. Prefer
    // completed_at (terminal rows) so a freshly-failed job sorts above
    // a long-running job that's still in-flight from earlier. Falls
    // back to started_at otherwise.
    sort_ts: job.completed_at || job.started_at || null,
    // v07perf — Mark the row as user-driven so the Sidebar's queue
    // panel can render the status pill as a clickable cycle button.
    is_manual_external: isManualExternal,
    db_status: job.status,
  };
  if (uiStatus === "rendering") {
    // v773 — depth-map jobs report REAL progress: the server parses the Python
    // script's PROGRESS/tqdm output into progress_pct (attached to the poll) and
    // streams depth.progress over SSE (cached in _DEPTH_PCT, fresher than the
    // poll). Other job kinds keep the old expected_at time interpolation.
    const live = job.kind === "depth" ? _DEPTH_PCT.get(job.id) : null;
    const p = live ? live.pct : (job.progress_pct != null ? job.progress_pct : _pct(job));
    if (p != null) row.pct = p;
    const lbl = live ? live.label : job.progress_label;
    if (lbl) row.progressLabel = lbl;
  } else if (uiStatus === "completed") {
    row.pct = 100;
  }
  return row;
}

// v773 — live depth progress cache: queue_row_id → { pct, label }. Fed by the
// paradise-sse depth_progress relay (App.jsx forwards the server's depth.progress
// broadcast ~1/s per running job); read by _toQueueRow + patched straight into
// the visible rows below so the bar moves between 8s polls.
const _DEPTH_PCT = new Map();

// Live refresh — called by Sidebar mount + on every paradise-sse
// event that suggests the queue might have changed. Mutates the
// module-scope arrays IN PLACE so existing window.__imageQueue
// references stay valid; dispatches a 'paradise-queue-changed'
// CustomEvent so the Sidebar can re-render.
let _queueRefreshInflight = false;
// v07zz226 — Readiness signal for the loading-screen orchestrator. The
// Sidebar's bottom-left Shot Queue card is the only lower-left card that
// fetches its own data (/api/generation) independently of the main
// /api/data load. App.jsx gates the splash fade on this flag so the queue
// card is already populated when the loader fades in — otherwise it pops
// in afterwards and shifts the panel height. We mark "ready" on the FIRST
// completed refresh attempt regardless of outcome (success, !ok, or
// network error) so a failing fetch can never strand the splash; App.jsx
// also keeps its own hard-timeout fallback.
window.__queueReady = false;
function _markQueueReady() {
  if (window.__queueReady) return;
  window.__queueReady = true;
  try { window.dispatchEvent(new CustomEvent("paradise-queue-ready")); } catch (_) {}
}
async function refreshQueue() {
  if (_queueRefreshInflight) return;
  _queueRefreshInflight = true;
  try {
    const fetcher = window.authFetch || fetch;
    const r = await fetcher("/api/generation");
    if (!r.ok) return;
    const data = await r.json();
    const jobs = Array.isArray(data && data.jobs) ? data.jobs : [];
    // v773 — drop live-progress entries for depth jobs that reached a terminal
    // state (the map would otherwise hold stale pcts for reused ids forever).
    for (const j of jobs) {
      if (j.kind === "depth" && j.status !== "generating") _DEPTH_PCT.delete(j.id);
    }
    // Newest first → split by kind, map to UI shape.
    const nextImage = jobs.filter(j => j.kind === "image" || j.kind === "upscale" || j.kind === "depth").map(_toQueueRow);   // v07zz594 — depth jobs ride the image queue
    const nextVideo = jobs.filter(j => j.kind === "video").map(_toQueueRow);
    IMAGE_QUEUE.length = 0; IMAGE_QUEUE.push(...nextImage);
    VIDEO_QUEUE.length = 0; VIDEO_QUEUE.push(...nextVideo);
    window.dispatchEvent(new CustomEvent("paradise-queue-changed"));
  } catch (_) { /* network blip — try again next tick */ }
  finally { _queueRefreshInflight = false; _markQueueReady(); }
}
// Kick off on load, refresh every 8s, and immediately on SSE pokes.
window.__refreshQueue = refreshQueue;
if (typeof window !== "undefined" && !window.__queueWired) {
  window.__queueWired = true;
  refreshQueue();
  setInterval(refreshQueue, 8000);
  window.addEventListener("paradise-sse", (e) => {
    let type = null;
    try {
      const msg = (e && e.detail) || JSON.parse(e.data || "{}");
      type = msg && msg.type;
    } catch (_) {}
    if (!type) return;
    // v773 — depth_progress patches the visible row in place (no refetch: these
    // arrive ~1/s per running job and the row data hasn't changed, just the pct).
    if (type === "depth_progress") {
      const msg = (e && e.detail) || {};
      if (msg.job_id == null) return;
      _DEPTH_PCT.set(msg.job_id, { pct: msg.pct, label: msg.label || "" });
      let touched = false;
      for (const r of IMAGE_QUEUE) {
        if (r.queue_id === msg.job_id) { r.pct = msg.pct; r.progressLabel = msg.label || ""; touched = true; }
      }
      if (touched) window.dispatchEvent(new CustomEvent("paradise-queue-changed"));
      else refreshQueue();   // job started since the last poll — pull it into the list
      return;
    }
    // Only events that affect the queue.
    if (/^(generate|image_|video_|asset_version)/.test(type)) refreshQueue();
  });
}

const STATUS_DOT = { rendering: "var(--st-hero)", waiting: "var(--st-wip)", submitting: "var(--st-video-wip)" };
const countByStatus = (arr) => arr.reduce(
  (acc, r) => (acc[r.status] = (acc[r.status] || 0) + 1, acc),
  { rendering: 0, waiting: 0, submitting: 0 }
);

function QueueProgress({ pct = 0 }) {
  return (
    <div className="qp-track">
      <div className="qp-fill" style={{width: pct + "%"}}/>
    </div>
  );
}

// Status pill (reused by the modal table). Background cream, coloured
// border + dot per status. Mirrors the t05e shot-pill design language.
function QStatusPill({ status }) {
  const label = status === "rendering" ? "RENDERING" : status === "waiting" ? "WAITING" : "SUBMITTING";
  return (
    <span className={"qm-pill qm-pill--" + status}>
      <span className="qm-pill-dot"/>{label}
    </span>
  );
}

// Submitting "thinking" indicator — animated dots.
function QSubmittingDots() {
  return <span className="qm-submitting" aria-label="Submitting"><span/><span/><span/></span>;
}

// Modal — full-screen dark forest-green panel with image + video queue
// tables. Triggered by clicking the inline ShotQueueCard summary.
function ShotQueueModal({ image = IMAGE_QUEUE, video = VIDEO_QUEUE, onClose }) {
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose && onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);
  const imgC = countByStatus(image);
  const vidC = countByStatus(video);
  // v07zz183 — count only ACTIVE jobs for the in-flight headline; completed
  // rows stay in the list but must not read as "in flight".
  const imgActive = imgC.rendering + imgC.waiting + imgC.submitting;
  const vidActive = vidC.rendering + vidC.waiting + vidC.submitting;
  const total = imgActive + vidActive;
  return (
    <div className="qm-backdrop" onClick={onClose}>
      <div className="qm-panel" onClick={e => e.stopPropagation()} role="dialog" aria-label="Shot queue">
        <button className="qm-close" onClick={onClose} 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>

        <div className="qm-head">
          <div className="qm-eyebrow">SHOT QUEUE</div>
          <div className="qm-title">{total} JOBS IN FLIGHT</div>
          <div className="qm-subtitle">{imgActive} image · {vidActive} video</div>

          <div className="qm-cards">
            <div className="qm-stat-card qm-stat-card--image">
              <div className="qm-stat-icon">
                <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><rect x="4" y="4" width="16" height="12" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M4 16l4.5-4 3 3 4-3 4.5 4"/></svg>
              </div>
              <div className="qm-stat-body">
                <div className="qm-stat-num">{imgActive}</div>
                <div className="qm-stat-label">IMAGE JOBS</div>
              </div>
            </div>
            <div className="qm-stat-card qm-stat-card--video">
              <div className="qm-stat-icon">
                <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
              </div>
              <div className="qm-stat-body">
                <div className="qm-stat-num">{vidActive}</div>
                <div className="qm-stat-label">VIDEO JOBS</div>
              </div>
            </div>
          </div>
        </div>

        {/* IMAGE QUEUE section */}
        <section className="qm-section">
          <div className="qm-section-head">
            <span className="qm-section-title">IMAGE QUEUE</span>
            <span className="qm-section-summary">
              <span className="qm-summary-stat"><span className="qm-summary-dot" style={{background: STATUS_DOT.rendering}}/>{imgC.rendering} Rendering</span>
              <span className="qm-summary-stat"><span className="qm-summary-dot" style={{background: STATUS_DOT.waiting}}/>{imgC.waiting} Waiting</span>
              <span className="qm-summary-stat"><span className="qm-summary-dot" style={{background: STATUS_DOT.submitting}}/>{imgC.submitting} Submitting</span>
            </span>
          </div>
          <div className="qm-table">
            <div className="qm-row qm-row--head">
              <div className="qm-cell qm-cell--shot">SHOT</div>
              <div className="qm-cell qm-cell--seq">SEQ</div>
              <div className="qm-cell qm-cell--model">MODEL</div>
              <div className="qm-cell qm-cell--stage">STAGE</div>
              <div className="qm-cell qm-cell--progress">PROGRESS</div>
              <div className="qm-cell qm-cell--eta">ETA</div>
            </div>
            {image.map(r => (
              <div className="qm-row" key={r.id}>
                <div className="qm-cell qm-cell--shot">
                  <span className="qm-row-dot" style={{background: STATUS_DOT[r.status]}}/>
                  <span className="qm-shot-id" title={r.shotTitle || r.id}>{r.shotLabel || r.id}</span>
                </div>
                <div className="qm-cell qm-cell--seq"><span className="qm-seq-pill">SEQ {String(r.seq).padStart(2,"0")}</span></div>
                <div className="qm-cell qm-cell--model">{r.model}</div>
                <div className="qm-cell qm-cell--stage">{r.stage}</div>
                <div className="qm-cell qm-cell--progress">
                  {r.status === "rendering" && (
                    <div className="qm-bar" title={r.progressLabel || undefined}><div className="qm-bar-fill" style={{width: `${r.pct || 0}%`}}/><span className="qm-bar-pct">{r.pct != null ? r.pct + "%" : "…"}</span></div>
                  )}
                  {r.status === "waiting" && (
                    <span className="qm-waiting-row"><QStatusPill status="waiting"/><span className="qm-queue-pos">#{r.queuePos}</span></span>
                  )}
                  {r.status === "submitting" && (
                    <span className="qm-submitting-row"><QSubmittingDots/><QStatusPill status="submitting"/></span>
                  )}
                </div>
                <div className="qm-cell qm-cell--eta">{r.eta}</div>
              </div>
            ))}
          </div>
        </section>

        {/* VIDEO QUEUE section */}
        <section className="qm-section">
          <div className="qm-section-head">
            <span className="qm-section-title">VIDEO QUEUE</span>
            <span className="qm-section-summary">
              <span className="qm-summary-stat"><span className="qm-summary-dot" style={{background: STATUS_DOT.rendering}}/>{vidC.rendering} Rendering</span>
              <span className="qm-summary-stat"><span className="qm-summary-dot" style={{background: STATUS_DOT.waiting}}/>{vidC.waiting} Waiting</span>
              <span className="qm-summary-stat"><span className="qm-summary-dot" style={{background: STATUS_DOT.submitting}}/>{vidC.submitting} Submitting</span>
            </span>
          </div>
          <div className="qm-table">
            <div className="qm-row qm-row--head qm-row--video">
              <div className="qm-cell qm-cell--shot">SHOT</div>
              <div className="qm-cell qm-cell--seq">SEQ</div>
              <div className="qm-cell qm-cell--model">MODEL</div>
              <div className="qm-cell qm-cell--status">STATUS</div>
              <div className="qm-cell qm-cell--submitted">SUBMITTED</div>
              <div className="qm-cell qm-cell--eta">ETA</div>
              <div className="qm-cell qm-cell--queue">QUEUE</div>
            </div>
            {video.map(r => (
              <div className="qm-row qm-row--video" key={r.id}>
                <div className="qm-cell qm-cell--shot">
                  <span className="qm-row-dot" style={{background: STATUS_DOT[r.status]}}/>
                  <span className="qm-shot-id" title={r.shotTitle || r.id}>{r.shotLabel || r.id}</span>
                </div>
                <div className="qm-cell qm-cell--seq"><span className="qm-seq-pill">SEQ {String(r.seq).padStart(2,"0")}</span></div>
                <div className="qm-cell qm-cell--model">{r.model}</div>
                <div className="qm-cell qm-cell--status">
                  {r.status === "submitting"
                    ? <span className="qm-submitting-row"><QSubmittingDots/><QStatusPill status="submitting"/></span>
                    : <QStatusPill status={r.status}/>}
                </div>
                <div className="qm-cell qm-cell--submitted">{r.submitted}</div>
                <div className="qm-cell qm-cell--eta">
                  {r.status === "rendering" ? (
                    <div className="qm-bar"><div className="qm-bar-fill" style={{width: `${r.pct}%`}}/><span className="qm-bar-pct">{r.eta}</span></div>
                  ) : r.eta}
                </div>
                <div className="qm-cell qm-cell--queue">{r.status === "waiting" ? `#${r.queuePos}` : "—"}</div>
              </div>
            ))}
          </div>
        </section>
      </div>
    </div>
  );
}

function ShotQueueCard() {
  const image = IMAGE_QUEUE;
  const video = VIDEO_QUEUE;
  // v07zz183 — in-flight counts exclude completed/terminal rows.
  const _imgC = countByStatus(image), _vidC = countByStatus(video);
  const imgActive = _imgC.rendering + _imgC.waiting + _imgC.submitting;
  const vidActive = _vidC.rendering + _vidC.waiting + _vidC.submitting;
  const [modalOpen, setModalOpen] = React.useState(false);
  return (
    <>
      <section className="rc-card glass shot-queue-card">
        <div className="rc-head">
          <div className="rc-title">SHOT QUEUE</div>
          <button className="sqc-expand" type="button" onClick={() => setModalOpen(true)} title="Open full queue">
            View all →
          </button>
        </div>

        <div className="queue-section">
          <div className="qs-head">
            <span className="qs-label">IMAGE QUEUE</span>
            <span className="qs-count">{imgActive} in flight</span>
          </div>
          {/* v815 — Hugo: "expand the shot queue small panel to be much taller and show at
              least the last 10 shots." Was 2. The list scrolls inside its own box past
              QUEUE_ROWS so a 40-job batch can't push the card off the screen. */}
          {image.length === 0 ? (
            <div className="qs-empty">No image jobs running</div>
          ) : (
            <div className="qs-list">
              {image.slice(0, QUEUE_ROWS).map(r => (
                <div className="queue-row" key={r.id}>
                  <div className="qr-top">
                    <span className="qr-id" title={r.shotTitle || r.id}>{r.shotLabel || r.id}</span>
                    <span className="qr-stage">{r.stage}</span>
                    <span className="qr-eta">{r.eta}</span>
                  </div>
                  {r.status === "rendering" && <QueueProgress pct={r.pct}/>}
                </div>
              ))}
              {image.length > QUEUE_ROWS && (
                <button type="button" className="qs-more" onClick={() => setModalOpen(true)}>
                  +{image.length - QUEUE_ROWS} more — view all
                </button>
              )}
            </div>
          )}
        </div>

        <div className="queue-section">
          <div className="qs-head">
            <span className="qs-label">VIDEO QUEUE</span>
            <span className="qs-count">{vidActive} queued</span>
          </div>
          {video.length === 0 ? (
            <div className="qs-empty">No video jobs queued</div>
          ) : (
            <div className="qs-list">
              {video.slice(0, QUEUE_ROWS).map(r => (
                <div className="queue-row video" key={r.id}>
                  <div className="qr-top">
                    <span className="qr-id" title={r.shotTitle || r.id}>{r.shotLabel || r.id}</span>
                    <span className="qr-eta">{r.eta}</span>
                  </div>
                  <div className="qr-bot">
                    <span className="qr-model">{r.model}</span>
                    <span className="qr-sub">submitted {r.submitted}</span>
                  </div>
                </div>
              ))}
              {video.length > QUEUE_ROWS && (
                <button type="button" className="qs-more" onClick={() => setModalOpen(true)}>
                  +{video.length - QUEUE_ROWS} more — view all
                </button>
              )}
            </div>
          )}
        </div>
      </section>
      {modalOpen && <ShotQueueModal image={image} video={video} onClose={() => setModalOpen(false)}/>}
    </>
  );
}
// Exposed so the mobile Overview dashboard can mount the Shot Queue card
// directly (App.jsx) — on a phone the sidebar bottom-zone that normally hosts
// it is hidden.
window.ShotQueueCard = ShotQueueCard;

function ShotBreakdownCard({ shots = [], sequences = [] }) {
  const STAGE_COLORS = {
    // 16 Sep 2026 - a skin may give each stage its own bar colour (--bd-*); without one the
    // bar reads the accent it always did
    hero: "var(--bd-hero, var(--leaf))",
    refinement: "var(--bd-refinement, var(--amber))",
    first_pass: "var(--bd-first-pass, var(--teal))",
    prompt: "var(--tan)",
    pending: "color-mix(in srgb, var(--ink-muted) 35%, transparent)",
    archive: "var(--archive)",
  };
  const stageOf = (s) => {
    if (s.is_archive) return "archive";
    const st = s.stage_status || {};
    if (st.hero === "done") return "hero";
    if (st.refinement === "done" || (st.refinement === "pending" && st.first_pass === "done")) return "refinement";
    if (st.first_pass === "done") return "first_pass";
    if (st.prompt === "done") return "prompt";
    return "pending";
  };
  // Build bars from real sequences. Each bar shows the stage breakdown of
  // its shots; if the seq's declared shot_count exceeds seeded shots, the
  // remainder shows as "pending" so totals stay honest.
  const seqList = sequences.slice().sort((a, b) => a.number - b.number);
  const max = seqList.length ? Math.max(...seqList.map(s => s.shot_count || 0), 1) : 1;
  const order = ["archive","pending","prompt","first_pass","refinement","hero"];
  const bars = seqList.map(s => {
    const seq = s.number;
    const total = s.shot_count || 0;
    const inSeq = shots.filter(sh => sh.seq === seq);
    const counted = {};
    for (const sh of inSeq) {
      const k = stageOf(sh);
      counted[k] = (counted[k] || 0) + 1;
    }
    const seeded = Object.values(counted).reduce((a, b) => a + b, 0);
    const pad = Math.max(0, total - seeded);
    counted.pending = (counted.pending || 0) + pad;
    return { seq, total, parts: counted, slug: s.slug };
  });

  const [hoverSeq, setHoverSeq] = React.useState(null);
  const STAGE_LABEL = {
    hero: "Hero done", refinement: "In refinement", first_pass: "First-pass done",
    prompt: "Prompt only", pending: "Pending", archive: "Archive",
  };
  const totalAcross = bars.reduce((a, b) => a + b.total, 0);
  const heroDone = shots.filter(s => s && s.stage_status && s.stage_status.hero === "done").length;
  const handleBarClick = (seq) => {
    if (window.__nav && window.__nav.openSequence) window.__nav.openSequence(seq);
  };
  const hovered = hoverSeq != null ? bars.find(b => b.seq === hoverSeq) : null;

  return (
    <section className="rc-card glass shot-breakdown-card">
      <div className="rc-head">
        <div className="rc-title">SHOT BREAKDOWN</div>
        <div className="bd-hint">Click a bar →</div>
      </div>
      <div className="bd-top">
        <div>
          <div className="bd-num">{totalAcross}</div>
          <div className="bd-cap">TOTAL SHOTS · {seqList.length} SEQUENCES</div>
        </div>
        <div>
          <div className="bd-num leaf">{heroDone}</div>
          <div className="bd-cap">HERO DONE</div>
        </div>
      </div>
      <div className="breakdown-frame">
        <div className="bd-yticks">
          <span>{max}</span><span>{Math.round(max / 2)}</span><span>0</span>
        </div>
        <div className="breakdown-bars" style={{gridTemplateColumns: `repeat(${bars.length}, 1fr)`}}>
          {bars.map(b => (
            <button
              type="button"
              className={"breakdown-bar" + (hoverSeq === b.seq ? " is-hover" : "")}
              key={b.seq}
              onClick={() => handleBarClick(b.seq)}
              onMouseEnter={() => setHoverSeq(b.seq)}
              onMouseLeave={() => setHoverSeq(null)}
              aria-label={`Sequence ${b.seq} — ${b.total} shots`}
            >
              <div className="bd-stack" style={{height: (b.total / max) * 100 + "%"}}>
                {order.map(stage => {
                  const n = b.parts[stage] || 0;
                  if (!n) return null;
                  return (
                    <div key={stage} className="bd-seg"
                      style={{ flex: n, background: STAGE_COLORS[stage] }}/>
                  );
                })}
              </div>
            </button>
          ))}
        </div>
      </div>
      <div className="bd-axis" style={{gridTemplateColumns: `repeat(${bars.length}, 1fr)`}}>
        {bars.map(b => (
          <span key={b.seq} className={hoverSeq === b.seq ? "is-hover" : ""}>{String(b.seq).padStart(2,"0")}</span>
        ))}
      </div>
      <div className={"bd-tooltip" + (hovered ? " is-on" : "")}>
        {hovered ? (
          <>
            <span className="bd-tt-label">SEQ {String(hovered.seq).padStart(2,"0")}</span>
            <span className="bd-tt-total">{hovered.total} shots</span>
            <span className="bd-tt-sep">·</span>
            <span className="bd-tt-detail">
              {order.filter(k => hovered.parts[k]).map((k, i, arr) => (
                <span key={k}>
                  <span className="bd-tt-dot" style={{background: STAGE_COLORS[k]}}/>
                  {hovered.parts[k]} {STAGE_LABEL[k]}{i < arr.length - 1 ? " · " : ""}
                </span>
              ))}
            </span>
          </>
        ) : (
          <span className="bd-tt-default">Hover a bar for stage breakdown · click to open the sequence</span>
        )}
      </div>
    </section>
  );
}

Object.assign(window, { ShotQueueCard, ShotBreakdownCard });
