// 칼바람 컴패니언 로컬 서버 — 더블클릭 런처가 이걸 띄우고 브라우저를 연다.
//  /api/me      = 내 LCU 개인기록(일반·증강). Riot API키 0.
//  /api/champs  = aram-mayhem 집계 메타(모드별).
import http from "node:http";
import https from "node:https";
import os from "node:os";
import { readFile } from "node:fs/promises";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { exec } from "node:child_process";
import { getRecords, personalChampBuild } from "./lcu_personal.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REL_DIR = path.resolve(__dirname, "../../aram_mayhem_auto/data/pages_site/static/api");
const ABS_DIR = "E:\\프린서버\\plugins\\Skript\\scripts\\aram_mayhem_auto\\data\\pages_site\\static\\api"; // 설치본 폴백(상대경로 깨질 때)
const API_DIR = process.env.MAYHEM_API_DIR || (existsSync(REL_DIR) ? REL_DIR : ABS_DIR);
const PORT = Number(process.env.MAYHEM_COMPANION_PORT || 8900);
const cache = {};
const norm = s => String(s || "").normalize("NFKC").trim().toLowerCase();
const GROUP_KO = { fighter: "전사", mage: "마법사", marksman: "원거리", assassin: "암살자", tank: "탱커", support: "서포터", slayer: "척결자", controller: "제어", specialist: "전문가" };
// 스탯 파편 아이콘 — championAugment·augmentRows 둘 다 빈값이라 ddragon StatMods로 id 매핑(전부 200 확인)
const SHARD_ICON = { 5008: "StatModsAdaptiveForceIcon", 5005: "StatModsAttackSpeedIcon", 5007: "StatModsCDRScalingIcon", 5010: "StatModsMovementSpeedIcon", 5013: "StatModsTenacityIcon", 5001: "StatModsHealthScalingIcon", 5011: "StatModsHealthPlusIcon" };
const shardIcon = id => SHARD_ICON[Number(id)] ? `https://ddragon.leagueoflegends.com/cdn/img/perk-images/StatMods/${SHARD_ICON[Number(id)]}.png` : "";
const adjPct = (w, g) => Math.round((g ? (w + 10) / (g + 20) : 0.5) * 100); // 표본보정(소표본 50%수축)

