#!/usr/bin/env node
import { createServer } from "node:http";
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { spawn } from "node:child_process";
import { dirname, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { PNG } from "pngjs";
import { chromium } from "playwright";
import {
  classifyRuntimeOutcome,
  createClipPlaybackStatus,
  createPosePlaybackStatus,
  sampleGltfClipPoseFromGlb,
  sanitizeServerLogLine,
  shouldFailProcess,
} from "./kagura-runtime-smoke-utils.mjs";

const repoRoot = resolve(new URL("../../..", import.meta.url).pathname);
const defaultKaguraRepo = resolve(repoRoot, "..", "kagura");

function parseArgs(argv) {
  const args = {
    contract: "",
    out: "",
    screenshot: "",
    calibrationContract: "",
    kaguraRepo: process.env.KAGURA_REPO ? resolve(process.env.KAGURA_REPO) : defaultKaguraRepo,
    port: 8765,
    timeoutMs: 90_000,
    minChangedPixelRatio: 0.01,
    minVisiblePixelRatio: 0.03,
    allowEnvironmentFailure: false,
  };
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i];
    if (arg === "--") continue;
    else if (arg === "--contract") args.contract = resolve(required(argv, ++i, arg));
    else if (arg === "--out") args.out = resolve(required(argv, ++i, arg));
    else if (arg === "--screenshot") args.screenshot = resolve(required(argv, ++i, arg));
    else if (arg === "--calibration-contract") args.calibrationContract = resolve(required(argv, ++i, arg));
    else if (arg === "--kagura-repo") args.kaguraRepo = resolve(required(argv, ++i, arg));
    else if (arg === "--port") args.port = Number(required(argv, ++i, arg));
    else if (arg === "--timeout-ms") args.timeoutMs = Number(required(argv, ++i, arg));
    else if (arg === "--min-changed-pixel-ratio") {
      args.minChangedPixelRatio = Number(required(argv, ++i, arg));
    } else if (arg === "--min-visible-pixel-ratio") {
      args.minVisiblePixelRatio = Number(required(argv, ++i, arg));
    } else if (arg === "--allow-environment-failure") {
      args.allowEnvironmentFailure = true;
    }
    else if (arg === "--help" || arg === "-h") {
      console.log(`Usage:
  node design-runs/game-assets-20260520/tools/run-kagura-runtime-smoke.mjs --contract <path> [options]

Options:
  --contract <path>                  Kagura handoff JSON contract
  --out <path>                       JSON report path
  --screenshot <path>                Optional canvas screenshot path
  --calibration-contract <path>      Optional known-good Kagura handoff contract
  --kagura-repo <path>               Local mizchi/kagura repo (default: sibling ghq checkout)
  --port <n>                         Kagura dev server port (default: 8765)
  --timeout-ms <n>                   Startup/browser timeout (default: 90000)
  --min-changed-pixel-ratio <n>      Minimum non-flat canvas ratio (default: 0.01)
  --min-visible-pixel-ratio <n>      Minimum non-dark canvas ratio (default: 0.03)
  --allow-environment-failure        Exit 0 when target and calibration both fail
`);
      process.exit(0);
    } else {
      throw new Error(`Unknown argument: ${arg}`);
    }
  }
  if (!args.contract) throw new Error("--contract is required");
  if (!args.out) args.out = args.contract.replace(/\.json$/, ".kagura-runtime-smoke.json");
  return args;
}

function required(argv, index, flag) {
  const value = argv[index];
  if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
  return value;
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  const report = await runKaguraRuntimeSmoke(args);
  let calibrationOk = null;
  if (args.calibrationContract) {
    const calibration = await runKaguraRuntimeSmoke({
      ...args,
      contract: args.calibrationContract,
      out: "",
      screenshot: "",
      calibrationContract: "",
      port: args.port + 1,
    });
    calibrationOk = calibration.ok;
    report.calibration = summarizeCalibration(calibration);
  }
  report.outcome = classifyRuntimeOutcome({
    targetOk: report.ok,
    calibrationOk,
  });
  report.environmentLikelyBroken = report.outcome.environmentLikelyBroken;
  report.assetLikelyBroken = report.outcome.assetLikelyBroken;
  await writeFile(args.out, `${JSON.stringify(report, null, 2)}\n`);
  console.log(`${report.outcome.status.toUpperCase()} ${relative(repoRoot, args.out)}`);
  if (shouldFailProcess(report.outcome, args)) process.exit(1);
}

