import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

// Deployment-mode values injected by server.mjs via configure().
// Defaults are bare-node-safe so the module is usable in tests without configure().
let HOST_FOR_WORLD = process.env.OLAM_HOST_FOR_WORLD ?? '127.0.0.1';
let TUNNELS_PATH =
  process.env.OLAM_WORLD_TUNNELS_PATH ??
  path.join(os.homedir(), '.olam', 'world-tunnels.json');

/**
 * Called by server.mjs immediately after it resolves HOST_FOR_WORLD and
 * WORLD_TUNNELS_PATH from the deployment-mode branch. Avoids re-deriving
 * container-specific literals (host.docker.internal, /data/…) in this module.
 * Re-runs loadState() when tunnelsPath differs from the env-var default so
 * container-mode persistence is loaded from /data/ rather than ~/.olam/.
 */
export function configure({ hostForWorld, tunnelsPath }) {
  HOST_FOR_WORLD = hostForWorld;
  if (tunnelsPath !== TUNNELS_PATH) {
    TUNNELS_PATH = tunnelsPath;
    loadState();
  }
}

const TUNNEL_TIMEOUT_MS = 30_000;
const PROBE_TIMEOUT_MS = 3_000;
const URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;

export const STATUS = {
  IDLE: 'idle',
  STARTING: 'starting',
  RUNNING: 'running',
  ERROR: 'error',
  STALE: 'stale',
};

export class AlreadyStartingError extends Error {
  constructor(worldId, serviceName) {
    super(`tunnel for ${serviceName} in world ${worldId} is already starting`);
    this.name = 'AlreadyStartingError';
    this.worldId = worldId;
    this.serviceName = serviceName;
  }
}

export class TunnelTimeoutError extends Error {
  constructor(worldId, serviceName) {
    super(`tunnel for ${serviceName} in world ${worldId} timed out after 30s with no URL`);
    this.name = 'TunnelTimeoutError';
    this.worldId = worldId;
    this.serviceName = serviceName;
  }
}

// Key: `${worldId}:${serviceName}` → {worldId, serviceName, port, status, url, process?}
const registry = new Map();

function tunnelKey(worldId, serviceName) {
  return `${worldId}:${serviceName}`;
}

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

function saveState() {
  try {
    const dir = path.dirname(TUNNELS_PATH);
    fs.mkdirSync(dir, { recursive: true });
    const data = {};
    for (const [key, entry] of registry) {
      // eslint-disable-next-line no-unused-vars
      const { process: _proc, ...rest } = entry;
      data[key] = rest;
    }
    const tmp = `${TUNNELS_PATH}.tmp-${process.pid}-${Date.now()}`;
    fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
    fs.renameSync(tmp, TUNNELS_PATH);
  } catch (err) {
    console.error(`world-tunnel-manager: saveState failed: ${err.message}`);
  }
}

/**
 * Start a cloudflared quick-tunnel for a world service.
 * Resolves with the assigned trycloudflare.com URL.
 * Rejects with AlreadyStartingError if the service is already starting/running.
 * Rejects with TunnelTimeoutError if no URL is emitted within 30s.
 *
 * @param {string} worldId
 * @param {string} serviceName
 * @param {number} port  host-side port (i.e. the published port on this machine)
 * @returns {Promise<string>} the public tunnel URL
 */
export async function startTunnel(worldId, serviceName, port) {
  const key = tunnelKey(worldId, serviceName);
  const existing = registry.get(key);
  if (existing && (existing.status === STATUS.STARTING || existing.status === STATUS.RUNNING)) {
    throw new AlreadyStartingError(worldId, serviceName);
  }

  const entry = {
    worldId,
    serviceName,
    port,
    status: STATUS.STARTING,
    url: null,
    process: null,
  };
  registry.set(key, entry);
  saveState();

  const target = `http://${HOST_FOR_WORLD}:${port}`;
  const child = spawn('cloudflared', ['tunnel', '--url', target], {
    stdio: ['ignore', 'pipe', 'pipe'],
    detached: false,
  });
  entry.process = child;

  return new Promise((resolve, reject) => {
    let settled = false;

    function settle(resolvedUrl) {
      if (settled) return;
      settled = true;
      clearTimeout(timer);

      if (resolvedUrl) {
        entry.status = STATUS.RUNNING;
        entry.url = resolvedUrl;
        saveState();
        resolve(resolvedUrl);
      } else {
        entry.status = STATUS.ERROR;
        entry.process = null;
        saveState();
        reject(new TunnelTimeoutError(worldId, serviceName));
      }
    }

    const timer = setTimeout(() => settle(null), TUNNEL_TIMEOUT_MS);

    function scanChunk(chunk) {
      const lines = chunk.toString().split('\n');
      for (const line of lines) {
        const match = URL_PATTERN.exec(line);
        if (match) { settle(match[0]); return; }
      }
    }

    child.stdout.on('data', scanChunk);
    child.stderr.on('data', scanChunk);

    child.on('error', (err) => {
      console.error(`world-tunnel-manager: cloudflared spawn error: ${err.message}`);
      settle(null);
    });

    child.on('exit', (code) => {
      if (!settled) {
        console.error(`world-tunnel-manager: cloudflared exited (code ${code}) before URL`);
        settle(null);
      } else {
        // Process died after URL was emitted (tunnel dropped)
        entry.status = STATUS.ERROR;
        entry.process = null;
        saveState();
      }
    });
  });
}

