"use strict";
const { app, BrowserWindow, Tray, Menu, screen, ipcMain, nativeImage, shell, dialog } = require("electron");
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const autostart = require("./lib/autostart");
const hooksInstaller = require("./lib/hooks-installer");
const { unpacked, portableDir, isPackaged } = require("./lib/paths");

const APP_DIR = __dirname;
const HOME = os.homedir();
const CONFIG_DIR = path.join(HOME, ".claude-pet");
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
const LOG_PATH = path.join(CONFIG_DIR, "events.log");

// 執行檔所在的資料夾：開發時是專案資料夾，打包後是 exe 旁邊
// （portable 版這裡會是 %TEMP% 的解壓目錄，使用者那顆 exe 的位置要看 portableDir()）
const APP_ROOT = isPackaged() ? path.dirname(process.execPath) : APP_DIR;
// 寵物一律放執行目錄的 pets/，不埋在 asar 裡，使用者直接丟資料夾進去就會出現
const BUNDLED_PETS_DIR = path.join(APP_ROOT, "pets");
// portable 版整包解在 %TEMP%、關掉就被刪，使用者自己的寵物要放在那顆 exe 旁邊才留得住
const USER_PETS_DIR = portableDir() ? path.join(portableDir(), "pets") : BUNDLED_PETS_DIR;
const CODEX_PETS_DIR = path.join(HOME, ".codex", "pets");
// 一律會被掃到、且不寫進設定檔的來源。使用者自己放的排最前面，同 id 才不會被內附的蓋掉
// （非 portable 時 USER 就等於 BUNDLED，去重後只剩一個）
const IMPLICIT_PET_DIRS = [...new Set([USER_PETS_DIR, BUNDLED_PETS_DIR, CODEX_PETS_DIR])];

const CELL_W = 192;    // spritesheet 單格來源尺寸
const CELL_H = 208;
// 100% 時的顯示尺寸，對齊 Codex 桌面寵物：寬度為基準，高度用 Codex 的 ake() 公式 ceil(w * 208/192)
const PET_W = 126;
const PET_H = Math.ceil((PET_W * CELL_H) / CELL_W);   // 137
const BUBBLE_H = 72;   // 氣泡保留高度
const PAD_X = 24;      // 左右最小透明邊
const PAD_BOTTOM = 8;
const MIN_WIN_W = 240; // 視窗最小寬度，保留氣泡的可讀寬度（Codex 的氣泡也遠寬於寵物本身）
// Codex 的 mascot 寬度可調範圍是 80–224 px（ike() 的 clamp）；以 126 為 100%，這幾檔都落在範圍內
const SCALES = [0.65, 0.8, 1, 1.25, 1.5, 1.75];
const TOPMOST_REASSERT_MS = 2000;   // 多久重新宣告一次置頂（見 reassertTopmost）
const DRAG_MAX_MS = 20000;          // 拖曳最長時間：放開事件若沒送到，別讓寵物一直跟著游標（見 endDrag）
const { inside, petBoxRect, wantIgnore } = require("./lib/hit-test");

const DEFAULT_CONFIG = {
  activePetId: "xiao-nian",
  petSources: [],   // 只放使用者自己加的額外路徑；執行目錄的 pets 與 ~/.codex/pets 一律隱含
  scale: 1,
  position: null,
  alwaysOnTop: true,
  followCursor: false,   // Codex 的寵物不看滑鼠（它看的是 quick chat 文字游標／computer-use 游標）；預設關
  greetedPetIds: [],     // 已經打過招呼的寵物（Codex 的 first-awake 每隻只做一次）
  port: 17333,
};

