#!/usr/bin/env node
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";

import {
  AUDIT_SHAPE_FIELDS,
  EVIDENCE_SHAPE_FIELDS
} from "../internal/sim/l2-runtime.mjs";
import { ERROR_CODES } from "../internal/protocol/index.mjs";

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const children = [];

function logOk(name) {
  process.stdout.write(`[l2-runtime-smoke] ok ${name}\n`);
}

function freePort() {
  return new Promise((resolve, reject) => {
    const server = net.createServer();
    server.on("error", reject);
    server.listen(0, "127.0.0.1", () => {
      const address = server.address();
      const port = typeof address === "object" && address ? address.port : null;
      server.close((error) => {
        if (error) {
          reject(error);
        } else {
          resolve(port);
        }
      });
    });
  });
}

function startNode(relativeScript, env = {}) {
  const child = spawn(process.execPath, [relativeScript], {
    cwd: repoRoot,
    env: {
      ...process.env,
      ...env
    },
    stdio: ["ignore", "pipe", "pipe"]
  });
  const output = {
    stdout: "",
    stderr: ""
  };

  child.stdout.setEncoding("utf8");
  child.stderr.setEncoding("utf8");
  child.stdout.on("data", (chunk) => {
    output.stdout += chunk;
  });
  child.stderr.on("data", (chunk) => {
    output.stderr += chunk;
  });

  const record = { child, relativeScript, output };
  children.push(record);
  return record;
}

async function stopNode(record) {
  const { child } = record;
  if (child.exitCode !== null || child.signalCode !== null) {
    return;
  }

  await new Promise((resolve) => {
    const killTimer = setTimeout(() => {
      child.kill("SIGKILL");
    }, 1500);
    child.once("exit", () => {
      clearTimeout(killTimer);
      resolve();
    });
    child.kill("SIGTERM");
  });
}

async function requestJson(url, options = {}) {
  const response = await fetch(url, {
    ...options,
    headers: {
      "content-type": "application/json",
      ...(options.headers ?? {})
    }
  });
  const text = await response.text();
  const body = text ? JSON.parse(text) : null;
  return { response, body };
}

async function waitForHttp(url, record) {
  const startedAt = Date.now();
  let lastError;

  while (Date.now() - startedAt < 5000) {
    if (record.child.exitCode !== null) {
      throw new Error(
        `${record.relativeScript} exited before readiness\nstdout:\n${record.output.stdout}\nstderr:\n${record.output.stderr}`
      );
    }

    try {
      const { response } = await requestJson(url);
      if (response.ok) {
        return;
      }
      lastError = new Error(`HTTP ${response.status}`);
    } catch (error) {
      lastError = error;
    }

    await new Promise((resolve) => setTimeout(resolve, 75));
  }

  throw new Error(
    `${record.relativeScript} did not become ready at ${url}: ${lastError?.message ?? "timeout"}\nstdout:\n${record.output.stdout}\nstderr:\n${record.output.stderr}`
  );
}

function assertShape(record, fields, name) {
  assert.ok(record && typeof record === "object", `${name} must be an object`);
  for (const field of fields) {
    assert.ok(Object.hasOwn(record, field), `${name}.${field} is required`);
  }
}

function assertBoxStatus(status, { boxId, resourceId }) {
  assert.equal(status.serviceId, "hwlab-box-simu");
  assert.equal(status.boxId, boxId);
  assert.equal(status.resource.resourceId, resourceId);
  assert.equal(status.environment, "dev");
  assert.equal(status.constraints.crossDevicePropagation, "patch-panel-only");
  assert.equal(status.constraints.localLoopbackEnabled, false);

  for (const port of ["AI1", "AO1", "DI1", "DO1", "FREQ1"]) {
    assert.ok(status.ports[port], `${boxId} has ${port}`);
    assert.equal(status.ports[port].internalPortApi, true, `${boxId} ${port} internal API`);
  }
}

