// version: v9
// v9 — F-24 (Security wave 2): three new IPC handlers for encrypted
//      credential storage via Electron safeStorage. Replaces the
//      consumer-side plaintext password in localStorage (the audit's
//      F-24 vulnerability). safeStorage uses the OS keychain — DPAPI on
//      Windows, Keychain on macOS, libsecret on Linux. Encrypted blob
//      written to `app.getPath('userData')/mk_credentials.json` (same
//      Electron userData dir as session storage). LoginScreen.jsx v50
//      consumes these via window.mkElectron.secureSavePassword/
//      secureLoadPassword/secureClearPassword and runs a one-time
//      migration that reads any legacy localStorage.mk_saved_pass,
//      encrypts it, then deletes the plaintext. Bus thread:
//      security-audit-2026-05-09 + terminal_server_vps msg #166.
// v8 — Ctrl+= / Ctrl+- / Ctrl+0 zoom shortcuts + external-URL routing
//      (2026-05-08). Two operator-requested changes:
//       (1) Restored browser-style font zoom that v1 had natively (browser
//           mode). Wired via webContents.setZoomLevel; Ctrl+0 resets to
//           100%. Range clamped to [-8, +9] (V8's safe zone). Applied to
//           BOTH the main window and detached windows. Existing F12 dev-
//           tools toggle stays dev-only; zoom is always-on.
//       (2) Added setWindowOpenHandler to BrowserWindows so window.open()
//           calls and <a target="_blank"> clicks route to the OS default
//           browser via shell.openExternal instead of spawning an embedded
//           Electron child window. Specific motivator: the "OPEN FREE
//           DEMO ACCOUNT" button in SettingsPanel was opening Tickmill
//           inside a stripped-down Electron sub-window.
// v7 — Drop the /S NSIS silent flag from the installer spawn. Electron-
//      builder's oneClick:true installer is already near-silent (small
//      progress window only) AND its .onInstSuccess auto-launches the
//      new exe. The /S flag put NSIS into truly-silent mode which
//      ALSO suppressed the auto-launch hook — so the update finished
//      with no terminal running, requiring the user to click the
//      shortcut. With /S dropped, oneClick handles both: silent UX
//      (small NSIS progress only) + auto-relaunch.
// v6 — Silent NSIS install. The installer is now spawned with /S and
//      windowsHide: true so no NSIS GUI flashes during update. Combined
//      with oneClick:true in electron-builder NSIS config, the installer
//      runs silently and auto-launches the new exe on completion.
// v5 — IPC: mk:download-and-install. Replaces the old "open download URL
//      in a new window" flow which spawned a blank Electron child window
//      and then chained Chromium's download dialog. Now the URL is fetched
//      directly via https/http (with redirects), streamed to %TEMP%, the
//      installer is spawned detached, and the running terminal quits so
//      NSIS can replace the install dir without "in use" conflicts.
// Quantum Terminal Consumer — Electron main process
// =============================================================================
// Approach: minimal-risk, maximum-reuse.
//
//   1. Spawn the existing Python backend (PyInstaller-built data_server.exe in
//      production, or `python data_server.py` from venv_consumer in dev) as a
//      hidden child process — `windowsHide: true` so the user sees no console.
//
//   2. Wait for /api/health to come up.
//
//   3. Open a single BrowserWindow pointing at http://127.0.0.1:8502/ — same
//      URL the browser opens today. All React fetch('/api/...') calls resolve
//      relative to that host, no frontend code changes required.
//
//   4. On window close: kill the backend child cleanly so no zombie process.
//
// This is intentionally a thin shell. The whole React/LWC/data pipeline is
// untouched — same code as production. If this proves stable, follow-ups can
// switch the renderer to load from a local file:// build for full offline /
// no-localhost-port operation.

const { app, BrowserWindow, dialog, Menu, ipcMain, shell, safeStorage } = require("electron"); // v9: safeStorage for F-24 credential encryption
const { spawn } = require("child_process");
const path = require("path");
const fs   = require("fs");
const http = require("http");
const https = require("https");
const os    = require("os");
const { URL } = require("url");

