import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { spawn } from "node:child_process";
import { chromium, devices } from "@playwright/test";

const root = process.cwd();
const externalBaseUrl = process.env.PERF_BASE_URL;
const localEnvPath = resolve(root, ".env.local");
const baseUrl = externalBaseUrl ?? "http://127.0.0.1:3100";
const routes = parseList(
  process.env.PERF_ROUTES ?? "/splash,/tip",
);
const runCount = readPositiveInteger(process.env.PERF_RUNS, 3);
const outputPath = resolve(
  root,
  process.env.PERF_REPORT_PATH ??
    ".next/diagnostics/web-vitals-baseline.json",
);
const shouldThrottle = process.env.PERF_DISABLE_THROTTLING !== "1";
let serverProcess;

try {
  if (!externalBaseUrl) {
    const buildIdPath = resolve(root, ".next/BUILD_ID");
    if (!existsSync(buildIdPath)) {
      throw new Error(
        "Production build not found. Run `pnpm build` before `pnpm perf:vitals`.",
      );
    }
    serverProcess = startProductionServer();
    await waitForServer(baseUrl);
  }

  const browser = await chromium.launch({ headless: true });
  const samples = [];

  try {
    for (const route of routes) {
      for (let run = 1; run <= runCount; run += 1) {
        const sample = await measureRoute(browser, route, run);
        samples.push(sample);
        console.log(
          `${route} run ${run}/${runCount}: LCP ${formatMetric(sample.lcpMs, "ms")}, CLS ${formatMetric(sample.cls, "")}`,
        );
      }
    }
  } finally {
    await browser.close();
  }

  const routeReports = routes.map((route) => summarizeRoute(route, samples));
  const report = {
    generatedAt: new Date().toISOString(),
    baseUrl,
    profile: {
      device: "Pixel 7",
      runs: runCount,
      network: shouldThrottle
        ? "1.6 Mbps down / 750 Kbps up / 150 ms latency"
        : "unthrottled",
      cpuSlowdownMultiplier: shouldThrottle ? 4 : 1,
    },
    routes: routeReports,
    samples,
  };

  mkdirSync(dirname(outputPath), { recursive: true });
  writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);

  console.table(
    routeReports.map((route) => ({
      route: route.route,
      finalPath: route.finalPaths.join(", "),
      TTFB: formatMetric(route.median.ttfbMs, " ms"),
      FCP: formatMetric(route.median.fcpMs, " ms"),
      LCP: formatMetric(route.median.lcpMs, " ms"),
      CLS: formatDecimal(route.median.cls, 3),
      "synthetic INP": formatMetric(route.median.syntheticInpMs, " ms"),
      "initial JS": formatBytes(route.median.initialJsTransferBytes),
    })),
  );
  console.log(`\nWeb Vitals baseline written to ${outputPath}`);
} finally {
  serverProcess?.kill("SIGTERM");
}

async function measureRoute(browser, route, run) {
  const context = await browser.newContext({
    ...devices["Pixel 7"],
    serviceWorkers: "block",
  });
  const page = await context.newPage();
  const session = await context.newCDPSession(page);

  if (shouldThrottle) {
    await session.send("Network.enable");
    await session.send("Network.emulateNetworkConditions", {
      offline: false,
      latency: 150,
      downloadThroughput: (1.6 * 1024 * 1024) / 8,
      uploadThroughput: (750 * 1024) / 8,
    });
    await session.send("Emulation.setCPUThrottlingRate", { rate: 4 });
  }

  await page.addInitScript(installPerformanceObservers);

  try {
    await page.goto(new URL(route, baseUrl).toString(), {
      waitUntil: "load",
      timeout: 45_000,
    });
    await page.waitForTimeout(2_500);
    await page.mouse.click(1, 1);
    await page.waitForTimeout(500);

    return await page.evaluate(
      ({ requestedRoute, sampleRun }) => {
        const state = window.__cozsweetPerformanceBaseline;
        const navigation = performance.getEntriesByType("navigation")[0];
        const fcp = performance
          .getEntriesByType("paint")
          .find((entry) => entry.name === "first-contentful-paint");
        const scriptResources = performance
          .getEntriesByType("resource")
          .filter(
            (entry) =>
              entry.initiatorType === "script" &&
              new URL(entry.name).pathname.includes("/_next/"),
          );
        const interactions = [...state.interactions.values()].sort(
          (left, right) => right - left,
        );
        const inpIndex = Math.min(
          interactions.length - 1,
          Math.floor(interactions.length / 50),
        );

        return {
          route: requestedRoute,
          run: sampleRun,
          finalPath: window.location.pathname,
          ttfbMs: navigation
            ? navigation.responseStart - navigation.requestStart
            : null,
          fcpMs: fcp?.startTime ?? null,
          lcpMs: state.lcp,
          cls: state.cls,
          syntheticInpMs: inpIndex >= 0 ? interactions[inpIndex] : null,
          initialJsTransferBytes: scriptResources.reduce(
            (total, entry) => total + entry.transferSize,
            0,
          ),
          initialJsEncodedBytes: scriptResources.reduce(
            (total, entry) => total + entry.encodedBodySize,
            0,
          ),
        };
      },
      { requestedRoute: route, sampleRun: run },
    );
  } finally {
    await context.close();
  }
}