// ── 아이콘 프록시 — Electron 렌더러가 외부 https 이미지를 못 띄우는 문제 우회. 서버가 받아 localhost로 내줌(+디스크캐시). ──
const ICON_HOSTS = new Set(["ddragon.leagueoflegends.com", "opgg-static.akamaized.net", "raw.communitydragon.org", "cdn.communitydragon.org"]); // metasrc는 403(핫링크차단)이라 제외 — 대신 augmentCombos가 번들 communitydragon 아이콘으로 폴백
const ICON_DIR = path.join(os.tmpdir(), "aram-mayhem-icons");
try { mkdirSync(ICON_DIR, { recursive: true }); } catch {}
const imgMem = new Map();
const diskKey = url => path.join(ICON_DIR, url.replace(/[^a-z0-9]/gi, "_").slice(-140) + ".png");
function fetchBuf(url) {
  return new Promise((resolve, reject) => {
    const req = https.get(url, { timeout: 10000 }, r => {
      if (r.statusCode !== 200) { r.resume(); return reject(new Error("status " + r.statusCode)); }
      const ch = []; r.on("data", c => ch.push(c)); r.on("end", () => resolve(Buffer.concat(ch)));
    });
    req.on("timeout", () => req.destroy(new Error("timeout"))); req.on("error", reject);
  });
}
async function getIcon(url) {
  if (imgMem.has(url)) return imgMem.get(url);
  const f = diskKey(url);
  let buf = existsSync(f) ? readFileSync(f) : null;
  if (!buf) { buf = await fetchBuf(url); try { writeFileSync(f, buf); } catch {} }
  imgMem.set(url, buf); return buf;
}
// ── 메타 데이터 원격화 — 어느 PC에서든 aram-mayhem.com 게시본을 받아 사이트와 일치(사이트 갱신 시 자동 반영). ──
//    조건부 GET(ETag)으로 변경분만 받고, 실패 시 디스크캐시→로컬파일 순 폴백(오프라인/메인PC 동작).
const DATA_BASE = process.env.MAYHEM_DATA_BASE || "https://aram-mayhem.com/static/api";
const R2_BASE = process.env.MAYHEM_R2_BASE || "https://pub-05f47b0b01de41feaa96c0efaedde627.r2.dev"; // 컴패니언 전용 번들 호스팅(사이트 25MB 제한 회피)
const FILE_BASE = { "companion-augment-meta.json": R2_BASE }; // 증강 메타 번들은 R2에서(사이트엔 미배포). 표준·콤보는 DATA_BASE(사이트, 자동동기화)
const DATA_DIR = path.join(os.tmpdir(), "aram-mayhem-data");
try { mkdirSync(DATA_DIR, { recursive: true }); } catch {}
const dataMem = {};
const dataDisk = file => path.join(DATA_DIR, file.replace(/[^a-z0-9._-]/gi, "_"));
const TTL_MS = 5 * 60 * 1000; // 디스크캐시 신선도 — 이 안엔 네트워크 생략(잦은 재실행 시 대용량 재다운 방지). 한 세션 내엔 dataMem로 1회만 fetch.
function fetchData(url, cond) {
  return new Promise((resolve, reject) => {
    const headers = {};
    if (cond && cond.etag) headers["If-None-Match"] = cond.etag;
    if (cond && cond.lastModified) headers["If-Modified-Since"] = cond.lastModified;
    const req = https.get(url, { headers, timeout: 15000 }, r => {
      if (r.statusCode === 304) { r.resume(); return resolve({ notModified: true }); }
      if (r.statusCode !== 200) { r.resume(); return reject(new Error("status " + r.statusCode)); }
      let d = ""; r.on("data", c => d += c); r.on("end", () => resolve({ body: d, etag: r.headers.etag || "", lastModified: r.headers["last-modified"] || "" }));
    });
    req.on("timeout", () => req.destroy(new Error("timeout"))); req.on("error", reject); // idle 타임아웃 → 캐시/로컬 폴백
  });
}
const parseJson = t => { try { return (t && t[0] !== "<") ? JSON.parse(t) : null; } catch { return null; } }; // SPA 폴백 HTML(<!doctype) 거부 + 안전 파싱
async function loadData(file) { // 파싱된 JSON 반환. (TTL내 디스크) → 조건부 원격 → 디스크캐시 → 로컬 순
  if (dataMem[file]) return dataMem[file];
  const diskF = dataDisk(file), metaF = diskF + ".meta";
  let meta = null; try { meta = JSON.parse(readFileSync(metaF, "utf8")); } catch {}
  let json = null;
  if (meta && existsSync(diskF) && (Date.now() - (meta.fetchedAt || 0) < TTL_MS)) json = parseJson(readFileSync(diskF, "utf8")); // TTL 안 → 네트워크 생략
  if (json == null) {
    try {
      const r = await fetchData(`${FILE_BASE[file] || DATA_BASE}/${file}`, meta);
      if (r.notModified) { if (existsSync(diskF)) { json = parseJson(readFileSync(diskF, "utf8")); try { writeFileSync(metaF, JSON.stringify({ ...meta, fetchedAt: Date.now() })); } catch {} } }
      else { json = parseJson(r.body); if (json) { try { writeFileSync(diskF, r.body); writeFileSync(metaF, JSON.stringify({ etag: r.etag, lastModified: r.lastModified, fetchedAt: Date.now() })); } catch {} } } // 없는 파일=Pages가 200+HTML → null → 폴백
    } catch {}
  }
  if (json == null && existsSync(diskF)) json = parseJson(readFileSync(diskF, "utf8")); // 원격 실패/HTML → 디스크캐시(만료여도)
  if (json == null) { const lf = path.join(API_DIR, file); if (existsSync(lf)) json = parseJson(readFileSync(lf, "utf8")); } // → 로컬(개발/메인PC)
  if (json == null) throw new Error("데이터를 받을 수 없음: " + file);
  return (dataMem[file] = json);
}
async function augMeta() { return await loadData("companion-augment-meta.json"); } // 증강 메타 번들(챔피언/아이템/증강 — db.json 없이 사이트와 일치)
async function load(file) { return (await loadData(file)).report || {}; }
function itemIconMap(r) { if (!r._iic) { r._iic = new Map(); for (const it of (r.itemRows || [])) if (it.id != null) r._iic.set(Number(it.id), it.icon); } return r._iic; }
function champIconMap(r) { if (!r._cic) { r._cic = new Map(); for (const c of (r.championRows || [])) { if (!c.icon) continue; if (c.localName) r._cic.set(norm(c.localName), c.icon); if (c.displayName) r._cic.set(norm(c.displayName), c.icon); } } return r._cic; }
function topCombos(r, champ, field, min, limit = 6) {
  const rows = field === "augmentName" ? ((r.comboRows || {}).championAugment) : ((r.comboRows || {}).championItem);
  const iic = field === "itemName" ? itemIconMap(r) : null, q = norm(champ);
  const m = (rows || []).filter(x => norm(x.championName) === q && x[field]).map(x => {
    const games = +x.games || 0, wins = +x.wins || 0;
    let icon = "", cat = null, slot = null, tree = null;
    let id = null;
    if (field === "augmentName") { const a = x.augment || {}; id = a.id ?? null; cat = a.runeCategory || null; slot = a.runeSlot ?? null; tree = a.rarity || null; icon = a.icon || shardIcon(a.id); } // 룬 슬롯메타 + id + 파편 아이콘 폴백
    else { const mm = String(x.key || "").match(/item:(\d+)/); icon = mm ? (iic.get(Number(mm[1])) || `https://ddragon.leagueoflegends.com/cdn/16.11.1/img/item/${mm[1]}.png`) : ""; }
    return { name: x[field], icon, games, thin: games < min, winRate: Math.round(+x.winRate || 0), adj: adjPct(wins, games), cat, slot, tree, id };
  });
  let p = m.filter(x => x.games >= 5 && x.name !== "기회"); // 기회=삭제된 아이템 배제
  return p.sort((a, b) => b.games - a.games).slice(0, limit); // 더 많이 반환(클라가 토글로 정렬/그룹)
}
function topSynergies(r, champ) {
  const q = norm(champ), cic = champIconMap(r), rows = (r.personalRecommendations && r.personalRecommendations.observedChampionPairs) || [], out = [];
  for (const x of rows) {
    const partner = norm(x.championName) === q ? x.rightLabel : (norm(x.rightLabel) === q ? x.championName : null);
    if (!partner) continue;
    const games = +x.games || 0, wins = x.wins != null ? +x.wins : Math.round((+x.winRate || 0) / 100 * games);
    out.push({ name: partner, icon: cic.get(norm(partner)) || "", games, thin: games < 15, winRate: Math.round(+x.winRate || 0), adj: adjPct(wins, games) });
  }
  const p = out.filter(x => x.games >= 5 && x.winRate >= 50); // 잘 맞는(고승률)만, 안 맞는 페어 제외
  return p.sort((a, b) => (b.adj - a.adj) || (b.winRate - a.winRate) || (b.games - a.games)).slice(0, 8); // 표본보정 승률순(가장 잘 맞는 순), 최대 8
}