// v2 uses port 8503 (v1 uses 8502) so both can run simultaneously without
// conflict. Explicitly passed to the backend via --port so server_config
// defaults can't override.
const BACKEND_PORT = 8503;
const BACKEND_URL  = `http://127.0.0.1:${BACKEND_PORT}`;
const HEALTH_URL   = `${BACKEND_URL}/api/health`;
const READY_TIMEOUT_MS = 60_000;   // give the backend up to 60s to come up
const HEALTH_POLL_MS   = 500;

let backendProc = null;
let mainWindow  = null;
let isQuitting  = false;

// Registry of detached child windows (right-click "Open in New Window" → page).
// `parent: mainWindow` causes Electron to auto-close these when main closes,
// so we just need to track membership for cleanup + future broadcast features.
const detachedWindows = new Set();

// Allowlist must match the renderer's VALID_PAGES in useDetachedMode.js.
const VALID_DETACHED_PAGES = new Set(["quant", "fundamental", "flow", "options", "stress", "learn"]);

// ── 1. Locate the backend ─────────────────────────────────────────────────
//   Production (after `npm run dist`):
//     extraResources copies `source code/backend/` to `<resourcesDir>/backend/`
//     PyInstaller exe is expected at `<resourcesDir>/backend/dist/data_server/data_server.exe`
//     Falling back to `<resourcesDir>/backend/data_server.exe` if the user
//     copied the PyInstaller output one level up.
//   Development (`npm start` or `npm run start:dev`):
//     Look for venv_consumer + data_server.py beside the project tree.
function findBackendCommand() {
  const isDev = !app.isPackaged;
  const projectRoot = isDev
    ? path.resolve(__dirname, "..")             // v2/ folder
    : process.resourcesPath;                    // electron resources/ in installed app

  const candidates = [];

  // Spawn launcher.py — it imports data_server.app, mounts the React build
  // at /, and runs uvicorn. Spawning data_server.py directly only serves
  // /api/* and 404s on the root URL.
  // Port + no-browser are env vars (MK_PORT, MK_NO_BROWSER), set in startBackend().

  // Production: prebuilt PyInstaller exe inside extraResources
  if (!isDev) {
    candidates.push({
      cmd:  path.join(projectRoot, "backend", "dist", "data_server", "data_server.exe"),
      args: [],
      cwd:  path.join(projectRoot, "backend", "dist", "data_server"),
      label: "packaged: extraResources/backend/dist/data_server/",
    });
    candidates.push({
      cmd:  path.join(projectRoot, "backend", "data_server.exe"),
      args: [],
      cwd:  path.join(projectRoot, "backend"),
      label: "packaged: extraResources/backend/",
    });
  }

  // Dev: run launcher.py via v2's own venv. Independent of v1 — no fallbacks.
  if (isDev) {
    const v2Venv     = path.join(projectRoot, "source code", "venv_consumer", "Scripts", "python.exe");
    const launcherPy = path.join(projectRoot, "source code", "backend", "launcher.py");
    if (fs.existsSync(v2Venv) && fs.existsSync(launcherPy)) {
      candidates.push({
        cmd: v2Venv, args: [launcherPy],
        cwd: path.dirname(launcherPy),
        label: "dev: v2 venv + launcher.py",
      });
    }
    // System python only if v2 venv isn't built yet — useful for the
    // "I cloned the repo, haven't run the venv setup" first-run case.
    if (fs.existsSync(launcherPy)) {
      candidates.push({
        cmd: "python", args: [launcherPy],
        cwd: path.dirname(launcherPy),
        label: "dev: system python (no venv detected, may fail without deps installed)",
      });
    }
  }

  for (const c of candidates) {
    if (c.cmd === "python" || (c.cmd && fs.existsSync(c.cmd))) {
      console.log(`[electron] backend: ${c.label}`);
      return c;
    }
  }
  return null;
}

