/**
 * Development Stack with Automation Service
 *
 * Extends agent-canvas's dev-safe.mjs to additionally run the OpenHands Automation
 * backend via uvx. No cloning required - runs directly from git reference.
 *
 * Uses a standalone ingress proxy to route traffic to multiple backends.
 *
 * Architecture:
 *   ┌──────────────────────────────────────────────────────────────────────────┐
 *   │              http://localhost:8000 (Ingress Proxy)                       │
 *   │              /api/automation/* → Automation Backend                      │
 *   │              /api/*, /sockets  → Agent Server                            │
 *   │              /*                → Vite Dev Server                         │
 *   └──────────────────────────────────────────────────────────────────────────┘
 *          │                    │                         │
 *          ▼                    ▼                         ▼
 *   ┌─────────────┐    ┌───────────────┐         ┌──────────────────┐
 *   │ Vite        │    │ Agent Server  │         │ Automation       │
 *   │ :3001       │    │ (uvx) :18000  │         │ Backend (uvx)    │
 *   │             │    │               │         │ :18001           │
 *   └─────────────┘    └───────────────┘         └──────────────────┘
 *
 * Usage:
 *   node scripts/dev-with-automation.mjs
 *   node scripts/dev-with-automation.mjs --automation-ref feat/my-branch
 *   node scripts/dev-with-automation.mjs --port 12000
 *
 * Environment variables:
 *   - PORT: Ingress port (default: 8000)
 *   - OH_AUTOMATION_GIT_REF: Git ref for automation (default: main)
 *   - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a local software-agent-sdk
 *     checkout. Highest precedence for agent-server source selection: rebuilds
 *     the agent-server from local source and installs openhands-sdk,
 *     openhands-tools and openhands-workspace as editable so source edits are
 *     picked up without manual reinstall.
 *   - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server
 * Secrets:
 *   The session API key is automatically seeded into agent-server secrets
 *   as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
 *   Both the agent-server and automation backend use the same key value
 *   and the same `X-Session-API-Key` header for authentication.
 *   AUTOMATION_KV_SECRET is derived from the session key if not set explicitly,
 *   enabling the KV store out of the box for local development.
 */

import { spawn, spawnSync } from "node:child_process";
import { mkdirSync, existsSync, readFileSync } from "node:fs";
import { join, resolve, dirname, isAbsolute } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { homedir } from "node:os";
import { setTimeout as delay } from "node:timers/promises";
import process from "node:process";

import {
  assertPortsFree,
  buildAgentServerCommand,
  buildSafeDevConfig,
  buildAgentServerEnv,
  buildNpmScriptCommand,
  buildRuntimeServicesInfo,
  formatMissingUvxGuidance,
  validateFrontendDependencies,
  validateLocalAgentServerPath,
} from "./dev-safe.mjs";
import {
  createShutdownHookRegistry,
  getProcessTreeSpawnOptions,
  isProcessRunning,
  resolveWindowsCommand,
  signalProcessTree,
} from "./dev-process-utils.mjs";
import { fileLog, stripAnsi } from "./logger.mjs";

const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, "..");

// ── Centralized config (single source of truth for versions, ports, etc.) ───
const SHARED_DEFAULTS = JSON.parse(
  readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"),
);

const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation";
const DEFAULT_AUTOMATION_PACKAGE = SHARED_DEFAULTS.packages.automation;
const DEFAULT_AUTOMATION_VERSION = SHARED_DEFAULTS.versions.automation;
const DEFAULT_AUTOMATION_SDK_VERSION = SHARED_DEFAULTS.versions.agentServer;
const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer;
const DEFAULT_AUTOMATION_PORT = SHARED_DEFAULTS.ports.automation;
const DEFAULT_POSTHOG_API_KEY = SHARED_DEFAULTS.telemetry.posthogApiKey;
const DEFAULT_POSTHOG_HOST = SHARED_DEFAULTS.telemetry.posthogHost;

// ═══════════════════════════════════════════════════════════════════════════
// Terminal Styling
// ═══════════════════════════════════════════════════════════════════════════

const c = {
  reset: "\x1b[0m",
  bold: "\x1b[1m",
  dim: "\x1b[2m",
  red: "\x1b[31m",
  green: "\x1b[32m",
  yellow: "\x1b[33m",
  blue: "\x1b[34m",
  magenta: "\x1b[35m",
  cyan: "\x1b[36m",
};

