/* ────────────────────────────────────────────────────────────
   collab.jsx — 에디터 통합 협업/검수 UI (feature b · 듀얼뷰 인지)
   설계 정본: docs/design/COLLAB-REVIEW.md (REST shape + UI), DESIGN-DECISIONS.md.
   여기서 만드는 것(전부 window 노출):
     · useCollab(projectId, editingSlideId, opts) — 5s GET /collab 폴링 + 10s 하트비트.
       반환 { presence, locks, changes, comments, role, lockHeldBy, refresh, claimLock, releaseLock,
              ackChange, ackSlide, postComment, resolveComment }.
     · CollabBar — 접속자 아바타(이니셜) + "○○ 편집 중" 칩.
     · LockChip — 슬라이드 잠금 상태 칩(내가 잠금 / ○○님이 편집 중 / 잠금 안내).
     · LockNotice — 저장 409 {code:slide_locked} 안내(해요체 · 다시 불러오기).
     · ChangeBadge + ChangeDiffPopover — '변경됨'(amber) 배지 + 전/후 토글 + 인라인 diff + 확인(ack).
     · diffInline — 단어 단위 LCS 인라인 diff(추가=positive bg, 삭제=취소선 muted).
     · CommentLayer — 비주얼 뷰 캔버스 좌표 핀(분수 x/y).
     · CommentColumn / CommentDot — 표 뷰 셀 표시점(필드 앵커).
     · CommentThread + CommentPanel — 슬라이드 스레드 목록 + @멘션 + 해결/해결취소 + 앵커 배치.
   계약 고정:
     - 케이싱 camelCase 봉투, 슬라이드 도메인 필드 v3 이름 보존(field=layout_suggestion 등).
     - 소프트락 MANDATORY: 편집하려면 잠금 필요. 409 {code:slide_locked, heldBy} 정중 처리.
     - 듀얼뷰 패리티: 한 store(useCollab.comments) → 두 투영(핀/표 점) → 같은 CommentThread.
   토큰(tokens.css) 변수만 · 해요체 · 이모지 없음 · 옵셔널 체이닝 · React.createElement.
   ──────────────────────────────────────────────────────────── */

const cc = React.createElement;

/* ── 폴링 케이던스 (COLLAB-REVIEW §3.1 / §11 · 단일 출처) ── */
const COLLAB_POLL_MS = 5000;       // GET /collab
const COLLAB_HEARTBEAT_MS = 10000; // POST /presence/heartbeat (+ 잠금 갱신)
const COLLAB_ONLINE_MS = 30000;    // last_seen < 30s = 온라인(읽기 측 필터)

/* 역할 랭크(COLLAB-REVIEW §0.1) — 쓰기 가능 판정용(클라 표시 게이트, 권위는 서버). */
const COLLAB_ROLE_RANK = { viewer: 1, reviewer: 2, editor: 3, owner: 4 };
function collabAtLeast(role, min) {
  return (COLLAB_ROLE_RANK[role] || 0) >= (COLLAB_ROLE_RANK[min] || 99);
}

/* 이름 → 이니셜(아바타). 한글은 첫 글자, 영문은 최대 2글자. 빈 값은 "?" */
function collabInitials(name) {
  const n = (name || "").trim();
  if (!n) return "?";
  const parts = n.split(/\s+/).filter(Boolean);
  if (/[가-힣]/.test(n)) return n.slice(0, 1);
  if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
  return n.slice(0, 2).toUpperCase();
}

/* 안정적 아바타 색 — userId/name 해시로 토큰색 순환(저장 X). */
const COLLAB_AVATAR_COLORS = [
  "var(--primary)", "var(--violet)", "var(--cyan)", "var(--positive)", "var(--caution)", "var(--negative)",
];
function collabColorFor(key) {
  const s = String(key || "");
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
  return COLLAB_AVATAR_COLORS[Math.abs(h) % COLLAB_AVATAR_COLORS.length];
}

