/**
 * Resolution + correction endpoints.
 *
 *   POST /api/grading/resolve  — called by the ESPN poller at FINAL
 *   POST /api/grading/correct  — called by the morning sweep (Section 8)
 *
 * Auth model:
 *   - X-VYNDR-Internal-Key header must match VYNDR_INTERNAL_KEY
 *   - Source IP must be loopback (127.0.0.1 / ::1 / ::ffff:127.0.0.1)
 *     Two-factor "in-cluster only": even if the key leaks, an attacker still
 *     needs a foothold inside Docker's network. PM2 pollers and the morning
 *     cron run from the same host, so this is fine in practice.
 *
 * Service-role Supabase: we read & write across every user's grade_history
 * for the game, bypassing RLS intentionally.
 */

const express = require('express');
const path = require('path');
const { getSupabaseServiceClient } = require('../utils/supabase');
const { getSportConfig } = require('../config/sports');
const { logResolution } = require('../services/training/jsonlLogger');
const webPush = require('../services/distribution/webPush');
const telegram = require('../services/distribution/telegram');
const discord = require('../services/distribution/discord');
const clvTracker = require('../services/intelligence/clvTracker');
const accuracyTracker = require('../services/intelligence/accuracyTracker');
const weightAdjuster = require('../services/intelligence/weightAdjuster');

const { requireInternalAuth } = require('../middleware/internalAuth');

const router = express.Router();

// Session 10 — extracted into src/middleware/internalAuth.js. The two
// internal routes below keep slightly different policies:
//   /resolve  — loopback-only (called by the on-host poller)
//   /pipeline — accepts off-host (called by n8n from another container,
//               which is why the legacy loopback-only check broke it)
//
// `requireInternal` stays exported as `__helpers.requireInternal` for
// the existing test suite — it's a thin alias for the loopback-only
// variant so the resolution test (which spins up its own server on
// 127.0.0.1) behaves identically.
const requireInternal = requireInternalAuth({ loopbackOnly: true });
const requireInternalAnyOrigin = requireInternalAuth({ loopbackOnly: false });

// ---------------------------------------------------------------
// Box-score traversal — sport-specific shapes flattened into a uniform
// { playerEspnId, statsBag } pair so downstream calculateStat doesn't
// need to know which sport produced the box.
// ---------------------------------------------------------------

function indexBasketballBox(boxScore) {
  // Two teams, each with statistics[0].athletes[]
  const out = new Map();
  for (const team of boxScore?.boxscore?.players || []) {
    const stats = team?.statistics?.[0];
    if (!stats?.athletes) continue;
    for (const a of stats.athletes) {
      const id = a?.athlete?.id || a?.id;
      if (!id) continue;
      out.set(String(id), {
        statsBag: a.stats || [],
        starter: !!a.starter,
      });
    }
  }
  return out;
}

function indexMlbBox(boxScore) {
  const out = new Map();
  const teams = boxScore?.liveData?.boxscore?.teams || {};
  for (const side of ['home', 'away']) {
    const players = teams[side]?.players || {};
    for (const key of Object.keys(players)) {
      const p = players[key];
      // MLB Stats API "person" carries a numeric ID. The player_id_map's
      // mlbam_id column is matched to this for ESPN-graded props.
      const id = p?.person?.id;
      if (!id) continue;
      // Aggregate batting + pitching stats into a single bag.
      out.set(String(id), {
        statsBag: { ...(p.stats?.batting || {}), ...(p.stats?.pitching || {}) },
        starter: !!p.gameStatus?.isCurrentBatter,
      });
    }
  }
  return out;
}

function indexFootballBox(boxScore) {
  // statistics is an array of categories: passing, rushing, receiving, …
  const out = new Map();
  for (const team of boxScore?.boxscore?.players || []) {
    const cats = team?.statistics || [];
    if (!Array.isArray(cats)) continue;
    for (const cat of cats) {
      for (const a of cat?.athletes || []) {
        const id = a?.athlete?.id || a?.id;
        if (!id) continue;
        const existing = out.get(String(id)) || { statsBag: {}, starter: !!a.starter };
        // Each category exposes a different stat label ordering; build a
        // named map per category from labels[] × stats[].
        const labels = cat?.labels || cat?.keys || [];
        const stats = a?.stats || [];
        const named = {};
        labels.forEach((label, i) => { named[label] = stats[i]; });
        existing.statsBag[cat.name || cat.text] = named;
        out.set(String(id), existing);
      }
    }
  }
  return out;
}

