const axios = require('axios');
const { getRedisClient } = require('../utils/redis');
const { normalizeProps, extractSpreads, MARKET_MAP } = require('../utils/oddsNormalizer');
// Session 20 — every odds-api hit now flows through the gateway so
// the quota counter advances and the fallback chain (oddspapi,
// parlayapi) can take over when we approach the monthly cap. The
// gateway is intentionally light-weight on the hot path — it adds
// one Redis GET + SET per call (degrades open if Redis is down).
const gateway = require('./providerGateway');

const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
// Session 22 — cache TTL is the dominant lever on monthly odds-api
// credit spend. Each cache miss fans out to (1 + N) upstream calls
// (1 events lookup + 1 per game-day event). At the free tier's
// 500 credits/month, the previous 15-min TTL was an order of
// magnitude too aggressive for live serving across multiple sports.
//
// Default raised to 60 minutes; operators can override via
// `ODDS_CACHE_TTL_SECONDS` from Coolify without a redeploy:
//   ODDS_CACHE_TTL_SECONDS=3600   → 1 hour  (default)
//   ODDS_CACHE_TTL_SECONDS=7200   → 2 hours (free tier with 4+ sports)
//   ODDS_CACHE_TTL_SECONDS=900    → 15 min  (legacy, only on paid tier)
const DEFAULT_CACHE_TTL = 3600; // 1h
function getConfiguredCacheTTL() {
  const raw = process.env.ODDS_CACHE_TTL_SECONDS;
  if (!raw) return DEFAULT_CACHE_TTL;
  const n = Number.parseInt(raw, 10);
  // Defensive — refuse to bypass cache entirely (very_low_ttl < 60s
  // would shred credits). Same upper bound (1 day) prevents a
  // typo from holding stale data forever.
  if (!Number.isFinite(n) || n < 60 || n > 86400) return DEFAULT_CACHE_TTL;
  return n;
}
// Kept as a top-level binding for backward compat with the existing
// `module.exports.CACHE_TTL` consumers (tests + the route layer
// probe). Resolved at module load so deploys that change the env
// var require a restart — same contract as every other env-driven
// constant in the codebase.
const CACHE_TTL = getConfiguredCacheTTL();
// Sport identifiers consumed by getOdds → mapped to the odds-api.com
// sport key. Soccer leagues are listed individually so the route layer
// can fetch per-league without changing the upstream contract. Only
// fetched on user demand (on-demand cache with 15-min TTL); leagues
// nobody queries don't consume odds-api quota.
const SPORT_KEYS = {
  nba: 'basketball_nba',
  ncaab: 'basketball_ncaab',
  // Session 14 — WNBA + MLB. odds-api may not always carry WNBA props
  // (off-season returns empty); the route layer surfaces an empty
  // array with a friendly message in that case.
  wnba: 'basketball_wnba',
  mlb: 'baseball_mlb',
  // Session 32 — NFL + NHL. odds-api keys per the-odds-api sports list.
  // Off-season returns an empty events array; the route layer surfaces an
  // empty slate (never a crash). NFL props normalize through the MARKET_MAP
  // keys added in Session 31; NHL keys were added alongside this wiring.
  nfl: 'americanfootball_nfl',
  nhl: 'icehockey_nhl',
  // MMA / UFC (Wave 6 — combat intelligence). odds-api sport key for the
  // moneyline + round-total feed. PropLine carries NO combat → odds-api-only
  // (combat never enters the abundant player-props path). Off-card windows
  // return an empty events array and the combat surface self-hides honestly.
  mma: 'mma_mixed_martial_arts',
  // Soccer (Session 7j) — odds-api sport keys verified against
  // https://the-odds-api.com/sports-odds-data/sports-apis.html
  soccer_wc:         'soccer_fifa_world_cup',
  soccer_epl:        'soccer_epl',
  soccer_laliga:     'soccer_spain_la_liga',
  soccer_bundesliga: 'soccer_germany_bundesliga',
  soccer_seriea:     'soccer_italy_serie_a',
  soccer_ligue1:     'soccer_france_ligue_one',
  soccer_ucl:        'soccer_uefa_champs_league',
  soccer_mls:        'soccer_usa_mls',
  soccer_ligamx:     'soccer_mexico_ligamx',
};
const SOCCER_SPORT_KEYS = Object.freeze(
  Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_'))
);

