// Upgrade-trigger: spawn an ephemeral `olam upgrade` runner container.
//
// The user clicks "Run upgrade" in the dashboard → host-cp's
// /api/admin/upgrade endpoint asks the docker daemon (via the
// socket-proxy sidecar) to create + start a one-off container that
// runs the olam CLI's full upgrade pipeline against the local stack.
//
// Why a separate container (and not a child process inside host-cp)?
//   `olam upgrade` recreates host-cp itself as part of the atomic
//   tag-swap. If the orchestrator lived inside host-cp, the moment it
//   asked docker to stop the old host-cp container the orchestrator
//   would die with it — leaving no one to start the new container.
//   A sibling container survives host-cp's recreate.
//
// Why this same image (not a purpose-built `olam-upgrader`)?
//   The host-cp image already has Node, the olam CLI, the docker CLI,
//   and the docker compose plugin baked in by Dockerfile. Reusing it
//   means there's nothing extra to publish, and the upgrader is
//   guaranteed to ship from the same source SHA as the host-cp it
//   replaces. The upgrader's `Cmd` overrides host-cp's default CMD
//   so it runs the CLI instead of starting the server.
//
// Security note (single-user-trusted-local-dev assumption):
//   POST /api/admin/upgrade requires the host-cp auth token. Anyone
//   with that token can already spawn arbitrary commands inside
//   running devboxes via the existing exec path; spawning an upgrader
//   container does not meaningfully widen the blast radius for the
//   single-user model. Multi-user / hosted deployments will need a
//   tighter policy (capability bit, user-scoped tokens).

import http from 'node:http';

/**
 * Default upgrader entrypoint. Authenticates docker against GHCR (where
 * the host-cp / auth / devbox images live), then runs the full
 * atomic-swap pipeline.
 *
 * Auth resolution order:
 *   1. `$GH_TOKEN` env var (set on host-cp via compose; operator
 *      typically resolves it from `gh auth token` before `olam host-cp
 *      start`). Required path on macOS — the host's `gh` keeps the
 *      token in Keychain, which doesn't follow into a Linux container.
 *   2. `gh auth token` against the mounted ~/.config/gh. Works on Linux
 *      operators whose gh keeps the token in the config dir directly.
 *   3. No-token (warns). The pull-by-digest step will fail with a clear
 *      `unauthorized` error from the daemon and the upgrader exits non-
 *      zero — surfacing in `docker logs` for diagnosis.
 *
 * Wrapping the auth + upgrade in a single `sh -c` invocation lets the
 * `docker login` stage feed credentials directly into the docker
 * daemon without leaking the token through shared volumes.
 */
const DEFAULT_UPGRADER_CMD = [
  'sh',
  '-c',
  [
    'set -e',
    // Resolve the GH token. Prefer the env var (works on every OS);
    // fall back to `gh auth token` (Linux-only on macOS Keychain hosts).
    'TOKEN="${GH_TOKEN:-$(gh auth token 2>/dev/null || true)}"',
    'if [ -z "$TOKEN" ]; then echo "[upgrader] no GH_TOKEN; ghcr pulls will fail" >&2; fi',
    // Authenticate against ghcr.io. `oauth2` is GitHub's canonical
    // username placeholder for PAT-style tokens.
    '[ -n "$TOKEN" ] && echo "$TOKEN" | docker login ghcr.io -u oauth2 --password-stdin',
    // The CLI resolves `packages/host-cp/compose.yaml` relative to its
    // current working directory (see upgrade.ts:1008-1009). Inside the
    // upgrader the npm-installed package lives at
    // /usr/local/lib/node_modules/@pleri/olam-cli/, so cd there before
    // running so the relative path resolves to the bundled compose
    // file. Without this the recreate step fails with `open
    // /app/packages/host-cp/compose.yaml: no such file or directory`.
    // Operator's olam repo is bind-mounted at /workspace below; cd
    // there so the CLI finds packages/host-cp/compose.yaml. Without
    // this the recreate step fails with `open <cwd>/packages/host-cp/
    // compose.yaml: no such file or directory` because the npm-
    // installed @pleri/olam-cli package does NOT bundle the compose
    // file (it's repo-source only).
    'cd /workspace',
    // Then run the upgrade. The CLI handles pull-by-digest, atomic
    // swap, recreate, and the post-recreate /api/version/status
    // round-trip itself.
    'olam upgrade -y',
  ].join(' && '),
];