function indexNhlBox(boxScore) {
  // ESPN NHL surfaces statistics as a labels/stats pair per athlete.
  const out = new Map();
  for (const team of boxScore?.boxscore?.players || []) {
    for (const cat of team?.statistics || []) {
      const labels = cat?.labels || [];
      for (const a of cat?.athletes || []) {
        const id = a?.athlete?.id || a?.id;
        if (!id) continue;
        const stats = a?.stats || [];
        const named = {};
        labels.forEach((label, i) => { named[label.toLowerCase()] = Number(stats[i]) || 0; });
        const existing = out.get(String(id)) || { statsBag: {}, starter: !!a.starter };
        Object.assign(existing.statsBag, named);
        out.set(String(id), existing);
      }
    }
  }
  return out;
}

function indexBoxScore(sport, boxScore) {
  switch (sport) {
    case 'nba':
    case 'wnba':
    case 'ncaab':
      return indexBasketballBox(boxScore);
    case 'nfl':
    case 'ncaafb':
      return indexFootballBox(boxScore);
    case 'mlb':
      return indexMlbBox(boxScore);
    case 'nhl':
      return indexNhlBox(boxScore);
    default:
      return new Map();
  }
}

// calculateStat: pick the parse strategy based on which key the statMap
// entry carries. Format pecking order matches the four documented styles
// in src/config/sports.js.
function calculateStat(statsBag, statType, sportCfg) {
  const map = sportCfg.statMap?.[statType];
  if (!map) return null;
  if (map.calc) return Number(map.calc(statsBag)) || 0;
  if (map.mlbCalc) return Number(map.mlbCalc(statsBag)) || 0;
  if (map.mlbField) {
    const raw = statsBag?.[map.mlbField];
    return map.parse ? map.parse(raw) : (Number(raw) || 0);
  }
  if (map.category) {
    const cat = statsBag?.[map.category];
    if (!cat) return 0;
    return Number(cat[map.field]) || 0;
  }
  if (map.idx !== undefined) {
    const raw = statsBag?.[map.idx];
    if (raw === undefined || raw === null || raw === '') return 0;
    return map.parse ? map.parse(raw) : (Number(raw) || 0);
  }
  if (map.field) return Number(statsBag?.[map.field]) || 0;
  return 0;
}

function emoji(result) {
  return { hit: '✅', miss: '❌', push: '➡️', void: '⚪' }[result] || '•';
}

// ---------------------------------------------------------------
// Resolution endpoint
// ---------------------------------------------------------------

async function handleVoidGame(supabase, gameId, reason) {
  // Mark every unresolved prop for this game as void with the reason.
  const { data: rows, error: lookupErr } = await supabase
    .from('grade_history')
    .select('id, player_id, player_name, stat_type, line, direction, grade, sport')
    .eq('game_id', gameId)
    .is('resolved_at', null);
  if (lookupErr) throw lookupErr;
  if (!rows || rows.length === 0) return { resolved: 0, voided: 0, results: [] };
  const nowIso = new Date().toISOString();
  const ids = rows.map((r) => r.id);
  await supabase
    .from('grade_history')
    .update({ result: 'void', resolved_at: nowIso, correction_note: reason })
    .in('id', ids);
  return { resolved: 0, voided: rows.length, results: rows.map((r) => ({ ...r, result: 'void' })) };
}

