"use strict";
// Claude Pet renderer：Codex V2 pet contract 的動畫引擎 + 狀態機
// 規格來源：8 欄 x 11 列、192x208 cell，列 0-8 標準動作、列 9-10 為 16 個順時針看向方向（000 = 正上方）

const CELL_W = 192;    // spritesheet 單格來源尺寸
const CELL_H = 208;
// 100% 時的顯示尺寸，對齊 Codex 桌面寵物（與 main.js 的 PET_W / PET_H 一致）
const PET_W = 126;
const PET_H = Math.ceil((PET_W * CELL_H) / CELL_W);   // 137

const ROW = {
  idle: 0,
  "running-right": 1,
  "running-left": 2,
  waving: 3,
  jumping: 4,
  failed: 5,
  waiting: 6,
  running: 7,
  review: 8,
};

// 每一幀的毫秒數（Codex V2 animation-rows 合約）
const TIMINGS = {
  idle: [280, 110, 110, 140, 140, 320],
  "running-right": [120, 120, 120, 120, 120, 120, 120, 220],
  "running-left": [120, 120, 120, 120, 120, 120, 120, 220],
  waving: [140, 140, 140, 280],
  jumping: [140, 140, 140, 140, 280],
  failed: [140, 140, 140, 140, 140, 140, 140, 240],
  waiting: [150, 150, 150, 150, 150, 260],
  running: [120, 120, 120, 120, 120, 220],
  review: [150, 150, 150, 150, 150, 280],
};

// Codex 的 idle 每幀時長是合約值的 6 倍（codex-pet-assets 的 `_ = 6`），
// 一輪 6.6 秒而不是 1.1 秒，這就是為什麼它待機時看起來幾乎不動。
const IDLE_SLOWDOWN = 6;
// Codex 的非 idle 狀態只播 3 次就沉澱回慢速 idle，不會一直循環下去。
const STATE_REPEATS = 3;
// 每列 look 格子的整體縮放若比 idle 小超過這個比例就視為圖檔缺陷並補正
const LOOK_SCALE_TOLERANCE = 0.9;

const LOOK_DEADZONE = 1;           // 與 Codex 相同（它的死區只有 1 px）
const LOOK_IDLE_AFTER = 6000;      // 游標靜止多久後不再盯著看（Codex 的追視來源不是滑鼠，沒有這個問題）

// 跨 session 的優先序，與 Codex 通知排序 Ai() 相同：waiting 0 > failed 1 > review 2 > running 3 > idle 4
const PRIORITY = { waiting: 4, failed: 3, review: 2, running: 1, idle: 0 };
// 各狀態多久沒更新就退回 idle，與 Codex 通知到期 Ei() 相同：failed 1 小時、waiting 24 小時、review 7 天。
// running 與 idle 在 Codex 裡不會到期。
const EXPIRY = { failed: 3600e3, waiting: 1440 * 60e3, review: 10080 * 60e3 };
// 安全網（Codex 沒有，它的 thread 是常駐連線）：終端機被直接關掉時不會送 SessionEnd，
// running 太久沒任何事件就視為已結束。
const RUNNING_SILENCE = 30 * 60e3;
const IDLE_PRUNE = 30 * 60e3;      // idle 的 session 閒置多久後從清單移除（純清理，不影響顯示）

const GREETING_MS = 8000;          // first-awake 問候通知的存活時間（Pi = 8 s）
const ACTIVITY_MS = 6000;          // 工作中氣泡（工具動作／助理說明）的存活時間

// 工具對照表；沒列到的就直接顯示工具名稱
const TOOL_LABELS = {
  Bash: "⚙️ 執行",
  Read: "📖 讀",
  Write: "✏️ 寫",
  Edit: "✏️ 改",
  NotebookEdit: "✏️ 改",
  Glob: "🔍 找檔案",
  Grep: "🔍 搜尋",
  WebFetch: "🌐 擷取",
  WebSearch: "🔎 搜尋",
  Task: "🤖 子代理",
  TodoWrite: "📝 待辦",
  Artifact: "📄 產出",
};