// ── 2. Spawn backend ──────────────────────────────────────────────────────
function startBackend() {
  const target = findBackendCommand();
  if (!target) {
    dialog.showErrorBox(
      "Backend not found",
      "Could not locate the Python backend. Expected a PyInstaller-built data_server.exe inside extraResources/backend/, or a dev venv_consumer with data_server.py."
    );
    app.exit(1);
    return;
  }

  console.log(`[electron] spawning: ${target.cmd} ${target.args.join(" ")}`);
  backendProc = spawn(target.cmd, target.args, {
    cwd: target.cwd,
    windowsHide: true,
    detached: false,
    stdio: ["ignore", "pipe", "pipe"],
    env: {
      ...process.env,
      PYTHONUNBUFFERED: "1",
      MK_PORT: String(BACKEND_PORT),       // launcher.py reads this
      MK_NO_BROWSER: "1",                  // skip auto-launch of system browser
      MK_APP_DIR_NAME: "QuantumTerminal-v2",      // isolate AppData from v1 (auth, cache, account state)
    },
  });

  backendProc.stdout.on("data", (d) => console.log(`[backend] ${d.toString().trimEnd()}`));
  backendProc.stderr.on("data", (d) => console.error(`[backend ERR] ${d.toString().trimEnd()}`));
  backendProc.on("exit", (code, signal) => {
    console.log(`[electron] backend exited code=${code} signal=${signal}`);
    if (!isQuitting) {
      // Backend died unexpectedly — close the window to make it visible.
      if (mainWindow) mainWindow.close();
    }
  });
  backendProc.on("error", (err) => {
    console.error("[electron] backend spawn error:", err);
    dialog.showErrorBox("Backend spawn failed", String(err));
  });
}

// ── 3. Poll /api/health ───────────────────────────────────────────────────
function waitForBackend() {
  return new Promise((resolve, reject) => {
    const startTs = Date.now();
    const tick = () => {
      const req = http.get(HEALTH_URL, (res) => {
        if (res.statusCode === 200) return resolve();
        retry();
      });
      req.on("error", retry);
      req.setTimeout(2000, () => { req.destroy(); retry(); });
    };
    const retry = () => {
      if (Date.now() - startTs > READY_TIMEOUT_MS) return reject(new Error("backend health check timed out"));
      setTimeout(tick, HEALTH_POLL_MS);
    };
    tick();
  });
}

// ── 4. Create the window ──────────────────────────────────────────────────
function createWindow() {
  mainWindow = new BrowserWindow({
    width:  1600,
    height: 1000,
    minWidth:  1024,
    minHeight: 720,
    icon: path.join(app.isPackaged ? process.resourcesPath : __dirname, "..", "icon.ico"),
    backgroundColor: "#000000",
    show: false,
    autoHideMenuBar: true,
    webPreferences: {
      contextIsolation: true,
      nodeIntegration:  false,
      preload: path.join(__dirname, "preload.js"),
      // Allow the renderer to talk to http://127.0.0.1:8502 — no mixed-content
      // headache because Electron's default security model already permits
      // localhost http when the page itself is loaded over http.
    },
  });

  // Strip the default File/Edit/View menu — keeps the chrome looking like an app.
  // Comment this out if you want devtools/menu access during testing.
  Menu.setApplicationMenu(null);

  mainWindow.loadURL(BACKEND_URL);
  mainWindow.once("ready-to-show", () => mainWindow.show());
  mainWindow.on("closed", () => { mainWindow = null; });

  // v8: route window.open() and target=_blank links to OS default browser
  //     instead of spawning an embedded Electron sub-window.
  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
    if (/^https?:\/\//.test(url)) {
      shell.openExternal(url).catch(err =>
        console.error("[electron] shell.openExternal failed:", err));
    }
    return { action: "deny" };
  });

  // v8: keyboard shortcuts. Ctrl+= / Ctrl+- / Ctrl+0 zoom (always on);
  //     F12 devtools (dev mode only). Single listener so we don't
  //     register two before-input-event handlers on the same wc.
  mainWindow.webContents.on("before-input-event", (event, input) => {
    if (input.type !== "keyDown") return;
    // F12 dev tools — gated to dev / unpackaged builds.
    if (input.key === "F12" &&
        (process.env.ELECTRON_DEV === "1" || !app.isPackaged)) {
      mainWindow.webContents.toggleDevTools();
      return;
    }
    // Ctrl+= / Ctrl+- / Ctrl+0 zoom — always on, production included.
    if (input.control || input.meta) {
      const wc = mainWindow.webContents;
      if (input.key === "=" || input.key === "+") {
        wc.setZoomLevel(Math.min(wc.getZoomLevel() + 0.5, 9));
        event.preventDefault();
      } else if (input.key === "-") {
        wc.setZoomLevel(Math.max(wc.getZoomLevel() - 0.5, -8));
        event.preventDefault();
      } else if (input.key === "0") {
        wc.setZoomLevel(0);
        event.preventDefault();
      }
    }
  });
}

