import { existsSync, mkdtempSync, mkdirSync, 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();

mkdirSync(outputDir, { recursive: true });

const desktop = await verifyViewport({
  name: "desktop",
  width: 1440,
  height: 960,
  deviceScaleFactor: 1,
  mobile: false,
  screenshotPath: join(outputDir, "virtual-controller-desktop.png")
});
const mobile = await verifyViewport({
  name: "mobile",
  width: 390,
  height: 844,
  deviceScaleFactor: 2,
  mobile: true,
  screenshotPath: join(outputDir, "virtual-controller-mobile.png")
});

const evidence = {
  app: appHtml,
  chrome: chromeBin,
  desktop,
  mobile
};
writeFileSync(join(outputDir, "evidence.json"), `${JSON.stringify(evidence, null, 2)}\n`);
console.log(JSON.stringify(evidence, null, 2));

async function verifyViewport(viewport) {
  const browser = await launchChrome(viewport);
  try {
    const client = await connect(browser.websocketUrl);
    await client.send("Page.enable");
    await client.send("Runtime.enable");
    await client.send("Emulation.setDeviceMetricsOverride", {
      width: viewport.width,
      height: viewport.height,
      deviceScaleFactor: viewport.deviceScaleFactor,
      mobile: viewport.mobile
    });
    await client.send("Page.navigate", { url: pathToFileURL(appHtml).href });
    await waitForLoad(client);

    const initial = await waitForProbe(
      client,
      (probe) => probe.parserProgress === 1 && !probe.parser.includes("parsing"),
      "initial parse",
      4000
    );
    assertProbe(initial, viewport.name, "initial");

    await evaluate(client, clickCommandSource("load"));
    await evaluate(client, clickCommandSource("run"));
    await evaluate(client, clickIoSource());
    const afterRun = await waitForProbe(client, (probe) => probe.state === "running", "run", 1200);
    assertProbe(afterRun, viewport.name, "after run");
    if (afterRun.state !== "running") {
      throw new Error(`${viewport.name}: expected state running, got ${afterRun.state}`);
    }
    if (!afterRun.running) {
      throw new Error(`${viewport.name}: data-running was not set after Run`);
    }
    if (!afterRun.wait.includes("satisfied")) {
      throw new Error(`${viewport.name}: expected DI wait to be satisfied, got ${afterRun.wait}`);
    }

    const screenshot = await client.send("Page.captureScreenshot", {
      format: "png",
      captureBeyondViewport: false
    });
    writeFileSync(viewport.screenshotPath, Buffer.from(screenshot.data, "base64"));
    const pngBytes = Buffer.byteLength(screenshot.data, "base64");
    if (pngBytes < 10_000) {
      throw new Error(`${viewport.name}: screenshot is unexpectedly small (${pngBytes} bytes)`);
    }

    await client.close();
    return {
      viewport: {
        width: viewport.width,
        height: viewport.height,
        deviceScaleFactor: viewport.deviceScaleFactor,
        mobile: viewport.mobile
      },
      screenshot: viewport.screenshotPath,
      screenshotBytes: pngBytes,
      state: afterRun.state,
      parser: afterRun.parser,
      wait: afterRun.wait,
      trace: afterRun.trace,
      visiblePanels: afterRun.visiblePanels
    };
  } finally {
    browser.close();
  }
}

function launchChrome(viewport) {
  const userDataDir = mkdtempSync(join(tmpdir(), `kdl-vc-${viewport.name}-`));
  const child = spawn(chromeBin, [
    "--headless=new",
    "--disable-gpu",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--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 new Promise((resolveRetry) => setTimeout(resolveRetry, 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 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 new Promise((resolveRetry) => setTimeout(resolveRetry, 100));
  }
  throw new Error(`${label} timed out. Last probe: ${JSON.stringify(lastProbe)}`);
}

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 pageProbeSource() {
  return `() => {
    const field = (name) => document.querySelector("[data-field='" + name + "']")?.textContent?.trim() ?? "";
    const box = (selector) => {
      const item = document.querySelector(selector);
      if (!item) return null;
      const rect = item.getBoundingClientRect();
      return { width: rect.width, height: rect.height, top: rect.top, left: rect.left };
    };
    const visiblePanels = [".command-bar", ".object-tree", ".viewport", ".pendant", ".editor", ".bottom-panel"]
      .map((selector) => ({ selector, box: box(selector) }))
      .filter((item) => item.box && item.box.width > 0 && item.box.height > 0)
      .map((item) => item.selector);
    return {
      title: document.title,
      state: field("state"),
      wait: field("wait"),
      queue: field("queue"),
      trace: field("trace"),
      parser: field("parser"),
      parserProgress: Number(document.querySelector("[data-parser-progress]")?.value ?? 0),
      editor: field("editor"),
      stepRows: document.querySelectorAll("[data-step-table] tr").length,
      activeStepRows: document.querySelectorAll("[data-step-table] tr[data-step-state='active']").length,
      stepStatus: field("step-status"),
      running: document.querySelector(".workbench")?.dataset.running === "true",
      visiblePanels,
      viewport: box(".viewport"),
      bodyScrollWidth: document.documentElement.scrollWidth,
      clientWidth: document.documentElement.clientWidth
    };
  }`;
}

function clickCommandSource(command) {
  return `() => {
    document.querySelector("[data-command='${command}']").click();
    return document.querySelector(".workbench")?.dataset.lastCommand;
  }`;
}

function clickIoSource() {
  return `() => {
    document.querySelector("[data-io='di1']").click();
    return document.querySelector("[data-field='wait']")?.textContent;
  }`;
}

function assertProbe(probe, viewportName, phase) {
  if (probe.title !== "ABB120 Virtual Controller") {
    throw new Error(`${viewportName}/${phase}: unexpected title ${probe.title}`);
  }
  const required = [".command-bar", ".object-tree", ".viewport", ".pendant", ".editor", ".bottom-panel"];
  for (const selector of required) {
    if (!probe.visiblePanels.includes(selector)) {
      throw new Error(`${viewportName}/${phase}: ${selector} is not visible`);
    }
  }
  if (!probe.editor.includes("proc main") && !probe.editor.includes("MoveJ") && !probe.editor.includes("PTP")) {
    throw new Error(`${viewportName}/${phase}: editor source did not render`);
  }
  if (probe.stepRows < 1 || probe.activeStepRows !== 1 || !probe.stepStatus.includes("line")) {
    throw new Error(`${viewportName}/${phase}: program steps did not render correctly`);
  }
  if (!probe.viewport || probe.viewport.width < 100 || probe.viewport.height < 100) {
    throw new Error(`${viewportName}/${phase}: viewport is too small`);
  }
  if (probe.bodyScrollWidth > probe.clientWidth + 2) {
    throw new Error(`${viewportName}/${phase}: horizontal overflow detected`);
  }
}

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";
}
