/**
 * SlideCanvas — Phase Z 실제 결과 (final.html) iframe 표시.
 *
 * 역할 (Phase 1 — MDX upload → 파이프라인 실행 → 결과 표시):
 *   - MDX 업로드 후 server 가 Phase Z 실행
 *   - run_id 의 final.html 을 iframe 으로 표시
 *   - 시뮬레이션 / mock 슬라이드 layout 렌더 X
 *
 * 이전 simulation (FocusSplitLayout / QuadrantGridLayout / DEFAULT_GEOMETRIES /
 * Phase Z Engine header / Summary footer) 모두 제거 — frontend 가 슬라이드를 *만들지 않음*,
 * backend 결과를 *읽기만 함*.
 *
 * Layout / zone / region / frame 변경 UI 는 좌우 패널 (LayoutPanel / FramePanel)
 * 에서 수행. 본 컴포넌트는 *결과 viewer*.
 */

import { useRef, useEffect, useState } from "react";
import { Loader2, Upload as UploadIcon } from "lucide-react";
import type {
  SlidePlan,
  UserSelection,
  NormalizedContent,
} from "../types/designAgent";
import {
  IMAGE_RESIZE_MIN_SIZE_PERCENT,
  clampImagePercentGeometry,
  clampZoneMove,
  crossedDragThreshold,
  type ImageDragDirection,
} from "./slideCanvasDragMath";
import type {
  ImageOverridesOverride,
  StructureOverridesOverride,
  StructureOverridePerZone,
} from "../services/userOverridesApi";
import StructureEditOverlay from "./StructureEditOverlay";

interface SlideCanvasProps {
  slidePlan: SlidePlan | null;
  normalizedContent: NormalizedContent | null;
  userSelection: UserSelection;
  /** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
  finalHtmlUrl?: string;
  /** 파이프라인 실행 중 표시 (loading state). */
  isPipelineRunning?: boolean;
  /** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */
  isPendingLayout?: boolean;
  pendingLayoutId?: string | null;
  onCancelPendingLayout?: () => void;
  /** 편집 모드 텍스트 변경 발생 시 — Home.tsx 가 hasPendingChanges 트리거. */
  onContentEdit?: () => void;
  /** Zone overlay 클릭 — 우측 Frame 탭으로 전환 + 그 zone 의 frame 후보 표시. */
  onZoneClick?: (zoneId: string) => void;
  /** 슬라이드 박스 (zone overlay 외 빈 영역) 클릭 — 우측 Layout 탭으로 전환. */
  onSlideClick?: () => void;
  onRegionClick?: (regionId: string) => void;
  onFrameSelect?: (frameId: string) => void;
  onSectionDrop?: (sectionId: string, zoneId: string) => void;
  onLayoutResize?: (groupId: string, sizes: number[]) => void;
  onZoneResize?: (
    geometries: Record<string, { x: number; y: number; w: number; h: number }>
  ) => void;
  /** IMP-51 (#79) u8 — persisted slide-absolute image geometries
   *  (image_id → {x,y,w,h} as percent of 1280×720, range 0–100). Mirrors
   *  the u3 typed-client `ImageOverride` contract and the u7 stamper that
   *  emits CSS `left/top/width/height: {value}%`. Forward-compat optional;
   *  u11 wires this from `userSelection.overrides.image_overrides`. When
   *  present, SlideCanvas displays the persisted geometry instead of the
   *  iframe-measured baseline. */
  imageOverrides?: ImageOverridesOverride;
  /** IMP-51 (#79) u8 — emitted when the user drags or resizes a stamped
   *  user-content image. Geometry is slide-absolute percent (0–100 of
   *  1280×720), matching the persisted axis schema (u3 typed client) and
   *  the u7 CSS injection that writes the values directly into
   *  `left/top/width/height: {value}%`. u10 wires this to a persistence
   *  handler that updates `image_overrides` on user_overrides.json. */
  onImageResize?: (
    imageId: string,
    geometry: { x: number; y: number; w: number; h: number }
  ) => void;
  /** IMP-90 (#90) u13 — focusout-emitted capture; u15 debounces + PUTs. */
  onTextEdit?: (capture: TextEditCapture) => void;
  /** IMP-90 (#90) u14 — persisted structure overrides per zone
   *  (slot_order + hidden_slots). When `editMode === "structure"` the
   *  StructureEditOverlay reads from this to render the current state. */
  structureOverrides?: StructureOverridesOverride;
  /** IMP-90 (#90) u14 — emitted whenever the user reorders or hides a
   *  slot in structure-mode. u15 will debounce + PUT to /api/user-
   *  overrides; u14 only exposes the capture. SCOPE LOCK: inner shape is
   *  `{slot_order, hidden_slots}` only (frame swap stays on `frames` axis). */
  onStructureEdit?: (zoneId: string, capture: StructureOverridePerZone) => void;
}

const SLIDE_W = 1280;
const SLIDE_H = 720;

// IMP-90 (#90) u11 — discriminated edit mode. Replaces the prior single
// `isEditMode` boolean. u11 introduces the enum + the toolbar UI surface;
// gesture gating (text contentEditable vs structure reorder vs image-zone
// drag/resize) stays unified behind `isEditMode = editMode !== 'off'` so
// existing behavior is preserved byte-identical. u12 will discriminate the
// gestures per mode (mutually exclusive). The 'off' state is the no-edit
// baseline; 'image-zone' bundles image edit (#79) + zone resize (#81)
// because both are pointer-driven canvas gestures on slide geometry.
export type EditMode = "off" | "text" | "structure" | "image-zone";
export const EDIT_MODES: ReadonlyArray<EditMode> = ["text", "structure", "image-zone"];
/** Pure helper — given the current edit mode and the user's requested mode,
 *  return the next mode. Clicking the active mode toggles back to 'off';
 *  clicking a different mode switches; explicit 'off' always exits. */
export function nextEditMode(current: EditMode, requested: EditMode): EditMode {
  if (requested === "off") return "off";
  return current === requested ? "off" : requested;
}

// IMP-90 (#90) u12 — per-mode gesture gating. Pure helper deriving the
// boolean gates that drive SlideCanvas's useEffect branches (designMode
// + iframe-side image click listener) and JSX conditionals (iframe
// pointer-events, zone resize/drag affordances, image overlay). The
// mapping enforces the mutually-exclusive contract from the issue body:
//   text       -> contentEditable + iframe pointer-events:auto only.
//   structure  -> nothing here; u14 will plant the structure overlay.
//   image-zone -> zone resize/drag + image overlay; iframe pe:auto so
//                 in-iframe user-content images can be click-selected.
//   off        -> every gate false (baseline).
// pendingLayout fully suppresses every gate — mirrors the existing
// useEffect (line ~248) that forces editMode='off' on pendingLayout
// entry. The helper still defensively returns all-false so a stray
// pendingLayout=true with a non-'off' editMode never leaks gestures.
export interface EditModeGates {
  textEditing: boolean;
  imageSelection: boolean;
  iframePointerAuto: boolean;
  zoneGestures: boolean;
  imageOverlay: boolean;
}
export function computeEditModeGates(
  editMode: EditMode,
  isPendingLayout: boolean
): EditModeGates {
  if (isPendingLayout) {
    return {
      textEditing: false,
      imageSelection: false,
      iframePointerAuto: false,
      zoneGestures: false,
      imageOverlay: false,
    };
  }
  return {
    textEditing: editMode === "text",
    imageSelection: editMode === "image-zone",
    iframePointerAuto: editMode === "text" || editMode === "image-zone",
    zoneGestures: editMode === "image-zone",
    imageOverlay: editMode === "image-zone",
  };
}

