import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawn } from "node:child_process";

const root = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const appHtml = resolve(root, "kdl-wasm/web/app/virtual-controller.html");
const outputDir = resolve(root, "kdl-wasm/web/test-results/virtual-controller");
const chromeBin = process.env.CHROME_BIN ?? defaultChromeBin();
const targetUrl = process.argv[2] ?? pathToFileURL(appHtml).href;
const isRemote = /^https?:\/\//i.test(targetUrl);

mkdirSync(outputDir, { recursive: true });

const evidence = await verifyButtonFlow(targetUrl);
const evidencePath = join(outputDir, isRemote ? "button-evidence-public.json" : "button-evidence.json");
writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
console.log(JSON.stringify(evidence, null, 2));

async function verifyButtonFlow(url) {
  const browser = await launchChrome("buttons");
  try {
    const client = await connect(browser.websocketUrl);
    await client.send("Page.enable");
    await client.send("Runtime.enable");
    await client.send("Network.enable");
    await client.send("Emulation.setDeviceMetricsOverride", {
      width: 1440,
      height: 960,
      deviceScaleFactor: 1,
      mobile: false
    });
    await client.send("Page.navigate", { url });
    await waitForLoad(client);

    const initial = await waitForProbe(
      client,
      (probe) => probe.title === "ABB120 Virtual Controller"
        && probe.frameTotal > 0
        && probe.samplePeriodSec === 0.05
        && probe.stepRows > 0
        && probe.activeStepRows === 1
        && (!isRemote || probe.programOptions.length >= 10),
      "initial controller render",
      12_000
    );
    if (isRemote && initial.programOptions.length < 10) {
      throw new Error(`remote manifest did not populate enough programs: ${initial.programOptions.length}`);
    }

    await clickCommand(client, "load");
    const afterLoadClick = await sleepAndProbe(client, 120);
    if (!["load", "loading", "parsing", "ready"].includes(afterLoadClick.state)) {
      throw new Error(`Load did not show an active state, got ${afterLoadClick.state}`);
    }
    if (!afterLoadClick.log.includes(afterLoadClick.program)) {
      throw new Error(`Load did not write the active program to the log: ${afterLoadClick.log}`);
    }
    const afterParse = await waitForProbe(
      client,
      (probe) => probe.state === "ready"
        && probe.parser.includes("parsed")
        && probe.parserProgress === 1
        && probe.stepRows > 1
        && probe.frameRows === probe.sourceFrameTotal,
      "Load parse stream",
      4000
    );
    if (afterLoadClick.state === "parsing") {
      if (!afterLoadClick.parser.includes("parsing")) {
        throw new Error(`Load did not expose parser progress: ${afterLoadClick.parser}`);
      }
      if (afterLoadClick.frameRows >= afterParse.frameRows && afterParse.frameRows > 1) {
        throw new Error(`Trajectory did not grow during parsing: ${afterLoadClick.frameRows} -> ${afterParse.frameRows}`);
      }
    }

    await clickCommand(client, "run");
    const afterRun = await waitForProbe(
      client,
      (probe) => probe.state === "running" && probe.running,
      "Run command",
      1200
    );
    if (!afterRun.wait.includes("waiting") && !afterRun.wait.includes("satisfied")) {
      throw new Error(`Run did not update wait state: ${afterRun.wait}`);
    }
    assertProgramSteps(afterRun, "Run command");

    await sleep(700);
    await clickCommand(client, "pause");
    const afterPause = await waitForProbe(client, (probe) => probe.state === "paused", "Pause command", 1000);
    assertProgramSteps(afterPause, "Pause command");

    await clickCommand(client, "step");
    const afterStep = await waitForProbe(client, (probe) => probe.state === "paused", "Step command", 1000);
    const expectedStepFrame = afterPause.frameCurrent < afterPause.frameTotal
      ? afterPause.frameCurrent + 1
      : afterPause.frameCurrent;
    if (afterStep.frameCurrent !== expectedStepFrame) {
      throw new Error(`Step sample mismatch: expected ${expectedStepFrame}, got ${afterStep.frameCurrent}`);
    }
    assertProgramSteps(afterStep, "Step command");

    await clickCommand(client, "stop");
    const afterStop = await waitForProbe(client, (probe) => probe.state === "stopped", "Stop command", 1000);

    await clickCommand(client, "reset");
    const afterReset = await waitForProbe(
      client,
      (probe) => probe.state === "ready" && probe.frameCurrent === 1,
      "Reset command",
      1000
    );

    await clickCommand(client, "export");
    const afterExport = await sleepAndProbe(client, 150);
    if (!afterExport.log.includes("exported") && !afterExport.reports.includes("exported")) {
      throw new Error(`Export did not update log/report fields: ${afterExport.log} / ${afterExport.reports}`);
    }

    const fullSpecId = afterExport.programOptions.find((id) => id.startsWith("W2_99_"));
    let afterProgramSwitch = afterExport;
    if (fullSpecId) {
      await selectProgram(client, fullSpecId);
      afterProgramSwitch = await waitForProbe(
        client,
        (probe) => probe.program === fullSpecId
          && probe.state === "ready"
          && probe.sourceFrameTotal >= 20
          && probe.frameTotal >= 39
          && probe.stepRows >= 8
          && probe.activeStepRows === 1,
        `${fullSpecId} program switch`,
        isRemote ? 12_000 : 4_000
      );
      assertTcpAndJoints(afterProgramSwitch, fullSpecId);
    }

    const afterCacheWarm = await waitForProbe(
      client,
      (probe) => !isRemote || probe.storageState.writes >= 3,
      "OPFS cache warmup",
      isRemote ? 12_000 : 1500
    );
    const offlineCache = await verifyOfflineCache(client, afterCacheWarm);

    await client.close();
    return {
      targetUrl: url,
      chrome: chromeBin,
      initial,
      afterLoadClick,
      afterRun,
      afterPause,
      afterStep,
      afterStop,
      afterReset,
      afterExport,
      afterProgramSwitch,
      offlineCache
    };
  } finally {
    browser.close();
  }
}

