// Version detection for Phase 1 of self-upgrade.
//
// Compares each component's baked OLAM_BUILD_SHA against the operator's
// local repo HEAD (mounted read-only at /operator-repo). Reports upgrade
// availability without triggering any automatic action — Phase 1 is
// detection only.

import fs from 'node:fs';
import path from 'node:path';

/** @typedef {'ok' | 'behind' | 'unknown'} VersionState */

/**
 * @typedef {Object} ComponentVersion
 * @property {string} running  - SHA baked into the running image
 * @property {string} latest   - SHA of operator's local HEAD (or 'unknown')
 * @property {boolean} upgradeAvailable
 */

/**
 * @typedef {Object} VersionSnapshot
 * @property {ComponentVersion} hostCp
 * @property {ComponentVersion} authService
 * @property {ComponentVersion} devbox
 * @property {string} operatorHead  - resolved HEAD or 'unknown'
 * @property {string} checkedAt     - ISO timestamp
 * @property {string} cliVersion    - operator's CLI semver (e.g. "0.1.69") or 'unknown'
 */

/**
 * Read the operator's local repo HEAD.
 *
 * Tries OLAM_REPO_PATH env var first, then /operator-repo (the compose-
 * mounted path), then $HOME/Projects/ein-sof/olam as a bare-node fallback.
 *
 * Returns 'unknown' on any read error.
 *
 * @returns {string}
 */
export function readOperatorHead() {
  const candidates = [
    process.env.OLAM_REPO_PATH,
    '/operator-repo',
  ].filter(Boolean);

  for (const repoPath of candidates) {
    try {
      // Read HEAD to find the current branch ref (e.g. "ref: refs/heads/main")
      // then resolve to the SHA.
      const headFile = path.join(repoPath, '.git', 'HEAD');
      if (!fs.existsSync(headFile)) continue;

      const headContent = fs.readFileSync(headFile, 'utf-8').trim();

      if (headContent.startsWith('ref: ')) {
        // Symbolic ref → resolve to SHA via the packed-refs or loose ref.
        const refPath = headContent.slice('ref: '.length);
        const looseRef = path.join(repoPath, '.git', refPath);
        if (fs.existsSync(looseRef)) {
          return fs.readFileSync(looseRef, 'utf-8').trim();
        }
        // Try packed-refs fallback.
        const packedRefs = path.join(repoPath, '.git', 'packed-refs');
        if (fs.existsSync(packedRefs)) {
          const lines = fs.readFileSync(packedRefs, 'utf-8').split('\n');
          for (const line of lines) {
            if (line.startsWith('#')) continue;
            const [sha, ref] = line.trim().split(' ');
            if (ref === refPath) return sha;
          }
        }
      } else if (/^[0-9a-f]{40}$/i.test(headContent)) {
        // Detached HEAD — use the SHA directly.
        return headContent;
      }
    } catch {
      // silently try next candidate
    }
  }
  return 'unknown';
}

/**
 * Compare two SHAs. Returns true when they differ and both are known.
 * If either is 'unknown' we cannot assert an upgrade is available.
 *
 * @param {string} running
 * @param {string} latest
 * @returns {boolean}
 */
export function isUpgradeAvailable(running, latest) {
  if (running === 'unknown' || latest === 'unknown') return false;
  // SHAs may be full (40 hex chars) or short (7+ hex chars from --short).
  // Compare by checking if one is a prefix of the other.
  const a = running.toLowerCase();
  const b = latest.toLowerCase();
  return !a.startsWith(b) && !b.startsWith(a);
}

/**
 * Fetch the auth-service's /health endpoint and extract buildSha.
 *
 * @param {string} authServiceUrl
 * @returns {Promise<string>}
 */
export async function fetchAuthServiceSha(authServiceUrl) {
  try {
    const res = await fetch(`${authServiceUrl}/health`, {
      signal: AbortSignal.timeout(5000),
    });
    if (!res.ok) return 'unknown';
    const data = /** @type {unknown} */ (await res.json());
    if (data && typeof data === 'object' && 'buildSha' in data) {
      const sha = /** @type {Record<string, unknown>} */ (data)['buildSha'];
      return typeof sha === 'string' ? sha : 'unknown';
    }
    return 'unknown';
  } catch {
    return 'unknown';
  }
}

