#!/usr/bin/env bun
/**
 * Bake celilo into the management image, then `docker commit` the result
 * onto the `celilo-e2e/management:latest` tag. Two modes:
 *
 *   (default)     Run install.sh against the simulated celilo.computer +
 *                 npm registry, installing the monorepo DEV cli exactly the
 *                 way users do. Exercises install.sh end-to-end.
 *
 *   --published   Install the PUBLISHED @celilo/cli from REAL npm in a
 *                 standalone container on the default docker network. The
 *                 simulated topology has no route to npmjs.org, so this
 *                 path deliberately runs outside it. This is what a
 *                 monorepo-free npm consumer actually gets (ce-fo6).
 *
 * Run as the last step of `cele2e build-infra`. After this:
 *   - celilo-e2e/management:vanilla — bun + unzip + bunfig (no celilo)
 *   - celilo-e2e/management:latest  — vanilla + celilo
 *
 * If the install regresses, this step fails build-infra loudly. The CLI is
 * verified AFTER the source-mount shim is written and with no /celilo mount,
 * so a broken shim fallback can't ship green (ISS-0124).
 */

import { execSync } from 'node:child_process';
import { join } from 'node:path';
import { ensureSharedInfra } from '../src/shared-infra';
import { network } from '../src/network-builder';
import { imageTag } from '../src/docker-compose-generator';
import { findMonorepoRoot } from '../src/repo-root';
import {
  packNpmRegistryTarballs,
  stageWebsiteDist,
} from '../src/stage-simulator-inputs';
import {
  CONSUMER_FINGERPRINT_PREFIX,
  PUBLISHED_FINGERPRINT_PREFIX,
  SOURCE_LABEL,
} from '../src/source-fingerprint';
import { readFileSync } from 'node:fs';
import { ZONE_GATEWAYS, greenwaveRouterIp } from '../src/types';

const PACKAGE_ROOT = join(import.meta.dir, '..');
const COMPOSE_FILE = join(PACKAGE_ROOT, 'docker-compose.test.yml');

function run(cmd: string): string {
  return execSync(cmd, { stdio: 'pipe', encoding: 'utf-8' });
}

interface ExecResult {
  exitCode: number;
  stdout: string;
  stderr: string;
}
type Exec = (cmd: string, timeoutMs?: number) => Promise<ExecResult>;

/**
 * Write the CLI shim to /usr/local/bin/celilo and verify the CLI works through
 * it. Shared by both bake modes.
 *
 * The shim (at /usr/local/bin/celilo so plain `docker exec bash -c celilo`
 * finds it) runs the INSTALLED CLI at /root/.bun/bin/celilo — the artifact
 * install.sh just produced, which is the thing the rig exists to test. It runs
 * the mounted /celilo workspace instead only when `CELILO_E2E_SOURCE_CLI` is
 * set, which `cele2e run --source-cli` does.
 *
 * That default used to be the other way round, and both halves of the swap are
 * worth stating.
 *
 * It cost real time per invocation. `bun run <source>` resolves 1522 modules
 * across the bind mount rather than starting an installed binary. Measured
 * inside this image on 2026-09-05, same command and same container, on a VM
 * sized per the host policy: `celilo --version` took 0.14s installed and 0.31s
 * from the mounted source over virtiofs, or 0.16s against 0.56s over sshfs. A
 * test issues 25 to 40 celilo commands.
 *
 * (An earlier measurement put that gap at 2.3s. Most of that was a separate
 * problem — the VM had been given 24 of the host's 32 GiB and the host was
 * swapping 21.9 GiB. Fixing the memory removed about 1.9s of it. Worth stating,
 * because a number measured with two variables moving blamed the wrong one.)
 *
 * And it made the verify below dishonest. This function exists because of
 * ISS-0124 — a broken shim fallback shipping green — and its comment says the
 * verify runs "with NO /celilo mount, i.e. exactly the shipped path". That was
 * true of the published bake, which runs in a standalone container, and false
 * of this one: the sim bake's container HAS the workspace mounted, so the old
 * shim sent the verify down the source branch and the installed CLI was never
 * executed at all. The check could not reach its subject. Now both modes verify
 * the installed binary.
 *
 * Two things must hold, both bitten in ISS-0124:
 *
 *  1. `bun add -g` links the celilo binary at /usr/local/bin/celilo as a
 *     SYMLINK into the package (bun's global bin dir in this image is
 *     /usr/local/bin, where bun itself lives). Writing the shim with
 *     `> /usr/local/bin/celilo` would follow that symlink and clobber the
 *     package's OWN launcher — turning the fallback into an infinite loop.
 *     So we `rm -f` the symlink first and write a fresh regular file.
 *  2. The fallback target /root/.bun/bin/celilo doesn't exist after a
 *     /usr/local/bin install, so we point a guaranteed symlink at the
 *     package launcher first. Guarded so an image where bun genuinely
 *     lives under /root/.bun is left untouched.
 *
 * Verify runs AFTER the shim and with NO /celilo mount — i.e. exactly the
 * shipped path — so a dangling/looping fallback fails the bake instead of
 * shipping green.
 */