async function verifyOfflineCache(client, beforeOffline) {
  if (!isRemote) {
    return { skipped: true, reason: "local file target does not require network-offline cache proof" };
  }

  const cacheProgram = beforeOffline.program
    ?? beforeOffline.programOptions.find((id) => id === "W2_60_IOWaitPulse")
    ?? beforeOffline.programOptions[0];
  if (!cacheProgram) throw new Error("No program available for offline cache check");

  await evaluate(client, `() => {
    const app = window.__virtualController;
    app.programCache.clear();
    app.programLoads.clear();
    app.assetTextCache.clear();
    return true;
  }`);
  await client.send("Network.emulateNetworkConditions", {
    offline: true,
    latency: 0,
    downloadThroughput: 0,
    uploadThroughput: 0
  });
  await selectProgram(client, cacheProgram);
  const offline = await waitForProbe(
    client,
    (probe) => probe.program === cacheProgram
      && probe.state === "ready"
      && probe.sourceFrameTotal >= 4
      && probe.storageState.hits > beforeOffline.storageState.hits,
    "offline OPFS program load",
    5000
  );
  if (offline.storageState.mode !== "OPFS") {
    throw new Error(`Expected OPFS mode during offline load, got ${offline.storageState.mode}`);
  }
  assertTcpAndJoints(offline, cacheProgram);
  return {
    program: cacheProgram,
    mode: offline.storageState.mode,
    hitsBefore: beforeOffline.storageState.hits,
    hitsAfter: offline.storageState.hits,
    frame: offline.frame,
    storage: offline.storage
  };
}

async function clickCommand(client, command) {
  return evaluate(client, `() => {
    const button = document.querySelector("[data-command='${command}']");
    if (!button) throw new Error("Missing command ${command}");
    button.click();
    return true;
  }`);
}

async function selectProgram(client, programId) {
  return evaluate(client, `() => {
    const select = document.querySelector("[data-program-select]");
    if (!select) throw new Error("Missing program select");
    select.value = ${JSON.stringify(programId)};
    select.dispatchEvent(new Event("change", { bubbles: true }));
    return select.value;
  }`);
}

function assertTcpAndJoints(probe, label) {
  if (!/\[[^\]]+\]/.test(probe.joints) || probe.joints.split(",").length < 6) {
    throw new Error(`${label}: joints field is not a 6-axis vector: ${probe.joints}`);
  }
  if (!probe.tcp.includes("pos [") || !probe.tcp.includes(" q [")) {
    throw new Error(`${label}: TCP field is incomplete: ${probe.tcp}`);
  }
  if (probe.frameRows !== probe.sourceFrameTotal) {
    throw new Error(`${label}: frame table rows ${probe.frameRows} != source frame total ${probe.sourceFrameTotal}`);
  }
  if (probe.samplePeriodSec !== 0.05) {
    throw new Error(`${label}: expected 50ms sample period, got ${probe.samplePeriodSec}`);
  }
  assertProgramSteps(probe, label);
}