function summarizeCalibration(report) {
  return {
    ok: report.ok,
    contract: report.contract,
    assetId: report.assetId,
    checks: report.checks,
    warnings: report.warnings,
    failures: report.failures,
  };
}

export async function runKaguraRuntimeSmoke(args) {
  const contract = JSON.parse(await readFile(args.contract, "utf8"));
  const contractDir = dirname(args.contract);
  const handoff = contract.kaguraHandoff;
  const failures = [];
  const warnings = [];
  const checks = [];
  if (!handoff || typeof handoff !== "object") {
    failures.push({ path: "kaguraHandoff", reason: "expected object" });
    return buildReport(args, contract, checks, warnings, failures);
  }
  const modelPath = resolve(contractDir, handoff.modelPath ?? "");
  if (!existsSync(modelPath)) failures.push({ path: "kaguraHandoff.modelPath", reason: "model file is missing" });
  if (!existsSync(resolve(args.kaguraRepo, "scripts/dev-server.mjs"))) {
    failures.push({ path: "kaguraRepo", reason: "missing scripts/dev-server.mjs" });
  }
  if (failures.length > 0) return buildReport(args, contract, checks, warnings, failures);

  let assetServer;
  let kaguraProc;
  let browser;
  const serverLogs = [];
  const consoleMessages = [];
  const pageErrors = [];
  try {
    const modelBuffer = await readFile(modelPath);
    assetServer = await startAssetServer(modelPath);
    kaguraProc = startKaguraDevServer(args.kaguraRepo, args.port, serverLogs);
    const baseUrl = `http://127.0.0.1:${args.port}/`;
    await waitForHttp(baseUrl, args.timeoutMs);

    browser = await chromium.launch({
      headless: true,
      args: chromiumLaunchArgs(),
    });
    const page = await browser.newPage({ viewport: { width: 640, height: 480 } });
    await page.addInitScript(() => {
      globalThis.__kaguraVrtReadbackEnabled = true;
    });
    page.on("console", (msg) => {
      const text = msg.text();
      if (["error", "warning", "warn"].includes(msg.type())) consoleMessages.push({ type: msg.type(), text });
    });
    page.on("pageerror", (error) => pageErrors.push(error.message));

    const assetUrl = assetServer.url;
    const viewerUrl = `${baseUrl}?asset=${encodeURIComponent(assetUrl)}&hud=false`;
    await page.goto(viewerUrl, { waitUntil: "domcontentloaded", timeout: args.timeoutMs });
    await page.waitForFunction(
      () => globalThis.__kaguraWebRuntime?.webgpu?.presentScheduled !== undefined,
      undefined,
      { timeout: args.timeoutMs },
    );
    const frameSignal = await waitForFrameSignal(page, Math.min(5_000, args.timeoutMs));
    await waitForAnimationFrames(page, 2);
    const readback = await waitForRuntimeReadback(page, Math.min(5_000, args.timeoutMs));
    const runtimeState = await readClipPlaybackState(page);
    const clipPlayback = createClipPlaybackStatus(handoff.animationClips ?? [], runtimeState);
    const expectedPose = clipPlayback.status === "verified"
      ? sampleGltfClipPoseFromGlb(modelBuffer, clipPlayback.playedClip, clipPlayback.timeSeconds ?? 0)
      : null;
    const posePlayback = createPosePlaybackStatus({
      clipPlayback,
      runtimeState,
      expectedPose,
    });
    const canvasInfo = await page.locator("#app").evaluate((canvas) => ({
      tagName: canvas.tagName,
      width: canvas.width,
      height: canvas.height,
      clientWidth: canvas.clientWidth,
      clientHeight: canvas.clientHeight,
    }));
    const screenshot = await page.locator("#app").screenshot({ path: args.screenshot || undefined });
    const frame = readback.ok ? analyzeReadbackFrame(readback.summary) : analyzePngFrame(screenshot);

    checks.push({
      id: "kagura-dev-server",
      status: "pass",
      example: "gltf_viewer",
    });
    checks.push({
      id: "runtime-load",
      status: "pass",
      canvas: canvasInfo,
    });
    checks.push({
      id: "runtime-frame-signal",
      status: frameSignal.ok ? "pass" : "fail",
      lastCompletedFrameMs: frameSignal.lastCompletedFrameMs,
      lastRenderCpuMs: frameSignal.lastRenderCpuMs,
      lastRenderSubmitCpuMs: frameSignal.lastRenderSubmitCpuMs,
      readback: readback.ok ? readback.summary : null,
    });
    if (!frameSignal.ok) {
      failures.push({
        path: "runtime-frame-signal",
        reason: "Kagura did not report a completed or submitted render frame",
      });
    }
    checks.push({
      id: "runtime-frame",
      status: frame.nonDominantPixelRatio >= args.minChangedPixelRatio &&
        frame.visiblePixelRatio >= args.minVisiblePixelRatio
        ? "pass"
        : "fail",
      nonDominantPixelRatio: round(frame.nonDominantPixelRatio),
      visiblePixelRatio: round(frame.visiblePixelRatio),
      dominantPixelRatio: round(frame.dominantPixelRatio),
      source: frame.source,
      width: frame.width,
      height: frame.height,
    });
    if (frame.nonDominantPixelRatio < args.minChangedPixelRatio) {
      failures.push({
        path: "runtime-frame",
        reason: `canvas is too flat: nonDominantPixelRatio=${round(frame.nonDominantPixelRatio)}`,
      });
    }
    if (frame.visiblePixelRatio < args.minVisiblePixelRatio) {
      failures.push({
        path: "runtime-frame",
        reason: `canvas is too dark/empty: visiblePixelRatio=${round(frame.visiblePixelRatio)}`,
      });
    }
    checks.push({
      id: "runtime-clip-playback",
      status: clipPlayback.status === "verified"
        ? "pass"
        : clipPlayback.status === "not-applicable"
          ? "info"
          : clipPlayback.status === "pending-viewer-support"
            ? "info"
            : "fail",
      clipPlayback,
    });
    checks.push({
      id: "runtime-pose-playback",
      status: posePlayback.status === "verified"
        ? "pass"
        : posePlayback.status === "not-applicable" || posePlayback.status === "pending-viewer-support"
          ? "info"
          : "fail",
      posePlayback,
    });
    if (clipPlayback.status === "pending-viewer-support") {
      warnings.push({
        path: "kaguraHandoff.animationClips",
        reason: clipPlayback.reason,
      });
    } else if (["missing-clips", "unexpected-clip", "available-not-playing"].includes(clipPlayback.status)) {
      failures.push({
        path: "runtime.clipPlayback",
        reason: clipPlayback.reason,
      });
    }
    if (posePlayback.status === "pending-viewer-support") {
      warnings.push({
        path: "runtime.posePlayback",
        reason: posePlayback.reason,
      });
    } else if (["missing-node-transform", "mismatch"].includes(posePlayback.status)) {
      failures.push({
        path: "runtime.posePlayback",
        reason: posePlayback.reason,
        mismatches: posePlayback.mismatches,
      });
    }
  } catch (error) {
    failures.push({ path: "runtime", reason: error instanceof Error ? error.message : String(error) });
  } finally {
    if (browser) await browser.close().catch(() => {});
    if (kaguraProc) stopChild(kaguraProc);
    if (assetServer) await assetServer.close();
  }
  if (serverLogs.length > 0) {
    checks.push({
      id: "kagura-server-log-tail",
      status: "info",
      lines: serverLogs
        .slice(-10)
        .map((line) => sanitizeServerLogLine(line, { kaguraRepo: args.kaguraRepo })),
    });
  }
  if (pageErrors.length > 0) {
    failures.push({ path: "pageerror", reason: pageErrors.join("\n") });
  }
  if (consoleMessages.length > 0) {
    warnings.push({
      path: "browser-console",
      reason: `${consoleMessages.length} warning/error console messages`,
      messages: consoleMessages,
    });
    const gpuValidationWarnings = consoleMessages.filter((message) => isGpuValidationWarning(message.text));
    if (gpuValidationWarnings.length > 0) {
      failures.push({
        path: "browser-console",
        reason: `${gpuValidationWarnings.length} WebGPU validation warning(s)`,
      });
    }
  }
  return buildReport(args, contract, checks, warnings, failures);
}

