'use strict';

/**
 * WEATHER MODULATION (Layer 3, Step 5) — completes the coupled environment.
 *
 *     effective_environment = park_base × weather_mod
 *
 * Park is the stable BASE run/HR environment of a stadium. Weather is the
 * day-of MODULATION that tilts it. It multiplies, it never overrides: a
 * wind-out night at Oracle Park is still Oracle Park.
 *
 * ── THE SPINE: TWO WEATHER VALUES, TWO PURPOSES, NEVER CROSSED ───────────
 *   FORECAST at projection time  → drives the live adjustment AND is what the
 *                                  instrument measures. It is what we actually
 *                                  knew when we projected.
 *   ACTUAL at game time          → accrues to `game_context` ONLY, as raw
 *                                  material for future self-derived weather
 *                                  factors.
 * Using the actual to drive or measure tonight's projection would be lookahead
 * bias — scoring ourselves on information we did not have. The two live in
 * different columns, written by different paths, and nothing reads across.
 *
 * ── CONSERVATIVE BY DEFAULT, LEDGER-TUNED ────────────────────────────────
 * The coefficients below are deliberately SMALL. We are not asserting a known
 * weather edge; we are running a directional lean the instrument can size. Every
 * magnitude is env-tunable so the ledger can move it without a code change.
 */

const { DOME_VENUES } = require('./parkFactors');

/**
 * Bearing in degrees from home plate to CENTER FIELD, per park.
 * PUBLIC GEOMETRY — a fact about how a building is oriented, in the same class
 * as the dome list, not a derived statistic. Without it "wind out" is
 * unknowable, so a park missing from this table gets NO wind effect rather than
 * a guessed one.
 */
const CF_BEARING = Object.freeze({
  ARI: 0, ATL: 47, BAL: 32, BOS: 45, CHC: 32, CWS: 39, CIN: 20, CLE: 0,
  COL: 5, DET: 27, HOU: 20, KC: 45, LAA: 44, LAD: 27, MIA: 40, MIL: 33,
  MIN: 26, NYM: 26, NYY: 76, ATH: 60, PHI: 3, PIT: 27, SD: 0, SF: 62,
  SEA: 47, STL: 62, TB: 45, TEX: 0, TOR: 0, WSH: 30,
});

/** Conservative, env-tunable. Each is the multiplier delta at a reference. */
const WIND_PER_MPH = Number(process.env.WX_WIND_PER_MPH) || 0.006; // per mph of OUT component
const TEMP_PER_DEG = Number(process.env.WX_TEMP_PER_DEG) || 0.003; // per °F above/below baseline
const TEMP_BASELINE = Number(process.env.WX_TEMP_BASELINE) || 70;
/** Below these the weather is noise — do not manufacture a tiny adjustment. */
const WIND_THRESHOLD = Number(process.env.WX_WIND_THRESHOLD) || 5;   // mph OUT component
const TEMP_THRESHOLD = Number(process.env.WX_TEMP_THRESHOLD) || 8;   // °F from baseline
/** Hard cap: weather MODULATES, never overrides. */
const MAX_MOD = Number(process.env.WX_MAX_MOD) || 0.12; // ±12%

/** Which stats weather speaks to at all. Strikeouts, walks etc. are absent —
 *  we are not going to invent a wind effect on a strikeout prop. */
const WEATHER_STATS = Object.freeze(new Set([
  'home_runs', 'home_runs_allowed', 'total_bases', 'runs', 'rbi', 'hits', 'hits_allowed', 'earned_runs',
]));

const num = (v) => {
  if (v == null || v === '') return null;
  const n = typeof v === 'number' ? v : Number(v);
  return Number.isFinite(n) ? n : null;
};
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const rad = (d) => (d * Math.PI) / 180;

/**
 * outComponent(windFromDeg, speedMph, cfBearingDeg) — how much of the wind is
 * blowing OUT toward center field, in mph. Negative = blowing in.
 *
 * Meteorological convention: `wind_direction` is the direction the wind comes
 * FROM. Wind blowing out to centre therefore arrives FROM the opposite bearing,
 * which is why 180 is added before comparing. Getting this backwards would
 * invert every wind adjustment — the single easiest sign error in the file.
 */
function outComponent(windFromDeg, speedMph, cfBearingDeg) {
  const from = num(windFromDeg); const spd = num(speedMph); const cf = num(cfBearingDeg);
  if (from == null || spd == null || cf == null) return null;
  const blowingToward = (from + 180) % 360;
  const delta = rad(blowingToward - cf);
  return Math.round(spd * Math.cos(delta) * 100) / 100;
}

/**
 * weatherMod({ forecast, teamAbbr, venueName, statType, weatherNa }) — the
 * COMPOSABLE modulation. Always returns a multiplier; 1.0 is a true no-op and
 * is reached by three HONESTLY DISTINCT routes.
 */
