/**
 * port-bridge-manager.mjs
 * Manages socat sidecar containers that bridge host port → world devbox port.
 * Dual-mode: Docker HTTP API (container) vs docker CLI (bare-node).
 */
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const DOCKER_HOST = process.env.DOCKER_HOST ?? 'docker-cli';
const SOCAT_IMAGE = 'alpine/socat';
const SOCAT_IMAGE_TAGGED = 'alpine/socat:latest';
const HOST_PORT_MIN = 25000;
const HOST_PORT_MAX = 25999;
const INFRA_PORTS = new Set([8080, 7681, 7682]);

let BRIDGES_PATH =
  process.env.OLAM_PORT_BRIDGES_PATH ??
  path.join(os.homedir(), '.olam', 'port-bridges.json');
let HOST_IP = '127.0.0.1';

// key: `${worldId}:${containerPort}` → { worldId, containerPort, hostPort, containerId, containerName }
const registry = new Map();

export function configure({ bridgesPath, hostIp }) {
  if (bridgesPath && bridgesPath !== BRIDGES_PATH) {
    BRIDGES_PATH = bridgesPath;
    loadState();
  }
  if (hostIp) HOST_IP = hostIp;
}

function bridgeKey(worldId, containerPort) {
  return `${worldId}:${containerPort}`;
}

function bridgeContainerName(worldId, containerPort) {
  return `olam-${worldId}-bridge-${containerPort}`;
}

function loadState() {
  try {
    if (!fs.existsSync(BRIDGES_PATH)) return;
    const raw = fs.readFileSync(BRIDGES_PATH, 'utf-8');
    const data = JSON.parse(raw);
    if (!data || typeof data !== 'object') return;
    for (const [key, entry] of Object.entries(data)) {
      registry.set(key, entry);
    }
  } catch (err) {
    console.error(`port-bridge-manager: loadState failed: ${err.message}`);
  }
}

function saveState() {
  try {
    const dir = path.dirname(BRIDGES_PATH);
    fs.mkdirSync(dir, { recursive: true });
    const data = {};
    for (const [key, entry] of registry) {
      data[key] = entry;
    }
    const tmp = `${BRIDGES_PATH}.tmp-${process.pid}-${Date.now()}`;
    fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
    fs.renameSync(tmp, BRIDGES_PATH);
  } catch (err) {
    console.error(`port-bridge-manager: saveState failed: ${err.message}`);
  }
}

function allocateHostPort() {
  const used = new Set(Array.from(registry.values()).map((e) => e.hostPort));
  for (let p = HOST_PORT_MIN; p <= HOST_PORT_MAX; p++) {
    if (!used.has(p)) return p;
  }
  return null;
}

async function dockerApiBase() {
  return DOCKER_HOST === 'docker-cli'
    ? null // bare-node: no socket proxy HTTP API
    : DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
}

/**
 * Detect whether a docker error message indicates the image is missing
 * (and therefore a `docker pull` retry would help). Docker uses a handful
 * of phrasings across CLI + HTTP API surfaces.
 */
function isImageMissingError(message) {
  if (!message) return false;
  return /Unable to find image|pull access denied|manifest unknown|No such image|not found in (the )?(repository|registry)/i.test(
    message,
  );
}

/**
 * Pull alpine/socat:latest via docker CLI. Used by the bare-node bridge
 * create path's fallback retry. 60s budget — image is ~5MB; real pull
 * is typically <2s.
 *
 * @returns {{ok: boolean, stderr: string}}
 */
function pullSocatViaCli() {
  const r = spawnSync('docker', ['pull', SOCAT_IMAGE_TAGGED], {
    encoding: 'utf-8',
    timeout: 60_000,
  });
  return {
    ok: r.status === 0,
    stderr: (r.stderr ?? '').trim() || (r.stdout ?? '').trim(),
  };
}

/**
 * Pull alpine/socat:latest via Docker HTTP API. Used by the container-mode
 * bridge create path's fallback retry. Streams the pull progress body so
 * Docker actually performs the pull (it's a streaming endpoint).
 *
 * @param {string} apiBase — Docker HTTP API base URL
 * @returns {Promise<{ok: boolean, stderr: string}>}
 */