// Wave 2A — FUTURES / OUTRIGHTS keys. DELIBERATELY SEPARATE from SPORT_KEYS so
// they NEVER enter the daily player-prop `getOdds`/snapshot loop or its budget.
// These are championship-winner outright markets on the already-paid odds-api
// key, fetched by `futuresService` on a long TTL (quota-disciplined). Each key
// is a SINGLE `/sports/{key}/odds?markets=outrights` call (outrights don't fan
// out over events → 1 credit per refresh, not 1+N like player props).
const FUTURES_KEYS = Object.freeze({
  mlb:  'baseball_mlb_world_series_winner',
  nba:  'basketball_nba_championship_winner',
  wnba: 'basketball_wnba_championship_winner',
});
// Session 16 — per-sport market lists.
//
// The old `ALL_MARKETS = every key in MARKET_MAP` would send
// soccer markets (player_goals, player_shots_on_target, etc.) to
// basketball + baseball endpoints, triggering odds-api 422 errors.
// Production briefly worked around this with a runtime axios
// interceptor injected via `NODE_OPTIONS=--require /app/data/patch.js`;
// the proper fix is to scope the markets list to the sport before
// the request leaves the process.
//
// After this lands, the operator can drop the NODE_OPTIONS env var
// from Coolify and delete /app/data/patch.js.
const NBA_MARKETS = [
  'player_points',
  'player_rebounds',
  'player_assists',
  'player_threes',
  'player_blocks',
  'player_steals',
  'player_turnovers',
  'player_points_rebounds_assists',
];
const WNBA_MARKETS = [
  'player_points',
  'player_rebounds',
  'player_assists',
  'player_threes',
  'player_blocks',
  'player_steals',
  'player_turnovers',
];
const MLB_MARKETS = [
  'batter_home_runs',
  'batter_hits',
  'batter_total_bases',
  'batter_rbis',
  'batter_runs_scored',
  'batter_stolen_bases',
  'pitcher_strikeouts',
  'pitcher_outs',
];
// Session 32 — NFL + NHL market lists. NFL keys mirror the MARKET_MAP
// entries added Session 31 (both _yds and _yards spellings normalize). NHL
// keys were added to MARKET_MAP alongside this wiring so they don't
// silently normalize to zero in-season.
const NFL_MARKETS = [
  'player_pass_yds',
  'player_pass_tds',
  'player_rush_yds',
  'player_reception_yds',
  'player_receptions',
  'player_anytime_td',
];
const NHL_MARKETS = [
  'player_goals',
  'player_shots_on_goal',
  'player_assists',
  'goalie_saves',
];
const SOCCER_MARKETS = [
  'player_goals',
  'player_shots_on_target',
  'player_shots',
  'player_tackles',
  'player_cards',
  'player_corners',
  'player_saves',
  'player_goals_conceded',
  'player_passes',
  'team_clean_sheet',
];
// MMA / UFC (Wave 6) — GAME-level markets only: moneyline (h2h) + round total
// (totals). No player props on the free feed → NO 'spreads' suffix (unlike the
// player-prop sports, whose buildMarketString appends spreads).
const MMA_MARKETS = ['h2h', 'totals'];

function buildMarketString(markets) {
  return [...markets, 'spreads'].join(',');
}

// Indexed by the local sport key (the keys in SPORT_KEYS, not the
// odds-api keys). Soccer leagues all share the same market list.
const SPORT_MARKETS = Object.freeze({
  nba:   buildMarketString(NBA_MARKETS),
  wnba:  buildMarketString(WNBA_MARKETS),
  mlb:   buildMarketString(MLB_MARKETS),
  nfl:   buildMarketString(NFL_MARKETS),
  nhl:   buildMarketString(NHL_MARKETS),
  // MMA carries no player-prop spreads → join the game-level markets directly.
  mma:   MMA_MARKETS.join(','),
  ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA
  // Every soccer league code shares the same market set.
  ...Object.fromEntries(
    Object.keys(SPORT_KEYS)
      .filter((k) => k.startsWith('soccer_'))
      .map((k) => [k, buildMarketString(SOCCER_MARKETS)]),
  ),
});