// ── 5. Lifecycle ──────────────────────────────────────────────────────────
app.whenReady().then(async () => {
  startBackend();
  try {
    await waitForBackend();
    console.log("[electron] backend healthy — opening window");
    createWindow();
  } catch (e) {
    console.error("[electron] giving up:", e);
    dialog.showErrorBox("Backend failed to start", String(e));
    app.exit(1);
  }
});

app.on("window-all-closed", () => {
  isQuitting = true;
  app.quit();
});

app.on("before-quit", () => {
  isQuitting = true;
  if (backendProc && !backendProc.killed) {
    try { backendProc.kill("SIGTERM"); } catch {}
    setTimeout(() => {
      if (backendProc && !backendProc.killed) { try { backendProc.kill("SIGKILL"); } catch {} }
    }, 3000);
  }
});

// ── IPC: secure credential storage (F-24, v9) ──────────────────────────
// safeStorage encrypts via OS keychain (DPAPI on Windows, Keychain on
// macOS, libsecret on Linux). Encrypted blob stored at:
//   path.join(app.getPath('userData'), 'mk_credentials.json')
// File shape: { email: <plain>, passwordEncryptedB64: <base64 of encrypted Buffer> }
// Email kept plain (not sensitive — visible on screen during entry).
// Password encrypted at rest. LoginScreen.jsx v50 calls these via
// window.mkElectron.secureSavePassword/secureLoadPassword/secureClearPassword.

function _credsFilePath() {
  return path.join(app.getPath("userData"), "mk_credentials.json");
}

ipcMain.handle("mk:secure-save-password", async (_event, { email, password }) => {
  try {
    if (!safeStorage.isEncryptionAvailable()) {
      return { ok: false, error: "encryption_unavailable" };
    }
    if (typeof password !== "string" || password.length === 0) {
      return { ok: false, error: "empty_password" };
    }
    const encrypted = safeStorage.encryptString(password);
    const payload = {
      email: typeof email === "string" ? email : "",
      passwordEncryptedB64: encrypted.toString("base64"),
      savedAt: new Date().toISOString(),
    };
    await fs.promises.writeFile(_credsFilePath(), JSON.stringify(payload), "utf8");
    return { ok: true };
  } catch (e) {
    console.error("[electron] secure-save-password failed:", e);
    return { ok: false, error: String(e?.message || e) };
  }
});

ipcMain.handle("mk:secure-load-password", async () => {
  try {
    const file = _credsFilePath();
    if (!fs.existsSync(file)) return { ok: true, email: "", password: "" };
    if (!safeStorage.isEncryptionAvailable()) {
      return { ok: false, error: "encryption_unavailable" };
    }
    const raw = await fs.promises.readFile(file, "utf8");
    const data = JSON.parse(raw);
    if (!data?.passwordEncryptedB64) {
      return { ok: true, email: data?.email || "", password: "" };
    }
    const encrypted = Buffer.from(data.passwordEncryptedB64, "base64");
    const password = safeStorage.decryptString(encrypted);
    return { ok: true, email: data.email || "", password };
  } catch (e) {
    console.error("[electron] secure-load-password failed:", e);
    return { ok: false, error: String(e?.message || e) };
  }
});

ipcMain.handle("mk:secure-clear-password", async () => {
  try {
    const file = _credsFilePath();
    if (fs.existsSync(file)) await fs.promises.unlink(file);
    return { ok: true };
  } catch (e) {
    console.error("[electron] secure-clear-password failed:", e);
    return { ok: false, error: String(e?.message || e) };
  }
});

