'use strict';

/**
 * Quota tracker (Session 20).
 *
 * Single source of truth for "how many calls have we made to each
 * provider this period?" Pure Redis-backed counter — no in-process
 * memory beyond a one-shot warn dedupe set per period.
 *
 * Redis key shape:
 *   quota:{providerId}:{period}        → { used: number, limit: number, syncedAt?: iso }
 *   quota_warned:{providerId}:{period} → '1' (dedupes the 80% warn line)
 *
 * Period format:
 *   monthly:    "YYYY-MM"
 *   daily:      "YYYY-MM-DD"
 *   per_minute: "YYYY-MM-DDTHH:MM"
 *
 * Degraded mode: when Redis is unavailable, `getQuotaStatus`
 * returns `{ allowed: true, degraded: true }` — i.e. we FAIL OPEN
 * (allow the call) rather than block the whole platform on a Redis
 * outage. The original quota-exhaustion bug we're guarding against
 * is recoverable (a real upstream 429 stops the bleeding); a
 * Redis-tied block would make every outage existential.
 */

const { cacheGet, cacheSet, isDegraded } = require('../utils/redis');
const { getProvider, THRESHOLDS, getConfiguredProviders } = require('../config/providers');
// Session 21 — ntfy push for the WARN/BLOCK transitions. Lazy-
// imported inside `sendQuotaAlert` so the tracker stays loadable
// in environments without axios (it's a dev-time dependency
// already, but lazy-require keeps the symmetry with the gateway).
let _axios = null;
function getAxios() {
  if (_axios) return _axios;
  try { _axios = require('axios'); } catch { _axios = null; }
  return _axios;
}

function pad(n) {
  return String(n).padStart(2, '0');
}

function getPeriodKey(providerId, now = new Date()) {
  const cfg = getProvider(providerId);
  if (!cfg) return '';
  switch (cfg.quotaType) {
    case 'monthly':
      return `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}`;
    case 'daily':
      return `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`;
    case 'per_minute':
      return `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}T${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}`;
    default:
      return `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`;
  }
}

/**
 * Redis TTL for the counter key. Set so the counter naturally
 * expires at the end of its period — no separate sweep job.
 *
 * Monthly:    35 days (slightly past the next reset; the new key
 *             takes over on day-1 of the new month anyway).
 * Daily:      48 hours.
 * Per-minute: 2 minutes.
 */
function getQuotaTTL(providerId) {
  const cfg = getProvider(providerId);
  if (!cfg) return 60;
  switch (cfg.quotaType) {
    case 'monthly':    return 35 * 24 * 60 * 60;
    case 'daily':      return 2 * 24 * 60 * 60;
    case 'per_minute': return 2 * 60;
    default:           return 24 * 60 * 60;
  }
}

function buildKey(providerId, now = new Date()) {
  return `quota:${providerId}:${getPeriodKey(providerId, now)}`;
}

function buildWarnKey(providerId, now = new Date()) {
  return `quota_warned:${providerId}:${getPeriodKey(providerId, now)}`;
}

/**
 * Best-effort push notification when a provider crosses WARN or
 * BLOCK. Two priorities:
 *   - WARN (80%)  → priority 4 ("high")
 *   - BLOCK (95%) → priority 5 ("urgent")
 *
 * Failure is silent — ntfy down must NEVER block the call path.
 * Disabled when `NTFY_URL` is unset (development default).
 */
async function sendQuotaAlert(providerCfg, pct, used, limit) {
  const url = process.env.NTFY_URL;
  if (!url) return false;
  const axios = getAxios();
  if (!axios) return false;
  const blocked = pct >= THRESHOLDS.BLOCK_PCT;
  const pctRounded = Math.round(pct * 100);
  const title = blocked
    ? `VYNDR Quota BLOCKED: ${providerCfg.name}`
    : `VYNDR Quota Warning: ${providerCfg.name}`;
  const body = blocked
    ? `${providerCfg.name} at ${pctRounded}% quota (${used}/${limit}). Calls now BLOCKED. Fallback chain active where configured. Check /admin.`
    : `${providerCfg.name} at ${pctRounded}% quota (${used}/${limit}). Approaching block threshold (95%). Check /admin.`;
  try {
    await axios.post(url, body, {
      headers: {
        Title: title,
        Priority: blocked ? '5' : '4',
        Tags: blocked ? 'no_entry,chart_decreasing' : 'warning,chart_decreasing',
      },
      timeout: 5000,
    });
    return true;
  } catch (err) {
    // ntfy failure is a signal-degradation, not a path-failure.
    console.warn('[quotaTracker] ntfy alert failed:', err && err.message ? err.message : err);
    return false;
  }
}

