// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later

// The static server the browser tests load the real web assets from. It was
// inline in browser-smoke.mjs until a second browser test needed a rig with a
// spectrum: the interesting layout lives in the spectrum area, and none of it
// could be exercised while the only fixture served a CAT-only rig.

import { readFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";

const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");

// A realistic decoder registry.  Serving an empty one hid most of the
// application from this test: the decoder sub-tabs, their panels, the decode
// toggles and the bookmark decoder checkboxes are all built from it, so with
// no decoders only three of thirteen sub-tabs existed and none of the decoder
// UI was ever constructed.
const DECODER_REGISTRY = [
  { id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"] },
  { id: "ft4", label: "FT4", activation: "toggle", active_modes: ["USB"] },
  { id: "ft2", label: "FT2", activation: "toggle", active_modes: ["USB"] },
  { id: "wspr", label: "WSPR", activation: "toggle", active_modes: ["USB"] },
  { id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
  { id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
  { id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
  { id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
  { id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
  { id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
  { id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));

const CONTENT_TYPES = new Map([
  [".css", "text/css; charset=utf-8"],
  [".html", "text/html; charset=utf-8"],
  [".js", "application/javascript; charset=utf-8"],
  [".json", "application/json; charset=utf-8"],
  [".png", "image/png"],
  [".woff2", "font/woff2"],
]);

function assetPath(urlPath) {
  // Every tab route has its own index handler on the server (see api/assets.rs),
  // so a deep link or a refresh serves the SPA shell, not a 404.
  if (!path.extname(urlPath)) return path.join(webDir, "index.html");
  if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath);
  const generated = path.join(generatedDir, path.basename(urlPath));
  if (urlPath.endsWith(".js")) return generated;
  return path.join(webDir, urlPath);
}

/**
 * Starts the fixture server.
 *
 * `spectrum` turns the rig into an SDR: `filter_controls` is what gates the
 * spectrum panel, and frames are pushed on the /spectrum stream from a centre
 * frequency the test moves with `setCenterHz` to simulate tuning across bands.
 */
export async function startWebFixture({
  spectrum = false,
  tx = false,
  bookmarks = [],
  bandplan = {},
  bandplanEnabled = false,
} = {}) {
  const rigItems = ["rig-a", "rig-b"].map((remote) => ({
    remote,
    display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture",
    manufacturer: "Smoke",
    model: "Fixture",
    supported_modes: ["FM"],
    tx,
    filter_controls: spectrum,
    initialized: true,
    latitude: null,
    longitude: null,
  }));
  const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
  const selectedRigs = [];
  const state = { centerHz: 7074000 };

  const status = {
    info: {
      manufacturer: "Smoke",
      model: "Fixture",
      revision: "1",
      access: { Tcp: { addr: "127.0.0.1:0" } },
      capabilities: {
        min_freq_step_hz: 1,
        supported_bands: [],
        supported_modes: ["LSB", "USB", "CW", "CWR", "AM", "SAM", "WFM", "FM", "AIS", "VDES", "DIG", "PKT"],
        num_vfos: 1,
        lock: false,
        lockable: tx,
        attenuator: false,
        preamp: false,
        rit: false,
        rpt: false,
        split: false,
        tx,
        tx_limit: tx,
        vfo_switch: false,
        filter_controls: spectrum,
        signal_meter: spectrum,
      },
    },
    status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: null, lock: null },
    band: null,
    enabled: true,
    initialized: true,
    cw_auto: false,
    cw_wpm: 20,
    cw_tone_hz: 700,
    aprs_decode_enabled: false,
    hf_aprs_decode_enabled: false,
    cw_decode_enabled: false,
    ft8_decode_enabled: false,
    ft4_decode_enabled: false,
    ft2_decode_enabled: false,
    wspr_decode_enabled: false,
    lrpt_decode_enabled: false,
    wefax_decode_enabled: false,
    recorder_enabled: false,
    clients: 1,
    rigctl_clients: 0,
    audio_clients: 0,
    active_remote: "rig-a",
    remotes: ["rig-a", "rig-b"],
    show_sdr_gain_control: false,
    initial_map_zoom: 10,
    spectrum_coverage_margin_hz: 50_000,
    spectrum_usable_span_ratio: 0.92,
    bandplan_enabled: bandplanEnabled,
    bandplan_region: "iaru1",
    decode_history_retention_min: 1440,
    server_connected: true,
  };

  const jsonRoutes = new Map([
    ["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
    ["/decoders", DECODER_REGISTRY],
    ["/rigs", rigsResponse],
    ["/status", status],
    ["/bookmarks", bookmarks],
    ["/bandplan.json", bandplan],
    ["/api/recorder/status", []],
    ["/api/recorder/files", []],
  ]);

  // Flat i8 bins: the shape does not matter, only that frames arrive so the
  // page has a spectrum range to place bookmarks and allocations against.
  const spectrumBins = Buffer.alloc(512, 200);
  const spectrumB64 = spectrumBins.toString("base64");

  const server = http.createServer(async (request, response) => {
    const url = new URL(request.url ?? "/", "http://127.0.0.1");
    if (url.pathname === "/select_rig" && request.method === "POST") {
      const remote = url.searchParams.get("remote");
      if (remote) {
        rigsResponse.active_remote = remote;
        status.active_remote = remote;
        selectedRigs.push(remote);
      }
      response.writeHead(200).end();
      return;
    }
    if (jsonRoutes.has(url.pathname)) {
      response.writeHead(200, { "content-type": "application/json" });
      response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
      return;
    }
    if (url.pathname === "/audio") {
      response.writeHead(404).end();
      return;
    }
    if (spectrum && url.pathname === "/spectrum") {
      response.writeHead(200, {
        "cache-control": "no-cache",
        connection: "keep-alive",
        "content-type": "text/event-stream",
      });
      const timer = setInterval(() => {
        response.write(`event: b\ndata: ${state.centerHz},192000,${spectrumB64}\n\n`);
      }, 100);
      request.on("close", () => clearInterval(timer));
      return;
    }
    if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
      response.writeHead(200, {
        "cache-control": "no-cache",
        connection: "keep-alive",
        "content-type": "text/event-stream",
      });
      response.write(": browser smoke stream\n\n");
      return;
    }
    try {
      const file = assetPath(url.pathname);
      const bytes = await readFile(file);
      response.writeHead(200, {
        "content-type": CONTENT_TYPES.get(path.extname(file)) ?? "application/octet-stream",
      });
      response.end(bytes);
    } catch {
      response.writeHead(404).end();
    }
  });

  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
  const address = server.address();
  if (!address || typeof address !== "object") throw new Error("fixture server has no port");

  return {
    origin: `http://127.0.0.1:${address.port}`,
    selectedRigs,
    rigsResponse,
    /** Moves the spectrum centre, i.e. tunes the fixture rig to another band. */
    setCenterHz(hz) { state.centerHz = hz; },
    close() {
      return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
    },
  };
}

/** Launches headless Chromium and records any uncaught page error. */
export async function startBrowser(chromium) {
  const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
    ?? "/usr/bin/chromium";
  const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
  const page = await browser.newPage();
  const runtimeErrors = [];
  page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message));
  return { browser, page, runtimeErrors };
}