function installPerformanceObservers() {
  const state = {
    lcp: null,
    cls: 0,
    clsSessionValue: 0,
    clsSessionStart: 0,
    clsSessionEnd: 0,
    interactions: new Map(),
  };
  window.__cozsweetPerformanceBaseline = state;

  try {
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      state.lcp = entries.at(-1)?.startTime ?? state.lcp;
    }).observe({ type: "largest-contentful-paint", buffered: true });
  } catch {}

  try {
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.hadRecentInput) continue;
        const startsNewSession =
          state.clsSessionEnd === 0 ||
          entry.startTime - state.clsSessionEnd >= 1_000 ||
          entry.startTime - state.clsSessionStart >= 5_000;
        if (startsNewSession) {
          state.clsSessionValue = entry.value;
          state.clsSessionStart = entry.startTime;
        } else {
          state.clsSessionValue += entry.value;
        }
        state.clsSessionEnd = entry.startTime;
        state.cls = Math.max(state.cls, state.clsSessionValue);
      }
    }).observe({ type: "layout-shift", buffered: true });
  } catch {}

  try {
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.interactionId) continue;
        state.interactions.set(
          entry.interactionId,
          Math.max(
            state.interactions.get(entry.interactionId) ?? 0,
            entry.duration,
          ),
        );
      }
    }).observe({ type: "event", buffered: true, durationThreshold: 16 });
  } catch {}
}

function summarizeRoute(route, samples) {
  const routeSamples = samples.filter((sample) => sample.route === route);
  const metricNames = [
    "ttfbMs",
    "fcpMs",
    "lcpMs",
    "cls",
    "syntheticInpMs",
    "initialJsTransferBytes",
    "initialJsEncodedBytes",
  ];
  const median = Object.fromEntries(
    metricNames.map((name) => [
      name,
      getMedian(routeSamples.map((sample) => sample[name])),
    ]),
  );
  return {
    route,
    finalPaths: [...new Set(routeSamples.map((sample) => sample.finalPath))],
    median,
  };
}

function getMedian(values) {
  const numbers = values
    .filter((value) => typeof value === "number" && Number.isFinite(value))
    .sort((left, right) => left - right);
  if (numbers.length === 0) return null;
  const middle = Math.floor(numbers.length / 2);
  return numbers.length % 2 === 0
    ? (numbers[middle - 1] + numbers[middle]) / 2
    : numbers[middle];
}

function startProductionServer() {
  const standaloneRoot = resolve(root, ".next/standalone");
  const standaloneServer = resolve(standaloneRoot, "server.js");
  const standaloneNextDirectory = resolve(standaloneRoot, ".next");
  mkdirSync(standaloneNextDirectory, { recursive: true });
  cpSync(resolve(root, "public"), resolve(standaloneRoot, "public"), {
    recursive: true,
    force: true,
  });
  cpSync(
    resolve(root, ".next/static"),
    resolve(standaloneNextDirectory, "static"),
    { recursive: true, force: true },
  );

  const child = spawn(
    process.execPath,
    [
      ...(existsSync(localEnvPath) ? [`--env-file=${localEnvPath}`] : []),
      standaloneServer,
    ],
    {
      cwd: standaloneRoot,
      env: {
        ...process.env,
        HOSTNAME: "127.0.0.1",
        PORT: "3100",
      },
      stdio: ["ignore", "pipe", "pipe"],
    },
  );
  child.stdout.on("data", (chunk) => process.stdout.write(chunk));
  child.stderr.on("data", (chunk) => process.stderr.write(chunk));
  return child;
}

async function waitForServer(url) {
  const deadline = Date.now() + 30_000;
  while (Date.now() < deadline) {
    try {
      const response = await fetch(url, { redirect: "manual" });
      if (response.status < 500) return;
    } catch {}
    await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
  }
  throw new Error(`Timed out waiting for ${url}`);
}

function parseList(value) {
  return value
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean)
    .map((route) => (route.startsWith("/") ? route : `/${route}`));
}

function readPositiveInteger(value, fallback) {
  const number = Number.parseInt(value ?? "", 10);
  return Number.isInteger(number) && number > 0 ? number : fallback;
}

function formatMetric(value, suffix) {
  return typeof value === "number" ? `${value.toFixed(1)}${suffix}` : "n/a";
}

function formatDecimal(value, fractionDigits) {
  return typeof value === "number" ? value.toFixed(fractionDigits) : "n/a";
}

function formatBytes(value) {
  return typeof value === "number"
    ? `${(value / 1024).toFixed(1)} KiB`
    : "n/a";
}
