#!/usr/bin/env node
/** Repeatable anonymous WebGL performance gate over a built Tera bundle. */

import { chromium } from "playwright";
import { createServer } from "node:http";
import { access, readFile, writeFile } 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 DEFAULT_BUDGETS = join(ROOT, "scripts", "performance-budgets.json");
const PRIVATE_PATH = /^\/api\/v1\/(?:media|realtime|presence|offices)(?:\/|$)|^\/assets\/(?:remoteMedia|presenceIndicator|scenePeers)-/;
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 VIEWPORTS = {
  desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
  mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
};
const SCENES = {
  california: { host: "tera.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0 },
  "california-drive": {
    host: "tera.lumbridgecorp.com",
    ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 1,
    activate: () => document.querySelectorAll("#chapters .chapter")[1]?.click(),
    active: () => document.querySelector("[data-control-mode='drive']")?.getAttribute("aria-pressed") === "true" && document.getElementById("play-hud")?.hidden === false,
  },
  office: { host: "office.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true },
};

const args = process.argv.slice(2);
function option(name, fallback) {
  const at = args.indexOf(`--${name}`);
  return at < 0 ? fallback : args[at + 1];
}
function positive(name, fallback) {
  const value = Number(option(name, fallback));
  if (!Number.isFinite(value) || value <= 0) throw new Error(`--${name} must be a positive number`);
  return value;
}
function sceneBudget(value, label) {
  if (!value || typeof value !== "object") throw new Error(`missing budget for ${label}`);
  for (const key of ["p95FrameIntervalMs", "maxDrawCalls", "maxTriangles"]) {
    if (typeof value[key] !== "number" || !Number.isFinite(value[key]) || value[key] <= 0) {
      throw new Error(`${label}.${key} must be a positive number`);
    }
  }
  return value;
}
const outputPath = resolve(option("output", "/tmp/tera-performance-budget.json"));
const budgetPath = resolve(option("budgets", DEFAULT_BUDGETS));
const warmupMs = positive("warmup-ms", 3_000);
const sampleMs = positive("sample-ms", 8_000);
const readyTimeoutMs = positive("ready-timeout-ms", 180_000);
const softwareOnly = args.includes("--software");
// Chrome exposes rAF timestamps at 0.1 ms precision, so an ideal 60 Hz cadence
// can quantize to 16.8 ms. This tolerance is measurement resolution, not budget
// headroom; reported values and declared budgets remain unchanged.
const FRAME_COMPARISON_EPSILON_MS = 0.1;

function percentile(values, fraction) {
  if (values.length === 0) return null;
  const ordered = [...values].sort((a, b) => a - b);
  return ordered[Math.max(0, Math.ceil(ordered.length * fraction) - 1)];
}
const rounded = (value) => value === null ? null : Math.round(value * 1_000) / 1_000;

async function serve() {
  const requests = [];
  const server = createServer(async (req, res) => {
    const url = new URL(req.url ?? "/", "http://local.invalid");
    requests.push({ method: req.method ?? "GET", path: url.pathname });
    if (url.pathname === "/api/v1/health") {
      res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
      res.end(JSON.stringify({ auth: { mode: "jwt", entryUrl: "/login.html" }, sources: { weather: "none", flights: "none", satellites: "none", markers: "none" } }));
      return;
    }
    if (url.pathname === "/api/v1/session") {
      res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
      res.end(JSON.stringify({ authenticated: false, subject: null, passwordLogin: false, admin: false }));
      return;
    }
    if (url.pathname.startsWith("/api/v1/")) {
      res.writeHead(404, { "content-type": "application/json", "cache-control": "no-store" });
      res.end(JSON.stringify({ error: "not_found" }));
      return;
    }
    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 { /* SPA fallback */ }
    }
    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("performance server did not expose a TCP port");
  return { server, requests, port: address.port };
}

const COMMON_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"];
async function rendererOf(browser) {
  const page = await browser.newPage();
  try {
    await page.goto("about:blank");
    return await page.evaluate(() => {
      const gl = document.createElement("canvas").getContext("webgl2");
      const extension = gl?.getExtension("WEBGL_debug_renderer_info");
      return extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null;
    });
  } finally { await page.close(); }
}
async function launchBrowser(port) {
  const resolver = `--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1, MAP office.lumbridgecorp.com 127.0.0.1`;
  const attempts = 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 attempts) {
    try {
      const browser = await chromium.launch({ channel: "chrome", args: [...COMMON_ARGS, resolver, ...flags] });
      const renderer = await rendererOf(browser);
      const softwareRenderer = renderer !== null && /SwiftShader|llvmpipe/i.test(renderer);
      if (renderer && (backend === "swiftshader" || !softwareRenderer)) return { browser, backend, renderer, port };
      await browser.close();
    } catch (error) { last = error; }
  }
  for (const executablePath of ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]) {
    try {
      await access(executablePath);
      const browser = await chromium.launch({ executablePath, args: [...COMMON_ARGS, resolver, "--use-gl=angle", "--use-angle=swiftshader"] });
      return { browser, backend: "system-chrome-swiftshader", renderer: await rendererOf(browser), port };
    } catch (error) { last = error; }
  }
  throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
}

function instrumentation() {
  // Custom production hostnames mapped to loopback are not secure contexts over
  // HTTP, so Chrome withholds randomUUID even though getRandomValues remains.
  // Production is HTTPS; this shim only restores that API in the local harness.
  if (typeof crypto.randomUUID !== "function") {
    crypto.randomUUID = () => {
      const bytes = crypto.getRandomValues(new Uint8Array(16));
      bytes[6] = (bytes[6] & 0x0f) | 0x40;
      bytes[8] = (bytes[8] & 0x3f) | 0x80;
      const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
      return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
    };
  }
  const state = { frames: [], longTasks: [], drawCalls: [], triangles: [], currentCalls: 0, currentTriangles: 0, last: 0, longTaskSupported: false };
  Object.defineProperty(globalThis, "__teraPerformanceBudget", { value: state });
  const triangleCount = (mode, count) => mode === 4 ? count / 3 : (mode === 5 || mode === 6) ? Math.max(0, count - 2) : 0;
  const patch = (prototype, method, countAt, instancesAt = null) => {
    if (!prototype || typeof prototype[method] !== "function") return;
    const original = prototype[method];
    if (original.__teraPerformancePatched) return;
    const wrapped = function (...values) {
      state.currentCalls += 1;
      const instances = instancesAt === null ? 1 : Number(values[instancesAt]) || 0;
      state.currentTriangles += triangleCount(Number(values[0]), Number(values[countAt]) || 0) * instances;
      return original.apply(this, values);
    };
    Object.defineProperty(wrapped, "__teraPerformancePatched", { value: true });
    prototype[method] = wrapped;
  };
  for (const prototype of [globalThis.WebGLRenderingContext?.prototype, globalThis.WebGL2RenderingContext?.prototype]) {
    patch(prototype, "drawArrays", 2);
    patch(prototype, "drawElements", 1);
    patch(prototype, "drawArraysInstanced", 2, 3);
    patch(prototype, "drawElementsInstanced", 1, 4);
  }
  try {
    new PerformanceObserver((list) => state.longTasks.push(...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })))).observe({ type: "longtask", buffered: true });
    state.longTaskSupported = true;
  } catch { /* Long Tasks API is optional. */ }
  requestAnimationFrame(function sample(now) {
    if (state.last > 0) {
      state.frames.push(now - state.last);
      state.drawCalls.push(state.currentCalls);
      state.triangles.push(state.currentTriangles);
    }
    state.last = now;
    state.currentCalls = 0;
    state.currentTriangles = 0;
    requestAnimationFrame(sample);
  });
}