// Kept for backward-compat with any caller that still imports it,
// but the call site (`fetchEventOddsFromApi`) now uses the sport-
// specific lookup. Composed once on module load.
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads';

function getMarketsForSport(sport) {
  if (!sport) return SPORT_MARKETS.nba; // safe default (basketball)
  return SPORT_MARKETS[sport] || SPORT_MARKETS.nba;
}

const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers';

function getCacheKey(sport) {
  const now = new Date();
  const date = now.toISOString().split('T')[0]; // UTC date
  return `odds:${sport}:${date}`;
}

function getQuotaKey() {
  const now = new Date();
  const month = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
  return `odds:quota:${month}`;
}

async function getQuotaRemaining(redis) {
  const data = await redis.hgetall(getQuotaKey());
  return data.remaining != null ? parseInt(data.remaining, 10) : null;
}

async function updateQuota(redis, headers) {
  const remaining = headers['x-requests-remaining'];
  const used = headers['x-requests-used'];
  // Session 20 — sync the per-provider tracker from the same
  // headers. The gateway's syncHeadersFrom already does this on
  // each call; doing it here too is belt-and-suspenders for any
  // call path that bypassed the gateway. Lazy-required so the
  // tests that don't mock providerGateway don't crash on load.
  try {
    const quotaTracker = require('./quotaTracker');
    await quotaTracker.syncFromHeaders('odds-api', headers);
  } catch (e) {
    // Tracker failure must never break odds delivery — it's a
    // signal, not a dependency.
    console.warn('[oddsService] quotaTracker sync failed:', e.message);
  }
  if (remaining != null) {
    const key = getQuotaKey();
    await redis.hset(key, 'remaining', String(remaining), 'used', String(used || 0), 'last_checked', new Date().toISOString());
    await redis.expire(key, 60 * 60 * 24 * 35); // keep for ~1 month
    if (parseInt(remaining, 10) < 50) {
      console.warn(`[VYNDR] Odds API quota low: ${remaining} credits remaining`);
    }
  }
  return remaining != null ? parseInt(remaining, 10) : null;
}

async function fetchEventsFromApi(sportKey, apiKey) {
  const url = `${ODDS_API_BASE}/${sportKey}/events`;
  const response = await gateway.fetch(
    'odds-api',
    () => axios.get(url, { params: { apiKey }, timeout: 10000 }),
    {
      capability: 'odds',
      // Best-effort sport tag for the fallback chain. The events
      // endpoint isn't sport-scoped on the fallback providers, but
      // passing it through lets the registry filter to relevant
      // candidates.
      sport: sportKey.replace(/^.*?_/, ''),
      syncHeadersFrom: (r) => r && r.headers,
    },
  );
  return { data: response.data, headers: response.headers };
}

// Session 16 — third arg is now a local sport key (nba, mlb,
// soccer_wc, ...) so we can scope the markets list. Backwards-
// compatible: if `sport` is omitted, falls back to the basketball
// market set, which is what every legacy caller assumed.
async function fetchEventOddsFromApi(sportKey, eventId, apiKey, sport) {
  const url = `${ODDS_API_BASE}/${sportKey}/events/${eventId}/odds`;
  const response = await gateway.fetch(
    'odds-api',
    () => axios.get(url, {
      params: {
        apiKey,
        regions: 'us',
        markets: getMarketsForSport(sport),
        bookmakers: BOOKMAKERS,
        oddsFormat: 'american',
      },
      timeout: 10000,
    }),
    {
      capability: 'odds',
      sport,
      syncHeadersFrom: (r) => r && r.headers,
    },
  );
  return { data: response.data, headers: response.headers };
}

async function fetchAllOdds(sport, apiKey) {
  const sportKey = SPORT_KEYS[sport];
  if (!sportKey) throw new Error(`Unknown sport: ${sport}`);

  // Step 1: Get today's events
  const eventsResult = await fetchEventsFromApi(sportKey, apiKey);
  const events = eventsResult.data;
  let lastHeaders = eventsResult.headers;

  if (!events || events.length === 0) {
    return { props: [], quotaRemaining: await parseQuota(lastHeaders) };
  }

  // Step 2: Fetch odds for each event
  const eventsWithOdds = [];
  for (const event of events) {
    const quotaLeft = lastHeaders['x-requests-remaining'];
    if (quotaLeft != null && parseInt(quotaLeft, 10) <= 0) {
      console.warn('[VYNDR] Quota exhausted mid-fetch, stopping');
      break;
    }

    const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey, sport);
    eventsWithOdds.push(oddsResult.data);
    lastHeaders = oddsResult.headers;
  }

  const props = normalizeProps(eventsWithOdds);
  const spreads = extractSpreads(eventsWithOdds);
  const quotaRemaining = lastHeaders['x-requests-remaining']
    ? parseInt(lastHeaders['x-requests-remaining'], 10)
    : null;

  return { props, spreads, quotaRemaining, headers: lastHeaders };
}

