import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_AGENT_PATH = path.join(
  repoRoot,
  "services/nginx/app/resources/edge-gateway-agent/agent.php"
);
const DEFAULT_WORKER_PATH = path.join(
  repoRoot,
  "services/nginx/app/resources/edge-gateway-agent/lan-worker.php"
);
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
const DEFAULT_TIMEOUT_MS = 15000;
const AGENT_TOKEN = "agent-token";

function parseArgs(argv = process.argv.slice(2)) {
  const options = {
    agentPath: DEFAULT_AGENT_PATH,
    workerPath: DEFAULT_WORKER_PATH,
    phpImage: DEFAULT_PHP_IMAGE,
    timeoutMs: DEFAULT_TIMEOUT_MS,
    keepTemp: false,
    help: false,
  };

  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    const next = argv[index + 1];

    switch (arg) {
      case "--agent-path":
        options.agentPath = path.resolve(String(next || "").trim());
        index += 1;
        break;
      case "--worker-path":
        options.workerPath = path.resolve(String(next || "").trim());
        index += 1;
        break;
      case "--php-image":
        options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
        index += 1;
        break;
      case "--timeout-ms":
        options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
        index += 1;
        break;
      case "--keep-temp":
        options.keepTemp = true;
        break;
      case "--help":
      case "-h":
        options.help = true;
        break;
      default:
        throw new Error(`Unknown argument: ${arg}`);
    }
  }

  return options;
}

function printUsage() {
  process.stdout.write(`Usage:
  node scripts/edge-agent-to-shelly-proof.mjs [options]

Runs the PHP edge agent and real LAN worker against fake broker, API, and
Shelly RPC endpoints. Verifies that a broker-connected SET_RELAY_STATE command
drains from the API, reaches the worker, triggers a Shelly-style Switch.Set
call, reads Switch.GetStatus, and posts the command result.

Options:
  --agent-path <path>   PHP agent artifact to execute.
                       Default: ${DEFAULT_AGENT_PATH}
  --worker-path <path>  PHP LAN worker artifact to execute.
                       Default: ${DEFAULT_WORKER_PATH}
  --php-image <image>   Docker PHP image with curl, sqlite3, and pdo_sqlite.
                       Default: ${DEFAULT_PHP_IMAGE}
  --timeout-ms <ms>     Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
  --keep-temp           Keep the temporary config/runtime directory.
  --help                Show this help text.
`);
}

function readJson(request) {
  return new Promise((resolve) => {
    let raw = "";
    request.setEncoding("utf8");
    request.on("data", (chunk) => {
      raw += chunk;
    });
    request.on("end", () => {
      if (raw.trim() === "") {
        resolve({});
        return;
      }

      try {
        resolve(JSON.parse(raw));
      } catch {
        resolve({ __invalid: raw });
      }
    });
  });
}

function sendJson(response, status, payload) {
  const body = JSON.stringify(payload);
  response.writeHead(status, {
    "content-type": "application/json; charset=utf-8",
    "content-length": Buffer.byteLength(body),
  });
  response.end(body);
}

function requestJson({ method = "GET", port, path: requestPath, body = null, headers = {} }) {
  return new Promise((resolve, reject) => {
    const payload = body === null ? null : JSON.stringify(body);
    const request = http.request({
      hostname: "127.0.0.1",
      port,
      path: requestPath,
      method,
      headers: {
        accept: "application/json",
        ...(payload === null ? {} : {
          "content-type": "application/json",
          "content-length": Buffer.byteLength(payload),
        }),
        ...headers,
      },
      timeout: 1000,
    }, (response) => {
      let raw = "";
      response.setEncoding("utf8");
      response.on("data", (chunk) => {
        raw += chunk;
      });
      response.on("end", () => {
        let decoded;
        try {
          decoded = raw.trim() === "" ? {} : JSON.parse(raw);
        } catch {
          decoded = { __invalid: raw };
        }
        resolve({ status: response.statusCode || 0, body: decoded });
      });
    });
    request.on("error", reject);
    request.on("timeout", () => {
      request.destroy(new Error("request timed out"));
    });
    if (payload !== null) {
      request.write(payload);
    }
    request.end();
  });
}