// 증강 빌드는 report.comboRows가 비어있어 별도 pool 파일에서 읽는다(season2)
async function loadPool(file) { return (await loadData(file)).rows || []; }
async function augmentCombos(name, kind, limit = 6) { // kind: "augment" | "item"
  const file = kind === "augment" ? "search-pool-all-observedChampionAugments-season2.json" : "search-pool-all-observedChampionItems-season2.json";
  const rows = await loadPool(file), q = norm(name);
  let augIcon = null; // metasrc(403) 아이콘 폴백용 — 번들의 communitydragon 아이콘(이름→아이콘)
  if (kind === "augment") { augIcon = new Map(); for (const a of ((await augMeta()).augments || [])) augIcon.set(norm(a.name), a.icon); }
  return rows.filter(x => norm(x.championLocalName) === q || norm(x.championName) === q).map(x => {
    const o = kind === "augment" ? x.augment : x.item, games = +x.games || 0, wins = +x.wins || 0;
    const nm = (o && (o.localName || o.name)) || x.rightLabel || "";
    let icon = (o && o.icon) || "";
    if (kind === "augment" && (!icon || /metasrc/.test(icon))) icon = augIcon.get(norm(nm)) || ""; // metasrc/빈값 → 번들 communitydragon 아이콘
    return { name: nm, icon, games, thin: games < 8, winRate: Math.round(+x.winRate || 0), adj: adjPct(wins, games) };
  }).filter(x => x.name && x.name !== "기회").sort((a, b) => b.games - a.games).slice(0, limit); // 표본순(증강 데이터 희소), 기회 배제
}
async function augmentSynergies(name) {
  const rows = await loadPool("search-pool-all-observedChampionPairs-season2.json"), q = norm(name), out = [];
  for (const x of rows) {
    const champs = x.champions || []; if (!champs.some(c => norm(c.localName) === q || norm(c.name) === q)) continue;
    const partner = champs.find(c => norm(c.localName) !== q && norm(c.name) !== q); if (!partner) continue;
    const games = +x.games || 0, wins = +x.wins || 0;
    out.push({ name: partner.localName || partner.name, icon: partner.icon || "", games, thin: games < 8, winRate: Math.round(+x.winRate || 0), adj: adjPct(wins, games) });
  }
  return out.filter(x => x.winRate >= 50).sort((a, b) => (b.adj - a.adj) || (b.winRate - a.winRate) || (b.games - a.games)).slice(0, 6); // 잘 맞는(고승률)만, 표본보정 승률순(일반칼바람과 통일)
}