const canvas = document.getElementById("sprite");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const bubble = document.getElementById("bubble");
const bubbleTitle = document.getElementById("bubble-title");
const bubbleText = document.getElementById("bubble-text");

let sheet = null;
let petInfo = null;
let scale = 1;
let followCursor = false;     // Codex 的追視來源是 quick chat 文字游標／computer-use 游標，不是滑鼠；預設關

const sessions = new Map();   // sessionId → { state, updatedAt, cwd }
let overlay = null;           // 一次性動作：{ anim, loops, onDone }
let drag = null;              // 拖曳中：{ dir: "left" | "right" }
const look = { idx: null, lastMoveAt: 0 };
// 播放中的序列：frames = [{row, col, ms}]，loopStart 為 null 表示播完就結束
let player = { anim: null, frames: [], loopStart: 0, step: 0, stepStart: performance.now() };
let prevBase = "idle";
let lastNarrationId = null;   // 同一段說明只播報一次
let lookCells = null;         // 16 個 look 格子的量測結果（含缺陷補正倍率）

// 一輪原始動作
function cycleOf(anim) {
  return TIMINGS[anim].map((ms, col) => ({ row: ROW[anim], col, ms }));
}

// Codex 的慢速 idle
const IDLE_SLOW = TIMINGS.idle.map((ms, col) => ({ row: ROW.idle, col, ms: ms * IDLE_SLOWDOWN }));

const baseSeqCache = new Map();

// 對應 Codex 的 f(state)：idle 直接循環慢速 idle，其他狀態播 STATE_REPEATS 次後沉澱成慢速 idle
function baseSequence(anim) {
  let seq = baseSeqCache.get(anim);
  if (seq) return seq;
  if (anim === "idle") {
    seq = { frames: IDLE_SLOW, loopStart: 0 };
  } else {
    const repeated = [];
    for (let i = 0; i < STATE_REPEATS; i++) repeated.push(...cycleOf(anim));
    seq = { frames: [...repeated, ...IDLE_SLOW], loopStart: repeated.length };
  }
  baseSeqCache.set(anim, seq);
  return seq;
}

const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");

function startAnim(anim, now) {
  // 追視是單一靜態擺姿，沒有幀序列（TIMINGS 裡沒有 look）。
  // 少了這道判斷會走進 cycleOf("look") 而丟 TypeError，requestAnimationFrame 迴圈整個死掉。
  if (anim === "look") {
    player = { anim, frames: [], loopStart: null, step: 0, stepStart: now };
    return;
  }
  let seq;
  if (reducedMotionQuery.matches) {
    // 與 Codex 相同：系統關閉動畫時只顯示該狀態的第一格
    seq = { frames: [cycleOf(anim)[0]], loopStart: null };
  } else if (overlay && overlay.anim === anim) {
    // 一次性動作：播指定次數後結束，交還給基礎狀態
    const frames = [];
    for (let i = 0; i < overlay.loops; i++) frames.push(...cycleOf(anim));
    seq = { frames, loopStart: null };
  } else if (anim === "running-left" || anim === "running-right") {
    // 拖曳中要持續跑，不能沉澱成 idle
    seq = { frames: cycleOf(anim), loopStart: 0 };
  } else {
    seq = baseSequence(anim);
  }
  player = { anim, frames: seq.frames, loopStart: seq.loopStart, step: 0, stepStart: now };
}

let bubbleTimer = null;
let bubbleSticky = false;
let hovered = false;          // 游標是否在人物方框內
let lastPoint = null;         // 最後已知的游標位置（視窗相對），主程序輪詢或 mousemove 都會更新
let pressed = null;
let lastReported = "";

function log(msg) {
  try { window.pet.send("pet:log", `[renderer] ${msg}`); } catch { /* ignore */ }
}

// renderer 的例外以前只會出現在 DevTools，包成 exe 後根本看不到；寫進事件紀錄才查得到
window.addEventListener("error", (e) => log(`error: ${e.message} (${e.filename}:${e.lineno})`));
window.addEventListener("unhandledrejection", (e) => log(`error: unhandled rejection: ${e.reason?.message || e.reason}`));

