#!/usr/bin/env node
/**
 * Ad-hoc perf-monitor trigger.
 *
 * Fires POST https://perf.emdashcms.com/api/trigger via Cloudflare Access.
 * The endpoint is gated by Access, so authentication is handled by
 * `cloudflared access` (first invocation opens a browser; subsequent
 * invocations reuse the token until session expiry).
 *
 * Usage:
 *   pnpm trigger                          # default: runs probes, does NOT record
 *   pnpm trigger -- --store               # persist with source=manual
 *   pnpm trigger -- --store --note "..."  # persist with a note
 *   pnpm trigger -- --sha abc1234         # attach a SHA (requires --store to persist)
 *   pnpm trigger -- --pr 123              # attach a PR number (requires --store)
 *   pnpm trigger -- --site cache          # measure only the cache-demo site
 *
 * The default is ephemeral -- probes run for real but nothing is written
 * to the database. Pass --store to persist the run as source=manual
 * (excluded from the graph and summary cards, visible in the results
 * table). Other flags like --note/--sha/--pr only have an effect when
 * combined with --store.
 */

import { spawnSync } from "node:child_process";
import { parseArgs } from "node:util";

const ENDPOINT = process.env.PERF_ENDPOINT ?? "https://perf.emdashcms.com/api/trigger";

function die(msg, code = 1) {
	console.error(`trigger: ${msg}`);
	process.exit(code);
}

// pnpm passes a literal `--` token through when users invoke `pnpm trigger -- --note foo`.
// parseArgs treats that as a positional and throws in strict mode. Strip any `--` tokens.
const argv = process.argv.slice(2).filter((a) => a !== "--");

if (argv.includes("-h") || argv.includes("--help")) {
	console.log(
		"Usage: pnpm trigger [-- --store] [--note <string>] [--sha <sha>] [--pr <number>] [--site <id>]\n" +
			"\n" +
			"Runs an ad-hoc perf measurement against every registered demo site.\n" +
			"Pass --site <id> (e.g. blog, cache) to target a single site.\n" +
			"\n" +
			"Default is ephemeral: probes run for real but nothing is written to\n" +
			"the database. Pass --store to persist the run as source=manual\n" +
			"(excluded from the graph and summary cards, visible in the results\n" +
			"table). --note/--sha/--pr only take effect together with --store.",
	);
	process.exit(0);
}

const { values } = parseArgs({
	args: argv,
	options: {
		store: { type: "boolean" },
		note: { type: "string" },
		sha: { type: "string" },
		pr: { type: "string" },
		site: { type: "string" },
	},
	allowPositionals: false,
	strict: true,
});

// Default is ephemeral (not persisted). --store flips that.
const ephemeral = !values.store;

const body = {};
if (ephemeral) body.ephemeral = true;
if (values.note) body.note = values.note;
if (values.sha) body.sha = values.sha;
if (values.pr) {
	const n = Number.parseInt(values.pr, 10);
	if (!Number.isInteger(n) || n <= 0) die(`--pr must be a positive integer, got ${values.pr}`);
	body.prNumber = n;
}
if (values.site) body.site = values.site;

// Warn loudly if someone passed metadata flags without --store: those fields
// only make it into the DB, and we're not writing to the DB in ephemeral mode.
if (ephemeral && (values.note || values.sha || values.pr)) {
	console.warn(
		"trigger: warning: --note/--sha/--pr have no effect without --store (ephemeral mode discards everything)",
	);
}

const label = values.note ? ` (${values.note})` : "";
const mode = ephemeral ? " [ephemeral, not recorded]" : "";
console.log(`trigger: firing against ${ENDPOINT}${label}${mode}`);
console.log("trigger: this typically takes 20-40s while probes run...");

// `cloudflared access curl` passes everything after the URL straight to curl.
// The URL must come first, immediately after `curl` (no `--` separator).
const result = spawnSync(
	"cloudflared",
	[
		"access",
		"curl",
		ENDPOINT,
		"-sS",
		"-X",
		"POST",
		"-H",
		"content-type: application/json",
		"--data",
		JSON.stringify(body),
	],
	{ encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] },
);

if (result.error) {
	if (result.error.code === "ENOENT") {
		die(
			"cloudflared is not installed or not on PATH.\n" +
				"       Install: brew install cloudflared   (or see https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/)\n" +
				"       Then run this command again to complete first-time browser login.",
		);
	}
	die(`cloudflared failed: ${result.error.message}`);
}
if (result.status !== 0) die(`cloudflared exited ${result.status}`);