function logService(name, message, color = c.reset) {
  const ts = new Date().toISOString().split("T")[1].split(".")[0];
  console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`);
  fileLog("info", `[${name}] ${stripAnsi(message)}`);
}

function logStep(step, message) {
  console.log(`${c.cyan}[${step}]${c.reset} ${message}`);
  fileLog("info", `[${step}] ${message}`);
}

function logSuccess(message) {
  console.log(`${c.green}✓${c.reset} ${message}`);
  fileLog("info", `✓ ${message}`);
}

function logError(message) {
  console.error(`${c.red}✗${c.reset} ${message}`);
  fileLog("error", `✗ ${stripAnsi(message)}`);
}

/**
 * Parse one JSON log line produced by the SDK's JsonFormatter and return a
 * single-line human-readable string + an appropriate ANSI color.
 *
 * Returns null for non-JSON lines so callers can fall back to the raw text.
 *
 * @param {string} rawLine
 * @returns {{ text: string; color: string } | null}
 */
function parseAgentServerLogLine(rawLine) {
  try {
    const obj = JSON.parse(rawLine);
    if (!obj.levelname || obj.message === undefined) return null;
    const level = obj.levelname.padEnd(8);
    const location =
      obj.filename && obj.lineno ? `  ${obj.filename}:${obj.lineno}` : "";
    const text = `${level} ${obj.message}${location}`;
    const lvl = obj.levelname;
    const color =
      lvl === "DEBUG"
        ? c.dim
        : lvl === "WARNING"
          ? c.yellow
          : lvl === "ERROR" || lvl === "CRITICAL"
            ? c.red
            : c.blue;
    return { text, color };
  } catch {
    return null;
  }
}

// ═══════════════════════════════════════════════════════════════════════════
// Configuration
// ═══════════════════════════════════════════════════════════════════════════

function parseArgs() {
  const args = process.argv.slice(2);
  const config = {
    port: null,
    automationGitRef: null,
    automationRepo: null,
    verbose: false,
    static: false,
    dynamic: false,
    staticDir: null,
    skipBuild: false,
    public: false,
    frontendOnly: false,
    backendOnly: false,
  };

  for (let i = 0; i < args.length; i++) {
    switch (args[i]) {
      case "-p":
      case "--port":
        config.port = parseInt(args[++i], 10);
        break;
      case "--automation-ref":
        config.automationGitRef = args[++i];
        break;
      case "--automation-repo":
        config.automationRepo = args[++i];
        break;
      case "-v":
      case "--verbose":
        config.verbose = true;
        break;
      case "--static":
        config.static = true;
        break;
      case "--dynamic":
        config.dynamic = true;
        break;
      case "--static-dir":
        config.staticDir = args[++i];
        break;
      case "--skip-build":
        config.skipBuild = true;
        break;
      case "--public":
        config.public = true;
        break;
      case "--frontend-only":
        config.frontendOnly = true;
        break;
      case "--backend-only":
        config.backendOnly = true;
        break;
      case "-h":
      case "--help":
        showHelp();
        process.exit(0);
    }
  }

  return config;
}

function showHelp() {
  console.log(`
Agent Canvas + Automation Development Stack

Runs agent-canvas with the automation backend (via uvx, no clone needed).
Uses a standalone ingress proxy to route traffic.

USAGE:
  node scripts/dev-with-automation.mjs [options]

OPTIONS:
  -p, --port <port>           Ingress port (default: 8000)
  --automation-ref <ref>      Git ref for automation (branch/tag/SHA)
  --automation-repo <url>     Git repo URL (default: ${DEFAULT_AUTOMATION_REPO})
  --static                    Serve an existing production build instead of Vite
  --static-dir <dir>          Static build directory (default: build/)
  --skip-build                Reuse build/ when the launcher builds static assets
  --dynamic                   Force Vite dev server when a wrapper defaults static
  --frontend-only             Start only the frontend behind ingress
  --backend-only              Start only agent-server + automation behind ingress
  -v, --verbose               Show detailed output
  -h, --help                  Show this help

ENVIRONMENT VARIABLES:
  PORT                        Alternative to --port
  OH_AUTOMATION_GIT_REF       Git ref for automation (overrides default version)
  OH_AUTOMATION_VERSION       Specific PyPI version for automation (default: ${DEFAULT_AUTOMATION_VERSION})
  OH_AUTOMATION_LOCAL_PATH    Absolute path to a local automation checkout (overridden only by --automation-git-ref)
  OH_AGENT_SERVER_LOCAL_PATH  Absolute path to a local software-agent-sdk checkout (highest precedence)
  OH_AGENT_SERVER_GIT_REF     Git ref for agent-server SDK (overrides default version)
  OH_AGENT_SERVER_VERSION     Specific PyPI version for agent-server
  OH_SECRET_KEY               Secret key for sessions

SECRETS:
  The session API key is automatically seeded into agent-server secrets
  as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
  Both backends (agent-server and automation) share the same key value.
  AUTOMATION_KV_SECRET defaults to the session key so the KV store works
  out of the box; override with an explicit value for stronger isolation.

ACCESS POINTS:
  Main UI:      http://localhost:PORT/
  API Docs:     http://localhost:PORT/api/automation/docs
`);
}

/**
 * Fail fast on an unusable OH_AUTOMATION_LOCAL_PATH instead of letting
 * `uv run --project <bad path>` exit on its own -- that leaves the rest of the
 * stack up and the automations UI just reporting "backend unavailable", with
 * nothing pointing at the env var. Mirrors validateLocalAgentServerPath.
 */
function validateLocalAutomationPath(localPath) {
  if (!isAbsolute(localPath)) {
    throw new Error(
      `OH_AUTOMATION_LOCAL_PATH must be an absolute path, got: ${localPath}`,
    );
  }
  if (!existsSync(localPath)) {
    throw new Error(`OH_AUTOMATION_LOCAL_PATH does not exist: ${localPath}`);
  }
  const projectFile = join(localPath, "pyproject.toml");
  if (!existsSync(projectFile)) {
    throw new Error(
      `OH_AUTOMATION_LOCAL_PATH is not a Python project (no pyproject.toml): ${projectFile}`,
    );
  }
}

/**
 * Build the uvx command for running automation backend.
 *
 * Environment variables (highest precedence first):
 * - OH_AUTOMATION_LOCAL_PATH: Absolute path to a local checkout
 * - OH_AUTOMATION_GIT_REF: Git commit SHA or branch name
 * - OH_AUTOMATION_VERSION: Specific PyPI version (e.g., "1.0.0a1")
 *
 * If none are set, defaults to the released version specified by
 * DEFAULT_AUTOMATION_VERSION. Set OH_AUTOMATION_GIT_REF to use a
 * git branch or commit instead.
 */
function buildAutomationCommand(env = process.env) {
  const localPath = env.OH_AUTOMATION_LOCAL_PATH;
  const gitRef = env.OH_AUTOMATION_GIT_REF;
  const version = env.OH_AUTOMATION_VERSION;
  const repoUrl = env.OH_AUTOMATION_REPO || DEFAULT_AUTOMATION_REPO;

  const uvxArgs = [];
  let source = "";

  if (localPath) {
    // Run straight from a local checkout via `uv run --project`, so
    // uncommitted working-tree changes are picked up. Outranks the other
    // automation env vars, mirroring OH_AGENT_SERVER_LOCAL_PATH for the
    // agent-server SDK; buildConfig drops it when --automation-git-ref asks
    // for a specific ref.
    return {
      command: "uv",
      args: [
        "run",
        "--project",
        localPath,
        "uvicorn",
        "openhands.automation.app:app",
      ],
      source: `local (${localPath})`,
    };
  }

  if (gitRef) {
    // Use git ref - refresh to ensure latest commit is fetched
    const gitUrl = `git+${repoUrl}@${gitRef}`;
    uvxArgs.push(
      "--refresh",
      "--from",
      gitUrl,
      "uvicorn",
      "openhands.automation.app:app",
    );
    source = `git (${gitRef})`;
  } else if (version) {
    // Use specific PyPI version
    uvxArgs.push(
      "--from",
      `${DEFAULT_AUTOMATION_PACKAGE}==${version}`,
      "uvicorn",
      "openhands.automation.app:app",
    );
    source = `PyPI (${version})`;
  } else {
    // Default to released PyPI version
    uvxArgs.push(
      "--from",
      `${DEFAULT_AUTOMATION_PACKAGE}==${DEFAULT_AUTOMATION_VERSION}`,
      "uvicorn",
      "openhands.automation.app:app",
    );
    source = `PyPI (${DEFAULT_AUTOMATION_VERSION}, default)`;
  }

  return {
    command: "uvx",
    args: uvxArgs,
    source,
  };
}

async function buildConfig(args, env = process.env) {
  // Apply args to env for buildAutomationCommand
  if (args.automationGitRef) {
    env.OH_AUTOMATION_GIT_REF = args.automationGitRef;
    // An explicit flag outranks an ambient env var. Otherwise someone with
    // OH_AUTOMATION_LOCAL_PATH exported in their shell profile would run their
    // own working tree while believing they were reproducing against the ref
    // they just passed.
    if (env.OH_AUTOMATION_LOCAL_PATH) {
      logStep(
        "automation",
        `--automation-git-ref ${args.automationGitRef} overrides OH_AUTOMATION_LOCAL_PATH (${env.OH_AUTOMATION_LOCAL_PATH})`,
      );
      delete env.OH_AUTOMATION_LOCAL_PATH;
    }
  }
  if (args.automationRepo) {
    env.OH_AUTOMATION_REPO = args.automationRepo;
  }

  const frontendOnly = Boolean(args.frontendOnly);
  const backendOnly = Boolean(args.backendOnly);
  if (frontendOnly && backendOnly) {
    throw new Error(
      "--frontend-only and --backend-only cannot be used together",
    );
  }

  const launchFrontend = !backendOnly;
  const launchAgentServer = !frontendOnly;
  const launchAutomation = !frontendOnly;
  const isPublic = args.public;

  if (isPublic && frontendOnly) {
    throw new Error("--public cannot be used with --frontend-only");
  }

  // In public mode, LOCAL_BACKEND_API_KEY is required — without it the
  // auth screen has nothing to validate against.
  if (isPublic && !env.LOCAL_BACKEND_API_KEY) {
    logError(
      "PUBLIC MODE requires LOCAL_BACKEND_API_KEY environment variable.\n" +
        "  Example: LOCAL_BACKEND_API_KEY=my-secret npm run dev -- --public",
    );
    process.exit(1);
  }

  // Preferred ports (from env or defaults).
  // OH_CANVAS_SAFE_BACKEND_PORT / OH_CANVAS_SAFE_AUTOMATION_PORT /
  // OH_CANVAS_SAFE_VITE_PORT allow tests (and advanced users) to redirect
  // internal service ports without affecting the production default.
  const preferredIngressPort = args.port || parseInt(env.PORT, 10) || 8000;
  const preferredBackendPort =
    parseInt(env.OH_CANVAS_SAFE_BACKEND_PORT, 10) || DEFAULT_BACKEND_PORT;
  const preferredAutomationPort =
    parseInt(env.OH_CANVAS_SAFE_AUTOMATION_PORT, 10) || DEFAULT_AUTOMATION_PORT;
  const preferredVitePort = parseInt(env.OH_CANVAS_SAFE_VITE_PORT, 10) || 3001;

  // Fail fast if any preferred port for a service in this mode is already in use.
  const requiredPorts = [{ name: "ingress", port: preferredIngressPort }];
  if (launchAgentServer) {
    requiredPorts.push({ name: "agent-server", port: preferredBackendPort });
  }
  if (launchAutomation) {
    requiredPorts.push({ name: "automation", port: preferredAutomationPort });
  }
  if (launchFrontend) {
    requiredPorts.push({ name: "frontend", port: preferredVitePort });
  }

  logStep("ports", "Checking ports...");
  await assertPortsFree(requiredPorts);

  const vscodePort = preferredBackendPort + 1000;

  // API key — shared by both agent-server and automation backend.
  // Both validate it via the `X-Session-API-Key` header.
  // LOCAL_BACKEND_API_KEY is the single user-facing env var: if set it's
  // used directly; otherwise one is auto-generated and persisted.
  const stateDir =
    env.OH_CANVAS_SAFE_STATE_DIR ||
    join(homedir(), ".openhands", "agent-canvas");

  const safeConfig = buildSafeDevConfig(projectRoot, {
    ...env,
    OH_CANVAS_SAFE_STATE_DIR: stateDir,
    OH_CANVAS_SAFE_BACKEND_PORT: preferredBackendPort.toString(),
    OH_CANVAS_SAFE_VSCODE_PORT: vscodePort.toString(),
  });
  const sessionApiKey = safeConfig.sessionApiKey;

  if (isPublic) {
    logService(
      "auth",
      "PUBLIC MODE — key will NOT be injected into the frontend",
      c.yellow,
    );
    logService(
      "auth",
      "Users must paste the LOCAL_BACKEND_API_KEY in the browser",
      c.dim,
    );
  }

  return {
    // Ingress port (main entry point)
    ingressPort: preferredIngressPort,

    // Service ports (internal)
    agentServerPort: preferredBackendPort,
    autoBackendPort: preferredAutomationPort,
    vitePort: preferredVitePort,
    vscodePort,
    // Prefix the editor is served under on the ingress origin. Carried on the
    // config so the route table and the agent-server env are built from one
    // value (see getLocalServiceRoutes / buildAgentServerEnv).
    vscodeBasePath: safeConfig.vscodeBasePath,

    // Paths
    canvasPath: projectRoot,

    // Data directories (same as dev-safe.mjs)
    stateDir,
    // Only bake the host-side workspace path when this launcher also starts
    // the agent-server that can read it. In frontend-only mode the backend may
    // be a tunnel/remote service, so leave VITE_WORKING_DIR unset unless the
    // user explicitly supplied a backend-relative value.
    viteWorkingDir: launchAgentServer
      ? safeConfig.workingDir
      : env.VITE_WORKING_DIR,

    // Auth — single key for both backends
    sessionApiKey,

    // Public mode — the session key should NOT be baked into the frontend
    isPublic,

    frontendOnly,
    backendOnly,
    launchFrontend,
    launchAgentServer,
    launchAutomation,

    verbose: args.verbose,
  };
}

// ═══════════════════════════════════════════════════════════════════════════
// Prerequisites & Setup
// ═══════════════════════════════════════════════════════════════════════════

function commandExists(cmd) {
  const result =
    process.platform === "win32"
      ? spawnSync("where.exe", [cmd], { stdio: "pipe" })
      : spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" });

  return result.status === 0;
}

function checkPrerequisites({
  checkUvx = true,
  checkNpm = true,
  checkFrontendDependencies = true,
} = {}) {
  logStep("1/2", "Checking prerequisites...");

  if (checkUvx) {
    if (!commandExists("uvx")) {
      const uvxGuidance = formatMissingUvxGuidance(projectRoot);
      console.error(uvxGuidance);
      fileLog("error", stripAnsi(uvxGuidance));
      process.exit(1);
    }
    logSuccess("uvx found");
  }

  if (checkNpm) {
    if (!commandExists("npm")) {
      logError("npm is required but not found");
      process.exit(1);
    }
    logSuccess("npm found");
  }

  if (checkFrontendDependencies) {
    try {
      validateFrontendDependencies(projectRoot);
    } catch (error) {
      logError(error instanceof Error ? error.message : String(error));
      process.exit(1);
    }
    logSuccess("frontend dependencies found");
  }
}

function ensureDirectories(config) {
  const dirs = [
    config.stateDir,
    // Both agent-server and automation use storage; create it unconditionally
    // whenever either backend service runs (i.e. not frontend-only).
    ...(!config.frontendOnly ? [join(config.stateDir, "storage")] : []),
  ];

  if (config.launchAgentServer) {
    dirs.push(
      join(config.stateDir, "dev_conversations"),
      join(config.stateDir, "workspaces"),
      join(config.stateDir, "bash_events"),
    );
  }

  if (config.launchAutomation) {
    dirs.push(
      // Automation DB directory — matches docker/entrypoint.sh mkdir -p behaviour.
      dirname(
        join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb),
      ),
    );
  }

  for (const dir of dirs) {
    mkdirSync(dir, { recursive: true });
  }
}

// ═══════════════════════════════════════════════════════════════════════════
// Process Management
// ═══════════════════════════════════════════════════════════════════════════

const processes = new Map();
const shutdownHooks = createShutdownHookRegistry((err) => {
  logService("cleanup", `Cleanup hook failed: ${err.message}`, c.yellow);
});

// Optional external listener for every service log line. Set by `main()` from
// its `onServiceLog` option so embedded launchers (e.g. the Electron desktop
// app) can stream uvx download / install progress to their loading window
// without touching the terminal logging path. Receives `(name, line, level)`
// where `level` is one of "stdout" | "stderr" | "info" | "warn" | "error".
let serviceLogListener = null;

export function setServiceLogListener(listener) {
  serviceLogListener = typeof listener === "function" ? listener : null;
}

function emitServiceLog(name, line, level) {
  if (!serviceLogListener) return;
  try {
    serviceLogListener(name, line, level);
  } catch {
    // Never let a listener bug crash the dev stack.
  }
}

function registerShutdownHook(hook) {
  return shutdownHooks.add(hook);
}

function spawnService(name, command, args, options = {}) {
  const proc = spawn(
    resolveWindowsCommand(command),
    args,
    getProcessTreeSpawnOptions({
      stdio: ["ignore", "pipe", "pipe"],
      env: { ...process.env, ...options.env },
      cwd: options.cwd,
    }),
  );

  const color = options.color || c.reset;
  const parseLogLine = options.parseLogLine;

  proc.stdout.on("data", (data) => {
    data
      .toString()
      .split("\n")
      .filter(Boolean)
      .forEach((line) => {
        const trimmed = line.trim();
        const parsed = parseLogLine ? parseLogLine(trimmed) : null;
        logService(
          name,
          parsed ? parsed.text : trimmed,
          parsed ? parsed.color : color,
        );
        emitServiceLog(name, trimmed, "stdout");
      });
  });

  proc.stderr.on("data", (data) => {
    data
      .toString()
      .split("\n")
      .filter(Boolean)
      .forEach((line) => {
        const trimmed = line.trim();
        const parsed = parseLogLine ? parseLogLine(trimmed) : null;
        logService(
          name,
          parsed ? parsed.text : trimmed,
          parsed ? parsed.color : c.yellow,
        );
        emitServiceLog(name, trimmed, "stderr");
      });
  });

  proc.on("error", (error) => {
    logError(`${name} failed to start: ${error.message}`);
    emitServiceLog(name, `failed to start: ${error.message}`, "error");
  });

  proc.on("exit", (code, _signal) => {
    if (code !== 0 && code !== null && !shuttingDown) {
      logService(name, `Exited with code ${code}`, c.red);
      emitServiceLog(name, `exited with code ${code}`, "error");
    }
    processes.delete(name);
  });

  processes.set(name, proc);
  return proc;
}

async function waitForService(name, url, timeoutMs = 30000) {
  const start = Date.now();
  let lastError = null;

  while (Date.now() - start < timeoutMs) {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
      if (res.ok) {
        logService(name, `Ready at ${url}`, c.green);
        return true;
      }
    } catch (err) {
      lastError = err;
      // Keep trying
    }
    await delay(500);
  }

  const elapsed = Math.round((Date.now() - start) / 1000);
  logService(name, `Timeout waiting for ${url} after ${elapsed}s`, c.red);
  if (lastError) {
    logService(name, `Last error: ${lastError.message}`, c.dim);
  }
  return false;
}

// ═══════════════════════════════════════════════════════════════════════════
// Service Starters
// ═══════════════════════════════════════════════════════════════════════════

const AUTOMATION_ROUTE_PREFIX = "/api/automation";
const AGENT_SERVER_ROUTE_PREFIXES = [
  "/api",
  "/sockets",
  "/server_info",
  "/health",
  "/ready",
  "/alive",
  "/docs",
  "/redoc",
  "/openapi.json",
];

// This launcher starts the agent-server with `--host 127.0.0.1`, but localhost
// can resolve to ::1 first (notably on Windows), so every request this process
// or the automation backend makes to it must address IPv4 explicitly.
function getAgentServerBaseUrl(config) {
  return `http://127.0.0.1:${config.agentServerPort}`;
}

