'use strict';

/**
 * PARK FACTORS (Layer 3, Step 4) — the first derive-it-ourselves adjuster.
 *
 * Commodity concept, PROPRIETARY derivation: everyone knows Coors inflates
 * offence; the value is in deriving OUR coefficient, with our sample floors,
 * our regime detection, and our honest-absent states.
 *
 * ── COMPOSABLE BY CONSTRUCTION (the whole architectural point) ────────────
 * A park factor is the BASE run/HR environment. Weather MODULATES it for a
 * given night. So this emits a **multiplicative coefficient** (1.0 = neutral),
 * never a fixed additive nudge:
 *
 *     effective_environment = park_base × weather_mod × …
 *
 * Weather slots in next order by multiplying, with no rearchitecting. An
 * additive nudge could not compose: "+3% HR" and "+8% for wind out" do not
 * combine correctly, while 1.03 × 1.08 does.
 *
 * ── THE METHOD ───────────────────────────────────────────────────────────
 * The classic home/away construction, which controls for team quality: a park's
 * factor is the rate in games PLAYED THERE against the same teams' rate in
 * their games elsewhere. Computed per season, then combined with RECENCY
 * WEIGHTS across seasons — but only within the CURRENT REGIME.
 *
 * ── STRUCTURAL BREAKS ────────────────────────────────────────────────────
 * Parks change: the Coors humidor, moved walls, new dimensions. Blending
 * pre- and post-change seasons produces a number that describes a stadium that
 * no longer exists. `detectRegime` finds the most recent step and uses ONLY
 * post-break seasons — and if that leaves too few games, the factor is
 * HONEST-ABSENT rather than confidently wrong.
 *
 * ── HONEST STATES (three, deliberately distinct) ─────────────────────────
 *   absent      thin sample or unknown venue → adjust NOTHING
 *   present     stable factor → adjust
 *   weather_na  a DOME. The park factor still applies (a dome has a real run
 *               environment); the venue is simply flagged so next order's
 *               weather modulation correctly does nothing there.
 *               N/A IS NOT ABSENT — conflating them would either drop a valid
 *               park factor or apply weather indoors.
 */

/** Minimum HOME games in the current regime before a factor is emitted at all.
 *  ~81 home games per season, so 150 ≈ two full seasons of the current park. */
const MIN_GAMES = Number(process.env.PARK_MIN_GAMES) || 150;
/** Fine conditioning (park × handedness, park × batted-ball) needs far more
 *  than the coarse factor: a sliver overfits. Absent until it clears this. */
const MIN_GAMES_FINE = Number(process.env.PARK_MIN_GAMES_FINE) || 400;
/** A season whose factor deviates this far from the prior-regime mean is a
 *  candidate structural break — confirmed only if the NEXT season agrees. */
const BREAK_THRESHOLD = Number(process.env.PARK_BREAK_THRESHOLD) || 0.15;
/** Recency weights, newest first. A five-year-old park is still the same
 *  building, but the current configuration deserves more weight. */
const RECENCY = [1.0, 0.75, 0.55, 0.4, 0.3];
/** Regression to the mean: a park with exactly MIN_GAMES gets pulled halfway
 *  to neutral; a park with many seasons barely moves. Prevents a two-season
 *  sample from asserting a Coors-sized effect. */
const REGRESSION_GAMES = Number(process.env.PARK_REGRESSION_GAMES) || 300;

/** Domes + retractable roofs. A fact about buildings, not a derived statistic —
 *  and the flag exists so WEATHER does nothing here, not so park does. */
const DOME_VENUES = Object.freeze(new Set([
  'Tropicana Field', 'Rogers Centre', 'Chase Field', 'Minute Maid Park',
  'Daikin Park', 'American Family Field', 'Globe Life Field', 'loanDepot park',
  'T-Mobile Park', 'Tokyo Dome',
]));

const num = (v) => {
  if (v == null || v === '') return null;
  const n = typeof v === 'number' ? v : Number(v);
  return Number.isFinite(n) ? n : null;
};

/**
 * tallyBySeason(games) — PURE. Reduces raw game rows into per-venue,
 * per-season home/away tallies for each team.
 *
 * game: { season, venue_id, venue_name, home_team, away_team, home_runs,
 *         away_runs, home_hr, away_hr }
 */