let config = loadConfig();
let win = null;
let tray = null;
let pets = [];
let dragTimer = null;
let cursorTimer = null;
let topmostTimer = null;
let lastCursor = { x: NaN, y: NaN };
// 點穿狀態由主程序自己決定與追蹤（見 updateIgnore）；renderer 只回報它量到的矩形
let ignoring = true;
let hitRects = null;          // renderer 回報的 { box, bubble }（視窗相對座標），null 表示還沒回報
let menuOpenedAt = 0;         // 右鍵選單打開的時間，0 表示沒開
// 給 /state 看的診斷資料：下次「點不到寵物」時不用猜，直接看這裡
const mouseDiag = {
  toggles: 0, lastToggleAt: null, polls: 0, lastPollAt: null,
  rel: null, inBox: false, inBubble: false,
  rendererErrors: 0, lastRendererError: null, dragEnds: [],
};
let currentState = { anim: "idle", base: "idle", sessions: 0 };

// ---------- config ----------

function loadConfig() {
  let saved = {};
  try { saved = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { /* first run */ }
  const merged = { ...DEFAULT_CONFIG, ...saved };
  // petSources 只存「使用者自己加的」。隱含來源（執行目錄的 pets、~/.codex/pets）不寫進設定檔，
  // 否則程式一搬家、或在安裝版與 portable 版之間換來換去，設定檔就會一直累積失效的絕對路徑。
  const implicit = new Set(IMPLICIT_PET_DIRS.map(dirKey));
  merged.petSources = (Array.isArray(merged.petSources) ? merged.petSources : [])
    .filter((p) => typeof p === "string" && p.trim())
    .filter((p) => !implicit.has(dirKey(p)))
    .filter((p) => fs.existsSync(p));
  if (!SCALES.includes(Number(merged.scale))) merged.scale = 1;
  merged.scale = Number(merged.scale);
  if (!Array.isArray(merged.greetedPetIds)) merged.greetedPetIds = [];
  return merged;
}

function saveConfig() {
  try {
    fs.mkdirSync(CONFIG_DIR, { recursive: true });
    fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
  } catch (err) {
    console.error("saveConfig failed:", err);
  }
}

function appendLog(line) {
  try {
    fs.mkdirSync(CONFIG_DIR, { recursive: true });
    try { if (fs.statSync(LOG_PATH).size > 2 * 1024 * 1024) fs.truncateSync(LOG_PATH, 0); } catch { /* no file yet */ }
    fs.appendFileSync(LOG_PATH, `${new Date().toISOString()} ${line}\n`, "utf8");
  } catch { /* ignore */ }
}

// ---------- transcript：取最新的助理說明文字 ----------

// Claude Code 從 2.1.238 起不再把 thinking 的文字寫進 transcript（只剩加密簽章），
// 但助理的可見說明（text 區塊）還在。讀檔尾就夠了，transcript 會長到好幾 MB。
const TRANSCRIPT_TAIL = 192 * 1024;
const narrationCache = new Map();   // path → { size, value }

// 回傳 { narration: { id, text } | null, title: string | null }
// title 取自 /rename 寫進 transcript 的 custom-title，沒有就讓呼叫端退回專案名
function readTranscript(file) {
  if (typeof file !== "string" || !file) return null;
  let stat;
  try { stat = fs.statSync(file); } catch { return null; }
  const cached = narrationCache.get(file);
  if (cached && cached.size === stat.size) return cached.value;

  let narration = null;
  let title = null;
  try {
    const start = Math.max(0, stat.size - TRANSCRIPT_TAIL);
    const fd = fs.openSync(file, "r");
    const buf = Buffer.alloc(stat.size - start);
    fs.readSync(fd, buf, 0, buf.length, start);
    fs.closeSync(fd);
    const lines = buf.toString("utf8").split("\n");
    if (start > 0) lines.shift();   // 第一行多半被切一半
    // 由後往前找，先遇到的就是最新的；兩樣都拿到就停
    for (let i = lines.length - 1; i >= 0 && !(narration && title); i--) {
      const line = lines[i].trim();
      if (!line) continue;
      let entry;
      try { entry = JSON.parse(line); } catch { continue; }
      if (!title && entry.type === "custom-title" && typeof entry.customTitle === "string" && entry.customTitle.trim()) {
        title = entry.customTitle.trim().slice(0, 60);
        continue;
      }
      if (narration || entry.type !== "assistant") continue;
      const content = entry.message?.content;
      if (!Array.isArray(content)) continue;
      for (const block of content) {
        if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) {
          narration = { id: entry.uuid || entry.timestamp || String(i), text: block.text.trim().slice(0, 400) };
          break;
        }
      }
    }
  } catch { /* 讀不到就算了，寵物不該因此出錯 */ }

  const value = { narration, title };
  narrationCache.set(file, { size: stat.size, value });
  if (narrationCache.size > 32) narrationCache.delete(narrationCache.keys().next().value);
  return value;
}

