/* global React */
// v07zz247 — Shared, state-free presentation SLIDE renderer.
//
// This is the SINGLE SOURCE OF TRUTH for slide markup. It is used by BOTH:
//   • the Presentations builder (edit mode — contentEditable fields, drag-pan,
//     reorder, add/remove item controls), and
//   • the Review deck viewer (read-only — frozen snapshot, click-an-image-to-
//     comment), see src/PresentationDeckViewer.jsx.
//
// Every environment-specific dependency is routed through `ctx` so the produced
// DOM (the ps-* classes the CSS targets) is byte-for-byte identical in both
// places — a style/structure change happens once and never drifts.
//
// ctx fields:
//   mode          "edit" | "view"
//   ro            boolean — read-only (no editable fields / no item controls)
//   editable(k,field,val,cls)               — edit-field renderer (edit mode)
//   editItem(id,key,idx,field,val,cls)      — item edit-field renderer
//   slideLayout   map  key -> layout id
//   slideArrange  map  key -> arrange id
//   imgPos        map  (key+"|"+url) -> {x,y}
//   fillOf(key,n,pos) posOf(s) textAlignOf(s) layOf(s,arr,def)
//   AGENDA_LAYS COLS_LAYS SECTION_LAYS
//   activeSlide   currently-selected slide key (edit mode hint text only)
//   thumbU(url,w) autoGrid(imgs,aspect,arrange) areaAspectFor(pos)
//   compassEl() iconEl(name) coverMeta(ver) peekVer()
//   startPan(e,pid,el) reorderImg(key,url,dir) delItem(id,key,idx) addItem(id,key,blank)  [edit]
//   onImageClick(key,labels,url) imgSource(key,labels,url)                                  [view]
(function () {
  // v07zz271 — "Label each image" grid: each image in its own card with the whole
  // image shown (contain, never cropped) and an editable Title + Subtitle beneath
  // it, so the client can comment on what each image is. Shared by builder (edit:
  // contentEditable) + review viewer (view: click image to comment / ⤢ to zoom) +
  // is mirrored in the PDF export's gridHtml.
  function renderLabeledGrid(imgs, key, ctx) {
    const ro = ctx.ro, view = ctx.mode === "view";
    const editable = !ro && !view && typeof ctx.editImgCap === "function";
    const cols = Math.min(imgs.length, 4) || 1;
    const cap = (url) => (ctx.imgCap ? ctx.imgCap(key, url) : null) || {};
    const imgAttrs = (url) => {
      if (view) {
        const linked = !!(ctx.imgSource && ctx.imgSource(key, null, url));
        const sel = ctx.selectedUrl && ctx.selectedUrl === url;
        const hov = !sel && ctx.hoveredUrl && ctx.hoveredUrl === url;
        return { cls: " ps-grid-cell--review" + (linked ? "" : " is-nolink") + (sel ? " is-selected" : (hov ? " is-hovered" : "")), onClick: (e) => { e.stopPropagation(); ctx.onImageClick(key, null, url); }, title: linked ? "Click to comment (also posts to its Notes)" : "Click to comment on this image" };
      }
      return { cls: "", onClick: undefined, title: undefined };
    };
    const zoomBtn = (url) => (view && ctx.onImageZoom) ? <button type="button" className="ps-grid-zoom" title="View full screen" aria-label="View full screen" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); ctx.onImageZoom(url); }}>⤢</button> : null;
    const edLine = (url, field, ph, cls) => {
      const txt = cap(url)[field] || "";
      if (editable) return <div className={cls} contentEditable suppressContentEditableWarning draggable={false} data-ph={ph} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onDragStart={(e) => e.preventDefault()} onBlur={(e) => { const v = (e.currentTarget.textContent || "").trim(); if (!v) e.currentTarget.innerHTML = ""; ctx.editImgCap(key, url, field, v); }}>{txt}</div>;
      return txt ? <div className={cls}>{txt}</div> : (field === "t" ? <div className={cls + " is-empty"} /> : null);
    };
    return (
      <div className="ps-lblgrid" style={{ gridTemplateColumns: "repeat(" + cols + ", minmax(0,1fr))" }}>
        {imgs.map((url) => { const a = imgAttrs(url); return (
          <div className="ps-lblcard" key={url}>
            <div className={"ps-lblcard-img" + a.cls} onClick={a.onClick} title={a.title}>
              <img className="ps-lblcard-imgel" src={ctx.thumbU(url, Math.min(imgs.length, 4) <= 2 ? 1200 : 800)} alt="" draggable={false} />
              {zoomBtn(url)}{ctx.cellBadge ? ctx.cellBadge(key, url) : null}
            </div>
            <div className="ps-lblcard-txt">
              {edLine(url, "t", "Title", "ps-lblcard-t")}
              {edLine(url, "s", "Subtitle / note", "ps-lblcard-s")}
            </div>
          </div>
        ); })}
      </div>
    );
  }
  function renderGrid(imgs, pos, key, labels, ctx, opts) {
    opts = opts || {};
    const ro = ctx.ro, view = ctx.mode === "view";
    if (!imgs.length) return <div className="ps-grid-empty">{ro ? "" : <>No images — pick some on the left{ctx.activeSlide === key ? " (they add to this slide)" : ""}.</>}</div>;
    // v07zz271 — "Label each image" mode: each image gets an editable title + sub
    // underneath (for per-image client comments). Overrides the normal grid layout.
    if (ctx.captionOf && ctx.captionOf(key)) return renderLabeledGrid(imgs, key, ctx);
    const fill = ctx.fillOf(key, imgs.length, pos);
    // v07zz271 — "Fit whole image" (contain) — never crop, letterbox instead.
    const fit = ctx.fitOf ? !!ctx.fitOf(key) : false;
    const canReorder = !ro && !view && ctx.activeSlide === key && imgs.length > 1;
    // Per-image caption. Two styles: the default corner STAMP (".ps-grid-label",
    // used for shot-id overprints on combined grids / Character-Shots), and a "band"
    // style (".ps-grid-caption", a full-width bottom caption used by Outfit Selection
    // slides). The band is EDITABLE inline when ctx.editLabel is provided (builder
    // edit mode), so each outfit image carries a "when is this used" note; the empty
    // placeholder is scoped to [contenteditable] in CSS so it never leaks into the
    // PDF export (printDeck strips contenteditable from the clone).
    const capBand = opts.caption === "band";
    const editCap = capBand && !ro && !view && typeof ctx.editLabel === "function";
    const labelEl = (url) => {
      if (capBand) {
        // Band captions are FREE TEXT and live in opts.captions — NOT the `labels`
        // (shot-id) channel. The review viewer's imgSource() treats labels[url] as a
        // shot id; routing a caption through it would create phantom shot notes. So
        // outfit slides pass labels=null and the caption text via opts.captions.
        const txt = (opts.captions && opts.captions[url]) || "";
        if (editCap) return <div className="ps-grid-caption" contentEditable suppressContentEditableWarning draggable={false} data-ph="+ When is this outfit used?" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onDragStart={(e) => e.preventDefault()} onBlur={(e) => { const v = (e.currentTarget.textContent || "").trim(); if (!v) e.currentTarget.innerHTML = ""; ctx.editLabel(key, url, v); }}>{txt}</div>;
        return txt ? <div className="ps-grid-caption">{txt}</div> : null;
      }
      return (labels && labels[url]) ? <span className="ps-grid-label">{labels[url]}</span> : null;
    };
    // VIEW mode: a hover ⤢ button opens the image full screen (assets-style Lightbox).
    const zoomBtn = (url) => (view && ctx.onImageZoom) ? <button type="button" className="ps-grid-zoom" title="View full screen" aria-label="View full screen" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); ctx.onImageZoom(url); }}>⤢</button> : null;
    const reorderEls = (url) => canReorder ? (
      <div className="ps-cell-tools" onMouseDown={(e) => e.stopPropagation()}>
        <button type="button" className="ps-cell-move" title="Move earlier" onClick={(e) => { e.stopPropagation(); ctx.reorderImg(key, url, -1); }}>‹</button>
        <button type="button" className="ps-cell-move" title="Move later" onClick={(e) => { e.stopPropagation(); ctx.reorderImg(key, url, 1); }}>›</button>
      </div>
    ) : null;
    // per-cell interactivity. In VIEW mode the cell is clickable to leave
    // feedback (and never pans); in EDIT mode it pans/reframes as before.
    const cellAttrs = (url) => {
      if (view) {
        // EVERY image is commentable in review. "linked" images (a source shot/asset
        // is known) mirror the comment to that entity's Notes; unlinked images
        // (freeform grid adds, external/historical refs) still take a deck-only
        // comment. selectedUrl highlights the image the drawer composer targets.
        const linked = !!(ctx.imgSource && ctx.imgSource(key, labels, url));
        const sel = ctx.selectedUrl && ctx.selectedUrl === url;
        const hov = !sel && ctx.hoveredUrl && ctx.hoveredUrl === url;
        return { cls: " ps-grid-cell--review" + (linked ? "" : " is-nolink") + (sel ? " is-selected" : (hov ? " is-hovered" : "")), onMouseDown: undefined, onClick: (e) => { e.stopPropagation(); ctx.onImageClick(key, labels, url); }, title: linked ? "Click to comment (also posts to its Notes)" : "Click to comment on this image" };
      }
      return { cls: ro ? "" : " ps-grid-cell--pan", onMouseDown: ro ? undefined : (e) => ctx.startPan(e, key + "|" + url, e.currentTarget), onClick: undefined, title: ro ? undefined : "Drag to reposition" };
    };
    // v07zz274 — request a thumb width that matches how LARGE the cell renders, so
    // few-up layouts (1 wide cell ≈ half-to-full slide width) aren't upscaled from a
    // tiny 400px thumb (the old fixed width → blur). Fewer images per row = bigger
    // cell = higher res. (View mode doubles these via hiResW; PDF uses 1600.)
    const cw = (n) => n <= 2 ? 1200 : n === 3 ? 800 : 560;
    // single image + fill + cinematic position → full-bleed backdrop
    if (imgs.length === 1 && fill && !fit && (pos === "overlay" || pos === "none")) {
      const pid = key + "|" + imgs[0]; const p = ctx.imgPos[pid] || { x: 50, y: 50 }; const a = cellAttrs(imgs[0]);
      return <div className="ps-grid ps-grid--fill"><div className={"ps-grid-cell" + a.cls} style={{ backgroundImage: `url(${ctx.thumbU(imgs[0], 1200)})`, backgroundPosition: `${p.x}% ${p.y}%` }} onMouseDown={a.onMouseDown} onClick={a.onClick} title={a.title}>{labelEl(imgs[0])}{reorderEls(imgs[0])}{zoomBtn(imgs[0])}{ctx.cellBadge ? ctx.cellBadge(key, imgs[0]) : null}</div></div>;
    }
    const rows = ctx.autoGrid(imgs, ctx.areaAspectFor(pos), ctx.slideArrange[key]);
    return (
      <div className={"ps-grid" + (fill ? " ps-grid--cover" : "") + (fit ? " ps-grid--contain" : "")}>
        {rows.map((row, ri) => { const sumA = row.reduce((x, i) => x + i.a, 0); return (
          <div className="ps-grid-row" key={ri} style={fill ? undefined : { aspectRatio: String(sumA.toFixed(3)) }}>
            {row.map((it, ci) => { const pid = key + "|" + it.url; const p = ctx.imgPos[pid] || { x: 50, y: 50 }; const a = cellAttrs(it.url);
              // Fit mode: render a real <img> (which sizes to the image itself) in a
              // transparent cell, so the frame HUGS the image — no dark letterbox bars.
              if (fit) return (
                <div className={"ps-grid-cell ps-grid-cell--fit" + a.cls} key={ci} draggable={false} style={{ flexGrow: it.a }}
                  onMouseDown={a.onMouseDown} onClick={a.onClick} title={a.title}>
                  <img className="ps-grid-cell-img" src={ctx.thumbU(it.url, cw(row.length))} alt="" draggable={false} />
                  {labelEl(it.url)}{reorderEls(it.url)}{zoomBtn(it.url)}{ctx.cellBadge ? ctx.cellBadge(key, it.url) : null}
                </div>
              );
              return (
              <div className={"ps-grid-cell" + a.cls} key={ci} draggable={false}
                style={{ flexGrow: fill ? 1 : it.a, backgroundImage: `url(${ctx.thumbU(it.url, cw(row.length))})`, backgroundPosition: `${p.x}% ${p.y}%` }}
                onMouseDown={a.onMouseDown} onClick={a.onClick} title={a.title}>
                {labelEl(it.url)}{reorderEls(it.url)}{zoomBtn(it.url)}{ctx.cellBadge ? ctx.cellBadge(key, it.url) : null}
              </div>
            ); })}
          </div>
        ); })}
      </div>
    );
  }

  function slideBody(s, ctx) {
    const ro = ctx.ro;
    const ed  = ro ? ((k, f, v, cls) => <div className={"pres-edit " + cls}>{v}</div>) : ctx.editable;
    const edI = ro ? ((id, key, idx, f, v, cls) => <div className={"pres-edit " + cls}>{v}</div>) : ctx.editItem;
    const grid = (imgs, pos, key, labels, opts) => renderGrid(imgs, pos, key, labels, ctx, opts);
    if (s.type === "intro" || s.type === "outro") {
      const lay = ctx.slideLayout[s.key] || "cover"; const bg = s.imgs[0] ? { backgroundImage: `url(${ctx.thumbU(s.imgs[0], 1200)})` } : undefined;
      return (
        <div className={"ps-cover lay-" + lay} style={bg}>
          <div className="ps-cover-veil" />
          <div className="ps-cover-inner">
            <div className="ps-cover-head">{ctx.compassEl()}{ed(s.key, "label", s.label, "ps-cover-eyebrow")}</div>
            {ed(s.key, "title", s.title, "ps-cover-title")}
            <div className="ps-cover-rule"><span className="ps-rule-line" /><span className="ps-rule-dot" /><span className="ps-rule-line" /></div>
            {s.type === "intro" && <div className="ps-cover-meta">{ctx.coverMeta(ctx.peekVer())}</div>}
            {ed(s.key, "body", s.body, "ps-cover-desc")}
            <div className={"ps-cover-pill" + (s.pill ? "" : " is-empty")}>{ed(s.key, "pill", s.pill, "ps-cover-pilltext")}</div>
          </div>
        </div>
      );
    } else if (s.type === "text") {
      const lay = ctx.slideLayout[s.key] || "center";
      return (<div className={"ps-text lay-" + lay}>{ed(s.key, "title", s.title, "ps-text-title")}{ed(s.key, "body", s.body, "ps-text-body")}</div>);
    } else if (s.type === "agenda") {
      return (
        <div className={"ps-agenda lay-" + ctx.layOf(s, ctx.AGENDA_LAYS, "split")}>
          <div className="ps-ag-left">
            <div className="ps-ag-head">{ctx.compassEl()}{ed(s.key, "label", s.label, "ps-overlabel")}</div>
            {ed(s.key, "title", s.title, "ps-title ps-title--lg")}
            <div className="ps-cover-rule ps-rule--block"><span className="ps-rule-line" /><span className="ps-rule-dot" /><span className="ps-rule-line" /></div>
            {ed(s.key, "body", s.body, "ps-ag-sub")}
          </div>
          <div className="ps-ag-list">
            {(s.items || []).map((it, i) => (
              <div className="ps-ag-row" key={it.iid || i}>
                <span className="ps-ag-num">{String(i + 1).padStart(2, "0")}</span>
                <span className="ps-ag-bar" />
                <div className="ps-ag-main">{edI(s.id, "items", i, "label", it.label, "ps-ag-item-label")}{edI(s.id, "items", i, "desc", it.desc, "ps-ag-item-desc")}</div>
                {!ro && <button type="button" className="ps-item-del" onClick={() => ctx.delItem(s.id, "items", i)} title="Remove">×</button>}
              </div>
            ))}
            {!ro && (s.items || []).length < 7 && <button type="button" className="ps-item-add" onClick={() => ctx.addItem(s.id, "items", { label: "New item", desc: "Description" })}>+ row</button>}
          </div>
        </div>
      );
    } else if (s.type === "columns") {
      const cols = s.columns || [];
      const lay = ctx.layOf(s, ctx.COLS_LAYS, "row");
      const colCount = lay === "cards" ? 2 : Math.min(Math.max(cols.length, 1), 4);
      return (
        <div className={"ps-cols lay-" + lay}>
          <div className="ps-cols-textcol">
            <div className="ps-ag-head">{ctx.compassEl()}{ed(s.key, "label", s.label, "ps-overlabel")}</div>
            {ed(s.key, "title", s.title, "ps-title ps-title--lg")}
            <div className="ps-cover-rule ps-rule--full"><span className="ps-rule-line" /><span className="ps-rule-dot" /><span className="ps-rule-line" /></div>
            {ed(s.key, "body", s.body, "ps-cols-intro")}
          </div>
          <div className="ps-cols-grid" style={{ gridTemplateColumns: `repeat(${colCount},1fr)` }}>
            {cols.map((c, i) => (
              <div className="ps-col" key={c.iid || i}>
                <div className="ps-col-head">{ctx.iconEl(c.icon)}{edI(s.id, "columns", i, "label", c.label, "ps-col-label")}</div>
                <span className="ps-col-rule" />
                {edI(s.id, "columns", i, "text", c.text, "ps-col-text")}
                {!ro && <button type="button" className="ps-item-del" onClick={() => ctx.delItem(s.id, "columns", i)} title="Remove">×</button>}
              </div>
            ))}
          </div>
          {!ro && cols.length < 4 && <button type="button" className="ps-item-add ps-item-add--col" onClick={() => ctx.addItem(s.id, "columns", { icon: "target", label: "New", text: "Description" })}>+ column</button>}
        </div>
      );
    } else if (s.type === "prose") {
      // "Text" slide — Columns top header (eyebrow + title + rule + intro) but the
      // body is up to 5 editable PARAGRAPH blocks (each an optional short heading +
      // a full paragraph) instead of the icon-columns grid.
      // v07zz514 — Hugo: NOT columns. Full-width paragraphs stacked one after the other
      // down the slide, under the Columns-style top header.
      const paras = s.paras || [];
      return (
        <div className="ps-prose">
          <div className="ps-cols-textcol">
            <div className="ps-ag-head">{ctx.compassEl()}{ed(s.key, "label", s.label, "ps-overlabel")}</div>
            {ed(s.key, "title", s.title, "ps-title ps-title--lg")}
            <div className="ps-cover-rule ps-rule--full"><span className="ps-rule-line" /><span className="ps-rule-dot" /><span className="ps-rule-line" /></div>
            {ed(s.key, "body", s.body, "ps-cols-intro")}
          </div>
          <div className="ps-prose-body">
            {paras.map((p, i) => (
              <div className="ps-para" key={p.iid || i}>
                {edI(s.id, "paras", i, "text", p.text, "ps-para-text")}
                {!ro && <button type="button" className="ps-item-del" onClick={() => ctx.delItem(s.id, "paras", i)} title="Remove">×</button>}
              </div>
            ))}
          </div>
          {!ro && paras.length < 6 && <button type="button" className="ps-item-add ps-item-add--col" onClick={() => ctx.addItem(s.id, "paras", { text: "New paragraph." })}>+ paragraph</button>}
        </div>
      );
    } else if (s.type === "sectiontitle") {
      // Section divider card — centred eyebrow + big title + rule + subtitle, no images.
      // v07zz514 — no eyebrow/icon row on the section divider (Hugo): just the big
      // title + rule + subtitle, centred.
      return (
        <div className="ps-sectitle">
          <div className="ps-sectitle-inner">
            {ed(s.key, "title", s.title, "ps-title ps-sectitle-title")}
            <div className="ps-cover-rule"><span className="ps-rule-line" /><span className="ps-rule-dot" /><span className="ps-rule-line" /></div>
            {ed(s.key, "body", s.body, "ps-sectitle-sub")}
          </div>
        </div>
      );
    } else if (s.type === "section") {
      const lay = ctx.layOf(s, ctx.SECTION_LAYS, "grid-right");
      return (
        <div className={"ps-section lay-" + lay}>
          <div className="ps-sec-textcol">
            <div className="ps-ag-head">{ctx.iconEl(s.icon)}{ed(s.key, "label", s.label, "ps-overlabel")}</div>
            {ed(s.key, "title", s.title, "ps-title ps-title--xl")}
            <div className="ps-cover-rule ps-rule--block"><span className="ps-rule-line" /><span className="ps-rule-dot" /><span className="ps-rule-line" /></div>
            {ed(s.key, "body", s.body, "ps-ag-sub")}
          </div>
          {lay !== "title" && <div className="ps-sec-grid">{grid(s.imgs, "none", s.key, null)}</div>}
        </div>
      );
    } else if (s.type === "retake") {
      // Asset RETAKE slide. Eyebrow is the asset-type-aware "<TYPE> RETAKE" (set in
      // the builder), big title = asset name, a role/subtitle line, and TWO
      // "what changed" lines. Two layouts: an INTRO CARD (single hero + text column)
      // and a SHOWCASE (text header + multi-image grid of the retake renders).
      const lay = ctx.layOf(s, ctx.RETAKE_LAYS || ["intro-card", "showcase"], "intro-card");
      const changes = (
        <div className="ps-rt-changes">
          <div className="ps-rt-changes-lbl">What changed</div>
          <div className="ps-rt-change">{ed(s.key, "change1", s.change1, "ps-rt-change-txt")}</div>
          <div className="ps-rt-change">{ed(s.key, "change2", s.change2, "ps-rt-change-txt")}</div>
        </div>
      );
      if (lay === "showcase") {
        return (
          <div className="ps-retake lay-showcase">
            <div className="ps-rt-head">
              <div className="ps-ag-head">{ed(s.key, "label", s.label, "ps-overlabel")}</div>
              {ed(s.key, "title", s.title, "ps-title ps-title--lg")}
              {s.sub ? ed(s.key, "sub", s.sub, "ps-splitsub") : null}
              {changes}
            </div>
            <div className="ps-rt-grid">{grid(s.imgs, "none", s.key, null)}</div>
          </div>
        );
      }
      return (
        <div className="ps-retake lay-intro-card">
          <div className="ps-rt-hero">{grid(s.imgs.slice(0, 1), "none", s.key, null)}</div>
          <div className="ps-rt-textcol">
            <div className="ps-ag-head">{ed(s.key, "label", s.label, "ps-overlabel")}</div>
            {ed(s.key, "title", s.title, "ps-title ps-title--xl")}
            <div className="ps-cover-rule ps-rule--block"><span className="ps-rule-line" /><span className="ps-rule-dot" /><span className="ps-rule-line" /></div>
            {s.sub ? ed(s.key, "sub", s.sub, "ps-splitsub") : null}
            {changes}
          </div>
        </div>
      );
    } else if (s.type === "outfit") {
      // OUTFIT SELECTION slide. A text header (eyebrow + name + role + a free
      // caption line, e.g. "Armored and Extravagant outfits") and a grid of outfit
      // images. Layouts: CAPTIONED (a text band on each image — editable inline,
      // explaining when that outfit is used), CLEAN (grid, no captions), and SPLIT
      // (header in a side column, captioned grid beside it).
      const lay = ctx.layOf(s, ctx.OUTFIT_LAYS || ["captioned", "clean", "split", "banner"], "captioned");
      const captioned = lay !== "clean" && lay !== "banner";
      // v07zz268 — BANNER 21:9: a wide image frame on top, then JUST the gold
      // eyebrow + the two bottom lines (role + caption). The big name/title is hidden.
      if (lay === "banner") {
        return (
          <div className="ps-outfit lay-banner">
            <div className="ps-of-grid ps-of-grid--banner">{grid(s.imgs, "none", s.key, null, null)}</div>
            <div className="ps-of-head ps-of-head--banner">
              <div className="ps-ag-head">{ed(s.key, "label", s.label, "ps-overlabel")}</div>
              {s.sub ? ed(s.key, "sub", s.sub, "ps-splitsub") : null}
              {ed(s.key, "caption", s.caption, "ps-of-line")}
            </div>
          </div>
        );
      }
      const head = (
        <div className="ps-of-head">
          <div className="ps-ag-head">{ed(s.key, "label", s.label, "ps-overlabel")}</div>
          {ed(s.key, "title", s.title, "ps-title ps-title--lg")}
          {s.sub ? ed(s.key, "sub", s.sub, "ps-splitsub") : null}
          {ed(s.key, "caption", s.caption, "ps-of-line")}
        </div>
      );
      return (
        <div className={"ps-outfit lay-" + lay}>
          {head}
          <div className="ps-of-grid">{grid(s.imgs, "none", s.key, null, captioned ? { caption: "band", captions: s.imgLabels } : null)}</div>
        </div>
      );
    }
    const pos = ctx.posOf(s);
    const ta = ctx.textAlignOf(s);
    return (
      <div className={"ps-compose pos-" + pos + " tah-" + ta.h + " tav-" + ta.v}>
        <div className="ps-grid-wrap">{grid(s.imgs, pos, s.key, s.imgLabels)}</div>
        {pos !== "none" && (
          <div className="ps-tb">
            <div className="ps-tb-row">{ed(s.key, "label", s.label, "ps-overlabel")}{s.statusBadge ? <span className="ps-stagebadge">{s.statusBadge}</span> : (s.idLabel ? <span className="ps-shotid">{s.idLabel}</span> : null)}</div>
            {ed(s.key, "title", s.title, "ps-title")}
            {s.sub ? ed(s.key, "sub", s.sub, "ps-splitsub") : null}
            {s.caption ? ed(s.key, "caption", s.caption, "ps-caption") : null}
            {s.badges && s.badges.length > 0 && <div className="ps-badges">{s.badges.map((b, i) => <span key={i} className="ps-badge">{b}</span>)}</div>}
          </div>
        )}
      </div>
    );
  }

  // ── Layout + icon helpers ── these are pure and self-contained. The builder
  // keeps its OWN copies (it also uses COMPASS_SVG/ICONS in template-string HTML
  // export, so they can't simply move) and feeds them into ctx; the Review deck
  // viewer has no builder around, so it uses THESE via window.PresHelpers. Both
  // produce identical DOM. autoGrid/defaultPos take getAspect so the viewer can
  // drive them from the snapshot's frozen aspect map.
  var COMPASS_SVG = '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"><circle cx="24" cy="24" r="19"/><path d="M24 7 L27 21 L24 24 L21 21 Z" fill="currentColor" stroke="none"/><path d="M24 41 L21 27 L24 24 L27 27 Z" fill="currentColor" stroke="none" opacity="0.45"/><path d="M7 24 L21 21 L24 24 L21 27 Z" fill="currentColor" stroke="none" opacity="0.65"/><path d="M41 24 L27 27 L24 24 L27 21 Z" fill="currentColor" stroke="none" opacity="0.65"/><circle cx="24" cy="24" r="2" fill="currentColor" stroke="none"/></svg>';
  var _ic = (inner) => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
  var ICONS = {
    film: _ic('<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4"/>'),
    leaf: _ic('<path d="M5 19c0-8 6-14 14-14 0 8-6 14-14 14z"/><path d="M5.5 18.5C9 15 13 11 17 9"/>'),
    people: _ic('<circle cx="9" cy="9" r="3"/><path d="M3.5 19a5.5 5.5 0 0 1 11 0"/><path d="M16 6.2a3 3 0 0 1 0 5.6"/><path d="M16.5 13.5a5.5 5.5 0 0 1 4 5.5"/>'),
    camera: _ic('<path d="M3 8.5A1.5 1.5 0 0 1 4.5 7H7l1.4-2h7.2L17 7h2.5A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5z"/><circle cx="12" cy="13" r="3.2"/>'),
    map: _ic('<path d="M9 4 4 6v14l5-2 6 2 5-2V4l-5 2-6-2z"/><path d="M9 4v14M15 6v14"/>'),
    pin: _ic('<path d="M12 21s7-5.5 7-11a7 7 0 1 0-14 0c0 5.5 7 11 7 11z"/><circle cx="12" cy="10" r="2.5"/>'),
    person: _ic('<circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/>'),
    mountains: _ic('<path d="M3 18l5-8 3.5 4.5L15 9l6 9z"/>'),
    sun: _ic('<circle cx="12" cy="12" r="4"/><path d="M12 2.5v2.5M12 19v2.5M2.5 12h2.5M19 12h2.5M5 5l1.7 1.7M17.3 17.3 19 19M19 5l-1.7 1.7M6.7 17.3 5 19"/>'),
    hourglass: _ic('<path d="M6 3h12M6 21h12M7.5 3c0 5 4.5 6 4.5 9s-4.5 4-4.5 9M16.5 3c0 5-4.5 6-4.5 9s4.5 4 4.5 9"/>'),
    target: _ic('<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="3.5"/><path d="M12 1.5v3M12 19.5v3M1.5 12h3M19.5 12h3"/>'),
    book: _ic('<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H11v15.5H5.5A1.5 1.5 0 0 0 4 21z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H13v15.5h5.5A1.5 1.5 0 0 1 20 21z"/>'),
    arrow: _ic('<path d="M4 12h14"/><path d="M13 6l6 6-6 6"/>'),
    mail: _ic('<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M4 7l8 6 8-6"/>'),
    phone: _ic('<path d="M6 3h3l2 5-2.2 1.1c1 2.6 2.5 4.1 5.1 5.1L17 13l5 2v3a2 2 0 0 1-2.2 2C11 21 4 14 4 5.2A2 2 0 0 1 6 3z"/>'),
    play: _ic('<circle cx="12" cy="12" r="9"/><path d="M10 8.2l6 3.8-6 3.8z" fill="currentColor" stroke="none"/>'),
    calendar: _ic('<rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 9.5h16M8.5 3v4M15.5 3v4"/>'),
    doc: _ic('<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/><path d="M8.5 12h7M8.5 15.5h7"/>'),
  };
  var _clampA = (a) => Math.max(0.4, Math.min(3.2, a || 1.6));
  var _areaAspectFor = (pos) => (pos === "left" || pos === "right") ? 1.21 : pos === "top" ? 2.4 : 1.778;
  var _autoGrid = (imgs, areaAspect, arrange, getAspect) => {
    const ga = getAspect || (() => 1.6);
    const items = imgs.map(u => ({ url: u, a: _clampA(ga(u)) }));
    const n = items.length; if (n <= 1) return [items];
    const distribute = (R) => { R = Math.max(1, Math.min(R, n)); const total = items.reduce((s, i) => s + i.a, 0); const target = total / R; const rows = []; let row = [], sum = 0; for (const it of items) { row.push(it); sum += it.a; if (sum >= target && rows.length < R - 1) { rows.push(row); row = []; sum = 0; } } if (row.length) rows.push(row); return rows; };
    if (arrange === "row") return distribute(1);
    if (arrange === "rows2") return distribute(2);
    if (arrange === "rows3") return distribute(3);
    if (arrange === "grid") return distribute(Math.round(Math.sqrt(n)));
    if (arrange === "featured") return n >= 2 ? [[items[0]], items.slice(1)] : [items];
    let best = null;
    for (let R = 1; R <= Math.min(4, n); R++) { const rows = distribute(R); const gAspect = 1 / rows.reduce((s, row) => s + 1 / row.reduce((x, i) => x + i.a, 0), 0); const diff = Math.abs(gAspect - (areaAspect || 1.778)); if (!best || diff < best.diff) best = { rows, diff }; }
    return best.rows;
  };
  var _defaultPos = (s, getAspect) => { const ga = getAspect || (() => 1.6); return (s.imgs.length <= 1 && ga(s.imgs[0]) >= 1.5) ? "overlay" : "left"; };

  // Build a read-only ctx for the shared renderer from a FROZEN deck snapshot.
  // Used by both the Review deck viewer (interactive: onImageClick/cellBadge) and
  // the Review-tab card thumbnail (mode:"thumb", non-interactive). Keeping this in
  // one place means viewer + thumbnail can never drift from each other.
  function buildViewCtx(deck, opts) {
    opts = opts || {};
    var maps = (deck && deck.maps) || {};
    var aspects = (deck && deck.img_aspects) || {};
    var getAspect = (u) => { var a = aspects[u]; return typeof a === "number" ? a : 1.6; };
    var rawThumbU = opts.thumbU || ((u, w) => (window.thumbUrl ? window.thumbUrl(u, w) : u));
    // hiRes (the full-size Review viewer): the 640x360 canvas is scaled UP ~2.3x, so
    // the renderer's baked-in 400/800px requests look soft — but a FLAT 1600 for every
    // image was the opposite problem: a 4-up grid cell (renders ~200px on screen) was
    // fetching a 1600px image, so Railway decks loaded a pile of oversized R2 thumbnails
    // slowly. Instead, RESPECT the renderer's per-image width and just scale it for the
    // viewer (×2), snapped to an allowed thumb width. Result: full-bleed heroes get
    // 1600 (crisp), grid cells get 800 (right-sized, ~4× less data) — "the right
    // resolution for how big they are". Lightbox zoom still requests 1600 separately.
    var HIRES_STEPS = [400, 560, 800, 1200, 1600];
    var hiResW = function (w) { var t = (w || 800) * 2; for (var i = 0; i < HIRES_STEPS.length; i++) { if (HIRES_STEPS[i] >= t) return HIRES_STEPS[i]; } return 1600; };
    var thumbU = opts.hiRes ? ((u, w) => rawThumbU(u, hiResW(w))) : rawThumbU;
    return {
      mode: opts.mode || "view", ro: opts.ro !== false,
      editable: () => null, editItem: () => null, editLabel: () => null,
      slideLayout: maps.slideLayout || {}, slideArrange: maps.slideArrange || {}, imgPos: maps.imgPos || {},
      fillOf: (key, n, pos) => { var f = maps.slideFill || {}; return f[key] !== undefined ? f[key] : (n === 1 && (pos === "overlay" || pos === "none")); },
      fitOf: (key) => !!(maps.slideFit || {})[key],
      captionOf: (key) => !!(maps.slideCaption || {})[key],
      imgCap: (key, url) => (((maps.imgCaptions || {})[key] || {})[url]) || {},
      editImgCap: () => null,
      posOf: (s) => (maps.slideTextPos || {})[s.key] || _defaultPos(s, getAspect),
      textAlignOf: (s) => { var t = (maps.slideTextAlign || {})[s.key] || {}; return { h: t.h || "left", v: t.v || "bottom" }; },
      layOf: (s, arr, def) => { var cur = (maps.slideLayout || {})[s.key] || def; return arr.includes(cur) ? cur : def; },
      AGENDA_LAYS: ["split", "stack"], COLS_LAYS: ["row", "cards"], SECTION_LAYS: ["grid-right", "grid-below", "title"],
      RETAKE_LAYS: ["intro-card", "showcase"], OUTFIT_LAYS: ["captioned", "clean", "split", "banner"],
      activeSlide: null, thumbU: thumbU,
      autoGrid: (imgs, areaAspect, arrange) => _autoGrid(imgs, areaAspect, arrange, getAspect),
      areaAspectFor: _areaAspectFor,
      compassEl: () => <span className="ps-mark" dangerouslySetInnerHTML={{ __html: COMPASS_SVG }} />,
      iconEl: (name) => <span className="ps-ico" dangerouslySetInnerHTML={{ __html: ICONS[name] || ICONS.leaf }} />,
      coverMeta: () => (deck && deck.cover_meta) || "",
      peekVer: () => 0,
      selectedUrl: opts.selectedUrl || null,
      hoveredUrl: opts.hoveredUrl || null,   // v07zz291 — note hover → ring (no glow) on its image
      onImageClick: opts.onImageClick || (() => {}),
      onImageZoom: opts.onImageZoom || null,
      imgSource: opts.imgSource || (() => null),
      cellBadge: opts.cellBadge || (() => null),
    };
  }

  window.PresHelpers = {
    COMPASS_SVG: COMPASS_SVG, ICONS: ICONS,
    clampA: _clampA,
    areaAspectFor: _areaAspectFor,
    autoGrid: _autoGrid,
    defaultPos: _defaultPos,
    compassEl: () => <span className="ps-mark" dangerouslySetInnerHTML={{ __html: COMPASS_SVG }} />,
    iconEl: (name) => <span className="ps-ico" dangerouslySetInnerHTML={{ __html: ICONS[name] || ICONS.leaf }} />,
    buildViewCtx: buildViewCtx,
  };

  window.renderPresentationGrid = renderGrid;
  window.renderPresentationSlideBody = slideBody;
})();