/**
 * Fire the once-per-period WARN/BLOCK alert when `pct` crosses a threshold.
 * Extracted so BOTH the optimistic counter (recordCall) AND the authoritative
 * header reconcile (syncFromHeaders) can page — the drain that put odds-api at
 * 500/500 came in via header-sync, which used to update the number silently
 * (recordCall was never on the burner's path). A silent drain must be
 * impossible, so the authoritative writer alerts too. Deduped per period.
 * Returns true if an alert was sent this call.
 */
async function fireThresholdAlert(providerId, cfg, pct, used, limit) {
  if (!(pct >= THRESHOLDS.WARN_PCT)) return false;
  const blocked = pct >= THRESHOLDS.BLOCK_PCT;
  const dedupeKey = blocked ? `${buildWarnKey(providerId)}:block` : buildWarnKey(providerId);
  const already = await cacheGet(dedupeKey);
  if (already) return false;
  console.warn(`[quotaTracker] ${cfg.name} at ${(pct * 100).toFixed(0)}% quota (${used}/${limit}) for ${getPeriodKey(providerId)}`);
  await cacheSet(dedupeKey, '1', getQuotaTTL(providerId));
  // Off the hot path — errors already swallowed inside sendQuotaAlert.
  sendQuotaAlert(cfg, pct, used, limit).catch(() => {});
  return true;
}

/**
 * Read the counter without mutating it. Returns the structured
 * status the admin dashboard renders + the gateway consults.
 */
async function getQuotaStatus(providerId) {
  const cfg = getProvider(providerId);
  if (!cfg) {
    return {
      provider: providerId,
      allowed: false,
      reason: 'unknown_provider',
      used: 0, limit: 0, remaining: 0, pct: 1, period: '',
      quotaType: 'unknown',
    };
  }
  if (isDegraded()) {
    // Fail open — the alternative (degrade closed) means a Redis
    // hiccup takes down every provider call platform-wide.
    return {
      provider: providerId, name: cfg.name,
      allowed: true, degraded: true,
      used: 0, limit: cfg.quotaLimit, remaining: cfg.quotaLimit, pct: 0,
      period: getPeriodKey(providerId), quotaType: cfg.quotaType,
    };
  }

  const cached = await cacheGet(buildKey(providerId));
  const used = (cached && typeof cached.used === 'number') ? cached.used : 0;
  const limit = (cached && typeof cached.limit === 'number') ? cached.limit : cfg.quotaLimit;
  const pct = limit > 0 ? used / limit : 0;
  return {
    provider: providerId,
    name: cfg.name,
    allowed: pct < THRESHOLDS.BLOCK_PCT,
    used, limit,
    remaining: Math.max(0, limit - used),
    pct,
    period: getPeriodKey(providerId),
    quotaType: cfg.quotaType,
    syncedAt: cached && cached.syncedAt ? cached.syncedAt : null,
  };
}

/**
 * Increment the counter by one. Returns the updated status.
 *
 * The warning at 80% fires once per period via a separate sentinel
 * key so the log line doesn't repeat 200 times.
 */
async function recordCall(providerId) {
  const cfg = getProvider(providerId);
  if (!cfg) return { provider: providerId, allowed: false, reason: 'unknown_provider' };
  if (isDegraded()) return { provider: providerId, allowed: true, degraded: true };

  const key = buildKey(providerId);
  const cached = await cacheGet(key);
  const used = (cached && typeof cached.used === 'number') ? cached.used : 0;
  const limit = (cached && typeof cached.limit === 'number') ? cached.limit : cfg.quotaLimit;
  const nextUsed = used + 1;
  const pct = limit > 0 ? nextUsed / limit : 0;

  const payload = { used: nextUsed, limit, updatedAt: new Date().toISOString() };
  if (cached && cached.syncedAt) payload.syncedAt = cached.syncedAt;
  await cacheSet(key, payload, getQuotaTTL(providerId));

  // Session 21 — WARN (80%) / BLOCK (95%) alert, once per period per threshold.
  // Shared with syncFromHeaders so a header-reconciled crossing pages too.
  await fireThresholdAlert(providerId, cfg, pct, nextUsed, limit);

  return {
    provider: providerId, name: cfg.name,
    allowed: pct < THRESHOLDS.BLOCK_PCT,
    used: nextUsed, limit,
    remaining: Math.max(0, limit - nextUsed),
    pct,
    period: getPeriodKey(providerId),
    quotaType: cfg.quotaType,
  };
}

