// Recovery engine — the single entry point for bounded auto-attempts.
//
// Key invariants:
//   1. ONE attempt per (worldId, failureKind) pair. The ledger enforces
//      idempotency: a second call with the same key returns the prior
//      entry with outcome='escalated'.
//   2. Concurrent calls for the same (worldId, failureKind) key fire only
//      ONE attempt. An in-flight Map holds the running Promise; concurrent
//      callers await the same Promise.
//   3. Steps execute in order. First failing step short-circuits to
//      outcome='failed'; subsequent steps are NOT executed.
//   4. All attempts (success, failed, escalated) are written to the ledger.
//
// The engine is async and pure-functional with respect to the host-stream:
// callers (server.mjs) are responsible for emitting the recovery.* events
// AFTER receiving the returned RecoveryLedgerEntry. The engine does not
// broadcast directly, keeping it testable without a hostStream fixture.

import { findScenarioForKind } from './scenarios.mjs';
import { appendLedgerEntry, findPriorEntry } from './ledger.mjs';
import { runStep } from './step-runners.mjs';
import { DEFAULT_LEDGER_PATH } from './ledger.mjs';

/**
 * @typedef {import('./ledger.mjs').RecoveryLedgerEntry} RecoveryLedgerEntry
 * @typedef {import('../lifecycle/failure-kinds.mjs').WorldStartupFailureKind | null} FailureKindOrNull
 */

// In-flight promise map: key = `${worldId}::${failureKind ?? 'null'}` → Promise<RecoveryLedgerEntry>
/** @type {Map<string, Promise<RecoveryLedgerEntry>>} */
const _inFlight = new Map();

/**
 * Attempt a bounded recovery for the given world + failure kind.
 *
 * @param {string}            worldId
 * @param {object}            [evidence]     — WorldStartupEvidence, optional
 * @param {FailureKindOrNull} [failureKind]  — classified bucket, or null for non-FSM triggers
 * @param {{ ledgerPath?: string, log?: (msg: string) => void }} [opts]
 * @returns {Promise<RecoveryLedgerEntry>}
 */
export function attemptRecovery(worldId, evidence, failureKind = null, opts = {}) {
  const key = `${worldId}::${failureKind ?? 'null'}`;
  const existing = _inFlight.get(key);
  if (existing) return existing;

  const promise = _attempt(worldId, evidence, failureKind, opts).finally(() => {
    _inFlight.delete(key);
  });
  _inFlight.set(key, promise);
  return promise;
}

/**
 * Internal: run the actual attempt. Always resolves (never rejects) — all
 * errors are captured into the returned ledger entry.
 *
 * @param {string}            worldId
 * @param {object}            [evidence]
 * @param {FailureKindOrNull} failureKind
 * @param {{ ledgerPath?: string, log?: (msg: string) => void }} opts
 * @returns {Promise<RecoveryLedgerEntry>}
 */
async function _attempt(worldId, evidence, failureKind, opts) {
  const { ledgerPath = DEFAULT_LEDGER_PATH, log = (msg) => console.warn(`[recovery] ${msg}`) } = opts;
  const startedAt = Date.now();

  // Idempotency check: if a prior entry exists for this key, return it
  // with outcome='escalated' and write an escalated entry.
  const prior = await findPriorEntry(worldId, failureKind, ledgerPath);
  if (prior !== undefined) {
    const escalated = /** @type {RecoveryLedgerEntry} */ ({
      worldId,
      failureKind: failureKind ?? null,
      scenario: prior.scenario,
      stepsRun: [],
      startedAt,
      endedAt: Date.now(),
      outcome: 'escalated',
      errorReason: `prior attempt already recorded (outcome=${prior.outcome})`,
    });
    await appendLedgerEntry(escalated, ledgerPath);
    log(`recovery idempotency: escalated (worldId=${worldId}, kind=${failureKind})`);
    return escalated;
  }

  // Find the scenario.
  const scenario = findScenarioForKind(failureKind);
  if (!scenario) {
    const entry = /** @type {RecoveryLedgerEntry} */ ({
      worldId,
      failureKind: failureKind ?? null,
      scenario: 'unmatched',
      stepsRun: [],
      startedAt,
      endedAt: Date.now(),
      outcome: 'failed',
      errorReason: 'no scenario matched',
    });
    await appendLedgerEntry(entry, ledgerPath);
    log(`recovery: no scenario for kind=${failureKind} (worldId=${worldId})`);
    return entry;
  }

  log(`recovery: starting scenario="${scenario.name}" for worldId=${worldId}`);

  // Execute steps in order, short-circuit on first failure.
  /** @type {import('./recipes.mjs').RecoveryStep[]} */
  const stepsRun = [];
  /** @type {string | undefined} */
  let errorReason;
  /** @type {'success' | 'failed'} */
  let outcome = 'success';

  for (const step of scenario.recipe.steps) {
    stepsRun.push(step);
    try {
      await runStep(step, { worldId, evidence, log });
    } catch (err) {
      outcome = 'failed';
      errorReason = `step "${step.kind}" threw: ${err?.message ?? String(err)}`;
      log(`recovery: step failed — ${errorReason}`);
      break;
    }
  }

  const entry = /** @type {RecoveryLedgerEntry} */ ({
    worldId,
    failureKind: failureKind ?? null,
    scenario: scenario.name,
    stepsRun,
    startedAt,
    endedAt: Date.now(),
    outcome,
    ...(errorReason !== undefined ? { errorReason } : {}),
  });

  await appendLedgerEntry(entry, ledgerPath);
  log(`recovery: scenario="${scenario.name}" outcome=${outcome} (worldId=${worldId})`);
  return entry;
}

/**
 * Exposed for testing only: clear the in-flight map so tests don't bleed state.
 */
export function _clearInFlight() {
  _inFlight.clear();
}