function assertGatewayStatus(status, { gatewayId, resourceIds }) {
  assert.equal(status.serviceId, "hwlab-gateway-simu");
  assert.equal(status.gatewayId, gatewayId);
  assert.equal(status.session.gatewayId, gatewayId);
  assert.equal(status.session.status, "connected");
  assert.equal(status.session.environment, "dev");
  assert.deepEqual(status.boxes, resourceIds);
}

async function main() {
  const ports = {
    boxA: await freePort(),
    boxB: await freePort(),
    gatewayA: await freePort(),
    gatewayB: await freePort()
  };

  const base = {
    boxA: `http://127.0.0.1:${ports.boxA}`,
    boxB: `http://127.0.0.1:${ports.boxB}`,
    gatewayA: `http://127.0.0.1:${ports.gatewayA}`,
    gatewayB: `http://127.0.0.1:${ports.gatewayB}`
  };

  const boxA = startNode("cmd/hwlab-box-simu/main.mjs", {
    PORT: String(ports.boxA),
    HWLAB_BOX_ID: "box-a",
    HWLAB_BOX_RESOURCE_ID: "res_box-a",
    HWLAB_GATEWAY_SESSION_ID: "gws_gateway-a"
  });
  const boxB = startNode("cmd/hwlab-box-simu/main.mjs", {
    PORT: String(ports.boxB),
    HWLAB_BOX_ID: "box-b",
    HWLAB_BOX_RESOURCE_ID: "res_box-b",
    HWLAB_GATEWAY_SESSION_ID: "gws_gateway-b"
  });
  const gatewayA = startNode("cmd/hwlab-gateway-simu/main.mjs", {
    PORT: String(ports.gatewayA),
    HWLAB_GATEWAY_ID: "gateway-a",
    HWLAB_BOX_RESOURCES: "res_box-a",
    HWLAB_BOX_IDS: "box-a",
    HWLAB_BOX_URLS: base.boxA
  });
  const gatewayB = startNode("cmd/hwlab-gateway-simu/main.mjs", {
    PORT: String(ports.gatewayB),
    HWLAB_GATEWAY_ID: "gateway-b",
    HWLAB_BOX_RESOURCES: "res_box-b",
    HWLAB_BOX_IDS: "box-b",
    HWLAB_BOX_URLS: base.boxB
  });

  await Promise.all([
    waitForHttp(`${base.boxA}/health/live`, boxA),
    waitForHttp(`${base.boxB}/health/live`, boxB),
    waitForHttp(`${base.gatewayA}/health/live`, gatewayA),
    waitForHttp(`${base.gatewayB}/health/live`, gatewayB)
  ]);

  const [boxAStatus, boxBStatus, gatewayAStatus, gatewayBStatus] = await Promise.all([
    requestJson(`${base.boxA}/status`),
    requestJson(`${base.boxB}/status`),
    requestJson(`${base.gatewayA}/status`),
    requestJson(`${base.gatewayB}/status`)
  ]);
  assertBoxStatus(boxAStatus.body, { boxId: "box-a", resourceId: "res_box-a" });
  assertBoxStatus(boxBStatus.body, { boxId: "box-b", resourceId: "res_box-b" });
  assertGatewayStatus(gatewayAStatus.body, { gatewayId: "gateway-a", resourceIds: ["res_box-a"] });
  assertGatewayStatus(gatewayBStatus.body, { gatewayId: "gateway-b", resourceIds: ["res_box-b"] });
  assert.notEqual(gatewayAStatus.body.session.gatewaySessionId, gatewayBStatus.body.session.gatewaySessionId);
  logOk("multi-instance identity");

  const registerB = await requestJson(`${base.gatewayB}/register`, {
    method: "POST",
    body: JSON.stringify({
      boxId: "box-b",
      resourceId: "res_box-b",
      url: base.boxB
    })
  });
  assert.equal(registerB.response.status, 200);
  assert.equal(registerB.body.accepted, true);
  assert.equal(registerB.body.registered.boxId, "box-b");

  const heartbeatA = await requestJson(`${base.gatewayA}/heartbeat`, {
    method: "POST",
    body: JSON.stringify({
      reportedBy: "l2-smoke"
    })
  });
  assert.equal(heartbeatA.response.status, 200);
  assert.equal(heartbeatA.body.accepted, true);
  assert.equal(heartbeatA.body.heartbeat.boxCount, 1);
  logOk("register and heartbeat");

  const [capA, capB] = await Promise.all([
    requestJson(`${base.gatewayA}/capabilities`),
    requestJson(`${base.gatewayB}/capabilities`)
  ]);
  assert.equal(capA.response.status, 200);
  assert.equal(capB.response.status, 200);
  assert.equal(capA.body.boxes[0].resourceId, "res_box-a");
  assert.equal(capB.body.boxes[0].resourceId, "res_box-b");
  for (const report of [capA.body, capB.body]) {
    const names = new Set(report.boxes.flatMap((box) => box.capabilities.map((capability) => capability.name)));
    for (const name of ["ai.read", "ao.write", "di.read", "do.write", "freq.read", "freq.write"]) {
      assert.equal(names.has(name), true, `capability report includes ${name}`);
    }
  }
  logOk("capability report");

  const aiWrite = await requestJson(`${base.boxA}/ports/write`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_ai1_write",
      traceId: "trc_l2_ai1_write",
      port: "AI1",
      value: 1.25,
      source: "direct-smoke"
    })
  });
  assert.equal(aiWrite.response.status, 200);
  assert.equal(aiWrite.body.value, 1.25);
  const aiRead = await requestJson(`${base.boxA}/ports/read`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_ai1_read",
      traceId: "trc_l2_ai1_read",
      port: "AI1"
    })
  });
  assert.equal(aiRead.response.status, 200);
  assert.equal(aiRead.body.value, 1.25);

  const aoWrite = await requestJson(`${base.gatewayA}/invoke`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_ao1_write",
      traceId: "trc_l2_ao1_write",
      resourceId: "res_box-a",
      operation: "port.write",
      port: "AO1",
      value: 3.3
    })
  });
  assert.equal(aoWrite.response.status, 200);
  assert.equal(aoWrite.body.result.value, 3.3);

  const freqWrite = await requestJson(`${base.gatewayA}/invoke`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_freq1_write",
      traceId: "trc_l2_freq1_write",
      resourceId: "res_box-a",
      operation: "port.write",
      port: "FREQ1",
      value: 1000
    })
  });
  assert.equal(freqWrite.response.status, 200);
  assert.equal(freqWrite.body.result.value, 1000);
  const freqRead = await requestJson(`${base.gatewayA}/invoke`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_freq1_read",
      traceId: "trc_l2_freq1_read",
      resourceId: "res_box-a",
      operation: "port.read",
      port: "FREQ1"
    })
  });
  assert.equal(freqRead.response.status, 200);
  assert.equal(freqRead.body.result.value, 1000);
  logOk("AI/AO/FREQ state read-write");

  const directWrite = await requestJson(`${base.boxA}/ports/write`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_direct_do1",
      traceId: "trc_l2_direct_do1",
      port: "DO1",
      value: true,
      source: "direct-smoke"
    })
  });
  assert.equal(directWrite.response.status, 200);
  assert.equal(directWrite.body.accepted, true);
  assert.equal(directWrite.body.port, "DO1");
  assert.equal(directWrite.body.value, true);
  assert.equal(directWrite.body.crossDevicePropagation, "patch-panel-only");
  assert.equal(directWrite.body.localLoopbackEnabled, false);
  assertShape(directWrite.body.audit, AUDIT_SHAPE_FIELDS, "directWrite.audit");
  assertShape(directWrite.body.evidence, EVIDENCE_SHAPE_FIELDS, "directWrite.evidence");

  const internalWrite = await requestJson(`${base.boxB}/internal/ports/write`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_internal_di1",
      traceId: "trc_l2_internal_di1",
      port: "DI1",
      value: true,
      sourceResourceId: "res_box-a",
      sourcePort: "DO1",
      propagatedBy: "hwlab-patch-panel",
      source: "patch-panel"
    })
  });
  assert.equal(internalWrite.response.status, 200);
  assert.equal(internalWrite.body.accepted, true);
  assert.equal(internalWrite.body.port, "DI1");
  assert.equal(internalWrite.body.value, true);
  assertShape(internalWrite.body.audit, AUDIT_SHAPE_FIELDS, "internalWrite.audit");
  assertShape(internalWrite.body.evidence, EVIDENCE_SHAPE_FIELDS, "internalWrite.evidence");
  logOk("direct call and internal port API");

  const invoke = await requestJson(`${base.gatewayB}/invoke`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_gateway_read_di1",
      traceId: "trc_l2_gateway_read_di1",
      resourceId: "res_box-b",
      operation: "port.read",
      port: "DI1"
    })
  });
  assert.equal(invoke.response.status, 200);
  assert.equal(invoke.body.accepted, true);
  assert.equal(invoke.body.gatewayId, "gateway-b");
  assert.equal(invoke.body.target.resourceId, "res_box-b");
  assert.equal(invoke.body.result.value, true);
  assertShape(invoke.body.audit, AUDIT_SHAPE_FIELDS, "invoke.audit");
  assertShape(invoke.body.evidence, EVIDENCE_SHAPE_FIELDS, "invoke.evidence");
  assertShape(invoke.body.result.audit, AUDIT_SHAPE_FIELDS, "invoke.result.audit");
  assertShape(invoke.body.result.evidence, EVIDENCE_SHAPE_FIELDS, "invoke.result.evidence");
  logOk("gateway invoke forwarding");

  const notRegistered = await requestJson(`${base.gatewayA}/invoke`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_wrong_gateway",
      traceId: "trc_l2_wrong_gateway",
      resourceId: "res_box-b",
      operation: "port.read",
      port: "DI1"
    })
  });
  assert.equal(notRegistered.response.status, 404);
  assert.equal(notRegistered.body.error.code, ERROR_CODES.sessionNotFound);
  assert.equal(notRegistered.body.error.data.gatewayId, "gateway-a");

  const badCapability = await requestJson(`${base.boxA}/invoke`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_bad_capability",
      traceId: "trc_l2_bad_capability",
      operation: "port.read",
      capabilityId: "cap_box-a_missing"
    })
  });
  assert.equal(badCapability.response.status, 404);
  assert.equal(badCapability.body.error.code, ERROR_CODES.capabilityUnavailable);
  assert.equal(badCapability.body.error.data.reason, "capability_unavailable");

  const capabilityPortMismatch = await requestJson(`${base.boxA}/invoke`, {
    method: "POST",
    body: JSON.stringify({
      operationId: "op_l2_capability_mismatch",
      traceId: "trc_l2_capability_mismatch",
      operation: "port.read",
      capabilityId: "cap_box-a_di1_read",
      port: "DO1"
    })
  });
  assert.equal(capabilityPortMismatch.response.status, 400);
  assert.equal(capabilityPortMismatch.body.error.code, ERROR_CODES.invalidParams);
  assert.equal(capabilityPortMismatch.body.error.data.reason, "capability_port_mismatch");
  logOk("error codes");

  const finalBoxA = await requestJson(`${base.boxA}/status`);
  const finalBoxB = await requestJson(`${base.boxB}/status`);
  assert.equal(finalBoxA.body.ports.DI1.value, false, "box-a DI1 must not loop back from its DO1 write");
  assert.equal(finalBoxB.body.ports.DI1.value, true, "box-b DI1 changes only through internal port API");
  assert.equal(finalBoxB.body.ports.DI1.propagatedBy, "hwlab-patch-panel");
  logOk("patch-panel-only propagation boundary");

  process.stdout.write("[l2-runtime-smoke] passed\n");
}

try {
  await main();
} finally {
  await Promise.allSettled(children.map(stopNode));
}