function tallyBySeason(games) {
  const venues = new Map(); // venueKey -> season -> { g, runs, hr }
  const teamAway = new Map(); // season -> team -> { g, runs, hr }

  const bumpTeamAway = (season, team, runs, hr) => {
    if (!teamAway.has(season)) teamAway.set(season, new Map());
    const m = teamAway.get(season);
    const t = m.get(team) || { g: 0, runs: 0, hr: 0 };
    t.g += 1; t.runs += runs; t.hr += hr;
    m.set(team, t);
  };

  for (const g of games || []) {
    const season = String(g.season);
    const vid = g.venue_id != null ? String(g.venue_id) : g.venue_name;
    if (!vid) continue;
    const hr = (num(g.home_runs) ?? 0) + (num(g.away_runs) ?? 0);
    const hrs = (num(g.home_hr) ?? 0) + (num(g.away_hr) ?? 0);

    if (!venues.has(vid)) venues.set(vid, { name: g.venue_name, seasons: new Map() });
    const v = venues.get(vid);
    const s = v.seasons.get(season) || { g: 0, runs: 0, hr: 0, teams: new Set() };
    s.g += 1; s.runs += hr; s.hr += hrs;
    if (g.home_team) s.teams.add(g.home_team);
    v.seasons.set(season, s);

    // The AWAY team's road game — the control side of the comparison.
    if (g.away_team) bumpTeamAway(season, g.away_team, hr, hrs);
  }
  return { venues, teamAway };
}

/**
 * detectRegime(seasonFactors) — PURE. Returns the first season of the CURRENT
 * regime. A candidate break needs CONFIRMATION from the following season: one
 * odd year is noise, two consecutive years on the same side is a new park.
 *
 * seasonFactors: [{ season, factor }] ascending by season.
 */
function detectRegime(seasonFactors) {
  const rows = (seasonFactors || []).filter((r) => Number.isFinite(r.factor));
  if (rows.length < 3) return { start: rows.length ? rows[0].season : null, broke: false };

  let start = rows[0].season;
  let broke = false;
  for (let i = 1; i < rows.length - 1; i++) {
    const prior = rows.slice(0, i).filter((r) => r.season >= start);
    if (prior.length < 1) continue;
    const mean = prior.reduce((a, r) => a + r.factor, 0) / prior.length;
    const dev = rows[i].factor - mean;
    if (Math.abs(dev) < BREAK_THRESHOLD) continue;
    // Confirmation: the NEXT season must sit on the same side of the old mean.
    const nextDev = rows[i + 1].factor - mean;
    if (Math.sign(nextDev) === Math.sign(dev) && Math.abs(nextDev) >= BREAK_THRESHOLD * 0.6) {
      start = rows[i].season;
      broke = true;
    }
  }
  return { start, broke };
}

/**
 * deriveParkFactors(games, opts) — PURE. games → { [venueKey]: factor record }.
 *
 * Each record: { venue, hr_base, run_base, games, seasons, regime_start,
 *                regime_broke, weather_na, state, fine_available }
 * `*_base` are MULTIPLIERS around 1.0, ready to be composed with weather.
 */
