/**
 * world-watchdog-recovery.mjs — recovery hook for wedged claude processes.
 *
 * Isolated from world-watchdog.mjs so kill + replay logic is independently
 * mockable in tests without touching the watchdog's ticker.
 *
 * API:
 *   createRecovery({ autoRecoverMode, leakyBucket, broadcaster, persister,
 *                    replay, processKill, log })
 *     → { onWedgedVerdict({ worldId, pid }): Promise<void> }
 *
 * Three modes (from compute.autoRecover in .olam/config.yaml):
 *   false      — no-op; recovery never fires even on wedged verdict (DEFAULT)
 *   'dry-run'  — emits all breadcrumbs, never calls processKill or replay
 *   true       — SIGKILL pid + read last-dispatch + replay; rate-limited
 *
 * Rate-limit: B2 leaky-bucket (3/hour/world). 4th wedge in window emits
 * world.watchdog.recovery.budget_exhausted and skips all action.
 *
 * Replay stub: the `replay` dep is accepted as an injected function. In
 * server.mjs it is wired to a console.warn stub + breadcrumb until the
 * operator runs the B3 idempotence probe and signs off. See TODO below.
 *
 * @see docs/architecture/world-watchdog.md Recovery section
 * @see packages/host-cp/src/lib/leaky-bucket.mjs
 * @see packages/host-cp/src/dispatch-persister.mjs
 */

/**
 * @typedef {'false'|true|'dry-run'} AutoRecoverMode
 */

/**
 * @typedef {object} RecoveryDeps
 * @property {false|true|'dry-run'} autoRecoverMode
 *   Passed from server.mjs which reads config.compute.autoRecover.
 *   Default false if config unavailable.
 * @property {{ tryConsume(key: string): { allowed: boolean, retryAfterMs?: number, totalInWindow: number } }} leakyBucket
 *   B2 leaky-bucket instance. Keyed by worldId.
 * @property {{ broadcast(type: string, payload: object): void }} [broadcaster]
 *   Host-stream broadcaster. Optional — when absent, breadcrumbs are skipped.
 * @property {{ read({ worldId: string }): Promise<{ messageId: string, prompt: string, dispatchedAt: string, source: string } | null> }} persister
 *   B4 dispatch-persister read function.
 * @property {(opts: { worldId: string, messageId: string, prompt: string }) => Promise<void>} replay
 *   Opaque dispatch helper. Injected dep — DO NOT implement dispatch here.
 *   In server.mjs this is wired to a stub until operator signs off on B3 probe.
 * @property {(pid: number) => void} [processKill]
 *   process.kill indirection so tests can spy without actually killing.
 *   Defaults to process.kill.
 * @property {(msg: string) => void} [log]
 *   Logger. Defaults to console.log with [world-watchdog-recovery] prefix.
 */

/**
 * @typedef {object} RecoveryHandle
 * @property {(opts: { worldId: string, pid: number|null }) => Promise<void>} onWedgedVerdict
 */

/**
 * Create a recovery handle.
 *
 * @param {RecoveryDeps} deps
 * @returns {RecoveryHandle}
 */
export function createRecovery({
  autoRecoverMode = false,
  leakyBucket,
  broadcaster = null,
  persister,
  replay,
  processKill = (pid) => process.kill(pid, 'SIGKILL'),
  log = (m) => console.log(`[world-watchdog-recovery] ${m}`),
} = {}) {
  /**
   * Emit a breadcrumb via broadcaster (fail-soft).
   *
   * @param {string} type
   * @param {object} payload
   */
  function broadcast(type, payload) {
    if (!broadcaster || typeof broadcaster.broadcast !== 'function') return;
    try {
      broadcaster.broadcast(type, payload);
    } catch (err) {
      log(`broadcast ${type} failed: ${err?.message ?? err}`);
    }
  }

  /**
   * Handle a 2-tick-confirmed wedged verdict for a world.
   *
   * Called by world-watchdog.mjs on verdict-transition only (suspect → wedged),
   * NOT on steady-state re-wedge.
   *
   * @param {{ worldId: string, pid: number|null }} opts
   * @returns {Promise<void>}
   */
  async function onWedgedVerdict({ worldId, pid }) {
    // mode=false → detection-only; never act.
    if (autoRecoverMode === false) return;

    // PID null → watchdog hasn't resolved a real PID yet (Phase A stub case);
    // skip silently — there is nothing to kill.
    if (pid === null) return;

    // Rate-limit gate.
    const bucket = leakyBucket.tryConsume(worldId);
    if (!bucket.allowed) {
      broadcast('world.watchdog.recovery.budget_exhausted', {
        worldId,
        retryAfterMs: bucket.retryAfterMs,
        totalInWindow: bucket.totalInWindow,
      });
      log(`worldId=${worldId}: budget exhausted (${bucket.totalInWindow} in window); skipping recovery`);
      return;
    }

    // Read last persisted dispatch for replay.
    let lastDispatch = null;
    try {
      lastDispatch = await persister.read({ worldId });
    } catch (err) {
      log(`worldId=${worldId}: persister.read failed: ${err?.message ?? err}`);
    }

    broadcast('world.watchdog.recovery.start', {
      worldId,
      pid,
      mode: autoRecoverMode,
      lastDispatchMessageId: lastDispatch?.messageId ?? null,
    });

    // dry-run — log planned action but do NOT kill.
    if (autoRecoverMode === 'dry-run') {
      log(`worldId=${worldId}: dry-run — would SIGKILL pid=${pid}${lastDispatch ? ` + replay messageId=${lastDispatch.messageId}` : ' (no last-dispatch)'}`);
      broadcast('world.watchdog.recovery.complete', {
        worldId,
        pid,
        mode: 'dry-run',
        replayed: false,
      });
      return;
    }

    // mode=true — act.
    try {
      // 1. SIGKILL the wedged process.
      processKill(pid);
      log(`worldId=${worldId}: SIGKILL sent to pid=${pid}`);

      // 2. Replay or note absence of last-dispatch.
      if (!lastDispatch) {
        broadcast('world.watchdog.recovery.restart_without_replay', {
          worldId,
          pid,
        });
        log(`worldId=${worldId}: no last-dispatch; killed without replay`);
      } else {
        // TODO: wire real replay once operator has run the B3 idempotence probe
        // and confirmed dispatch is idempotent for the substrates in use.
        // Until then this stub logs and emits a breadcrumb so the stub path
        // is visible in production logs. See B3 probe + operator review gate B6.
        broadcast('world.watchdog.recovery.replay_stub', {
          worldId,
          prompt: lastDispatch.prompt,
        });
        log(`worldId=${worldId}: replay stub hit — real replay deferred pending B3 sign-off`);
        await replay({
          worldId,
          messageId: lastDispatch.messageId,
          prompt: lastDispatch.prompt,
        });
      }

      broadcast('world.watchdog.recovery.complete', {
        worldId,
        pid,
        mode: true,
        replayed: !!lastDispatch,
      });
    } catch (err) {
      log(`worldId=${worldId}: recovery failed: ${err?.message ?? err}`);
      broadcast('world.watchdog.recovery.failed', {
        worldId,
        pid,
        error: err?.message ?? String(err),
      });
    }
  }

  return { onWedgedVerdict };
}