/**
 * Stop a tunnel for a specific service. No-op if the service has no tunnel.
 * @param {string} worldId
 * @param {string} serviceName
 */
export function stopTunnel(worldId, serviceName) {
  const key = tunnelKey(worldId, serviceName);
  const entry = registry.get(key);
  if (!entry) return;
  if (entry.process) {
    try { entry.process.kill('SIGTERM'); } catch { /* already dead */ }
    entry.process = null;
  }
  entry.status = STATUS.IDLE;
  entry.url = null;
  saveState();
}

/**
 * Return tunnel state for ALL worlds, keyed by worldId. Used by the
 * host-stream broadcaster (sse-consolidation Phase B-bonus) to push a
 * `tunnels.snapshot` whenever the registry changes — replaces the
 * SPA's per-row `usePublishedTunnels` poll loop.
 *
 * @returns {{ [worldId: string]: Array<{name: string, port: number, url: string|null, status: string}> }}
 */
export function getAllTunnels() {
  /** @type {Record<string, Array<{name: string, port: number, url: string|null, status: string}>>} */
  const byWorld = {};
  for (const entry of registry.values()) {
    if (!byWorld[entry.worldId]) byWorld[entry.worldId] = [];
    byWorld[entry.worldId].push({
      name: entry.serviceName,
      port: entry.port,
      url: entry.url,
      status: entry.status,
    });
  }
  return byWorld;
}

/**
 * Return the current tunnel state for all services in a world.
 * @param {string} worldId
 * @returns {Array<{name: string, port: number, url: string|null, status: string}>}
 */
export function getWorldTunnels(worldId) {
  const result = [];
  for (const entry of registry.values()) {
    if (entry.worldId === worldId) {
      result.push({
        name: entry.serviceName,
        port: entry.port,
        url: entry.url,
        status: entry.status,
      });
    }
  }
  return result;
}

/**
 * Kill all tunnels for a world. Called when a world is destroyed.
 * Idempotent — no-op if world has no tunnels.
 * @param {string} worldId
 */
export function killWorld(worldId) {
  const toDelete = [];
  for (const [key, entry] of registry) {
    if (entry.worldId !== worldId) continue;
    if (entry.process) {
      try { entry.process.kill('SIGTERM'); } catch { /* already dead */ }
      entry.process = null;
    }
    toDelete.push(key);
  }
  for (const key of toDelete) registry.delete(key);
  if (toDelete.length > 0) saveState();
}

/**
 * On startup, probe each persisted "running" tunnel. If the URL is unreachable,
 * mark as stale so the UI can surface a Re-publish affordance.
 */
export async function probeAllOnStartup() {
  const toProbe = [];
  for (const [key, entry] of registry) {
    if (entry.status === STATUS.RUNNING && entry.url) {
      toProbe.push({ key, url: entry.url });
    }
  }

  await Promise.all(
    toProbe.map(async ({ key, url }) => {
      try {
        const res = await fetch(url, {
          signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
      } catch {
        const entry = registry.get(key);
        if (entry) {
          entry.status = STATUS.STALE;
          saveState();
        }
      }
    }),
  );
}

function killAll() {
  for (const entry of registry.values()) {
    if (entry.process) {
      try { entry.process.kill('SIGTERM'); } catch { /* already dead */ }
      entry.process = null;
    }
  }
}

process.on('SIGTERM', killAll);
process.on('exit', killAll);

// Initialise on module load using env-var or bare-node default path.
// configure() re-runs loadState() when server.mjs provides a different path
// (container mode: /data/world-tunnels.json vs the ~/.olam default above).
loadState();
