/* ============================================================
   Session 75 — WEATHER MODULATION. Composes onto the park base.
   Forecast drives + measures; actual accrues. Never crossed.
   ============================================================ */

const wx = require('../../src/services/weatherMod');
const pb = require('../../src/services/parkBase');
const ch = require('../../src/services/challengerProjection');
const gc = require('../../src/services/gameContext');

// Wrigley's centre field bears 32°; wind FROM 212° blows straight out.
const OUT = { temperature_f: 85, wind_speed_mph: 15, wind_direction_deg: 212 };
const IN = { temperature_f: 85, wind_speed_mph: 15, wind_direction_deg: 32 };
const CALM = { temperature_f: 72, wind_speed_mph: 3, wind_direction_deg: 180 };
const COLD = { temperature_f: 48, wind_speed_mph: 2, wind_direction_deg: 180 };
const mod = (fc, team = 'CHC', stat = 'home_runs', na = false) =>
  wx.weatherMod({ forecast: fc, teamAbbr: team, statType: stat, weatherNa: na });

describe('wind is PARK-ORIENTATION conditioned', () => {
  it('computes the OUT component from the bearing, not raw speed', () => {
    // Meteorological convention: direction is where wind comes FROM. Getting
    // this backwards would invert every wind adjustment.
    expect(wx.outComponent(212, 15, 32)).toBeCloseTo(15, 1);   // straight out
    expect(wx.outComponent(32, 15, 32)).toBeCloseTo(-15, 1);   // straight in
    expect(wx.outComponent(122, 15, 32)).toBeCloseTo(0, 1);    // cross-wind
  });

  it('wind OUT raises the coefficient, wind IN lowers it', () => {
    expect(mod(OUT).multiplier).toBeGreaterThan(1);
    expect(mod(IN).multiplier).toBeLessThan(1);
  });

  it('a park with no known orientation gets NO wind effect, only temperature', () => {
    const r = wx.weatherMod({ forecast: OUT, teamAbbr: 'ZZZ', statType: 'home_runs' });
    expect(r.wind_out_mph).toBeNull();
    expect(r.components.wind).toBe(0);
  });

  it('cold suppresses offence on temperature alone', () => {
    expect(mod(COLD).multiplier).toBeLessThan(1);
  });
});

describe('three HONEST do-nothing states — all 1.0, distinct reasons', () => {
  it('DOME — weather does not apply, but the PARK factor still does', () => {
    const r = mod(OUT, 'TB', 'home_runs', true);
    expect(r.multiplier).toBe(1);
    expect(r.state).toBe('dome_na');
    const park = pb.resolveParkBase({ teamAbbr: 'TB' });
    const env = wx.composeEnvironment({ park, weather: r, statType: 'home_runs' });
    expect(env.park_base).toBeLessThan(1); // park STILL applies indoors
    expect(env.weather_mod).toBe(1);
  });

  it('FORECAST ABSENT — no forecast for this park/time', () => {
    const r = mod(null);
    expect(r.multiplier).toBe(1);
    expect(r.state).toBe('forecast_absent');
  });

  it('SUB-THRESHOLD — real forecast, but a light breeze is noise', () => {
    const r = mod(CALM);
    expect(r.multiplier).toBe(1);
    expect(r.state).toBe('sub_threshold');
    expect(r.wind_out_mph).not.toBeNull(); // measured, just not meaningful
  });

  it('weather says NOTHING about a strikeout prop', () => {
    expect(mod(OUT, 'CHC', 'strikeouts').state).toBe('not_applicable');
  });

  it('none of the three fabricate an effect', () => {
    for (const r of [mod(null), mod(CALM), mod(OUT, 'TB', 'home_runs', true)]) {
      expect(r.multiplier).toBe(1);
    }
  });
});