function buildReport(args, contract, checks, warnings, failures) {
  const handoff = contract.kaguraHandoff;
  return {
    ok: failures.length === 0,
    contract: relative(repoRoot, args.contract),
    assetId: contract.asset?.id ?? null,
    kaguraRepo: relative(repoRoot, args.kaguraRepo),
    runtime: {
      example: "gltf_viewer",
      model: handoff?.modelPath ?? null,
      animationClips: handoff?.animationClips ?? [],
      clipPlayback: clipPlaybackFromChecks(checks) ??
        createClipPlaybackStatus(handoff?.animationClips ?? [], null),
      posePlayback: posePlaybackFromChecks(checks) ?? {
        status: "not-applicable",
        clip: null,
        timeSeconds: null,
        comparedNodeCount: 0,
        maxDelta: null,
        mismatches: [],
        reason: "runtime pose playback was not checked",
      },
    },
    checks,
    warnings,
    failures,
  };
}

function startAssetServer(modelPath) {
  return new Promise((resolvePromise, reject) => {
    const server = createServer(async (req, res) => {
      try {
        const url = new URL(req.url ?? "/", "http://127.0.0.1");
        if (url.pathname !== "/asset.glb") {
          res.writeHead(404).end("not found");
          return;
        }
        const body = await readFile(modelPath);
        res.writeHead(200, {
          "content-type": "model/gltf-binary",
          "access-control-allow-origin": "*",
          "cache-control": "no-store",
        });
        res.end(body);
      } catch (error) {
        res.writeHead(500).end(error instanceof Error ? error.message : String(error));
      }
    });
    server.once("error", reject);
    server.listen(0, "127.0.0.1", () => {
      const address = server.address();
      if (!address || typeof address === "string") {
        reject(new Error("failed to bind asset server"));
        return;
      }
      resolvePromise({
        url: `http://127.0.0.1:${address.port}/asset.glb`,
        close: () => new Promise((done) => server.close(done)),
      });
    });
  });
}