async function installShimAndVerify(exec: Exec, label: string): Promise<string> {
  process.stdout.write('  source-mount shim ...... ');
  const t0 = Date.now();
  const shim = [
    '#!/bin/bash',
    '# Opt-in source loop: `cele2e run --source-cli` sets this in the container',
    '# environment. Unset (the default) runs the CLI install.sh actually installed.',
    'if [ -n "$CELILO_E2E_SOURCE_CLI" ] && [ -d /celilo/apps/celilo ]; then',
    '  exec bun run /celilo/apps/celilo/src/cli/index.ts "$@"',
    'fi',
    'exec /root/.bun/bin/celilo "$@"',
  ].join('\n');
  const shimBase64 = Buffer.from(shim).toString('base64');
  const shimResult = await exec(
    'mkdir -p /root/.bun/bin && ' +
      '{ [ -e /root/.bun/bin/celilo ] || ' +
      'ln -s /root/.bun/install/global/node_modules/@celilo/cli/bin/celilo /root/.bun/bin/celilo; } && ' +
      'rm -f /usr/local/bin/celilo && ' +
      `echo ${shimBase64} | base64 -d > /usr/local/bin/celilo && chmod +x /usr/local/bin/celilo`,
  );
  if (shimResult.exitCode !== 0) {
    throw new Error(`Shim install failed: ${shimResult.stderr}`);
  }
  console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);

  process.stdout.write('  verify celilo --version  ');
  const t1 = Date.now();
  const verify = await exec('celilo --version 2>&1');
  if (verify.exitCode !== 0) {
    console.log(`✗ exit ${verify.exitCode}`);
    console.error('--- celilo --version output ---');
    console.error(verify.stdout);
    console.error(verify.stderr);
    throw new Error(
      `celilo --version failed after shim (${label}) — image is broken (dangling shim fallback?)`,
    );
  }
  console.log(`✔ ${Math.round((Date.now() - t1) / 1000)}s  (${verify.stdout.trim()})`);
  return verify.stdout.trim();
}

/**
 * The image id currently on a tag, or '' when the tag is unused.
 *
 * Captured before a commit so the superseded image can be removed after it.
 * Every bake used to orphan a 1.5 GB untagged image and nothing ever collected
 * them: measured 2026-09-05, 3178 dangling images holding 25.8 GB, 1478 of them
 * from a single 35-hour window. That pile is what drives an operator to reach
 * for `docker image prune -a`, which deletes the TAGGED base images the next
 * build needs and costs a full 27-image rebuild.
 */
function imageIdOnTag(tag: string): string {
  try {
    return run(`docker image inspect --format '{{.Id}}' ${tag}`).trim();
  } catch {
    return '';
  }
}

/** Remove an image the bake just superseded. Best-effort: a tag or container still using it wins. */
function removeSupersededImage(previousId: string, tag: string): void {
  if (!previousId || previousId === imageIdOnTag(tag)) return;
  try {
    run(`docker image rm ${previousId}`);
    console.log(`  superseded image ....... ✔ removed ${previousId.slice(7, 19)}`);
  } catch {
    // Still referenced (a running container, another tag). Leaving it is correct.
  }
}

/**
 * The sim tarballs the default bake installs, staged fresh by
 * restageSimulatorInputs, carry a manifest recording the tree fingerprint they
 * were packed from. The stamp IS that value: the label then describes the
 * tarballs install.sh actually installs, not the tree the bake happened to run
 * in. Stamping `computeSourceFingerprint(tree)` here is what produced a green
 * doctor over a CLI without the tree's changes (celilo#1299): the tree moved
 * between the last `cele2e build` and the bake, and the fingerprint followed
 * the tree, not the stale cache the bake reinstalled.
 */