function getLocalServiceRoutes(config) {
  const routes = [];

  // These services bind to IPv4 loopback, but localhost can resolve to ::1.
  if (config.launchAutomation) {
    routes.push([
      AUTOMATION_ROUTE_PREFIX,
      `http://127.0.0.1:${config.autoBackendPort}`,
    ]);
  }

  if (config.launchAgentServer) {
    for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
      routes.push([prefix, getAgentServerBaseUrl(config)]);
    }

    // The editor is a separate process on its own port, but it is reached
    // through the same origin as the canvas so no second port has to be
    // published. The prefix is deliberately preserved rather than stripped:
    // agent-server launches openvscode-server with `--server-base-path`, so
    // the editor generates its own HTTP and WebSocket URLs beneath the prefix
    // and only answers there. `createRouter` matches the longest prefix and
    // the proxy forwards the original path, so both are already handled.
    if (config.vscodeBasePath) {
      routes.push([
        config.vscodeBasePath,
        `http://127.0.0.1:${config.vscodePort}`,
      ]);
    }
  }

  return routes;
}

function buildRouteArgs(routes) {
  return routes.flatMap(([prefix, url]) => ["--route", `${prefix}=${url}`]);
}

/**
 * The editor prefix, if this mode serves it, as `--no-referrer-prefix` args.
 *
 * agent-server hands the editor a connection token derived from its session
 * key and advertises it in the URL's query string, so the workbench document
 * must not leak a Referer to the subresources it loads.
 */
