/* nukki-h1/canvas.jsx — 상품배너 H1 인터랙티브 캔버스 (nukki550/canvas.jsx 완전 복사본)
   · 유형별 독립: 550 캔버스를 복사, 크기만 776×388(가로형). 컴포넌트명 *H1·엔진 window.AP_H1. 550 무접촉. */
const CW = 776, CH = 388;
const H1_LOGO_MAX_COUNT = 3;
const H1_LOGO_MAX_ROW = 388;
const H1_LOGO_GAP = 10;
const H1_LOGO_RIGHT = 756; // 776 - 우측여백 20
const logoAspectH1 = (value) => {
  const n = Number(value);
  return Number.isFinite(n) && n > 0 ? Math.max(0.25, Math.min(20, n)) : 1;
};
function logoScaleH1(value) {
  const n = Number(value);
  return Number.isFinite(n) && n > 0 ? Math.max(0.25, Math.min(4, n)) : 1;
}
function logoScaleWH1(g) {
  const n = Number(g && g.scaleW);
  if (Number.isFinite(n) && n > 0) return Math.max(0.25, Math.min(4, n));
  return logoScaleH1(g && g.scale);
}
function logoScaleHH1(g) {
  const n = Number(g && g.scaleH);
  if (Number.isFinite(n) && n > 0) return Math.max(0.25, Math.min(4, n));
  return logoScaleH1(g && g.scale);
}
/* ============================================================
   canvas.jsx — 캔버스 렌더 조각들
   · APCanvas      : 인터랙티브 H1 캔버스 (선택·드래그·리사이즈)
   · APMiniCanvas  : 후보 랭킹용 소형 미리보기 (비인터랙티브)
   · APResultCard  : 랭킹된 후보 1개를 크게 — 지표 + 조정
   ============================================================ */
const { useRef: useCR, useState: useCS, useEffect: useCE } = React;
const isModelRectH1 = (r) => r && r.role === 'model';
const isCornerModelH1 = (r) => r && r.id === '__modelCorner';
function normalizeCornerModelH1(r) {
  if (!isCornerModelH1(r)) return r;
  const w = Math.max(30, Math.min(CW, Number(r.w) || 30));
  const h = Math.max(30, Math.min(CH, Number(r.h) || 30));
  return { ...r, w, h, x: Math.max(0, Math.min(CW - w, Number(r.x) || 0)), y: Math.max(0, Math.min(CH - h, Number(r.y) || 0)) };
}

// H1 여백 가이드라인 오버레이 — 회색 밴드 + 헤어라인 + 레이블 (pointer-events: none)
function SafeGuideOverlayH1({ sx, sy, cw, ch }) {
  const band = { position: 'absolute', background: 'rgba(0,0,0,0.14)', pointerEvents: 'none' };
  const lbl = { position: 'absolute', fontSize: 10.5, fontWeight: 700, fontFamily: 'monospace', color: 'rgba(78,76,219,0.85)', pointerEvents: 'none' };
  const safeW = cw - sx * 2, safeH = ch - sy * 2;
  return (
    <>
      <div style={{ ...band, left: 0, top: 0, width: cw, height: sy }} />
      <div style={{ ...band, left: 0, top: ch - sy, width: cw, height: sy }} />
      <div style={{ ...band, left: 0, top: sy, width: sx, height: safeH }} />
      <div style={{ ...band, left: cw - sx, top: sy, width: sx, height: safeH }} />
      <div style={{ position: 'absolute', left: sx, top: sy, width: safeW, height: safeH, outline: '1px solid rgba(78,76,219,0.55)', pointerEvents: 'none' }} />
      <span style={{ ...lbl, right: Math.floor(sx / 2) - 8, top: Math.floor(ch / 2) - 7 }}>{sx}</span>
      <span style={{ ...lbl, left: Math.floor(cw / 2) - 8, top: Math.floor(sy / 2) - 7 }}>{sy}</span>
    </>
  );
}