// ---------- pets ----------

function dirKey(p) {
  return path.resolve(p).toLowerCase();
}

// 實際要掃的資料夾：執行目錄的 pets 永遠排最前面，其次是 Codex 的，最後才是使用者自己加的
function petSearchDirs() {
  const seen = new Set();
  return [...IMPLICIT_PET_DIRS, ...config.petSources].filter((p) => {
    const key = dirKey(p);
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

// portable 版第一次跑時，在 exe 旁邊開一個 pets 資料夾給使用者放自己的寵物
function ensureUserPetsDir() {
  if (USER_PETS_DIR === BUNDLED_PETS_DIR) return;
  try { fs.mkdirSync(USER_PETS_DIR, { recursive: true }); } catch { /* 唯讀路徑就算了 */ }
}

function discoverPets() {
  const found = [];
  const seen = new Set();
  for (const src of petSearchDirs()) {
    let entries = [];
    try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { continue; }
    for (const ent of entries) {
      if (!ent.isDirectory()) continue;
      const dir = path.join(src, ent.name);
      let manifest;
      try { manifest = JSON.parse(fs.readFileSync(path.join(dir, "pet.json"), "utf8")); } catch { continue; }
      const id = String(manifest.id || ent.name);
      if (seen.has(id)) continue;
      const spritesheetPath = path.join(dir, manifest.spritesheetPath || "spritesheet.webp");
      if (!fs.existsSync(spritesheetPath)) continue;
      seen.add(id);
      found.push({
        id,
        displayName: manifest.displayName || id,
        description: manifest.description || "",
        dir,
        spritesheetPath,
        version: Number(manifest.spriteVersionNumber) === 2 ? 2 : 1,
      });
    }
  }
  return found;
}

function activePet() {
  return pets.find((p) => p.id === config.activePetId) || pets[0] || null;
}

function petPayload(pet) {
  if (!pet) return null;
  const ext = path.extname(pet.spritesheetPath).toLowerCase();
  const mime = ext === ".png" ? "image/png" : "image/webp";
  const data = fs.readFileSync(pet.spritesheetPath).toString("base64");
  return {
    id: pet.id,
    displayName: pet.displayName,
    description: pet.description,
    version: pet.version,
    greeted: config.greetedPetIds.includes(pet.id),
    dataUrl: `data:${mime};base64,${data}`,
  };
}

// ---------- window ----------

function windowSizeFor(scale) {
  return {
    width: Math.max(Math.round(PET_W * scale) + PAD_X * 2, MIN_WIN_W),
    height: Math.round(PET_H * scale) + BUBBLE_H + PAD_BOTTOM,
  };
}

function defaultPosition(size) {
  const { workArea } = screen.getPrimaryDisplay();
  return {
    // Codex 的 anchor：離工作區右邊與底邊各 24 px（z5 = 24）
    x: workArea.x + workArea.width - size.width - 24,
    y: workArea.y + workArea.height - size.height - 24,
  };
}

function clampToDisplays(pos, size) {
  const display = screen.getDisplayMatching({ x: pos.x, y: pos.y, width: size.width, height: size.height });
  const wa = display.workArea;
  return {
    x: Math.round(Math.min(Math.max(pos.x, wa.x - size.width + 48), wa.x + wa.width - 48)),
    y: Math.round(Math.min(Math.max(pos.y, wa.y - 16), wa.y + wa.height - 48)),
  };
}

function createWindow() {
  const size = windowSizeFor(config.scale);
  const pos = clampToDisplays(config.position || defaultPosition(size), size);
  win = new BrowserWindow({
    x: pos.x,
    y: pos.y,
    width: size.width,
    height: size.height,
    transparent: true,
    frame: false,
    alwaysOnTop: config.alwaysOnTop,
    skipTaskbar: true,
    resizable: false,
    movable: false,
    minimizable: false,
    maximizable: false,
    fullscreenable: false,
    hasShadow: false,
    focusable: false,
    show: false,
    title: "Claude Pet",
    webPreferences: {
      preload: path.join(APP_DIR, "preload.js"),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
      backgroundThrottling: false,
    },
  });
  win.setMenu(null);
  if (config.alwaysOnTop) win.setAlwaysOnTop(true, "screen-saver");
  win.setIgnoreMouseEvents(true, { forward: true });
  win.loadFile(path.join(APP_DIR, "renderer", "index.html"));
  win.once("ready-to-show", () => {
    win.showInactive();
    reassertTopmost();
  });
  win.on("closed", () => { win = null; });
  // renderer 掛了就記下來並重載；點穿判定在主程序，寵物不會因此卡成點不到
  win.webContents.on("render-process-gone", (_e, details) => {
    appendLog(`renderer gone: ${details?.reason || "unknown"}`);
    hitRects = null;
    setTimeout(() => { if (win && !win.isDestroyed()) win.webContents.reload(); }, 500);
  });
  win.webContents.on("unresponsive", () => appendLog("renderer unresponsive"));
  win.webContents.on("responsive", () => appendLog("renderer responsive again"));
}

function send(channel, data) {
  if (win && !win.isDestroyed()) win.webContents.send(channel, data);
}

function sendInit() {
  send("pet:init", {
    pet: petPayload(activePet()),
    scale: config.scale,
    followCursor: config.followCursor,
  });
}

// ---------- drag ----------

function startDrag(offsetX, offsetY) {
  if (!win || win.isDestroyed()) return;
  endDrag(false, "restart");
  let last = screen.getCursorScreenPoint();
  const startedAt = Date.now();
  appendLog(`drag start offset=${offsetX},${offsetY}`);
  applyIgnore(false);
  dragTimer = setInterval(() => {
    if (!win || win.isDestroyed()) return endDrag(false, "no-window");
    if (Date.now() - startedAt > DRAG_MAX_MS) return endDrag(true, "timeout");
    const p = screen.getCursorScreenPoint();
    const dx = p.x - last.x;
    last = p;
    win.setPosition(Math.round(p.x - offsetX), Math.round(p.y - offsetY), false);
    send("pet:drag-move", { dx });
  }, 16);
}

// 視窗永遠不取得焦點（focusable:false），Windows 對背景視窗的滑鼠 capture 有限制：
// 放開的那一下若剛好落在視窗外，renderer 收不到 mouseup。所以結束拖曳的原因不只 mouseup，
// 而且不管誰結束的都要通知 renderer 清掉按住／拖曳狀態，免得它一直以為還在拖。
function endDrag(save, reason = "mouseup") {
  if (!dragTimer) return;
  clearInterval(dragTimer);
  dragTimer = null;
  if (save && win && !win.isDestroyed()) {
    const [x, y] = win.getPosition();
    config.position = { x, y };
    saveConfig();
  }
  appendLog(`drag end reason=${reason} pos=${config.position ? `${config.position.x},${config.position.y}` : "?"}`);
  mouseDiag.dragEnds.push({ at: Date.now(), reason });
  if (mouseDiag.dragEnds.length > 10) mouseDiag.dragEnds.shift();
  send("pet:drag-ended");
}

// ---------- cursor look ----------

// Windows 有時候會把視窗踢出 topmost band，卻留著 WS_EX_TOPMOST 樣式位元：
// 從外部檢查會看到 TOPMOST=true，但 z-order 排在一般視窗（例如 VS Code）之下，
// 於是寵物就被蓋住，而 Electron 這邊仍以為自己是置頂的，不會自己修好。
// setAlwaysOnTop 在狀態沒變時可能不會真的呼叫 SetWindowPos，所以再補一個 moveTop()
// 強制重新插回 topmost band 的最上面（視窗是 WS_EX_NOACTIVATE + focusable:false，不會搶焦點）。
function reassertTopmost() {
  // 選單開著時不做：moveTop 會把寵物視窗抬到選單上面，人物方框整塊接滑鼠，被蓋住的選單項目就點不到了
  if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer || menuIsOpen()) return;
  try {
    win.setAlwaysOnTop(true, "screen-saver");
    win.moveTop();
  } catch { /* 視窗正在關閉就算了 */ }
}

function startTopmostGuard() {
  topmostTimer = setInterval(reassertTopmost, TOPMOST_REASSERT_MS);
  // 螢幕配置變動最容易觸發降級，變動後立刻補一次
  screen.on("display-metrics-changed", reassertTopmost);
  screen.on("display-added", reassertTopmost);
  screen.on("display-removed", reassertTopmost);
}

function menuIsOpen() {
  // popup 的 callback 關閉時一定會來；保險起見超過 60 秒就當它已經關了
  return menuOpenedAt > 0 && Date.now() - menuOpenedAt < 60000;
}

function currentHitRects() {
  if (hitRects) return hitRects;
  if (!win || win.isDestroyed()) return { box: null, bubble: null };
  // renderer 還沒回報（或掛了）：用版面公式算人物方框，氣泡就當沒有
  return { box: petBoxRect(win.getBounds(), config.scale, { petW: PET_W, petH: PET_H, bubbleH: BUBBLE_H }), bubble: null };
}

// 點穿只在這裡切換。以前是 renderer 判定再用 IPC 叫主程序切：renderer 一旦例外、卡住或狀態錯亂
// （例如放開事件沒送到、pressed 一直是 true），點穿就永遠不會再更新——這是「不能拖曳也不能右鍵」
// 最難查的一種成因。現在主程序拿自己輪詢到的游標、renderer 回報的矩形、是否拖曳中，自己決定。
function applyIgnore(ignore) {
  if (!win || win.isDestroyed() || ignore === ignoring) return;
  ignoring = ignore;
  mouseDiag.toggles++;
  mouseDiag.lastToggleAt = Date.now();
  try { win.setIgnoreMouseEvents(ignore, { forward: true }); } catch { /* 視窗正在關閉 */ }
  send("pet:ignore-state", ignore);
}

function updateIgnore(rel) {
  const { box, bubble } = currentHitRects();
  mouseDiag.rel = rel;
  mouseDiag.inBox = inside(box, rel);
  mouseDiag.inBubble = inside(bubble, rel);
  applyIgnore(wantIgnore({ rel, box, bubble, dragging: !!dragTimer }));
}

function startCursorPolling() {
  cursorTimer = setInterval(() => {
    if (!win || win.isDestroyed()) return;
    let p;
    try { p = screen.getCursorScreenPoint(); } catch { return; }
    mouseDiag.polls++;
    mouseDiag.lastPollAt = Date.now();
    const b = win.getBounds();
    const rel = { x: p.x - b.x, y: p.y - b.y };
    updateIgnore(rel);
    if (dragTimer) return;   // 拖曳中視窗跟著游標走，座標交給 drag loop
    if (p.x === lastCursor.x && p.y === lastCursor.y) return;
    lastCursor = p;
    // hover（跳三下）與拖曳起點仍由 renderer 處理，所以座標照送。視窗處於點穿狀態時
    // Electron 的 setIgnoreMouseEvents(true, { forward: true }) 並不保證會把 mousemove 轉發進來
    // （游標直接跳到寵物上常常收不到），主程序自己輪詢絕對不會漏。
    send("pet:cursor-pos", rel);
    if (!config.followCursor) return;
    const cx = b.x + b.width / 2;
    const cy = b.y + BUBBLE_H + (PET_H * config.scale) / 2; // 人物方框的中心，與 Codex 相同
    send("pet:cursor", { dx: p.x - cx, dy: p.y - cy });
  }, 50);
}

// ---------- HTTP：接收 Claude Code hook ----------

function startServer() {
  const server = http.createServer((req, res) => {
    if (req.method === "POST" && req.url === "/event") {
      let body = "";
      req.on("data", (chunk) => {
        body += chunk;
        if (body.length > 65536) req.destroy();
      });
      req.on("end", () => {
        let payload = null;
        try { payload = JSON.parse(body || "{}"); } catch { /* bad json */ }
        if (payload && typeof payload === "object") {
          appendLog(JSON.stringify(payload));
          const info = readTranscript(payload.transcriptPath);
          if (info) {
            if (info.narration) {
              payload.narration = info.narration.text;
              payload.narrationId = info.narration.id;
            }
            if (info.title) payload.sessionTitle = info.title;
          }
          send("pet:event", payload);
          res.writeHead(204);
        } else {
          res.writeHead(400);
        }
        res.end();
      });
      return;
    }
    if (req.method === "GET" && req.url === "/state") {
      res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
      res.end(JSON.stringify({
        ...currentState,
        pet: activePet()?.id || null,
        scale: config.scale,
        bounds: win && !win.isDestroyed() ? win.getBounds() : null,
        mouse: {
          ignoring,
          dragging: !!dragTimer,
          menuOpen: menuIsOpen(),
          rects: { ...currentHitRects(), source: hitRects ? "renderer" : "fallback" },
          ...mouseDiag,
        },
      }));
      return;
    }
    if (req.method === "GET" && req.url === "/health") {
      res.writeHead(200, { "content-type": "text/plain" });
      res.end("ok");
      return;
    }
    res.writeHead(404);
    res.end();
  });
  server.on("error", (err) => {
    dialog.showErrorBox("Claude Pet", `無法監聽 127.0.0.1:${config.port}\n${err.message}\n\n是不是已經有一隻在跑了？`);
  });
  server.listen(config.port, "127.0.0.1");
}

// ---------- tray / menu ----------

function fallbackTrayIcon() {
  const size = 16;
  const buf = Buffer.alloc(size * size * 4);
  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      const dx = x - 7.5;
      const dy = y - 7.5;
      const inside = dx * dx + dy * dy <= 6.5 * 6.5;
      const i = (y * size + x) * 4;
      buf[i] = 0x4a; buf[i + 1] = 0x7c; buf[i + 2] = 0xff; buf[i + 3] = inside ? 255 : 0; // BGRA
    }
  }
  return nativeImage.createFromBitmap(buf, { width: size, height: size });
}