// ── IPC: save screenshot via native dialog ─────────────────────────────
// Renderer sends raw PNG bytes; main pops a native dialog + writes the file.
// Bypasses Chromium's download manager → instant dialog, no base64 hop.
ipcMain.handle("mk:save-screenshot", async (_event, { suggestedName, bytes }) => {
  try {
    const win = BrowserWindow.fromWebContents(_event.sender) || mainWindow;
    const { canceled, filePath } = await dialog.showSaveDialog(win, {
      title: "Save chart screenshot",
      defaultPath: suggestedName || "chart.png",
      filters: [{ name: "PNG image", extensions: ["png"] }],
    });
    if (canceled || !filePath) return { saved: false };
    // bytes arrives as Uint8Array (preload wraps ArrayBuffer). Buffer.from
    // accepts Uint8Array and writes raw bytes — no base64 conversion.
    await fs.promises.writeFile(filePath, Buffer.from(bytes));
    return { saved: true, path: filePath };
  } catch (e) {
    console.error("[electron] save-screenshot failed:", e);
    return { saved: false, error: String(e) };
  }
});

// ── IPC: fetch Trump-related news from Google News RSS ───────────────
// Renderer's `fetch` can't reach news.google.com (no CORS). Main process
// fetches the RSS, parses items with regex (no XML lib dep), caches for
// 5 minutes so repeated toast-opens don't spam the upstream.
let trumpNewsCache = { items: null, fetchedAt: 0 };
const TRUMP_CACHE_MS = 5 * 60 * 1000;

function decodeHtml(s) {
  return String(s || "")
    .replace(/&amp;/g,  "&")
    .replace(/&lt;/g,   "<")
    .replace(/&gt;/g,   ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g,  "'")
    .replace(/&apos;/g, "'");
}
function parseRssItems(xml) {
  const items = [];
  const itemRe = /<item>([\s\S]*?)<\/item>/g;
  let m;
  while ((m = itemRe.exec(xml)) !== null) {
    const block = m[1];
    const titleMatch   = block.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/);
    const linkMatch    = block.match(/<link>([\s\S]*?)<\/link>/);
    const dateMatch    = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/);
    const sourceMatch  = block.match(/<source[^>]*>([\s\S]*?)<\/source>/);
    items.push({
      title:   decodeHtml((titleMatch  ? titleMatch[1]  : "").trim()),
      link:    (linkMatch  ? linkMatch[1]  : "").trim(),
      pubDate: (dateMatch  ? dateMatch[1]  : "").trim(),
      source:  decodeHtml((sourceMatch ? sourceMatch[1] : "").trim()),
    });
  }
  return items;
}

ipcMain.handle("mk:fetch-trump-news", async (_event, opts) => {
  const force = opts && opts.force;
  const now = Date.now();
  if (!force && trumpNewsCache.items && now - trumpNewsCache.fetchedAt < TRUMP_CACHE_MS) {
    return {
      ok: true, items: trumpNewsCache.items,
      fetchedAt: trumpNewsCache.fetchedAt, cached: true,
    };
  }
  try {
    const url = "https://news.google.com/rss/search?q=Trump&hl=en-US&gl=US&ceid=US:en";
    const res = await fetch(url, {
      headers: { "User-Agent": "Mozilla/5.0 Quantum Terminal Consumer Terminal" },
    });
    if (!res.ok) {
      return { ok: false, error: `HTTP ${res.status} from Google News` };
    }
    const xml = await res.text();
    // v4: sort by pubDate descending (latest first) BEFORE slicing so we
    //     never drop fresher items just because Google's RSS shipped them
    //     out of order. Items without a parseable date sink to the bottom.
    const all = parseRssItems(xml);
    all.sort((a, b) => {
      const ta = new Date(a.pubDate).getTime();
      const tb = new Date(b.pubDate).getTime();
      const sa = isNaN(ta) ? -Infinity : ta;
      const sb = isNaN(tb) ? -Infinity : tb;
      return sb - sa;
    });
    const items = all.slice(0, 30);
    trumpNewsCache = { items, fetchedAt: now };
    return { ok: true, items, fetchedAt: now, cached: false };
  } catch (e) {
    console.error("[electron] fetch-trump-news failed:", e);
    return { ok: false, error: String(e) };
  }
});

