/**
 * @file Desktop smoke test.
 *
 * Boots the compiled main process under Electron, then probes the embedded
 * dashboard server's /api/health endpoint. This is intentionally minimal:
 * it does not exercise the BrowserWindow (which requires a display) so it
 * runs on headless CI without xvfb. The window itself is covered by manual
 * QA in the PR description.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import http from "node:http";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DESKTOP_ROOT = path.resolve(__dirname, "..");
const MAIN_JS = path.join(DESKTOP_ROOT, "out", "main.js");
// Resolve the actual Electron executable (electron.exe on Windows, the binary
// under Electron.app on macOS). The `.bin/electron` shim is extension-less and
// cannot be spawned without a shell on Windows; `require("electron")` returns
// the real binary path on every platform.
const ELECTRON_BIN = createRequire(import.meta.url)("electron");

const HEALTH_TIMEOUT_MS = 60_000;
const POLL_INTERVAL_MS = 500;

/** Resolve when GET /api/health on any of these ports answers ok. */
async function waitForHealth(ports, deadline) {
  while (Date.now() < deadline) {
    for (const port of ports) {
      const ok = await probeHealth(port);
      if (ok) return port;
    }
    await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
  }
  throw new Error(`No port answered /api/health within ${HEALTH_TIMEOUT_MS}ms (tried ${ports})`);
}

function probeHealth(port) {
  return new Promise((resolve) => {
    const req = http.get({ host: "127.0.0.1", port, path: "/api/health", timeout: 1500 }, (res) => {
      let buf = "";
      res.setEncoding("utf8");
      res.on("data", (chunk) => (buf += chunk));
      res.on("end", () => {
        try {
          resolve(JSON.parse(buf)?.status === "ok");
        } catch {
          resolve(false);
        }
      });
    });
    req.on("error", () => resolve(false));
    req.on("timeout", () => {
      req.destroy();
      resolve(false);
    });
  });
}

let electronProc;
// Pick a unique high port for each test run so we never accidentally probe an
// unrelated server (e.g. the user's own `npm start` on 4820). The env var
// `CCAM_DESKTOP_BIND_PORT` tells the desktop process to bind exactly this port,
// skipping the "adopt an existing healthy server" code path.
const TEST_PORT = 50000 + Math.floor(Math.random() * 5000);

// On POSIX, spawn the Electron parent as a process-group leader so we can
// signal the whole tree (helpers, embedded server) with one kill(-pid).
// Without this, SIGTERM only hits the parent and leaves helpers alive,
// keeping the stdio pipes open and hanging `node --test` indefinitely.
const IS_POSIX = process.platform !== "win32";

/** Kill the Electron process tree and resolve when it's actually gone. */
async function killElectronTree(proc, { timeoutMs = 5_000 } = {}) {
  if (!proc || proc.exitCode !== null || proc.signalCode !== null) return;
  proc.killedByTest = true;

  const signalGroup = (sig) => {
    try {
      if (IS_POSIX && proc.pid) process.kill(-proc.pid, sig);
      else proc.kill(sig);
    } catch {
      /* group may already be gone */
    }
  };

  const exited = once(proc, "exit");
  signalGroup("SIGTERM");

  const timer = new Promise((resolve) => setTimeout(resolve, timeoutMs, "timeout"));
  const winner = await Promise.race([exited.then(() => "exit"), timer]);
  if (winner === "timeout") {
    signalGroup("SIGKILL");
    await Promise.race([exited, new Promise((r) => setTimeout(r, 2_000))]);
  }
}

describe("desktop smoke", () => {
  before(async () => {
    electronProc = spawn(ELECTRON_BIN, [MAIN_JS], {
      cwd: DESKTOP_ROOT,
      detached: IS_POSIX,
      env: {
        ...process.env,
        // Suppress the BrowserWindow on the test runner; we only care that
        // the server boots cleanly.
        ELECTRON_DISABLE_GPU: "1",
        ELECTRON_ENABLE_LOGGING: "1",
        CCAM_DESKTOP_VERBOSE: "1",
        CCAM_DESKTOP_BIND_PORT: String(TEST_PORT),
      },
      stdio: ["ignore", "pipe", "pipe"],
    });

    electronProc.stdout.on("data", (b) => process.stdout.write(`[electron] ${b}`));
    electronProc.stderr.on("data", (b) => process.stderr.write(`[electron] ${b}`));

    electronProc.on("exit", (code, signal) => {
      if (!electronProc.killedByTest) {
        // eslint-disable-next-line no-console
        console.error(`electron exited unexpectedly: code=${code} signal=${signal}`);
      }
    });
  });

  after(async () => {
    await killElectronTree(electronProc);
  });

  it("brings up the embedded server and serves /api/health on the bound port", async () => {
    const deadline = Date.now() + HEALTH_TIMEOUT_MS;
    const port = await waitForHealth([TEST_PORT], deadline);
    assert.equal(
      port,
      TEST_PORT,
      `desktop process should have bound CCAM_DESKTOP_BIND_PORT=${TEST_PORT}`
    );
    assert.ok(
      electronProc && !electronProc.killed && electronProc.exitCode === null,
      "electron process should still be alive when /api/health answers"
    );
  });
});
