#!/usr/bin/env node
/**
 * The interface gate: does the chrome actually mount, on both shapes of screen
 * and on both sides of the sign-in line?
 *
 *     node scripts/ui-smoke.mjs            # against dist/, which must be built
 *     node scripts/ui-smoke.mjs --headed   # watch it happen
 *
 * ## Why this exists
 *
 * Until `ui/chromeState.ts` landed, every visibility decision in this product
 * was a line in `main.ts` that needed a `Stage`, a heightfield and a WebGL
 * context to reach — so the interface, on a product whose entire pitch is the
 * interface, was the one subsystem with no coverage at all. `chromeState` made
 * the *decisions* testable in `node --test`; this makes the *wiring* testable,
 * which is the other half and the half that breaks at a seam nobody owns.
 *
 * Four combinations, because each of the two axes has genuinely changed
 * behaviour behind it and neither implies the other:
 *
 * - **1600 × 1000 and 390 × 844.** The phone is a different design, not a narrow
 *   desktop: no hover, no keyboard, one thumb. Live defect 10 was the plan view
 *   disappearing entirely there, and 12 was there being no visible movement
 *   control at all.
 * - **Anonymous and signed in.** Owner decision 1 makes the signed-out visitor
 *   the audience this is designed for rather than a degraded tier, so anonymous
 *   is the case that must be *good*, not merely the case that must not crash.
 *
 * ## GPU flags
 *
 * This box has an AMD card and no monitor. `--use-gl=angle --use-angle=vulkan`
 * is what makes Chrome render headlessly on it at all, and the SwiftShader
 * fallback behind it is what makes this runnable on a CI box with no card —
 * `--enable-unsafe-swiftshader` is required since Chrome 137 made software WebGL
 * opt-in. Exactly the ladder `scripts/performance-budget.mjs` climbs, for
 * exactly the same reason.
 */

import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = fileURLToPath(new URL("..", import.meta.url));
const DIST = join(ROOT, "dist");
const MIME = {
  ".html": "text/html; charset=utf-8",
  ".js": "text/javascript",
  ".css": "text/css",
  ".json": "application/json",
  ".png": "image/png",
  ".svg": "image/svg+xml",
  ".webp": "image/webp",
  ".webmanifest": "application/manifest+json",
};

const args = process.argv.slice(2);
const headed = args.includes("--headed");
const softwareOnly = args.includes("--software");

const VIEWPORTS = {
  desktop: { width: 1_600, height: 1_000 },
  phone: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 3 },
};

/**
 * The two tiers, as the two bodies `/api/v1/session` can return.
 *
 * `/health` says `mode: "jwt"` in both, which is what makes the anonymous case
 * a *real* anonymous case: `resolveAccess` treats `mode: "none"` as a self-host
 * that chose to stay open and hands it `member`, so a smoke that left auth off
 * would never once exercise the tier this product is designed for.
 */
const TIERS = {
  anonymous: { authenticated: false, subject: null, passwordLogin: true, admin: false },
  "signed-in": {
    authenticated: true,
    subject: "smoke@lumbridgecorp.test",
    passwordLogin: true,
    admin: false,
  },
};

// ---- The deployment, as a few hundred bytes of JSON -----------------------

async function serve(tier) {
  const server = createServer(async (req, res) => {
    const url = new URL(req.url ?? "/", "http://local.invalid");
    const json = (status, body) => {
      res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
      res.end(JSON.stringify(body));
    };
    if (url.pathname === "/api/v1/health") {
      return json(200, {
        auth: { mode: "jwt", entryUrl: "/login.html" },
        // Every source off, which is the shipped default and the configuration
        // CONTRACT §0's acceptance test is written against. The city still gets
        // a sky, traffic and a studio full of instruments — from the simulators.
        sources: { weather: "none", flights: "none", satellites: "none", markers: "none" },
        regions: [],
      });
    }
    if (url.pathname === "/api/v1/session") return json(200, TIERS[tier]);
    if (url.pathname.startsWith("/api/v1/")) return json(404, { error: "not_found" });

    const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, "");
    for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) {
      const target = resolve(DIST, `.${relative}`);
      if (!target.startsWith(`${resolve(DIST)}/`)) continue;
      try {
        const body = await readFile(target);
        res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" });
        res.end(body);
        return;
      } catch { /* fall through to the SPA entry */ }
    }
    res.writeHead(404).end("not found");
  });
  await new Promise((ok, fail) => {
    server.once("error", fail);
    server.listen(0, "127.0.0.1", ok);
  });
  const address = server.address();
  if (!address || typeof address === "string") throw new Error("no TCP port");
  return { server, port: address.port };
}