function getNoReferrerPrefixArgs(config) {
  if (!config.launchAgentServer || !config.vscodeBasePath) return [];
  return ["--no-referrer-prefix", config.vscodeBasePath];
}

/**
 * The editor prefix, if this mode serves it, as `--vscode-base-path` args.
 *
 * Gated on exactly the same condition as the editor route in
 * `getLocalServiceRoutes`, because they answer the same question: an origin
 * advertises the editor if and only if it routes it. static-server enforces
 * that pairing at startup, so a future edit that breaks it fails loudly rather
 * than shipping a control that opens the SPA.
 */
function getVSCodeAdvertiseArgs(config) {
  if (!config.launchAgentServer || !config.vscodeBasePath) return [];
  return ["--vscode-base-path", config.vscodeBasePath];
}

/**
 * Build --reject-prefix args for the static server.
 * In frontend-only mode, API paths that have no backend should return 503
 * instead of being SPA-fallbacked to index.html.
 */
function getRejectPrefixes(config) {
  const prefixes = [];
  if (!config.launchAutomation) {
    prefixes.push(AUTOMATION_ROUTE_PREFIX);
  }
  if (!config.launchAgentServer) {
    for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
      prefixes.push(prefix);
    }
    // No agent-server means no editor behind this prefix either. Reject it
    // rather than SPA-fallbacking to index.html, which would answer an editor
    // request with the canvas shell.
    if (config.vscodeBasePath) {
      prefixes.push(config.vscodeBasePath);
    }
  }
  return prefixes;
}

function buildRejectPrefixArgs(prefixes) {
  return prefixes.flatMap((prefix) => ["--reject-prefix", prefix]);
}

function getFrontendBackend(config) {
  return config.launchFrontend ? `http://localhost:${config.vitePort}` : null;
}