// ---------- 載入 ----------

function loadPet(pet) {
  petInfo = pet;
  sheet = null;
  const img = new Image();
  img.onload = () => {
    sheet = img;
    measureLookRows();
    makeTrayIcon();
    log(`loaded ${pet.id} (v${pet.version}) ${img.naturalWidth}x${img.naturalHeight}`);
    // Codex 的 first-awake：某隻寵物第一次被叫出來時揮手（播 3 次）並打招呼 8 秒，之後不再
    if (!pet.greeted) {
      play("waving", STATE_REPEATS);
      say(`👋 嗨，我是${pet.displayName}`, GREETING_MS);
      window.pet.send("pet:greeted", pet.id);
    }
  };
  img.onerror = () => log(`spritesheet load failed for ${pet.id}`);
  img.src = pet.dataUrl;
}

function applyScale(s) {
  scale = s;
  canvas.width = Math.round(PET_W * s);
  canvas.height = Math.round(PET_H * s);
  canvas.style.width = `${canvas.width}px`;
  canvas.style.height = `${canvas.height}px`;
  reportHitRects();
}

// 量一格的人物輪廓（用來偵測圖檔裡整列被畫小的缺陷）
function measureCell(g, row, col) {
  g.clearRect(0, 0, CELL_W, CELL_H);
  g.drawImage(sheet, col * CELL_W, row * CELL_H, CELL_W, CELL_H, 0, 0, CELL_W, CELL_H);
  const d = g.getImageData(0, 0, CELL_W, CELL_H).data;
  let minX = CELL_W, maxX = -1, minY = CELL_H, maxY = -1;
  for (let y = 0; y < CELL_H; y++) {
    for (let x = 0; x < CELL_W; x++) {
      if (d[(y * CELL_W + x) * 4 + 3] <= 20) continue;
      if (x < minX) minX = x;
      if (x > maxX) maxX = x;
      if (y < minY) minY = y;
      if (y > maxY) maxY = y;
    }
  }
  if (maxY < 0) return null;
  return { w: maxX - minX + 1, h: maxY - minY + 1, cx: (minX + maxX) / 2, bottom: maxY };
}

// Codex 的 hatch 產生器有時會把整列 look 格子畫得比 idle 小（小念的第 10 列就小了 22%），
// 直接照畫會讓游標一往下移人物就縮水。以「整列中位數」判斷，單格差異（低頭之類的姿勢）不動。
function measureLookRows() {
  lookCells = null;
  if (!sheet || petInfo?.version !== 2) return;
  try {
    const c = document.createElement("canvas");
    c.width = CELL_W;
    c.height = CELL_H;
    const g = c.getContext("2d", { willReadFrequently: true });
    const base = measureCell(g, ROW.idle, 0);
    if (!base) return;
    const cells = [];
    let fixed = 0;
    for (const row of [9, 10]) {
      const measured = [];
      for (let col = 0; col < 8; col++) measured.push(measureCell(g, row, col));
      const heights = measured.filter(Boolean).map((m) => m.h).sort((a, b) => a - b);
      if (!heights.length) {
        cells.push(...measured);
        continue;
      }
      const median = heights[heights.length >> 1];
      const scale = median / base.h < LOOK_SCALE_TOLERANCE ? base.h / median : 1;
      if (scale !== 1) fixed += measured.filter(Boolean).length;
      for (const m of measured) cells.push(m ? { ...m, scale } : null);
    }
    lookCells = cells;
    if (fixed) log(`look rows rescaled: ${fixed} cells (idle h=${base.h})`);
  } catch (err) {
    log(`measureLookRows failed: ${err.message}`);
  }
}

function cellHasPixels(row, col) {
  const c = document.createElement("canvas");
  c.width = 8; c.height = 8;
  const g = c.getContext("2d");
  g.drawImage(sheet, col * CELL_W + CELL_W / 2 - 24, row * CELL_H + CELL_H / 2 - 24, 48, 48, 0, 0, 8, 8);
  const d = g.getImageData(0, 0, 8, 8).data;
  for (let i = 3; i < d.length; i += 4) if (d[i] > 30) return true;
  return false;
}