function listen(server) {
  return new Promise((resolve) => {
    server.listen(0, "127.0.0.1", () => resolve(server.address().port));
  });
}

function closeServer(server) {
  return new Promise((resolve) => {
    server.close(() => resolve());
  });
}

async function reservePort() {
  const server = net.createServer();
  const port = await new Promise((resolve) => {
    server.listen(0, "127.0.0.1", () => resolve(server.address().port));
  });
  await closeServer(server);
  return port;
}

function websocketAcceptKey(key) {
  return crypto
    .createHash("sha1")
    .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
    .digest("base64");
}

function createBrokerServer(state) {
  const sockets = new Set();
  const server = net.createServer((socket) => {
    sockets.add(socket);
    socket.on("close", () => sockets.delete(socket));

    let buffer = "";
    socket.on("data", (chunk) => {
      buffer += chunk.toString("binary");
      if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
        return;
      }

      const requestText = Buffer.from(buffer, "binary").toString("utf8");
      const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
      const requestLine = requestText.split("\r\n")[0] || "";
      if (!requestLine.includes("/ws/agent?")) {
        state.failure = new Error(`unexpected broker path: ${requestLine}`);
      }
      if (!key) {
        state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
        return;
      }

      socket.write([
        "HTTP/1.1 101 Switching Protocols",
        "Upgrade: websocket",
        "Connection: Upgrade",
        `Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
        "",
        "",
      ].join("\r\n"));
      state.brokerHandshakeSeen = true;
      buffer = "";
    });
  });

  return { server, sockets };
}

function createShellyServer(state) {
  return http.createServer((request, response) => {
    const url = new URL(request.url, "http://127.0.0.1");
    state.requests.push({
      service: "shelly",
      method: request.method,
      path: url.pathname,
      query: Object.fromEntries(url.searchParams.entries()),
    });

    if (request.method === "GET" && url.pathname === "/rpc/Switch.Set") {
      state.shellySwitchSetSeen = true;
      if (url.searchParams.get("id") !== "0" || url.searchParams.get("on") !== "true") {
        state.failure = new Error(`unexpected Shelly Switch.Set query: ${url.search}`);
      }
      sendJson(response, 200, { was_on: false, output: true });
      return;
    }

    if (request.method === "GET" && url.pathname === "/rpc/Switch.GetStatus") {
      state.shellyStatusSeen = true;
      if (url.searchParams.get("id") !== "0") {
        state.failure = new Error(`unexpected Shelly Switch.GetStatus query: ${url.search}`);
      }
      sendJson(response, 200, { id: 0, output: true, source: "fake-shelly-rpc" });
      return;
    }

    if (url.pathname.startsWith("/relay/")) {
      state.failure = new Error(`legacy Shelly endpoint should not be used for generation 2 proof: ${url.pathname}`);
    }

    sendJson(response, 404, { message: "not found" });
  });
}

function createApiServer(state, brokerPort, shellyAddress) {
  return http.createServer(async (request, response) => {
    const url = new URL(request.url, "http://127.0.0.1");
    const body = await readJson(request);
    state.requests.push({ service: "api", method: request.method, path: url.pathname, body });

    if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
      sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
      return;
    }

    if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
      sendJson(response, 200, { data: { monitors: [] } });
      return;
    }

    if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
      state.commandPollSeen = true;
      if (body.wait_seconds !== 0) {
        state.failure = new Error(
          `broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
        );
      }

      if (!state.commandDelivered) {
        state.commandDelivered = true;
        sendJson(response, 200, {
          data: {
            id: 77,
            command_type: "SET_RELAY_STATE",
            payload: {
              localIp: shellyAddress,
              local_ip: shellyAddress,
              channel: 0,
              on: true,
              relayId: "relay-proof",
              relay_id: "relay-proof",
              deviceGeneration: 2,
              device_generation: 2,
            },
          },
        });
        return;
      }

      sendJson(response, 200, { data: null });
      return;
    }

    if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
      state.resultSeen = true;
      if (
        body.ok !== true ||
        body.result?.on !== true ||
        body.result?.output !== true ||
        body.result?.raw?.source !== "fake-shelly-rpc"
      ) {
        state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
      }
      sendJson(response, 200, { data: { acknowledged: true } });
      return;
    }

    sendJson(response, 404, { message: "not found", path: url.pathname });
  });
}