/**
 * Spawn the upgrader. Resolves with the container ID on a successful
 * `/start`; throws on any failure path so the caller can surface a
 * clean 500 with the daemon's reason.
 *
 * @param {object} args
 * @param {string} args.dockerHost            tcp://docker-socket-proxy:2375 or 'docker-cli'
 * @param {string} args.olamHomeHostPath      e.g. /Users/ernie/.olam
 * @param {string} args.dockerSockHostPath    e.g. /var/run/docker.sock
 * @param {string} args.image                 upgrader image (defaults to host-cp's own image)
 * @param {string} [args.ghConfigHostPath]    operator's ~/.config/gh; bind-mounted ro for
 *                                            `gh auth token` to work inside the upgrader
 *                                            (Linux fallback only; macOS uses GH_TOKEN env)
 * @param {string} [args.ghToken]             pre-resolved GH token (typically read from
 *                                            host-cp's GH_TOKEN env via compose). Passed
 *                                            to the upgrader as $GH_TOKEN so `docker login
 *                                            ghcr.io` works on macOS hosts whose Keychain-
 *                                            backed gh config can't be read inside a Linux
 *                                            container.
 * @param {string} [args.repoHostPath]        operator's olam repo path on the host. Bind-
 *                                            mounted into the upgrader at /workspace so
 *                                            the CLI's cwd-relative compose-file lookup
 *                                            resolves (the npm package doesn't bundle
 *                                            packages/host-cp/compose.yaml).
 * @param {string} [args.operatorHomeHostPath] operator's $HOME on the host. Passed as the
 *                                            upgrader's HOME env so docker-compose's
 *                                            `${HOME}` interpolation in bind sources
 *                                            resolves to a daemon-visible path.
 * @param {ReadonlyArray<string>} [args.cmd]  override the upgrade command for tests
 * @param {(host: string, init: object) => Promise<Response>} [args.fetchImpl]
 * @param {(message: string) => void} [args.log]
 * @returns {Promise<{ id: string, name: string }>}
 */