let parsed;
try {
	parsed = JSON.parse(result.stdout);
} catch {
	die(`unexpected non-JSON response:\n${result.stdout}`);
}

if (parsed.error) die(`server error: ${parsed.error}`);

if (parsed.ephemeral) {
	console.log(
		`trigger: measured ${parsed.results?.length ?? 0} samples in ${parsed.durationMs}ms (ephemeral, nothing recorded)`,
	);
} else {
	console.log(
		`trigger: recorded ${parsed.inserted} samples in ${parsed.durationMs}ms (source=manual)`,
	);
}

// Pretty-print a per-site, per-route table. Layout per (site, route):
//
//   [cache] /
//     REGION  COLD     WARM     P95      COLO    TIMINGS
//     use     1234ms   123ms    156ms    IAD     render=42ms  mw=58ms
//     euw     ...
//
// Column widths are computed from the rows we're about to print so that
// unusually slow runs don't break alignment, and timing columns only
// appear if at least one row has timings.
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
const bold = (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s);

const formatMs = (n) => (n == null ? "-" : `${Math.round(n)}ms`);

// Group results by site -> route, preserving insertion order. Results from
// the server arrive interleaved per (site, region, route); a Map-of-Maps
// keeps each site's rows together for display.
const bySite = new Map();
for (const r of parsed.results ?? []) {
	const siteId = r.site ?? "blog";
	if (!bySite.has(siteId)) bySite.set(siteId, new Map());
	const byRoute = bySite.get(siteId);
	if (!byRoute.has(r.route)) byRoute.set(r.route, []);
	byRoute.get(r.route).push(r);
}

for (const [siteId, byRoute] of bySite) {
	for (const [route, rows] of byRoute) {
		console.log(`\n  ${bold(`[${siteId}] ${route}`)}`);

		// Collect the union of timing names present on this route across BOTH
		// cold and warm snapshots so every row gets a cell in each column,
		// even when a particular probe response lacked some entries.
		// Warm timings are prefixed with "w." in the column header to make
		// the split obvious (cold and warm snapshots share the same metric
		// names — "render", "rt", "mw" — so we'd collide otherwise).
		const coldNames = [];
		const warmNames = [];
		const seenCold = new Set();
		const seenWarm = new Set();
		for (const r of rows) {
			if (r.coldServerTimings) {
				for (const name of Object.keys(r.coldServerTimings)) {
					if (!seenCold.has(name)) {
						seenCold.add(name);
						coldNames.push(name);
					}
				}
			}
			if (r.warmServerTimings) {
				for (const name of Object.keys(r.warmServerTimings)) {
					if (!seenWarm.has(name)) {
						seenWarm.add(name);
						warmNames.push(name);
					}
				}
			}
		}

		// Build row cells. Column order: region, cold, warm, p95, colo,
		// then all cold timings (cold-* intent), then warm timings.
		// Cold timings keep their bare names for backwards-compatible output;
		// warm timings get a "w." prefix.
		const warmHeaders = warmNames.map((n) => `w.${n}`);
		const header = ["region", "cold", "warm", "p95", "colo", ...coldNames, ...warmHeaders];
		const tableRows = rows.map((r) => {
			const cells = [
				r.region,
				formatMs(r.coldTtfbMs),
				formatMs(r.warmTtfbMs),
				formatMs(r.p95TtfbMs),
				r.cfColo ?? "-",
			];
			for (const name of coldNames) {
				const t = r.coldServerTimings?.[name];
				cells.push(t ? formatMs(t.dur) : "-");
			}
			for (const name of warmNames) {
				const t = r.warmServerTimings?.[name];
				cells.push(t ? formatMs(t.dur) : "-");
			}
			return cells;
		});

		// Column widths = max(header, body) per column.
		const widths = header.map((h, col) =>
			Math.max(h.length, ...tableRows.map((cells) => cells[col].length)),
		);

		const padCell = (s, col) => s.padEnd(widths[col]);
		const joinRow = (cells) => cells.map(padCell).join("  ");

		console.log(`    ${dim(joinRow(header))}`);
		for (const cells of tableRows) {
			console.log(`    ${joinRow(cells)}`);
		}
	}
}