async function measure(browser, port, sceneName, viewportName, budget, requestLog) {
  const viewport = VIEWPORTS[viewportName];
  const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: viewport.deviceScaleFactor, isMobile: viewport.isMobile, hasTouch: viewport.hasTouch });
  const page = await context.newPage();
  const consoleErrors = [];
  page.on("pageerror", (error) => consoleErrors.push(String(error)));
  page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
  const before = requestLog.length;
  await page.addInitScript(instrumentation);
  const scene = SCENES[sceneName];
  const url = `http://${scene.host}:${port}/`;
  try {
    await page.goto(url, { waitUntil: "networkidle", timeout: readyTimeoutMs });
    try {
      await page.waitForFunction(scene.ready, null, { timeout: readyTimeoutMs });
    } catch (error) {
      const state = await page.evaluate(() => ({
        title: document.title,
        bootHidden: document.getElementById("boot")?.hidden ?? null,
        bootText: document.getElementById("boot")?.textContent?.trim().slice(0, 240) ?? null,
        chapters: document.querySelectorAll("#chapters .chapter").length,
        enter: document.getElementById("enter")?.textContent?.trim() ?? null,
      })).catch(() => null);
      throw new Error(`scene readiness timed out: ${JSON.stringify({ state, consoleErrors, requests: requestLog.slice(before) })}`, { cause: error });
    }
    if (scene.activate) {
      await page.evaluate(scene.activate);
      await page.waitForFunction(scene.active, null, { timeout: readyTimeoutMs });
    }
    await page.waitForTimeout(warmupMs);
    await page.evaluate(() => {
      const state = globalThis.__teraPerformanceBudget;
      state.frames.length = state.drawCalls.length = state.triangles.length = state.longTasks.length = 0;
      state.last = performance.now(); state.currentCalls = 0; state.currentTriangles = 0;
    });
    await page.waitForTimeout(sampleMs);
    const raw = await page.evaluate(() => {
      const state = globalThis.__teraPerformanceBudget;
      const canvas = document.querySelector("canvas");
      const gl = canvas?.getContext("webgl2") ?? canvas?.getContext("webgl");
      const extension = gl?.getExtension("WEBGL_debug_renderer_info");
      return {
        frames: [...state.frames], drawCalls: [...state.drawCalls], triangles: [...state.triangles],
        longTasks: [...state.longTasks], longTaskSupported: state.longTaskSupported,
        renderer: extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null,
      };
    });
    const relevantRequests = requestLog.slice(before);
    const privateRequests = relevantRequests.filter((request) => PRIVATE_PATH.test(request.path));
    const metrics = {
      frameSamples: raw.frames.length,
      p50FrameIntervalMs: rounded(percentile(raw.frames, 0.5)),
      p95FrameIntervalMs: rounded(percentile(raw.frames, 0.95)),
      p99FrameIntervalMs: rounded(percentile(raw.frames, 0.99)),
      maxFrameIntervalMs: rounded(raw.frames.length ? Math.max(...raw.frames) : null),
      p95DrawCalls: rounded(percentile(raw.drawCalls, 0.95)),
      maxDrawCalls: raw.drawCalls.length ? Math.max(...raw.drawCalls) : null,
      p95Triangles: rounded(percentile(raw.triangles, 0.95)),
      maxTriangles: raw.triangles.length ? Math.max(...raw.triangles) : null,
      longTasksSupported: raw.longTaskSupported,
      longTaskCount: raw.longTasks.length,
      longTaskTotalMs: raw.longTasks.reduce((sum, task) => sum + task.duration, 0),
    };
    const checks = {
      p95FrameIntervalMs: metrics.p95FrameIntervalMs !== null &&
        Math.round(metrics.p95FrameIntervalMs * 10) <=
          Math.round((budget.p95FrameIntervalMs + FRAME_COMPARISON_EPSILON_MS) * 10),
      maxDrawCalls: metrics.maxDrawCalls !== null && metrics.maxDrawCalls <= budget.maxDrawCalls,
      maxTriangles: metrics.maxTriangles !== null && metrics.maxTriangles <= budget.maxTriangles,
      anonymousPrivateRequests: privateRequests.length === 0,
      consoleErrors: consoleErrors.length === 0,
      enoughFrameSamples: metrics.frameSamples >= Math.max(30, Math.floor(sampleMs / 100)),
    };
    return { scene: sceneName, viewport: viewportName, url, viewportPixels: viewport, renderer: raw.renderer, budget, metrics, checks, passed: Object.values(checks).every(Boolean), privateRequests, consoleErrors };
  } finally { await context.close(); }
}