router.post('/resolve', requireInternal, async (req, res) => {
  const { gameId, sport, boxScore, void: isVoid, reason } = req.body || {};
  if (!gameId || !sport) {
    return res.status(400).json({ error: 'gameId and sport are required' });
  }
  let sportCfg;
  try { sportCfg = getSportConfig(sport); }
  catch (err) { return res.status(400).json({ error: err.message }); }

  const supabase = getSupabaseServiceClient();

  if (isVoid) {
    try {
      const summary = await handleVoidGame(supabase, gameId, reason || 'void');
      return res.json(summary);
    } catch (err) {
      console.error('[VYNDR] Void resolution error:', err.message);
      return res.status(503).json({ error: 'Void processing failed' });
    }
  }

  if (!boxScore) return res.status(400).json({ error: 'boxScore required (or void: true)' });

  // 1. Pull unresolved props for this game.
  const { data: unresolved, error: lookupErr } = await supabase
    .from('grade_history')
    .select('id, player_id, player_name, stat_type, line, direction, grade, sport, projection, closing_line_id, factors')
    .eq('game_id', gameId)
    .is('resolved_at', null);
  if (lookupErr) {
    console.error('[VYNDR] grade_history lookup error:', lookupErr.message);
    return res.status(503).json({ error: 'Resolution lookup failed' });
  }
  if (!unresolved || unresolved.length === 0) {
    return res.json({ resolved: 0, voided: 0, results: [] });
  }

  // 2. Walk the box score into a per-player stats bag.
  const boxIndex = indexBoxScore(sport, boxScore);

  const nowIso = new Date().toISOString();
  const updates = [];           // { id, patch } per prop
  const resolutionRows = [];    // resolution_results inserts
  const results = [];           // response payload

  for (const prop of unresolved) {
    const playerEspnId = String(prop.player_id);
    const found = boxIndex.get(playerEspnId);

    if (!found) {
      const patch = {
        result: 'void',
        resolved_at: nowIso,
        correction_note: 'DNP - Player not in box score',
      };
      updates.push({ id: prop.id, patch });
      resolutionRows.push({
        grade_id: prop.id,
        game_id: gameId,
        sport,
        player_espn_id: playerEspnId,
        player_name: prop.player_name,
        stat_type: prop.stat_type,
        line: Number(prop.line),
        direction: prop.direction,
        actual_value: 0,
        result: 'void',
        margin: null,
        correction_note: 'DNP',
      });
      results.push({ ...prop, result: 'void', actual_value: null });
      continue;
    }

    const actual = calculateStat(found.statsBag, prop.stat_type, sportCfg);
    let result, margin;
    if (actual == null) {
      result = 'void';
      margin = null;
    } else {
      const line = Number(prop.line);
      margin = actual - line;
      if (prop.direction === 'over') {
        if (actual > line) result = 'hit';
        else if (actual < line) result = 'miss';
        else result = 'push';
      } else {
        if (actual < line) result = 'hit';
        else if (actual > line) result = 'miss';
        else result = 'push';
      }
    }

    const actualNum = actual == null ? 0 : actual;
    updates.push({
      id: prop.id,
      patch: { result, actual_value: actualNum, margin, resolved_at: nowIso, was_starter: !!found.starter },
    });
    resolutionRows.push({
      grade_id: prop.id,
      game_id: gameId,
      sport,
      player_espn_id: playerEspnId,
      player_name: prop.player_name,
      stat_type: prop.stat_type,
      line: Number(prop.line),
      direction: prop.direction,
      actual_value: actualNum,
      result,
      margin,
      was_starter: !!found.starter,
      closing_line_id: prop.closing_line_id || null,
    });
    results.push({ ...prop, result, actual_value: actualNum, margin });
  }

  // 3. Atomic-ish batch write — Supabase doesn't expose a true transaction,
  // but a single .insert / single .upsert is one round-trip.
  if (resolutionRows.length) {
    const { error: insertErr } = await supabase.from('resolution_results').insert(resolutionRows);
    if (insertErr) console.warn('[VYNDR] resolution_results insert error:', insertErr.message);
  }

  // grade_history updates can't be batched (different patches per row), but
  // they're indexed by primary key so latency is bounded.
  for (const u of updates) {
    const { error: updErr } = await supabase.from('grade_history').update(u.patch).eq('id', u.id);
    if (updErr) console.warn('[VYNDR] grade_history update error:', updErr.message);
  }

  // 4. Side effects — none can block the response or each other.
  const sideEffects = [];
  for (const r of results) {
    sideEffects.push(Promise.resolve().then(() => {
      logResolution({
        sport,
        player_espn_id: String(r.player_id),
        player_name: r.player_name,
        stat_type: r.stat_type,
        line: Number(r.line),
        direction: r.direction,
        actual_value: r.actual_value,
        result: r.result,
        margin: r.margin,
        grade: r.grade,
      });
    }));

    if (webPush.configured() && r.result !== 'void') {
      sideEffects.push(webPush.sendPushToSport(sport, {
        title: 'VYNDR Grade Resolved',
        body: `${r.player_name} ${r.direction} ${r.line} ${r.stat_type}: ${(r.result || '').toUpperCase()} ${emoji(r.result)} (actual ${r.actual_value}, margin ${r.margin})`,
        url: '/ledger',
      }, { kind: 'resolution' }));
    }

    if (telegram.configured?.()) {
      sideEffects.push(telegram.postToTelegram({
        text: `${emoji(r.result)} ${(r.result || '').toUpperCase()} | ${r.player_name} ${r.direction} ${r.line} ${r.stat_type} | Actual: ${r.actual_value} | Grade: ${r.grade}`,
      }));
    }
    if (discord.webhookFor?.('results')) {
      sideEffects.push(discord.postToDiscord('results', {
        text: `${(r.result || '').toUpperCase()} ${emoji(r.result)} — ${r.player_name} ${r.direction} ${r.line} ${r.stat_type} → ${r.actual_value} (margin ${r.margin}) — Grade ${r.grade}`,
      }));
    }

    // Learning-loop hooks — all fire-and-forget. None can block the
    // response or each other. Failures inside any of these are logged
    // by the service itself and never propagate up.
    if (r.result === 'hit' || r.result === 'miss' || r.result === 'push' || r.result === 'void') {
      sideEffects.push(accuracyTracker.recordResolution(sport, r.grade, r.result));
    }
    if (r.result === 'hit' || r.result === 'miss') {
      sideEffects.push(clvTracker.computeCLV(r.id));
      sideEffects.push(weightAdjuster.adjustWeights({
        sport,
        stat_type: r.stat_type,
        grade: r.grade,
        result: r.result,
        factors: Array.isArray(r.factors) ? r.factors : [],
        grade_id: r.id,
      }));
    }
  }
  // Fire-and-forget; one failure can't block another.
  Promise.allSettled(sideEffects).catch(() => { /* swallowed */ });

  const resolvedCount = results.filter((r) => r.result === 'hit' || r.result === 'miss' || r.result === 'push').length;
  const voidedCount = results.length - resolvedCount;

  return res.json({ resolved: resolvedCount, voided: voidedCount, results });
});