function createTray() {
  tray = new Tray(fallbackTrayIcon());
  tray.setToolTip("Claude Pet");
  tray.on("click", () => tray.popUpContextMenu(buildMenu()));
  refreshTray();
}

function refreshTray() {
  if (!tray) return;
  tray.setContextMenu(buildMenu());
  const pet = activePet();
  tray.setToolTip(`Claude Pet – ${pet ? pet.displayName : "無寵物"}\n狀態：${currentState.anim}（${currentState.sessions} 個 session）`);
}

function runSafely(fn) {
  try { fn(); } catch (err) { dialog.showErrorBox("Claude Pet", String(err.message || err)); }
  refreshTray();
}

function info(message) {
  dialog.showMessageBox({ type: "info", title: "Claude Pet", message, buttons: ["OK"] });
}

function buildMenu() {
  const pet = activePet();
  const TESTS = [
    ["idle", "待機"], ["running", "工作中"], ["waiting", "等待授權"], ["review", "檢視成果（完成）"],
    ["failed", "失敗"], ["waving", "揮手"], ["jumping", "跳躍"],
  ];
  return Menu.buildFromTemplate([
    { label: pet ? `${pet.displayName}（${pet.id}）` : "沒有可用的寵物", enabled: false },
    { type: "separator" },
    {
      label: "切換寵物",
      submenu: [
        ...pets.map((p) => ({
          label: `${p.displayName}${p.version === 2 ? "" : "（v1，無追視）"}`,
          type: "radio",
          checked: p.id === pet?.id,
          click: () => switchPet(p.id),
        })),
        { type: "separator" },
        { label: "重新掃描", click: () => { pets = discoverPets(); if (!activePet()) return; refreshTray(); } },
        { label: "開啟寵物資料夾", click: () => { ensureUserPetsDir(); shell.openPath(USER_PETS_DIR); } },
      ],
    },
    {
      label: "大小",
      submenu: SCALES.map((s) => ({
        label: `${Math.round(s * 100)}%`,
        type: "radio",
        checked: s === config.scale,
        click: () => setScale(s),
      })),
    },
    {
      label: "跟著游標看",
      type: "checkbox",
      checked: config.followCursor,
      click: (item) => {
        config.followCursor = item.checked;
        saveConfig();
        send("pet:settings", { followCursor: config.followCursor });
      },
    },
    {
      label: "永遠置頂",
      type: "checkbox",
      checked: config.alwaysOnTop,
      click: (item) => {
        config.alwaysOnTop = item.checked;
        saveConfig();
        win?.setAlwaysOnTop(item.checked, "screen-saver");
        if (item.checked) reassertTopmost();
      },
    },
    {
      label: "開機自動啟動",
      type: "checkbox",
      checked: autostart.isInstalled(),
      click: (item) => runSafely(() => (item.checked ? autostart.install() : autostart.uninstall())),
    },
    { type: "separator" },
    {
      label: "動作測試",
      submenu: [
        ...TESTS.map(([anim, label]) => ({ label, click: () => send("pet:event", { event: "__test", anim }) })),
        { type: "separator" },
        { label: "清除測試狀態", click: () => send("pet:event", { event: "__test", anim: "clear" }) },
      ],
    },
    {
      label: "Claude Code hooks",
      submenu: [
        {
          label: hooksInstaller.isInstalled() ? "已安裝 ✓（重新安裝）" : "安裝到 ~/.claude/settings.json",
          click: () => runSafely(() => {
            const r = hooksInstaller.install();
            info(`已安裝 ${r.events.length} 個 hook 事件。\n\n已開啟的 Claude Code session 要重新啟動才會生效。`);
          }),
        },
        {
          label: "移除",
          enabled: hooksInstaller.isInstalled(),
          click: () => runSafely(() => {
            const r = hooksInstaller.uninstall();
            info(`已移除 ${r.removed} 個 hook。`);
          }),
        },
      ],
    },
    { label: "回到預設位置", click: resetPosition },
    { label: "開啟設定檔", click: () => { saveConfig(); shell.openPath(CONFIG_PATH); } },
    { label: "開啟事件紀錄", click: () => { appendLog("open log"); shell.openPath(LOG_PATH); } },
    { type: "separator" },
    { label: "結束", click: () => app.quit() },
  ]);
}