function APStageH1({ rects, dispW, editable, selectedId, onSelect, onChange, faintSafe, bounds, scorer, onBegin, logos, deco, decoPos, onDecoPosChange, onDecoScale, decoSelected, onSelectDeco, noReflow = true, onDeleteItem, guideOn = true, lockPositionResizeOnly = false, allowModelResize = false, imageAdjust, imageAdjustBySource, onSelectionChange }) {
  const scale = dispW / CW;
  const drag = useCR(null);
  const decoDrag = useCR(null);
  const decoResize = useCR(null);
  const groupDrag = useCR(null);
  const wrapRef = useCR(null);
  const deleteGuardRef = useCR({ key: null, at: 0 });
  const [menu, setMenu] = useCS(null);
  const [multiIds, _setMultiIds] = useCS([]);
  const [resizeModeId, setResizeModeId] = useCS(null);
  const multiIdsRef = useCR([]);
  rects = (rects || []).map(normalizeCornerModelH1);

  const setMultiIds = (v) => {
    const next = typeof v === 'function' ? v(multiIdsRef.current) : v;
    multiIdsRef.current = next;
    _setMultiIds(next);
    onSelectionChange && onSelectionChange(next.slice());
  };

  useCE(() => { if (selectedId === null) { setMultiIds([]); setResizeModeId(null); } }, [selectedId]);

  const onItemDown = (e, idx, mode) => {
    if (!editable || e.button === 2) return;
    e.stopPropagation(); e.preventDefault();
    setMenu(null);
    const r = rects[idx];
    const id = r.id;
    const resizeMode = mode !== 'move';

    if (e.shiftKey || e.metaKey || e.ctrlKey) {
      setMultiIds((prev) => prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]);
      onSelect && onSelect(id);
      return;
    }

    const curIds = multiIdsRef.current;
    if (lockPositionResizeOnly && mode === 'move') {
      if (!(curIds.length >= 2 && curIds.includes(id))) setMultiIds([id]);
      onSelect && onSelect(id);
      return;
    }
    if (mode === 'move' && curIds.length >= 2 && curIds.includes(id)) {
      const selRects = rects.filter((r2) => curIds.includes(r2.id));
      onBegin && onBegin();
      groupDrag.current = { mode: 'move', startX: e.clientX, startY: e.clientY, origRects: selRects.map((r2) => ({ ...r2 })) };
      window.addEventListener('pointermove', onGroupMove);
      window.addEventListener('pointerup', onGroupUp);
      return;
    }

    setMultiIds([id]);
    onSelect && onSelect(id);
    if (resizeMode && isModelRectH1(r) && !allowModelResize && resizeModeId !== id) return;
    onBegin && onBegin();
    drag.current = { idx, mode, startX: e.clientX, startY: e.clientY, orig: { ...r } };
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
  };

  const onMove = (e) => {
    const dr = drag.current; if (!dr) return;
    const dx = (e.clientX - dr.startX) / scale;
    const dy = (e.clientY - dr.startY) / scale;
    const next = rects.map((r) => ({ ...r }));
    const t = next[dr.idx]; const o = dr.orig;
    if (dr.mode === 'move') { t.x = o.x + dx; t.y = e.shiftKey ? o.y : o.y + dy; } else if (dr.mode === 'rotate') {
      const cx = o.x + o.w / 2;
      const cy = o.y + o.h / 2;
      const a0 = Math.atan2((dr.startY - dr.stageTop) / scale - cy, (dr.startX - dr.stageLeft) / scale - cx);
      const a1 = Math.atan2((e.clientY - dr.stageTop) / scale - cy, (e.clientX - dr.stageLeft) / scale - cx);
      t.rotate = Math.round((((o.rotate || 0) + (a1 - a0) * 180 / Math.PI) * 10)) / 10;
    } else {
      const a = o.aspect || o.w / o.h; const c = dr.mode;
      let w = Math.max(30, o.w + (c === 'se' || c === 'ne' ? dx : -dx));
      const h = w / a;
      t.w = w; t.h = h;
      if (lockPositionResizeOnly) {
        const cx = o.x + o.w / 2;
        const cy = o.y + o.h / 2;
        t.x = cx - w / 2;
        t.y = cy - h / 2;
      } else {
        t.x = c === 'nw' || c === 'sw' ? o.x + (o.w - w) : o.x;
        t.y = c === 'nw' || c === 'ne' ? o.y + (o.h - h) : o.y;
      }
    }
    if (isCornerModelH1(t)) Object.assign(t, normalizeCornerModelH1(t));
    if (drag.current) drag.current.last = next;
    onChange && onChange(next);
  };

  const onUp = () => {
    const dr = drag.current;
    if (dr && dr.last && editable && bounds && window.AP_H1) {
      const draggedIsModel = rects[dr.idx] && rects[dr.idx].role === 'model';
      if (noReflow || draggedIsModel) { onChange && onChange(dr.last); } else {
        const isMove = dr.mode === 'move';
        const movedId = rects[dr.idx] ? rects[dr.idx].id : null;
        const models = dr.last.filter((r) => r.role === 'model');
        const others = dr.last.filter((r) => r.role !== 'model');
        const out = isMove ?
          (window.AP_H1.reflowMove ? window.AP_H1.reflowMove(others, bounds, scorer, movedId) : others)
          : (window.AP_H1.reflow ? window.AP_H1.reflow(others, bounds, scorer, null) : others);
        onChange && onChange((out || others).concat(models));
      }
    }
    drag.current = null;
    window.removeEventListener('pointermove', onMove);
    window.removeEventListener('pointerup', onUp);
  };

  const onGroupMove = (e) => {
    const gd = groupDrag.current; if (!gd) return;
    const dx = (e.clientX - gd.startX) / scale;
    const dy = (e.clientY - gd.startY) / scale;
    const next = rects.map((r) => ({ ...r }));
    if (gd.mode === 'move') {
      gd.origRects.forEach((orig) => {
        const t = next.find((r) => r.id === orig.id);
        if (t) { t.x = orig.x + dx; t.y = e.shiftKey ? orig.y : orig.y + dy; }
      });
    } else if (gd.mode === 'resize') {
      const origDist = Math.sqrt(gd.hW * gd.hW + gd.hH * gd.hH);
      const newHW = gd.hW + dx, newHH = gd.hH + dy;
      const newDist = Math.sqrt(newHW * newHW + newHH * newHH);
      const sf = origDist > 0 ? Math.max(0.1, newDist / origDist) : 1;
      gd.origRects.forEach((orig) => {
        const t = next.find((r) => r.id === orig.id);
        if (t) {
          const oCx = orig.x + orig.w / 2;
          const oCy = orig.y + orig.h / 2;
          t.w = Math.max(20, orig.w * sf);
          t.h = Math.max(20, orig.h * sf);
          t.x = gd.cx + (oCx - gd.cx) * sf - t.w / 2;
          t.y = gd.cy + (oCy - gd.cy) * sf - t.h / 2;
        }
      });
    }
    if (groupDrag.current) groupDrag.current.last = next;
    onChange && onChange(next);
  };

  const onGroupUp = () => {
    const gd = groupDrag.current;
    if (gd && gd.last) onChange && onChange(gd.last);
    groupDrag.current = null;
    window.removeEventListener('pointermove', onGroupMove);
    window.removeEventListener('pointerup', onGroupUp);
  };

  const onGroupHandleDown = (e) => {
    if (!editable) return;
    e.stopPropagation(); e.preventDefault();
    const curIds = multiIdsRef.current;
    if (curIds.length < 2) return;
    const selRects = rects.filter((r) => curIds.includes(r.id));
    if (selRects.some(isModelRectH1)) return;
    let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
    selRects.forEach((r) => { minX = Math.min(minX, r.x); minY = Math.min(minY, r.y); maxX = Math.max(maxX, r.x + r.w); maxY = Math.max(maxY, r.y + r.h); });
    onBegin && onBegin();
    groupDrag.current = {
      mode: 'resize',
      startX: e.clientX, startY: e.clientY,
      cx: (minX + maxX) / 2, cy: (minY + maxY) / 2,
      hW: (maxX - minX) / 2, hH: (maxY - minY) / 2,
      origRects: selRects.map((r) => ({ ...r })),
    };
    window.addEventListener('pointermove', onGroupMove);
    window.addEventListener('pointerup', onGroupUp);
  };

  const openMenu = (e, idx) => {
    if (!editable) return;
    e.preventDefault(); e.stopPropagation();
    onSelect && onSelect(rects[idx].id);
    setMenu({ x: e.clientX, y: e.clientY, idx });
  };
  const onRotateDown = (e, idx) => {
    if (!editable || e.button === 2) return;
    e.stopPropagation(); e.preventDefault();
    setMenu(null);
    const r = rects[idx];
    if (!r) return;
    setMultiIds([r.id]);
    onSelect && onSelect(r.id);
    onBegin && onBegin();
    const stage = wrapRef.current ? wrapRef.current.getBoundingClientRect() : { left: 0, top: 0 };
    drag.current = { idx, mode: 'rotate', startX: e.clientX, startY: e.clientY, stageLeft: stage.left, stageTop: stage.top, orig: { ...r } };
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
  };
  const openMenuAtPoint = (e) => {
    if (!editable) return;
    e.preventDefault(); e.stopPropagation();
    const stage = e.currentTarget.getBoundingClientRect();
    const x = (e.clientX - stage.left) / scale;
    const y = (e.clientY - stage.top) / scale;
    const hit = rects
      .map((r, idx) => ({ r, idx }))
      .filter(({ r }) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h)
      .sort((a, b) => (b.r.z || 0) - (a.r.z || 0))[0];
    if (!hit) return;
    onSelect && onSelect(hit.r.id);
    setMenu({ x: e.clientX, y: e.clientY, idx: hit.idx });
  };

  const changeZ = (idx, dir) => {
    const next = rects.map((r) => ({ ...r }));
    const zs = next.map((r) => r.z);
    next[idx].z = dir === 'front' ? Math.max.apply(null, zs) + 1 : Math.min.apply(null, zs) - 1;
    onBegin && onBegin();
    onChange && onChange(next);
    setMenu(null);
  };

  useCE(() => {
    if (!menu) return;
    const close = () => setMenu(null);
    window.addEventListener('pointerdown', close);
    return () => window.removeEventListener('pointerdown', close);
  }, [menu]);

  useCE(() => {
    if (!editable) return;
    const onKey = (e) => {
      if (e.key === 'Escape') { setMultiIds([]); onSelect && onSelect(null); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [editable]);

  useCE(() => {
    if (!editable || selectedId == null) return;
    if (lockPositionResizeOnly) return;
    const onKey = (e) => {
      if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].indexOf(e.key) < 0) return;
      e.preventDefault();
      onBegin && onBegin();
      const step = e.shiftKey ? 10 : 1;
      const next = rects.map((r) => ({ ...r }));
      const curIds = multiIdsRef.current;
      const targets = curIds.length >= 2 ? curIds : [selectedId];
      targets.forEach((tid) => {
        const t = next.find((r) => r.id === tid);
        if (!t) return;
        if (e.key === 'ArrowUp') t.y -= step;
        else if (e.key === 'ArrowDown') t.y += step;
        else if (e.key === 'ArrowLeft') t.x -= step;
        else if (e.key === 'ArrowRight') t.x += step;
      });
      let reflowed = next;
      if (!noReflow && curIds.length < 2) {
        const moved = next.find((r) => r.id === selectedId);
        if (moved && moved.role !== 'model' && window.AP_H1 && window.AP_H1.reflowMove && bounds) {
          const models = next.filter((r) => r.role === 'model'), others = next.filter((r) => r.role !== 'model');
          reflowed = window.AP_H1.reflowMove(others, bounds, scorer, selectedId).concat(models);
        }
      }
      onChange && onChange(reflowed);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [editable, selectedId, rects, bounds, onChange, scorer, onBegin, noReflow, lockPositionResizeOnly]);

  const groupBbox = (() => {
    if (multiIds.length < 2) return null;
    let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
    let hasModel = false;
    rects.forEach((r) => {
      if (multiIds.includes(r.id)) {
        if (isModelRectH1(r)) hasModel = true;
        minX = Math.min(minX, r.x); minY = Math.min(minY, r.y);
        maxX = Math.max(maxX, r.x + r.w); maxY = Math.max(maxY, r.y + r.h);
      }
    });
    return minX < 1e9 ? { x: minX, y: minY, w: maxX - minX, h: maxY - minY, hasModel } : null;
  })();

  const ordered = rects.map((r, i) => ({ r, i })).sort((a, b) => a.r.z - b.r.z);
  const selectedChrome = (() => {
    if (!editable || selectedId == null || multiIds.length >= 2) return null;
    const idx = rects.findIndex((r) => r.id === selectedId);
    if (idx < 0) return null;
    const r = rects[idx];
    const showHandles = !isModelRectH1(r) || allowModelResize || resizeModeId === r.id;
    if (!showHandles) return null;
    return { r, idx };
  })();
  const stopDeleteEvent = (e) => {
    e.preventDefault(); e.stopPropagation();
    if (e.nativeEvent && e.nativeEvent.stopImmediatePropagation) e.nativeEvent.stopImmediatePropagation();
  };
  const deleteFromButton = (e, r) => {
    stopDeleteEvent(e);
    const key = (r.id || '') + '|' + (r.src || r.url || '');
    const now = Date.now();
    if (deleteGuardRef.current.key === key && now - deleteGuardRef.current.at < 300) return;
    deleteGuardRef.current = { key, at: now };
    onDeleteItem && onDeleteItem(r);
  };
  const deleteFromMenu = (e) => {
    const r = menu && rects[menu.idx];
    if (!r) return;
    deleteFromButton(e, r);
    setMenu(null);
  };
  const resizeFromMenu = (e) => {
    stopDeleteEvent(e);
    const r = menu && rects[menu.idx];
    if (!r) return;
    setMultiIds([r.id]);
    onSelect && onSelect(r.id);
    setResizeModeId(r.id);
    setMenu(null);
  };
  const CORNERS = ['nw', 'ne', 'sw', 'se'];
  const clampDecoPos = (p, d) => window.H1DecoPlacement
    ? window.H1DecoPlacement.clamp(p, d)
    : (() => { const x = Number(p && p.x), y = Number(p && p.y); return { x: Math.max(0, Math.min(CW - (d.w || 90), Number.isFinite(x) ? x : 0)), y: Math.max(0, Math.min(CH - (d.h || 90), Number.isFinite(y) ? y : 0)) }; })();
  const onDecoDown = (e, pos) => {
    if (!editable || !deco || !onDecoPosChange || e.button === 2) return;
    e.stopPropagation(); e.preventDefault();
    onSelectDeco && onSelectDeco();
    decoDrag.current = { startX: e.clientX, startY: e.clientY, orig: pos };
    window.addEventListener('pointermove', onDecoMove);
    window.addEventListener('pointerup', onDecoUp);
  };
  const onDecoMove = (e) => {
    const dr = decoDrag.current; if (!dr || !deco) return;
    onDecoPosChange(clampDecoPos({ x: dr.orig.x + (e.clientX - dr.startX) / scale, y: dr.orig.y + (e.clientY - dr.startY) / scale }, deco));
  };
  const onDecoUp = () => {
    decoDrag.current = null;
    window.removeEventListener('pointermove', onDecoMove);
    window.removeEventListener('pointerup', onDecoUp);
  };
  const onDecoResizeDown = (e) => {
    if (!editable || !deco || !onDecoScale || e.button === 2) return;
    e.stopPropagation(); e.preventDefault();
    onSelectDeco && onSelectDeco();
    decoResize.current = { sx: e.clientX, sy: e.clientY, scale: Number(deco.scale) || 1 };
    const mv = (ev) => {
      const dr = decoResize.current; if (!dr) return;
      onDecoScale(Math.max(0.5, Math.min(2.4, +(dr.scale + ((ev.clientX - dr.sx) + (ev.clientY - dr.sy)) / 180).toFixed(3))));
    };
    const up = () => { decoResize.current = null; window.removeEventListener('pointermove', mv); window.removeEventListener('pointerup', up); };
    window.addEventListener('pointermove', mv); window.addEventListener('pointerup', up);
  };

  const decoEls = (() => {
    // H1 가이드: 상20·우20·간격10·maxRow=388(50%)
    const out = []; let logoBottom = 20;
    if (logos && logos.length) {
      const arr = logos.slice(0, H1_LOGO_MAX_COUNT).map((g) => { const baseH = g.orient === 'h' ? 20 : 60; return { url: g.url, baseH, baseW: baseH * logoAspectH1(g.aspect), src: g }; });
      const gap = H1_LOGO_GAP;
      const totalW = arr.reduce((s, l) => s + l.baseW, 0) + gap * (arr.length - 1);
      const maxRow = H1_LOGO_MAX_ROW; const k = totalW > maxRow ? maxRow / totalW : 1;
      const minX = H1_LOGO_RIGHT - maxRow;
      const kGap = gap * k;
      const rightEdges = new Array(arr.length);
      let xR = H1_LOGO_RIGHT;
      for (let i = arr.length - 1; i >= 0; i--) {
        rightEdges[i] = xR;
        xR = xR - arr[i].baseW * k - kGap;
      }
      const dims = arr.map((l, i) => {
        let w = l.baseW * k * logoScaleWH1(l.src);
        const h = l.baseH * k * logoScaleHH1(l.src);
        const maxW = rightEdges[i] - minX;
        if (w > maxW && maxW > 0) w = maxW;
        if (w < 1) w = 1;
        return { w, h };
      });
      const rowH = Math.max.apply(null, dims.map((l) => l.h));
      const top = 20;
      for (let i = arr.length - 1; i >= 0; i--) { const w = dims[i].w, h = dims[i].h; const x = rightEdges[i] - w; out.push(<img key={'lg' + i} src={arr[i].url} alt="" draggable={false} style={{ position: 'absolute', left: x, top: top + (rowH - h) / 2, width: w, height: h, objectFit: 'contain', pointerEvents: 'none', zIndex: 4000 }} />); }
      logoBottom = top + rowH;
    }
    if (deco && window.DecoBadge) {
      const def = window.H1DecoPlacement
        ? window.H1DecoPlacement.defaultPosition(deco, logos && logos.length ? logoBottom : null)
        : (deco.kind === 'image'
          ? { x: CW - 20 - (deco.w || 90), y: CH - 20 - (deco.h || 90) }
          : { x: CW - 20 - (deco.w || 90), y: logos && logos.length ? logoBottom + 30 : 20 });
      const pos = clampDecoPos(decoPos && typeof decoPos === 'object' ? decoPos : def, deco);
      const ds = Number(deco.scale) || 1;
      const bw = deco.baseW || (deco.w || 90) / ds;
      const bh = deco.baseH || (deco.h || 90) / ds;
      out.push(<div key="deco" data-deco-overlay="h1" onPointerDown={(e) => onDecoDown(e, pos)} style={{ position: 'absolute', left: pos.x, top: pos.y, width: deco.w || bw * ds, height: deco.h || bh * ds, zIndex: 4000, pointerEvents: editable && onDecoPosChange ? 'auto' : 'none', cursor: editable && onDecoPosChange ? 'move' : 'default', touchAction: 'none', userSelect: 'none', outline: decoSelected ? '1.5px solid #4E4CDB' : 'none', outlineOffset: 3 }}>
        <div style={{ width: bw, height: bh, transform: `scale(${ds})`, transformOrigin: 'top left' }}>{React.createElement(window.DecoBadge, { deco })}</div>
        {decoSelected && editable && onDecoScale && <span data-deco-resize="h1" title="부가정보 크기 조절" onPointerDown={onDecoResizeDown} style={{ position: 'absolute', right: -8, bottom: -8, width: 15, height: 15, border: '2px solid #fff', borderRadius: 2, background: '#4E4CDB', cursor: 'nwse-resize', boxShadow: '0 1px 5px rgba(0,0,0,.18)' }} />}
      </div>);
    }
    return out.length ? out : null;
  })();

  // H1 세이프 값 (엔진에서 읽거나 기본값 사용)
  const h1Safe = (window.AP_H1 && window.AP_H1.SAFE) || { x: 50, y: 50 };
  const menuPortal = menu && editable && document.body ? (() => {
    const w = 168;
    const h = onDeleteItem ? 150 : 114;
    const left = Math.max(8, Math.min(window.innerWidth - w - 8, menu.x));
    const top = Math.max(8, Math.min(window.innerHeight - h - 8, menu.y));
    return ReactDOM.createPortal(
      <div className="ap-ctx" style={{ position: 'fixed', left, top, zIndex: 100000 }} onPointerDown={(e) => e.stopPropagation()}>
        <button className="ap-ctx-item" onPointerDown={() => changeZ(menu.idx, 'front')}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5M5 12l7-7 7 7" /></svg>앞으로
        </button>
        <button className="ap-ctx-item" onPointerDown={() => changeZ(menu.idx, 'back')}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12l7 7 7-7" /></svg>뒤로
        </button>
        <button className="ap-ctx-item" onPointerDown={resizeFromMenu}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 9V4h5" /><path d="M20 15v5h-5" /><path d="M4 4l6 6" /><path d="M20 20l-6-6" /></svg>사이즈 변경
        </button>
        {onDeleteItem && (
          <button className="ap-ctx-item" onPointerDown={deleteFromMenu}>
            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /></svg>삭제
          </button>
        )}
      </div>,
      document.body
    );
  })() : null;

  return (
    <div className="ap-stage-wrap" ref={wrapRef} style={{ width: dispW, height: dispW * CH / CW, margin: '0px', borderStyle: 'none', borderColor: 'rgb(204, 204, 204)', borderRadius: '0px' }}>
      <div className="ap-stage" style={{ width: CW, height: CH, transform: `scale(${scale})` }}
        onContextMenu={openMenuAtPoint}
        onPointerDown={(e) => { if (editable && e.button !== 2) { onSelect && onSelect(null); setMultiIds([]); setMenu(null); } }}>
        <div className="ap-canvas-bg" style={{ borderStyle: 'none' }} />
        {ordered.map(({ r, i }) => {
          const sel = selectedId === r.id || multiIds.includes(r.id);
          const showHandles = sel && editable && multiIds.length < 2 && (!isModelRectH1(r) || allowModelResize || resizeModeId === r.id);
          return (
            <div key={r.id} className={'ap-item' + (sel ? ' sel' : '') + (editable ? ' edit' : '')}
              style={{ left: r.x, top: r.y, width: r.w, height: r.h, zIndex: r.z + 1000, cursor: editable && lockPositionResizeOnly ? 'default' : undefined, transform: r.rotate ? `rotate(${r.rotate}deg)` : undefined, transformOrigin: '50% 50%' }}
              onPointerDown={(e) => onItemDown(e, i, 'move')}
              onContextMenu={(e) => openMenu(e, i)}>
              <img src={r.url} alt="" draggable={false} style={{ ...(r.fit === 'cover' ? { objectFit: 'cover', objectPosition: r.objectPosition || '50% 0%' } : (r.id === '__modelCorner' ? { objectPosition: 'right top' } : {})), ...((window.BannerImageTools && window.BannerImageTools.imageStyle(window.BannerImageTools.forSource ? window.BannerImageTools.forSource(imageAdjust, imageAdjustBySource, r.src || r.url) : imageAdjust)) || {}) }} />
              {showHandles && CORNERS.map((c) =>
                <span key={c} className={'ap-handle ' + c} style={{ transform: `scale(${1 / scale})` }} onPointerDown={(e) => onItemDown(e, i, c)} />
              )}
              {editable && onDeleteItem && !sel && (
                <button
                  type="button"
                  title="이미지 삭제"
                  aria-label="이미지 삭제"
                  onPointerDown={stopDeleteEvent}
                  onMouseDown={stopDeleteEvent}
                  onPointerUp={(e) => deleteFromButton(e, r)}
                  onClick={(e) => deleteFromButton(e, r)}
                  style={{ position: 'absolute', top: 0, right: 0, minWidth: 22, minHeight: 22, width: Math.max(22, Math.round(20 / scale)), height: Math.max(22, Math.round(20 / scale)), background: 'rgba(28,28,34,.82)', border: 'none', borderRadius: '0 0 0 4px', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, zIndex: 9500, lineHeight: 1, pointerEvents: 'auto', touchAction: 'none' }}
                >
                  <svg width={Math.round(9 / scale)} height={Math.round(9 / scale)} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
                </button>
              )}
            </div>
          );
        })}

        {selectedChrome && (
          <div
            data-h1-selection-tool="nukki"
            style={{ position: 'absolute', left: selectedChrome.r.x, top: selectedChrome.r.y, width: selectedChrome.r.w, height: selectedChrome.r.h, outline: '1.5px solid #4E4CDB', pointerEvents: 'none', zIndex: 9800, boxSizing: 'border-box', transform: selectedChrome.r.rotate ? `rotate(${selectedChrome.r.rotate}deg)` : undefined, transformOrigin: '50% 50%' }}
          >
            <span
              data-h1-rotate-handle="true"
              title="드래그로 각도 조절"
              style={{ position: 'absolute', left: -8, top: -8, width: 14, height: 14, borderRadius: '50%', background: '#fff', border: '2px solid #4E4CDB', boxShadow: '0 1px 4px rgba(0,0,0,.18)', cursor: 'grab', pointerEvents: 'auto', zIndex: 9802, transform: `scale(${1 / scale})`, transformOrigin: 'center' }}
              onPointerDown={(e) => onRotateDown(e, selectedChrome.idx)}
            />
            {CORNERS.map((c) => (
              <span
                key={c}
                className={'ap-handle ' + c}
                style={{ transform: `scale(${1 / scale})`, pointerEvents: 'auto', zIndex: 9801 }}
                onPointerDown={(e) => onItemDown(e, selectedChrome.idx, c)}
              />
            ))}
          </div>
        )}

        {editable && onDeleteItem && selectedId != null && multiIds.length < 2 && (() => {
          const layerIdx = rects.findIndex((r) => r.id === selectedId);
          if (layerIdx < 0) return null;
          const r = rects[layerIdx];
          const btn = Math.max(22, Math.round(20 / scale));
          const left = Math.max(0, Math.min(CW - btn, r.x + r.w - btn));
          const top = Math.max(0, Math.min(CH - btn, r.y));
          return (
            <button
              type="button"
              title="이미지 삭제"
              aria-label="선택 이미지 삭제"
              onPointerDown={stopDeleteEvent}
              onMouseDown={stopDeleteEvent}
              onPointerUp={(e) => deleteFromButton(e, r)}
              onClick={(e) => deleteFromButton(e, r)}
              style={{ position: 'absolute', left, top, minWidth: 22, minHeight: 22, width: btn, height: btn, background: 'rgba(28,28,34,.88)', border: 'none', borderRadius: '0 0 0 4px', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, zIndex: 9801, lineHeight: 1, pointerEvents: 'auto', touchAction: 'none' }}
            >
              <svg width={Math.round(9 / scale)} height={Math.round(9 / scale)} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
            </button>
          );
        })()}

        {groupBbox && editable && !groupBbox.hasModel && !lockPositionResizeOnly && (
          <div style={{ position: 'absolute', left: groupBbox.x - 3, top: groupBbox.y - 3, width: groupBbox.w + 6, height: groupBbox.h + 6, border: '1.5px dashed #4E4CDB', borderRadius: 3, pointerEvents: 'none', zIndex: 8900, boxSizing: 'border-box' }}>
            <span
              style={{ position: 'absolute', right: -5, bottom: -5, width: 10, height: 10, background: '#fff', border: '1.5px solid #4E4CDB', borderRadius: 2, cursor: 'nwse-resize', pointerEvents: 'auto', zIndex: 8901, transform: `scale(${1 / scale})`, transformOrigin: 'bottom right' }}
              onPointerDown={onGroupHandleDown}
            />
          </div>
        )}

        {!faintSafe && guideOn && (
          <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 9000 }}>
            <SafeGuideOverlayH1 sx={h1Safe.x} sy={h1Safe.y} cw={CW} ch={CH} />
          </div>
        )}

        {(faintSafe || !guideOn) && (() => {
          const sf = (window.AP_H1 && window.AP_H1.SAFE) || { x: 50, y: 50 };
          return (
            <div className={'ap-safe' + (faintSafe ? ' faint' : '')} style={{ left: sf.x, top: sf.y, right: sf.x, bottom: sf.y }}>
              {!faintSafe && <span className="ap-safe-tag">SAFE {Math.round(CW - sf.x * 2)} × {Math.round(CH - sf.y * 2)}</span>}
            </div>
          );
        })()}
        {decoEls}
      </div>
      {menuPortal}
    </div>
  );
}