describe('composition — weather MULTIPLIES the park base', () => {
  it('effective = park × weather, and both parts stay visible', () => {
    const park = pb.resolveParkBase({ teamAbbr: 'CHC' });
    const w = mod(OUT);
    const env = wx.composeEnvironment({ park, weather: w, statType: 'home_runs' });
    expect(env.multiplier).toBeCloseTo(env.park_base * env.weather_mod, 3);
    expect(env.weather_state).toBe('present');
  });

  it('MODULATES, never overrides — capped at ±12%', () => {
    const hurricane = { temperature_f: 110, wind_speed_mph: 60, wind_direction_deg: 212 };
    expect(mod(hurricane).multiplier).toBeLessThanOrEqual(1 + wx.MAX_MOD + 1e-9);
  });

  it('drives the challenger directionally and mirrors on the under', () => {
    const park = pb.resolveParkBase({ teamAbbr: 'CHC' });
    const env = wx.composeEnvironment({ park, weather: mod(OUT), statType: 'home_runs' });
    const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env });
    const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'home_runs', environment: env });
    expect(over.delta).toBeGreaterThan(0);
    expect(under.delta).toBeCloseTo(-over.delta, 3);
  });

  it('same night, the pitcher HR-allowed prop moves WITH the hitter HR prop', () => {
    // Both are P(over) on a ball leaving the park — the sign lives in the stat,
    // exactly as with park factors.
    const park = pb.resolveParkBase({ teamAbbr: 'CHC' });
    const h = wx.composeEnvironment({ park, weather: mod(OUT), statType: 'home_runs' });
    const p = wx.composeEnvironment({ park, weather: mod(OUT, 'CHC', 'home_runs_allowed'), statType: 'home_runs_allowed' });
    expect(p.multiplier).toBe(h.multiplier);
  });
});

describe('forecast selection — absent rather than nearest-anyway', () => {
  const payload = { hourly: {
    time: ['2026-07-21T18:00', '2026-07-21T19:00', '2026-07-21T20:00'],
    temperature_2m: [80, 82, 84], wind_speed_10m: [5, 7, 9],
    wind_direction_10m: [180, 200, 212], precipitation_probability: [0, 5, 10],
  } };

  it('picks the hour nearest first pitch', () => {
    const f = wx.pickHour(payload, '2026-07-21T19:10:00Z');
    expect(f.forecast_hour).toBe('2026-07-21T19:00');
    expect(f.temperature_f).toBe(82);
  });

  it('returns NULL when the requested time is outside the window', () => {
    expect(wx.pickHour(payload, '2026-07-25T19:00:00Z')).toBeNull();
    expect(wx.pickHour(payload, null)).toBeNull();
    expect(wx.pickHour(null, '2026-07-21T19:00:00Z')).toBeNull();
  });
});

describe('THE SPINE — forecast and actual are never crossed', () => {
  it('the LEDGER stores the forecast that drove the projection', () => {
    const src = require('fs').readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
    expect(src).toMatch(/wx_forecast: g\.wx_forecast/);
    expect(src).toMatch(/env_weather_mod/);
    // and says why
    expect(src).toMatch(/The FORECAST,\s*\n\s*\/\/ not the actual/);
  });

  it('the ACTUAL is written only to game_context, from the ARCHIVE endpoint', () => {
    // Asking the forecast endpoint after the fact returns a re-forecast, not
    // what happened.
    expect(gc.ARCHIVE_URL(39.7, -104.9, '2026-07-20')).toMatch(/archive-api\.open-meteo\.com/);
    const src = require('fs').readFileSync(require.resolve('../../src/services/gameContext'), 'utf8');
    expect(src).toMatch(/Never read back into a projection or a measurement/);
  });

  it('an unavailable actual stays NULL — never imputed', async () => {
    const sb = {
      from: () => ({
        select: () => ({ eq: () => ({ is: () => ({ limit: async () => ({ data: [{ game_id: 'g1', venue_id: 19, game_date: '2026-07-20' }], error: null }) }) }) }),
        update: () => ({ eq: async () => ({ error: null }) }),
      }),
    };
    // No coordinates for the venue → absent, not a guessed temperature.
    const r = await gc.captureWeatherActual('2026-07-20', { sb, venueCoords: {} });
    expect(r.updated).toBe(0);
    expect(r.absent).toBe(1);
  });

  it('the weather columns on game_context are named-purpose only', () => {
    const src = require('fs').readFileSync(require.resolve('../../src/services/gameContext'), 'utf8');
    expect(src).toMatch(/named-purpose: raw material for FUTURE/);
    expect(src).toMatch(/self-derived weather factors/);
  });
});