async function champMeta(mode) {
  if (mode === "augment") { // 증강 메타 번들(10명 전체 집계, 사이트와 일치)
    return (await augMeta()).champions.map(c => ({ name: c.name, icon: c.icon || "", games: c.games || 0, thin: (c.games || 0) < 10, winRate: c.winRate || 0, tier: c.tier ?? null }));
  }
  const rows = (await load("standard-aram-cohort-all.json")).championRows || [];
  return rows.map(c => ({ name: c.localName || c.displayName, icon: c.icon || "", games: c.games || 0, thin: (c.games || 0) < 20, winRate: Math.round(c.winRate || 0), tier: c.tier ?? null }))
    .filter(c => c.name).sort((a, b) => b.games - a.games); // 표본수(판수) 내림차순(전체 — 검색용)
}
// 표본보정 백분위 티어(1~5, cut 6/30/72/96%) — 일반 코호트 티어와 같은 의미로 증강쪽에 부여
function assignTiers(list) {
  const ranked = list.map(c => ({ c, s: (c.winRate / 100 * c.games + 10) / (c.games + 20) })).sort((a, b) => b.s - a.s);
  ranked.forEach((o, i) => { const p = ranked.length ? i / ranked.length : 1; o.c.tier = p < 0.06 ? 1 : p < 0.30 ? 2 : p < 0.72 ? 3 : p < 0.96 ? 4 : 5; });
  return list;
}
const DDV = "16.12.1";
async function itemMeta(mode) {
  if (mode === "augment") return (await augMeta()).items.slice().sort((a, b) => b.games - a.games); // 번들(이름·아이콘·티어 포함, 정규 아이템만)
  const cohort = await load("standard-aram-cohort-all.json");
  return (cohort.itemRows || []).filter(it => Number(it.id) !== 6701).map(it => ({ name: it.localName || it.name, icon: it.icon || "", games: it.games || 0, winRate: Math.round(it.winRate || 0), tier: it.tier ?? null, thin: (it.games || 0) < 50 })).filter(it => it.name).sort((a, b) => b.games - a.games);
}
async function runeMeta(mode) { // 일반=룬(코호트 augmentRows), 증강=증강(번들, 사이트와 일치)
  if (mode === "augment") {
    return (await augMeta()).augments.slice().sort((a, b) => b.games - a.games);
  }
  const r = await load("standard-aram-cohort-all.json");
  return (r.augmentRows || []).map(a => ({ name: a.localName || a.name, icon: a.icon || shardIcon(a.id), games: a.games || 0, winRate: Math.round(a.winRate || 0), tier: a.tier ?? null, thin: (a.games || 0) < 100 })).filter(a => a.name).sort((a, b) => b.games - a.games); // 파편 아이콘 폴백
}
// 완전한 룬 트리(ddragon runesReforged) — 관측된 룬만 아니라 전체 트리(키스톤+3줄×3)로 깔아야 lolalytics처럼 정렬됨
function fetchJsonU(url) { return new Promise((resolve, reject) => { const req = https.get(url, { timeout: 12000 }, r => { if (r.statusCode !== 200) { r.resume(); return reject(new Error("status " + r.statusCode)); } let d = ""; r.on("data", c => d += c); r.on("end", () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } }); }); req.on("timeout", () => req.destroy(new Error("timeout"))); req.on("error", reject); }); }
let _runeCat = null;
async function fullRuneCatalog() {
  if (_runeCat) return _runeCat;
  const out = [];
  try {
    const rr = await fetchJsonU(`https://ddragon.leagueoflegends.com/cdn/${DDV}/data/ko_KR/runesReforged.json`);
    for (const tree of (rr || [])) tree.slots.forEach((slot, ri) => { for (const rune of slot.runes) out.push({ id: rune.id, name: rune.name, icon: `https://ddragon.leagueoflegends.com/cdn/img/${rune.icon}`, tree: tree.name, slot: ri, cat: ri === 0 ? "main" : "sub" }); });
  } catch {}
  // 스탯 파편(runesReforged엔 없음) — 고정 3줄(공격/유연/방어)
  const SHARDS = [[5008, 5005, 5007], [5008, 5010, 5001], [5011, 5013, 5001]];
  SHARDS.forEach((row, ri) => row.forEach(id => out.push({ id, name: SHARD_NAME[id] || ("파편 " + id), icon: shardIcon(id), tree: "파편", slot: 7 + ri, cat: "shard" })));
  return (_runeCat = out);
}
const SHARD_NAME = { 5001: "체력", 5005: "공격 속도", 5007: "스킬 가속", 5008: "적응형 능력치", 5010: "이동 속도", 5011: "체력 증가", 5013: "강인함 및 둔화 저항" };
function send(res, code, type, body) { res.writeHead(code, { "content-type": type, "cache-control": "no-store" }); res.end(body); }
async function sendFile(res, f, type) { try { send(res, 200, type, await readFile(f)); } catch { send(res, 404, "text/plain", "not found"); } }