function simStampFromManifest(): string {
  const manifestPath = join(PACKAGE_ROOT, '.npm-registry-cache', 'pack-manifest.json');
  let manifest: { sourceFingerprint?: string | null };
  try {
    manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as {
      sourceFingerprint?: string | null;
    };
  } catch {
    throw new Error(
      `No readable pack-manifest.json at ${manifestPath} — the sim tarballs were never staged. ` +
        'Run the bake from a monorepo checkout; restageSimulatorInputs writes the manifest.',
    );
  }
  if (!manifest.sourceFingerprint) {
    throw new Error(
      'pack-manifest.json carries no sourceFingerprint — the tarballs this bake installs cannot be attributed to a tree.',
    );
  }
  return manifest.sourceFingerprint;
}

/**
 * Stamp the source this image was baked from, so `cele2e doctor` can say
 * whether the CLI under test is the code in the working tree. See
 * `src/source-fingerprint.ts` for why that has to be measured, not remembered.
 */
/**
 * `celilo --version` prints "celilo <semver>", and `docker commit --change
 * "LABEL name=value"` splits on whitespace, so stamping the raw output fails
 * the commit with `Syntax error - can't find = in "2.2.0"`. Take the last
 * field, so the label carries the version alone (celilo#1318).
 */
function labelSafeVersion(version: string): string {
  return version.trim().split(/\s+/).pop() || 'unknown';
}

function sourceStamp(published: boolean, version: string): string {
  if (published) return `${PUBLISHED_FINGERPRINT_PREFIX}${labelSafeVersion(version)}`;
  // Consumer mode: restageSimulatorInputs is the only writer of
  // pack-manifest.json and it is skipped without a checkout, so there is no
  // tree fingerprint to read — and none to record, since the tree does not
  // exist. Stamp the version the bake just installed and verified (celilo#1318).
  if (!findMonorepoRoot(PACKAGE_ROOT)) {
    return `${CONSUMER_FINGERPRINT_PREFIX}${labelSafeVersion(version)}`;
  }
  return simStampFromManifest();
}

/**
 * Re-stage the simulator inputs the default bake's install path depends on,
 * and rebuild the two sim images that COPY them at docker-build time.
 *
 * Without this, the bake reinstalls whatever tarballs the last `cele2e build`
 * left in `.npm-registry-cache/` — measured 2026-09-07: a bake at 00:17 UTC
 * shipped @celilo/cli 2.1.0 from a cache packed at 15:18 the day before,
 * silently dropping a CLI fix that had landed at 19:48 (celilo#1299). The
 * rebuilds are not optional either: the per-test stack only builds MISSING
 * images, so fresh tarballs on disk never reach the running sim otherwise.
 * Both docker builds are cache-warm no-ops when nothing staged has changed.
 */
function restageSimulatorInputs(): void {
  const repoRoot = findMonorepoRoot(PACKAGE_ROOT);
  if (!repoRoot) {
    // Consumer mode (no checkout): there is no tree to repack, and nothing to
    // correct. stageFromPublic already fetched THIS run's tarballs and site
    // dist into the sim caches, and the sim images were built from them before
    // the bake started. celilo#1299 is a monorepo-mode hazard — there the bake
    // would otherwise reinstall the previous build's cached CLI — so the
    // restage is redundant here, not impossible-but-required. Throwing broke
    // npm-consumer-smoke, whose whole point is a bake with no monorepo source
    // (celilo#1318).
    console.log('  restage ................ skipped (consumer mode: inputs staged this run)');
    return;
  }
  stageWebsiteDist(repoRoot, PACKAGE_ROOT);
  packNpmRegistryTarballs(repoRoot, PACKAGE_ROOT);
  for (const dockerfile of [
    'docker/Dockerfile.celilo-website-sim',
    'docker/Dockerfile.npm-registry-sim',
  ]) {
    const tag = imageTag(dockerfile);
    process.stdout.write(`  rebuild ${tag.padEnd(22)} `);
    const t0 = Date.now();
    execSync(
      `docker build -q -f ${join(PACKAGE_ROOT, dockerfile)} -t ${tag} ${PACKAGE_ROOT}`,
      { stdio: 'pipe' },
    );
    console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);
  }
}

/**
 * Default bake: install.sh against the simulated celilo.computer / npm
 * registry, inside the standard topology.
 */
