/**
 * 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 {
  clampZoneMove,
  crossedDragThreshold,
} from "./slideCanvasDragMath";

interface SlideCanvasProps {
  slidePlan: SlidePlan | null;
  normalizedContent: NormalizedContent | null;
  userSelection: UserSelection;
  /** Phase Z 가 만든 final.html URL (iframe 으로 표시). */
  finalHtmlUrl?: string;
  /** 슬라이드 단위 inline CSS override (catalog/template 무변, iframe contentDocument 에
   *  동적 inject). Home 이 mdx 별 default visual 보완 등을 지정. 빈 문자열/undefined =
   *  inject 안 함. 사용자 lock 2026-05-14 — slide-level only. */
  slideOverrideCss?: 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;
}

const SLIDE_W = 1280;
const SLIDE_H = 720;

export default function SlideCanvas({
  slidePlan,
  userSelection,
  finalHtmlUrl,
  slideOverrideCss,
  isPipelineRunning,
  isPendingLayout,
  pendingLayoutId,
  onCancelPendingLayout,
  onContentEdit,
  onZoneClick,
  onSlideClick,
  onSectionDrop,
  onZoneResize,
}: 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);
  // HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용.
  // 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업.
  // pendingLayout 과 배타적 (충돌 방지).
  const [isEditMode, setIsEditMode] = useState(false);
  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;
    if (isEditMode) {
      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);
    } else {
      doc.designMode = "off";
      doc.querySelectorAll("[contenteditable]").forEach((el) => {
        (el as HTMLElement).removeAttribute("contenteditable");
      });
    }

    return () => {
      if (inputHandler && doc) {
        doc.removeEventListener("input", inputHandler);
      }
    };
  }, [isEditMode, finalHtmlUrl, onContentEdit]);

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

  // finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset.
  // 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움.
  useEffect(() => {
    setMeasuredZones({});
    setMeasuredSlideBody(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>
          )}

          {/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만.
              글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */}
          {!isPendingLayout && finalHtmlUrl && (
            <button
              onClick={(e) => {
                e.stopPropagation();
                setIsEditMode((p) => !p);
              }}
              className={`absolute top-2 right-2 z-30 text-[10px] font-bold uppercase tracking-tighter px-2.5 py-1 rounded shadow transition ${
                isEditMode
                  ? "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"
              }`}
              style={{ pointerEvents: "auto" }}
              title={
                isEditMode
                  ? "편집 모드 — 텍스트 클릭하여 수정. 다시 클릭하여 종료. (변경은 frontend 만, backend 반영 미구현)"
                  : "텍스트 직접 편집 모드 진입"
              }
            >
              {isEditMode ? "✏ 편집 중 (클릭 종료)" : "✏ 편집"}
            </button>
          )}

          <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"
              style={{ pointerEvents: isEditMode ? "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;

                  // 2026-05-14 — slide-level override CSS (catalog/template 무변).
                  // Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
                  if (slideOverrideCss && slideOverrideCss.trim()) {
                    const overrideStyle = doc.createElement("style");
                    overrideStyle.setAttribute("data-purpose", "slide-level-override");
                    overrideStyle.textContent = slideOverrideCss;
                    doc.head.appendChild(overrideStyle);
                  }

                  // ── 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,
                    });
                  }
                } 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 편집 모드 활성. 2026-05-22 demo hot-fix —
              // frame partial 에 @container aspect-ratio 회전이 들어가서 fixed px 제약 사라짐.
              if ((!isPendingLayout && !isEditMode) || !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();
              const canDrag = !!((isPendingLayout || isEditMode) && 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 편집 모드 활성.
                    2026-05-22 demo hot-fix — frame partial 에 @container aspect-ratio 회전
                    들어간 후 fixed px 제약 사라져 편집 모드 resize 도 의미 있음.
                    edge handle (top/bottom/left/right) : 한 boundary 이동
                    corner handle (nw/ne/sw/se) : 두 boundary 동시. */}
                {(isPendingLayout || isEditMode) && 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. */}
                {isEditMode && !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>
            );
          })}
        </div>
      )}
    </div>
  );
}