/**
 * Inspect a locally-tagged docker image (by reference such as
 * `ghcr.io/pleri/olam-host-cp:latest`) and extract its baked
 * OLAM_BUILD_SHA env. Returns 'unknown' if the image isn't pulled,
 * the docker socket is unreachable, or the env is missing.
 *
 * Used as the "what's the latest published image we'd swap to?"
 * signal for the upgrade comparator — replaces the prior
 * `operatorHead` (operator's local git HEAD) which over-reports
 * upgradeAvailable whenever an SPA-only PR merges between releases.
 *
 * @param {string} dockerApiBase
 * @param {string} imageRef  e.g. "ghcr.io/pleri/olam-host-cp:latest"
 * @returns {Promise<string>}
 */
export async function fetchLatestImageSha(dockerApiBase, imageRef) {
  try {
    const res = await fetch(
      `${dockerApiBase}/images/${encodeURIComponent(imageRef)}/json`,
      { signal: AbortSignal.timeout(5000) },
    );
    if (!res.ok) return 'unknown';
    const image = /** @type {unknown} */ (await res.json());
    if (!image || typeof image !== 'object') return 'unknown';
    const config = /** @type {Record<string, unknown>} */ (image)['Config'];
    if (!config || typeof config !== 'object') return 'unknown';
    const env = /** @type {Record<string, unknown>} */ (config)['Env'];
    if (!Array.isArray(env)) return 'unknown';
    for (const e of env) {
      if (typeof e === 'string' && e.startsWith('OLAM_BUILD_SHA=')) {
        return e.slice('OLAM_BUILD_SHA='.length);
      }
    }
    return 'unknown';
  } catch {
    return 'unknown';
  }
}

/**
 * Fetch the devbox image SHA. We check the running devbox container's
 * OLAM_BUILD_SHA env var via the docker socket proxy (inspect endpoint).
 * Returns 'unknown' if any step fails.
 *
 * @param {string} dockerApiBase  e.g. "http://docker-socket-proxy:2375" or "http://localhost:2375"
 * @returns {Promise<string>}
 */
export async function fetchDevboxImageSha(dockerApiBase) {
  try {
    // List containers named olam-*-devbox and grab the first one.
    const listRes = await fetch(
      `${dockerApiBase}/containers/json?filters=${encodeURIComponent(JSON.stringify({ name: ['olam-devbox'] }))}`,
      { signal: AbortSignal.timeout(5000) },
    );
    if (!listRes.ok) return 'unknown';
    const containers = /** @type {unknown} */ (await listRes.json());
    if (!Array.isArray(containers) || containers.length === 0) return 'unknown';

    // Use the most recently-created devbox container's image ID.
    // Inspect the image for OLAM_BUILD_SHA label or env.
    const container = /** @type {Record<string, unknown>} */ (containers[0]);
    const imageId = typeof container['ImageID'] === 'string' ? container['ImageID'] : null;
    if (!imageId) return 'unknown';

    const inspectRes = await fetch(
      `${dockerApiBase}/images/${encodeURIComponent(imageId)}/json`,
      { signal: AbortSignal.timeout(5000) },
    );
    if (!inspectRes.ok) return 'unknown';
    const image = /** @type {unknown} */ (await inspectRes.json());
    if (!image || typeof image !== 'object') return 'unknown';

    const config = /** @type {Record<string, unknown>} */ (image)['Config'];
    if (!config || typeof config !== 'object') return 'unknown';
    const env = /** @type {Record<string, unknown>} */ (config)['Env'];
    if (!Array.isArray(env)) return 'unknown';

    for (const e of env) {
      if (typeof e === 'string' && e.startsWith('OLAM_BUILD_SHA=')) {
        return e.slice('OLAM_BUILD_SHA='.length);
      }
    }
    return 'unknown';
  } catch {
    return 'unknown';
  }
}

/**
 * Build a full VersionSnapshot from all available sources.
 *
 * @param {{
 *   authServiceUrl: string;
 *   dockerApiBase: string;
 * }} opts
 * @returns {Promise<VersionSnapshot>}
 */