const server = http.createServer(async (req, res) => {
  const u = new URL(req.url, `http://${req.headers.host}`);
  try {
    if (u.pathname === "/" || u.pathname === "/index.html") return sendFile(res, path.join(__dirname, "public", "index.html"), "text/html; charset=utf-8");
    if (u.pathname === "/img") {
      const url = u.searchParams.get("u") || "";
      try { if (!ICON_HOSTS.has(new URL(url).host)) return send(res, 400, "text/plain", "bad host");
        const buf = await getIcon(url);
        res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=604800" }); return res.end(buf);
      } catch { return send(res, 404, "text/plain", "img"); }
    }
    if (u.pathname === "/api/champs") return send(res, 200, "application/json; charset=utf-8", JSON.stringify(await champMeta(u.searchParams.get("mode") || "standard")));
    if (u.pathname === "/api/items") return send(res, 200, "application/json; charset=utf-8", JSON.stringify(await itemMeta(u.searchParams.get("mode") || "standard")));
    if (u.pathname === "/api/runes") return send(res, 200, "application/json; charset=utf-8", JSON.stringify(await runeMeta(u.searchParams.get("mode") || "standard")));
    if (u.pathname === "/api/mychamp") { // 내 챔프 개인 빌드 — 메타 페이지와 동일 레이아웃, 내 데이터로(토글·지표·시너지 포함)
      const mode = u.searchParams.get("mode") || "standard", name = u.searchParams.get("name") || "", q = norm(name);
      const b = await personalChampBuild(mode, name);
      if (mode !== "augment") { const cohort = await load("standard-aram-cohort-all.json"); const nm = new Map(); for (const it of (cohort.itemRows || [])) nm.set(Number(it.id), { name: it.localName || it.name, icon: it.icon }); b.items = (b.items || []).map(it => { const r = nm.get(Number(it.id)); return r ? { ...it, name: r.name, icon: r.icon } : it; }).filter(it => it.name); }
      if (mode === "augment") { const meta = (await augMeta()).champions.find(x => norm(x.name) === q) || {}; b.metaDpm = meta.dpm || 0; b.metaTank = meta.taken || 0; b.metaGpm = meta.gpm || 0; b.synergies = await augmentSynergies(name); }
      else { const r = await load("standard-aram-cohort-all.json"); const c = (r.championRows || []).find(x => [x.localName, x.displayName, x.champion].some(v => norm(v) === q)) || {}; b.metaDpm = Math.round(c.avgDpm || 0); b.metaTank = Math.round(c.avgTakenPerMinute || 0); b.metaGpm = Math.round(c.avgGoldPerMinute || 0); b.synergies = topSynergies(r, name); }
      return send(res, 200, "application/json; charset=utf-8", JSON.stringify(b));
    }
    if (u.pathname === "/api/champ") {
      const mode = u.searchParams.get("mode") || "standard", name = u.searchParams.get("name") || "", q = norm(name);
      if (mode === "augment") { // 증강 = 10명 전체 메타(teamParticipants) + 추천은 pool 파일
        const ca = (await augMeta()).champions.find(x => norm(x.name) === q) || {};
        const [runes, items, synergies] = await Promise.all([augmentCombos(name, "augment", 24), augmentCombos(name, "item", 24), augmentSynergies(name)]);
        return send(res, 200, "application/json; charset=utf-8", JSON.stringify({
          name: ca.name || name, icon: ca.icon || "", tier: ca.tier ?? null, winRate: ca.winRate || 0, games: ca.games || 0,
          kda: ca.kda || "-", dpm: ca.dpm || 0, gpm: ca.gpm || 0, tank: ca.taken || 0, cls: "", rank: null, teamDmgPct: null,
          runes, items, synergies
        }));
      }
      const r = await load("standard-aram-cohort-all.json");
      const c = (r.championRows || []).find(x => [x.localName, x.displayName, x.champion].some(v => norm(v) === q)) || {};
      const hasKda = c.avgKills != null && c.avgAssists != null;
      return send(res, 200, "application/json; charset=utf-8", JSON.stringify({
        name: c.localName || name, icon: c.icon || "", tier: c.tier ?? null, winRate: Math.round(c.winRate || 0), games: c.games || 0,
        kda: hasKda ? `${(c.avgKills || 0).toFixed(1)}/${(c.avgDeaths || 0).toFixed(1)}/${(c.avgAssists || 0).toFixed(1)}` : "-",
        dpm: Math.round(c.avgDpm || 0), gpm: Math.round(c.avgGoldPerMinute ?? c.avgGpm ?? 0), tank: Math.round(c.avgTakenPerMinute || 0),
        cls: GROUP_KO[c.group] || c.roleLabel || "", rank: c.rank ?? null,
        teamDmgPct: c.avgTeamDamagePct != null ? Math.round(c.avgTeamDamagePct) : null,
        runes: topCombos(r, name, "augmentName", 20, 40), items: topCombos(r, name, "itemName", 20, 24), synergies: topSynergies(r, name),
        runeCatalog: await fullRuneCatalog() // 전체 룬 트리(runesReforged 완전체: 키스톤행+3줄×3, lolalytics식 정렬)
      }));
    }
    if (u.pathname === "/api/me") {
      try { return send(res, 200, "application/json; charset=utf-8", JSON.stringify(await getRecords({ mock: u.searchParams.has("mock") }))); }
      catch (e) { if (e.code === "LEAGUE_OFF") return send(res, 200, "application/json; charset=utf-8", JSON.stringify({ leagueOff: true })); throw e; }
    }
    if (u.pathname === "/api/version") { // 업데이트 알림 — 번들 버전(package.json) vs R2 version.json
      let current = "0.0.0";
      try { current = JSON.parse(readFileSync(path.join(__dirname, "package.json"), "utf8")).version || "0.0.0"; } catch {}
      let info = null; try { info = await fetchJsonU(`${R2_BASE}/version.json`); } catch {}
      const latest = (info && info.version) || current;
      const sv = s => String(s).split(".").map(n => parseInt(n, 10) || 0);
      const cmp = (a, b) => { a = sv(a); b = sv(b); for (let i = 0; i < 3; i++) { if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) - (b[i] || 0); } return 0; };
      return send(res, 200, "application/json; charset=utf-8", JSON.stringify({ current, latest, updateAvailable: cmp(latest, current) > 0, url: (info && info.url) || "", notes: (info && info.notes) || "" }));
    }
    send(res, 404, "application/json", '{"error":"not found"}');
  } catch (e) { send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(e.message || e) })); }
});
export function startServer(port = PORT) {
  return new Promise((resolve, reject) => {
    server.once("error", e => {
      if (e.code === "EADDRINUSE" && port !== 0) { // 고정포트(.bat/CLI) 충돌 시에만 기존 것 열기. Electron은 port 0이라 충돌 없음
        const url = `http://127.0.0.1:${port}`;
        if (process.env.OPEN_BROWSER) exec(`start "" ${url}`, () => {});
        resolve({ url, port, alreadyRunning: true });
      } else reject(e);
    });
    server.listen(port, "127.0.0.1", () => {
      const actual = server.address().port; // port 0이면 OS가 준 실제 포트
      const url = `http://127.0.0.1:${actual}`;
      console.log(`Mayhem 실행 중: ${url}`);
      if (process.env.OPEN_BROWSER) exec(`start "" ${url}`, () => {});
      resolve({ url, port: actual });
    });
  });
}
// 직접 실행(node server.mjs / .bat) 시 자동 시작. Electron은 main.cjs가 startServer를 호출.
if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) startServer();