// ── IPC: open URL in user's default browser ──────────────────────────
ipcMain.handle("mk:open-external", async (_event, url) => {
  try {
    if (typeof url !== "string" || !/^https?:\/\//.test(url)) {
      return { ok: false, error: "invalid url" };
    }
    await shell.openExternal(url);
    return { ok: true };
  } catch (e) {
    return { ok: false, error: String(e) };
  }
});

// ── v5: IPC — download installer + auto-launch + quit terminal ────────
// Renderer (UpdateAvailableModal) calls this with the installer URL.
// We follow redirects (max 5 hops), stream to %TEMP%\mk-trades-update.exe
// emitting `mk:installer-progress` events to the renderer for the progress
// UI, then spawn the .exe detached and quit the terminal so NSIS can
// replace the install directory without file-in-use errors.
function _httpGet(url, redirectsLeft = 5) {
  return new Promise((resolve, reject) => {
    let parsed;
    try { parsed = new URL(url); } catch (e) { return reject(e); }
    const lib = parsed.protocol === "https:" ? https : http;
    const req = lib.get(url, { headers: { "User-Agent": "MK-TRADES-Updater" } }, (res) => {
      const code = res.statusCode || 0;
      if ([301, 302, 303, 307, 308].includes(code)) {
        if (redirectsLeft <= 0) return reject(new Error("too many redirects"));
        const loc = res.headers.location;
        res.resume();
        if (!loc) return reject(new Error("redirect without Location"));
        const next = loc.startsWith("http") ? loc : new URL(loc, parsed).toString();
        return _httpGet(next, redirectsLeft - 1).then(resolve, reject);
      }
      if (code < 200 || code >= 300) {
        res.resume();
        return reject(new Error(`HTTP ${code}`));
      }
      resolve(res);
    });
    req.on("error", reject);
    req.setTimeout(120_000, () => { try { req.destroy(new Error("timeout")); } catch {} });
  });
}

ipcMain.handle("mk:download-and-install", async (event, url) => {
  if (typeof url !== "string" || !/^https?:\/\//.test(url)) {
    return { ok: false, error: "invalid url" };
  }
  // Path: %TEMP%\mk-trades-update-<timestamp>.exe (timestamp avoids
  // file-locked rename-on-rerun scenarios).
  const fname = `mk-trades-update-${Date.now()}.exe`;
  const dest  = path.join(os.tmpdir(), fname);
  try {
    const res = await _httpGet(url);
    const total = parseInt(res.headers["content-length"] || "0", 10) || 0;
    let received = 0;
    let lastEmit = 0;
    const out = fs.createWriteStream(dest);
    await new Promise((resolve, reject) => {
      res.on("data", (chunk) => {
        received += chunk.length;
        const now = Date.now();
        // Throttle progress events to ~6/s so renderer doesn't get hammered.
        if (now - lastEmit > 160) {
          lastEmit = now;
          try {
            event.sender.send("mk:installer-progress", {
              received, total,
              pct: total > 0 ? received / total : null,
            });
          } catch {}
        }
      });
      res.on("error", reject);
      out.on("error", reject);
      out.on("finish", resolve);
      res.pipe(out);
    });
    // Final progress emit so the UI can flip to "launching".
    try {
      event.sender.send("mk:installer-progress", {
        received, total: received, pct: 1, done: true,
      });
    } catch {}

    // Spawn the installer detached so it survives the terminal quitting.
    // NSIS handles its own "stop running app" but we quit explicitly so
    // the install dir is unlocked the moment NSIS reaches the file copy.
    //
    // v7: NO /S flag. With electron-builder oneClick:true the installer
    //   is already near-silent (just a small "Installing…" progress
    //   window) AND it auto-launches the new exe via .onInstSuccess.
    //   /S would suppress BOTH the progress window AND the auto-launch,
    //   leaving the user with no running app after update.
    //   windowsHide:true is kept to avoid any console flash, but the
    //   NSIS progress window (a GUI window, not a console) still shows.
    try {
      const child = spawn(dest, [], {
        detached: true,
        stdio: "ignore",
        windowsHide: true,
      });
      child.unref();
    } catch (e) {
      return { ok: false, error: `spawn failed: ${String(e)}` };
    }

    // Tiny delay so the installer process is alive before we quit — avoids
    // the Windows "starting up" race where a freshly-spawned detached child
    // can be killed if its parent exits within ~50ms on some configs.
    setTimeout(() => {
      try { app.quit(); } catch {}
    }, 800);
    return { ok: true, file: dest };
  } catch (e) {
    try { fs.unlinkSync(dest); } catch {}
    return { ok: false, error: String(e?.message || e) };
  }
});

