<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]
  // size = 그렸을 시점의 effectiveSize. widthMode (가늘게/보통/굵게) 변경 시 *기존*
  // stroke 의 굵기는 변하지 않도록 (사용자 요청). 새 stroke 만 새 effectiveSize 사용.
  // refW / refH = 그렸을 시점의 cssWidth / cssHeight. canvas dimension 변화 후에도
  // stroke 의 시각적 위치/비율 유지 (style:cursor 회귀 fix 와 별개의 비례 보정).
  // _path2d 는 런타임 캐시 — 직렬화 시 제외 (밑줄 prefix 가 marker).
  type Stroke = {
    id: string;
    points: Point[];
    size?: number;
    refW?: number;
    refH?: number;
    // fixedPressure = stroke 시작 후 처음 N 점에서 측정한 평균 속도 기반 압력.
    // 결정 후 stroke 전체 그 굵기 유지 (Notability felt). undefined = 아직 결정 전.
    fixedPressure?: number;
    _path2d?: Path2D;
  };
  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 시 활성화.
  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,
    // pressure 파이프라인 진단 — raw → mapped → final.
    // raw = PointerEvent.pressure 원본
    // mapped = getStrokePressure 의 inputP (raw 매핑 또는 속도 fallback)
    // final = fixedPressure update 후 perfect-freehand 에 전달되는 값
    rawP: 0,
    mappedP: 0,
    finalP: 0,
    tiltX: 0, tiltY: 0,
    ptrW: 0, ptrH: 0,
    buttons: 0,
    // raw pressure 의 세션 min/max — 사용자가 펜 강약 시도 후 확인.
    rawMin: 1,
    rawMax: 0,
  });
  // dimension 측정 — button click 시 어느 element 의 dimension 이 변하는지 진단.
  let dimDbg = $state({ canW: 0, canH: 0, conW: 0, conH: 0 });
  // button click 카운터 — 우리 코드까지 click 이 도달했는지 확인.
  let btnDbg = $state({ pen: 0, eraser: 0, width: 0, undo: 0 });

  let strokeColor = $state('#e4e4e7');
  let tool = $state<Tool>('pen');
  let widthMode = $state<WidthMode>('normal');
  // PEN_PRESET_NOTABILITY_LIKE — 사용자 지정 프리셋.
  //   thin   0.38
  //   normal 0.55  → baseSize 7 × 0.55 = 3.85px
  //   thick  0.90  → baseSize 7 × 0.90 = 6.30px
  const WIDTH_FACTOR: Record<WidthMode, number> = { thin: 0.38, normal: 0.55, thick: 0.9 };
  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;
  // 지우개 인디케이터 위치 (Notability 스타일) — hover 만으로도 표시. tool=eraser
  // 일 때 redraw 가 그 위치에 원형 outline + 반투명 fill 그림. plain 변수 + scheduleRedraw.
  let eraserCursor: [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) 제외하고 직렬화. refW/refH 는 그렸을 시점의
  // cssWidth/cssHeight — 다른 환경 (창 크기 다른 데스크톱, 모바일 등) 에서 load 시
  // 비례 보정에 사용. 없으면 load 시점의 cssWidth/cssHeight 가 기준이 됨.
  function serializableStrokes(): Pick<Stroke, 'id' | 'points' | 'size' | 'refW' | 'refH' | 'simPressure'>[] {
    return strokes.map((s) => ({
      id: s.id,
      points: s.points,
      size: s.size,
      refW: s.refW,
      refH: s.refH,
      simPressure: s.simPressure,
    }));
  }
  // 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/height 는 svelte 의 style:width / style:height directive 가
    // reactive 로 처리. imperative set 은 inline style="..." 의 reactive 변수
    // (예: style="cursor: {tool}") 갱신 시 함께 덮어써서 사라지는 회귀 (확대 = canvas
    // 가 internal pixel 그대로 displayed) 의 원인이었음.
    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;

    // stroke 별 size — widthMode 변경 시 기존 stroke 굵기 유지. legacy stroke (size
    // 없음) 은 첫 draw 시점의 effectiveSize 로 fix (refW/refH 패턴과 동일).
    if (s.size === undefined || s.size <= 0) s.size = effectiveSize;
    const size = s.size;

    if (pts.length === 1) {
      const [x, y, p] = pts[0];
      const r = size * (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 재사용. stroke.size 가 불변이므로 캐시 영원.
    // inflight 는 점이 매 frame 추가되므로 매번 재계산.
    let path = !isInflight ? s._path2d : undefined;
    if (!path) {
      const outline = getStroke(pts, {
        size,
        // PEN_PRESET_NOTABILITY_LIKE — 획 내부 안정 + 자연스러운 끝 + 입력 즉시 반응.
        thinning: 0.45,
        smoothing: 0.82,
        streamline: 0.45,
        simulatePressure: false,
        last: !isInflight,
        // taper 짧게 — Notability 처럼 끝부분만 살짝 빠짐. cap: true 로 둥근 끝.
        start: { cap: true, taper: size * 0.15, easing: (t) => t * (2 - t) },
        end: { cap: true, taper: size * 0.25, easing: (t) => t * (2 - t) },
      });
      if (outline.length < 2) return;
      path = new Path2D(getSvgPathFromStroke(outline));
      if (!isInflight) s._path2d = path;
    }
    ctx.fillStyle = strokeColor;
    ctx.fill(path);
  }

  // 지우개 인디케이터 (Notability 스타일) — eraserRadius 크기 원형 outline + 살짝
  // 반투명 fill. 사용자가 어디를 지우게 될지 시각 피드백.
  function drawEraserCursor(ctx: CanvasRenderingContext2D) {
    if (tool !== 'eraser' || !eraserCursor) return;
    const [x, y] = eraserCursor;
    ctx.save();
    ctx.beginPath();
    ctx.arc(x, y, eraserRadius, 0, Math.PI * 2);
    ctx.fillStyle = strokeColor;
    ctx.globalAlpha = 0.12;
    ctx.fill();
    ctx.globalAlpha = 0.7;
    ctx.strokeStyle = strokeColor;
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.restore();
  }

  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) drawStrokeScaled(ctx, s, false);
    if (inflight) drawStrokeScaled(ctx, inflight, true);
    drawEraserCursor(ctx);
    if (DBG) {
      const cr = canvas.getBoundingClientRect();
      const er = containerEl?.getBoundingClientRect();
      dimDbg = {
        canW: Math.round(cr.width),
        canH: Math.round(cr.height),
        conW: Math.round(er?.width ?? 0),
        conH: Math.round(er?.height ?? 0),
      };
    }
  }

  // stroke 의 refW/refH 와 현재 cssWidth/cssHeight 비례로 ctx.scale 적용 후 그림.
  // legacy stroke (refW 없음) 은 *첫 draw 시점* 의 cssWidth/cssHeight 로 fix —
  // 그 후 cssWidth 변화 (button click 의 layout shift / 창 크기 조정) 시 비례 유지.
  function drawStrokeScaled(ctx: CanvasRenderingContext2D, s: Stroke, isInflight: boolean) {
    if (!s.refW || s.refW <= 0) s.refW = cssWidth;
    if (!s.refH || s.refH <= 0) s.refH = cssHeight;
    const sx = cssWidth / s.refW;
    const sy = cssHeight / s.refH;
    if (sx === 1 && sy === 1) {
      drawStroke(ctx, s, isInflight);
      return;
    }
    ctx.save();
    ctx.scale(sx, sy);
    drawStroke(ctx, s, isInflight);
    ctx.restore();
  }

  // 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';
  }

  // pressure dynamic range — 만년필 reference (종이) 의 큰 굵기 차이 (약 5:1)
  // 재현 위해 floor 0.25. thinning 0.55 와 조합 → 약한 stroke ≈ size×0.42, 강한
  // stroke ≈ size×1.0. 비율 약 2.4:1 (적당한 만년필 felt).
  const MIN_PRESSURE = 0.25;
  function normalizePressure(p: number | undefined): number {
    if (typeof p !== 'number' || !Number.isFinite(p)) return 0.5;
    return Math.max(MIN_PRESSURE, Math.min(1, p));
  }

  // 입력 보간 거리 — 사용자 분석: 16 은 빠른 stroke 시 점 간격 벌어져 ".........."
  // 효과. 6 으로 줄여 촘촘히 보간. getCoalescedEvents() 의 raw sample 도 활용.
  const MAX_GAP_PX = 6;
  // Notability felt — 획 내부 균일 + 의도적 변화만 반영.
  //   잡음 (dev < 0.18) → α 0.015 (강하게 무시) → 획 내부 압력 노이즈 억제
  //   의도적 (0.18 ≤ dev < 0.32) → α 0.4 (자연스럽게 따라감)
  //   매우 큼 (dev ≥ 0.32) → 즉시 update (강한 필압 변화 반응)
  const FIXED_THRESHOLD = 0.18;
  const FIXED_LARGE = 0.32;
  const FIXED_ALPHA_NOISE = 0.015;
  const FIXED_ALPHA_INTENT = 0.4;
  // pressure floor 0.6 — 약한 입력에도 stroke 가 사라지거나 점선 안 되게.
  // firstPointPressure 0.72 — 첫 점 fallback (Notability 기본 굵기 느낌).
  const PRESSURE_FLOOR = 0.6;
  const FIRST_POINT_PRESSURE = 0.72;
  function getStrokePressure(target: Stroke, x: number, y: number, rawPressure: number): number {
    // 1. inputP 결정.
    let inputP: number;
    if (Number.isFinite(rawPressure) && rawPressure > 0.1 && rawPressure < 0.99) {
      inputP = PRESSURE_FLOOR + ((rawPressure - 0.1) / 0.89) * (1 - PRESSURE_FLOOR);
    } else {
      const last = target.points[target.points.length - 1];
      if (last) {
        const dist = Math.hypot(x - last[0], y - last[1]);
        inputP = Math.max(PRESSURE_FLOOR, Math.min(1.0, 1.6 - dist / 25));
      } else {
        inputP = FIRST_POINT_PRESSURE;
      }
    }
    // 2. fixed hybrid.
    let finalP: number;
    if (target.fixedPressure === undefined) {
      target.fixedPressure = inputP;
      finalP = inputP;
    } else {
      const dev = Math.abs(inputP - target.fixedPressure);
      if (dev >= FIXED_LARGE) {
        target.fixedPressure = inputP;
        finalP = inputP;
      } else {
        const alpha = dev < FIXED_THRESHOLD ? FIXED_ALPHA_NOISE : FIXED_ALPHA_INTENT;
        target.fixedPressure = target.fixedPressure + (inputP - target.fixedPressure) * alpha;
        finalP = target.fixedPressure;
      }
    }
    // DBG: raw → mapped → final 3 단계 + raw min/max 추적.
    if (DBG) {
      const rawMin = Math.min(dbg.rawMin, rawPressure);
      const rawMax = Math.max(dbg.rawMax, rawPressure);
      dbg = { ...dbg, rawP: rawPressure, mappedP: inputP, finalP, rawMin, rawMax };
    }
    return finalP;
  }
  function pushPointWithInterp(target: Stroke, x: number, y: number, p: number) {
    const last = target.points[target.points.length - 1];
    if (last) {
      const sp = getStrokePressure(target, x, y, p);
      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);
        for (let i = 1; i < steps; i++) {
          const t = i / steps;
          target.points.push([
            last[0] + dx * t,
            last[1] + dy * t,
            sp,
          ]);
        }
      }
      target.points.push([x, y, sp]);
      return;
    }
    // 첫 점 — fixedPressure 초기화 위해 호출 (속도 추정 위해 dummy)
    const sp = getStrokePressure(target, x, y, p);
    target.points.push([x, y, sp]);
  }

  // 점 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];
      eraserCursor = [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)]],
      size: effectiveSize,
      refW: cssWidth,
      refH: cssHeight,
      // pen 외 (mouse / touch) 는 속도 기반 시뮬. pressure 정보 없거나 무시되는 경우
      // 굵기 변화 보장.
      simPressure: e.pointerType !== 'pen',
    };
    scheduleRedraw();
  }

  function onPointerMove(e: PointerEvent) {
    if (DBG) {
      dbg = {
        ...dbg,
        move: dbg.move + 1,
        lastType: e.pointerType,
        lastPressure: e.pressure,
        tiltX: e.tiltX ?? 0,
        tiltY: e.tiltY ?? 0,
        ptrW: e.width ?? 0,
        ptrH: e.height ?? 0,
        buttons: e.buttons,
      };
    }

    // 지우개 인디케이터 — hover (펜 미접촉) 만으로도 cursor 위치 추적. isDrawing
    // 가드 *전*이라 mouse hover / Pencil hover 모두 잡힘.
    if (tool === 'eraser' && isPenLike(e)) {
      const [hx, hy] = getLocalXY(e);
      if (!eraserCursor || eraserCursor[0] !== hx || eraserCursor[1] !== hy) {
        eraserCursor = [hx, hy];
        scheduleRedraw();
      }
    }

    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();
      }
      eraserLast = [x, y];
      eraserCursor = [x, y];
      scheduleRedraw();
      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 시 hover 인디케이터 숨김 (isDrawing 무관).
    if (e.type === 'pointerleave' && eraserCursor !== null) {
      eraserCursor = null;
      scheduleRedraw();
    }
    // 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;
      eraserCursor = null;
      // 지우개 stroke 끝나면 자동으로 펜 복귀 (사용자 요청).
      tool = 'pen';
      scheduleRedraw();
      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();
    }
  });

  // tool 이 pen 으로 바뀌면 eraserCursor 숨김 (자동 복귀 포함).
  $effect(() => {
    if (tool !== 'eraser' && eraserCursor !== null) {
      eraserCursor = null;
      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();
      // DBG: 클릭 *직후* dimension 재측정. RAF 두 번 통과 후 layout 안정 시점.
      if (DBG) {
        requestAnimationFrame(() => requestAnimationFrame(() => scheduleRedraw()));
      }
    };
  }
  // button click 카운터 — 핸들러까지 도달했는지 검증.
  function bumpBtn(kind: 'pen' | 'eraser' | 'width' | 'undo') {
    if (DBG) btnDbg = { ...btnDbg, [kind]: btnDbg[kind] + 1 };
  }
</script>

<div class="flex flex-col h-full" style="touch-action: manipulation;">
  <!-- 툴바: flex-wrap 제거 — 자릿수 변화 (stroke 99→100) 등으로 wrap 발생 시
       toolbar height 증가 → 캔버스 height 감소 → ResizeObserver 발동 → cssWidth/
       cssHeight 변경 → stroke 비례 깨짐 (확대된 것처럼 보이는 회귀의 trigger 였음).
       overflow-x-auto 로 좁은 화면에서는 가로 스크롤 허용. -->
  <div
    class="flex items-center gap-1 px-2 py-1 border-b border-default bg-surface shrink-0 overflow-x-auto"
    style="touch-action: manipulation; user-select: none; -webkit-user-select: none;"
  >
    <button
      type="button"
      onmousedown={blockBtnFocus}
      onpointerdown={blockBtnFocus}
      onclick={clickThenBlur(() => { bumpBtn('pen'); 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(() => { bumpBtn('eraser'); 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(() => { bumpBtn('width'); 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 tabular-nums shrink-0">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:cursor={tool === 'eraser' ? 'cell' : 'crosshair'}
      style:width={cssWidth ? `${cssWidth}px` : undefined}
      style:height={cssHeight ? `${cssHeight}px` : undefined}
      style="touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;"
    ></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">
        tool:{tool} width:{widthMode}<br/>
        type:{dbg.lastType} buttons:{dbg.buttons}<br/>
        <span class="text-accent">PRESSURE PIPELINE</span><br/>
        raw:{dbg.rawP.toFixed(3)} (min:{dbg.rawMin.toFixed(2)} max:{dbg.rawMax.toFixed(2)})<br/>
        mapped:{dbg.mappedP.toFixed(3)} final:{dbg.finalP.toFixed(3)}<br/>
        tilt:{dbg.tiltX},{dbg.tiltY} ptr:{dbg.ptrW}×{dbg.ptrH}<br/>
        <span class="text-accent">EVENT COUNTERS</span><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} btn pen:{btnDbg.pen} er:{btnDbg.eraser}<br/>
        css:{cssWidth}×{cssHeight} canvas:{dimDbg.canW}×{dimDbg.canH}
      </div>
    {/if}
  </div>
</div>