function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
  const containerProofDir = "/proof";
  const runtimeDir = `${containerProofDir}/runtime`;
  const config = {
    apiUrl: `http://127.0.0.1:${apiPort}`,
    brokerUrl: `ws://127.0.0.1:${brokerPort}`,
    gatewayId: 42,
    agentToken: AGENT_TOKEN,
    installDir: containerProofDir,
    runtimeDir,
    stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
    workerBaseUrl: `http://127.0.0.1:${workerPort}`,
    heartbeatIntervalSeconds: 60,
    operationPollTimeoutSeconds: 20,
  };

  const configPath = path.join(tempDir, "config.json");
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
  return { containerConfigPath: `${containerProofDir}/config.json` };
}

function spawnWorker({ workerPath, phpImage, workerPort }) {
  return spawn("docker", [
    "run",
    "--rm",
    "--network",
    "host",
    "-e",
    `TRUCKWASH_WORKER_TOKEN=${AGENT_TOKEN}`,
    "-v",
    `${workerPath}:/lan-worker.php:ro`,
    phpImage,
    "php",
    "-S",
    `127.0.0.1:${workerPort}`,
    "/lan-worker.php",
  ], { stdio: ["ignore", "pipe", "pipe"] });
}

function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
  return spawn("docker", [
    "run",
    "--rm",
    "--network",
    "host",
    "-v",
    `${agentPath}:/agent.php:ro`,
    "-v",
    `${tempDir}:/proof`,
    phpImage,
    "php",
    "/agent.php",
    "--config",
    containerConfigPath,
  ], { stdio: ["ignore", "pipe", "pipe"] });
}

async function waitForWorker(workerPort, child, timeoutMs) {
  const deadline = Date.now() + timeoutMs;
  let lastError = null;
  while (Date.now() < deadline) {
    if (child.exitCode !== null || child.signalCode !== null) {
      throw new Error(`LAN worker exited before becoming healthy: ${child.exitCode ?? child.signalCode}`);
    }

    try {
      const response = await requestJson({ port: workerPort, path: "/health" });
      if (response.status === 200 && response.body?.service === "lan-worker") {
        return;
      }
    } catch (error) {
      lastError = error;
    }
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
  throw lastError || new Error("LAN worker did not become healthy");
}

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

  child.kill("SIGTERM");
  const hardKill = setTimeout(() => {
    if (child.exitCode === null && child.signalCode === null) {
      child.kill("SIGKILL");
    }
  }, 1500);

  await Promise.race([
    new Promise((resolve) => child.once("exit", resolve)),
    new Promise((resolve) => setTimeout(resolve, 2200)),
  ]);
  clearTimeout(hardKill);
}

function evidenceFromState(state, agentExited, workerExited) {
  return {
    brokerHandshakeSeen: state.brokerHandshakeSeen,
    commandPollSeen: state.commandPollSeen,
    shellySwitchSetSeen: state.shellySwitchSetSeen,
    shellyStatusSeen: state.shellyStatusSeen,
    resultSeen: state.resultSeen,
    agentStayedRunningUntilProofComplete: !agentExited,
    workerStayedRunningUntilProofComplete: !workerExited,
  };
}

