import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { AddressInfo } from "node:net";
import type { FastifyInstance } from "fastify";
import { createApp, type Config } from "../src/app.js";
import type { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js";

const HERE = dirname(fileURLToPath(import.meta.url));
const LUA_DIR = join(HERE, "lua");
const REPO_ROOT = join(HERE, "../../..");

export type Server = {
  registry: GatewayRegistry;
  // The MCP Fastify app, sharing the gateway registry. Drive it in-process with
  // `mcpApp.inject({ method: "POST", url: "/", payload })` — no real listener needed.
  mcpApp: FastifyInstance;
  // Gateway origin (no path). This is what `cloud.url` expects: libcloud appends
  // `/gateway` itself. Pass this to the Lua scripts.
  baseUrl: string;
  // Full WS endpoint (origin + `/gateway`), for direct ws clients.
  socketUrl: string;
  // Count captured server log lines whose `msg` contains `substring` (0 unless the
  // server was started with `captureLogs: true`). Lets a test observe connection
  // churn that registry.count() hides (newest-wins keeps the count pinned at 1).
  countLog: (substring: string) => number;
  close: () => Promise<void>;
};

export type ServerOptions = {
  serverPassword?: string;
  opencodeUrl?: string;
  requestTimeoutMs?: number;
  // Capture server logs (at info level) so tests can assert on message frequency.
  captureLogs?: boolean;
};

// Real Fastify gateway on an ephemeral port. Mirrors index.ts's wiring but with a
// silent logger and test-supplied config (never reads process.env).
export async function startServer(opts: ServerOptions = {}): Promise<Server> {
  const logLines: string[] = [];
  const logger: Config["logger"] = opts.captureLogs
    ? { level: "info", stream: { write: (line: string) => void logLines.push(line) } }
    : { level: "silent" };

  const timeoutMs = opts.requestTimeoutMs ?? 5_000;
  const config: Config = {
    host: "127.0.0.1",
    port: 0,
    serverPassword: opts.serverPassword,
    requestTimeoutMs: timeoutMs,
    opencodeUrl: opts.opencodeUrl,
    opencodeUsername: "opencode",
    opencodeServerPassword: undefined,
    mcpEnabled: true,
    mcpHost: "127.0.0.1",
    mcpPort: 0,
    mcpDefaultTimeoutMs: timeoutMs,
    mcpMaxTimeoutMs: 30_000,
    mcpProbeTimeoutMs: timeoutMs,
    logger,
  };

  const { app, mcpApp, registry } = await createApp(config);
  await app.listen({ host: config.host, port: config.port });
  // Driven in-process via inject(); ready() loads the plugin without a socket.
  if (mcpApp === null) {
    throw new Error("expected mcpApp to be created (mcpEnabled is true)");
  }
  await mcpApp.ready();
  const { port } = app.server.address() as AddressInfo;

  const baseUrl = `ws://127.0.0.1:${port}`;
  return {
    registry,
    mcpApp,
    baseUrl,
    socketUrl: `${baseUrl}/gateway`,
    countLog: (substring) => logLines.filter((line) => line.includes(substring)).length,
    close: async () => {
      await mcpApp.close();
      await app.close();
    },
  };
}

export type FakeOpencode = {
  url: string;
  close: () => Promise<void>;
};

// Tiny stand-in for the opencode server. The probe issues `GET /config`; we reply
// with `status` (default 200) on every path so opencodeOk can be steered per test.
export async function startFakeOpencode(opts: { status?: number } = {}): Promise<FakeOpencode> {
  const status = opts.status ?? 200;
  const server = createServer((_req, res) => {
    res.writeHead(status, { "content-type": "application/json" });
    res.end("{}");
  });
  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
  const { port } = server.address() as AddressInfo;
  return {
    url: `http://127.0.0.1:${port}`,
    close: () => new Promise<void>((resolve) => server.close(() => resolve())),
  };
}

export async function waitFor(predicate: () => boolean, timeoutMs: number, label: string): Promise<void> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (predicate()) {
      return;
    }
    await sleep(50);
  }
  throw new Error(`waitFor timed out: ${label}`);
}

export type CraftosResult = {
  status: number | null;
  signal: NodeJS.Signals | null;
  output: string;
};

export type CraftosHandle = {
  done: Promise<CraftosResult>;
  abort: () => void;
  // Stdout/stderr accumulated so far; lets a test await a marker line while the
  // process is still running (e.g. wait for "__CLOUD_READY__" before acting).
  snapshot: () => string;
};

// Spawn headless CraftOS-PC running a Lua script from test-integration/lua, with the
// repo mounted read-only (/apis, /daemons, /programs, /trapos) so the scripts can
// require the real libcloud / daemons / programs.
export function startCraftos(
  luaName: string,
  opts: { computerId?: number; computerLabel?: string; shellArgs?: string[]; timeoutMs?: number } = {},
): CraftosHandle {
  const timeoutMs = opts.timeoutMs ?? 15_000;
  const controller = new AbortController();
  const chunks: Buffer[] = [];

  const done = (async (): Promise<CraftosResult> => {
    const dataDir = await mkdtemp(join(tmpdir(), "trapos-server-it-"));
    const watchdog = setTimeout(() => controller.abort(), timeoutMs);
    try {
      if (!existsSync(join(REPO_ROOT, ".stage/apis"))) {
        throw new Error(
          ".stage/ is missing — run `just stage` (or use `just test-integration`) before the integration tests.",
        );
      }
      const args: string[] = [
        "--directory", dataDir,
        "--headless",
        "--mount-ro", `/staging=${LUA_DIR}`,
        "--mount-ro", `/trapos=${REPO_ROOT}`,
        "--mount-ro", `/apis=${join(REPO_ROOT, ".stage/apis")}`,
        "--mount-ro", `/daemons=${join(REPO_ROOT, ".stage/daemons")}`,
        "--mount-ro", `/programs=${join(REPO_ROOT, ".stage/programs")}`,
      ];
      if (opts.computerId !== undefined) {
        args.push("--id", String(opts.computerId));
      }
      if (process.platform === "darwin") {
        args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources");
      }
      args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [], opts.computerLabel));

      const child = spawn("craftos", args, { signal: controller.signal });
      child.stdout.on("data", (d: Buffer) => chunks.push(d));
      child.stderr.on("data", (d: Buffer) => chunks.push(d));

      const result = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolve) => {
        child.once("close", (code, signal) => resolve({ status: code, signal }));
        child.once("error", (err: NodeJS.ErrnoException) => {
          if (err.code !== "ABORT_ERR") {
            resolve({ status: null, signal: null });
          }
        });
      });

      return { status: result.status, signal: result.signal, output: Buffer.concat(chunks).toString("utf8") };
    } finally {
      clearTimeout(watchdog);
      await rm(dataDir, { recursive: true, force: true });
    }
  })();

  return { done, abort: () => controller.abort(), snapshot: () => Buffer.concat(chunks).toString("utf8") };
}

export function formatFailure(message: string, craftosOutput: string): string {
  return ["\x1b[31mFAIL\x1b[0m " + message, "--- craftos output ---", craftosOutput.trimEnd(), "----------------------"].join("\n");
}

function buildExecCode(luaName: string, shellArgs: string[], computerLabel?: string): string {
  const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`;
  const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)];
  const setup = computerLabel === undefined ? "" : `os.setComputerLabel(${luaQuote(computerLabel)}); `;
  return `${setup}shell.run(${parts.join(", ")})`;
}

function luaQuote(value: string): string {
  return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