function assertProgramSteps(probe, label) {
  if (probe.stepRows < 1) {
    throw new Error(`${label}: program step table did not render rows`);
  }
  if (probe.activeStepRows !== 1) {
    throw new Error(`${label}: expected one active program step, got ${probe.activeStepRows}`);
  }
  if (!probe.stepStatus.includes("/") || !probe.stepStatus.includes("line")) {
    throw new Error(`${label}: step status is incomplete: ${probe.stepStatus}`);
  }
  if (!probe.activeStepText.includes("@50ms") && !probe.activeStepText.includes("line")) {
    throw new Error(`${label}: active step does not expose execution detail: ${probe.activeStepText}`);
  }
}

async function sleepAndProbe(client, ms) {
  await sleep(ms);
  return evaluate(client, pageProbeSource());
}

async function waitForProbe(client, predicate, label, timeoutMs) {
  const started = Date.now();
  let lastProbe;
  while (Date.now() - started < timeoutMs) {
    lastProbe = await evaluate(client, pageProbeSource());
    if (predicate(lastProbe)) return lastProbe;
    await sleep(100);
  }
  throw new Error(`${label} timed out. Last probe: ${JSON.stringify(lastProbe)}`);
}

function pageProbeSource() {
  return `() => {
    const field = (name) => document.querySelector("[data-field='" + name + "']")?.textContent?.trim() ?? "";
    const frame = field("frame");
    const frameMatch = frame.match(/(\\d+)\\s*\\/\\s*(\\d+)/);
    const app = window.__virtualController;
    return {
      title: document.title,
      state: field("state"),
      program: field("program"),
      wait: field("wait"),
      log: field("log"),
      reports: field("reports"),
      storage: field("storage"),
      joints: field("joints"),
      tcp: field("tcp"),
      frame,
      frameCurrent: frameMatch ? Number(frameMatch[1]) : 0,
      frameTotal: frameMatch ? Number(frameMatch[2]) : 0,
      frameRows: document.querySelectorAll("[data-frame-table] tr").length,
      frameTime: field("frame-time"),
      parser: field("parser"),
      parserProgress: Number(document.querySelector("[data-parser-progress]")?.value ?? 0),
      stepStatus: field("step-status"),
      stepRows: document.querySelectorAll("[data-step-table] tr").length,
      activeStepRows: document.querySelectorAll("[data-step-table] tr[data-step-state='active']").length,
      activeStepText: document.querySelector("[data-step-table] tr[data-step-state='active']")?.textContent?.trim() ?? "",
      programOptions: Array.from(document.querySelectorAll("[data-program-select] option")).map((item) => item.value),
      running: document.querySelector(".workbench")?.dataset.running === "true",
      playbackTime: Number(app?.playbackTime ?? 0),
      samplePeriodSec: Number(app?.samplePeriodSec ?? 0),
      sampleIndex: Number(app?.currentSampleIndex ?? 0),
      sourceFrameIndex: Number(app?.currentFrameIndex ?? 0),
      sourceFrameTotal: Array.isArray(app?.programCache?.get?.(app?.selectedProgramId)?.trajectory?.frames)
        ? app.programCache.get(app.selectedProgramId).trajectory.frames.length
        : document.querySelectorAll("[data-frame-table] tr").length,
      storageState: app?.storage ? { ...app.storage } : {},
      parseStream: app?.parseStream ? { ...app.parseStream, timer: Boolean(app.parseStream.timer) } : {},
      errors: Array.isArray(window.__vcErrors) ? [...window.__vcErrors] : []
    };
  }`;
}