function buildViteBackendEnv(config, env = process.env) {
  // VITE_BACKEND_HOST tells the Vite dev-server proxy (vite.config.ts) where
  // to forward /api, /sockets, etc.  It is NOT read by the frontend at
  // runtime, so it is safe to keep as an absolute address.
  //
  // VITE_BACKEND_BASE_URL is intentionally left unset so the frontend falls
  // back to window.location.origin (same-origin) at runtime — matching the
  // behaviour of dev:static / agent-canvas and keeping the dev server
  // portable across localhost, LAN hosts, SSH tunnels, and ngrok.
  const backendHost = config.launchAgentServer
    ? `127.0.0.1:${config.ingressPort}`
    : (env.VITE_BACKEND_HOST ??
      env.VITE_BACKEND_BASE_URL?.replace(/^https?:\/\//, "") ??
      "127.0.0.1:8000");

  const env_out = { VITE_BACKEND_HOST: backendHost };

  // If the user supplied VITE_BACKEND_BASE_URL with an https:// scheme and
  // did not explicitly set VITE_USE_TLS, propagate the HTTPS intent so the
  // Vite proxy forwards over TLS instead of plain HTTP.
  if (
    !config.launchAgentServer &&
    env.VITE_BACKEND_BASE_URL?.startsWith("https://") &&
    env.VITE_USE_TLS === undefined
  ) {
    env_out.VITE_USE_TLS = "true";
  }

  return env_out;
}

function buildAgentServerAutomationEnv(config) {
  return {
    // Make the session API key available to terminal commands spawned by the
    // agent-server as OPENHANDS_AUTOMATION_API_KEY. The launcher also seeds
    // this into Settings > Secrets, but agents commonly create automations
    // with a curl command that references `$OPENHANDS_AUTOMATION_API_KEY`;
    // exposing it here keeps that path working even before/without
    // secret-registry env expansion.
    OPENHANDS_AUTOMATION_API_KEY: config.sessionApiKey,
  };
}

function buildAutomationTelemetryEnv(env = process.env) {
  const telemetryDisabled = env.VITE_DO_NOT_TRACK === "1";
  const apiKey =
    env.AUTOMATION_POSTHOG_API_KEY ||
    env.VITE_POSTHOG_API_KEY ||
    (telemetryDisabled ? "" : DEFAULT_POSTHOG_API_KEY);

  if (!apiKey) return {};

  return {
    AUTOMATION_POSTHOG_API_KEY: apiKey,
    AUTOMATION_POSTHOG_HOST:
      env.AUTOMATION_POSTHOG_HOST ||
      env.VITE_POSTHOG_HOST ||
      DEFAULT_POSTHOG_HOST,
  };
}

function startAgentServer(config) {
  logService(
    "agent-server",
    `Starting on port ${config.agentServerPort}...`,
    c.blue,
  );

  const agentServerCmd = buildAgentServerCommand(process.env);
  logService("agent-server", `Using ${agentServerCmd.source}`, c.dim);

  // Build safe config for agent-server env vars
  const safeConfig = buildSafeDevConfig(config.canvasPath, {
    ...process.env,
    OH_CANVAS_SAFE_STATE_DIR: config.stateDir,
    OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(),
    OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(),
  });

  const agentServerEnv = {
    // Opt into prefix-mode: `getLocalServiceRoutes` registers the matching
    // route on both the static server and the ingress, so the prefix this
    // advertises resolves to the editor port on the canvas origin.
    ...buildAgentServerEnv(safeConfig, {
      vscodeBasePath: config.vscodeBasePath,
    }),
    ...buildAgentServerAutomationEnv(config),
    OPENHANDS_REMOTE_WS_READY_REQUIRED:
      process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
    // Ensure the agent-server uses the resolved key from config. This is
    // LOCAL_BACKEND_API_KEY when set, or the auto-generated persisted key.
    OH_SESSION_API_KEYS_0: config.sessionApiKey,
    // Emit structured JSON log lines instead of Rich-formatted output.
    // Rich wraps long messages across multiple lines and prepends its own
    // timestamp; LOG_JSON=true produces one JSON object per record which
    // parseAgentServerLogLine re-formats into a clean single-line entry.
    LOG_JSON: "true",
  };

  spawnService(
    "agent-server",
    agentServerCmd.command,
    [
      ...agentServerCmd.args,
      "--host",
      "127.0.0.1",
      "--port",
      String(config.agentServerPort),
    ],
    {
      cwd: safeConfig.workspacesPath,
      env: agentServerEnv,
      color: c.blue,
      parseLogLine: parseAgentServerLogLine,
    },
  );
}

function startAutomationBackend(config) {
  logService(
    "automation",
    `Starting on port ${config.autoBackendPort}...`,
    c.green,
  );

  const automationCmd = buildAutomationCommand(process.env);
  logService("automation", `Using ${automationCmd.source}`, c.dim);

  spawnService(
    "automation",
    automationCmd.command,
    [
      ...automationCmd.args,
      "--host",
      "127.0.0.1",
      "--port",
      config.autoBackendPort.toString(),
    ],
    {
      cwd: config.stateDir,
      env: {
        // Force UTF-8 for all Python file I/O (same reason as agent-server;
        // see buildAgentServerEnv in dev-safe.mjs).
        PYTHONUTF8: "1",
        OPENHANDS_REMOTE_WS_READY_REQUIRED:
          process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
        // The URL the automation backend itself uses to call the
        // agent-server's REST API (tarball upload + bash dispatch).
        //
        // Priority:
        //   1. AUTOMATION_AGENT_SERVER_URL explicitly set in the user's env
        //   2. `127.0.0.1:<agentServerPort>`
        AUTOMATION_AGENT_SERVER_URL:
          process.env.AUTOMATION_AGENT_SERVER_URL ||
          getAgentServerBaseUrl(config),
        // The URL exported into the in-sandbox bash chain as
        // `AGENT_SERVER_URL` (read by main.py / setup.sh to call back into
        // the agent-server).
        //
        // Priority:
        //   1. AUTOMATION_SANDBOX_AGENT_SERVER_URL explicitly set in env
        //   2. launcher-provided value
        //   3. unset — backend falls back to AUTOMATION_AGENT_SERVER_URL
        ...(process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL ||
        config.sandboxAgentServerUrl
          ? {
              AUTOMATION_SANDBOX_AGENT_SERVER_URL:
                process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL ||
                config.sandboxAgentServerUrl,
            }
          : {}),
        AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey,
        // ~/.openhands/automation/automations.db — matches docker/entrypoint.sh.
        AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb)}`,
        // The automation backend uses this as its publicly-reachable base
        // URL: it's appended to callback URLs and injected into each
        // sandbox as `AUTOMATION_API_URL` (consumed by setup.sh for
        // /sdk-version and by the SDK for run completion).
        // Priority:
        //   1. AUTOMATION_BASE_URL explicitly set in the user's env
        //   2. launcher-provided host
        //   3. `localhost`
        AUTOMATION_BASE_URL:
          process.env.AUTOMATION_BASE_URL ||
          `http://${config.automationApiHost ?? "localhost"}:${config.ingressPort}`,
        // The dispatcher resolves this path and embeds it into a
        // `mkdir -p ...` shell command executed by the agent-server.
        // Priority:
        //   1. AUTOMATION_WORKSPACE_BASE explicitly set in the user's env
        //   2. `automationWorkspaceBase` option passed by the launcher
        //   3. host-side default under config.stateDir
        AUTOMATION_WORKSPACE_BASE:
          process.env.AUTOMATION_WORKSPACE_BASE ||
          config.automationWorkspaceBase ||
          join(config.stateDir, "workspaces"),
        // Session API key for self-hosted auth — shared with agent-server via X-Session-API-Key header
        AUTOMATION_LOCAL_API_KEY: config.sessionApiKey,
        ...buildAutomationTelemetryEnv(),
        // KV store secret — required for automations to use the built-in
        // key-value store for state persistence between runs. Used for JWT
        // signing and value encryption.
        // Priority:
        //   1. AUTOMATION_KV_SECRET explicitly set in the user's env
        //   2. sessionApiKey — convenient zero-config default for local dev
        AUTOMATION_KV_SECRET:
          process.env.AUTOMATION_KV_SECRET || config.sessionApiKey,
        // CORS: allow localhost origins for dev, unless explicitly overridden.
        AUTOMATION_CORS_ORIGINS:
          process.env.AUTOMATION_CORS_ORIGINS ||
          `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`,
        FILE_STORE: "local",
        LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
        OPENHANDS_SUPPRESS_BANNER: "1",
      },
      color: c.green,
    },
  );
}

// ═══════════════════════════════════════════════════════════════════════════
// Main
// ═══════════════════════════════════════════════════════════════════════════

let shuttingDown = false;

