import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";

import {
  buildAgentServerCommand,
  buildAgentServerEnv,
  buildSafeDevConfig,
  formatMissingUvxGuidance,
  validateLocalAgentServerPath,
} from "./dev-safe.mjs";
import {
  getProcessTreeSpawnOptions,
  isProcessRunning,
  signalProcessTree,
} from "./dev-process-utils.mjs";

const DEFAULT_EXTRA_BACKEND_PORT = 18002;
const DEFAULT_EXTRA_VSCODE_PORT = 18003;
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;

function parsePort(value, fallback) {
  if (value == null || value === "") {
    return fallback;
  }

  const parsed = Number.parseInt(value, 10);
  if (!Number.isInteger(parsed) || parsed <= 0) {
    throw new Error(`Invalid port: ${value}`);
  }

  return parsed;
}

/**
 * Build a config for an *extra* standalone agent-server that shares the
 * bundled instance's persistence (state dir, conversations, secret key)
 * but listens on a different backend + vscode port.
 *
 * @param {string} cwd
 * @param {Record<string, string | undefined>} env
 */
export function buildExtraBackendConfig(
  cwd = process.cwd(),
  env = process.env,
) {
  const base = buildSafeDevConfig(cwd, env);

  const backendPort = parsePort(
    env.OH_CANVAS_EXTRA_BACKEND_PORT,
    DEFAULT_EXTRA_BACKEND_PORT,
  );
  const vscodePort = parsePort(
    env.OH_CANVAS_EXTRA_VSCODE_PORT,
    DEFAULT_EXTRA_VSCODE_PORT,
  );

  return {
    ...base,
    backendPort,
    vscodePort,
    backendBaseUrl: `http://127.0.0.1:${backendPort}`,
    backendHost: `127.0.0.1:${backendPort}`,
  };
}

function isEnoentError(error) {
  return Boolean(
    (error &&
      typeof error === "object" &&
      "code" in error &&
      error.code === "ENOENT") ||
    /ENOENT/.test(String(error)),
  );
}

async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
  const startedAt = Date.now();

  while (Date.now() - startedAt < timeoutMs) {
    try {
      const response = await fetch(url);
      if (response.ok) {
        return;
      }
    } catch {
      // Keep polling until timeout.
    }

    await delay(500);
  }

  throw new Error(`Timed out waiting for agent-server at ${url}`);
}

function spawnProcess(command, args, options = {}) {
  const child = spawn(
    command,
    args,
    getProcessTreeSpawnOptions({
      stdio: "inherit",
      ...options,
    }),
  );

  child.once("error", (error) => {
    if (isEnoentError(error) && command === "uvx") {
      console.error(formatMissingUvxGuidance(options?.cwd));
    } else if (isEnoentError(error)) {
      console.error(
        `Failed to start ${command}. Make sure it is installed and on your PATH.`,
      );
    } else {
      console.error(`Failed to start ${command}:`, error);
    }
  });

  return child;
}

async function main() {
  const config = buildExtraBackendConfig();

  if (process.env.OH_AGENT_SERVER_LOCAL_PATH) {
    validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
  }

  for (const dir of [
    config.stateDir,
    config.tmuxTmpDir,
    config.conversationsPath,
    config.workspacesPath,
    config.bashEventsDir,
  ]) {
    mkdirSync(dir, { recursive: true });
  }

  const agentServerCmd = buildAgentServerCommand();

  const secretKeySource = process.env.OH_SECRET_KEY
    ? "custom (from OH_SECRET_KEY)"
    : "default (for local development)";

  console.log("Starting EXTRA standalone agent-server (shared state)...");
  console.log(`- agent-server: ${agentServerCmd.source}`);
  console.log(`- backend: ${config.backendBaseUrl}`);
  console.log(`- vscode port: ${config.vscodePort}`);
  console.log(`- shared state dir: ${config.stateDir}`);
  console.log(`- shared conversations: ${config.conversationsPath}`);
  console.log(`- secret key: ${secretKeySource}`);
  console.log("");
  console.log(
    "Connect via the GUI: open Add Backend, enter " +
      `${config.backendBaseUrl} as the host. Leave the API key blank ` +
      "unless this server is started with OH_SESSION_API_KEYS_0 set.",
  );
  console.log("");

  const backend = spawnProcess(
    agentServerCmd.command,
    [
      ...agentServerCmd.args,
      "--host",
      "127.0.0.1",
      "--port",
      String(config.backendPort),
    ],
    {
      cwd: config.cwd,
      env: {
        // Deliberately not opting into the editor path prefix. This server is
        // reached by registering it as an extra backend from a browser whose
        // origin belongs to some *other* stack, so a prefix on that origin
        // either does not resolve or — worse — resolves to the bundled
        // stack's editor, silently handing back a different container's
        // workspace. No single global prefix can disambiguate the two, so this
        // launcher stays out of prefix-mode; the editor button is unavailable
        // for conversations on an extra backend.
        ...process.env,
        ...buildAgentServerEnv(config),
      },
    },
  );

  let shuttingDown = false;

  const shutdown = (signal = "SIGTERM") => {
    if (shuttingDown) {
      return;
    }

    shuttingDown = true;
    signalProcessTree(backend, signal);

    setTimeout(() => {
      if (isProcessRunning(backend)) {
        signalProcessTree(backend, "SIGKILL");
      }
      process.exit(process.exitCode ?? 0);
    }, 3000);
  };

  process.on("SIGINT", () => shutdown("SIGINT"));
  process.on("SIGTERM", () => shutdown("SIGTERM"));
  // The agent-server is spawned detached, so a SIGHUP that kills this launcher
  // (terminal or multiplexer death) would otherwise leave it running and holding
  // its port. Forward SIGTERM rather than SIGHUP: uvicorn only handles
  // SIGINT/SIGTERM, so a forwarded SIGHUP would terminate the agent-server by
  // default action instead of shutting it down gracefully.
  process.on("SIGHUP", () => shutdown("SIGTERM"));

  const backendErrored = new Promise((_, reject) => {
    backend.once("error", (error) => reject(error));
  });
  const backendExited = new Promise((_, reject) => {
    backend.once("exit", (code, signal) => {
      if (!shuttingDown) {
        reject(
          new Error(
            `agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`,
          ),
        );
      }
    });
  });

  try {
    await Promise.race([
      waitForServer(`${config.backendBaseUrl}/server_info`),
      backendErrored,
      backendExited,
    ]);
  } catch (error) {
    shutdown();
    throw error;
  }

  console.log(`Extra agent-server is ready at ${config.backendBaseUrl}.`);

  backend.once("exit", (code) => {
    if (!shuttingDown) {
      console.error(`agent-server exited unexpectedly with code ${code ?? 0}`);
      shutdown();
      process.exitCode = code ?? 1;
    } else {
      process.exitCode = code ?? 0;
    }
  });
}

if (
  process.argv[1] &&
  import.meta.url === pathToFileURL(process.argv[1]).href
) {
  main().catch((error) => {
    console.error(error instanceof Error ? error.message : error);
    process.exit(1);
  });
}