async function bakeViaSim(): Promise<void> {
  console.log('▸ Baking celilo into management:latest via install.sh');
  console.log('');

  process.stdout.write('  shared-infra ........... ');
  const t0 = Date.now();
  await ensureSharedInfra();
  console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);

  // Fresh tarballs and site dist, and sim images that actually carry them,
  // BEFORE install.sh runs — otherwise this bake reinstalls the last build's
  // CLI (celilo#1299).
  restageSimulatorInputs();

  process.stdout.write('  per-test stack ......... ');
  const t1 = Date.now();
  const net = await network().topology('default').managementVariant('vanilla').start();
  console.log(`✔ ${Math.round((Date.now() - t1) / 1000)}s  (${net.projectName})`);

  const exec: Exec = (cmd, timeoutMs) => net.exec('management', cmd, timeoutMs);

  let containerId = '';
  try {
    // Wait for the management container's Pebble CA fetch to complete so
    // the curl-pipe-bash below can verify TLS to celilo.computer. Without
    // pipefail an early curl failure returns 0 silently and install.sh
    // "runs" in 0s installing nothing.
    process.stdout.write('  pebble CA ready .......... ');
    const tCa = Date.now();
    let caReady = false;
    for (let i = 0; i < 30; i++) {
      const probe = await exec(
        'curl -fsSL --max-time 3 https://celilo.computer/install.sh -o /dev/null 2>&1',
        5_000,
      );
      if (probe.exitCode === 0) {
        caReady = true;
        break;
      }
      await new Promise((resolve) => setTimeout(resolve, 1000));
    }
    if (!caReady) {
      throw new Error(
        'Timed out (30s) waiting for celilo.computer to serve install.sh ' +
          'over HTTPS — Pebble CA fetch likely stalled or website-sim is down.',
      );
    }
    console.log(`✔ ${Math.round((Date.now() - tCa) / 1000)}s`);

    process.stdout.write('  install.sh ............. ');
    const t2 = Date.now();
    const result = await exec(
      'set -o pipefail; curl -fsSL https://celilo.computer/install.sh | bash 2>&1',
      180_000,
    );
    if (result.exitCode !== 0) {
      console.log(`✗ exit ${result.exitCode}`);
      console.error('--- install.sh output (tail) ---');
      console.error(result.stdout.slice(-3000));
      console.error(result.stderr.slice(-2000));
      throw new Error('install.sh failed inside the bake container');
    }
    console.log(`✔ ${Math.round((Date.now() - t2) / 1000)}s`);

    // Shim + verify. Post-shim and, since the shim now prefers the installed
    // CLI, genuinely against the installed binary rather than the mount.
    const version = await installShimAndVerify(exec, 'sim');

    process.stdout.write('  docker commit .......... ');
    const t5 = Date.now();
    containerId = run(
      `docker compose -f ${COMPOSE_FILE} -p ${net.projectName} ps -q management`,
    ).trim();
    if (!containerId) {
      throw new Error('Could not resolve management container id');
    }
    const superseded = imageIdOnTag('celilo-e2e/management:latest');
    // The committed container runs under the sim topology's compose env, and
    // commit captures its Env. Since the default managementZone became
    // secure-mgmt, that baked FW_MAIN_HOP=10.226.120.1 (and the control-plane
    // default gateway) into :latest — every internal-topology suite then
    // inherited a nexthop that is not on-link from the internal LAN and the
    // mgmt box crash-looped in management-routes.sh (celilo#1351). Reset the
    // per-topology routing vars to the internal-topology defaults at commit
    // time, so the image is topology-neutral; every compose sets both anyway.
    run(
      [
        'docker commit',
        `--change ${JSON.stringify(`ENV DEFAULT_GATEWAY=${greenwaveRouterIp()}`)}`,
        `--change ${JSON.stringify(`ENV FW_MAIN_HOP=${ZONE_GATEWAYS.internal}`)}`,
        `--change ${JSON.stringify(`LABEL ${SOURCE_LABEL}=${sourceStamp(false, version)}`)}`,
        containerId,
        'celilo-e2e/management:latest',
      ].join(' '),
    );
    console.log(`✔ ${Math.round((Date.now() - t5) / 1000)}s`);
    removeSupersededImage(superseded, 'celilo-e2e/management:latest');
  } finally {
    process.stdout.write('  tearing down ........... ');
    const tDown = Date.now();
    await net.stop();
    console.log(`✔ ${Math.round((Date.now() - tDown) / 1000)}s`);
  }

  console.log('');
  console.log('✓ celilo-e2e/management:latest baked from install.sh');
}

/**
 * Published bake: install the PUBLISHED @celilo/cli from REAL npm in a
 * standalone vanilla container on the default docker network (real
 * internet). The simulated topology has no route to npmjs.org, so we run
 * outside it.
 */