function weatherMod({ forecast, teamAbbr, venueName, statType, weatherNa } = {}) {
  const neutral = (state, reason) => ({
    multiplier: 1, state, reason, components: null,
    wind_out_mph: null, temp_f: null,
  });

  const stat = String(statType || '').toLowerCase();
  if (!WEATHER_STATS.has(stat)) return neutral('not_applicable', 'weather says nothing about this stat');

  // 1. DOME — weather does not apply. The PARK factor still does; this flag
  //    only tells the weather layer to stand down. Not the same as absent.
  const isDome = weatherNa === true
    || (venueName ? DOME_VENUES.has(venueName) : false);
  if (isDome) return neutral('dome_na', 'roofed venue — weather does not apply');

  // 2. FORECAST ABSENT — we could not get one for this park/time.
  const f = forecast || null;
  const temp = num(f && f.temperature_f);
  const windSpeed = num(f && f.wind_speed_mph);
  const windDir = num(f && f.wind_direction_deg);
  if (temp == null && windSpeed == null) {
    return neutral('forecast_absent', 'no forecast available for this park/time');
  }

  const abbr = String(teamAbbr || '').toUpperCase();
  const cf = CF_BEARING[abbr];
  // No orientation → wind is unknowable here. Temperature still applies.
  const windOut = cf == null ? null : outComponent(windDir, windSpeed, cf);

  const windActive = windOut != null && Math.abs(windOut) >= WIND_THRESHOLD;
  const tempDelta = temp == null ? null : temp - TEMP_BASELINE;
  const tempActive = tempDelta != null && Math.abs(tempDelta) >= TEMP_THRESHOLD;

  // 3. SUB-THRESHOLD — a real forecast, but a light breeze and a mild evening
  //    are noise. Manufacturing a 0.3% nudge on them would be false precision.
  if (!windActive && !tempActive) {
    return {
      ...neutral('sub_threshold', 'weather present but below a meaningful threshold'),
      wind_out_mph: windOut, temp_f: temp,
    };
  }

  const windEffect = windActive ? windOut * WIND_PER_MPH : 0;
  const tempEffect = tempActive ? tempDelta * TEMP_PER_DEG : 0;
  const raw = windEffect + tempEffect;
  const capped = clamp(raw, -MAX_MOD, MAX_MOD);

  return {
    multiplier: Math.round((1 + capped) * 1000) / 1000,
    state: 'present',
    reason: null,
    wind_out_mph: windOut,
    temp_f: temp,
    components: {
      wind: Math.round(windEffect * 1000) / 1000,
      temp: Math.round(tempEffect * 1000) / 1000,
      capped: capped !== raw,
      cf_bearing: cf ?? null,
    },
  };
}

/**
 * composeEnvironment({ park, weather }) — the coupled coefficient.
 * park_base × weather_mod, with provenance from both so a rendered effect can
 * say which parts are real and which stood down.
 */
function composeEnvironment({ park, weather, statType } = {}) {
  const { parkMultiplier } = require('./parkFactors');
  const pm = park ? parkMultiplier({ factor: park, statType }) : 1;
  const wx = weather || { multiplier: 1, state: 'forecast_absent' };
  const multiplier = Math.round(pm * (wx.multiplier ?? 1) * 1000) / 1000;
  return {
    multiplier,
    park_base: pm,
    weather_mod: wx.multiplier ?? 1,
    weather_state: wx.state,
    label: 'PARK × WEATHER',
    venue: (park && park.venue) || null,
    weather_na: (park && park.weather_na) ?? null,
    provenance: (park && park.provenance) || null,
  };
}

/** Open-Meteo — free, keyless. Hourly forecast at the park's coordinates. */
const FORECAST_URL = (lat, lon) =>
  `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}`
  + '&hourly=temperature_2m,wind_speed_10m,wind_direction_10m,precipitation_probability'
  + '&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=3';

/** Pick the forecast hour nearest first pitch. Returns null when the requested
 *  hour is outside the returned window — absent, never the closest-anyway. */
function pickHour(payload, isoTime) {
  const h = payload && payload.hourly;
  if (!h || !Array.isArray(h.time) || !isoTime) return null;
  const target = new Date(isoTime).getTime();
  if (!Number.isFinite(target)) return null;
  let best = -1; let bestDiff = Infinity;
  h.time.forEach((t, i) => {
    const diff = Math.abs(new Date(`${t}Z`).getTime() - target);
    if (diff < bestDiff) { bestDiff = diff; best = i; }
  });
  if (best < 0 || bestDiff > 3 * 3600 * 1000) return null; // >3h away = no forecast
  return {
    temperature_f: num(h.temperature_2m && h.temperature_2m[best]),
    wind_speed_mph: num(h.wind_speed_10m && h.wind_speed_10m[best]),
    wind_direction_deg: num(h.wind_direction_10m && h.wind_direction_10m[best]),
    precip_pct: num(h.precipitation_probability && h.precipitation_probability[best]),
    forecast_hour: h.time[best],
  };
}

module.exports = {
  weatherMod,
  composeEnvironment,
  outComponent,
  pickHour,
  FORECAST_URL,
  CF_BEARING,
  WEATHER_STATS,
  WIND_PER_MPH, TEMP_PER_DEG, TEMP_BASELINE,
  WIND_THRESHOLD, TEMP_THRESHOLD, MAX_MOD,
};