// IMP-90 (#90) u13 — pure helper resolving a contentEditable focusout
// target into (zoneId, textPath, value). data-text-path stamped by u8 at
// Step 13; .zone[data-zone-position] from Phase Z slide-base. Non-stamped
// targets return null so capture silently skips. u15 will debounce + PUT.
export interface TextEditCaptureTarget {
  closest(selector: string): TextEditCaptureTarget | null;
  getAttribute(name: string): string | null;
  textContent: string | null;
}
export interface TextEditCapture {
  zoneId: string;
  textPath: string;
  value: string;
}
export function deriveTextEditCapture(
  target: TextEditCaptureTarget | null
): TextEditCapture | null {
  if (!target) return null;
  const lineEl = target.closest("[data-text-path]");
  if (!lineEl) return null;
  const textPath = lineEl.getAttribute("data-text-path");
  if (!textPath) return null;
  const zoneEl = lineEl.closest(".zone[data-zone-position]");
  if (!zoneEl) return null;
  const zoneId = zoneEl.getAttribute("data-zone-position");
  if (!zoneId) return null;
  return { zoneId, textPath, value: (lineEl.textContent ?? "").trim() };
}

export default function SlideCanvas({
  slidePlan,
  userSelection,
  finalHtmlUrl,
  isPipelineRunning,
  isPendingLayout,
  pendingLayoutId,
  onCancelPendingLayout,
  onContentEdit,
  onZoneClick,
  onSlideClick,
  onSectionDrop,
  onZoneResize,
  imageOverrides,
  onImageResize,
  onTextEdit,
  structureOverrides,
  onStructureEdit,
}: SlideCanvasProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scale, setScale] = useState(1);
  // iframe 안 final.html 의 실제 .zone DOM boundingClientRect 측정값 (정규화 0~1).
  // key = data-zone-position (e.g., "top" / "bottom" / "primary"). 이게 SlidePlan
  // 의 zone.zone_id 와 1:1 일치하므로 overlay rendering 시 매칭 가능.
  const [measuredZones, setMeasuredZones] = useState<
    Record<string, { x: number; y: number; w: number; h: number }>
  >({});
  // iframe 안 .slide-body 의 bbox (정규화 0~1, 1280×720 기준). slide-base (title /
  // divider / footer) 는 그 외부. pendingLayout 모드 시 이 영역 안에만 빈 layout
  // overlay 를 깔아서 slide-base 는 그대로 유지.
  const [measuredSlideBody, setMeasuredSlideBody] = useState<{
    x: number;
    y: number;
    w: number;
    h: number;
  } | null>(null);
  // Step B : section drag-drop drop target. 사용자가 LeftMdxPanel 의 section 카드
  // 를 drag 해서 zone 에 drop 시 그 zone 에 section 할당. dragOver 시 강조 표시.
  const [dragOverZoneId, setDragOverZoneId] = useState<string | null>(null);
  // IMP-51 (#79) u8 — measured user-content image bboxes inside iframe
  // (slide-absolute percent of 1280×720, range 0–100). key = data-image-id
  // stamped by u4 (`src/image_id_stamper.py`). Populated in the iframe
  // onLoad measure block alongside measuredZones / measuredSlideBody.
  // Units intentionally match the persisted `image_overrides` axis (u3
  // typed client) and the u7 CSS injection so the overlay math has a
  // single coord space across measured/persisted/emitted values. Used as
  // the baseline geometry when no persisted override exists for that id;
  // `imageOverrides` prop (u11-fed) wins when present.
  const [measuredImages, setMeasuredImages] = useState<
    Record<string, { x: number; y: number; w: number; h: number }>
  >({});
  // IMP-51 (#79) u8 — currently selected user-content image id (= the one
  // whose drag/resize handles are shown). Set by the click-listener
  // installed inside the iframe contentDocument when edit mode is active.
  // Reset on finalHtmlUrl change and on edit-mode exit so stale ids never
  // leak across runs.
  const [selectedImageId, setSelectedImageId] = useState<string | null>(null);
  // HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
  // 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
  // pendingLayout 과 배타적 (충돌 방지).
  // IMP-90 (#90) u11 — `editMode` enum replaces the prior boolean. The
  // `isEditMode` shim is kept ONLY for the pendingLayout coupling +
  // zone-wrapper visual cues (border / hover / selected styling) that
  // fire whenever any edit mode is active. u12 routes gesture-activating
  // gates through `editGates` so text / structure / image-zone gestures
  // are mutually exclusive.
  const [editMode, setEditMode] = useState<EditMode>("off");
  const isEditMode = editMode !== "off";
  const editGates = computeEditModeGates(editMode, !!isPendingLayout);
  const iframeRef = useRef<HTMLIFrameElement>(null);

  // 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제.
  useEffect(() => {
    const iframe = iframeRef.current;
    if (!iframe) return;
    const doc = iframe.contentDocument;
    if (!doc) return;

    // 편집 모드 outline CSS — 한 번만 주입 (id 로 중복 방지)
    let editStyle = doc.getElementById("phase-z-edit-style") as HTMLStyleElement | null;
    if (!editStyle) {
      editStyle = doc.createElement("style");
      editStyle.id = "phase-z-edit-style";
      editStyle.textContent = `
        [contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); cursor: text; }
        [contenteditable]:focus { outline: 2px solid #00C853 !important; outline-offset: 2px; }
      `;
      doc.head.appendChild(editStyle);
    }

    const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"];
    let inputHandler: ((e: Event) => void) | null = null;
    // IMP-90 (#90) u13 — focusout (= bubbling blur) emits one capture per
    // finished line edit; u15 will debounce + PUT.
    let textEditCaptureHandler: ((e: Event) => void) | null = null;
    // IMP-51 (#79) u8 — user-content image click listeners installed
    // inside the iframe contentDocument. Tracked here so the cleanup
    // callback can remove them when edit mode exits (or iframe reloads).
    const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = [];

    // IMP-90 (#90) u12 — text-editing gate: only the 'text' editMode
    // turns designMode on + makes the editable tags contentEditable.
    // The else branch tears the prior state down so leaving text mode
    // (to structure / image-zone / off) immediately disables in-place
    // text editing — required for mutual exclusivity vs the image-zone
    // overlay's drag/resize gestures (a contentEditable cursor would
    // otherwise be placed by every image click).
    if (editGates.textEditing) {
      doc.designMode = "on";
      doc.querySelectorAll(".slide *").forEach((el) => {
        if (editableTags.includes((el as HTMLElement).tagName)) {
          (el as HTMLElement).setAttribute("contenteditable", "true");
        }
      });
      // 편집 발생 시 hasPendingChanges 트리거.
      inputHandler = () => {
        onContentEdit?.();
      };
      doc.addEventListener("input", inputHandler);

      textEditCaptureHandler = (ev: Event) => {
        const cap = deriveTextEditCapture(
          ev.target as unknown as TextEditCaptureTarget | null
        );
        if (cap) onTextEdit?.(cap);
      };
      doc.addEventListener("focusout", textEditCaptureHandler);
    } else {
      doc.designMode = "off";
      doc.querySelectorAll("[contenteditable]").forEach((el) => {
        (el as HTMLElement).removeAttribute("contenteditable");
      });
    }

    // IMP-90 (#90) u12 — image-selection gate: only the 'image-zone'
    // editMode wires the in-iframe user-content image click → selection.
    // Selector mirrors USER_CONTENT_IMAGE_SELECTOR in image_id_stamper.py
    // (requires data-image-id which the stamper always emits). Decorative
    // / frame imgs lacking the role attribute are NOT clickable. The
    // else branch clears `selectedImageId` so the React-side overlay
    // never lingers on a non-image-zone edit mode.
    if (editGates.imageSelection) {
      const imgEls = doc.querySelectorAll<HTMLImageElement>(
        '.slide img[data-image-role="user-content"][data-image-id]'
      );
      imgEls.forEach((imgEl) => {
        const imgId = imgEl.dataset.imageId;
        if (!imgId) return;
        const handler = (ev: Event) => {
          ev.stopPropagation();
          ev.preventDefault();
          setSelectedImageId(imgId);
        };
        const prevCursor = imgEl.style.cursor;
        const prevOutline = imgEl.style.outline;
        imgEl.style.cursor = "pointer";
        imgEl.style.outline = "1px dashed rgba(16, 185, 129, 0.55)";
        imgEl.addEventListener("click", handler);
        imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline });
      });
    } else {
      setSelectedImageId(null);
    }

    return () => {
      if (inputHandler && doc) {
        doc.removeEventListener("input", inputHandler);
      }
      if (textEditCaptureHandler && doc) {
        doc.removeEventListener("focusout", textEditCaptureHandler);
      }
      imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => {
        el.removeEventListener("click", handler);
        el.style.cursor = prevCursor;
        el.style.outline = prevOutline;
      });
    };
  }, [editGates.textEditing, editGates.imageSelection, finalHtmlUrl, onContentEdit, onTextEdit]);

  // pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지).
  useEffect(() => {
    if (isPendingLayout && isEditMode) setEditMode("off");
  }, [isPendingLayout, isEditMode]);

  // IMP-90 (#90) u14 — discover slot keys per zone for the structure
  // overlay. Source = iframe DOM `data-text-path="{slot_key}.{line_index}"`
  // attributes stamped by u8 (`src/text_path_stamper.py`). Unique slot_key
  // prefixes per `.zone[data-zone-position]` form the overlay's slot list.
  // Discovery runs only when entering structure mode (and resets on exit
  // or iframe reload) so off / text / image-zone modes never pay this
  // traversal cost.
  const [slotKeysByZone, setSlotKeysByZone] = useState<
    Record<string, string[]>
  >({});
  useEffect(() => {
    if (editMode !== "structure" || isPendingLayout) {
      setSlotKeysByZone({});
      return;
    }
    const doc = iframeRef.current?.contentDocument;
    if (!doc) return;
    const next: Record<string, string[]> = {};
    doc.querySelectorAll(".zone[data-zone-position]").forEach((zEl) => {
      const zoneId = (zEl as HTMLElement).getAttribute("data-zone-position");
      if (!zoneId) return;
      const seen = new Set<string>();
      const keys: string[] = [];
      zEl.querySelectorAll("[data-text-path]").forEach((lineEl) => {
        const path = (lineEl as HTMLElement).getAttribute("data-text-path");
        if (!path) return;
        const lastDot = path.lastIndexOf(".");
        const slotKey = lastDot > 0 ? path.slice(0, lastDot) : path;
        if (slotKey && !seen.has(slotKey)) {
          seen.add(slotKey);
          keys.push(slotKey);
        }
      });
      next[zoneId] = keys;
    });
    setSlotKeysByZone(next);
  }, [editMode, isPendingLayout, finalHtmlUrl]);

  // finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset.
  // 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움.
  useEffect(() => {
    setMeasuredZones({});
    setMeasuredSlideBody(null);
    // IMP-51 (#79) u8 — image measurements + selection are per-render;
    // drop both so the new iframe's onLoad starts clean.
    setMeasuredImages({});
    setSelectedImageId(null);
  }, [finalHtmlUrl]);

  // 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X).
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;

    const updateScale = () => {
      const padding = 32; // 컨테이너 양쪽 여백 (1rem * 2)
      const cw = el.clientWidth - padding;
      const ch = el.clientHeight - padding;
      if (cw <= 0 || ch <= 0) return;
      const sw = cw / SLIDE_W;
      const sh = ch / SLIDE_H;
      setScale(Math.min(sw, sh));
    };

    updateScale();
    const ro = new ResizeObserver(updateScale);
    ro.observe(el);
    window.addEventListener("resize", updateScale);
    return () => {
      ro.disconnect();
      window.removeEventListener("resize", updateScale);
    };
  }, []);

  // Empty state — MDX 업로드 전.
  const isEmpty = !finalHtmlUrl && !slidePlan && !isPipelineRunning && !isPendingLayout;
  // 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
  const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning;

  // IMP-14 (Step 13 A-4) — backend slide_base.html 가 embedded vs standalone CSS
  // contract 를 `?embedded=1` query 로 소유. 기존 query string 보존하면서 flag 만 추가.
  const embeddedSrc = finalHtmlUrl
    ? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
    : undefined;

  // wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로
  // top-left origin scale 후 wrapper 안에 정확히 맞춤.
  const W_SCALED = SLIDE_W * scale;
  const H_SCALED = SLIDE_H * scale;

  return (
    <div
      ref={containerRef}
      className="flex-1 flex items-center justify-center bg-slate-100 p-4 relative overflow-hidden"
    >
      {isEmpty && (
        <div className="text-center text-slate-400 select-none">
          <UploadIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" />
          <p className="text-sm font-medium">왼쪽 패널에서 MDX 파일을 업로드하세요.</p>
          <p className="text-xs text-slate-300 mt-1">
            업로드 후 하단 "슬라이드 플랜 생성하기" 버튼을 눌러주세요.
          </p>
        </div>
      )}

      {isPipelineRunning && (
        <div className="text-center text-slate-500 select-none">
          <Loader2 className="w-10 h-10 mx-auto mb-3 text-blue-500 animate-spin" />
          <p className="text-sm font-bold">Phase Z 파이프라인 실행 중...</p>
          <p className="text-xs text-slate-400 mt-1">
            MDX 분석 → V4 매칭 → 레이아웃 / 프레임 결정 → 렌더
          </p>
        </div>
      )}

      {showSlideBox && (
        <div
          className="relative overflow-hidden cursor-pointer bg-white shadow-[0_40px_100px_-20px_rgba(0,0,0,0.2)]"
          style={{ width: W_SCALED, height: H_SCALED }}
          onClick={(e) => {
            // wrapper 자체 클릭 (zone overlay 가 아닌 영역) = slide 전체 = Layout 탭.
            // zone overlay 는 stopPropagation 으로 여기까지 안 옴.
            if (e.target === e.currentTarget && onSlideClick) onSlideClick();
          }}
          title={
            isPendingLayout
              ? `Pending layout: ${pendingLayoutId} — slide-body 만 변경. section 카드 → zone drop → frame 선택 → 재생성`
              : "슬라이드 전체 클릭 → 우측 Layout 후보"
          }
        >
          {/* pendingLayout 일 때만 노출되는 우상단 취소 버튼 (slide 박스 외곽 유지) */}
          {isPendingLayout && onCancelPendingLayout && (
            <button
              onClick={(e) => {
                e.stopPropagation();
                onCancelPendingLayout();
              }}
              className="absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2 py-1 rounded bg-slate-800 text-white hover:bg-slate-700 shadow"
              style={{ pointerEvents: "auto" }}
            >
              취소
            </button>
          )}

          {/* IMP-90 (#90) u11 — discriminated edit-mode toolbar.
              Replaces the prior single ✏ toggle. Three modes (text /
              structure / image-zone) are mutually exclusive; clicking the
              active mode toggles back to 'off'. Gesture gating per mode is
              u12 — u11 only plants the state + UI surface, so all three
              modes currently share the same `isEditMode` shim behavior. */}
          {!isPendingLayout && finalHtmlUrl && (
            <div
              data-testid="edit-mode-toolbar"
              className="absolute top-2 right-2 z-30 flex gap-1"
              style={{ pointerEvents: "auto" }}
            >
              {EDIT_MODES.map((mode) => {
                const active = editMode === mode;
                const label =
                  mode === "text" ? "✏ 텍스트" : mode === "structure" ? "▦ 구조" : "🖼 이미지/존";
                const title =
                  mode === "text"
                    ? "텍스트 편집 — 텍스트 클릭하여 직접 수정"
                    : mode === "structure"
                    ? "구조 편집 — slot 순서 / 숨김 변경 (u14 펜딩)"
                    : "이미지/존 편집 — 이미지 드래그·리사이즈 + 존 리사이즈";
                return (
                  <button
                    key={mode}
                    type="button"
                    data-testid={`edit-mode-${mode}`}
                    aria-pressed={active}
                    onClick={(e) => {
                      e.stopPropagation();
                      setEditMode((prev) => nextEditMode(prev, mode));
                    }}
                    className={`text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
                      active
                        ? "bg-emerald-500 text-white hover:bg-emerald-600 ring-2 ring-emerald-200"
                        : "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
                    }`}
                    title={title}
                  >
                    {label}
                  </button>
                );
              })}
            </div>
          )}

          <div
            style={{
              width: SLIDE_W,
              height: SLIDE_H,
              transform: `scale(${scale})`,
              transformOrigin: "top left",
              position: "absolute",
              top: 0,
              left: 0,
              pointerEvents: "none",
            }}
          >
            <iframe
              ref={iframeRef}
              src={embeddedSrc}
              title="Phase Z 렌더 결과"
              className="w-full h-full border-0 block"
              scrolling="no"
              sandbox="allow-same-origin allow-scripts"
              // IMP-90 (#90) u12 — iframe pointer-events gate. 'text' needs
              // pe:auto so the user can click into text fields; 'image-zone'
              // needs pe:auto so user-content image clicks can reach the
              // in-iframe click handler that drives `selectedImageId`.
              // 'structure' and 'off' keep pe:none — structure has no
              // in-iframe gesture (u14 will overlay React-side controls).
              style={{ pointerEvents: editGates.iframePointerAuto ? "auto" : "none" }}
              onLoad={(e) => {
                // IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
                // slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
                // reset CSS 를 contentDocument 에 inject 하지 않음. embedded query 가
                // backend auto-mode detection script 를 trigger 해서 html.embedded
                // class 를 붙이고 standalone-only body 규칙을 reset.
                try {
                  const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
                  if (!doc) return;

                  // ── Zone DOM 측정 ──
                  // backend final.html 의 .zone[data-zone-position="..."] 요소를
                  // 찾아서 boundingClientRect 측정 → 1280×720 기준 정규화.
                  // overlay 가 이 측정값으로 그려지므로 backend 의 layout (slide-base
                  // padding / dynamic ratios / grid) 그대로 반영됨.
                  const zoneEls = doc.querySelectorAll(
                    ".zone[data-zone-position]"
                  );
                  const measured: Record<
                    string,
                    { x: number; y: number; w: number; h: number }
                  > = {};
                  zoneEls.forEach((el) => {
                    const r = (el as HTMLElement).getBoundingClientRect();
                    const pos = (el as HTMLElement).dataset.zonePosition;
                    if (!pos) return;
                    measured[pos] = {
                      x: r.left / SLIDE_W,
                      y: r.top / SLIDE_H,
                      w: r.width / SLIDE_W,
                      h: r.height / SLIDE_H,
                    };
                  });
                  setMeasuredZones(measured);

                  // .slide-body 측정 — pendingLayout 모드 시 이 영역에만 빈
                  // overlay 를 깔아서 slide-base (title / footer) 는 유지.
                  const bodyEl = doc.querySelector(".slide-body");
                  if (bodyEl) {
                    const r = (bodyEl as HTMLElement).getBoundingClientRect();
                    setMeasuredSlideBody({
                      x: r.left / SLIDE_W,
                      y: r.top / SLIDE_H,
                      w: r.width / SLIDE_W,
                      h: r.height / SLIDE_H,
                    });
                  }

                  // ── IMP-51 (#79) u8 — user-content image bbox 측정 ──
                  // u4 stamper 가 부착한 data-image-id 가 있는 img 만 잡음
                  // (decorative / frame img 제외). 측정 결과는 1280×720 기준
                  // 슬라이드-절대 percent (0–100) — image_overrides axis (u3
                  // 타입 + u7 CSS `left/top/width/height: {value}%` 주입) 와
                  // 동일한 좌표계라서 측정 / 영구 저장 / emit 가 1:1 매칭됨.
                  const imageEls = doc.querySelectorAll<HTMLImageElement>(
                    '.slide img[data-image-role="user-content"][data-image-id]'
                  );
                  const measuredImg: Record<
                    string,
                    { x: number; y: number; w: number; h: number }
                  > = {};
                  imageEls.forEach((imgEl) => {
                    const id = imgEl.dataset.imageId;
                    if (!id) return;
                    const r = imgEl.getBoundingClientRect();
                    if (r.width <= 0 || r.height <= 0) return;
                    measuredImg[id] = {
                      x: (r.left / SLIDE_W) * 100,
                      y: (r.top / SLIDE_H) * 100,
                      w: (r.width / SLIDE_W) * 100,
                      h: (r.height / SLIDE_H) * 100,
                    };
                  });
                  setMeasuredImages(measuredImg);
                } catch (err) {
                  console.warn("[SlideCanvas] iframe inject/measure 실패:", err);
                }
              }}
            />
          </div>

          {/* ── pendingLayout 시 slide-body 영역 overlay ──
            slide-base (title / divider / footer) 는 final.html 그대로 유지.
            slide-body 영역에만 흰 dashed overlay 깔아서 기존 .zone 들 가리고
            새 layout zone 안내 표시. */}
          {isPendingLayout && measuredSlideBody && (
            <div
              className="absolute bg-white border-2 border-dashed border-amber-400 z-10"
              style={{
                left: `${measuredSlideBody.x * 100}%`,
                top: `${measuredSlideBody.y * 100}%`,
                width: `${measuredSlideBody.w * 100}%`,
                height: `${measuredSlideBody.h * 100}%`,
                pointerEvents: "none",
              }}
            >
              <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
                <div className="text-center select-none">
                  <span className="text-[10px] font-black uppercase tracking-widest text-amber-500 block mb-1">
                    Pending Body Layout
                  </span>
                  <span className="text-lg font-bold text-slate-500 block">
                    {pendingLayoutId}
                  </span>
                  <span className="text-[10px] text-slate-400 block mt-1">
                    1. section 카드 → zone drop &nbsp;·&nbsp; 2. zone 클릭 → frame 선택 &nbsp;·&nbsp; 3. 재생성
                  </span>
                </div>
              </div>
            </div>
          )}

          {/* ── Zone overlay layer ──
            normal mode : measuredZones (= iframe 안 .zone DOM bbox) 우선.
            pendingLayout : zone.position (computeZonePositions 결과 = slide-body
              내부 0~1) 을 measuredSlideBody 안 절대 정규화로 변환.
            isEditMode : zone overlay 반투명 + pointer-events: none → 텍스트 편집
              방해 X. resize handle 만 pointer-events: auto 로 활성. */}
          {slidePlan?.zones.map((zone) => {
            // selectedZoneId 는 zone.id 또는 zone.zone_id 중 어느 쪽이든 매칭 (utils
            // 의 getSelectedZone 도 양쪽 보므로 일관).
            const isSelected =
              userSelection.selectedZoneId === zone.id ||
              userSelection.selectedZoneId === zone.zone_id;
            const isDragOver = dragOverZoneId === zone.id;
            // Step B : zone overlay 라벨 = section_ids 기반 (S1, S1+S2 등).
            // backend zone position (top/bottom/primary) 은 부 라벨 / tooltip 으로.
            // pendingLayout 모드에서는 override 만 사용 (default 무시 — 빈 상태로 시작).
            const sectionIds = isPendingLayout
              ? userSelection.overrides.zone_sections?.[zone.zone_id] ?? []
              : userSelection.overrides.zone_sections?.[zone.zone_id] ??
                zone.section_ids;
            const sectionLabel =
              sectionIds.length > 0
                ? sectionIds
                    .map((s) => `S${s.split("-").pop() ?? s}`)
                    .join("+")
                : "(empty)";
            // 좌표 결정 (schema 통일 — 사용자 override 는 항상 slide-body 내부 0~1) :
            //   override 있음 (zone_geometries) → slide-body 기준 → 1280×720 절대 변환.
            //   pending mode + override 없음 → zone.position (slide-body 기준) 변환.
            //   normal mode + override 없음 → measuredZones (1280×720 절대) 직접.
            const overrideGeom =
              userSelection.overrides.zone_geometries?.[zone.zone_id] as
                | { x: number; y: number; w: number; h: number }
                | undefined;
            const localGeom = overrideGeom ?? {
              x: zone.position.x,
              y: zone.position.y,
              w: zone.position.width,
              h: zone.position.height,
            };
            let x: number, y: number, width: number, height: number;
            if (overrideGeom && measuredSlideBody) {
              // 사용자 override → slide-body 기준
              x = measuredSlideBody.x + overrideGeom.x * measuredSlideBody.w;
              y = measuredSlideBody.y + overrideGeom.y * measuredSlideBody.h;
              width = overrideGeom.w * measuredSlideBody.w;
              height = overrideGeom.h * measuredSlideBody.h;
            } else if (isPendingLayout && measuredSlideBody) {
              x = measuredSlideBody.x + zone.position.x * measuredSlideBody.w;
              y = measuredSlideBody.y + zone.position.y * measuredSlideBody.h;
              width = zone.position.width * measuredSlideBody.w;
              height = zone.position.height * measuredSlideBody.h;
            } else {
              const measured = measuredZones[zone.zone_id];
              x = measured?.x ?? zone.position.x;
              y = measured?.y ?? zone.position.y;
              width = measured?.w ?? zone.position.width;
              height = measured?.h ?? zone.position.height;
            }

            // Step C : pending mode 의 zone overlay resize handles — independent 모델.
            // 각 zone 이 독립 박스 — 인접 zone 영향 X. 자유 배치 (gap / 겹침 사용자 책임).
            // boundary drag 모델 (인접 자동 조정) 은 grid 같은 묶음 layout 에 적합한데,
            // 사용자 의도 = zone position drag 까지 자유롭게 → independent 가 자연.
            type ResizeDir =
              | "top"
              | "bottom"
              | "left"
              | "right"
              | "nw"
              | "ne"
              | "sw"
              | "se";
            const makeResizeHandler = (
              direction: ResizeDir
            ) => (ev: React.MouseEvent<HTMLDivElement>) => {
              // resize 는 pendingLayout OR image-zone 편집 모드 활성. 2026-05-22
              // demo hot-fix — frame partial 에 @container aspect-ratio 회전이
              // 들어가서 fixed px 제약 사라짐. IMP-90 u12: text/structure 모드
              // 에서는 zone resize 비활성 (mutually exclusive per editGates).
              if ((!isPendingLayout && !editGates.zoneGestures) || !onZoneResize) return;
              if (!measuredSlideBody) return;
              ev.preventDefault();
              ev.stopPropagation();
              const startMouseX = ev.clientX;
              const startMouseY = ev.clientY;
              const startGeom: { x: number; y: number; w: number; h: number } =
                overrideGeom ? { ...overrideGeom } : { ...localGeom };
              const slideBodyWidthPx = W_SCALED * measuredSlideBody.w;
              const slideBodyHeightPx = H_SCALED * measuredSlideBody.h;
              const minSize = 0.05;

              const affectsLeft = direction === "left" || direction === "nw" || direction === "sw";
              const affectsRight = direction === "right" || direction === "ne" || direction === "se";
              const affectsTop = direction === "top" || direction === "nw" || direction === "ne";
              const affectsBottom = direction === "bottom" || direction === "sw" || direction === "se";

              // 2026-05-22 demo hot-fix — iframe 이 마우스 가로채서 mouseup leak 일어남
              // (편집 모드에서 iframe pointerEvents=auto). drag 동안 iframe 강제 none.
              const iframeEl = iframeRef.current;
              const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
              if (iframeEl) iframeEl.style.pointerEvents = "none";

              const onMove = (mv: MouseEvent) => {
                const dx = (mv.clientX - startMouseX) / slideBodyWidthPx;
                const dy = (mv.clientY - startMouseY) / slideBodyHeightPx;
                let { x, y, w, h } = startGeom;
                if (affectsRight) {
                  w = Math.max(minSize, Math.min(1 - startGeom.x, startGeom.w + dx));
                }
                if (affectsBottom) {
                  h = Math.max(minSize, Math.min(1 - startGeom.y, startGeom.h + dy));
                }
                if (affectsLeft) {
                  // 좌측 경계 이동 — w 줄이고 x 늘림 (또는 반대)
                  const newW = Math.max(minSize, startGeom.w - dx);
                  x = Math.max(0, startGeom.x + (startGeom.w - newW));
                  w = newW;
                }
                if (affectsTop) {
                  const newH = Math.max(minSize, startGeom.h - dy);
                  y = Math.max(0, startGeom.y + (startGeom.h - newH));
                  h = newH;
                }
                onZoneResize({ [zone.zone_id]: { x, y, w, h } });
              };
              const onUp = () => {
                document.removeEventListener("mousemove", onMove);
                document.removeEventListener("mouseup", onUp);
                if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
              };
              document.addEventListener("mousemove", onMove);
              document.addEventListener("mouseup", onUp);
            };
            const handleResizeTop = makeResizeHandler("top");
            const handleResizeBottom = makeResizeHandler("bottom");
            const handleResizeLeft = makeResizeHandler("left");
            const handleResizeRight = makeResizeHandler("right");
            const handleResizeNW = makeResizeHandler("nw");
            const handleResizeNE = makeResizeHandler("ne");
            const handleResizeSW = makeResizeHandler("sw");
            const handleResizeSE = makeResizeHandler("se");

            // zone position drag — pending mode 에서 zone body (handle 외 영역)
            // mousedown drag 로 x/y 이동. drag vs click 구분 :
            //   5px 미만 이동 = click → onZoneClick
            //   5px 이상 = drag → onZoneResize 로 x/y 변경
            const handleZoneMouseDown = (
              ev: React.MouseEvent<HTMLDivElement>
            ) => {
              ev.stopPropagation();
              // IMP-90 u12: zone drag is image-zone-mode-only (text /
              // structure suppress canDrag; non-zoneGestures click still
              // triggers onZoneClick via the !dragged branch on mouse-up).
              const canDrag = !!((isPendingLayout || editGates.zoneGestures) && measuredSlideBody && onZoneResize);
              const startMouseX = ev.clientX;
              const startMouseY = ev.clientY;
              const startGeom = { ...localGeom };
              const slideBodyWidthPx = canDrag
                ? W_SCALED * measuredSlideBody!.w
                : 1;
              const slideBodyHeightPx = canDrag
                ? H_SCALED * measuredSlideBody!.h
                : 1;
              let dragged = false;

              // 2026-05-22 demo hot-fix — same iframe pointer-events fix as makeResizeHandler.
              const iframeEl = iframeRef.current;
              const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
              if (iframeEl) iframeEl.style.pointerEvents = "none";

              const onMove = (mv: MouseEvent) => {
                if (!canDrag) return;
                const dxPx = mv.clientX - startMouseX;
                const dyPx = mv.clientY - startMouseY;
                if (!dragged && crossedDragThreshold(dxPx, dyPx)) {
                  dragged = true;
                }
                if (dragged) {
                  const { x: newX, y: newY } = clampZoneMove(
                    startGeom,
                    dxPx,
                    dyPx,
                    slideBodyWidthPx,
                    slideBodyHeightPx
                  );
                  onZoneResize!({
                    [zone.zone_id]: {
                      x: newX,
                      y: newY,
                      w: startGeom.w,
                      h: startGeom.h,
                    },
                  });
                }
              };
              const onUp = () => {
                document.removeEventListener("mousemove", onMove);
                document.removeEventListener("mouseup", onUp);
                if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
                if (!dragged) {
                  // 단순 click 으로 처리 — onZoneClick.
                  onZoneClick?.(zone.id);
                }
              };
              document.addEventListener("mousemove", onMove);
              document.addEventListener("mouseup", onUp);
            };

            // Step A : 사용자가 다른 frame 을 골랐을 때 그 preview 를 zone 안에
            // 반투명으로 미리보기. final.html (iframe) 의 default frame 과 다를 때만.
            // override 없거나 default 와 같으면 미리보기 X (iframe 과 중복).
            const region = zone.internal_regions[0];
            const defaultFrameId = region?.frame_match_strategy.frame_id;
            const overrideFrameId = region?.id
              ? userSelection.overrides.zone_frames?.[region.id]
              : undefined;
            const previewFrameId =
              overrideFrameId && overrideFrameId !== defaultFrameId
                ? overrideFrameId
                : null;
            const previewCandidate = previewFrameId
              ? region?.frame_candidates?.find((c) => c.id === previewFrameId)
              : null;
            const previewUrl = previewCandidate?.thumbnailUrl ?? null;

            // IMP-11 u4: active frame lookup — distinct axis from preview.
            // preview is shown only when override differs from default; active is
            // always defined as override-if-present-else-default. Used by u5 to
            // compare the active frame's catalog min_height_px against zone height.
            const activeFrameId = overrideFrameId ?? defaultFrameId;
            const activeCandidate = activeFrameId
              ? region?.frame_candidates?.find((c) => c.id === activeFrameId)
              : undefined;

            // IMP-11 u5: catalog min_height_px violation hint. height is already
            // a fraction of SLIDE_H (1280x720 logical px coordinate space), so
            // logical px = height * SLIDE_H. measuredSlideBody.h is intentionally
            // not re-multiplied (double-apply would shrink the comparison value).
            // Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged.
            const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null;
            const minHeightPx = activeCandidate?.minHeightPx ?? null;
            const belowMinHeight =
              isPendingLayout &&
              minHeightPx != null &&
              zoneHeightPx != null &&
              zoneHeightPx < minHeightPx;

            return (
              <div
                key={zone.id}
                role="button"
                tabIndex={0}
                onMouseDown={handleZoneMouseDown}
                onDragEnter={(ev) => {
                  ev.preventDefault();
                  ev.stopPropagation();
                  // 브라우저는 custom dataTransfer type 을 lowercase 강제 — 양쪽 다 check.
                  const types = Array.from(ev.dataTransfer.types).map((t) => t.toLowerCase());
                  if (types.includes("sectionid") || types.includes("section-id")) {
                    setDragOverZoneId(zone.id);
                  }
                }}
                onDragOver={(ev) => {
                  // drop target 이려면 onDragOver 에서 preventDefault 필수 — type check 없이 무조건 허용.
                  ev.preventDefault();
                  ev.dataTransfer.dropEffect = "move";
                }}
                onDragLeave={(ev) => {
                  // 자식으로 들어가면서 발생하는 leave 무시 — 실제 leave 만 처리.
                  if (ev.currentTarget.contains(ev.relatedTarget as Node)) return;
                  setDragOverZoneId((p) => (p === zone.id ? null : p));
                }}
                onDrop={(ev) => {
                  ev.preventDefault();
                  ev.stopPropagation();
                  setDragOverZoneId(null);
                  // setData 했던 모든 type 을 시도 — case 변환 / 다른 키 fallback.
                  const sectionId =
                    ev.dataTransfer.getData("sectionId") ||
                    ev.dataTransfer.getData("section-id") ||
                    ev.dataTransfer.getData("text/plain");
                  if (sectionId && onSectionDrop) {
                    onSectionDrop(sectionId, zone.zone_id);
                  }
                }}
                className={`absolute z-20 transition-all overflow-hidden ${
                  isEditMode
                    ? "border border-dashed border-emerald-300/40 cursor-default"
                    : isPendingLayout
                    ? "cursor-grab active:cursor-grabbing"
                    : "cursor-pointer"
                } ${
                  isDragOver
                    ? "border-4 border-emerald-500 bg-emerald-100/30 shadow-[0_0_0_4px_rgba(16,185,129,0.3)]"
                    : isSelected && isEditMode
                    ? "border-2 border-emerald-500 bg-emerald-500/10 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
                    : isSelected && !isEditMode
                    ? "border-2 border-blue-500 bg-blue-500/10 shadow-[0_0_0_2px_rgba(59,130,246,0.2)]"
                    : !isEditMode
                    ? "border border-dashed border-slate-300/40 hover:border-blue-400 hover:bg-blue-500/5"
                    : ""
                }`}
                style={{
                  left: `${x * 100}%`,
                  top: `${y * 100}%`,
                  width: `${width * 100}%`,
                  height: `${height * 100}%`,
                  // 편집 모드 : zone overlay 자체는 클릭 통과 (텍스트 편집 우선),
                  // resize handle 만 그 안에서 pointer-events: auto.
                  pointerEvents: isEditMode ? "none" : "auto",
                }}
                title={
                  previewUrl
                    ? `${sectionLabel} (zone: ${zone.zone_id}) → preview frame ${previewCandidate?.name} (재생성 시 적용)`
                    : `${sectionLabel} (zone: ${zone.zone_id}) — 클릭 시 frame 후보 / section 카드 drop 가능`
                }
              >
                {/* Step A : 선택 frame preview 미리보기 (반투명, iframe 과 중복 X) */}
                {previewUrl && (
                  <>
                    <img
                      src={previewUrl}
                      alt={`${zone.zone_id} preview`}
                      className="absolute inset-0 w-full h-full object-contain bg-white/60 opacity-70 pointer-events-none"
                    />
                    {/* "preview" 배지 — 우상단 */}
                    <span className="absolute top-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-amber-500 text-white shadow">
                      preview
                    </span>
                  </>
                )}

                {/* IMP-11 u5: red border + 'min H Npx' badge when zone height
                    is below the active frame's catalog min_height_px. Visual
                    hint only, no clamp/resize behavior change. */}
                {belowMinHeight && minHeightPx != null && (
                  <>
                    <div className="absolute inset-0 pointer-events-none border-2 border-red-500" />
                    <span className="absolute bottom-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-red-500 text-white shadow pointer-events-none">
                      min H {minHeightPx}px
                    </span>
                  </>
                )}

                {/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
                    부 라벨 = backend zone position (top, bottom, primary). */}
                <div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
                  <span
                    className={`text-[10px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded ${
                      isSelected
                        ? "bg-blue-600 text-white"
                        : isDragOver
                        ? "bg-emerald-600 text-white"
                        : "bg-slate-900/80 text-white"
                    }`}
                  >
                    {sectionLabel}
                  </span>
                  <span className="text-[8px] font-bold uppercase tracking-tighter px-1 py-0.5 rounded bg-white/80 text-slate-500">
                    {zone.zone_id}
                  </span>
                </div>

                {/* drop hint — drag over 시 가운데에 안내 */}
                {isDragOver && (
                  <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
                    <span className="text-[12px] font-black uppercase tracking-wider px-3 py-1.5 rounded-full bg-emerald-500 text-white shadow-xl">
                      Drop to assign
                    </span>
                  </div>
                )}

                {/* Step C : zone resize handles — 8 방향. pendingLayout OR image-zone
                    편집 모드 활성. 2026-05-22 demo hot-fix — frame partial 에 @container
                    aspect-ratio 회전 들어간 후 fixed px 제약 사라져 image-zone 모드 resize
                    도 의미 있음. IMP-90 u12: text / structure 모드에서는 zone resize
                    affordance 미노출 (editGates.zoneGestures = image-zone only).
                    edge handle (top/bottom/left/right) : 한 boundary 이동
                    corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
                {(isPendingLayout || editGates.zoneGestures) && onZoneResize && (
                  <>
                    {/* top edge */}
                    <div
                      onMouseDown={handleResizeTop}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute -top-1 left-1/4 w-1/2 h-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ns-resize z-30 transition shadow"
                      style={{ pointerEvents: "auto" }}
                      title="상단 boundary 조절"
                    />
                    {/* bottom edge */}
                    <div
                      onMouseDown={handleResizeBottom}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ns-resize z-30 transition shadow"
                      style={{ pointerEvents: "auto" }}
                      title="하단 boundary 조절"
                    />
                    {/* left edge */}
                    <div
                      onMouseDown={handleResizeLeft}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute top-1/4 -left-1 h-1/2 w-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ew-resize z-30 transition shadow"
                      style={{ pointerEvents: "auto" }}
                      title="좌측 boundary 조절"
                    />
                    {/* right edge */}
                    <div
                      onMouseDown={handleResizeRight}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute top-1/4 -right-1 h-1/2 w-2 bg-blue-500/70 hover:bg-blue-500 rounded cursor-ew-resize z-30 transition shadow"
                      style={{ pointerEvents: "auto" }}
                      title="우측 boundary 조절"
                    />
                    {/* corner NW (좌상단) */}
                    <div
                      onMouseDown={handleResizeNW}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize z-30 hover:scale-125 transition shadow"
                      style={{ pointerEvents: "auto" }}
                      title="좌상단 동시 조절"
                    />
                    {/* corner NE (우상단) */}
                    <div
                      onMouseDown={handleResizeNE}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize z-30 hover:scale-125 transition shadow"
                      style={{ pointerEvents: "auto" }}
                      title="우상단 동시 조절"
                    />
                    {/* corner SW (좌하단) */}
                    <div
                      onMouseDown={handleResizeSW}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-blue-500 rounded-sm cursor-nesw-resize z-30 hover:scale-125 transition shadow"
                      style={{ pointerEvents: "auto" }}
                      title="좌하단 동시 조절"
                    />
                    {/* corner SE (우하단) */}
                    <div
                      onMouseDown={handleResizeSE}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-blue-500 rounded-sm cursor-nwse-resize z-30 hover:scale-125 transition shadow"
                      style={{ pointerEvents: "auto" }}
                      title="우하단 동시 조절"
                    />
                  </>
                )}

                {/* IMP-54 u1: edit-mode body-drag gesture surfaces.
                    wrapper sets pointerEvents:none in edit mode (see above) to
                    preserve iframe text-edit clicks (A8 guardrail), so the
                    wrapper-level handleZoneMouseDown is unreachable in edit mode.
                    These 4 perimeter strips + top-left grip provide a separate
                    pointer-event surface routing into handleZoneMouseDown.
                    zIndex 25 sits BELOW the 8 resize handles (z-30) so resize
                    gesture wins in overlap regions, and ABOVE the iframe so the
                    strips intercept the perimeter while the un-covered iframe
                    interior keeps text-edit reachability intact.
                    pendingLayout mode already has wrapper pointerEvents:auto,
                    so these surfaces are only needed in edit mode.
                    IMP-90 u12: image-zone-mode-only — text / structure 모드는
                    zone drag 안 함 (editGates.zoneGestures = false 두 모드 모두). */}
                {editGates.zoneGestures && !isPendingLayout && onZoneResize && (
                  <>
                    <div
                      onMouseDown={handleZoneMouseDown}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute top-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
                      style={{ pointerEvents: "auto", zIndex: 25 }}
                      title="zone 이동 — 드래그"
                    />
                    <div
                      onMouseDown={handleZoneMouseDown}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute bottom-0 left-0 right-0 h-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
                      style={{ pointerEvents: "auto", zIndex: 25 }}
                      title="zone 이동 — 드래그"
                    />
                    <div
                      onMouseDown={handleZoneMouseDown}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute top-0 left-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
                      style={{ pointerEvents: "auto", zIndex: 25 }}
                      title="zone 이동 — 드래그"
                    />
                    <div
                      onMouseDown={handleZoneMouseDown}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute top-0 right-0 bottom-0 w-2 cursor-grab active:cursor-grabbing hover:bg-emerald-500/20 transition"
                      style={{ pointerEvents: "auto", zIndex: 25 }}
                      title="zone 이동 — 드래그"
                    />
                    {/* visible grip affordance — placed below the section label
                        (top-1 left-1 container) so the two don't overlap. */}
                    <div
                      onMouseDown={handleZoneMouseDown}
                      onClick={(ev) => ev.stopPropagation()}
                      className="absolute top-7 left-1 w-3 h-3 bg-emerald-500/70 border border-emerald-700 rounded-full cursor-grab active:cursor-grabbing shadow hover:scale-125 transition"
                      style={{ pointerEvents: "auto", zIndex: 25 }}
                      title="zone 이동 — 드래그하여 위치 변경"
                    />
                  </>
                )}
              </div>
            );
          })}

          {/* IMP-90 (#90) u14 — structure edit overlay (slot reorder +
              hide). Renders only in `editMode === "structure"` over each
              measured zone, positioned at the zone's top-right inside the
              slide-absolute coord space. Slot keys come from u14 iframe
              traversal (`slotKeysByZone`). Mutations emit through
              onStructureEdit; u15 will debounce + PUT. */}
          {!isPendingLayout && editMode === "structure" && finalHtmlUrl &&
            slidePlan?.zones.map((zone) => {
              const m = measuredZones[zone.zone_id];
              if (!m) return null;
              const slotKeys = slotKeysByZone[zone.zone_id] ?? [];
              const current = structureOverrides?.[zone.zone_id];
              return (
                <div
                  key={`struct-${zone.id}`}
                  className="absolute z-30"
                  style={{
                    left: m.x * W_SCALED,
                    top: m.y * H_SCALED,
                    width: m.w * W_SCALED,
                    pointerEvents: "none",
                  }}
                >
                  <StructureEditOverlay
                    zoneId={zone.zone_id}
                    slotKeys={slotKeys}
                    current={current}
                    onChange={onStructureEdit}
                  />
                </div>
              );
            })}

          {/* ── IMP-51 (#79) u8 — user-content image edit overlay ──
            Activates only in edit mode when an image_id appears in either
            `imageOverrides` (u11-fed persisted axis) or `measuredImages`
            (iframe-measured baseline). pendingLayout suppresses the image
            overlay so zone editing and image editing never compete for the
            same pointer events.

            For every stamped user-content image we render a transparent
            wrapper at the image's slide-absolute coords. Wrapper picks up
            the body-drag gesture (move the image without resizing). When
            the image is the `selectedImageId` we additionally render 8
            resize handles. Aspect ratio is LOCKED on corner drags by
            default; holding Shift during the drag unlocks it (matches the
            issue contract "corner_resize_ratio_default_locked_shift_unlock").

            Coordinate space: slide-absolute percent (0–100) throughout —
            measured / persisted / emitted values share the same units as
            the u7 CSS injector (`left/top/width/height: {value}%`) and the
            u3 typed-client `ImageOverride` contract. CSS values are
            written verbatim ({geom.x}%, no scale factor) and pixel deltas
            from MouseEvent are converted to percent via
            `(dx_px / W_SCALED) * 100` so the round-trip drag → save →
            re-render produces identical geometry. IMP-51 (#79) u9 moved
            the resize / move math to `clampImagePercentGeometry` in
            `slideCanvasDragMath.ts` so the boundary contract Codex #16
            verified is exercised directly by vitest (mirror of how IMP-54
            u3 split the zone math out of SlideCanvas). */}
          {/* IMP-90 u12: image overlay is image-zone-mode-only. text /
              structure 모드에서는 image drag/resize affordance 미노출
              (editGates.imageOverlay = false). pendingLayout 도 동일하게
              suppress (computeEditModeGates 가 모두 false 반환). */}
          {!isPendingLayout && editGates.imageOverlay && finalHtmlUrl && onImageResize &&
            Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map(
              ([imageId]) => {
                const persisted = imageOverrides?.[imageId];
                const measured = measuredImages[imageId];
                // override 우선; 없으면 measured baseline. 둘 다 없으면 skip.
                const geom = persisted ?? measured;
                if (!geom) return null;
                const isSelected = selectedImageId === imageId;

                const beginDrag = (
                  ev: React.MouseEvent<HTMLDivElement>,
                  direction: ImageDragDirection
                ) => {
                  ev.preventDefault();
                  ev.stopPropagation();
                  setSelectedImageId(imageId);
                  const startMouseX = ev.clientX;
                  const startMouseY = ev.clientY;
                  const startGeom = { ...geom };

                  // 2026-05-22 demo hot-fix parity — iframe 이 마우스 가로
                  // 채서 mouseup leak 일어남 (편집 모드에서 pe=auto).
                  const iframeEl = iframeRef.current;
                  const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : "";
                  if (iframeEl) iframeEl.style.pointerEvents = "none";

                  const isCorner =
                    direction === "nw" ||
                    direction === "ne" ||
                    direction === "sw" ||
                    direction === "se";

                  const onMove = (mv: MouseEvent) => {
                    // Convert pixel delta on the on-screen scaled slide
                    // back into percent-of-slide so all downstream math
                    // shares the persisted axis's coord space. W_SCALED /
                    // H_SCALED already include the wrapper scale factor,
                    // so dividing then multiplying by 100 gives a stable
                    // value regardless of viewport zoom.
                    const dx = ((mv.clientX - startMouseX) / W_SCALED) * 100;
                    const dy = ((mv.clientY - startMouseY) / H_SCALED) * 100;
                    // IMP-51 (#79) u9 — boundary contract lives in the
                    // pure helper so vitest can verify it directly.
                    // Aspect lock is default on for corner handles and
                    // released when Shift is held.
                    const aspectLocked = isCorner && !mv.shiftKey;
                    const next = clampImagePercentGeometry(
                      startGeom,
                      dx,
                      dy,
                      direction,
                      aspectLocked,
                      IMAGE_RESIZE_MIN_SIZE_PERCENT,
                    );
                    onImageResize(imageId, next);
                  };
                  const onUp = () => {
                    document.removeEventListener("mousemove", onMove);
                    document.removeEventListener("mouseup", onUp);
                    if (iframeEl) iframeEl.style.pointerEvents = prevIframePE;
                  };
                  document.addEventListener("mousemove", onMove);
                  document.addEventListener("mouseup", onUp);
                };

                return (
                  <div
                    key={`img-overlay-${imageId}`}
                    role="button"
                    tabIndex={0}
                    data-image-overlay-id={imageId}
                    onMouseDown={(ev) => beginDrag(ev, "move")}
                    className={`absolute z-30 ${
                      isSelected
                        ? "border-2 border-emerald-500 bg-emerald-500/5 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]"
                        : "border border-dashed border-emerald-400/60 hover:border-emerald-500"
                    } cursor-grab active:cursor-grabbing`}
                    style={{
                      left: `${geom.x}%`,
                      top: `${geom.y}%`,
                      width: `${geom.w}%`,
                      height: `${geom.h}%`,
                      pointerEvents: "auto",
                    }}
                    title={
                      isSelected
                        ? "이미지 이동 — 드래그 / 모서리 핸들 = 크기 (Shift = 비율 해제)"
                        : "클릭하여 선택"
                    }
                  >
                    <span className="absolute top-1 left-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-emerald-600/90 text-white shadow pointer-events-none">
                      IMG
                    </span>

                    {isSelected && (
                      <>
                        {/* edges */}
                        <div
                          onMouseDown={(ev) => beginDrag(ev, "top")}
                          onClick={(ev) => ev.stopPropagation()}
                          className="absolute -top-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
                          style={{ pointerEvents: "auto" }}
                          title="상단"
                        />
                        <div
                          onMouseDown={(ev) => beginDrag(ev, "bottom")}
                          onClick={(ev) => ev.stopPropagation()}
                          className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow"
                          style={{ pointerEvents: "auto" }}
                          title="하단"
                        />
                        <div
                          onMouseDown={(ev) => beginDrag(ev, "left")}
                          onClick={(ev) => ev.stopPropagation()}
                          className="absolute top-1/4 -left-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
                          style={{ pointerEvents: "auto" }}
                          title="좌측"
                        />
                        <div
                          onMouseDown={(ev) => beginDrag(ev, "right")}
                          onClick={(ev) => ev.stopPropagation()}
                          className="absolute top-1/4 -right-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow"
                          style={{ pointerEvents: "auto" }}
                          title="우측"
                        />
                        {/* corners — aspect locked by default, Shift unlocks */}
                        <div
                          onMouseDown={(ev) => beginDrag(ev, "nw")}
                          onClick={(ev) => ev.stopPropagation()}
                          className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
                          style={{ pointerEvents: "auto" }}
                          title="좌상단 (Shift = 비율 해제)"
                        />
                        <div
                          onMouseDown={(ev) => beginDrag(ev, "ne")}
                          onClick={(ev) => ev.stopPropagation()}
                          className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
                          style={{ pointerEvents: "auto" }}
                          title="우상단 (Shift = 비율 해제)"
                        />
                        <div
                          onMouseDown={(ev) => beginDrag(ev, "sw")}
                          onClick={(ev) => ev.stopPropagation()}
                          className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow"
                          style={{ pointerEvents: "auto" }}
                          title="좌하단 (Shift = 비율 해제)"
                        />
                        <div
                          onMouseDown={(ev) => beginDrag(ev, "se")}
                          onClick={(ev) => ev.stopPropagation()}
                          className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow"
                          style={{ pointerEvents: "auto" }}
                          title="우하단 (Shift = 비율 해제)"
                        />
                      </>
                    )}
                  </div>
                );
              }
            )}
        </div>
      )}
    </div>
  );
}