/* 상대 시간(해요체) — "방금 전 · N분 전 · N시간 전 · YYYY.MM.DD" */
function collabAgo(iso) {
  if (!iso) return "";
  const t = (typeof iso === "number") ? iso : Date.parse(iso);
  if (!t || isNaN(t)) return "";
  const diff = Date.now() - t;
  if (diff < 60000) return "방금 전";
  if (diff < 3600000) return Math.floor(diff / 60000) + "분 전";
  if (diff < 86400000) return Math.floor(diff / 3600000) + "시간 전";
  try {
    const d = new Date(t);
    return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`;
  } catch (e) { return ""; }
}

/* ── 단어 단위 LCS 인라인 diff (COLLAB-REVIEW §2.3) ──
   before/after 문자열을 공백 토큰화 → LCS → 토큰 리스트 [{t,kind}] 반환.
   kind: 'same' | 'add'(after에만) | 'del'(before에만). 순수 함수(검증 용이). */
function collabTokenize(s) {
  if (s == null) return [];
  return String(s).split(/(\s+)/).filter((x) => x.length > 0);
}
function diffTokens(beforeStr, afterStr) {
  const a = collabTokenize(beforeStr);
  const b = collabTokenize(afterStr);
  const n = a.length, m = b.length;
  // LCS 길이표(상한 가드 — 아주 긴 텍스트는 셀 폭탄 방지로 통째 치환 표시)
  if (n * m > 40000) {
    const out = [];
    if (beforeStr) out.push({ t: beforeStr, kind: "del" });
    if (afterStr) out.push({ t: afterStr, kind: "add" });
    return out;
  }
  const dp = [];
  for (let i = 0; i <= n; i++) { dp[i] = new Array(m + 1).fill(0); }
  for (let i = n - 1; i >= 0; i--) {
    for (let j = m - 1; j >= 0; j--) {
      dp[i][j] = (a[i] === b[j]) ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
    }
  }
  const out = [];
  let i = 0, j = 0;
  while (i < n && j < m) {
    if (a[i] === b[j]) { out.push({ t: a[i], kind: "same" }); i++; j++; }
    else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ t: a[i], kind: "del" }); i++; }
    else { out.push({ t: b[j], kind: "add" }); j++; }
  }
  while (i < n) { out.push({ t: a[i], kind: "del" }); i++; }
  while (j < m) { out.push({ t: b[j], kind: "add" }); j++; }
  return out;
}

/* diff 토큰 → React 노드(추가=positive bg, 삭제=취소선 muted). */
function diffInline(beforeStr, afterStr) {
  const toks = diffTokens(beforeStr, afterStr);
  return cc("span", { style: { lineHeight: 1.7, wordBreak: "break-word", whiteSpace: "pre-wrap" } },
    toks.map((tk, i) => {
      if (tk.kind === "add") return cc("span", { key: i, style: { background: "var(--positive-bg)", color: "var(--positive)", borderRadius: 2, padding: "0 1px" } }, tk.t);
      if (tk.kind === "del") return cc("span", { key: i, style: { color: "var(--label-assist)", textDecoration: "line-through" } }, tk.t);
      return cc("span", { key: i, style: { color: "var(--label-neutral)" } }, tk.t);
    }));
}

/* 값 → 사람이 읽는 문자열(diff/표시용). 배열·객체는 요약. */
function collabValToStr(v) {
  if (v == null) return "";
  if (typeof v === "string") return v;
  if (Array.isArray(v)) return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join("\n");
  if (typeof v === "object") return JSON.stringify(v);
  return String(v);
}

/* ── useCollab — 단일 5s GET /collab 폴링 + 10s 하트비트 + 잠금/변경/코멘트 액션 ──
   엔드포인트(COLLAB-REVIEW §3.5/§2.5/§4.3):
     GET    /api/projects/:id/collab?since=<iso>   → {presence,locks,changes,comments}
     POST   /api/projects/:id/presence/heartbeat   {status,slideId,editingSlideId}
     POST   /api/projects/:id/slides/:slideId/lock  → {lock,held} · 409 {error,lock,code:slide_locked}
     DELETE /api/projects/:id/slides/:slideId/lock
     POST   /api/projects/:id/changes/:changeId/ack
     POST   /api/projects/:id/changes/ack          {changeIds|slideId}
     POST   /api/projects/:id/comments             {slideId,anchor,body,parentId,mentions}
     POST   /api/projects/:id/comments/:commentId/resolve {resolved}
   document.hidden 이면 폴링·하트비트 일시정지. 언마운트 시 clearInterval + 잠금 해제(best-effort). */
function useCollab(projectId, editingSlideId, opts) {
  const options = opts || {};
  const role = options.role || null;            // 프로젝트 내 내 역할(없으면 서버가 권위)
  const me = options.user || null;              // {id,name}

  const [presence, setPresence] = useState([]);
  const [locks, setLocks] = useState([]);
  const [changes, setChanges] = useState([]);
  const [comments, setComments] = useState([]);
  const [connected, setConnected] = useState(false);

  const pollTimer = useRef(null);
  const beatTimer = useRef(null);
  const editingRef = useRef(editingSlideId || null);
  const sinceRef = useRef(null);
  useEffect(() => { editingRef.current = editingSlideId || null; }, [editingSlideId]);

  const fetchCollab = useCallback(async () => {
    if (!projectId) return;
    if (typeof document !== "undefined" && document.hidden) return;
    try {
      const data = await api(`/projects/${projectId}/collab`);   // 전체 스냅샷(증분 since 는 서버 옵션)
      if (data) {
        const now = Date.now();
        const pres = Array.isArray(data.presence) ? data.presence.filter((p) => {
          const t = p && p.lastSeen ? Date.parse(p.lastSeen) : now;
          return !t || isNaN(t) || (now - t) < COLLAB_ONLINE_MS;     // 스테일 방어(읽기 측 필터)
        }) : [];
        setPresence(pres);
        setLocks(Array.isArray(data.locks) ? data.locks : []);
        setChanges(Array.isArray(data.changes) ? data.changes : []);
        setComments(Array.isArray(data.comments) ? data.comments : []);
        setConnected(true);
        sinceRef.current = new Date().toISOString();
      }
    } catch (ex) {
      // 협업 신호는 비치명적 — 에디터 본기능을 막지 않아요(백지 금지).
      setConnected(false);
    }
  }, [projectId]);

  const heartbeat = useCallback(async () => {
    if (!projectId) return;
    if (typeof document !== "undefined" && document.hidden) return;
    const editing = editingRef.current;
    try {
      await api(`/projects/${projectId}/presence/heartbeat`, {
        method: "POST",
        body: {
          status: editing ? "editing" : "viewing",
          slideId: options.viewingSlideId || editing || null,
          editingSlideId: editing || null,       // 보유 잠금 갱신(now()+30s)
        },
      });
    } catch (ex) { /* 비치명적 */ }
  }, [projectId, options.viewingSlideId]);

  /* 마운트/projectId 변경 시 폴링·하트비트 시작, document.visibility 변화 시 재개. */
  useEffect(() => {
    if (!projectId) return;
    fetchCollab(); heartbeat();
    pollTimer.current = setInterval(fetchCollab, COLLAB_POLL_MS);
    beatTimer.current = setInterval(heartbeat, COLLAB_HEARTBEAT_MS);
    const onVis = () => { if (typeof document !== "undefined" && !document.hidden) { fetchCollab(); heartbeat(); } };
    if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVis);
    return () => {
      if (pollTimer.current) clearInterval(pollTimer.current);
      if (beatTimer.current) clearInterval(beatTimer.current);
      if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVis);
    };
  }, [projectId, fetchCollab, heartbeat]);

  /* ── 잠금 claim/release (COLLAB-REVIEW §3.5) ──
     claimLock → {held:true, lock} · 409 시 throw(에러에 payload.lock=holder). */
  const claimLock = useCallback(async (slideId) => {
    if (!projectId || !slideId) return null;
    const data = await api(`/projects/${projectId}/slides/${slideId}/lock`, { method: "POST", body: {} });
    fetchCollab();
    return data;
  }, [projectId, fetchCollab]);

  const releaseLock = useCallback(async (slideId) => {
    if (!projectId || !slideId) return;
    // 계약 정본: DELETE /slides/:slideId/lock. 언마운트/탭 종료에도 닿도록 keepalive 로 보내요
    // (sendBeacon 은 POST 만 가능 → 메서드를 못 맞춰 사용하지 않아요). 실패해도 서버 TTL(30s) 이
    // 스테일 잠금을 정리하므로 무해해요.
    try {
      await fetch(`/api/projects/${projectId}/slides/${slideId}/lock`, {
        method: "DELETE", credentials: "include", keepalive: true,
        headers: { "Content-Type": "application/json" }, body: JSON.stringify({}),
      });
    } catch (ex) { /* no-op(보유자 아니면 무해) */ }
    fetchCollab();
  }, [projectId, fetchCollab]);

  /* ── 변경 ack (COLLAB-REVIEW §2.5) ── */
  const ackChange = useCallback(async (changeId) => {
    if (!projectId || !changeId) return;
    await api(`/projects/${projectId}/changes/${changeId}/ack`, { method: "POST", body: {} });
    setChanges((cur) => cur.filter((c) => (c.changeId || c.id) !== changeId));
    fetchCollab();
  }, [projectId, fetchCollab]);

  const ackSlide = useCallback(async (slideId) => {
    if (!projectId || !slideId) return;
    await api(`/projects/${projectId}/changes/ack`, { method: "POST", body: { slideId } });
    setChanges((cur) => cur.filter((c) => c.slideId !== slideId));
    fetchCollab();
  }, [projectId, fetchCollab]);

  /* ── 코멘트 (COLLAB-REVIEW §4.3) ── */
  const postComment = useCallback(async (payload) => {
    if (!projectId) return null;
    const data = await api(`/projects/${projectId}/comments`, { method: "POST", body: payload });
    fetchCollab();
    return data && data.comment ? data.comment : null;
  }, [projectId, fetchCollab]);

  const resolveComment = useCallback(async (commentId, resolved) => {
    if (!projectId || !commentId) return null;
    const data = await api(`/projects/${projectId}/comments/${commentId}/resolve`, { method: "POST", body: { resolved: !!resolved } });
    fetchCollab();
    return data && data.comment ? data.comment : null;
  }, [projectId, fetchCollab]);

  /* 파생: 편집 중인 슬라이드의 잠금 보유자(있으면 {slideId,userId,name}). */
  const lockFor = useCallback((slideId) => {
    if (!slideId) return null;
    return (locks || []).find((l) => l.slideId === slideId) || null;
  }, [locks]);

  const canWrite = role ? collabAtLeast(role, "editor") : true;   // role 미상이면 서버에 위임(표시는 허용)
  const canReview = role ? collabAtLeast(role, "reviewer") : true;

  return {
    presence, locks, changes, comments, connected,
    role, canWrite, canReview, me,
    refresh: fetchCollab,
    claimLock, releaseLock, lockFor,
    ackChange, ackSlide,
    postComment, resolveComment,
  };
}

/* ── 아바타(이니셜) ── status='editing' 이면 success 점 표시. */
function CollabAvatar({ name, userId, status, sm }) {
  const sz = sm ? 24 : 28;
  const editing = status === "editing";
  return cc("div", { title: (name || "사용자") + (editing ? " · 편집 중" : ""), style: { position: "relative", width: sz, height: sz, flexShrink: 0 } },
    cc("div", {
      style: {
        width: sz, height: sz, borderRadius: "50%", background: collabColorFor(userId || name),
        color: "#fff", display: "flex", alignItems: "center", justifyContent: "center",
        fontSize: sm ? 11 : 12, fontWeight: 800, boxSizing: "border-box", border: "2px solid var(--bg-normal)",
      },
    }, collabInitials(name)),
    editing ? cc("span", {
      "aria-label": "편집 중",
      style: { position: "absolute", right: -1, bottom: -1, width: 9, height: 9, borderRadius: "50%", background: "var(--positive)", border: "2px solid var(--bg-normal)" },
    }) : null);
}

/* ── CollabBar — 접속자 아바타 묶음 + "○○ 편집 중" 칩 ──
   props: presence([{userId,name,status,slideId,lastSeen}]), me({id}), connected. */
function CollabBar({ presence, me, connected }) {
  const list = Array.isArray(presence) ? presence : [];
  const meId = me && me.id;
  const others = list.filter((p) => !(meId && p.userId === meId));
  const editors = others.filter((p) => p.status === "editing");
  const shown = others.slice(0, 4);
  const extra = others.length - shown.length;

  if (others.length === 0) {
    return cc("div", { style: { display: "inline-flex", alignItems: "center", gap: 6 } },
      cc("span", {
        title: connected ? "지금 보는 사람은 나뿐이에요" : "협업 정보를 불러오는 중이에요",
        style: { width: 7, height: 7, borderRadius: "50%", background: connected ? "var(--positive)" : "var(--label-disable)" },
      }),
      cc("span", { className: "t-caption-1", style: { color: "var(--label-assist)" } }, "나만 보는 중"));
  }

  return cc("div", { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
    cc("div", { style: { display: "inline-flex", alignItems: "center" } },
      shown.map((p, i) => cc("span", { key: p.userId || i, style: { marginLeft: i ? -7 : 0, zIndex: shown.length - i } },
        cc(CollabAvatar, { name: p.name, userId: p.userId, status: p.status, sm: true }))),
      extra > 0 ? cc("span", {
        style: { marginLeft: -7, width: 24, height: 24, borderRadius: "50%", background: "var(--bg-alt)", color: "var(--label-alt)", border: "2px solid var(--bg-normal)", display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: 10.5, fontWeight: 800 },
      }, "+" + extra) : null),
    editors.length > 0
      ? cc("span", { style: { display: "inline-flex", alignItems: "center", gap: 5, height: 22, padding: "0 9px", borderRadius: "var(--r-full)", background: "var(--positive-bg)", color: "var(--positive)", fontSize: 11.5, fontWeight: 700 } },
          cc("span", { style: { width: 6, height: 6, borderRadius: "50%", background: "var(--positive)" } }),
          (editors[0].name || "다른 사용자") + (editors.length > 1 ? ` 외 ${editors.length - 1}명` : "") + " 편집 중")
      : null);
}

/* ── LockChip — 슬라이드 잠금 상태 칩 ──
   mode: 'held'(내가 잠금) | 'other'(○○님이 편집 중) | 'free'(편집하려면 잠금) | 'claiming'.
   props: lock({name}|null), heldByMe, canWrite, onClaim, onRelease, claiming. */
function LockChip({ lock, heldByMe, canWrite, onClaim, onRelease, claiming }) {
  const lockIcon = cc("svg", { width: 13, height: 13, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", style: { flexShrink: 0 } },
    cc("rect", { x: 4, y: 11, width: 16, height: 10, rx: 2 }), cc("path", { d: "M8 11V7a4 4 0 0 1 8 0v4" }));

  if (claiming) {
    return cc("span", { style: { display: "inline-flex", alignItems: "center", gap: 6, height: 26, padding: "0 10px", borderRadius: "var(--r-full)", background: "var(--bg-alt)", color: "var(--label-alt)", fontSize: 12, fontWeight: 700 } },
      cc(Spinner, { s: 12 }), "잠그는 중");
  }

  if (heldByMe) {
    return cc("span", { style: { display: "inline-flex", alignItems: "center", gap: 6, height: 26, padding: "0 6px 0 10px", borderRadius: "var(--r-full)", background: "var(--primary-light)", color: "var(--primary)", fontSize: 12, fontWeight: 700 } },
      lockIcon, "내가 편집 중(잠금)",
      onRelease ? cc("button", {
        onClick: onRelease, title: "잠금을 풀어 다른 사람이 편집할 수 있게 해요",
        style: { height: 20, padding: "0 8px", borderRadius: "var(--r-full)", border: "1px solid var(--primary)", background: "var(--bg-normal)", color: "var(--primary)", fontSize: 11, fontWeight: 700, cursor: "pointer" },
      }, "잠금 해제") : null);
  }

  if (lock) {
    return cc("span", { style: { display: "inline-flex", alignItems: "center", gap: 6, height: 26, padding: "0 11px", borderRadius: "var(--r-full)", background: "var(--caution-bg)", color: "var(--caution)", fontSize: 12, fontWeight: 700 } },
      lockIcon, (lock.name || "다른 사용자") + "님이 편집 중이에요");
  }

  // free — 편집하려면 잠금
  if (!canWrite) {
    return cc("span", { style: { display: "inline-flex", alignItems: "center", gap: 6, height: 26, padding: "0 11px", borderRadius: "var(--r-full)", background: "var(--bg-alt)", color: "var(--label-assist)", fontSize: 12, fontWeight: 700 } },
      lockIcon, "읽기 전용이에요");
  }
  return cc("button", {
    onClick: onClaim, title: "이 슬라이드를 잠그고 편집을 시작해요",
    style: { display: "inline-flex", alignItems: "center", gap: 6, height: 26, padding: "0 11px", borderRadius: "var(--r-full)", border: "1px solid var(--border)", background: "var(--bg-normal)", color: "var(--label-neutral)", fontSize: 12, fontWeight: 700, cursor: "pointer" },
  }, lockIcon, "편집하려면 잠금");
}

/* ── LockNotice — 저장 409 {code:slide_locked} 안내(해요체 · 다시 불러오기) ──
   props: heldBy({name}|null), onReload, onClose. */
function LockNotice({ heldBy, onReload, onClose }) {
  return cc("div", {
    className: "t-label-2",
    style: { color: "var(--caution)", background: "var(--caution-bg)", margin: "12px 16px 0", borderRadius: "var(--r-sm)", padding: "10px 14px", display: "flex", alignItems: "center", gap: 10 },
  },
    cc("svg", { width: 15, height: 15, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", style: { flexShrink: 0 } },
      cc("rect", { x: 4, y: 11, width: 16, height: 10, rx: 2 }), cc("path", { d: "M8 11V7a4 4 0 0 1 8 0v4" })),
    cc("span", { style: { flex: 1 } },
      (heldBy && heldBy.name ? heldBy.name + "님이 " : "다른 사용자가 ") + "먼저 이 슬라이드를 편집하고 있어요. 방금 저장은 적용되지 않았어요. 최신 내용을 다시 불러와 주세요."),
    onReload ? cc("button", {
      onClick: onReload,
      style: { height: 28, padding: "0 12px", borderRadius: "var(--r-sm)", border: "1px solid var(--caution)", background: "var(--bg-normal)", color: "var(--caution)", fontSize: 12, fontWeight: 700, cursor: "pointer", flexShrink: 0 },
    }, "다시 불러오기") : null,
    onClose ? cc("button", { onClick: onClose, "aria-label": "닫기", style: { width: 24, height: 24, border: "none", background: "transparent", color: "var(--caution)", cursor: "pointer", fontSize: 16, lineHeight: 1, flexShrink: 0 } }, "×") : null);
}

/* v3 필드명 → 한글 라벨(변경 배지/diff 헤더). */
const COLLAB_FIELD_LABELS = {
  title: "화면 제목", content: "핵심 키워드", narration: "나레이션",
  layout_suggestion: "레이아웃", notes: "화면설명", objects: "캔버스",
  illustration_needed: "삽화 필요", illustration_prompt: "삽화 프롬프트",
  content_density: "콘텐츠 밀도", diagram_subtype: "도식 종류", steps_style: "단계 표시",
  card_count: "카드 개수", confirmed: "확정", __slide__: "슬라이드",
};
function collabFieldLabel(f) { return COLLAB_FIELD_LABELS[f] || f || "필드"; }

/* ── ChangeBadge — '변경됨'(amber) 배지. 클릭 → ChangeDiffPopover ──
   props: change({changeId,field,beforeValue,afterValue,changeKind,author,createdAt}), onAck. */
function ChangeBadge({ change, onAck, anchor }) {
  const [open, setOpen] = useState(false);
  if (!change) return null;
  return cc("span", { style: { position: "relative", display: "inline-flex" } },
    cc("button", {
      onClick: (e) => { e.stopPropagation(); setOpen((v) => !v); },
      title: "변경된 내용을 확인해요",
      style: {
        display: "inline-flex", alignItems: "center", gap: 4, height: 20, padding: "0 8px",
        borderRadius: "var(--r-full)", border: "1px solid var(--caution)", background: "var(--caution-bg)",
        color: "var(--caution)", fontSize: 10.5, fontWeight: 800, cursor: "pointer", whiteSpace: "nowrap",
      },
    },
      cc("span", { style: { width: 5, height: 5, borderRadius: "50%", background: "var(--caution)" } }), "변경됨"),
    open ? cc(ChangeDiffPopover, {
      change,
      onAck: onAck ? () => { onAck(change.changeId || change.id); setOpen(false); } : null,
      onClose: () => setOpen(false),
      placement: anchor,
    }) : null);
}

/* ── ChangeDiffPopover — 변경 전/후 토글 + 인라인 diff + 누가·언제 + 확인(ack) ── */
function ChangeDiffPopover({ change, onAck, onClose, placement }) {
  const [showBefore, setShowBefore] = useState(false);   // 기본 '후'(after) + amber
  const beforeStr = collabValToStr(change.beforeValue);
  const afterStr = collabValToStr(change.afterValue);
  const isObjects = change.field === "objects" || change.changeKind && change.changeKind !== "update";
  const authorName = (change.author && change.author.name) || "다른 사용자";

  return cc("div", {
    onMouseDown: (e) => e.stopPropagation(), onClick: (e) => e.stopPropagation(),
    style: {
      position: "absolute", top: 26, zIndex: 9999, width: 320,
      [placement === "right" ? "right" : "left"]: 0,
      background: "var(--bg-normal)", border: "1px solid var(--border)", borderRadius: "var(--r-md)",
      boxShadow: "var(--sh-3)", padding: 14,
    },
  },
    cc("div", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 10 } },
      cc("span", { className: "t-label-1", style: { color: "var(--label-normal)" } }, collabFieldLabel(change.field)),
      /* 전/후 토글 */
      cc("div", { style: { marginLeft: "auto", display: "inline-flex", padding: 2, gap: 2, background: "var(--bg-alt)", borderRadius: "var(--r-sm)" } },
        [["변경 후", false], ["변경 전", true]].map(([lbl, val]) => cc("button", {
          key: lbl, onClick: () => setShowBefore(val),
          style: { height: 22, padding: "0 8px", borderRadius: 5, border: "none", cursor: "pointer", fontSize: 11, fontWeight: 700, background: showBefore === val ? "var(--bg-normal)" : "transparent", color: showBefore === val ? "var(--primary)" : "var(--label-alt)", boxShadow: showBefore === val ? "var(--sh-1)" : "none" },
        }, lbl))),
      cc("button", { onClick: onClose, "aria-label": "닫기", style: { width: 22, height: 22, border: "none", background: "transparent", color: "var(--label-alt)", cursor: "pointer", fontSize: 15, lineHeight: 1 } }, "×")),

    /* 본문 — objects/슬라이드 변경은 요약, 텍스트는 인라인 diff */
    cc("div", { style: { maxHeight: 200, overflowY: "auto", padding: "9px 11px", borderRadius: "var(--r-sm)", background: "var(--bg-neutral)", border: "1px solid var(--line-alt)", fontSize: 12.5, marginBottom: 10 } },
      isObjects
        ? cc("span", { style: { color: "var(--label-neutral)" } },
            change.changeKind && change.changeKind !== "update"
              ? ({ slide_insert: "슬라이드를 추가했어요.", slide_delete: "슬라이드를 삭제했어요.", reorder: "슬라이드 순서를 바꿨어요." }[change.changeKind] || "변경했어요.")
              : "캔버스 구성이 바뀌었어요.")
        : showBefore
          ? cc("span", { style: { color: "var(--label-assist)", whiteSpace: "pre-wrap", wordBreak: "break-word" } }, beforeStr || cc("span", { style: { fontStyle: "italic" } }, "(이전 값 없음)"))
          : diffInline(beforeStr, afterStr)),

    /* 누가·언제 + 확인 */
    cc("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
      cc("span", { className: "t-caption-1", style: { color: "var(--label-alt)" } }, `${authorName} · ${collabAgo(change.createdAt)}`),
      onAck ? cc("button", {
        onClick: onAck,
        style: { marginLeft: "auto", height: 28, padding: "0 14px", borderRadius: "var(--r-sm)", border: "1px solid var(--primary)", background: "var(--primary)", color: "#fff", fontSize: 12, fontWeight: 700, cursor: "pointer" },
      }, "확인") : null));
}

/* 특정 (slideId, field)에 대한 '열린' 변경(내가 작성하지 않음 · 미확인)을 찾아요(§2.4). */