// ---------------------------------------------------------------
// POST /pipeline — called by n8n (or curl) to run the grading pipeline
// for one sport. Same auth as /resolve. The orchestrator handles all
// upstream calls; we just wrap it in HTTP with input validation.
// ---------------------------------------------------------------

const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'ncaab', 'ncaafb']);

// Session 10 — `/pipeline` accepts off-host callers (n8n runs in a
// separate container). With a `sport` body field, runs that sport
// only; with an empty body, iterates every active sport. n8n's
// Morning Ops workflow sends an empty body; the per-sport workflows
// pass a specific sport. The legacy header (X-VYNDR-Internal-Key) and
// the new short form (x-internal-key) both authenticate.
router.post('/pipeline', requireInternalAnyOrigin, async (req, res) => {
  const { sport, options } = req.body || {};
  if (sport && !VALID_SPORTS.has(sport)) {
    return res.status(400).json({ error: 'sport must be one of: nba, wnba, mlb, nfl, nhl, ncaab, ncaafb' });
  }
  const { runPipeline } = require('../services/intelligence/gradingOrchestrator');
  const sportsToRun = sport ? [sport] : ['nba', 'wnba', 'mlb'];
  const results = [];
  for (const s of sportsToRun) {
    try {
      const summary = await runPipeline(s, options || {});
      results.push({ sport: s, ...summary });
    } catch (err) {
      console.error('[VYNDR] Pipeline error:', s, err.message);
      results.push({ sport: s, error: err.message });
    }
  }
  // If a specific sport was requested, preserve the legacy single-object
  // shape so existing callers (tests + the n8n per-sport workflows)
  // don't break. Multi-sport runs return an array.
  if (sport) return res.json(results[0]);
  return res.json({ status: 'ok', timestamp: new Date().toISOString(), sports: results });
});

// Exported so server.js can wire it up with a larger body limit; also lets
// tests import the helper without binding to Express.
module.exports = router;
module.exports.__helpers = { calculateStat, indexBoxScore, requireInternal };