// ---- Chrome ---------------------------------------------------------------

const COMMON_ARGS = [
  "--no-sandbox",
  "--disable-dev-shm-usage",
  "--enable-unsafe-swiftshader",
  "--ignore-gpu-blocklist",
];

async function launch() {
  const ladder = softwareOnly
    ? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]]
    : [
        ["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]],
        ["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]],
      ];
  let last;
  for (const [backend, flags] of ladder) {
    try {
      const browser = await chromium.launch({
        channel: "chrome",
        headless: !headed,
        args: [...COMMON_ARGS, ...flags],
      });
      return { browser, backend };
    } catch (error) {
      last = error;
    }
  }
  throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
}

// ---- Assertions -----------------------------------------------------------

const failures = [];
const notes = [];

function check(label, condition, detail = "") {
  if (condition) return true;
  failures.push(`${label}${detail === "" ? "" : ` — ${detail}`}`);
  return false;
}

/**
 * A `console.error` or an uncaught exception, and nothing softer.
 *
 * Warnings are deliberately not fatal: three.js warns about the tone-mapping
 * change on some drivers and a missing optional API is a warning by design in
 * three separate adapters here. An *error* means something threw or something
 * decided the page was broken, and neither may happen on a first load.
 */
function watchForErrors(page, seen) {
  page.on("console", (message) => {
    if (message.type() !== "error") return;
    /*
     * Chrome logs its own console error for every non-2xx response, and the
     * optional API surface answering 4xx is not an application error — it is the
     * architecture. `adapters/http.ts` degrades to bundled fiction rather than
     * failing, and CONTRACT §0's acceptance test is a clone with no server at
     * all, so a build that treated `/api/v1/*` 404s as fatal would be gating on
     * the opposite of the promise.
     *
     * Everything else stays fatal, and the case that matters is a missing
     * `/assets/*.js` chunk: a deploy that moved a file under an open tab is
     * exactly the failure this check exists to catch, and it arrives as a 404 on
     * a URL that is not under `/api/v1/`.
     */
    const from = message.location()?.url ?? "";
    if (/\/api\/v1\//.test(from)) return;
    seen.push(`console: ${message.text()}${from === "" ? "" : ` (${from})`}`);
  });
  page.on("pageerror", (error) => seen.push(`uncaught: ${String(error)}`));
  page.on("requestfailed", (request) => {
    // A cancelled navigation request is not a failure of the page.
    const failure = request.failure();
    if (failure && !/ERR_ABORTED/.test(failure.errorText)) {
      seen.push(`request: ${request.url()} ${failure.errorText}`);
    }
  });
  page.on("response", (response) => {
    const url = new URL(response.url());
    if (url.pathname.startsWith("/api/v1/")) return;
    if (response.status() >= 400) seen.push(`http ${response.status()}: ${url.pathname}`);
  });
}

const chromeSnapshot = () => {
  const visible = (id) => {
    const element = document.getElementById(id);
    return element !== null && element.hidden === false;
  };
  const text = (id) => document.getElementById(id)?.textContent?.trim() ?? "";
  return {
    bootHidden: document.getElementById("boot")?.hidden === true,
    chapters: document.querySelectorAll("#chapters .chapter").length,
    boards: document.querySelectorAll("#cities .board").length,
    activeBoard: document.querySelectorAll("#cities .board[aria-pressed='true']").length,
    modeButtons: document.querySelectorAll("#mode-dock [data-control-mode]:not([hidden])").length,
    tierVisible: visible("tier"),
    tierLabel: text("tier-label"),
    tierAdds: text("tier-adds"),
    signInVisible: visible("tier-signin"),
    enterLabel: text("enter"),
    panelToggleVisible: visible("panel-toggle"),
    planVisible: visible("corner"),
    planPressed: document.getElementById("plan-toggle")?.getAttribute("aria-pressed"),
    helpLabel: text("help"),
    onboardingCards: document.querySelectorAll("#onboarding-host [data-onboarding-step]").length,
    onboardingHostChildren: document.getElementById("onboarding-host")?.childElementCount ?? 0,
    deviceSectionVisible: visible("device-section"),
    devicesButtonVisible: visible("devices"),
    deviceCards: document.querySelectorAll("#device-host [data-device]").length,
    deviceControls: document.querySelectorAll("#device-host button, #device-host input").length,
    stickVisible: visible("play-stick"),
    canvasLabel: document.getElementById("scene")?.getAttribute("aria-label") ?? "",
    bodyClasses: [...document.body.classList],
    // The renderer counting its own draws is the one witness a fast frame
    // cannot fool: an increment means a frame of the real scene was submitted.
    frames: window.__teraSmokeFrames ?? 0,
  };
};

/** Count real WebGL draws, so "it booted" means pixels rather than a DOM tree. */
const countFrames = () => {
  let frames = 0;
  const patch = (prototype, method) => {
    if (!prototype || typeof prototype[method] !== "function") return;
    const original = prototype[method];
    prototype[method] = function (...values) {
      frames += 1;
      return original.apply(this, values);
    };
  };
  for (const prototype of [
    globalThis.WebGLRenderingContext?.prototype,
    globalThis.WebGL2RenderingContext?.prototype,
  ]) {
    patch(prototype, "drawArrays");
    patch(prototype, "drawElements");
    patch(prototype, "drawArraysInstanced");
    patch(prototype, "drawElementsInstanced");
  }
  Object.defineProperty(globalThis, "__teraSmokeFrames", { get: () => frames });
};

async function settle(page, timeoutMs = 120_000) {
  await page.waitForFunction(() => document.getElementById("boot")?.hidden === true, null, {
    timeout: timeoutMs,
  });
  // One more paint after the card goes, so anything the fade uncovers has been
  // through `apply` at least once.
  await page.evaluate(
    () => new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(ok))),
  );
}