function deriveParkFactors(games, opts = {}) {
  const minGames = opts.minGames || MIN_GAMES;
  const { venues, teamAway } = tallyBySeason(games);
  const out = {};

  // League baseline per season = the average home game, so a park factor is
  // "relative to a typical park that year" rather than to an absolute.
  const leagueBySeason = new Map();
  for (const v of venues.values()) {
    for (const [season, s] of v.seasons) {
      const l = leagueBySeason.get(season) || { g: 0, runs: 0, hr: 0 };
      l.g += s.g; l.runs += s.runs; l.hr += s.hr;
      leagueBySeason.set(season, l);
    }
  }

  for (const [vid, v] of venues) {
    const seasonRows = [...v.seasons.entries()]
      .map(([season, s]) => {
        const l = leagueBySeason.get(season);
        if (!l || !l.g || !s.g) return null;
        const lgRuns = l.runs / l.g;
        const lgHr = l.hr / l.g;
        return {
          season,
          games: s.g,
          run_factor: lgRuns > 0 ? (s.runs / s.g) / lgRuns : null,
          hr_factor: lgHr > 0 ? (s.hr / s.g) / lgHr : null,
        };
      })
      .filter(Boolean)
      .sort((a, b) => (a.season < b.season ? -1 : 1));

    const regime = detectRegime(seasonRows.map((r) => ({ season: r.season, factor: r.hr_factor })));
    const current = seasonRows.filter((r) => r.season >= regime.start);
    const totalGames = current.reduce((a, r) => a + r.games, 0);

    const weatherNa = DOME_VENUES.has(v.name);

    if (totalGames < minGames) {
      out[vid] = {
        venue: v.name, venue_id: vid,
        hr_base: null, run_base: null,
        games: totalGames, seasons: current.length,
        regime_start: regime.start, regime_broke: regime.broke,
        weather_na: weatherNa,
        // ABSENT is not the same as N/A: absent means we will not adjust at all.
        state: 'absent',
        reason: `only ${totalGames} games in the current regime (floor ${minGames})`,
        fine_available: false,
      };
      continue;
    }

    // Recency-weighted mean within the regime, newest season heaviest.
    const desc = [...current].reverse();
    const wsum = (pick) => {
      let n = 0; let d = 0;
      desc.forEach((r, i) => {
        const val = pick(r);
        if (val == null) return;
        const w = (RECENCY[i] ?? 0.2) * r.games;
        n += val * w; d += w;
      });
      return d > 0 ? n / d : null;
    };

    const rawHr = wsum((r) => r.hr_factor);
    const rawRun = wsum((r) => r.run_factor);

    // Regress toward neutral by sample size — a two-season park should not
    // assert a Coors-sized coefficient.
    const shrink = totalGames / (totalGames + REGRESSION_GAMES);
    const reg = (x) => (x == null ? null : Math.round((1 + (x - 1) * shrink) * 1000) / 1000);

    out[vid] = {
      venue: v.name, venue_id: vid,
      hr_base: reg(rawHr),
      run_base: reg(rawRun),
      hr_raw: rawHr == null ? null : Math.round(rawHr * 1000) / 1000,
      run_raw: rawRun == null ? null : Math.round(rawRun * 1000) / 1000,
      games: totalGames, seasons: current.length,
      regime_start: regime.start, regime_broke: regime.broke,
      weather_na: weatherNa,
      state: 'present',
      reason: null,
      // Fine conditioning stays ABSENT until it earns its own, larger floor.
      fine_available: totalGames >= MIN_GAMES_FINE,
    };
  }
  return out;
}

/** Which base a stat keys off. Absent = park says nothing about this stat. */
const STAT_BASE = Object.freeze({
  home_runs: 'hr_base', home_runs_allowed: 'hr_base', total_bases: 'hr_base',
  runs: 'run_base', rbi: 'run_base', earned_runs: 'run_base', hits: 'run_base',
  hits_allowed: 'run_base',
});

/**
 * parkMultiplier({ factor, statType, role }) — the COMPOSABLE coefficient for a
 * specific prop. Returns 1.0 (a no-op under multiplication) whenever the park
 * has nothing to say.
 *
 * DIRECTIONAL BY PROP-OWNER: Coors inflates home runs. For the HITTER's HR prop
 * that is UP; for the PITCHER's home-runs-allowed prop at the same park it is
 * ALSO up — the stat itself already encodes whose side it is. The sign lives in
 * the STAT, not in the park, which is why `home_runs` and `home_runs_allowed`
 * both key off `hr_base` in the same direction.
 */
function parkMultiplier({ factor, statType } = {}) {
  if (!factor || factor.state !== 'present') return 1;
  const key = STAT_BASE[String(statType || '').toLowerCase()];
  if (!key) return 1;
  const base = num(factor[key]);
  return base == null || base <= 0 ? 1 : base;
}

module.exports = {
  deriveParkFactors,
  detectRegime,
  tallyBySeason,
  parkMultiplier,
  STAT_BASE,
  DOME_VENUES,
  MIN_GAMES,
  MIN_GAMES_FINE,
  BREAK_THRESHOLD,
  RECENCY,
  REGRESSION_GAMES,
};