function makeTrayIcon() {
  if (!sheet) return;
  try {
    const col = petInfo.version === 2 && cellHasPixels(0, 6) ? 6 : 0;
    const c = document.createElement("canvas");
    c.width = 32; c.height = 32;
    const g = c.getContext("2d");
    g.imageSmoothingEnabled = true;
    g.imageSmoothingQuality = "high";
    // 取上半身（頭部）做成方形 icon
    const sw = 128, sh = 128;
    g.drawImage(sheet, col * CELL_W + (CELL_W - sw) / 2, 4, sw, sh, 0, 0, 32, 32);
    window.pet.send("pet:tray-icon", c.toDataURL("image/png"));
  } catch (err) {
    log(`tray icon failed: ${err.message}`);
  }
}

// ---------- 狀態 ----------

function session(id, cwd) {
  const key = id || "default";
  let s = sessions.get(key);
  if (!s) {
    s = { state: "idle", updatedAt: Date.now(), cwd: cwd || null };
    sessions.set(key, s);
  }
  if (cwd) s.cwd = cwd;
  s.updatedAt = Date.now();
  return s;
}

function setState(s, state) {
  s.state = state;
  s.updatedAt = Date.now();
}

// 對應 Codex：每個 thread 產生一則帶狀態的通知，依優先序排序後取最上面那則的狀態給寵物
function baseState() {
  const now = Date.now();
  let best = "idle";
  for (const [id, s] of sessions) {
    const age = now - s.updatedAt;
    if (EXPIRY[s.state] && age > EXPIRY[s.state]) s.state = "idle";
    else if (s.state === "running" && age > RUNNING_SILENCE) s.state = "idle";
    if (s.state === "idle" && age > IDLE_PRUNE) { sessions.delete(id); continue; }
    if (PRIORITY[s.state] > PRIORITY[best]) best = s.state;
  }
  return best;
}

function play(anim, loops = 1, onDone = null) {
  overlay = { anim, loops, onDone };
  startAnim(anim, performance.now());
}

function resolveAnim() {
  // 拖曳：Codex 在 pointerdown 時先清掉 transient state，要等到單次位移 ≥ 4 px 才決定方向
  if (drag) return drag.dir ? `running-${drag.dir}` : baseState();
  // 一次性動作（點擊揮手、初次問候）。Codex 的 lookFrame 會蓋過這些，但它的追視來源不是滑鼠；
  // 滑鼠永遠在畫面上，若照搬會讓點擊揮手永遠看不到，所以這裡讓手勢優先。
  if (overlay) return overlay.anim;
  const base = baseState();
  // Codex 的 lookFrame 會覆蓋所有狀態，但它的追視來源是 quick chat 的文字游標，
  // 只有你真的在那裡打字時才存在。滑鼠則是一直都在，照搬會讓工作中的動畫永遠被靜態擺姿蓋掉，
  // 寵物就失去反映 Claude Code 狀態的作用，所以只在 idle 時追視。
  if (base === "idle" && followCursor && petInfo?.version === 2 && look.idx !== null) return "look";
  return base;
}