function shutdown() {
  if (shuttingDown) return;
  shuttingDown = true;

  console.log("");
  console.log(`${c.yellow}Shutting down...${c.reset}`);
  fileLog("info", "Shutting down...");

  for (const [name, proc] of processes) {
    logService(name, "Stopping...", c.dim);
    signalProcessTree(proc, "SIGTERM");
  }

  setTimeout(() => {
    for (const [name, proc] of processes) {
      if (isProcessRunning(proc)) {
        logService(name, "Force stopping...", c.dim);
        signalProcessTree(proc, "SIGKILL");
      }
    }
    shutdownHooks.run();
    process.exit(0);
  }, 3000);
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
process.on("SIGHUP", shutdown);

function startIngress(config) {
  logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);

  const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
  const frontendBackend = getFrontendBackend(config);
  const runtimeServicesInfo = config.launchAgentServer
    ? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
    : null;

  spawnService(
    "ingress",
    "node",
    [
      ingressScript,
      "--port",
      config.ingressPort.toString(),
      ...(runtimeServicesInfo
        ? ["--runtime-services-info", runtimeServicesInfo]
        : []),
      ...buildRouteArgs(getLocalServiceRoutes(config)),
      ...getNoReferrerPrefixArgs(config),
      ...(frontendBackend ? ["--default", frontendBackend] : []),
    ],
    {
      cwd: projectRoot,
      color: c.yellow,
    },
  );
}

/**
 * Build the JSON-serializable runtime services info for an automation
 * stack. Backend-serving processes append this to `/server_info` so any
 * frontend connected to the backend can populate the agent's
 * `<RUNTIME_SERVICES>` system-prompt block.
 */
export function buildAutomationRuntimeServicesInfo(config) {
  return buildRuntimeServicesInfo({
    mode: config.mode ?? "dev:automation",
    agentHostAlias: config.agentHostAlias ?? "localhost",
    agentServerPort: config.agentServerPort,
    ingressPort: config.ingressPort,
    frontendPort: config.launchFrontend ? config.vitePort : undefined,
    // The same port hosts Vite in dynamic mode and a static-file server
    // in static mode. The launcher records this on the config so the
    // description shown to the agent matches reality.
    frontendKind: config.frontendKind ?? "vite",
    automation: config.launchAutomation
      ? { port: config.autoBackendPort }
      : undefined,
  });
}

function startVite(config) {
  logService("vite", `Starting on port ${config.vitePort}...`, c.magenta);

  const frontendCommand = buildNpmScriptCommand("dev:frontend");

  const viteEnv = {
    // Full-stack mode points Vite at this launcher's ingress. Frontend-only
    // mode uses the separately running backend ingress instead.
    ...buildViteBackendEnv(config),
    VITE_FRONTEND_PORT: config.vitePort.toString(),
  };
  if (config.viteWorkingDir) {
    viteEnv.VITE_WORKING_DIR = config.viteWorkingDir;
  }

  // Vite serves the HTML for this mode's browser origin, so this is where the
  // editor-capability advertisement has to be baked. The ingress in front of it
  // routes the prefix but is a pure proxy — it injects nothing into the
  // document, so it cannot tell the frontend what it serves.
  //
  // Both variables or neither: `vite.config.ts` only registers the editor proxy
  // when it has a target as well as a prefix, and this stack has two supported
  // browser origins — the ingress and Vite's own port, which is why the latter
  // is in AUTOMATION_CORS_ORIGINS. On the ingress the prefix is routed by the
  // ingress itself; on the Vite origin only this proxy can serve it. Baking the
  // prefix alone would advertise an editor on the Vite origin whose URL then
  // falls through to the SPA — the dead button this gating exists to prevent.
  if (config.launchAgentServer && config.vscodeBasePath) {
    viteEnv.VITE_VSCODE_BASE_PATH = config.vscodeBasePath;
    viteEnv.VITE_VSCODE_TARGET = `http://127.0.0.1:${config.vscodePort}`;
  }

  // In local mode, bake the session key into the frontend so the user
  // never has to paste it. In public mode, omit the key and set
  // VITE_AUTH_REQUIRED so the frontend shows the API key entry screen
  // immediately (no network round-trip needed).
  if (config.launchAgentServer && config.isPublic) {
    viteEnv.VITE_AUTH_REQUIRED = "true";
  } else if (config.launchAgentServer) {
    viteEnv.VITE_SESSION_API_KEY = config.sessionApiKey;
  }

  spawnService("vite", frontendCommand.command, frontendCommand.args, {
    cwd: config.canvasPath,
    env: viteEnv,
    color: c.magenta,
  });
}

/**
 * Seed the session API key into agent-server's secrets store as
 * OPENHANDS_AUTOMATION_API_KEY so agents can authenticate with the
 * automation backend in curl commands during conversations.
 *
 * Includes retry logic to handle slow server startup or transient failures.
 *
 * @param {object} config - Configuration object with agentServerPort, sessionApiKey
 * @param {object} options - Options for retry behavior
 * @param {number} options.maxRetries - Maximum number of retry attempts (default: 5)
 * @param {number} options.retryDelayMs - Delay between retries in ms (default: 2000)
 * @param {number} options.timeoutMs - Request timeout in ms (default: 10000)
 * @returns {Promise<boolean>} True if seeding succeeded, false otherwise
 */
async function seedAutomationSecret(config, options = {}) {
  const { maxRetries = 5, retryDelayMs = 2000, timeoutMs = 10000 } = options;

  const secretName = "OPENHANDS_AUTOMATION_API_KEY";
  const secretDescription =
    "API key for authenticating with the automation backend";

  logService("secrets", `Seeding ${secretName} into agent-server...`, c.dim);

  const url = `${getAgentServerBaseUrl(config)}/api/settings/secrets`;
  const body = JSON.stringify({
    name: secretName,
    value: config.sessionApiKey,
    description: secretDescription,
  });

  const headers = {
    "Content-Type": "application/json",
    // Include session API key if configured
    ...(config.sessionApiKey && { "X-Session-API-Key": config.sessionApiKey }),
  };

  let lastError = null;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, {
        method: "PUT",
        headers,
        body,
        signal: AbortSignal.timeout(timeoutMs),
      });

      if (response.ok) {
        logService("secrets", `${secretName} seeded successfully`, c.green);
        return true;
      }

      const text = await response.text();
      lastError = `HTTP ${response.status}: ${text}`;

      // Don't retry on authentication errors - they won't resolve with retries
      if (response.status === 401 || response.status === 403) {
        logService(
          "secrets",
          `Warning: Failed to seed secret (${response.status}): ${text}`,
          c.yellow,
        );
        return false;
      }

      // Retry on server errors or service unavailable
      if (attempt < maxRetries) {
        logService(
          "secrets",
          `Retry ${attempt}/${maxRetries} after ${response.status}...`,
          c.dim,
        );
        await delay(retryDelayMs);
      }
    } catch (err) {
      lastError = err.message;

      // Connection errors likely mean server isn't ready - wait and retry
      if (attempt < maxRetries) {
        logService(
          "secrets",
          `Retry ${attempt}/${maxRetries}: ${err.message}`,
          c.dim,
        );
        await delay(retryDelayMs);
      }
    }
  }

  logService(
    "secrets",
    `Warning: Failed to seed secret after ${maxRetries} attempts: ${lastError}`,
    c.yellow,
  );
  return false;
}