async function pullSocatViaHttpApi(apiBase) {
  try {
    const resp = await fetch(
      `${apiBase}/images/create?fromImage=${encodeURIComponent(SOCAT_IMAGE)}&tag=latest`,
      { method: 'POST', signal: AbortSignal.timeout(60_000) },
    );
    if (!resp.ok) {
      const body = await resp.text().catch(() => '');
      return { ok: false, stderr: `pull failed: ${resp.status} ${body}` };
    }
    // Drain the streaming progress body — Docker only completes the pull
    // when the response is consumed.
    await resp.text();
    return { ok: true, stderr: '' };
  } catch (err) {
    return { ok: false, stderr: err?.message ?? String(err) };
  }
}

/**
 * Create and start a socat bridge container.
 *
 * Returns `{ containerId, pulledImage }` — `pulledImage: true` indicates the
 * function had to fall back to `docker pull alpine/socat:latest` (issue #964
 * — preflight in `olam services up` should normally have already pulled it).
 *
 * @param {string} worldId
 * @param {number} containerPort
 * @param {number} hostPort
 * @returns {Promise<{containerId: string, pulledImage: boolean}>}
 */
async function createBridgeContainer(worldId, containerPort, hostPort) {
  const name = bridgeContainerName(worldId, containerPort);
  const networkName = `olam-${worldId}`;
  const devboxName = `olam-${worldId}-devbox`;
  const socatCmd = `TCP-LISTEN:${containerPort},fork,reuseaddr TCP:${devboxName}:${containerPort}`;

  const apiBase = await dockerApiBase();

  if (!apiBase) {
    // bare-node: use docker CLI
    const args = [
      'run', '-d',
      '--name', name,
      '--network', networkName,
      '-p', `${HOST_IP}:${hostPort}:${containerPort}`,
      '--label', `olam.world.id=${worldId}`,
      '--label', 'olam.role=server-bridge',
      '--restart', 'unless-stopped',
      SOCAT_IMAGE,
      'TCP-LISTEN:' + containerPort + ',fork,reuseaddr',
      'TCP:' + devboxName + ':' + containerPort,
    ];
    let result = spawnSync('docker', args, { encoding: 'utf-8', timeout: 10000 });
    let pulledImage = false;

    // Issue #964 fallback: if docker run failed because the image is missing,
    // pull it and retry once. This covers hosts where `olam services up`
    // didn't run the preflight (e.g. fresh Hazel install, docker restart
    // pruned the image, etc.).
    if (result.status !== 0 && isImageMissingError(result.stderr ?? '')) {
      const pull = pullSocatViaCli();
      if (!pull.ok) {
        throw new Error(
          `alpine/socat image missing and pull failed: ${pull.stderr || 'unknown error'}`,
        );
      }
      pulledImage = true;
      result = spawnSync('docker', args, { encoding: 'utf-8', timeout: 10000 });
    }

    if (result.status !== 0) {
      throw new Error(result.stderr?.trim() || 'docker run failed');
    }
    return { containerId: result.stdout.trim(), pulledImage };
  }

  // container mode: Docker HTTP API
  const createBody = {
    Image: SOCAT_IMAGE,
    Cmd: socatCmd.split(' '),
    Labels: {
      'olam.world.id': worldId,
      'olam.role': 'server-bridge',
    },
    HostConfig: {
      NetworkMode: networkName,
      PortBindings: {
        [`${containerPort}/tcp`]: [{ HostIp: HOST_IP, HostPort: String(hostPort) }],
      },
      RestartPolicy: { Name: 'unless-stopped' },
    },
  };

  const doCreate = () => fetch(
    `${apiBase}/containers/create?name=${encodeURIComponent(name)}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(createBody),
      signal: AbortSignal.timeout(10000),
    },
  );

  let createResp = await doCreate();
  let pulledImage = false;

  // Issue #964 fallback for HTTP API path. Docker returns 404 with a body
  // like {"message":"No such image: alpine/socat:latest"} when the image
  // is missing.
  if (!createResp.ok && createResp.status === 404) {
    const body = await createResp.text().catch(() => '');
    if (isImageMissingError(body)) {
      const pull = await pullSocatViaHttpApi(apiBase);
      if (!pull.ok) {
        throw new Error(
          `alpine/socat image missing and pull failed: ${pull.stderr || 'unknown error'}`,
        );
      }
      pulledImage = true;
      createResp = await doCreate();
    } else {
      throw new Error(`container create failed: 404 ${body}`);
    }
  }

  if (!createResp.ok) {
    const body = await createResp.text().catch(() => '');
    // If container already exists (409), try to get its ID
    if (createResp.status === 409) {
      const inspectResp = await fetch(
        `${apiBase}/containers/${encodeURIComponent(name)}/json`,
        { signal: AbortSignal.timeout(3000) },
      );
      if (inspectResp.ok) {
        const info = await inspectResp.json();
        return { containerId: info.Id, pulledImage };
      }
    }
    throw new Error(`container create failed: ${createResp.status} ${body}`);
  }

  const { Id: containerId } = await createResp.json();

  const startResp = await fetch(`${apiBase}/containers/${encodeURIComponent(containerId)}/start`, {
    method: 'POST',
    signal: AbortSignal.timeout(5000),
  });
  if (!startResp.ok && startResp.status !== 304) {
    throw new Error(`container start failed: ${startResp.status}`);
  }

  return { containerId, pulledImage };
}

async function removeBridgeContainer(containerName, containerId) {
  const id = containerId || containerName;
  const apiBase = await dockerApiBase();

  if (!apiBase) {
    spawnSync('docker', ['rm', '-f', id], { encoding: 'utf-8', timeout: 5000 });
    return;
  }

  // Force remove (stop + delete in one call)
  await fetch(`${apiBase}/containers/${encodeURIComponent(id)}?force=true`, {
    method: 'DELETE',
    signal: AbortSignal.timeout(5000),
  }).catch(() => { /* best-effort */ });
}

/**
 * Expose a world's container port via a socat bridge.
 * Idempotent: returns existing bridge if already active.
 *
 * @param {string} worldId
 * @param {number} containerPort
 * @returns {Promise<{hostPort: number, containerPort: number, url: string, containerId: string, pulledImage?: boolean}>}
 */
export async function exposePort(worldId, containerPort) {
  if (INFRA_PORTS.has(containerPort)) {
    throw new Error(`port ${containerPort} is reserved for infrastructure`);
  }

  const key = bridgeKey(worldId, containerPort);
  const existing = registry.get(key);
  if (existing) {
    return {
      hostPort: existing.hostPort,
      containerPort: existing.containerPort,
      url: `http://${HOST_IP}:${existing.hostPort}`,
      containerId: existing.containerId,
    };
  }

  const hostPort = allocateHostPort();
  if (hostPort === null) {
    throw new Error('no host ports available in range 25000–25999');
  }

  const containerName = bridgeContainerName(worldId, containerPort);
  const { containerId, pulledImage } = await createBridgeContainer(worldId, containerPort, hostPort);

  const entry = { worldId, containerPort, hostPort, containerId, containerName };
  registry.set(key, entry);
  saveState();

  const result = {
    hostPort,
    containerPort,
    url: `http://${HOST_IP}:${hostPort}`,
    containerId,
  };
  // Only attach pulledImage when true so existing callers/tests don't see
  // an unexpected key when the preflight succeeded.
  if (pulledImage) result.pulledImage = true;
  return result;
}