// ── IPC: open a detached child window for a given page ────────────────
// Renderer (TabContextMenu → preload.openInNewWindow) calls this with a
// pageId. We validate against the allowlist, spawn a BrowserWindow with
// parent: mainWindow (so it closes when main closes), and load the same
// app URL with ?detached=<pageId> so the renderer boots into DetachedShell.
ipcMain.handle("mk:open-in-new-window", async (_event, payload) => {
  try {
    const pageId = payload && payload.pageId;
    if (!VALID_DETACHED_PAGES.has(pageId)) {
      return { ok: false, error: `invalid pageId: ${String(pageId)}` };
    }
    if (!mainWindow) {
      return { ok: false, error: "main window not available" };
    }

    const childIcon = path.join(app.isPackaged ? process.resourcesPath : __dirname, "..", "icon.ico");

    const win = new BrowserWindow({
      width:  1200,
      height: 800,
      minWidth:  640,
      minHeight: 480,
      icon: childIcon,
      backgroundColor: "#000000",
      parent: mainWindow,
      autoHideMenuBar: true,
      show: false,
      webPreferences: {
        contextIsolation: true,
        nodeIntegration:  false,
        preload: path.join(__dirname, "preload.js"),
      },
    });

    const url = `${BACKEND_URL}/?detached=${encodeURIComponent(pageId)}`;
    win.loadURL(url);
    win.once("ready-to-show", () => win.show());
    win.on("closed", () => detachedWindows.delete(win));

    // v8: external-URL routing (matches main window).
    win.webContents.setWindowOpenHandler(({ url: u }) => {
      if (/^https?:\/\//.test(u)) {
        shell.openExternal(u).catch(err =>
          console.error("[electron] shell.openExternal failed:", err));
      }
      return { action: "deny" };
    });

    // v8: keyboard shortcuts. Ctrl+= / Ctrl+- / Ctrl+0 zoom (always on);
    //     F12 devtools (dev mode only). Mirrors main window.
    win.webContents.on("before-input-event", (event, input) => {
      if (input.type !== "keyDown") return;
      if (input.key === "F12" &&
          (process.env.ELECTRON_DEV === "1" || !app.isPackaged)) {
        win.webContents.toggleDevTools();
        return;
      }
      if (input.control || input.meta) {
        const wc = win.webContents;
        if (input.key === "=" || input.key === "+") {
          wc.setZoomLevel(Math.min(wc.getZoomLevel() + 0.5, 9));
          event.preventDefault();
        } else if (input.key === "-") {
          wc.setZoomLevel(Math.max(wc.getZoomLevel() - 0.5, -8));
          event.preventDefault();
        } else if (input.key === "0") {
          wc.setZoomLevel(0);
          event.preventDefault();
        }
      }
    });

    detachedWindows.add(win);
    console.log(`[electron] spawned detached window: pageId=${pageId} url=${url}`);
    return { ok: true };
  } catch (e) {
    console.error("[electron] open-in-new-window failed:", e);
    return { ok: false, error: String(e) };
  }
});

// Single-instance lock — clicking the icon while running just focuses the window
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
  app.quit();
} else {
  app.on("second-instance", () => {
    if (mainWindow) {
      if (mainWindow.isMinimized()) mainWindow.restore();
      mainWindow.focus();
    }
  });
}
