'use strict';

const { isLoopbackHost } = require('./transport-security');

function buildCtmListenerConfigs({ host, port, tlsEnabled }) {
  const primaryHost = String(host || '127.0.0.1').trim() || '127.0.0.1';
  const primaryPort = Number.isFinite(Number(port)) ? Number(port) : 3456;
  const primarySecure = !!tlsEnabled;
  const listeners = [{
    id: 'primary',
    host: primaryHost,
    port: primaryPort,
    scheme: primarySecure ? 'https' : 'http',
    secure: primarySecure,
  }];

  if (!isLoopbackHost(primaryHost)) {
    listeners.push({
      id: 'loopback-http',
      host: '127.0.0.1',
      port: primaryPort,
      scheme: 'http',
      secure: false,
    });
  }

  return listeners;
}

function hostForUrl(host) {
  const value = String(host || '').trim();
  return value.includes(':') && !value.startsWith('[') ? `[${value}]` : value;
}

function listenerUrl(listener, hostOverride = '') {
  const host = hostForUrl(hostOverride || listener.host);
  return `${listener.scheme}://${host}:${listener.port}/`;
}

// Errors that mean "the bind address is not currently assigned to any local
// interface" — typically because Tailscale logged out, a NIC was unplugged, or
// an IPv6-only address vanished. These are not permanent: a slow background
// retry recovers automatically when the interface reappears.
const INTERFACE_MISSING_CODES = new Set(['EADDRNOTAVAIL', 'EINVAL']);

const DEFAULT_FAST_RETRY_BASE_MS = 1000;
const DEFAULT_MAX_FAST_ATTEMPTS = 5;
const DEFAULT_SLOW_RETRY_MS = 30_000;

function classifyListenerError(err) {
  const code = err && err.code ? String(err.code) : null;
  const message = (err && err.message) ? String(err.message) : String(err || '');
  if (code === 'EADDRINUSE') return { kind: 'transient-port', code, message };
  if (code && INTERFACE_MISSING_CODES.has(code)) return { kind: 'interface-missing', code, message };
  return { kind: 'permanent', code, message };
}