function parseQuota(headers) {
  const val = headers && headers['x-requests-remaining'];
  return val != null ? parseInt(val, 10) : null;
}

// Best-effort post-fetch processing shared by both providers (PropLine +
// odds-api): line movement, scratch cascade, and rolling line snapshots.
// Never throws — a failure here must not break the odds response.
async function recordDownstream(sport, props, provider = 'odds-api') {
  let movements = [];
  let scratchedPlayers = [];
  try {
    const lineMovement = require('./lineMovementService');
    const cascade = require('./cascadeService');
    const moveResult = await lineMovement.processNewOdds(sport, props);
    movements = moveResult.movements || [];
    const cascadeResult = await cascade.detectScratches(sport, props);
    scratchedPlayers = cascadeResult.scratchedPlayers || [];
    const lineSnapshots = require('./lineSnapshotService');
    await lineSnapshots.recordSnapshots(sport, props);
  } catch (e) {
    console.warn('[VYNDR] Movement/cascade detection error:', e.message);
  }

  // Session 32 — grade the slate into the `grades:{sport}` cache that
  // contentTemplateService reads, closing the content pipeline. Fire-and-
  // forget: grading fans out to feature computation and must NOT hold the
  // odds HTTP response — content endpoints read the grades cache later and
  // independently. Errors are self-contained inside the service.
  if (shouldGradeSlate()) {
    require('./gradeSlateService')
      .gradeAndCacheSlate(sport, props, { source: provider })
      .catch((e) => console.warn('[VYNDR] slate grading error:', e.message));
  }

  return { movements, scratchedPlayers };
}

// Auto-grade the slate on a fresh odds fetch by default (closes the content
// pipeline). Skipped under the test env so its background feature-computation
// fan-out doesn't pollute call-count assertions in the odds integration
// tests; `GRADE_SLATE_ON_FETCH` is the explicit operator override
// ('1' forces on even in test, '0' is the production kill-switch if the
// feature-compute cost ever needs to be shed).
function shouldGradeSlate() {
  const flag = process.env.GRADE_SLATE_ON_FETCH;
  if (flag === '1') return true;
  if (flag === '0') return false;
  return process.env.NODE_ENV !== 'test';
}