function printBanner(config) {
  const stackName = config.frontendOnly
    ? "Agent Canvas Frontend Stack"
    : config.backendOnly
      ? "Agent Canvas Backend Stack"
      : "Agent Canvas + Automation Stack";

  // padEnd counts invisible ANSI escape bytes as visible characters, so we
  // compute the visible length separately and pad with spaces accordingly.
  const ansiEscape = String.fromCharCode(27);
  const ansiRe = new RegExp(`${ansiEscape}\\[[0-9;]*m`, "g");
  const ansiPadEnd = (str, targetVisible) => {
    const visible = str.replace(ansiRe, "").length;
    return str + " ".repeat(Math.max(0, targetVisible - visible));
  };
  // The box has 62-char inner width; each content line needs 63 visible chars
  // before the trailing border (1 leading ║ + 62 inner).
  const BOX_INNER = 63;

  console.log("");
  console.log(
    `${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`,
  );
  console.log(
    ansiPadEnd(
      `${c.green}${c.bold}║${c.reset}  ${c.bold}${stackName}${c.reset}`,
      BOX_INNER,
    ) + `${c.green}${c.bold}║${c.reset}`,
  );
  console.log(
    `${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`,
  );
  console.log(
    `${c.green}${c.bold}║${c.reset}                                                              ${c.green}${c.bold}║${c.reset}`,
  );
  console.log(
    ansiPadEnd(
      `${c.green}${c.bold}║${c.reset}  Ingress:      ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`,
      BOX_INNER,
    ) + `${c.green}${c.bold}║${c.reset}`,
  );
  if (config.launchFrontend) {
    console.log(
      ansiPadEnd(
        `${c.green}${c.bold}║${c.reset}  Main UI:      ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`,
        BOX_INNER,
      ) + `${c.green}${c.bold}║${c.reset}`,
    );
  }
  if (config.launchAutomation) {
    console.log(
      ansiPadEnd(
        `${c.green}${c.bold}║${c.reset}  API Docs:     ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`,
        BOX_INNER,
      ) + `${c.green}${c.bold}║${c.reset}`,
    );
  }
  console.log(
    `${c.green}${c.bold}║${c.reset}                                                              ${c.green}${c.bold}║${c.reset}`,
  );
  console.log(
    `${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`,
  );
  console.log("");
  console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`);
  console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`);
  console.log("");

  // Write a compact plain-text summary to the log file.
  const summary = [
    `${stackName} — started`,
    `  Ingress:         http://localhost:${config.ingressPort}/`,
    ...(config.launchFrontend
      ? [`  Main UI:         http://localhost:${config.ingressPort}/`]
      : []),
    ...(config.launchAutomation
      ? [
          `  API Docs:        http://localhost:${config.ingressPort}/api/automation/docs`,
        ]
      : []),
    `  State directory: ${config.stateDir}`,
  ];
  fileLog("info", summary.join("\n"));
}

async function main(options = {}) {
  const {
    bannerTitle = "Agent Canvas + Automation Development Stack",
    startAgentServer: startAgentServerOverride,
    extraPrereqs,
    viteWorkingDir,
    // Path used as `AUTOMATION_WORKSPACE_BASE` by the automation backend.
    // Defaults to a host-side path under config.stateDir.
    automationWorkspaceBase,
    // Host used in `AUTOMATION_BASE_URL` (the URL the automation sandbox
    // uses to call back into the automation backend). Defaults to `localhost`.
    automationApiHost,
    // Value exported as `AUTOMATION_SANDBOX_AGENT_SERVER_URL` to the
    // automation backend. This is the URL the in-sandbox bash chain uses
    // to reach the agent-server. When unset the backend falls back to
    // AUTOMATION_AGENT_SERVER_URL.
    sandboxAgentServerUrl,
    staticMode: staticModeOverride,
    defaultStaticMode = false,
    buildStaticFrontend,
    staticDir: staticDirOverride,
    // Hostname the agent uses to reach services running on the host.
    agentHostAlias = "localhost",
    // Human-readable label for the dev mode, surfaced in the agent's
    // <RUNTIME_SERVICES> system-prompt block.
    mode = "dev:automation",
    // When true, enable public mode (require LOCAL_BACKEND_API_KEY,
    // don't bake session key into frontend).
    isPublic: isPublicOverride,
    // When true, skip the npm prerequisite check. Used by the Electron desktop
    // launcher where npm is not needed at runtime in static mode.
    skipNpmCheck = false,
    // How long to wait for the agent-server's `/server_info` to return 200
    // before continuing. Defaults to 60 s, which is fine for warm-cache dev
    // workflows. The Electron desktop launcher bumps this to several minutes
    // because first-launch on a fresh machine runs `uvx` to download Python
    // and install `openhands-agent-server` from PyPI, which can take much
    // longer than 60 s on a slow network.
    agentServerReadyTimeoutMs = 60_000,
    // Optional `(name, line, level)` callback that receives every service log
    // line (stdout, stderr, and lifecycle events) emitted by any spawned
    // backend process. Used by the Electron loading screen to surface uvx
    // download / install progress to the user. `level` is one of
    // "stdout" | "stderr" | "info" | "warn" | "error".
    onServiceLog,
  } = options;

  // Install the listener early so log lines emitted before the first
  // `spawnService` call (e.g. by future setup steps) are also captured.
  setServiceLogListener(onServiceLog);

  const args = parseArgs();

  // Allow options to override CLI args for public mode
  if (isPublicOverride != null) {
    args.public = isPublicOverride;
  }

  // Allow options to override CLI args (for bin/agent-canvas.mjs)
  const useStaticMode =
    staticModeOverride ??
    (args.dynamic ? false : args.static || defaultStaticMode);
  const staticDir =
    staticDirOverride ?? args.staticDir ?? join(projectRoot, "build");

  const modeLabel = useStaticMode && !args.backendOnly ? "(Static)" : "";
  const titleWithMode = modeLabel ? `${bannerTitle} ${modeLabel}` : bannerTitle;

  console.log("");
  console.log(`${c.cyan}${c.bold}${titleWithMode}${c.reset}`);
  console.log("");
  fileLog("info", titleWithMode);

  // Setup phase
  checkPrerequisites({
    checkUvx: !args.frontendOnly,
    // Static-mode + backend-only has no frontend to build, so npm is not
    // required — unless the caller provides a custom buildStaticFrontend hook.
    // The Electron desktop launcher passes `skipNpmCheck: true` because the
    // packaged binary serves a pre-built static frontend and never invokes
    // npm at runtime, so we suppress the check unconditionally there.
    checkNpm:
      !skipNpmCheck &&
      ((!useStaticMode && !args.backendOnly) ||
        typeof buildStaticFrontend === "function"),
    checkFrontendDependencies:
      (!useStaticMode && !args.backendOnly) ||
      typeof buildStaticFrontend === "function",
  });

  // Fail fast on an obviously bad OH_AGENT_SERVER_LOCAL_PATH so we don't waste
  // time allocating ports / generating keys / launching uvx with a path that
  // would only produce a cryptic build error. Mirrors dev-safe.mjs and
  // dev-extra-backend.mjs.
  if (!args.frontendOnly && process.env.OH_AGENT_SERVER_LOCAL_PATH) {
    try {
      validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
    } catch (error) {
      logError(error instanceof Error ? error.message : String(error));
      process.exit(1);
    }
  }

  // Same for the automation checkout -- skipped when --automation-git-ref was
  // passed, since buildConfig drops the env var in favor of the explicit flag.
  if (
    !args.frontendOnly &&
    !args.automationGitRef &&
    process.env.OH_AUTOMATION_LOCAL_PATH
  ) {
    try {
      validateLocalAutomationPath(process.env.OH_AUTOMATION_LOCAL_PATH);
    } catch (error) {
      logError(error instanceof Error ? error.message : String(error));
      process.exit(1);
    }
  }

  // Build config with dynamic port allocation
  const config = await buildConfig(args);
  if (viteWorkingDir) config.viteWorkingDir = viteWorkingDir;
  if (automationWorkspaceBase) {
    config.automationWorkspaceBase = automationWorkspaceBase;
  }
  if (automationApiHost) {
    config.automationApiHost = automationApiHost;
  }
  if (sandboxAgentServerUrl) {
    config.sandboxAgentServerUrl = sandboxAgentServerUrl;
  }
  // Stamp the dev-mode label, host alias, and frontend kind on the config
  // so downstream helpers (Vite spawn, static build) can produce a
  // runtime-services info object describing what the agent can reach.
  config.mode = mode;
  config.agentHostAlias = agentHostAlias;
  config.frontendKind = useStaticMode ? "static" : "vite";
  ensureDirectories(config);
  if (typeof extraPrereqs === "function") {
    extraPrereqs(config);
  }

  if (
    config.launchFrontend &&
    useStaticMode &&
    typeof buildStaticFrontend === "function"
  ) {
    buildStaticFrontend(config, args);
  }

  // In static mode, verify build exists after any launcher-managed build.
  if (config.launchFrontend && useStaticMode && !existsSync(staticDir)) {
    logError(`Static directory not found: ${staticDir}`);
    logError(`Run 'npm run build' first to create the static files.`);
    process.exit(1);
  }

  // Start services phase
  logStep("2/2", "Starting services...");

  let agentServerReady = false;

  // 1. Start agent-server first (automation depends on it).
  //
  // Readiness timeout defaults to 60 s, which is fine for `npm run dev` against
  // a warm uvx cache. The Electron desktop launcher overrides this via the
  // `agentServerReadyTimeoutMs` option because first-launch on a fresh machine
  // runs `uvx` to download Python + install `openhands-agent-server` from PyPI,
  // which can take several minutes. Dropping the user into a half-booted UI
  // before that completes triggers axios "Request timeout" popups on the first
  // SPA fetch that hits an unbound port 18000.
  if (config.launchAgentServer) {
    const agentServerStarter = startAgentServerOverride ?? startAgentServer;
    agentServerStarter(config);

    agentServerReady = await waitForService(
      "agent-server",
      `${getAgentServerBaseUrl(config)}/server_info`,
      agentServerReadyTimeoutMs,
    );
  }

  // 2. Seed automation API key into agent-server secrets
  // This makes the key available to agents during conversations
  // Note: seedAutomationSecret has its own retry logic if server is still warming up
  if (config.launchAutomation && agentServerReady) {
    await seedAutomationSecret(config);
  } else if (config.launchAutomation) {
    logService(
      "secrets",
      "Skipping secret seeding - agent-server not ready",
      c.yellow,
    );
  }

  // 3. Start automation backend
  if (config.launchAutomation) {
    startAutomationBackend(config);
  }

  // 4. Start frontend server (Vite dev server OR static server)
  if (config.launchFrontend) {
    if (useStaticMode) {
      startStaticFrontend(config, staticDir);
    } else {
      startVite(config);
    }
  }

  // 5. Wait for services to be ready
  await delay(2000);

  // 6. Start ingress proxy (routes traffic only to running services)
  startIngress(config);

  // Wait for ingress to start
  await delay(1000);

  printBanner(config);

  // Return the resolved config + readiness signal so embedded launchers can
  // (a) build URLs from the actual allocated ports and (b) decide whether to
  // show an error to the user when the agent-server never came up.
  return { config, agentServerReady };
}