function APMetricRowH1({ k, v, tone, strong }) {
  return (
    <div className={'ap-mrow' + (strong ? ' strong' : '')}>
      <span className="ap-mk">{k}</span>
      <span className={'ap-mv' + (tone ? ' t-' + tone : '')}>{v}</span>
    </div>
  );
}

const toneColor = { good: '#0E9F6E', ok: '#3A7BD5', warn: '#D98324', bad: '#D64545' };

function APResultCardH1({ cand, rects, metrics, rank, primary, selectedId, onSelect, onChange, onReset, onApplyGuide, onMakePrimary, onDownload, count, scorer, onUndo, canUndo, onRedo, canRedo, onBegin, logos, deco, decoPos, onDecoPosChange, onDecoScale, decoSelected, onSelectDeco, dispW, onDeleteItem, guideOn = true, imageAdjust, imageAdjustBySource, onSelectionChange }) {
  const grade = window.AP_H1.recognitionGrade(metrics.exposure);
  const occWarn = metrics.occlusion > 22;
  const NP = window.NukkiPattern;
  const patName = NP && NP.labelOf(cand);
  return (
    <div className={'ap-card' + (primary ? ' active' : '')}>
      <div className="ap-card-head">
        <span className={'ap-rank r' + rank}>{rank}순위</span>
        <span className="ap-card-titles">
          <span className="ap-card-name">{patName || cand.strategyLabel}</span>
          <span className="ap-card-desc">{cand.rowsLabel} · 점수 {cand.scoring.score.toFixed(2)}</span>
        </span>
        {rank === 1 && <span className="ap-rec-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>스코어러 추천</span>}
      </div>

      <APStageH1 rects={rects} dispW={dispW} editable={primary} selectedId={primary ? selectedId : null} onSelect={onSelect} onChange={onChange} bounds={cand.bounds} scorer={scorer} onBegin={onBegin} noReflow logos={logos} deco={deco} decoPos={decoPos} onDecoPosChange={primary ? onDecoPosChange : null} onDecoScale={primary ? onDecoScale : null} decoSelected={primary ? decoSelected : false} onSelectDeco={primary ? onSelectDeco : null} onDeleteItem={primary ? onDeleteItem : null} allowModelResize={primary} guideOn={guideOn} imageAdjust={imageAdjust} imageAdjustBySource={imageAdjustBySource} onSelectionChange={primary ? onSelectionChange : null} />

      {primary && (
        <p style={{ margin: '4px 0 0', fontSize: 11, color: '#999', textAlign: 'center', lineHeight: 1.4 }}>
          Shift+클릭으로 여러 제품 선택 후 핸들로 함께 확대
        </p>
      )}

      <window.APMetFold recline={<span style={{ display: 'inline-flex', alignItems: 'center', gap: '7px', color: toneColor[grade.tone], fontSize: '13px', fontWeight: 700, letterSpacing: '-.01em' }}><span className="ap-dot" style={{ background: toneColor[grade.tone] }} />{grade.label} — 유효 노출 기준</span>}>
        <APMetricRowH1 k="실루엣 적합도" v={metrics.silhouette != null ? Math.round(metrics.silhouette * 100) + '%' : '—'} tone={metrics.silhouette != null ? (metrics.silhouette >= 0.85 ? 'good' : (metrics.silhouette < 0.66 ? 'warn' : null)) : null} strong />
        <APMetricRowH1 k="유효 노출" v={metrics.exposure + '%'} tone={grade.tone} />
        <APMetricRowH1 k="제품 가려짐" v={metrics.occlusion + '%'} tone={occWarn ? 'warn' : null} />
        <APMetricRowH1 k="여백률" v={metrics.whitespace + '%'} tone={metrics.whitespace > 38 ? 'warn' : (metrics.whitespace <= 28 ? 'good' : null)} />
        <APMetricRowH1 k="세이프 밀도" v={metrics.density + '%'} />
        <APMetricRowH1 k="배치 유형" v={patName || cand.strategyLabel} />
        <APMetricRowH1 k="행 구성" v={cand.rowsLabel} />
        <APMetricRowH1 k="겹친 쌍 / 최대" v={metrics.pairs + '쌍 · ' + metrics.maxPair + '%'} />
      </window.APMetFold>

      <div className="ap-card-actions">
        {primary ?
          <>
            <button className="ap-btn icon" title="되돌리기 (⌘Z)" onClick={onUndo} disabled={!canUndo}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M9 14L4 9l5-5" /><path d="M4 9h11a5 5 0 0 1 0 10h-1" /></svg></button>
            <button className="ap-btn icon" title="다시 실행 (⇧⌘Z)" onClick={onRedo} disabled={!canRedo}><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M15 14l5-5-5-5" /><path d="M20 9H9a5 5 0 0 0 0 10h1" /></svg></button>
            <button className="ap-btn ghost" onClick={onReset}>배치 초기화</button>
            {onApplyGuide && <button className="ap-btn ghost" onClick={onApplyGuide}>규칙 적용</button>}
          </> :
          <button className="ap-btn ghost block" onClick={onMakePrimary}>이 후보로 조정하기</button>
        }
      </div>
    </div>
  );
}

function APMiniCanvasH1({ cand, rank, active, onClick, onHide }) {
  const top2 = rank <= 2;
  const NP = window.NukkiPattern;
  const patName = NP && NP.labelOf(cand);
  return (
    <div className={'ap-mini' + (active ? ' active' : '') + (top2 ? ' top2' : '')}>
      <button className="ap-mini-btn" onClick={onClick} title={(patName || cand.strategyLabel) + ' · ' + cand.rowsLabel}>
        <div className="ap-mini-canvas" style={{ height: 50 }}>
          <APStageH1 rects={cand.rects} dispW={100} editable={false} faintSafe />
        </div>
        <div className="ap-mini-meta">
          <span className="ap-mini-rank">{rank}</span>
          <span className="ap-mini-score">{cand.scoring.score.toFixed(2)}</span>
        </div>
        {patName && <div style={{ fontSize: '10px', fontWeight: 700, color: '#6b6b78', padding: '0 2px 4px', lineHeight: 1.2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{patName}</div>}
        <div className="ap-mini-bar"><i style={{ width: Math.max(4, Math.min(100, cand.scoring.score * 70 + 30)) + '%' }} /></div>
      </button>
    </div>
  );
}

Object.assign(window, { APStageH1: APStageH1, APResultCardH1: APResultCardH1, APMiniCanvasH1: APMiniCanvasH1, APMetricRowH1: APMetricRowH1 });