// Coordinates `listen()` retries across a set of listener entries.
//
// Soft-degrade policy: if the primary bind fails because its interface is
// missing (e.g. Tailscale IP not currently assigned), the loopback fallback
// listener — already in the same listeners array via buildCtmListenerConfigs —
// keeps serving. The missing-interface listener is retried in the background
// at slowRetryMs cadence so remote access is restored automatically when the
// interface returns, without a CTM restart.
//
// Fatal exit (via onFatal) is only triggered when every listener has failed
// AND no retry is pending — i.e. nothing will ever serve.
//
// Dependency injection (timers, logger, onFatal) makes the coordinator
// testable without binding real ports or calling process.exit.
function createListenerRetryCoordinator({
  listeners,
  logger = console,
  onFatal = (msg) => { try { console.error(msg); } catch {} process.exit(1); },
  setTimeoutFn = (fn, delay) => {
    const t = setTimeout(fn, delay);
    if (t && typeof t.unref === 'function') t.unref();
    return t;
  },
  clearTimeoutFn = clearTimeout,
  fastRetryBaseMs = DEFAULT_FAST_RETRY_BASE_MS,
  maxFastAttempts = DEFAULT_MAX_FAST_ATTEMPTS,
  slowRetryMs = DEFAULT_SLOW_RETRY_MS,
} = {}) {
  if (!Array.isArray(listeners) || listeners.length === 0) {
    throw new Error('createListenerRetryCoordinator requires a non-empty listeners array');
  }

  const entries = listeners.map((listener) => ({
    listener,
    state: 'idle',
    slowRetryTimer: null,
    everListening: false,
    everFailed: false,
    // True once we've logged the current "interface missing" outage. Gates the
    // slow-retry warning to ONE line per outage episode instead of every
    // slowRetryMs tick (otherwise a logged-out Tailscale floods the log forever).
    interfaceMissingLogged: false,
  }));
  let fastRetryAttempts = 0;
  let fastRetryTimer = null;
  let started = false;
  let stopped = false;

  const fmtAddr = (l) => `${l.host}:${l.port}`;

  const anyListening = () => entries.some((e) => e.state === 'listening');

  const anyRetryPending = () => {
    if (fastRetryTimer) return true;
    for (const e of entries) {
      if (e.state === 'pending') return true;
      if (e.slowRetryTimer) return true;
    }
    return false;
  };

  function clearSlowRetry(entry) {
    if (entry.slowRetryTimer) {
      clearTimeoutFn(entry.slowRetryTimer);
      entry.slowRetryTimer = null;
    }
  }

  function startListen(entry) {
    if (stopped) return;
    entry.state = 'pending';
    try {
      entry.listener.server.listen(entry.listener.port, entry.listener.host);
    } catch (err) {
      // Some listen() failures throw synchronously instead of emitting 'error'.
      // Route through the same handler so policy stays consistent.
      handleError(entry, err);
    }
  }

  function scheduleFastRetry() {
    if (stopped || fastRetryTimer) return null;
    fastRetryAttempts += 1;
    const delay = fastRetryBaseMs * fastRetryAttempts;
    fastRetryTimer = setTimeoutFn(() => {
      fastRetryTimer = null;
      if (stopped) return;
      for (const e of entries) {
        if (e.state === 'pending' || e.state === 'listening') continue;
        if (e.slowRetryTimer) continue;
        startListen(e);
      }
    }, delay);
    return delay;
  }

  function scheduleSlowRetry(entry) {
    if (stopped || entry.slowRetryTimer) return;
    entry.slowRetryTimer = setTimeoutFn(() => {
      entry.slowRetryTimer = null;
      if (stopped || entry.state === 'listening') return;
      startListen(entry);
    }, slowRetryMs);
  }

  function maybeFatal(entry, message) {
    if (stopped) return;
    if (anyListening() || anyRetryPending()) {
      logger.error(
        `[startup] ${entry.listener.id} listener ${fmtAddr(entry.listener)} failed: ${message} — ` +
        (anyListening() ? 'continuing on other listener(s).' : 'waiting on other listener(s).')
      );
      return;
    }
    onFatal(`[FATAL] ${entry.listener.id} listener ${fmtAddr(entry.listener)}: ${message}`);
  }

  function handleListening(entry) {
    const isRecovery = entry.everFailed;
    entry.state = 'listening';
    clearSlowRetry(entry);
    if (isRecovery) {
      logger.log(
        `[startup] ${entry.listener.id} listener re-bound at ${fmtAddr(entry.listener)} ` +
        '(recovered after earlier bind failure).'
      );
    }
    entry.everListening = true;
    entry.everFailed = false;
    // A successful (re)bind ends any outage — re-arm the one-shot warning so a
    // future down→up→down cycle logs again.
    entry.interfaceMissingLogged = false;
  }

  function handleError(entry, err) {
    if (stopped) return;
    entry.state = 'failed';
    entry.everFailed = true;
    const decision = classifyListenerError(err);

    if (decision.kind === 'transient-port') {
      if (fastRetryTimer) {
        logger.error(
          `[startup] ${entry.listener.id} ${fmtAddr(entry.listener)} EADDRINUSE; ` +
          'covered by pending retry.'
        );
        return;
      }
      if (fastRetryAttempts >= maxFastAttempts) {
        maybeFatal(entry, `EADDRINUSE after ${maxFastAttempts} attempts`);
        return;
      }
      const delay = scheduleFastRetry();
      logger.error(
        `[startup] ${entry.listener.id} ${fmtAddr(entry.listener)} EADDRINUSE ` +
        `(attempt ${fastRetryAttempts}/${maxFastAttempts}), retrying in ${delay}ms...`
      );
      return;
    }

    if (decision.kind === 'interface-missing') {
      scheduleSlowRetry(entry);
      // Log ONCE per outage episode, not on every slowRetryMs tick. The slow
      // retry keeps running silently; handleListening() re-arms this flag when
      // the interface returns, so a new outage logs again.
      if (!entry.interfaceMissingLogged) {
        entry.interfaceMissingLogged = true;
        const tail = anyListening()
          ? 'Other listener(s) still serving in the meantime.'
          : 'No other listener bound yet; CTM will become reachable when the interface returns or another listener binds.';
        logger.warn(
          `[startup] ${entry.listener.id} listener ${fmtAddr(entry.listener)} ${decision.code}: ` +
          `bind interface not available — retrying every ${Math.round(slowRetryMs / 1000)}s (further attempts logged once on recovery). ${tail}`
        );
      }
      // Safety invariant only: never exits while this entry's slow retry is
      // pending (anyRetryPending() is true). Checked inline so repeat failures
      // don't re-log the "continuing on other listener(s)" line.
      if (!anyListening() && !anyRetryPending()) {
        onFatal(`[FATAL] ${entry.listener.id} listener ${fmtAddr(entry.listener)}: ${decision.code}: ${decision.message}`);
      }
      return;
    }

    maybeFatal(entry, decision.message);
  }

  function start() {
    if (started) return;
    started = true;
    for (const entry of entries) {
      entry.listener.server.on('listening', () => handleListening(entry));
      entry.listener.server.on('error', (err) => handleError(entry, err));
    }
    for (const entry of entries) startListen(entry);
  }

  function stop() {
    stopped = true;
    if (fastRetryTimer) { clearTimeoutFn(fastRetryTimer); fastRetryTimer = null; }
    for (const entry of entries) clearSlowRetry(entry);
  }

  function snapshot() {
    return entries.map((e) => ({
      id: e.listener.id,
      state: e.state,
      slowRetryScheduled: !!e.slowRetryTimer,
      everListening: e.everListening,
    }));
  }

  return { start, stop, snapshot };
}

module.exports = {
  buildCtmListenerConfigs,
  hostForUrl,
  listenerUrl,
  classifyListenerError,
  createListenerRetryCoordinator,
  // Exposed for tests / callers that want to tune defaults via env:
  DEFAULT_FAST_RETRY_BASE_MS,
  DEFAULT_MAX_FAST_ATTEMPTS,
  DEFAULT_SLOW_RETRY_MS,
};