async function bakeViaPublished(): Promise<void> {
  console.log('▸ Baking PUBLISHED @celilo/cli into management:latest from real npm');
  console.log('');

  const name = 'celilo-bake-published';
  const dexec: Exec = (cmd) => {
    try {
      const stdout = execSync(`docker exec ${name} bash -c ${JSON.stringify(cmd)}`, {
        stdio: 'pipe',
        encoding: 'utf-8',
      });
      return Promise.resolve({ stdout, stderr: '', exitCode: 0 });
    } catch (e: unknown) {
      const err = e as { stdout?: Buffer; stderr?: Buffer; status?: number };
      return Promise.resolve({
        stdout: err.stdout?.toString() ?? '',
        stderr: err.stderr?.toString() ?? '',
        exitCode: err.status ?? 1,
      });
    }
  };

  // Fresh container every bake — a stale one may have half-applied global state.
  try {
    run(`docker rm -f ${name}`);
  } catch {
    // no stale container — fine
  }

  process.stdout.write('  start vanilla container  ');
  const t0 = Date.now();
  // Default bridge network → real internet (real npm, real DNS).
  run(`docker run -d --name ${name} celilo-e2e/management:vanilla sleep infinity`);
  console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);

  try {
    // Reset bun global state before installing from real npm:
    //  - the @celilo scope override (bunfig) pins @celilo to the sim
    //    registry (npm-registry.lab), unresolvable on the default bridge —
    //    move it aside so `bun add -g` reaches real npm.
    //  - a prior monorepo bake leaves bun.lock pinning @celilo tarball URLs
    //    to the sim registry; every later `bun add -g` then dies with
    //    FailedToOpenSocket regardless of cache clearing (ce-fo6). Nuke it.
    process.stdout.write('  reset bun state ........ ');
    const t1 = Date.now();
    const reset = await dexec(
      'mv /root/.bunfig.toml /root/.bunfig.toml.sim 2>/dev/null; ' +
        'rm -f /root/.bun/install/global/bun.lock; true',
    );
    if (reset.exitCode !== 0) {
      throw new Error(`Failed to reset bun state: ${reset.stderr}`);
    }
    console.log(`✔ ${Math.round((Date.now() - t1) / 1000)}s`);

    process.stdout.write('  bun add -g (real npm) .. ');
    const t2 = Date.now();
    const add = await dexec(
      'bun add -g @celilo/cli@latest @celilo/event-bus@latest @celilo/e2e@latest 2>&1',
    );
    if (add.exitCode !== 0) {
      console.log(`✗ exit ${add.exitCode}`);
      console.error('--- bun add -g output ---');
      console.error(add.stdout.slice(-3000));
      console.error(add.stderr.slice(-2000));
      throw new Error('bun add -g of published @celilo/* from real npm failed');
    }
    console.log(`✔ ${Math.round((Date.now() - t2) / 1000)}s`);

    // Shim + verify (verify is post-shim, no mount — the shipped path).
    const version = await installShimAndVerify(dexec, 'published');

    // Restore the sim @celilo scope override so the committed image is
    // drop-in compatible with the simulated topology it runs tests in.
    // ponytail: inert at test runtime (nothing re-fetches @celilo), kept
    // only for parity with the sim-baked image.
    await dexec('mv /root/.bunfig.toml.sim /root/.bunfig.toml 2>/dev/null; true');

    process.stdout.write('  docker commit .......... ');
    const t3 = Date.now();
    // The vanilla container was started with `sleep infinity`; docker commit
    // persists the running Cmd, so override it back to the management image's
    // real entrypoint (Dockerfile.management: CMD ["/startup.sh"], no
    // ENTRYPOINT). Without this the baked image never runs /startup.sh ->
    // ssh-keys volume stays empty -> fw-main crashloops (ce-um6).
    const superseded = imageIdOnTag('celilo-e2e/management:latest');
    run(
      `docker commit --change 'CMD ["/startup.sh"]' --change ${JSON.stringify(`LABEL ${SOURCE_LABEL}=${sourceStamp(true, version)}`)} ${name} celilo-e2e/management:latest`,
    );
    console.log(`✔ ${Math.round((Date.now() - t3) / 1000)}s`);
    removeSupersededImage(superseded, 'celilo-e2e/management:latest');
  } finally {
    process.stdout.write('  removing container ..... ');
    const t4 = Date.now();
    try {
      run(`docker rm -f ${name}`);
    } catch {
      // best-effort
    }
    console.log(`✔ ${Math.round((Date.now() - t4) / 1000)}s`);
  }

  console.log('');
  console.log('✓ celilo-e2e/management:latest baked from PUBLISHED @celilo/cli');
}

async function main(): Promise<void> {
  const published = process.argv.includes('--published');
  if (published) {
    await bakeViaPublished();
  } else {
    await bakeViaSim();
  }
}

main().catch((err) => {
  console.error('');
  console.error(err);
  process.exit(1);
});