function launchChrome(name) {
  const userDataDir = mkdtempSync(join(tmpdir(), `kdl-vc-${name}-`));
  const child = spawn(chromeBin, [
    "--headless=new",
    "--disable-gpu",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--ignore-certificate-errors",
    "--remote-debugging-port=0",
    `--user-data-dir=${userDataDir}`,
    "about:blank"
  ], { stdio: ["ignore", "pipe", "pipe"] });

  let log = "";
  let settled = false;

  const cleanup = () => {
    child.kill("SIGTERM");
    setTimeout(() => {
      rmSync(userDataDir, {
        recursive: true,
        force: true,
        maxRetries: 5,
        retryDelay: 100
      });
    }, 250).unref();
  };

  return new Promise((resolveLaunch, reject) => {
    const timeout = setTimeout(() => {
      if (!settled) {
        settled = true;
        cleanup();
        reject(new Error(`Chrome did not expose DevTools endpoint. ${log}`));
      }
    }, 15_000);

    const onData = (chunk) => {
      log += chunk.toString();
      const match = log.match(/DevTools listening on (ws:\/\/[^\s]+)/);
      if (!match || settled) return;
      settled = true;
      clearTimeout(timeout);
      findPageWebsocket(match[1]).then((pageWebsocketUrl) => {
        resolveLaunch({
          websocketUrl: pageWebsocketUrl,
          close: cleanup
        });
      }, (error) => {
        cleanup();
        reject(error);
      });
    };

    child.stdout.on("data", onData);
    child.stderr.on("data", onData);
    child.on("error", (error) => {
      if (!settled) {
        settled = true;
        clearTimeout(timeout);
        cleanup();
        reject(error);
      }
    });
    child.on("exit", (code) => {
      if (!settled) {
        settled = true;
        clearTimeout(timeout);
        cleanup();
        reject(new Error(`${basename(chromeBin)} exited early with ${code}. ${log}`));
      }
    });
  });
}

async function findPageWebsocket(browserWebsocketUrl) {
  const endpoint = new URL(browserWebsocketUrl);
  const baseUrl = `http://${endpoint.host}`;
  const started = Date.now();
  let lastError;

  while (Date.now() - started < 5000) {
    try {
      const response = await fetch(`${baseUrl}/json/list`);
      const targets = await response.json();
      const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl);
      if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl;
    } catch (error) {
      lastError = error;
    }
    await sleep(100);
  }

  throw new Error(`Could not find Chrome page target. ${lastError instanceof Error ? lastError.message : ""}`);
}

function connect(websocketUrl) {
  const socket = new WebSocket(websocketUrl);
  let nextId = 1;
  const pending = new Map();

  socket.addEventListener("message", (event) => {
    const message = JSON.parse(event.data.toString());
    if (!message.id) return;
    const request = pending.get(message.id);
    if (!request) return;
    pending.delete(message.id);
    if (message.error) {
      request.reject(new Error(`${message.error.code}: ${message.error.message}`));
    } else {
      request.resolve(message.result ?? {});
    }
  });

  return new Promise((resolveClient, reject) => {
    socket.addEventListener("open", () => {
      resolveClient({
        send(method, params = {}) {
          const id = nextId++;
          socket.send(JSON.stringify({ id, method, params }));
          return new Promise((resolveSend, rejectSend) => {
            pending.set(id, { resolve: resolveSend, reject: rejectSend });
          });
        },
        close() {
          socket.close();
        }
      });
    }, { once: true });
    socket.addEventListener("error", reject, { once: true });
  });
}

function waitForLoad(client) {
  return new Promise((resolveLoad, reject) => {
    const started = Date.now();
    const poll = async () => {
      try {
        const ready = await evaluate(client, "() => document.readyState");
        if (ready === "complete") {
          resolveLoad();
          return;
        }
        if (Date.now() - started > 10_000) {
          reject(new Error("Timed out waiting for document.readyState complete"));
          return;
        }
        setTimeout(poll, 100);
      } catch (error) {
        reject(error);
      }
    };
    poll();
  });
}

async function evaluate(client, expression) {
  const result = await client.send("Runtime.evaluate", {
    expression: `(${expression})()`,
    awaitPromise: true,
    returnByValue: true
  });
  if (result.exceptionDetails) {
    throw new Error(result.exceptionDetails.text ?? "Runtime.evaluate failed");
  }
  return result.result.value;
}

function sleep(ms) {
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}

function defaultChromeBin() {
  if (process.platform !== "win32") return "/usr/bin/google-chrome";
  const candidates = [
    "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
    "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
    process.env.LOCALAPPDATA
      ? join(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe")
      : "",
    "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
  ];
  return candidates.find((candidate) => candidate && existsSync(candidate)) ?? "chrome.exe";
}