/**
 * Decrement after a failed call. The optimistic recordCall+rollback
 * pattern means a thread-unsafe race could overshoot by N during a
 * burst — acceptable for our scale (single-instance Express,
 * 5–10 concurrent calls peak). Atomic INCR would require a Lua
 * script and the gain is marginal.
 */
async function rollback(providerId) {
  const cfg = getProvider(providerId);
  if (!cfg || isDegraded()) return;
  const key = buildKey(providerId);
  const cached = await cacheGet(key);
  if (!cached || typeof cached.used !== 'number') return;
  const nextUsed = Math.max(0, cached.used - 1);
  await cacheSet(
    key,
    { ...cached, used: nextUsed, updatedAt: new Date().toISOString() },
    getQuotaTTL(providerId),
  );
}

/**
 * Sync from upstream response headers. odds-api returns
 * `x-requests-used` + `x-requests-remaining`; this becomes the
 * truth source for that provider's counter.
 *
 * Header presence varies — we look at common spellings.
 */
async function syncFromHeaders(providerId, headers) {
  if (!headers || isDegraded()) return null;
  const cfg = getProvider(providerId);
  if (!cfg) return null;

  // Normalize header lookup (axios returns lowercase, fetch keeps case).
  const h = {};
  for (const k of Object.keys(headers)) h[String(k).toLowerCase()] = headers[k];

  const usedHdr = h['x-requests-used'] ?? h['x-quota-used'] ?? h['x-ratelimit-used'];
  const remainingHdr = h['x-requests-remaining'] ?? h['x-quota-remaining'] ?? h['x-ratelimit-remaining'];
  const limitHdr = h['x-quota-limit'] ?? h['x-ratelimit-limit'];

  const used = Number.parseInt(usedHdr, 10);
  const remaining = Number.parseInt(remainingHdr, 10);
  const limitFromHdr = Number.parseInt(limitHdr, 10);
  if (!Number.isFinite(used) && !Number.isFinite(remaining)) return null;

  const limit = Number.isFinite(limitFromHdr)
    ? limitFromHdr
    : (Number.isFinite(used) && Number.isFinite(remaining))
      ? used + remaining
      : cfg.quotaLimit;
  const resolvedUsed = Number.isFinite(used)
    ? used
    : (Number.isFinite(remaining) ? Math.max(0, limit - remaining) : 0);

  const payload = {
    used: resolvedUsed,
    limit,
    syncedAt: new Date().toISOString(),
    source: 'headers',
  };
  await cacheSet(buildKey(providerId), payload, getQuotaTTL(providerId));
  // The authoritative number can cross the threshold entirely via header
  // reconcile (the futures burner synced but never recordCall'd). Page on it —
  // this is the fix for "the 80% alert never fired while odds-api hit 500/500".
  const pct = limit > 0 ? resolvedUsed / limit : 0;
  await fireThresholdAlert(providerId, cfg, pct, resolvedUsed, limit);
  return payload;
}

/**
 * Status snapshot for every configured provider — admin dashboard
 * tile, /api/internal/quota endpoint.
 */
async function getAllQuotaStatuses() {
  const configured = getConfiguredProviders();
  return Promise.all(configured.map((p) => getQuotaStatus(p.id)));
}

/**
 * Frequency-scaling helper for schedulers. Returns the wait
 * interval (ms) the caller SHOULD use before its next provider
 * hit, or null when the quota is exhausted.
 *
 * The discrete steps match the Session 20 spec:
 *    <50%  → 5 min (full speed)
 *    <80%  → 15 min
 *    <95%  → 30 min
 *    ≥95%  → null (stop)
 */
function getTickInterval(pct) {
  if (!Number.isFinite(pct)) return 5 * 60 * 1000;
  if (pct >= THRESHOLDS.BLOCK_PCT) return null;
  if (pct >= THRESHOLDS.WARN_PCT) return 30 * 60 * 1000;
  if (pct >= 0.50) return 15 * 60 * 1000;
  return 5 * 60 * 1000;
}

/**
 * Composite helper for schedulers: should this poller wait or fire
 * right now? Convenience over `getQuotaStatus + getTickInterval`.
 */
async function shouldThrottle(providerId) {
  const status = await getQuotaStatus(providerId);
  const interval = getTickInterval(status.pct);
  return { allowed: status.allowed, interval, status };
}

module.exports = {
  getPeriodKey,
  buildKey,
  buildWarnKey,
  getQuotaStatus,
  getAllQuotaStatuses,
  recordCall,
  rollback,
  syncFromHeaders,
  getTickInterval,
  shouldThrottle,
  // Exposed for tests that need to reach the threshold constants
  // without re-importing the providers module.
  THRESHOLDS,
};
