'use strict';

/**
 * futuresService — championship/outright futures wire (Wave 2A, D1).
 *
 * D1 DECISION: BUILD futures, QUOTA-DISCIPLINED. Futures spend the already-paid
 * The Odds API 500/mo quota, so this path is deliberately isolated from the
 * daily player-prop budget:
 *   - Separate `FUTURES_KEYS` map in oddsService (NOT in SPORT_KEYS / the
 *     snapshot loop).
 *   - A SINGLE `/sports/{key}/odds?markets=outrights` call per refresh —
 *     outrights do NOT fan out over events, so it's 1 credit, not 1+N.
 *   - Long logical TTL (default 12h; futures move slowly). With a 12h TTL the
 *     ceiling is 2 refreshes/day/sport ≈ 60 credits/mo/sport IF continuously
 *     queried; realistically a handful/day because fetches are on-demand
 *     (cache-aside, no cron). `FUTURES_ENABLED=0` skips the fetch entirely.
 *
 * Contract (consumed by the parallel Wave 2B ExploreHub):
 *   { sport, updated_at, markets: [{ key, title, selections:
 *       [{ name, price (american int), prevPrice?: int, move?:
 *          'shortening'|'drifting'|'flat' }] }] }
 * No data / gated-off / quota-skip → { sport, updated_at, markets: [] }.
 * NEVER errors, NEVER fabricates a price.
 *
 * Line-movement REUSE: `move` is computed by diffing the current outright PRICE
 * against the previous cached snapshot's price per selection — the same SHAPE
 * as snapshotService.computeLineDeltas/signedDelta, but on `odds` (price) not
 * `line`. Prev prices are persisted INSIDE the `futures:{sport}` cache value
 * (no new Redis key): the previous write's `price` fields ARE the next diff's
 * reference.
 */

const { normalizeOutrights, americanToDecimal } = require('../utils/oddsNormalizer');
const { FUTURES_KEYS, ODDS_API_BASE } = require('./oddsService');

const DEFAULT_TTL = 12 * 60 * 60; // 12h logical freshness (quota-disciplined).
const PERSIST_TTL = 7 * 24 * 60 * 60; // 7d Redis persistence so prev survives the gap.
const HTTP_TIMEOUT_MS = 12_000;
// Credits held back for the ESSENTIAL path (MLB prop backup when PropLine
// fails). Discretionary futures calls stop while ≤ this many odds-api credits
// remain. Operator-tunable; default 50 of the 500/mo pool.
const ODDS_API_RESERVE = (() => {
  const n = Number.parseInt(process.env.ODDS_API_RESERVE, 10);
  return Number.isFinite(n) && n >= 0 ? n : 50;
})();
// A price is "flat" unless the decimal payout moves by at least this much —
// filters odds-jitter from a real steam/drift (mirrors DELTA_NOISE in shape).
const MOVE_EPSILON = 0.05;

function futuresEnabled() {
  const raw = process.env.FUTURES_ENABLED;
  if (raw === '0' || String(raw).toLowerCase() === 'false') return false;
  return true; // default ON
}

function configuredTTL() {
  const raw = process.env.FUTURES_CACHE_TTL_SECONDS;
  if (!raw) return DEFAULT_TTL;
  const n = Number.parseInt(raw, 10);
  if (!Number.isFinite(n) || n < 300 || n > 86400) return DEFAULT_TTL; // 5m..24h
  return n;
}

/**
 * Pure: classify a price move from prev→cur american prices. Uses DECIMAL
 * payout so the sign is correct across the +/- american boundary.
 *   shortening = odds got SHORTER (payout ↓, market more confident)
 *   drifting   = odds got LONGER  (payout ↑, market less confident)
 *   flat       = |Δdecimal| < epsilon, or either price unusable
 */
function priceMove(prevPrice, curPrice) {
  const prev = americanToDecimal(prevPrice);
  const cur = americanToDecimal(curPrice);
  if (prev == null || cur == null) return 'flat';
  const d = cur - prev;
  if (d <= -MOVE_EPSILON) return 'shortening';
  if (d >= MOVE_EPSILON) return 'drifting';
  return 'flat';
}

/**
 * Diff freshly-normalized markets against the previous cached contract markets,
 * attaching `prevPrice` + `move` per selection. Pure. `prevMarkets` may be
 * undefined (first-ever fetch → every selection is `flat` with no prevPrice).
 */
function attachMoves(newMarkets, prevMarkets) {
  const prevIndex = {}; // key|name → price
  for (const m of Array.isArray(prevMarkets) ? prevMarkets : []) {
    for (const s of m.selections || []) {
      if (s && s.name != null && s.price != null) prevIndex[`${m.key}|${s.name}`] = s.price;
    }
  }
  return (newMarkets || []).map((m) => ({
    key: m.key,
    title: m.title,
    selections: (m.selections || []).map((s) => {
      const prevPrice = prevIndex[`${m.key}|${s.name}`];
      const sel = { name: s.name, price: s.price };
      if (prevPrice != null) {
        sel.prevPrice = prevPrice;
        sel.move = priceMove(prevPrice, s.price);
      } else {
        sel.move = 'flat';
      }
      return sel;
    }),
  }));
}