// ---- One combination -------------------------------------------------------

async function run(browser, port, viewportName, tier) {
  const label = `${viewportName} · ${tier}`;
  const viewport = VIEWPORTS[viewportName];
  const context = await browser.newContext({
    viewport: { width: viewport.width, height: viewport.height },
    deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
    isMobile: viewport.isMobile ?? false,
    hasTouch: viewport.hasTouch ?? false,
  });
  const errors = [];
  try {
    const page = await context.newPage();
    watchForErrors(page, errors);
    await page.addInitScript(countFrames);
    await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "domcontentloaded" });
    await settle(page);

    const city = await page.evaluate(chromeSnapshot);

    // ---- The board ----
    check(`${label}: boot card faded`, city.bootHidden);
    check(
      `${label}: a real frame reached the glass`,
      city.frames > 0,
      "no WebGL draw call was ever issued, so the card faded over an empty canvas",
    );
    check(`${label}: chapter list mounted`, city.chapters > 0, `${city.chapters} rows`);
    check(`${label}: board strip mounted`, city.boards === 3, `${city.boards} tabs`);
    check(`${label}: exactly one board is current`, city.activeBoard === 1);
    check(
      `${label}: canvas describes itself`,
      /California/.test(city.canvasLabel),
      JSON.stringify(city.canvasLabel),
    );
    check(`${label}: layout class applied`, city.bodyClasses.some((c) => c.startsWith("layout-")));

    // ---- The tier, and the anon-first promise ----
    check(`${label}: tier badge visible`, city.tierVisible);
    if (tier === "anonymous") {
      check(
        `${label}: the signed-out badge names what you get`,
        city.tierLabel === "Open demo",
        JSON.stringify(city.tierLabel),
      );
      check(`${label}: a way in is offered`, city.signInVisible);
      check(
        `${label}: the offer says what signing in adds`,
        city.tierAdds.length > 0,
        "'Public view · Sign in' was the entire signed-out story before this line",
      );
    } else {
      check(
        `${label}: the signed-in badge reads Full view`,
        city.tierLabel === "Full view",
        JSON.stringify(city.tierLabel),
      );
      check(`${label}: no sign-in offer once you are in`, city.signInVisible === false);
    }

    // ---- The two layouts ----
    if (viewportName === "phone") {
      check(
        `${label}: the plan view is on screen`,
        city.planVisible,
        "live defect 10: the minimap disappeared entirely on a phone, because the " +
          "seed was width > 600 and a phone has no M key",
      );
      check(`${label}: the panel has a toggle`, city.panelToggleVisible);
      check(
        `${label}: the shortcuts button is labelled for a thumb`,
        city.helpLabel === "Guide",
        `live defect 11: "? shortcuts" on a device with no keyboard; got ` +
          JSON.stringify(city.helpLabel),
      );
      check(
        `${label}: no joystick while the camera is the only thing you control`,
        city.stickVisible === false,
      );
    } else {
      check(`${label}: the panel is furniture, not a sheet`, city.panelToggleVisible === false);
      check(`${label}: the shortcuts button is the key hint`, city.helpLabel === "?");
    }

    // ---- Onboarding, which must appear exactly once ----
    check(
      `${label}: the first-run coach appeared`,
      city.onboardingHostChildren > 0,
      "there was no onboarding of any kind in this product before this build",
    );
    await page.reload({ waitUntil: "domcontentloaded" });
    await settle(page);
    const second = await page.evaluate(chromeSnapshot);
    check(
      `${label}: the coach does not come back on the second visit`,
      second.onboardingHostChildren === 0,
      `${second.onboardingHostChildren} children after a reload in the same context`,
    );

    // ---- Taking control ----
    //
    // Live defect 12 was that a phone in VIEW mode offered no on-screen way to
    // move and no way to discover that one existed. The joystick has been in
    // `input/pointerStick.ts` since the play modes landed; what it never had was
    // a state that showed it.
    await page.click("#mode-dock [data-control-mode='actor']");
    await page.evaluate(
      () => new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(ok))),
    );
    const playing = await page.evaluate(chromeSnapshot);
    check(
      `${label}: the play HUD appears with a body under control`,
      playing.bodyClasses.includes("playing"),
      JSON.stringify(playing.bodyClasses),
    );
    if (viewportName === "phone") {
      check(
        `${label}: and a thumb is given something to move with`,
        playing.stickVisible,
        "live defect 12: VIEW mode on a phone had no visible movement control at all",
      );
    }
    await page.click("#mode-dock [data-control-mode='overview']");

    // ---- The studio, through the door rather than through the URL ----
    //
    // The in-page swap is the path a visitor takes and the one that exercises
    // `enterOffice` / `leaveOffice`: the city is paused and kept, the office is
    // built, its hardware feed and its car are started, and the whole lot is torn
    // down again on the way out.
    if (viewportName === "phone") await page.click("#panel-toggle");
    await page.click("#enter");
    await settle(page);
    const office = await page.evaluate(chromeSnapshot);
    check(
      `${label}: the studio opened`,
      /Back to the city/.test(office.enterLabel),
      JSON.stringify(office.enterLabel),
    );
    check(
      `${label}: the hardware panel is offered`,
      office.devicesButtonVisible && office.deviceSectionVisible,
      "the studio declares mic and speaker hardware and the panel is anon-visible " +
        "by design — the declarations are authored, only the readings are an account",
    );
    check(
      `${label}: the hardware panel rendered one card per declared device`,
      office.deviceCards === 2,
      `lumbridge-hq declares a desk mic and a desk speaker; found ${office.deviceCards}`,
    );
    check(
      `${label}: every card carries real controls`,
      office.deviceControls >= 4,
      `power, mute, gain and volume are the minimum; found ${office.deviceControls}`,
    );
    check(`${label}: the studio has viewpoints`, office.chapters > 0);
    check(
      `${label}: the board strip became the studio picker`,
      office.boards === 3 && office.activeBoard === 1,
    );

    // ---- And back out, which is where a leak would show ----
    await page.click("#enter");
    await settle(page);
    const back = await page.evaluate(chromeSnapshot);
    check(
      `${label}: stepping back out returns to the board`,
      back.chapters === city.chapters && !/Back to the city/.test(back.enterLabel),
      `${back.chapters} chapters, enter reads ${JSON.stringify(back.enterLabel)}`,
    );
    check(
      `${label}: the studio's hardware panel went with the studio`,
      back.deviceSectionVisible === false && back.deviceCards === 0,
    );

    notes.push(
      `${label}: ${city.chapters} chapters, ${office.chapters} viewpoints, ` +
        `${office.deviceCards} devices / ${office.deviceControls} controls, ` +
        `${city.frames} draws`,
    );
  } finally {
    await context.close();
  }

  if (errors.length > 0) {
    for (const error of errors) failures.push(`${label}: ${error}`);
  }
}

// ---- Main ------------------------------------------------------------------

async function main() {
  try {
    await readFile(join(DIST, "index.html"));
  } catch {
    console.error("ui-smoke: dist/index.html is missing. Run `npm run build` first.");
    process.exit(2);
  }

  const { browser, backend } = await launch();
  console.log(`ui-smoke: chrome up on ${backend}`);
  try {
    for (const tier of Object.keys(TIERS)) {
      const { server, port } = await serve(tier);
      try {
        for (const viewportName of Object.keys(VIEWPORTS)) {
          await run(browser, port, viewportName, tier);
        }
      } finally {
        await new Promise((ok) => server.close(ok));
      }
    }
  } finally {
    await browser.close();
  }

  for (const note of notes) console.log(`  ${note}`);
  if (failures.length > 0) {
    console.error(`\nui-smoke: FAIL — ${failures.length} problem(s)\n`);
    for (const failure of failures) console.error(`  ✖ ${failure}`);
    console.error("");
    process.exit(1);
  }
  console.log("\nui-smoke: PASS — 2 viewports × 2 access tiers, no console errors\n");
}

await main();
