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

import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";

// page.evaluate callbacks run in the browser, not in this Node process.
/* global document, getComputedStyle */

const fixture = await startWebFixture();
const { selectedRigs } = fixture;
const { browser, page, runtimeErrors } = await startBrowser(chromium);

try {
  await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
  await page.waitForTimeout(500);
  assert.deepEqual(runtimeErrors, []);
  await page.locator("#content").waitFor({ state: "visible" });
  assert.equal(await page.locator("#auth-gate").isVisible(), false);
  assert.equal(await page.locator("#tab-main").isVisible(), true);
  await page.locator("summary", { hasText: "Audio controls" }).click();
  assert.equal(await page.locator("#rx-audio-btn").count(), 1);

  // Section order in the tray. "Advanced radio controls" is built at runtime,
  // so it lands wherever ui-core puts it rather than where the markup says —
  // appending, as it once did, always left it last.
  const sections = await page.evaluate(() =>
    [...document.querySelectorAll(".controls-tray > details")].map((section) =>
      section.querySelector("summary").textContent.trim()));
  assert.deepEqual(sections, ["Advanced radio controls", "Audio controls", "Scheduler controls"],
    `tray sections are ${JSON.stringify(sections)}`);

  // Mode is a button group over a hidden <select>, which stays the value a
  // dozen call sites and several plugins read. The click has to reach it, and
  // the select must not take part in layout while it does.
  const modeBefore = await page.evaluate(() => ({
    buttons: document.querySelectorAll("#mode-picker button").length,
    value: document.getElementById("mode").value,
    active: document.querySelector("#mode-picker button.active")?.dataset.mode,
  }));
  assert.ok(modeBefore.buttons > 1, `mode picker rendered ${modeBefore.buttons} buttons`);
  assert.equal(modeBefore.active, modeBefore.value, "mode picker disagrees with the select");
  const target = await page.evaluate(() => {
    const other = [...document.querySelectorAll("#mode-picker button")]
      .find((btn) => btn.dataset.mode !== document.getElementById("mode").value);
    return other?.dataset.mode;
  });
  await page.locator(`#mode-picker button[data-mode="${target}"]`).click();
  await page.waitForTimeout(200);
  const modeAfter = await page.evaluate(() => ({
    value: document.getElementById("mode").value,
    active: document.querySelector("#mode-picker button.active")?.dataset.mode,
    selectWidth: Math.round(document.getElementById("mode").getBoundingClientRect().width),
  }));
  assert.equal(modeAfter.value, target, `clicking ${target} left the select at ${modeAfter.value}`);
  assert.equal(modeAfter.active, target, "the clicked mode is not the marked one");
  assert.ok(modeAfter.selectWidth <= 2, `the hidden select still occupies ${modeAfter.selectWidth}px`);

  // Mode-specific controls live on their own row, which has to leave with them:
  // an empty one would still take a track and a gap in the tray and draw its
  // divider under the controls every rig has.
  const modeRowState = async () => page.evaluate(() => {
    const row = document.getElementById("mode-controls-row");
    return { display: getComputedStyle(row).display, height: Math.round(row.getBoundingClientRect().height) };
  });
  await page.locator('#mode-picker button[data-mode="WFM"]').click();
  await page.waitForTimeout(250);
  const withWfm = await modeRowState();
  assert.notEqual(withWfm.display, "none", "WFM controls did not bring their row up");
  assert.ok(withWfm.height > 0, `WFM row has no height (${withWfm.height}px)`);
  await page.locator('#mode-picker button[data-mode="FM"]').click();
  await page.waitForTimeout(250);
  const withoutWfm = await modeRowState();
  assert.equal(withoutWfm.display, "none", "the mode row stayed behind with nothing in it");

  // Scheduler controls read left to right: step, hand back, then the entry on
  // air. The separator is drawn by the current-entry block, so it can only sit
  // in the right place if that block is last.
  const schedulerRow = await page.evaluate(() => [...document.querySelectorAll(".scheduler-action-row > *")]
    .map((el) => el.id || [...el.children].map((c) => c.id).join("+")));
  assert.deepEqual(schedulerRow,
    ["scheduler-prev-btn+scheduler-next-btn", "scheduler-release-btn", "scheduler-cycle-status"],
    `scheduler control order is ${JSON.stringify(schedulerRow)}`);

  // The footer status pill colours its dot from data-state, so a hint written
  // straight to textContent would leave the dot stuck on the previous state.
  const hint = await page.evaluate(() => {
    const element = document.getElementById("power-hint");
    return { state: element.dataset.state, text: element.textContent.trim() };
  });
  assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`);
  assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`);

  const rigPicker = page.locator("#header-rig-switch-select");
  await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
  await rigPicker.selectOption("rig-b");
  await page.waitForTimeout(100);
  assert.deepEqual(selectedRigs, ["rig-b"]);

  await page.locator('.tab[data-tab="map"]').click();
  await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
  assert.equal(new URL(page.url()).pathname, "/map");

  // The selected destination is marked by a box on all four sides, so a rule
  // that drops one edge (the mobile nav used to lose its bottom border) is a
  // regression even though the tab still reads as "active".
  const activeTab = await page.evaluate(() => {
    const style = getComputedStyle(document.querySelector(".tab-bar-nav .tab.active"));
    return ["Top", "Right", "Bottom", "Left"].map((side) => ({
      width: style.getPropertyValue(`border-${side.toLowerCase()}-width`),
      color: style.getPropertyValue(`border-${side.toLowerCase()}-color`),
    }));
  });
  for (const edge of activeTab) {
    assert.notEqual(edge.width, "0px", `active tab border: ${JSON.stringify(activeTab)}`);
    assert.ok(!/rgba\(0, 0, 0, 0\)|transparent/.test(edge.color), `active tab border: ${JSON.stringify(activeTab)}`);
  }

  // The map is full-bleed: it breaks out of the centred .card column and
  // reaches both viewport edges, without pushing the page sideways.
  const stage = await page.evaluate(() => {
    const rect = document.getElementById("map-stage").getBoundingClientRect();
    return {
      left: Math.round(rect.left),
      right: Math.round(rect.right),
      viewport: document.documentElement.clientWidth,
      sideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
      gapToFooter: Math.round(document.querySelector(".footer").getBoundingClientRect().top - rect.bottom),
      pageScrolls: document.documentElement.scrollHeight > document.documentElement.clientHeight + 1,
    };
  });
  assert.equal(stage.left, 0, `map stage starts at ${stage.left}px, not the viewport edge`);
  assert.equal(stage.right, stage.viewport, `map stage ends at ${stage.right}px, not ${stage.viewport}px`);
  assert.equal(stage.sideways, false, "full-bleed map makes the page scroll sideways");
  // ...and fills the column down to the footer. Capping the height at a
  // fraction of the viewport left a dead band that grew with the window.
  assert.ok(stage.gapToFooter <= 16,
    `${stage.gapToFooter}px of dead space between the map and the footer`);
  assert.equal(stage.pageScrolls, false, "the map grew past the viewport");

  await page.locator('.tab[data-tab="main"]').click();
  assert.equal(new URL(page.url()).pathname, "/");

  // About is an occasional destination, so it lives behind More at every
  // width rather than in the operating tab strip.
  await page.locator("#mobile-more-btn").click();
  await page.locator('[data-navigate-tab="about"]').click();
  await page.locator("#tab-about").waitFor({ state: "visible" });
  assert.equal(new URL(page.url()).pathname, "/about");

  await page.goBack();
  await page.locator("#tab-main").waitFor({ state: "visible" });
  assert.equal(new URL(page.url()).pathname, "/");
  assert.deepEqual(runtimeErrors, []);

  // Refreshing or deep-linking must mark the destination, not Tools. The first
  // route navigation runs while the card is still behind the loading state, so
  // a test that asked whether the tab was displayed saw "none" for every tab
  // and lit Tools up on every refresh of every page.
  for (const [route, tab, toolsLit] of [["/map", "map", false], ["/about", "about", true]]) {
    await page.goto(`${fixture.origin}${route}`, { waitUntil: "domcontentloaded" });
    await page.locator(`#tab-${tab}`).waitFor({ state: "visible" });
    const marked = await page.evaluate(() => ({
      actives: [...document.querySelectorAll(".tab-bar .tab.active")].map((t) => t.dataset.tab || t.id),
      tools: document.getElementById("mobile-more-btn").classList.contains("active"),
    }));
    assert.ok(marked.actives.includes(tab), `${route} marks ${JSON.stringify(marked.actives)}`);
    assert.equal(marked.tools, toolsLit, `${route}: Tools active is ${marked.tools}`);
  }
  await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
  await page.locator("#tab-main").waitFor({ state: "visible" });
  assert.deepEqual(runtimeErrors, []);

  // --- Layout regressions -------------------------------------------------
  // Every fault below shipped at some point while the rest of this file
  // passed, because nothing here looked at geometry: a header whose height
  // tracked the viewport, controls that stretched, a tab strip that ran under
  // the controls, and a dropdown that opened underneath the spectrum.
  for (const width of [1440, 1280, 1100, 900]) {
    await page.setViewportSize({ width, height: 900 });
    await page.waitForTimeout(250);
    const header = await page.evaluate(() => {
      const bar = document.querySelector(".tab-bar");
      const nav = document.querySelector(".tab-bar-nav");
      const actions = document.querySelector(".top-bar-actions");
      const controls = [...actions.children]
        .filter((el) => !el.hidden && el.getBoundingClientRect().height > 0)
        .map((el) => Math.round(el.getBoundingClientRect().height));
      return {
        barHeight: Math.round(bar.getBoundingClientRect().height),
        // The tabs, not the strip: with the strip allowed to overflow its box
        // shrinks while its content paints across the controls, so the
        // container's own rect never registers the collision.
        overlap: Math.round(Math.max(...[...nav.querySelectorAll(".tab")]
          .filter((tab) => tab.offsetParent !== null)
          .map((tab) => tab.getBoundingClientRect().right))
          - actions.getBoundingClientRect().left),
        heights: [...new Set(controls)],
        pageScrollsSideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
      };
    });
    assert.ok(header.barHeight <= 96, `header is ${header.barHeight}px at ${width}px; it should stay one row`);
    assert.ok(header.overlap <= 0, `tab strip overlaps the controls by ${header.overlap}px at ${width}px`);
    assert.ok(header.heights.length <= 2, `controls have heights ${header.heights.join(", ")} at ${width}px`);
    assert.equal(header.pageScrollsSideways, false, `page scrolls sideways at ${width}px`);
  }

  // A dropdown must paint over the page, not inside the header: fixed
  // positioning escapes clipping but not the header's stacking context.
  await page.setViewportSize({ width: 1280, height: 900 });
  await page.waitForTimeout(250);
  const menu = await page.evaluate(() => {
    document.getElementById("mobile-more-btn").click();
    const element = document.getElementById("mobile-more-menu");
    const rect = element.getBoundingClientRect();
    const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
    return { width: Math.round(rect.width), height: Math.round(rect.height), onTop: element.contains(hit) };
  });
  assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`);
  assert.ok(menu.onTop, "menu is painted underneath the page");

  // Wider text than this machine renders. The checks above passed on macOS
  // while CI, whose system font is wider, put the tab strip into the controls:
  // the bar had run out of moves and the tabs kept their full width anyway.
  // Every piece of text in the bar is scaled, not just the tabs — the identity
  // block is what runs out of room first at 1100px, where the bookmark gutters
  // make the card narrower than it is at 900px.
  const scale = 1.9;
  await page.addStyleTag({ content: `
    .tab-bar .title { font-size: ${1.05 * scale}rem !important; }
    .tab-bar .subtitle { font-size: ${0.78 * scale}rem !important; }
    .tab-bar .tab { font-size: ${0.95 * scale}rem !important; }
    .tab-bar select, .tab-bar button { font-size: ${0.95 * scale}rem !important; }
  ` });
  for (const width of [1440, 1280, 1100, 900]) {
    await page.setViewportSize({ width, height: 900 });
    await page.waitForTimeout(250);
    const crowded = await page.evaluate(() => {
      const nav = document.querySelector(".tab-bar-nav");
      const actions = document.querySelector(".top-bar-actions");
      const tabs = [...nav.querySelectorAll(".tab")].filter((tab) => tab.offsetParent !== null);
      return {
        overlap: Math.round(Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right))
          - actions.getBoundingClientRect().left),
        iconsOnly: nav.classList.contains("nav-icons-only"),
      };
    });
    assert.ok(crowded.overlap <= 0,
      `with wider text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
    assert.equal(crowded.iconsOnly, true, `the strip kept its labels at ${width}px with no room for them`);
  }

} finally {
  await browser.close();
  await fixture.close();
}