export async function runProof(options) {
  if (process.platform !== "linux") {
    throw new Error("This proof uses Docker --network host and currently expects Linux.");
  }
  if (!fs.existsSync(options.agentPath)) {
    throw new Error(`Agent artifact not found: ${options.agentPath}`);
  }
  if (!fs.existsSync(options.workerPath)) {
    throw new Error(`LAN worker artifact not found: ${options.workerPath}`);
  }

  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-to-shelly-proof-"));
  fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });

  const state = {
    brokerHandshakeSeen: false,
    commandPollSeen: false,
    shellySwitchSetSeen: false,
    shellyStatusSeen: false,
    resultSeen: false,
    commandDelivered: false,
    failure: null,
    requests: [],
  };

  const broker = createBrokerServer(state);
  const shellyServer = createShellyServer(state);
  let apiServer = null;
  let agent = null;
  let worker = null;
  let agentStdout = "";
  let agentStderr = "";
  let workerStdout = "";
  let workerStderr = "";
  let agentExited = false;
  let workerExited = false;

  try {
    const brokerPort = await listen(broker.server);
    const shellyPort = await listen(shellyServer);
    const workerPort = await reservePort();
    const shellyAddress = `127.0.0.1:${shellyPort}`;
    apiServer = createApiServer(state, brokerPort, shellyAddress);
    const apiPort = await listen(apiServer);
    const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);

    worker = spawnWorker({ ...options, workerPort });
    worker.stdout.on("data", (chunk) => {
      workerStdout += chunk.toString();
    });
    worker.stderr.on("data", (chunk) => {
      workerStderr += chunk.toString();
    });
    worker.once("exit", () => {
      workerExited = true;
    });
    await waitForWorker(workerPort, worker, 5000);

    agent = spawnAgent({ ...options, tempDir, containerConfigPath });
    agent.stdout.on("data", (chunk) => {
      agentStdout += chunk.toString();
    });
    agent.stderr.on("data", (chunk) => {
      agentStderr += chunk.toString();
    });
    agent.once("exit", () => {
      agentExited = true;
    });

    const deadline = Date.now() + options.timeoutMs;
    while (Date.now() < deadline && !state.failure && !agentExited && !workerExited) {
      if (
        state.brokerHandshakeSeen &&
        state.commandPollSeen &&
        state.shellySwitchSetSeen &&
        state.shellyStatusSeen &&
        state.resultSeen
      ) {
        break;
      }
      await new Promise((resolve) => setTimeout(resolve, 100));
    }

    const evidence = evidenceFromState(state, agentExited, workerExited);
    if (
      state.failure ||
      !state.brokerHandshakeSeen ||
      !state.commandPollSeen ||
      !state.shellySwitchSetSeen ||
      !state.shellyStatusSeen ||
      !state.resultSeen
    ) {
      const error = state.failure || new Error("missing proof evidence");
      error.evidence = evidence;
      error.requests = state.requests;
      error.agentStdout = agentStdout.slice(-3000);
      error.agentStderr = agentStderr.slice(-3000);
      error.workerStdout = workerStdout.slice(-3000);
      error.workerStderr = workerStderr.slice(-3000);
      throw error;
    }

    return {
      evidence,
      agentPath: options.agentPath,
      workerPath: options.workerPath,
      phpImage: options.phpImage,
      tempDir,
      requestCount: state.requests.length,
    };
  } finally {
    if (agent) {
      await stopChild(agent);
    }
    if (worker) {
      await stopChild(worker);
    }
    for (const socket of broker.sockets) {
      socket.destroy();
    }
    await Promise.allSettled([
      closeServer(broker.server),
      closeServer(shellyServer),
      apiServer ? closeServer(apiServer) : Promise.resolve(),
    ]);
    if (!options.keepTemp) {
      fs.rmSync(tempDir, { recursive: true, force: true });
    }
  }
}

async function main() {
  const options = parseArgs();
  if (options.help) {
    printUsage();
    return;
  }

  const result = await runProof(options);
  process.stdout.write("PASS broker-connected API command triggered real LAN worker Shelly RPC signal and posted result\n");
  process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
  process.stdout.write(`Agent: ${result.agentPath}\n`);
  process.stdout.write(`LAN worker: ${result.workerPath}\n`);
  process.stdout.write(`PHP image: ${result.phpImage}\n`);
  if (options.keepTemp) {
    process.stdout.write(`Temp dir: ${result.tempDir}\n`);
  }
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((error) => {
    process.stderr.write(`FAIL ${error.message}\n`);
    if (error.evidence) {
      process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
    }
    if (error.requests) {
      process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
    }
    if (error.agentStdout) {
      process.stderr.write(`agent stdout: ${error.agentStdout}\n`);
    }
    if (error.agentStderr) {
      process.stderr.write(`agent stderr: ${error.agentStderr}\n`);
    }
    if (error.workerStdout) {
      process.stderr.write(`worker stdout: ${error.workerStdout}\n`);
    }
    if (error.workerStderr) {
      process.stderr.write(`worker stderr: ${error.workerStderr}\n`);
    }
    process.exit(1);
  });
}