/**
 * Remove a port bridge for a world.
 * No-op if bridge doesn't exist.
 *
 * @param {string} worldId
 * @param {number} containerPort
 */
export async function removePort(worldId, containerPort) {
  const key = bridgeKey(worldId, containerPort);
  const entry = registry.get(key);
  if (!entry) return;

  registry.delete(key);
  saveState();

  await removeBridgeContainer(entry.containerName, entry.containerId);
}

/**
 * Remove all bridges for a world. Called on world destroy.
 * @param {string} worldId
 */
export async function killWorld(worldId) {
  const toDelete = [];
  for (const [key, entry] of registry) {
    if (entry.worldId === worldId) toDelete.push({ key, entry });
  }
  for (const { key, entry } of toDelete) {
    registry.delete(key);
    await removeBridgeContainer(entry.containerName, entry.containerId).catch(() => {});
  }
  if (toDelete.length > 0) saveState();
}

/**
 * List active bridges for a world.
 * @param {string} worldId
 * @returns {Array<{containerPort: number, hostPort: number, url: string}>}
 */
export function getWorldBridges(worldId) {
  const result = [];
  for (const entry of registry.values()) {
    if (entry.worldId === worldId) {
      result.push({
        containerPort: entry.containerPort,
        hostPort: entry.hostPort,
        url: `http://${HOST_IP}:${entry.hostPort}`,
      });
    }
  }
  return result;
}

loadState();