async function getOdds(sport) {
  const redis = getRedisClient();
  const apiKey = process.env.ODDS_API_KEY;
  const cacheKey = getCacheKey(sport);

  // Check cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    const data = JSON.parse(cached);
    const quota = await getQuotaRemaining(redis);
    return {
      sport,
      updated_at: data.updated_at,
      source: 'cache',
      provider: data.provider || 'odds-api',
      quota_remaining: quota,
      props: data.props,
      spreads: data.spreads || [],
    };
  }

  // Session 30 — PropLine is the PRIMARY props provider when configured:
  // 3 free keys, 3,000 req/day combined (vs odds-api's 500/month). Try it
  // first; on empty/error fall through to the conserved odds-api path
  // below. Gated on hasKeys() so environments without PropLine keys (incl.
  // the test suite) keep the exact prior behavior.
  const propline = require('./adapters/proplineAdapter');
  if (propline.hasKeys()) {
    try {
      const pl = await propline.getProps(sport);
      if (pl && Array.isArray(pl.props) && pl.props.length > 0) {
        const now = new Date().toISOString();
        const cacheData = { updated_at: now, props: pl.props, spreads: pl.spreads || [], provider: 'propline' };
        await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
        const { movements, scratchedPlayers } = await recordDownstream(sport, pl.props, 'propline');
        return {
          sport,
          updated_at: now,
          source: 'live',
          provider: 'propline',
          props: pl.props,
          spreads: pl.spreads || [],
          movements,
          scratchedPlayers,
        };
      }
    } catch (e) {
      console.warn('[oddsService] PropLine failed, falling back to odds-api:', e.message);
    }
  }

  // Session 22 — pre-flight quota check now reads from the
  // Session 20 tracker (truth source: synced from upstream
  // response headers on every call). The legacy
  // `getQuotaRemaining(redis)` read a separate Redis hash that
  // drifted (Chrome Claude found it showing 46 remaining while
  // reality was 7) because that hash was only updated by THIS
  // file's `updateQuota` — gateway calls and other callers
  // bypassed it. The tracker is updated by both the gateway and
  // updateQuota, so it can't lag behind.
  //
  // Threshold: tracker BLOCKs at >=95%. Below that, we still
  // proceed (matches legacy behavior of allowing up to the very
  // last credit). Degraded-mode (Redis down) fails OPEN — see
  // quotaTracker.js for the rationale.
  const quotaTracker = require('./quotaTracker');
  const quotaStatus = await quotaTracker.getQuotaStatus('odds-api');
  if (!quotaStatus.allowed) {
    const error = new Error('Odds data temporarily unavailable. Try again later.');
    error.statusCode = 429;
    error.quotaStatus = quotaStatus;
    throw error;
  }

  // Fetch live data
  try {
    const { props, spreads, quotaRemaining, headers } = await fetchAllOdds(sport, apiKey);

    // Update quota in Redis
    if (headers) {
      await updateQuota(redis, headers);
    }

    const now = new Date().toISOString();
    const cacheData = { updated_at: now, props, spreads, provider: 'odds-api' };
    await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);

    // Line movement + cascade + snapshots (best-effort; shared helper).
    const { movements, scratchedPlayers } = await recordDownstream(sport, props, 'odds-api');

    return {
      sport,
      updated_at: now,
      source: 'live',
      provider: 'odds-api',
      quota_remaining: quotaRemaining,
      props,
      spreads,
      movements,
      scratchedPlayers,
    };
  } catch (err) {
    // If API fails, try stale cache (no TTL check — any cached data)
    const stale = await redis.get(cacheKey);
    if (stale) {
      const data = JSON.parse(stale);
      const quota = await getQuotaRemaining(redis);
      return {
        sport,
        updated_at: data.updated_at,
        source: 'cache',
        stale: true,
        quota_remaining: quota,
        props: data.props,
        spreads: data.spreads || [],
      };
    }

    // No cache at all. Session 19 — surface the underlying cause in
    // logs so operators can tell odds-api auth failures apart from
    // network blips apart from rate-limit responses. The 503 wrapper
    // hides everything by design (don't leak details to clients);
    // this log gives the operator the signal they need.
    const upstreamStatus = err.response && err.response.status;
    const upstreamBody = err.response && err.response.data;
    console.error(
      `[oddsService] ${sport} fetch failed — no cache fallback. ` +
      `upstream_status=${upstreamStatus || 'n/a'} ` +
      `code=${err.code || 'n/a'} ` +
      `message=${err.message} ` +
      `upstream_body=${upstreamBody ? JSON.stringify(upstreamBody).slice(0, 200) : 'n/a'}`,
    );
    if (err.statusCode === 429) throw err;
    const serviceError = new Error('Odds service unavailable.');
    serviceError.statusCode = 503;
    throw serviceError;
  }
}

module.exports = {
  getOdds,
  fetchAllOdds,
  fetchEventsFromApi,
  fetchEventOddsFromApi,
  getCacheKey,
  SPORT_KEYS,
  SOCCER_SPORT_KEYS,
  // Wave 2A — futures/outrights keys (separate budget; futuresService only).
  FUTURES_KEYS,
  ODDS_API_BASE,
  // Wave 6 — combat game-level markets (moneyline + round total).
  MMA_MARKETS,
  // Session 16 — per-sport market scoping.
  SPORT_MARKETS,
  getMarketsForSport,
  getQuotaKey,
  updateQuota,
  getQuotaRemaining,
  CACHE_TTL,
  // Session 22 — exposed for tests that exercise env-driven TTL
  // resolution without re-loading the module.
  getConfiguredCacheTTL,
  // Session 32 — slate auto-grade gate (exposed for tests).
  shouldGradeSlate,
};