let server;
let browser;
try {
  await access(join(DIST, "index.html"));
  const budgets = JSON.parse(await readFile(budgetPath, "utf8"));
  const hosted = await serve(); server = hosted.server;
  const launched = await launchBrowser(hosted.port); browser = launched.browser;
  const results = [];
  for (const scene of Object.keys(SCENES)) for (const viewport of Object.keys(VIEWPORTS)) {
    process.stderr.write(`performance-budget: ${scene}/${viewport}\n`);
    const budget = sceneBudget(budgets?.scenes?.[scene]?.[viewport], `${scene}.${viewport}`);
    results.push(await measure(browser, hosted.port, scene, viewport, budget, hosted.requests));
  }
  const report = {
    schemaVersion: 1, generatedAt: new Date().toISOString(), browserPlugin: "not available; Playwright system Chrome fallback used",
    browser: { backend: launched.backend, renderer: launched.renderer }, warmupMs, sampleMs,
    frameComparisonEpsilonMs: FRAME_COMPARISON_EPSILON_MS,
    budgets: budgetPath, passed: results.every((result) => result.passed), results,
  };
  await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`);
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
  process.stderr.write(`performance-budget: ${report.passed ? "PASS" : "FAIL"} — ${outputPath}\n`);
  if (!report.passed) process.exitCode = 1;
} catch (error) {
  console.error(`performance-budget: ERROR — ${error instanceof Error ? error.stack : String(error)}`);
  process.exitCode = 2;
} finally {
  await browser?.close().catch(() => undefined);
  if (server) await new Promise((resolveClose) => server.close(resolveClose));
}