/**
 * Pure NEWS→MOVE causal tie (optional-but-nice). Match a futures move to a
 * headline whose `published` PRECEDES the move within `windowHours`. Returns
 * `[{ moveKey, headline }]`. `moves` = [{ key, name, at }] (at = ISO time the
 * move was detected). NEVER invents a cause — a move with no preceding headline
 * in-window yields nothing.
 */
function linkNewsToMoves(newsItems, moves, windowHours = 48) {
  const out = [];
  const windowMs = windowHours * 60 * 60 * 1000;
  const dated = (Array.isArray(newsItems) ? newsItems : [])
    .filter((n) => n && n.published && Number.isFinite(Date.parse(n.published)))
    .map((n) => ({ headline: n.headline, t: Date.parse(n.published) }));
  for (const mv of Array.isArray(moves) ? moves : []) {
    const at = mv && mv.at ? Date.parse(mv.at) : NaN;
    if (!Number.isFinite(at)) continue;
    let best = null;
    for (const n of dated) {
      if (n.t <= at && at - n.t <= windowMs) {
        if (!best || n.t > best.t) best = n; // most recent preceding headline
      }
    }
    if (best) out.push({ moveKey: `${mv.key}|${mv.name}`, headline: best.headline });
  }
  return out;
}

/** Build the empty-but-valid contract response. */
function emptyResponse(sport) {
  return { sport, updated_at: new Date().toISOString(), markets: [] };
}

/**
 * getFutures(sport, deps) → the Wave-2 futures contract. Cache-aside on a long
 * logical TTL; persists prev prices inside the same key for the next diff.
 * All deps injectable → unit-tested with zero network. Never throws.
 */
async function getFutures(sport, deps = {}) {
  const sp = String(sport || '').toLowerCase();
  const futuresKey = (deps.FUTURES_KEYS || FUTURES_KEYS)[sp];
  if (!futuresKey) return emptyResponse(sp);

  const enabled = deps.enabled != null ? deps.enabled : futuresEnabled();
  const ttl = deps.ttl != null ? deps.ttl : configuredTTL();
  const cacheGet = deps.cacheGet || require('../utils/redis').cacheGet;
  const cacheSet = deps.cacheSet || require('../utils/redis').cacheSet;
  const key = `futures:${sp}`;

  // Read the last board (persists past the logical TTL so prev prices survive).
  let cached = null;
  try { cached = await cacheGet(key); } catch (_) { cached = null; }
  const now = Date.now();
  const isFresh = cached && cached.updated_at &&
    (now - Date.parse(cached.updated_at)) < ttl * 1000;

  // Gate: disabled → never spend quota. Serve the last board if we have one
  // (honest, already-captured market values), else empty.
  if (!enabled) {
    if (cached && Array.isArray(cached.markets)) {
      return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
    }
    return emptyResponse(sp);
  }

  // Fresh cache → serve it, no fetch.
  if (isFresh) {
    return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
  }

  // Stale / cold → fetch a single outrights call.
  const apiKey = deps.apiKey || process.env.ODDS_API_KEY;
  if (!apiKey) {
    if (cached && Array.isArray(cached.markets)) {
      return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
    }
    return emptyResponse(sp);
  }

  const axios = deps.axios || require('axios');
  const base = deps.ODDS_API_BASE || ODDS_API_BASE;
  // Quota guard — futures is DISCRETIONARY, so it goes through the gateway
  // (counted via recordCall → the 80% pager sees it, and blocked at 95%) with a
  // RESERVE floor: it stops spending while ≤ ODDS_API_RESERVE credits remain, so
  // a futures drain can never starve the essential MLB prop-backup path. This
  // closes the raw-axios bypass that let futures burn odds-api invisibly (the
  // reason the 500/500 exhaustion went unpaged). The gateway also syncs headers.
  const gateway = deps.gateway || require('./providerGateway');
  const reserve = deps.reserve != null ? deps.reserve : ODDS_API_RESERVE;
  try {
    const res = await gateway.fetch(
      'odds-api',
      () => axios.get(`${base}/${futuresKey}/odds`, {
        params: { apiKey, regions: 'us', markets: 'outrights', oddsFormat: 'american' },
        timeout: HTTP_TIMEOUT_MS,
      }),
      { capability: 'futures', sport: sp, reserve, syncHeadersFrom: (r) => r && r.headers },
    );

    const normalized = normalizeOutrights(res.data);
    const markets = attachMoves(normalized, cached && cached.markets);

    // Empty board (off-season / no priced selections) → serve empty, but keep
    // any prior good board rather than clobbering it with nothing.
    if (markets.length === 0) {
      if (cached && Array.isArray(cached.markets) && cached.markets.length > 0) {
        return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
      }
      return emptyResponse(sp);
    }

    const updated_at = new Date().toISOString();
    try { await cacheSet(key, { updated_at, markets }, PERSIST_TTL); } catch (_) { /* best-effort */ }
    return { sport: sp, updated_at, markets };
  } catch (e) {
    console.warn(`[futures] ${sp} fetch failed:`, e.message);
    // Serve stale board if present — better than empty, never fabricated.
    if (cached && Array.isArray(cached.markets)) {
      return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
    }
    return emptyResponse(sp);
  }
}

module.exports = {
  getFutures,
  priceMove,
  attachMoves,
  linkNewsToMoves,
  futuresEnabled,
  configuredTTL,
  __internals: { DEFAULT_TTL, PERSIST_TTL, MOVE_EPSILON },
};
