'use strict';

/**
 * Provider gateway (Session 20).
 *
 * The single entry point every external-data call passes through.
 * Adapters call:
 *
 *   const result = await gateway.fetch('odds-api', cbWithProvider, {
 *     capability: 'odds',
 *     sport: 'nba',
 *     fallbackProviders: ['oddspapi'],  // optional override
 *     syncHeadersFrom: (r) => r.headers, // optional
 *   });
 *
 * Flow:
 *   1. Check primary provider's quota via quotaTracker
 *   2. If allowed → invoke callback, sync headers on success
 *   3. If blocked → walk the fallback chain (explicit or
 *      capability-derived from the registry)
 *   4. If every provider is exhausted → throw QuotaExhaustedError
 *      with a structured `attempts` log so the operator can see
 *      what was tried
 *   5. Adapter-thrown errors propagate after rollback
 *
 * Callback receives the providerId actually being used so it can
 * pick the right base URL / API key for fallbacks. For
 * single-provider calls, callers can ignore the argument.
 */

const quotaTracker = require('./quotaTracker');
const { getFallbackChain } = require('../config/providers');

class QuotaExhaustedError extends Error {
  constructor(primary, sport, attempts) {
    super(`All providers exhausted for ${primary}/${sport || '*'}. Tried: ${attempts.map((a) => `${a.provider}=${a.reason}`).join('; ')}`);
    this.name = 'QuotaExhaustedError';
    this.code = 'QUOTA_EXHAUSTED';
    this.statusCode = 503;
    this.primary = primary;
    this.sport = sport;
    this.attempts = attempts;
  }
}

async function tryOne(providerId, callbackFn, syncHeadersFrom, reserve = 0) {
  // Reserve floor (Job 1 / quota guard) — a DISCRETIONARY call (futures,
  // soccer outrights) is refused while fewer than `reserve` credits remain, so
  // it can never drain the last credits that the ESSENTIAL path (MLB prop
  // backup when PropLine fails) depends on. Essential calls pass reserve=0 and
  // use the quota down to the normal 95% block. Checked BEFORE the optimistic
  // increment so we don't consume-then-refund. Degraded Redis fails open.
  if (reserve > 0) {
    const pre = await quotaTracker.getQuotaStatus(providerId);
    if (pre && !pre.degraded && Number.isFinite(pre.remaining) && pre.remaining <= reserve) {
      return { ok: false, reason: `reserve_floor(${pre.remaining}<=${reserve})`, status: pre };
    }
  }
  // Optimistic increment — if the call throws we roll back below.
  // recordCall also evaluates the post-increment threshold; if the
  // very next call would put us at 95%+, we still execute THIS one
  // (it returned allowed:true before incrementing) and the NEXT
  // call will see the block.
  const status = await quotaTracker.recordCall(providerId);
  if (!status.allowed) {
    await quotaTracker.rollback(providerId);
    return { ok: false, reason: status.reason || 'blocked', status };
  }
  try {
    const result = await callbackFn(providerId);
    // Best-effort header sync — caller signals where the headers
    // live on the response object. Failure is non-fatal; the
    // optimistic counter remains.
    if (typeof syncHeadersFrom === 'function') {
      try {
        const headers = syncHeadersFrom(result);
        if (headers) await quotaTracker.syncFromHeaders(providerId, headers);
      } catch (e) {
        console.warn(`[gateway] header sync failed for ${providerId}: ${e.message}`);
      }
    }
    return { ok: true, result, provider: providerId };
  } catch (err) {
    await quotaTracker.rollback(providerId);
    return { ok: false, reason: err && err.message ? err.message : 'error', err };
  }
}

/**
 * Invoke `callbackFn` against the primary provider, falling over
 * to alternatives in the fallback chain if quota is exhausted.
 *
 * IMPORTANT: this only retries fallbacks on QUOTA failures, not on
 * generic upstream errors. A network blip on the primary doesn't
 * silently shift the entire platform to the fallback (that masks
 * outages); it surfaces as the adapter's normal error path.
 */
async function fetch(primaryId, callbackFn, opts = {}) {
  const {
    capability,
    sport,
    fallbackProviders,
    syncHeadersFrom,
    reserve = 0,
  } = opts;

  const attempts = [];
  const result = await tryOne(primaryId, callbackFn, syncHeadersFrom, reserve);
  if (result.ok) return result.result;

  // Generic adapter error on the primary — propagate, don't shift.
  if (result.err) {
    attempts.push({ provider: primaryId, reason: result.reason });
    throw result.err;
  }

  attempts.push({ provider: primaryId, reason: result.reason });

  // Build the fallback chain. Caller can override; otherwise derive
  // from the capability/sport pair in the registry.
  const chain = Array.isArray(fallbackProviders) && fallbackProviders.length
    ? fallbackProviders
    : capability
      ? getFallbackChain(capability, sport, primaryId)
      : [];

  for (const fallbackId of chain) {
    const fb = await tryOne(fallbackId, callbackFn, syncHeadersFrom, reserve);
    if (fb.ok) {
      console.log(`[gateway] primary=${primaryId} blocked; succeeded via fallback=${fallbackId}`);
      return fb.result;
    }
    attempts.push({ provider: fallbackId, reason: fb.reason });
    // Generic error on a fallback → record and continue to the next.
    // We don't propagate fallback errors because the user only sees
    // one final response, and the original primary was already
    // unavailable when we entered this loop.
  }

  throw new QuotaExhaustedError(primaryId, sport, attempts);
}

module.exports = {
  fetch,
  QuotaExhaustedError,
};