export async function buildVersionSnapshot({ authServiceUrl, dockerApiBase }) {
  const operatorHead = readOperatorHead();

  // Inspect locally-pulled `:latest` image tags to get the actual
  // published baked SHA — what `olam upgrade` would swap us to next.
  // Fall back to operatorHead when the image isn't pulled (first-run
  // or stack never upgraded) so the banner still surfaces SOMETHING.
  const [authSha, devboxSha, hostCpLatestPublished, authLatestPublished, devboxLatestPublished] =
    await Promise.all([
      fetchAuthServiceSha(authServiceUrl),
      fetchDevboxImageSha(dockerApiBase),
      fetchLatestImageSha(dockerApiBase, 'ghcr.io/pleri/olam-host-cp:latest'),
      // NOTE: docker tag is `olam-auth` (no `-service` suffix); npm
      // workspace is `auth-service`. The two diverged historically.
      fetchLatestImageSha(dockerApiBase, 'ghcr.io/pleri/olam-auth:latest'),
      fetchLatestImageSha(dockerApiBase, 'ghcr.io/pleri/olam-devbox:latest'),
    ]);

  const hostCpRunning = process.env.OLAM_BUILD_SHA ?? 'unknown';

  // Pick "latest" per component: use the published image SHA when we
  // can read it (truthful — that's what would swap in), else fall back
  // to operatorHead (legacy behaviour, may over-report between SPA-only
  // PR merges and the next image rebuild — but still informative when
  // the operator hasn't yet pulled `:latest`).
  const hostCpLatest = pickLatest(hostCpLatestPublished, operatorHead);
  const authLatest = pickLatest(authLatestPublished, operatorHead);
  const devboxLatest = pickLatest(devboxLatestPublished, operatorHead);

  // CLI version is propagated by `olam host-cp start` via the
  // OLAM_CLI_VERSION env (see packages/cli/src/commands/host-cp.ts
  // buildComposeEnv). Falls back to host-cp's own package.json when
  // an older CLI started this container without setting the env.
  const cliVersion = process.env.OLAM_CLI_VERSION
    || readHostCpPackageVersion()
    || 'unknown';

  return {
    hostCp: {
      running: hostCpRunning,
      latest: hostCpLatest,
      upgradeAvailable: isUpgradeAvailable(hostCpRunning, hostCpLatest),
    },
    authService: {
      running: authSha,
      latest: authLatest,
      upgradeAvailable: isUpgradeAvailable(authSha, authLatest),
    },
    devbox: {
      running: devboxSha,
      latest: devboxLatest,
      upgradeAvailable: isUpgradeAvailable(devboxSha, devboxLatest),
    },
    operatorHead,
    checkedAt: new Date().toISOString(),
    cliVersion,
  };
}

/**
 * Prefer the published-image SHA (truthful "would swap to") over the
 * operator's local git HEAD (over-reports when SPA-only PRs land
 * between image rebuilds). Falls back to operatorHead when the image
 * isn't pulled (e.g. cold-start before first `olam upgrade`).
 *
 * @param {string} publishedImageSha
 * @param {string} operatorHead
 * @returns {string}
 */
export function pickLatest(publishedImageSha, operatorHead) {
  if (publishedImageSha && publishedImageSha !== 'unknown') return publishedImageSha;
  return operatorHead;
}

/**
 * Read host-cp's bundled package.json version as the CLI-version
 * fallback when OLAM_CLI_VERSION isn't propagated. The container
 * Dockerfile copies the manifest into /app, so the lookup walks up
 * from this module's location.
 *
 * @returns {string | null}
 */
function readHostCpPackageVersion() {
  try {
    const here = path.dirname(new URL(import.meta.url).pathname);
    for (const candidate of [
      path.join(here, '..', 'package.json'),
      path.join(here, '..', '..', 'package.json'),
    ]) {
      if (fs.existsSync(candidate)) {
        const pkg = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
        if (typeof pkg.version === 'string' && pkg.version.length > 0) return pkg.version;
      }
    }
  } catch {
    // best-effort
  }
  return null;
}