// 助理的說明是 markdown，直接塞進氣泡會看到 ** 和反引號
function stripMarkdown(text) {
  return String(text)
    .replace(/```[\s\S]*?```/g, " ")        // 整段程式碼
    .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // 連結與圖片只留文字
    .replace(/`([^`]*)`/g, "$1")
    .replace(/\*\*([^*]*)\*\*/g, "$1")
    .replace(/(^|\s)[*_]([^*_\s][^*_]*)[*_](?=\s|$)/g, "$1$2")
    .replace(/^\s{0,3}#{1,6}\s+/gm, "")     // 標題
    .replace(/^\s{0,3}[-*+>|]\s+/gm, "")    // 清單與引言
    .replace(/[ \t]+/g, " ");
}

// 只取第一句，氣泡是單行，塞整段沒有意義
function firstSentence(text) {
  const line = stripMarkdown(text).split(/\r?\n/).find((l) => l.trim()) || "";
  const m = line.trim().match(/^[^。！？!?]{1,60}[。！？!?]?/);
  return (m ? m[0] : line.trim()).slice(0, 60);
}

// 指令取前半段、路徑只取檔名，其餘截斷
function shortTarget(target, toolName) {
  const t = String(target).trim();
  if (toolName === "Bash") return t.slice(0, 44);
  if (/[\\/]/.test(t) && !/\s/.test(t)) return t.split(/[\\/]/).pop().slice(0, 40);
  return t.slice(0, 44);
}

// 氣泡第一行：對話名稱（/rename 設的）優先，其次專案資料夾名
function bubbleTitleOf(p, cwd) {
  return p.sessionTitle || projectName(cwd) || "";
}

// 有什麼就顯示什麼：優先顯示新的助理說明，否則顯示正在用的工具
function sayActivity(p, title) {
  if (baseState() === "waiting") return;   // 別蓋掉在等授權的固定氣泡
  if (p.narration && p.narrationId && p.narrationId !== lastNarrationId) {
    lastNarrationId = p.narrationId;
    say(`💬 ${firstSentence(p.narration)}`, ACTIVITY_MS, false, title);
    return;
  }
  if (!p.toolName) return;
  const label = TOOL_LABELS[p.toolName] || `🔧 ${p.toolName}`;
  const detail = p.toolName === "Task" && p.agentType ? p.agentType : p.toolTarget;
  say(detail ? `${label} ${shortTarget(detail, p.toolName)}` : label, ACTIVITY_MS, false, title);
}

function projectName(cwd) {
  if (!cwd) return "";
  return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
}

// ---------- 氣泡 ----------

// title 是第一行（對話名稱或專案名），text 是第二行
function say(text, ms, sticky = false, title = "") {
  clearTimeout(bubbleTimer);
  bubbleTitle.textContent = title || "";
  bubbleText.textContent = text;
  bubble.title = title ? `${title}\n${text}` : text;
  bubble.classList.remove("hidden");
  bubbleSticky = sticky;
  reportHitRects();
  if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
}

function hideBubble() {
  clearTimeout(bubbleTimer);
  bubble.classList.add("hidden");
  bubbleSticky = false;
  reportHitRects();
}

// ---------- 事件 ----------

function handleEvent(p) {
  if (!p || typeof p !== "object") return;
  if (p.event === "__test") return handleTest(p);

  const s = session(p.sessionId, p.cwd);
  const title = bubbleTitleOf(p, s.cwd);

  switch (p.event) {
    case "SessionStart":
      setState(s, "idle");
      // Codex 的 waving 只在寵物第一次醒來（first-awake），新 thread 不會揮手；這裡只留資訊氣泡
      if (!p.source || p.source === "startup") say("👋 嗨！", 3000, false, title);
      break;
    case "UserPromptSubmit":
      setState(s, "running");
      // 這時 transcript 裡最新的助理訊息還是上一輪的結尾，
      // 先標記成已顯示，免得接下來的工具事件把它當成新話講出來
      if (p.narrationId) lastNarrationId = p.narrationId;
      hideBubble();
      break;
    case "PreToolUse":
      if (p.toolName === "AskUserQuestion") {
        setState(s, "waiting");
        say("❓ 有問題想問你", 0, true, title);
      } else if (p.toolName === "ExitPlanMode") {
        setState(s, "waiting");
        say("📋 計畫等你確認", 0, true, title);
      } else {
        setState(s, "running");
        sayActivity(p, title);
      }
      break;
    case "PostToolUse":
      setState(s, "running");
      break;
    case "PostToolUseFailure":
      // Codex：單一工具失敗不會改變 turn 的狀態（turn 還在跑），寵物維持 running，只給個氣泡
      setState(s, "running");
      say(`😵 ${p.toolName || "工具"} 失敗了`, 4000, false, title);
      break;
    case "PermissionRequest":
      setState(s, "waiting");
      say(`🔐 需要你授權${p.toolName ? `：${p.toolName}` : ""}`, 0, true, title);
      break;
    case "Notification": {
      const t = p.notificationType;
      if (t === "permission_prompt") {
        setState(s, "waiting");
        say("🔐 需要你授權", 0, true, title);
      } else if (t === "idle_prompt") {
        // Codex 的 waiting 只對應授權、提問、計畫確認；單純等你下指令不算，狀態不變
        say("💤 在等你回覆", 4000, false, title);
      } else if (t === "elicitation_dialog") {
        setState(s, "waiting");
        say("❓ 有問題想問你", 0, true, title);
      } else if (p.message) {
        say(`💬 ${p.message}`, 4000, false, title);
      }
      break;
    }
    case "Stop":
      if (p.stopHookActive) break;
      // Codex：turn 完成且未讀 → review（播 review 3 次後沉澱），直到使用者回到該 thread 才回 idle。
      // 這裡對應成：維持 review 直到該 session 有下一個動作（UserPromptSubmit）、結束或 7 天到期。
      setState(s, "review");
      // 收尾時把最後一句回覆講出來，而不是只說「完成」
      if (p.narration) {
        lastNarrationId = p.narrationId || lastNarrationId;
        say(`✅ ${firstSentence(p.narration)}`, 8000, false, title);
      } else {
        say("✅ 完成！", 8000, false, title);
      }
      break;
    case "StopFailure":
      // Codex：turn 失敗 → failed 狀態（播 failed 3 次後沉澱），維持到下一個 turn 或 1 小時到期
      setState(s, "failed");
      say(`❌ ${p.error || "出錯了"}`, 8000, false, title);
      break;
    case "SubagentStart":
    case "SubagentStop":
      setState(s, "running");
      break;
    case "PreCompact":
      setState(s, "running");
      say("🗜️ 整理記憶中…", 3000, false, title);
      break;
    case "SessionEnd":
      sessions.delete(p.sessionId || "default");
      if (sessions.size === 0) {
        hideBubble();
        say("👋 掰掰", 2000, false, title);
      }
      break;
    default:
      break;
  }
  report();
}

function handleTest(p) {
  const anim = p.anim;
  if (anim === "clear") {
    sessions.delete("test");
    overlay = null;
    hideBubble();
  } else if (anim === "waving" || anim === "jumping") {
    play(anim, STATE_REPEATS);
  } else if (anim in PRIORITY) {
    const s = session("test");
    setState(s, anim);
    if (anim === "waiting") say("🔐（測試）需要你授權", 0, true, "動作測試");
    else if (anim === "idle") hideBubble();
  }
  report();
}

function report() {
  const base = baseState();
  const snapshot = JSON.stringify({ anim: player.anim, base, sessions: sessions.size, pet: petInfo?.id || null });
  if (snapshot !== lastReported) {
    lastReported = snapshot;
    window.pet.send("pet:state", JSON.parse(snapshot));
  }
}

// ---------- 動畫主迴圈 ----------

function tick(now) {
  const anim = resolveAnim();
  if (anim !== player.anim) {
    startAnim(anim, now);
    report();
  }

  if (anim !== "look") {
    if (now - player.stepStart > 5000) player.stepStart = now; // 從休眠醒來，避免狂追幀
    let guard = 0;
    while (guard++ < 64) {
      const cur = player.frames[player.step];
      if (!cur || now - player.stepStart < cur.ms) break;
      player.stepStart += cur.ms;
      player.step += 1;
      if (player.step >= player.frames.length) {
        if (player.loopStart == null) {
          // 一次性動作播完 → 停在最後一格，交還給基礎狀態
          player.step = player.frames.length - 1;
          const done = overlay?.onDone;
          overlay = null;
          if (done) done();
          break;
        }
        player.step = player.loopStart;
      }
    }
  }

  const base = baseState();
  if (prevBase === "waiting" && base !== "waiting" && bubbleSticky) hideBubble();
  prevBase = base;

  draw(anim);
  requestAnimationFrame(tick);
}

function draw(anim) {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  if (!sheet) return;
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high";

  if (anim === "look") {
    const idx = look.idx;
    const row = 9 + (idx >> 3);
    const col = idx & 7;
    const fix = lookCells && lookCells[idx];
    if (fix && fix.scale !== 1) {
      // 這一格在圖檔裡被整個畫小了（Codex 的 hatch 產生器有時會這樣），
      // 以腳底為支點放大回 idle 的身高，否則游標一往下移人物就會突然縮水。
      const k = fix.scale;
      const sx = canvas.width / CELL_W;
      const sy = canvas.height / CELL_H;
      ctx.drawImage(
        sheet, col * CELL_W, row * CELL_H, CELL_W, CELL_H,
        fix.cx * (1 - k) * sx, fix.bottom * (1 - k) * sy,
        canvas.width * k, canvas.height * k,
      );
      return;
    }
    ctx.drawImage(sheet, col * CELL_W, row * CELL_H, CELL_W, CELL_H, 0, 0, canvas.width, canvas.height);
    return;
  }

  const frame = player.frames[player.step] || player.frames[0];
  if (!frame) return;
  ctx.drawImage(sheet, frame.col * CELL_W, frame.row * CELL_H, CELL_W, CELL_H, 0, 0, canvas.width, canvas.height);
}

// ---------- 看向游標 ----------

window.pet.on("pet:cursor", ({ dx, dy }) => {
  const now = Date.now();
  const dist = Math.hypot(dx, dy);
  if (dist < LOOK_DEADZONE) {
    look.idx = null;
    look.lastMoveAt = now;
    return;
  }
  // 000 = 正上方，順時針
  const angle = (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360;
  const candidate = Math.round(angle / 22.5) % 16;
  if (look.idx === null) {
    look.idx = candidate;
  } else if (candidate !== look.idx) {
    // 加一點遲滯，避免在邊界抖動
    let diff = Math.abs(angle - look.idx * 22.5);
    diff = Math.min(diff, 360 - diff);
    if (diff > 11.25 + 3) look.idx = candidate;
  }
  look.lastMoveAt = now;
});

setInterval(() => {
  if (look.idx !== null && Date.now() - look.lastMoveAt > LOOK_IDLE_AFTER) look.idx = null;
}, 500);

// ---------- 滑鼠：點穿 / 拖曳 / 點擊 / 右鍵 ----------

// 主程序自己輪詢游標並決定點穿（見 main.js 的 updateIgnore），這裡只要回報「哪裡算是寵物」：
// 人物方框與（顯示中的）氣泡的視窗相對矩形。版面一變（縮放、氣泡出現／消失／改字）就重報一次。
// 用方框不用逐像素 alpha：sprite 每一格的輪廓不同，人物的手腳與邊緣會隨動畫在透明／不透明之間
// 切換，逐像素會讓點穿跟著動畫抖，按下去那一瞬間剛好是透明格就穿到底下去。Codex 也是用矩形。
function reportHitRects() {
  const rect = (r) => ({ x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) });
  const b = bubble.classList.contains("hidden") ? null : bubble.getBoundingClientRect();
  window.pet.send("pet:hit-rects", { box: rect(canvas.getBoundingClientRect()), bubble: b ? rect(b) : null });
}
window.addEventListener("resize", reportHitRects);

// hover 判定同樣用人物方框（Codex 的 pet-area-hover 也是 rect）
function overPetArea(x, y) {
  const r = canvas.getBoundingClientRect();
  return x >= r.left && x < r.right && y >= r.top && y < r.bottom;
}

// Codex 的寵物元件在 hover 時會把狀態換成 jumping（codex-pet-assets 的
// `respondToHover && hovered ? "jumping" : state`），走 f() 的規則播 3 次後沉澱回慢速 idle。
// 那個 prop 在 Codex 桌面寵物那層沒有開啟，這裡選擇打開它。
function setHovered(on) {
  if (on === hovered) return;
  hovered = on;
  if (on) {
    // 只在「進入」時觸發一次；一次性動作或拖曳進行中不打斷
    if (!overlay && !drag) {
      play("jumping", STATE_REPEATS);
      overlay.fromHover = true;
    }
    return;
  }
  // 游標離開就立刻收掉，不等三次跳完。Codex 也是這樣：它的顯示狀態是
  // `hovered ? "jumping" : state`，一離開就翻回原狀態、動畫重跑，不會播完。
  if (overlay?.fromHover) overlay = null;
}

// 命中判定只剩 hover（跳三下）：mousemove 與主程序輪詢都走同一條路
function refreshHitTest() {
  if (!lastPoint || pressed || drag) return;
  setHovered(overPetArea(lastPoint.x, lastPoint.y));
}

window.addEventListener("mousemove", (e) => {
  if (pressed) {
    if (!pressed.moved && Math.hypot(e.screenX - pressed.sx, e.screenY - pressed.sy) > 4) {
      pressed.moved = true;
      drag = { dir: null };
      canvas.classList.add("grabbing");
      window.pet.send("pet:drag-start", { offsetX: pressed.x, offsetY: pressed.y });
    }
    return;
  }
  lastPoint = { x: e.clientX, y: e.clientY };
  refreshHitTest();
});

document.addEventListener("mouseout", (e) => {
  if (e.relatedTarget || pressed) return;
  lastPoint = null;
  setHovered(false);
});

window.addEventListener("mousedown", (e) => {
  if (e.button === 0) {
    pressed = { x: e.clientX, y: e.clientY, sx: e.screenX, sy: e.screenY, moved: false };
  } else if (e.button === 2) {
    window.pet.send("pet:context-menu");
  }
});

window.addEventListener("mouseup", (e) => {
  if (e.button !== 0 || !pressed) return;
  const wasDrag = pressed.moved;
  pressed = null;
  if (wasDrag) {
    drag = null;
    canvas.classList.remove("grabbing");
    window.pet.send("pet:drag-end");
  } else {
    onClick();
  }
});

window.addEventListener("contextmenu", (e) => e.preventDefault());

// 主程序的游標輪詢：不管 mousemove 有沒有被轉發進來都會到
window.pet.on("pet:cursor-pos", ({ x, y }) => {
  lastPoint = { x, y };
  refreshHitTest();
});

window.pet.on("pet:drag-move", ({ dx }) => {
  // Codex：單一取樣的水平位移 >= 4 才切 running-right，<= -4 才切 running-left
  if (drag && Math.abs(dx) >= 4) drag.dir = dx < 0 ? "left" : "right";
});

// 主程序結束了拖曳（mouseup、逾時或其他原因）：不管這邊有沒有收到 mouseup，一律把狀態清掉
window.pet.on("pet:drag-ended", () => {
  pressed = null;
  drag = null;
  canvas.classList.remove("grabbing");
});

// 主程序切了點穿狀態：只負責換游標樣式
window.pet.on("pet:ignore-state", (ignore) => canvas.classList.toggle("grab", !ignore));

function onClick() {
  if (!bubble.classList.contains("hidden") && !bubbleSticky) {
    hideBubble();
    return;
  }
  if (!overlay) play("waving", 1);
}

// ---------- 初始化 ----------

window.pet.on("pet:init", ({ pet, scale: s, followCursor: f }) => {
  followCursor = f === true;
  applyScale(s || 1);
  if (pet) loadPet(pet);
  else log("no pet available");
});
window.pet.on("pet:load", ({ pet }) => { if (pet) loadPet(pet); });
window.pet.on("pet:scale", ({ scale: s }) => applyScale(s));
window.pet.on("pet:settings", (st) => {
  if (st && "followCursor" in st) followCursor = !!st.followCursor;
});
window.pet.on("pet:event", handleEvent);

// 系統的「減少動態效果」切換時，讓目前的動畫重新開始（Codex 的 effect 也把 reducedMotion 列為相依）
reducedMotionQuery.addEventListener("change", () => { if (player.anim) startAnim(player.anim, performance.now()); });

window.pet.send("pet:ready");
requestAnimationFrame(tick);
