/**
 * op-side-longpoll.mjs — Operator-side long-poll loop.
 *
 * Maintains a persistent outbound HTTPS connection from host-cp to
 * plan-DO's /v1/op-poll endpoint, waiting for local-Docker dispatch
 * work to appear. Feature-flag-gated behind OLAM_OPSIDE_LONGPOLL=1
 * (default OFF — no behavior change when unset).
 *
 * ELI5: like installing a phone line to the cloud planner.
 * Your local machine stays connected, ready to receive coding tasks.
 * Nothing calls yet — the phone just sits there waiting (v1).
 *
 * Phase D ships the plumbing and tests it behind a flag. Future cells
 * (#3/#6/#7) wire actual producers on the plan-DO side.
 *
 * Circuit-breaker: 10 consecutive errors → 60 s pause →
 * counter resets and polling resumes. Prevents hammering the server
 * during outages.
 *
 * Reconnect delay: 1000ms base + uniform 0-500ms jitter.
 * All structured log events emitted to console via JSON objects.
 *
 * @module op-side-longpoll
 */

// Reconnect delay constants.
const RECONNECT_BASE_MS = 1000;
const RECONNECT_JITTER_MS = 500;

// Circuit-breaker constants.
const CIRCUIT_BREAKER_THRESHOLD = 10;
const CIRCUIT_BREAKER_PAUSE_MS = 60_000;

// Long-poll timeout — plan-DO blocks up to 25 s; we give a 5 s margin.
const POLL_TIMEOUT_MS = 30_000;

/**
 * Compute reconnect delay: 1000 + uniform 0-500 ms jitter.
 * Exported so tests can mock Math.random and assert the formula.
 *
 * @param {() => number} [randFn] - Optional RNG override for testing.
 * @returns {number} Delay in milliseconds.
 */
export function reconnectDelay(randFn = Math.random) {
  return RECONNECT_BASE_MS + Math.floor(randFn() * RECONNECT_JITTER_MS);
}

/** @type {ReturnType<typeof setTimeout> | null} */
let pollTimer = null;

/** @type {boolean} */
let running = false;

/** @type {number} */
let consecutiveErrors = 0;

/** @type {string | null} */
let activeCloudUrl = null;

/** @type {string | null} */
let activeAuth = null;

/**
 * Emit a structured log event. Uses console.log to be consistent with
 * host-cp's existing logging style. All events include `event` + `ts`.
 *
 * @param {string} event
 * @param {Record<string, unknown>} [extra]
 */
function emit(event, extra = {}) {
  console.log(JSON.stringify({ event, ts: new Date().toISOString(), ...extra }));
}

/**
 * Sleep for ms milliseconds. Returns a Promise that resolves after the
 * delay. Cancellable via the token: if token.cancelled becomes true
 * before the timeout fires, the promise still resolves (callers check
 * running themselves).
 *
 * @param {number} ms
 * @returns {Promise<void>}
 */
function sleep(ms) {
  return new Promise((resolve) => {
    pollTimer = setTimeout(resolve, ms);
  });
}

/**
 * Single poll iteration: open a GET /v1/op-poll request, wait for
 * a response, parse the JSON body.
 *
 * @param {string} cloudUrl Base URL (e.g. https://plan-do.example.com)
 * @param {string} auth Authorization header value
 * @returns {Promise<{ work: null | { worldId: string, dispatchSpec: unknown } }>}
 */
async function pollOnce(cloudUrl, auth) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), POLL_TIMEOUT_MS);

  try {
    const url = `${cloudUrl.replace(/\/$/, '')}/v1/op-poll`;
    const res = await fetch(url, {
      method: 'GET',
      headers: { Authorization: auth },
      signal: controller.signal,
    });

    if (!res.ok) {
      throw new Error(`op-poll returned ${res.status}`);
    }

    const body = await res.json();
    return body;
  } finally {
    clearTimeout(timeoutId);
  }
}

/**
 * The main poll loop. Runs until stopPoll() is called.
 *
 * State transitions:
 *   idle
 *   → connecting (emit op-poll-connect)
 *   → got { work: null } timeout response (emit op-poll-timeout)
 *   → wait reconnect delay
 *   → connecting again
 *
 * On error:
 *   → consecutiveErrors++
 *   → emit op-poll-error
 *   → if consecutiveErrors >= threshold: circuit-breaker open
 *       emit op-poll-circuit-open, wait 60 s, reset counter
 *   → else: wait reconnect delay
 *
 * @returns {Promise<void>}
 */
async function pollLoop() {
  while (running) {
    emit('op-poll-connect', { cloud_url: activeCloudUrl });

    try {
      const result = await pollOnce(activeCloudUrl, activeAuth);

      // On a successful { work: null } response, reset the error counter.
      consecutiveErrors = 0;

      const delay = reconnectDelay();
      emit('op-poll-timeout', { work: result.work, reconnect_in_ms: delay });

      if (!running) break;
      await sleep(delay);
    } catch (err) {
      consecutiveErrors++;
      const message = err instanceof Error ? err.message : String(err);

      emit('op-poll-error', {
        error: message,
        consecutive_errors: consecutiveErrors,
      });

      if (consecutiveErrors >= CIRCUIT_BREAKER_THRESHOLD) {
        emit('op-poll-circuit-open', {
          consecutive_errors: consecutiveErrors,
          pause_ms: CIRCUIT_BREAKER_PAUSE_MS,
        });
        consecutiveErrors = 0;
        if (!running) break;
        await sleep(CIRCUIT_BREAKER_PAUSE_MS);
      } else {
        if (!running) break;
        const delay = reconnectDelay();
        await sleep(delay);
      }
    }
  }
}

/**
 * Start the operator-side long-poll loop.
 *
 * No-op if already running. Reads the flag from the environment:
 * only runs when OLAM_OPSIDE_LONGPOLL === '1'. Call this AFTER
 * server.listen() to avoid blocking the process startup path.
 *
 * @param {string} cloudUrl Base URL of the plan-DO deployment.
 * @param {string} auth Authorization header value for Basic auth.
 */
export function startPoll(cloudUrl, auth) {
  if (running) return;
  running = true;
  consecutiveErrors = 0;
  activeCloudUrl = cloudUrl;
  activeAuth = auth;
  // Fire-and-forget; errors are caught inside pollLoop.
  void pollLoop();
}

/**
 * Stop the operator-side long-poll loop.
 *
 * Cancels any in-progress sleep timer; the loop condition will
 * exit on its next iteration. Idempotent.
 */
export function stopPoll() {
  running = false;
  if (pollTimer !== null) {
    clearTimeout(pollTimer);
    pollTimer = null;
  }
}