function startStaticFrontend(config, staticDir) {
  logService("static", `Starting on port ${config.vitePort}...`, c.magenta);
  logService("static", `Serving from: ${staticDir}`, c.dim);

  // Build the runtime-services info JSON so static-server can append it to
  // /server_info. The static-server also injects the old window global for
  // compatibility with previously built frontend bundles.
  const runtimeServicesInfo = config.launchAgentServer
    ? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
    : null;

  const staticServerScript = join(projectRoot, "scripts", "static-server.mjs");
  spawnService(
    "static",
    "node",
    [
      staticServerScript,
      "--dir",
      staticDir,
      "--port",
      String(config.vitePort),
      ...(process.env.VITE_BASE_PATH
        ? ["--base-path", process.env.VITE_BASE_PATH]
        : []),
      // In local mode, inject the API key so the pre-built frontend can
      // authenticate transparently. In public mode, pass --auth-required
      // so the frontend shows the API key entry screen instead.
      ...(config.launchAgentServer && !config.isPublic && config.sessionApiKey
        ? ["--session-api-key", config.sessionApiKey]
        : []),
      ...(config.launchAgentServer && config.isPublic
        ? ["--auth-required"]
        : []),
      // Inject runtime-services info so the agent knows what's reachable.
      ...(runtimeServicesInfo
        ? ["--runtime-services-info", runtimeServicesInfo]
        : []),
      // Proxy routes only to services that this launch mode started.
      ...buildRouteArgs(getLocalServiceRoutes(config)),
      // Only the static server injects into the document, so only it can tell
      // the frontend this origin serves the editor. The ingress routes the same
      // prefix but proxies the HTML through untouched.
      ...getVSCodeAdvertiseArgs(config),
      ...getNoReferrerPrefixArgs(config),
      // Reject known API prefixes that have no backend — returns 503
      // instead of SPA-fallbacking to index.html.
      ...buildRejectPrefixArgs(getRejectPrefixes(config)),
    ],
    {
      cwd: config.canvasPath,
      color: c.magenta,
    },
  );
}

// ═══════════════════════════════════════════════════════════════════════════
// Exports for testing
// ═══════════════════════════════════════════════════════════════════════════

export {
  buildAgentServerAutomationEnv,
  buildAutomationCommand,
  buildAutomationTelemetryEnv,
  buildConfig,
  buildRouteArgs,
  buildViteBackendEnv,
  getAgentServerBaseUrl,
  getFrontendBackend,
  getLocalServiceRoutes,
  getNoReferrerPrefixArgs,
  getRejectPrefixes,
  getVSCodeAdvertiseArgs,
  main,
  registerShutdownHook,
  spawnService,
  commandExists,
  validateLocalAutomationPath,
  logService,
  logStep,
  logSuccess,
  logError,
  c,
  DEFAULT_AUTOMATION_REPO,
  DEFAULT_AUTOMATION_PACKAGE,
  DEFAULT_AUTOMATION_VERSION,
  DEFAULT_AUTOMATION_SDK_VERSION,
  DEFAULT_BACKEND_PORT,
  DEFAULT_AUTOMATION_PORT,
};

// ═══════════════════════════════════════════════════════════════════════════
// Main entry point (only when run directly, not when imported)
// ═══════════════════════════════════════════════════════════════════════════

// Check if this module is the main entry point
const isMainModule =
  process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;

if (isMainModule) {
  main().catch((err) => {
    logError(`Fatal error: ${err.message}`);
    if (err.stack) {
      console.error(c.dim + err.stack + c.reset);
      fileLog("error", err.stack);
    }
    process.exit(1);
  });
}