export async function spawnUpgraderContainer({
  dockerHost,
  olamHomeHostPath,
  dockerSockHostPath,
  image,
  ghConfigHostPath,
  ghToken,
  repoHostPath,
  operatorHomeHostPath,
  cmd = DEFAULT_UPGRADER_CMD,
  fetchImpl = globalThis.fetch,
  log = console.log,
}) {
  if (!olamHomeHostPath) {
    throw new Error('OLAM_HOME_HOST_PATH not set; cannot bind-mount operator state');
  }
  if (!dockerSockHostPath) {
    throw new Error('OLAM_DOCKER_SOCK_HOST_PATH not set; upgrader cannot reach docker daemon');
  }
  if (!image) {
    throw new Error('upgrader image not configured (OLAM_UPGRADER_IMAGE)');
  }
  if (!repoHostPath) {
    throw new Error(
      'OLAM_REPO_HOST_PATH not set; upgrader cannot find packages/host-cp/compose.yaml',
    );
  }

  // Bare-node (operator's host docker CLI on PATH) is documented but
  // out of scope for the trigger feature — container + unix-socket paths
  // are supported (compose stack and k8s hostPath socket mount).
  if (dockerHost === 'docker-cli') {
    // The literals below (`unix:///var/run/docker.sock` and `tcp://docker-socket-proxy:2375`)
    // are diagnostic text naming the deployment shapes that ARE supported,
    // not hostnames being used as transport — error-message-only.
    throw new Error(
      'upgrade-trigger requires a docker socket (unix:///var/run/docker.sock via k8s hostPath mount, ' + // bare-node-allow: diagnostic-text
      'or tcp://docker-socket-proxy:2375 via compose); bare-node not yet supported. ' + // bare-node-allow: diagnostic-text
      'For k8s: ensure the cluster was created with ' +
      '--volume /var/run/docker.sock:/var/run/docker.sock@server:* ' +
      'and olam doctor reports probeDockerSocketBindMount [PASS].',
    );
  }

  const apiBase = dockerHost.replace(/^tcp:\/\//, 'http://');
  const containerName = `olam-upgrader-${Date.now()}`;

  // Container create body. AutoRemove cleans up on exit so we don't
  // accumulate stopped upgrader carcasses; HostConfig.Binds gives the
  // CLI everything it needs (operator state + docker socket).
  const createBody = {
    Image: image,
    Cmd: [...cmd],
    Entrypoint: [], // override host-cp's tini entrypoint; olam CLI is self-contained
    Env: [
      // HOME serves two roles:
      //   - The CLI uses HOME to resolve ~/.olam (we bind-mount the
      //     operator's ~/.olam into the upgrader so the CLI sees its
      //     state).
      //   - docker-compose interpolates `${HOME}` in bind sources of
      //     compose.yaml. The daemon resolves those bind sources on
      //     the HOST filesystem, so HOME must be a path the daemon
      //     can find (typically the operator's host $HOME).
      //
      // Default to /root for back-compat with tests that don't pass
      // the operator host path; production callers (server.mjs) pass
      // operatorHomeHostPath through.
      `HOME=${operatorHomeHostPath ?? '/root'}`,
      // Non-interactive mode + auto-yes are belt-and-braces: -y flag is
      // also passed in Cmd, but env is the canonical way to opt out of
      // tty prompts when stdin is closed.
      'OLAM_NON_INTERACTIVE=1',
      'CI=1',
      // GH token forwarded for the docker-login-to-ghcr step. Only
      // included when host-cp received it (compose set GH_TOKEN); the
      // wrapper's auth fallback handles the unset case explicitly.
      ...(ghToken ? [`GH_TOKEN=${ghToken}`] : []),
    ],
    HostConfig: {
      AutoRemove: true,
      // Bind sources are resolved by the docker daemon on the HOST
      // filesystem, so target paths must match the host's view too —
      // when the upgrader's `docker compose up` recreates host-cp,
      // compose's ${HOME} interpolation pulls operatorHomeHostPath
      // (set as HOME below). We keep the source==target convention
      // for ~/.olam so the path is identical inside and out.
      Binds: [
        `${olamHomeHostPath}:${operatorHomeHostPath ? `${operatorHomeHostPath}/.olam` : '/root/.olam'}`,
        `${dockerSockHostPath}:/var/run/docker.sock`,
        // Operator's repo bind-mounted read-only at /workspace. The
        // wrapper cds here so the CLI's relative compose-file lookup
        // resolves to `<repo>/packages/host-cp/compose.yaml`.
        `${repoHostPath}:/workspace:ro`,
        // Optional gh config bind. When unset (operator skipped
        // `gh auth login`) the upgrader's `gh auth token` step fails
        // and the wrapper exits early — surface the failure via
        // `docker logs` rather than spawning a doomed run.
        ...(ghConfigHostPath
          ? [`${ghConfigHostPath}:${operatorHomeHostPath ? `${operatorHomeHostPath}/.config/gh` : '/root/.config/gh'}:ro`]
          : []),
      ],
      // Same network as host-cp so the upgrader can reach the
      // docker-socket-proxy + auth-service if it needs to during the
      // verification phase. Falls through to docker.sock for daemon
      // operations.
      NetworkMode: 'olam-host-cp-internal',
    },
    Labels: {
      'olam.role': 'upgrader',
      'olam.spawned-by': 'host-cp',
      'olam.spawned-at': new Date().toISOString(),
    },
  };

  log(`[upgrade] creating upgrader container ${containerName} from ${image}`);
  const createUrl = `${apiBase}/containers/create?name=${encodeURIComponent(containerName)}`;
  const createRes = await fetchImpl(createUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(createBody),
  });
  if (!createRes.ok) {
    const detail = await safeReadBody(createRes);
    throw new Error(
      `daemon rejected POST /containers/create: ${createRes.status} ${createRes.statusText} ${detail}`.trim(),
    );
  }
  const created = await createRes.json();
  const containerId = created.Id;
  if (!containerId) {
    throw new Error(`POST /containers/create returned no Id: ${JSON.stringify(created)}`);
  }

  log(`[upgrade] starting upgrader ${containerId.slice(0, 12)}`);
  const startUrl = `${apiBase}/containers/${encodeURIComponent(containerId)}/start`;
  const startRes = await fetchImpl(startUrl, { method: 'POST' });
  if (!startRes.ok && startRes.status !== 304) {
    // 304 Not Modified = already started; treat as success.
    const detail = await safeReadBody(startRes);
    throw new Error(
      `daemon rejected POST /containers/${containerId}/start: ${startRes.status} ${detail}`.trim(),
    );
  }

  return { id: containerId, name: containerName };
}

/**
 * Read the response body without crashing if it isn't JSON or is empty.
 * Used for human-readable error surfaces in 500 responses.
 *
 * @param {Response} res
 * @returns {Promise<string>}
 */
async function safeReadBody(res) {
  try {
    const txt = await res.text();
    return txt.trim().slice(0, 512);
  } catch {
    return '';
  }
}
