<script lang="ts">
  /**
   * HandwriteCanvas — Apple Pencil 필기 엔진 (전면 재작성).
   *
   * 4축 fix 통합:
   *   1) 입력 수집: PointerEvent + getCoalescedEvents() + setPointerCapture
   *      - pointerType === 'pen' 만 인식 (mouse 도 데스크톱 테스트용)
   *      - isDrawing flag + activePointerId 매칭
   *      - pointerup/cancel/leave 통합 finalize
   *   2) 렌더링: perfect-freehand getStroke → SVG path → Path2D → ctx.fill()
   *      - getSvgPathFromStroke 표준 builder
   *      - thinning/smoothing/streamline 튜닝
   *      - pressure 0 fallback (점선 방지)
   *   3) 입력/저장 분리: autosave 는 3초 idle debounce + setTimeout 0
   *   4) Safari hardening: touch-action / user-select / -webkit-touch-callout
   *      / oncontextmenu / onselectstart 차단. button 은 clickThenBlur + tabindex=-1.
   */
  import { onMount, onDestroy } from 'svelte';
  import { getStroke } from 'perfect-freehand';
  import { Eraser, Pencil, Undo2, Redo2, Trash2 } from 'lucide-svelte';
  import IconButton from '$lib/components/ui/IconButton.svelte';

  type Point = [number, number, number]; // [x, y, pressure]
  // _path2d / _size 는 런타임 캐시 — 직렬화 시 제외 (밑줄 prefix 가 marker).
  // 매 RAF frame 마다 모든 stroke 의 perfect-freehand outline 을 재계산하던
  // R3 hot path 를 stroke 당 1회로 줄여 frame budget 초과 (= 입력 적체) 방지.
  type Stroke = {
    id: string;
    points: Point[];
    _path2d?: Path2D;
    _size?: number;
  };
  export type StrokesJson = { version: 1; strokes: Stroke[] };
  type Tool = 'pen' | 'eraser';
  type WidthMode = 'thin' | 'normal' | 'thick';

  interface Props {
    sessionId: number;
    initialStrokes?: StrokesJson | null;
    traceText?: string | null;
    baseSize?: number;
    onChange?: (strokes: StrokesJson) => void;
    onSnapshot?: (blob: Blob) => Promise<void>;
  }

  let {
    sessionId,
    initialStrokes = null,
    traceText = null,
    baseSize = 6,
    onChange,
    onSnapshot,
  }: Props = $props();

  // ── 상태 ──
  let canvas = $state<HTMLCanvasElement | null>(null);
  let containerEl = $state<HTMLDivElement | null>(null);
  let cssWidth = $state(800);
  let cssHeight = $state(600);

  let strokes = $state<Stroke[]>(initialStrokes?.strokes ?? []);
  let undoStack = $state<Stroke[]>([]);
  // inflight 는 plain 변수 — Svelte 5 deep proxy 가 매 pointermove 의 coalesced
  // push 마다 reactive notify 하면서 onPointerMove 핸들러 실행 시간 누적 → 다음
  // pointermove 가 native queue 에 적체 → 빠른 stroke 시 capture race 가능성.
  // UI 는 redraw 함수가 호출 시점에 inflight 를 직접 읽으므로 reactive 불필요.
  let inflight: Stroke | null = null;

  // 입력 상태머신 — 다음 stroke 가 막히지 않게 명확히 관리. $state 유지 (디버그
  // 패널 표시 + 새 stroke 진입 가드 readability).
  let isDrawing = $state(false);
  let activePointerId = $state<number | null>(null);

  // ── 디버그 카운터 — DEV 빌드 또는 prod 에서 ?debug=1 query 시 활성화.
  // prod 에서 mutation 자체를 DCE 하려면 import.meta.env.DEV 단독이지만,
  // 사용자 iPad 진단을 위해 prod 에서도 query 로 토글 가능하게 함. const 라
  // 페이지 로드 시 한 번만 평가 (성능 영향 미미).
  const DBG = import.meta.env.DEV ||
    (typeof window !== 'undefined' && /[?&]debug=1\b/.test(window.location.search));
  let dbg = $state({
    down: 0, move: 0, up: 0, cancel: 0, leave: 0,
    rejectedByType: 0, rejectedByPointerId: 0,
    coalesced: 0,
    lastType: '-',
    lastPressure: 0,
  });

  let strokeColor = $state('#e4e4e7');
  let tool = $state<Tool>('pen');
  let widthMode = $state<WidthMode>('normal');
  const WIDTH_FACTOR: Record<WidthMode, number> = { thin: 0.6, normal: 1, thick: 1.6 };
  let effectiveSize = $derived(baseSize * WIDTH_FACTOR[widthMode]);
  let eraserRadius = $derived(Math.max(16, effectiveSize * 4));

  // 지우개 이동 경로의 직전 점. pointerdown 에서 set, pointermove 에서 segment
  // 시작점, end 에서 null. $state 아님 — 입력 루프 내부 값 (UI 미참조).
  let eraserLast: [number, number] | null = null;

  let isDirty = false;
  let saveTimer: number | null = null;
  let snapshotting = $state(false);
  let snapshotErr = $state<string | null>(null);

  // ── localStorage ──
  const lsKey = $derived(`study_session_${sessionId}_strokes`);
  // Path2D 등 런타임 캐시 (`_` prefix) 제외하고 직렬화. 서버/localStorage 에는 순수
  // {id, points} 만 저장.
  function serializableStrokes(): Pick<Stroke, 'id' | 'points'>[] {
    return strokes.map((s) => ({ id: s.id, points: s.points }));
  }
  // backup 은 stroke 완료마다 호출되지만 실제 sync I/O (JSON.stringify + localStorage
  // .setItem) 는 500ms idle 시 1회만. stroke 73 × 30점 = 65KB+ JSON 의 sync write 가
  // iPad CPU 에서 50~200ms main thread block → 다음 펜 입력 손실 (ㄱ 직후 ㅏ 안
  // 들어가는 회귀의 진짜 root cause). 빠른 연속 stroke 시 backup 0회 → block 0.
  let backupTimer: number | null = null;
  function backup() {
    if (backupTimer) clearTimeout(backupTimer);
    backupTimer = window.setTimeout(flushBackup, 500) as unknown as number;
  }
  function flushBackup() {
    if (backupTimer) { clearTimeout(backupTimer); backupTimer = null; }
    try {
      localStorage.setItem(
        lsKey,
        JSON.stringify({ version: 1, strokes: serializableStrokes() }),
      );
    } catch {}
  }
  function restoreFromLocalStorageIfNewer() {
    try {
      const raw = localStorage.getItem(lsKey);
      if (!raw) return;
      const parsed = JSON.parse(raw) as StrokesJson;
      if ((initialStrokes?.strokes?.length ?? 0) === 0 && parsed.strokes.length > 0) {
        strokes = parsed.strokes;
        scheduleSave();
      }
    } catch {}
  }

  // ── DPR + resize (layout/orientation 변화 시에만) ──
  function resizeCanvas() {
    if (!canvas || !containerEl) return;
    const rect = containerEl.getBoundingClientRect();
    cssWidth = Math.max(200, Math.floor(rect.width));
    cssHeight = Math.max(200, Math.floor(rect.height));
    const dpr = window.devicePixelRatio || 1;
    canvas.width = cssWidth * dpr;
    canvas.height = cssHeight * dpr;
    canvas.style.width = `${cssWidth}px`;
    canvas.style.height = `${cssHeight}px`;
    const ctx = canvas.getContext('2d');
    if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    scheduleRedraw();
  }

  // ── render (perfect-freehand polygon fill) ──
  // perfect-freehand README 의 표준 SVG path builder.
  function getSvgPathFromStroke(stroke: number[][]): string {
    if (!stroke.length) return '';
    const d: (string | number)[] = ['M', ...stroke[0], 'Q'];
    for (let i = 0; i < stroke.length; i++) {
      const [x0, y0] = stroke[i];
      const [x1, y1] = stroke[(i + 1) % stroke.length];
      d.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
    }
    d.push('Z');
    return d.join(' ');
  }

  function drawStroke(ctx: CanvasRenderingContext2D, s: Stroke, isInflight = false) {
    const pts = s.points;
    if (pts.length === 0) return;

    if (pts.length === 1) {
      const [x, y, p] = pts[0];
      const r = effectiveSize * (0.4 + p * 0.6) / 2;
      ctx.fillStyle = strokeColor;
      ctx.beginPath();
      ctx.arc(x, y, Math.max(1, r), 0, Math.PI * 2);
      ctx.fill();
      return;
    }

    // 완료 stroke 는 캐시된 Path2D 재사용 (size 변경 시만 재계산). inflight 는
    // 점이 매 frame 추가되므로 매번 재계산.
    let path = !isInflight && s._size === effectiveSize ? s._path2d : undefined;
    if (!path) {
      const outline = getStroke(pts, {
        size: effectiveSize,
        thinning: 0.25,
        smoothing: 0.85,
        streamline: 0.65,
        simulatePressure: false,
        last: !isInflight,
      });
      if (outline.length < 2) return;
      path = new Path2D(getSvgPathFromStroke(outline));
      if (!isInflight) {
        s._path2d = path;
        s._size = effectiveSize;
      }
    }
    ctx.fillStyle = strokeColor;
    ctx.fill(path);
  }

  function drawTraceBackground(ctx: CanvasRenderingContext2D) {
    if (!traceText) return;
    ctx.save();
    ctx.fillStyle = 'rgba(120, 120, 120, 0.18)';
    const fontSize = Math.min(cssWidth, cssHeight) * 0.4;
    ctx.font = `${fontSize}px sans-serif`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(traceText, cssWidth / 2, cssHeight / 2);
    ctx.restore();
  }

  function redraw() {
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;
    ctx.clearRect(0, 0, cssWidth, cssHeight);
    drawTraceBackground(ctx);
    for (const s of strokes) drawStroke(ctx, s, false);
    if (inflight) drawStroke(ctx, inflight, true);
  }

  // RAF throttle — pointermove 가 60Hz 보다 빠르게 들어와도 redraw 는 frame 당 1회.
  let rafScheduled = false;
  function scheduleRedraw() {
    if (rafScheduled) return;
    rafScheduled = true;
    requestAnimationFrame(() => {
      rafScheduled = false;
      redraw();
    });
  }

  // ── pointer ──
  function getLocalXY(e: PointerEvent): [number, number] {
    if (!canvas) return [0, 0];
    const rect = canvas.getBoundingClientRect();
    const scaleX = rect.width === 0 ? 1 : cssWidth / rect.width;
    const scaleY = rect.height === 0 ? 1 : cssHeight / rect.height;
    return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY];
  }

  function isPenLike(e: PointerEvent): boolean {
    // Apple Pencil ('pen') 만 stroke 입력. 손가락 ('touch') 거부 — palm rejection.
    // mouse 는 데스크톱 테스트 허용.
    return e.pointerType === 'pen' || e.pointerType === 'mouse';
  }

  function normalizePressure(p: number | undefined): number {
    if (typeof p !== 'number' || !Number.isFinite(p) || p <= 0.05) return 0.5;
    return Math.min(1, p);
  }

  // 점 사이 거리가 너무 멀면 중간 점 보간 — 빠른 stroke 의 sparse point 점선 방지.
  // iPad 60Hz pointermove + 빠른 펜 이동 시 점 간격이 16~30px 가 될 수 있음.
  const MAX_GAP_PX = 8;
  function pushPointWithInterp(target: Stroke, x: number, y: number, p: number) {
    const last = target.points[target.points.length - 1];
    if (last) {
      const dx = x - last[0];
      const dy = y - last[1];
      const dist = Math.hypot(dx, dy);
      if (dist > MAX_GAP_PX) {
        const steps = Math.ceil(dist / MAX_GAP_PX);
        const lp = last[2];
        for (let i = 1; i < steps; i++) {
          const t = i / steps;
          target.points.push([
            last[0] + dx * t,
            last[1] + dy * t,
            lp + (p - lp) * t,
          ]);
        }
      }
    }
    target.points.push([x, y, p]);
  }

  // 점 P 와 선분 [A, B] 사이 최단 거리의 제곱. 빠른 비교를 위해 sqrt 안 씀.
  function distSqPointToSegment(
    px: number, py: number,
    ax: number, ay: number, bx: number, by: number,
  ): number {
    const dx = bx - ax;
    const dy = by - ay;
    const lenSq = dx * dx + dy * dy;
    if (lenSq === 0) {
      const ex = px - ax;
      const ey = py - ay;
      return ex * ex + ey * ey;
    }
    let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
    if (t < 0) t = 0;
    else if (t > 1) t = 1;
    const ex = px - (ax + t * dx);
    const ey = py - (ay + t * dy);
    return ex * ex + ey * ey;
  }

  // 지우개 이동 경로 [A, B] 위에 stroke 점이 eraserRadius 이내로 들어오면 해당
  // stroke 통째 삭제 (object eraser). 단일 점 검사가 아니라 segment 검사라 빠른
  // 지우개 이동에서 점 사이 stroke 누락 방지. A == B 면 단일 점 검사로 환원.
  function eraseSegment(
    x0: number, y0: number, x1: number, y1: number,
  ): boolean {
    if (strokes.length === 0) return false;
    const r2 = eraserRadius * eraserRadius;
    const removed: Stroke[] = [];
    const keep: Stroke[] = [];
    for (const s of strokes) {
      let hit = false;
      for (const [px, py] of s.points) {
        if (distSqPointToSegment(px, py, x0, y0, x1, y1) <= r2) {
          hit = true;
          break;
        }
      }
      (hit ? removed : keep).push(s);
    }
    if (removed.length === 0) return false;
    strokes = keep;
    undoStack = [...undoStack, ...removed];
    return true;
  }

  function onPointerDown(e: PointerEvent) {
    if (DBG) dbg = { ...dbg, down: dbg.down + 1, lastType: e.pointerType, lastPressure: e.pressure };
    if (!canvas) return;
    if (!isPenLike(e)) {
      if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 };
      return;
    }
    // pen 의 hover-down (buttons===0) 은 무시 — 실제 접촉이 아닌 hover 진입.
    // Pencil 닿는 순간 buttons===1. 이걸 stroke 시작으로 오인하면 hover 이동이
    // 점으로 추가됨.
    if (e.pointerType === 'pen' && e.buttons === 0) {
      if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1, lastType: 'hover-down' };
      return;
    }
    // 이미 다른 pointer 가 그리는 중이면 무시 — palm rejection / multi-touch race 차단.
    // (Apple Pencil 진행 중에 손가락 닿거나 두 번째 pen 시도하면 둘 다 망가짐)
    if (isDrawing) return;

    e.preventDefault();
    try { document.getSelection?.()?.removeAllRanges?.(); } catch {}

    // 이전 inflight 가 살아있다면 폐기 (다음 stroke 와 섞이지 않게).
    inflight = null;

    try { canvas.setPointerCapture(e.pointerId); } catch {}
    activePointerId = e.pointerId;
    isDrawing = true;

    const [x, y] = getLocalXY(e);

    if (tool === 'eraser') {
      eraserLast = [x, y];
      if (eraseSegment(x, y, x, y)) {
        isDirty = true;
        backup();
        scheduleSave();
        scheduleRedraw();
      }
      return;
    }

    inflight = {
      id: `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
      points: [[x, y, normalizePressure(e.pressure)]],
    };
    scheduleRedraw();
  }

  function onPointerMove(e: PointerEvent) {
    if (DBG) dbg = { ...dbg, move: dbg.move + 1, lastType: e.pointerType, lastPressure: e.pressure };
    if (!isDrawing) return;
    if (!isPenLike(e)) {
      if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 };
      return;
    }
    if (e.pointerId !== activePointerId) {
      if (DBG) dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 };
      return;
    }

    // ── Apple Pencil hover 감지 (iPadOS 17+) ─────────────────────────
    // 펜이 화면에서 떨어진 채로도 pointermove 가 발화 — pointerType==='pen' 이지만
    // buttons===0 (펜 안 닿음). pointerup 안 와도 hover 모드는 사실상 stroke 종료.
    // 이 케이스를 잡지 않으면 hover 이동이 stroke 의 점으로 추가됨 → ㄱ 다음에
    // ㅏ 위치로 hover 이동 시 ㄱ 끝점에서 ㅏ 위치까지 직선이 그어지거나 한 stroke
    // 가 의도치 않게 연장됨 (사용자 보고: ㄱ→ㅏ 가 안 써짐, 글씨 흩어짐).
    if (e.pointerType === 'pen' && e.buttons === 0) {
      try { canvas?.releasePointerCapture?.(e.pointerId); } catch {}
      activePointerId = null;
      isDrawing = false;
      if (DBG) dbg = { ...dbg, up: dbg.up + 1, lastType: 'hover-end' };
      if (tool === 'eraser') {
        inflight = null;
        eraserLast = null;
        return;
      }
      // pointerup 흐름: 1점 stroke (짧은 탭) 도 보존.
      if (inflight && inflight.points.length >= 1) {
        strokes = [...strokes, inflight];
        undoStack = [];
        isDirty = true;
        backup();
        scheduleSave();
      }
      inflight = null;
      scheduleRedraw();
      return;
    }

    const [x, y] = getLocalXY(e);

    if (tool === 'eraser') {
      const prev = eraserLast ?? [x, y];
      if (eraseSegment(prev[0], prev[1], x, y)) {
        isDirty = true;
        backup();
        scheduleSave();
        scheduleRedraw();
      }
      eraserLast = [x, y];
      return;
    }

    if (!inflight) return;

    // coalesced events: 빠른 필기에서 보간 점들 모두 반영. 추가로 점 사이 거리가
    // 8px 초과 시 자동 중간 점 보간 → 점선 방지.
    const coalesced = (e.getCoalescedEvents?.() ?? []) as PointerEvent[];
    if (DBG) dbg = { ...dbg, coalesced: dbg.coalesced + coalesced.length };
    if (coalesced.length > 0) {
      for (const ev of coalesced) {
        const [cx, cy] = getLocalXY(ev);
        pushPointWithInterp(inflight, cx, cy, normalizePressure(ev.pressure));
      }
    } else {
      pushPointWithInterp(inflight, x, y, normalizePressure(e.pressure));
    }
    scheduleRedraw();
  }

  // pointerup → 정상 finalize. pointercancel → inflight 폐기 (사용자 의도가 아닌
  // OS 강제 취소 — multi-touch / 시스템 gesture 인식 시 발생. cancel 된 stroke 가
  // strokes 에 들어가면 의도치 않은 짧은 노이즈 stroke 누적, 사용자 글자 망가짐).
  // pointerleave 는 안전망 — capture 가 정상 잡혀 있으면 leave 자체가 사양상 안
  // 오므로 무해. 만약 leave 가 도착했다면 iOS Safari 가 capture 를 silently 풀어
  // pointerup 이 캔버스에 routing 안 된 케이스 → 이 분기에서 강제 finalize 해야
  // isDrawing 락이 풀려서 다음 stroke 가 막히지 않는다 (ㄱ → ㅏ hang 회귀 방어).
  function endStroke(e: PointerEvent) {
    if (DBG) {
      if (e.type === 'pointerup') dbg = { ...dbg, up: dbg.up + 1 };
      else if (e.type === 'pointercancel') dbg = { ...dbg, cancel: dbg.cancel + 1 };
      else if (e.type === 'pointerleave') dbg = { ...dbg, leave: dbg.leave + 1 };
    }
    // pointerleave 의 capture 활성 가드는 DBG 와 무관하게 적용.
    if (e.type === 'pointerleave' && canvas?.hasPointerCapture?.(e.pointerId)) return;

    if (!isDrawing) return;
    if (e.pointerId !== activePointerId) {
      if (DBG) dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 };
      return;
    }

    try { canvas?.releasePointerCapture?.(e.pointerId); } catch {}

    activePointerId = null;
    isDrawing = false;
    const wasCanceled = e.type === 'pointercancel';

    if (tool === 'eraser') {
      inflight = null;
      eraserLast = null;
      return;
    }

    // pointercancel 은 stroke 폐기.
    if (wasCanceled) {
      inflight = null;
      scheduleRedraw();
      return;
    }

    // pointerup: 정상 종료 — 1점 stroke (짧은 탭) 도 보존.
    if (inflight && inflight.points.length >= 1) {
      strokes = [...strokes, inflight];
      undoStack = [];
      isDirty = true;
      backup();
      scheduleSave();
    }
    inflight = null;
    scheduleRedraw();
  }

  // ── undo/redo/clear ──
  function undo() {
    if (strokes.length === 0) return;
    const last = strokes[strokes.length - 1];
    strokes = strokes.slice(0, -1);
    undoStack = [...undoStack, last];
    isDirty = true;
    backup();
    scheduleSave();
    scheduleRedraw();
  }
  function redo() {
    if (undoStack.length === 0) return;
    const last = undoStack[undoStack.length - 1];
    undoStack = undoStack.slice(0, -1);
    strokes = [...strokes, last];
    isDirty = true;
    backup();
    scheduleSave();
    scheduleRedraw();
  }
  function clearAll() {
    if (strokes.length === 0) return;
    if (!confirm('이 세션의 모든 stroke 를 지웁니다. 계속할까요?')) return;
    undoStack = [...undoStack, ...strokes];
    strokes = [];
    isDirty = true;
    backup();
    scheduleSave();
    scheduleRedraw();
  }

  // ── autosave: 입력 루프와 완전 분리 ──
  // 3초 idle debounce. flushSave 는 setTimeout 0 으로 다음 macrotask 에 ship.
  // PATCH 응답이 늦게 오더라도 strokes 배열을 덮어쓰지 않음 (응답 무시).
  function scheduleSave() {
    if (saveTimer) clearTimeout(saveTimer);
    saveTimer = window.setTimeout(flushSave, 3000) as unknown as number;
  }
  function flushSave() {
    if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
    if (!isDirty) return;
    isDirty = false;
    // 런타임 캐시 제외 — 서버 PATCH 페이로드에 Path2D 직렬화 cruft 들어가지 않도록.
    const snapshot = {
      version: 1 as const,
      strokes: serializableStrokes() as Stroke[],
    };
    setTimeout(() => onChange?.(snapshot), 0);
  }

  // ── snapshot (PNG) ──
  async function takeSnapshot() {
    if (!canvas || !onSnapshot) return;
    if (snapshotting) return;
    snapshotting = true;
    snapshotErr = null;
    try {
      redraw();
      flushSave();
      const blob = await new Promise<Blob | null>((resolve) =>
        canvas!.toBlob((b) => resolve(b), 'image/png')
      );
      if (!blob) throw new Error('PNG 생성 실패');
      await onSnapshot(blob);
    } catch (e) {
      snapshotErr = (e as { detail?: string; message?: string }).detail
        || (e as Error).message
        || '스냅샷 저장 실패';
    } finally {
      snapshotting = false;
    }
  }

  function onBeforeUnload() {
    if (isDirty) flushSave();
    if (backupTimer) flushBackup();  // pending debounced backup 강제 실행.
  }

  // ── 마운트 / cleanup ──
  let resizeObserver: ResizeObserver | null = null;
  function onWindowResize() { resizeCanvas(); }

  // window 레벨 안전망 — iOS Safari 가 setPointerCapture 를 silently 풀어
  // pointerup 이 캔버스 element 에 routing 안 되는 케이스 (특히 펜이 캔버스 영역
  // *안*에서 hover 해제될 때 pointerleave 도 미발화) 를 cover. window 에는
  // 거의 항상 도달하므로 isDrawing 락이 영구 풀린다 → ㄱ→ㅏ hang 회귀 차단.
  function onWindowPointerEnd(e: PointerEvent) {
    if (!isDrawing) return;
    if (e.pointerId !== activePointerId) return;
    endStroke(e);
  }

  onMount(() => {
    try {
      const txt = getComputedStyle(document.documentElement).getPropertyValue('--text').trim();
      if (txt) strokeColor = txt;
    } catch {}
    resizeCanvas();
    restoreFromLocalStorageIfNewer();
    requestAnimationFrame(() => requestAnimationFrame(resizeCanvas));

    if (containerEl && 'ResizeObserver' in window) {
      resizeObserver = new ResizeObserver(() => resizeCanvas());
      resizeObserver.observe(containerEl);
    }
    window.addEventListener('resize', onWindowResize);
    window.addEventListener('orientationchange', onWindowResize);
    window.addEventListener('beforeunload', onBeforeUnload);
    window.addEventListener('pointerup', onWindowPointerEnd);
    window.addEventListener('pointercancel', onWindowPointerEnd);
  });
  onDestroy(() => {
    flushSave();
    if (backupTimer) flushBackup();
    resizeObserver?.disconnect();
    if (typeof window !== 'undefined') {
      window.removeEventListener('resize', onWindowResize);
      window.removeEventListener('orientationchange', onWindowResize);
      window.removeEventListener('beforeunload', onBeforeUnload);
      window.removeEventListener('pointerup', onWindowPointerEnd);
      window.removeEventListener('pointercancel', onWindowPointerEnd);
    }
  });

  // traceText 변경 시만 redraw (의존성 명시)
  let _prevTraceText = $state(traceText);
  $effect(() => {
    if (traceText !== _prevTraceText) {
      _prevTraceText = traceText;
      scheduleRedraw();
    }
  });

  // ── Safari/Chrome hardening for buttons ──
  const BTN_STYLE =
    'touch-action: manipulation; user-select: none; -webkit-user-select: none; ' +
    '-webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;';

  // iOS Safari 의 button focus 는 mousedown/pointerdown 단계에 발동 → 그 영역으로
  // 자동 zoom (사용자 보고: "펜/지우개 누르면 해당 부분 확대"). click 시점의
  // clickThenBlur 는 이미 늦음 — focus 가 잡히는 시점이 mousedown 이라 그 단계에서
  // preventDefault 해야 focus 자체가 안 잡힘. click 이벤트는 별도라 onclick 정상.
  function blockBtnFocus(e: Event) { e.preventDefault(); }

  // (clickThenBlur 는 잔존 케이스 안전망으로 유지 — focus 가 어떻게든 잡힌
  // 케이스에서 즉시 blur. mousedown 차단이 1차, blur 가 2차.)
  function clickThenBlur(fn: () => void) {
    return (e: Event) => {
      const t = e?.currentTarget as HTMLElement | null;
      if (t?.blur) t.blur();
      fn();
    };
  }
</script>

<div class="flex flex-col h-full" style="touch-action: manipulation;">
  <!-- 툴바 -->
  <div
    class="flex items-center gap-1 px-2 py-1 border-b border-default bg-surface shrink-0 flex-wrap"
    style="touch-action: manipulation; user-select: none; -webkit-user-select: none;"
  >
    <button
      type="button"
      onmousedown={blockBtnFocus}
      onpointerdown={blockBtnFocus}
      onclick={clickThenBlur(() => (tool = 'pen'))}
      style={BTN_STYLE}
      tabindex={-1}
      class="flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
        {tool === 'pen' ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface'}"
      aria-pressed={tool === 'pen'}
    >
      <Pencil size={14} /> 펜
    </button>
    <button
      type="button"
      onmousedown={blockBtnFocus}
      onpointerdown={blockBtnFocus}
      onclick={clickThenBlur(() => (tool = 'eraser'))}
      style={BTN_STYLE}
      tabindex={-1}
      class="flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
        {tool === 'eraser' ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface'}"
      aria-pressed={tool === 'eraser'}
    >
      <Eraser size={14} /> 지우개
    </button>

    <span class="w-px h-5 bg-default mx-1" aria-hidden></span>

    {#each [['thin', '가늘게'], ['normal', '보통'], ['thick', '굵게']] as [w, label]}
      <button
        type="button"
        onmousedown={blockBtnFocus}
        onpointerdown={blockBtnFocus}
        onclick={clickThenBlur(() => (widthMode = w))}
        style={BTN_STYLE}
        tabindex={-1}
        class="px-2 py-1 rounded text-xs transition-colors
          {widthMode === w ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface'}"
        aria-pressed={widthMode === w}
      >{label}</button>
    {/each}

    <span class="w-px h-5 bg-default mx-1" aria-hidden></span>

    <IconButton icon={Undo2} size="sm" aria-label="실행 취소" onclick={clickThenBlur(undo)} disabled={strokes.length === 0} style={BTN_STYLE} tabindex={-1} onmousedown={blockBtnFocus} onpointerdown={blockBtnFocus} />
    <IconButton icon={Redo2} size="sm" aria-label="다시 실행" onclick={clickThenBlur(redo)} disabled={undoStack.length === 0} style={BTN_STYLE} tabindex={-1} onmousedown={blockBtnFocus} onpointerdown={blockBtnFocus} />
    <IconButton icon={Trash2} size="sm" aria-label="모두 지우기" onclick={clickThenBlur(clearAll)} disabled={strokes.length === 0} style={BTN_STYLE} tabindex={-1} onmousedown={blockBtnFocus} onpointerdown={blockBtnFocus} />

    <span class="text-xs text-dim ml-2">stroke {strokes.length}</span>
    <div class="flex-1"></div>
    {#if snapshotErr}
      <span class="text-xs text-error mr-2">{snapshotErr}</span>
    {/if}
    <button
      type="button"
      onmousedown={blockBtnFocus}
      onpointerdown={blockBtnFocus}
      onclick={clickThenBlur(takeSnapshot)}
      disabled={snapshotting || strokes.length === 0}
      style={BTN_STYLE}
      tabindex={-1}
      class="px-3 py-1 rounded text-sm bg-accent text-white disabled:opacity-50 disabled:cursor-not-allowed"
    >
      {snapshotting ? '저장 중...' : 'PNG 저장'}
    </button>
  </div>

  <!-- 캔버스 컨테이너 -->
  <div
    bind:this={containerEl}
    class="flex-1 min-h-0 bg-bg relative overflow-hidden"
    style="user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; touch-action: none;"
  >
    <canvas
      bind:this={canvas}
      onpointerdown={onPointerDown}
      onpointermove={onPointerMove}
      onpointerup={endStroke}
      onpointercancel={endStroke}
      onpointerleave={endStroke}
      oncontextmenu={(e) => e.preventDefault()}
      onselectstart={(e) => e.preventDefault()}
      class="block"
      style="touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; cursor: {tool === 'eraser' ? 'cell' : 'crosshair'};"
    ></canvas>

    {#if DBG}
      <!-- 라이브 디버그 패널 — DEV 빌드 또는 prod 에서 ?debug=1 query 시 활성. -->
      <div class="absolute top-1 left-1 px-2 py-1 rounded bg-bg/90 text-[10px] text-dim font-mono pointer-events-none leading-tight">
        type:{dbg.lastType} p:{dbg.lastPressure.toFixed(2)}<br/>
        down:{dbg.down} move:{dbg.move} up:{dbg.up} cancel:{dbg.cancel}<br/>
        rejType:{dbg.rejectedByType} rejId:{dbg.rejectedByPointerId} coal:{dbg.coalesced}<br/>
        drawing:{isDrawing ? 'Y' : 'N'} actId:{activePointerId ?? '-'} infPts:{inflight?.points.length ?? 0}<br/>
        strokes:{strokes.length}
      </div>
    {/if}
  </div>
</div>
