import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { createServer as createHttpServer } from "node:http";
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";

import { RuntimeManager } from "../dist/index.js";

const require = createRequire(import.meta.url);
const wsModulePath = require.resolve("ws");

function listen(server, host = "127.0.0.1", port = 0) {
  return new Promise((resolve, reject) => {
    server.once("error", reject);
    server.listen(port, host, () => {
      server.off("error", reject);
      resolve();
    });
  });
}

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

function serverPort(server) {
  const address = server.address();
  assert.equal(typeof address, "object");
  assert.ok(address);
  return address.port;
}

async function linkNodeExecutable(targetPath) {
  try {
    await symlink(process.execPath, targetPath);
  } catch {
    await copyFile(process.execPath, targetPath);
  }
  if (process.platform !== "win32") {
    await chmod(targetPath, 0o755);
  }
}

async function createFakeRuntime(rootDir, preferredPort) {
  const runtimeDir = path.join(rootDir, "runtime");
  const runtimeDataDir = path.join(rootDir, "data");
  await mkdir(path.join(runtimeDir, "node", "bin"), { recursive: true });
  await mkdir(path.join(runtimeDir, "openclaw", "package"), { recursive: true });
  await mkdir(path.join(runtimeDir, "config"), { recursive: true });
  await mkdir(path.join(runtimeDir, "python", "bin"), { recursive: true });

  await linkNodeExecutable(path.join(runtimeDir, "node", "bin", "node"));
  await writeFile(path.join(runtimeDir, "runtime-manifest.json"), "{}", "utf8");
  await writeFile(path.join(runtimeDir, "openclaw", "package", "openclaw.mjs"), "export {};\n", "utf8");
  await writeFile(path.join(runtimeDir, "python", "python-manifest.json"), JSON.stringify({
    requestedPackages: [
      "openpyxl", "pandas", "requests", "beautifulsoup4", "lxml", "urllib3", "pypdf",
      "python-docx", "charset-normalizer", "pyyaml", "pillow", "loguru", "pyexecjs",
      "python-dotenv", "certifi", "openai", "retry", "fastapi", "uvicorn",
      "python-multipart", "pdfplumber", "greenlet", "playwright", "edge-tts",
      "imageio-ffmpeg", "qiniu"
    ].map((name) => ({ name }))
  }), "utf8");

  const fakePythonPath = path.join(runtimeDir, "python", "bin", "python3");
  await writeFile(fakePythonPath, [
    "#!/usr/bin/env node",
    "console.log(JSON.stringify({ ready: true, pythonVersion: '3.12.0', installedPackages: [], missingModules: [], importErrors: {} }));"
  ].join("\n"), "utf8");
  if (process.platform !== "win32") {
    await chmod(fakePythonPath, 0o755);
  }

  await writeFile(path.join(runtimeDir, "config", "openclaw.json"), JSON.stringify({
    gateway: {
      mode: "local",
      bind: "loopback",
      port: preferredPort,
      auth: {
        mode: "token",
        token: "test-token"
      }
    }
  }, null, 2), "utf8");

  const gatewayScript = `
const { WebSocketServer } = require(${JSON.stringify(wsModulePath)});
const args = process.argv;
const portIndex = args.indexOf("--port");
const tokenIndex = args.indexOf("--token");
const port = Number(args[portIndex + 1]);
const token = tokenIndex >= 0 ? args[tokenIndex + 1] : undefined;
if (!Number.isFinite(port) || port <= 0) {
  console.error("missing port");
  process.exit(1);
}
const server = new WebSocketServer({ host: "127.0.0.1", port });
server.on("connection", (socket) => {
  socket.send(JSON.stringify({ event: "connect.challenge", payload: { nonce: "test" } }));
  socket.on("message", (data) => {
    const frame = JSON.parse(data.toString());
    if (frame.method === "connect") {
      socket.send(JSON.stringify({
        type: "res",
        id: frame.id,
        ok: true,
        payload: { server: { version: "test" }, features: { methods: ["status", "health"] } }
      }));
      return;
    }
    if (frame.method === "status") {
      socket.send(JSON.stringify({ type: "res", id: frame.id, ok: true, payload: { runtimeVersion: "test" } }));
      return;
    }
    if (frame.method === "health") {
      socket.send(JSON.stringify({ type: "res", id: frame.id, ok: true, payload: { ok: true } }));
      return;
    }
    socket.send(JSON.stringify({ type: "res", id: frame.id, ok: false, error: { message: "unknown method" } }));
  });
});
process.on("SIGTERM", () => server.close(() => process.exit(0)));
setInterval(() => {}, 1000);
`;
  await writeFile(path.join(runtimeDir, "openclaw", "index.js"), gatewayScript, "utf8");

  return { runtimeDir, runtimeDataDir };
}

test("managed runtime falls back to a free loopback port when configured port is occupied by a non-reusable service", async () => {
  const blocker = createHttpServer((_, response) => {
    response.writeHead(200, { "content-type": "text/plain" });
    response.end("not openclaw");
  });
  await listen(blocker);
  const occupiedPort = serverPort(blocker);

  const rootDir = await mkdtemp(path.join(tmpdir(), "qjc-runtime-port-fallback-"));
  let manager;
  try {
    const { runtimeDir, runtimeDataDir } = await createFakeRuntime(rootDir, occupiedPort);
    manager = new RuntimeManager({
      vendorRuntimeDir: runtimeDir,
      runtimeDataDir,
      logFilePath: path.join(runtimeDataDir, "logs", "runtime.log"),
      requestedMode: "bundled-runtime"
    });

    const status = await manager.start();

    assert.equal(status.processState, "running");
    assert.ok(status.gatewayUrl);
    assert.notEqual(Number(new URL(status.gatewayUrl).port), occupiedPort);

    const managedConfig = JSON.parse(await readFile(path.join(runtimeDataDir, "state", "openclaw.runtime.json"), "utf8"));
    assert.equal(managedConfig.gateway.port, Number(new URL(status.gatewayUrl).port));

    const syncedStatus = await manager.syncManagedConfig("sync");
    assert.equal(syncedStatus.gatewayUrl, status.gatewayUrl);

    const repeatedStartStatus = await manager.start();
    assert.equal(repeatedStartStatus.gatewayUrl, status.gatewayUrl);

    const logs = await manager.tailLogs();
    assert.match(logs.map((entry) => entry.message).join("\n"), /fallback port/i);
  } finally {
    if (manager) {
      await manager.stop();
    }
    await closeServer(blocker);
    await rm(rootDir, { recursive: true, force: true });
  }
});