function startKaguraDevServer(kaguraRepo, port, logs) {
  const child = spawn(process.execPath, ["scripts/dev-server.mjs", "gltf_viewer"], {
    cwd: kaguraRepo,
    env: { ...process.env, PORT: String(port) },
    stdio: ["ignore", "pipe", "pipe"],
  });
  child.stdout.on("data", (chunk) => collectLogLines(logs, chunk));
  child.stderr.on("data", (chunk) => collectLogLines(logs, chunk));
  return child;
}

function collectLogLines(logs, chunk) {
  const text = chunk.toString("utf8");
  for (const line of text.split(/\r?\n/)) {
    if (line.trim()) logs.push(line);
  }
  while (logs.length > 30) logs.shift();
}

function stopChild(child) {
  if (child.exitCode != null || child.signalCode != null) return;
  child.kill("SIGTERM");
}

async function waitForHttp(url, timeoutMs) {
  const started = Date.now();
  let lastError = null;
  while (Date.now() - started < timeoutMs) {
    try {
      const res = await fetch(url);
      if (res.ok) return;
      lastError = new Error(`HTTP ${res.status}`);
    } catch (error) {
      lastError = error;
    }
    await delay(250);
  }
  throw new Error(`timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}

function delay(ms) {
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
}

function chromiumLaunchArgs() {
  return [
    "--enable-unsafe-webgpu",
    ...(process.env.KAGURA_RUNTIME_CHROMIUM_ARGS ?? "").split(/\s+/).filter(Boolean),
  ];
}

async function waitForFrameSignal(page, timeoutMs) {
  const deadline = Date.now() + timeoutMs;
  let lastState = await readFrameSignal(page);
  while (Date.now() < deadline) {
    if (lastState.ok) return lastState;
    await page.waitForTimeout(100);
    lastState = await readFrameSignal(page);
  }
  return lastState;
}

async function readFrameSignal(page) {
  return page.evaluate(() => {
    const gfx = globalThis.__kaguraGfx;
    const lastGpu = globalThis.__kaguraLastGpu;
    const lastCompletedFrameMs = Number(gfx?.lastCompletedFrameMs?.() ?? lastGpu?._lastCompletedFrameMs ?? 0);
    const lastRenderCpuMs = Number(gfx?.lastRenderCpuMs?.() ?? lastGpu?._lastRenderCpuMs ?? 0);
    const lastRenderSubmitCpuMs = Number(gfx?.lastRenderSubmitCpuMs?.() ?? lastGpu?._lastRenderSubmitCpuMs ?? 0);
    return {
      ok: lastCompletedFrameMs > 0 || lastRenderSubmitCpuMs > 0 || lastRenderCpuMs > 0,
      lastCompletedFrameMs,
      lastRenderCpuMs,
      lastRenderSubmitCpuMs,
    };
  });
}

async function waitForRuntimeReadback(page, timeoutMs) {
  const deadline = Date.now() + timeoutMs;
  let summary = await readRuntimeReadback(page);
  while (!summary && Date.now() < deadline) {
    await page.waitForTimeout(100);
    summary = await readRuntimeReadback(page);
  }
  return summary ? { ok: true, summary } : { ok: false, summary: null };
}

async function readRuntimeReadback(page) {
  return page.evaluate(() => globalThis.__kaguraVrtLastReadback ?? null);
}

async function readClipPlaybackState(page) {
  return page.evaluate(() => {
    const tupleOrUndefined = (value, size) => {
      if (!Array.isArray(value) || value.length !== size) return undefined;
      const tuple = value.map((item) => Number(item));
      return tuple.every(Number.isFinite) ? tuple : undefined;
    };
    const candidates = [
      globalThis.__kaguraRuntimeClipPlayback,
      globalThis.__kaguraGltfViewerAnimation,
      globalThis.__kaguraGltfViewer?.animation,
      globalThis.__kaguraGltfViewer?.clipPlayback,
    ];
    for (const candidate of candidates) {
      if (candidate && typeof candidate === "object") return {
        clips: Array.isArray(candidate.clips) ? candidate.clips : undefined,
        clipIds: Array.isArray(candidate.clipIds) ? candidate.clipIds : undefined,
        availableClips: Array.isArray(candidate.availableClips) ? candidate.availableClips : undefined,
        currentClip: candidate.currentClip ?? candidate.playedClip ?? candidate.clip ?? undefined,
        playedClip: candidate.playedClip ?? undefined,
        clip: candidate.clip ?? undefined,
        playing: candidate.playing,
        timeSeconds: Number(candidate.timeSeconds ?? candidate.time ?? candidate.currentTime),
        nodeTransforms: Array.isArray(candidate.nodeTransforms)
          ? candidate.nodeTransforms.map((node) => ({
              nodeIndex: Number(node?.nodeIndex),
              translation: tupleOrUndefined(node?.translation, 3),
              rotation: tupleOrUndefined(node?.rotation, 4),
              scale: tupleOrUndefined(node?.scale, 3),
            })).filter((node) => Number.isInteger(node.nodeIndex))
          : undefined,
      };
    }
    return null;
  });
}

function clipPlaybackFromChecks(checks) {
  return checks.find((check) => check.id === "runtime-clip-playback")?.clipPlayback ?? null;
}

function posePlaybackFromChecks(checks) {
  return checks.find((check) => check.id === "runtime-pose-playback")?.posePlayback ?? null;
}

async function waitForAnimationFrames(page, count) {
  await page.evaluate((frameCount) => new Promise((resolvePromise) => {
    let remaining = Math.max(0, frameCount | 0);
    const tick = () => {
      remaining -= 1;
      if (remaining <= 0) resolvePromise(undefined);
      else requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);
  }), count);
}

function analyzePngFrame(buffer) {
  const image = PNG.sync.read(buffer);
  const buckets = new Map();
  let visible = 0;
  const total = image.width * image.height;
  for (let i = 0; i < image.data.length; i += 4) {
    const r = image.data[i];
    const g = image.data[i + 1];
    const b = image.data[i + 2];
    const a = image.data[i + 3];
    const key = `${r >> 3},${g >> 3},${b >> 3},${a >> 3}`;
    buckets.set(key, (buckets.get(key) ?? 0) + 1);
    if (a > 0 && r + g + b > 72) visible++;
  }
  const dominant = Math.max(...buckets.values());
  const dominantPixelRatio = dominant / total;
  return {
    source: "canvas-screenshot",
    width: image.width,
    height: image.height,
    dominantPixelRatio,
    nonDominantPixelRatio: 1 - dominantPixelRatio,
    visiblePixelRatio: visible / total,
  };
}

function analyzeReadbackFrame(summary) {
  const visiblePixelRatio = Number(summary.nonDarkPixelRatio ?? 0);
  return {
    source: "webgpu-readback",
    width: Number(summary.width ?? 0),
    height: Number(summary.height ?? 0),
    dominantPixelRatio: 1 - visiblePixelRatio,
    nonDominantPixelRatio: visiblePixelRatio,
    visiblePixelRatio,
  };
}

function isGpuValidationWarning(text) {
  return /\[Invalid (Texture|TextureView|CommandBuffer)\]|startup error|No available adapters/i.test(text);
}

function round(value) {
  return Math.round(value * 10000) / 10000;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main().catch((error) => {
    console.error(error instanceof Error ? error.message : error);
    process.exit(1);
  });
}
