/**
 * WorldsDbSource — reconcile loop that reads ~/.olam/worlds.db and
 * auto-registers running worlds into host-cp's in-memory registry.
 *
 * Two triggers (belt-and-suspenders):
 *   1. fs.watch on the worlds.db file — fires within ~100ms of a write
 *   2. 30s setInterval backstop — catches cases where fs.watch silently
 *      misses events (network filesystems, some Linux kernels)
 *
 * Uses better-sqlite3 for synchronous, lightweight reads. If the module
 * is not installed (e.g., no native build in the container), the module
 * logs a warning and exits without crashing the server.
 *
 * DB handle: deliberately NOT cached across reconcile calls. A long-lived
 * readonly connection with the DB bind-mounted across the docker boundary
 * does not reliably pick up writes committed on the host side — the host
 * writer appends to the WAL, but the container reader's snapshot is stuck
 * at the point the handle was first opened. Closing and reopening on every
 * reconcile forces a new read transaction that sees all committed WAL
 * frames. Cost: ~1 ms per call at a 30 s interval — negligible. This
 * eliminates the entire class of "olam create world vanishes within 30 s"
 * bugs (regression confirmed: ember-elk-9191 removed by reconciler despite
 * being present in worlds.db with status=running).
 *
 * Interface: thin wrapper so a future "remote" source (cloud orchestrator)
 * can drop in via the same WorldsSource interface in worlds-source.mjs.
 */

import fs from 'node:fs';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

/**
 * @typedef {object} WorldsDbSourceDeps
 * @property {string} dbPath           Path to worlds.db (OLAM_WORLDS_DB or ~/.olam/worlds.db)
 * @property {string} dockerHost       Docker API base URL (tcp://host:port)
 * @property {string} worldHost        Host used to reach world CPs (127.0.0.1 or host.docker.internal)
 * @property {() => Record<string, number>} getRegistry   Current WORLDS map
 * @property {(id: string, port: number) => void} onWorldAdded    Called when a new running world is found
 * @property {(id: string) => void} onWorldRemoved  Called when a running world disappears
 * @property {(msg: string) => void} [log]
 */

/**
 * Derive the per-world CP host port from docker inspect.
 *
 * @param {string} worldId
 * @param {string} dockerHost   e.g. 'tcp://docker-socket-proxy:2375'
 * @returns {Promise<number | null>}
 */
async function getWorldPortFromDocker(worldId, dockerHost) {
  const apiBase = dockerHost.replace(/^tcp:\/\//, 'http://');
  const containerName = `olam-${worldId}-devbox`;
  try {
    const res = await fetch(`${apiBase}/containers/${encodeURIComponent(containerName)}/json`, {
      signal: AbortSignal.timeout(3000),
    });
    if (!res.ok) return null;
    const data = await res.json();
    // Per-world CP runs on internal port 8080; host port is the published binding.
    const ports = data?.NetworkSettings?.Ports ?? {};
    const binding = ports['8080/tcp'];
    if (!Array.isArray(binding) || binding.length === 0) return null;
    const hostPort = parseInt(binding[0].HostPort, 10);
    return Number.isFinite(hostPort) ? hostPort : null;
  } catch {
    return null;
  }
}

/**
 * Start the worlds-db reconcile loop. Returns a stop function.
 *
 * @param {WorldsDbSourceDeps} deps
 * @returns {{ stop: () => void }}
 */
export function startWorldsDbReconciler(deps) {
  const { dbPath, dockerHost, getRegistry, onWorldAdded, onWorldRemoved, log = console.log } = deps;

  let db = null;
  let stopped = false;
  let watcher = null;

  function tryOpenDb() {
    if (db) return db;
    try {
      // Dynamic require — gracefully degrade if better-sqlite3 is not installed.
      // better-sqlite3 is CommonJS-only; createRequire enables sync dynamic loading in ESM.
      const Database = require('better-sqlite3');
      db = new Database(dbPath, { readonly: true, fileMustExist: true });
      log(`[worlds-db] opened ${dbPath}`);
      return db;
    } catch (err) {
      if (err.code === 'MODULE_NOT_FOUND') {
        log('[worlds-db] better-sqlite3 not available; skipping DB reconciler');
      } else if (err.code !== 'SQLITE_CANTOPEN') {
        log(`[worlds-db] failed to open ${dbPath}: ${err.message}`);
      }
      return null;
    }
  }

  async function reconcile() {
    if (stopped) return;

    // Close any cached handle so tryOpenDb() opens a fresh connection below.
    // A long-lived readonly handle under cross-bind-mount WAL mode has its
    // read snapshot frozen at open time; closing and reopening starts a new
    // read transaction that includes all WAL frames committed by the host.
    if (db) {
      try { db.close(); } catch { /* ignore */ }
      db = null;
    }

    const database = tryOpenDb();
    if (!database) return;

    try {
      let runningIds;
      try {
        const rows = database.prepare("SELECT id FROM worlds WHERE status = 'running'").all();
        runningIds = new Set(rows.map((r) => r.id));
      } catch (err) {
        log(`[worlds-db] query failed: ${err.message}`);
        return;
      }

      const registry = getRegistry();

      // Add worlds that are running in DB but missing from registry.
      for (const id of runningIds) {
        if (id in registry) continue;
        const port = await getWorldPortFromDocker(id, dockerHost);
        if (port === null) {
          log(`[worlds-db] world ${id} running in DB but no docker port found; skipping`);
          continue;
        }
        log(`[worlds-db] reconcile: adding ${id} → :${port}`);
        onWorldAdded(id, port);
      }

      // Remove worlds that are registered but no longer 'running' in DB.
      for (const id of Object.keys(registry)) {
        if (runningIds.has(id)) continue;
        log(`[worlds-db] reconcile: removing ${id} (not running in DB)`);
        onWorldRemoved(id);
      }
    } finally {
      // Always close — no need to hold the handle between reconciles.
      try { db.close(); } catch { /* ignore */ }
      db = null;
    }
  }

  // Watch the DB file for changes (fast path).
  if (fs.existsSync(dbPath)) {
    try {
      watcher = fs.watch(dbPath, { persistent: false }, () => {
        void reconcile();
      });
    } catch (err) {
      log(`[worlds-db] fs.watch failed: ${err.message}; relying on 30s poll`);
    }
    // Initial reconcile on startup.
    void reconcile();
  } else {
    log(`[worlds-db] ${dbPath} not found; will poll every 30s`);
  }

  // 30s backstop poll. Also watches for the file to appear.
  const interval = setInterval(async () => {
    if (!watcher && fs.existsSync(dbPath)) {
      // File appeared since startup — set up watcher now.
      try {
        watcher = fs.watch(dbPath, { persistent: false }, () => { void reconcile(); });
        log(`[worlds-db] ${dbPath} appeared; watcher started`);
      } catch { /* fs.watch failure is non-fatal */ }
    }
    await reconcile();
  }, 30_000);

  return {
    stop() {
      stopped = true;
      clearInterval(interval);
      if (watcher) { try { watcher.close(); } catch { /* ignore */ } }
      if (db) { try { db.close(); } catch { /* ignore */ } }
    },
  };
}