function switchPet(id) {
  if (!pets.some((p) => p.id === id)) return;
  config.activePetId = id;
  saveConfig();
  send("pet:load", { pet: petPayload(activePet()) });
  refreshTray();
}

function setScale(scale) {
  if (!win) return;
  const b = win.getBounds();
  const size = windowSizeFor(scale);
  const x = Math.round(b.x + (b.width - size.width) / 2);
  const y = Math.round(b.y + (b.height - size.height));
  win.setBounds({ x, y, width: size.width, height: size.height }, false);
  config.scale = scale;
  config.position = { x, y };
  saveConfig();
  send("pet:scale", { scale });
  refreshTray();
}

function resetPosition() {
  if (!win) return;
  const pos = defaultPosition(windowSizeFor(config.scale));
  win.setPosition(pos.x, pos.y, false);
  config.position = null;
  saveConfig();
}

// ---------- IPC ----------

ipcMain.on("pet:ready", () => sendInit());
function sanitizeRect(r) {
  if (!r || typeof r !== "object") return null;
  const v = ["x", "y", "w", "h"].map((k) => Number(r[k]));
  return v.every(Number.isFinite) ? { x: v[0], y: v[1], w: v[2], h: v[3] } : null;
}
ipcMain.on("pet:hit-rects", (_e, rects) => {
  const box = sanitizeRect(rects?.box);
  hitRects = box ? { box, bubble: sanitizeRect(rects?.bubble) } : null;
  // 氣泡剛出現在靜止的游標底下這種情況，不等下一次游標移動就重算
  if (win && !win.isDestroyed() && Number.isFinite(lastCursor.x)) {
    const b = win.getBounds();
    updateIgnore({ x: lastCursor.x - b.x, y: lastCursor.y - b.y });
  }
});
ipcMain.on("pet:drag-start", (_e, { offsetX, offsetY }) => startDrag(Number(offsetX) || 0, Number(offsetY) || 0));
ipcMain.on("pet:drag-end", () => endDrag(true));
ipcMain.on("pet:context-menu", () => {
  if (!win || win.isDestroyed()) return;
  menuOpenedAt = Date.now();   // 開著的期間暫停置頂重宣告，見 reassertTopmost
  appendLog("menu open");
  buildMenu().popup({ window: win, callback: () => { menuOpenedAt = 0; appendLog("menu closed"); } });
});
ipcMain.on("pet:tray-icon", (_e, dataUrl) => {
  try { tray?.setImage(nativeImage.createFromDataURL(dataUrl)); } catch (err) { console.error(err); }
});
ipcMain.on("pet:state", (_e, state) => {
  currentState = { ...currentState, ...state };
  refreshTray();
});
ipcMain.on("pet:log", (_e, line) => {
  const s = String(line);
  if (s.includes("[renderer] error")) {
    mouseDiag.rendererErrors++;
    mouseDiag.lastRendererError = s.slice(0, 200);
  }
  appendLog(s);
});
ipcMain.on("pet:greeted", (_e, id) => {
  if (typeof id !== "string" || config.greetedPetIds.includes(id)) return;
  config.greetedPetIds.push(id);
  saveConfig();
});

// ---------- app ----------

app.setAppUserModelId("com.jianmiau.claude-pet");

if (!app.requestSingleInstanceLock()) {
  app.quit();
} else {
  app.on("second-instance", () => {
    if (win) win.showInactive();
  });

  app.whenReady().then(() => {
    ensureUserPetsDir();
    pets = discoverPets();
    if (pets.length === 0) {
      dialog.showErrorBox("Claude Pet", `找不到任何寵物。\n請把 Codex 寵物包（pet.json + spritesheet.webp）放到：\n${petSearchDirs().join("\n")}`);
    }
    createWindow();
    createTray();
    startServer();
    startCursorPolling();
    startTopmostGuard();
    appendLog(`started pet=${activePet()?.id || "none"} scale=${config.scale} port=${config.port}`);
  });

  app.on("window-all-closed", () => {
    // 留在系統匣，不結束
  });

  app.on("before-quit", () => {
    if (cursorTimer) clearInterval(cursorTimer);
    if (topmostTimer) clearInterval(topmostTimer);
    endDrag(false);
  });
}
