/* ============================================================
   Session 73 — DERIVED PARK FACTORS (src/services/parkFactors.js).

   Distinct from the STATIC FanGraphs table (src/data/parkFactors.js, Session
   15), which already feeds computeFeatures. This is OUR derivation, and it is
   wired as a CHALLENGER precisely so the harness can test whether it adds
   anything beyond what the champion already has.
   ============================================================ */

const pf = require('../../src/services/parkFactors');
const ch = require('../../src/services/challengerProjection');

function games({ venue = 'Test Park', vid = 1, seasons = ['2022', '2023', '2024', '2025'],
  per = 81, hrPerGame = 2, runsPerGame = 9, leagueHr = 2, leagueRuns = 9 } = {}) {
  const out = [];
  for (const season of seasons) {
    for (let i = 0; i < per; i++) {
      out.push({ season, venue_id: vid, venue_name: venue, home_team: 'H', away_team: `A${i % 8}`,
        home_runs: runsPerGame, away_runs: 0, home_hr: hrPerGame, away_hr: 0 });
      out.push({ season, venue_id: 99, venue_name: 'Neutral Park', home_team: 'N', away_team: `A${i % 8}`,
        home_runs: leagueRuns, away_runs: 0, home_hr: leagueHr, away_hr: 0 });
    }
  }
  return out;
}

describe('derivation — coarse, sample-floored, regressed', () => {
  it('a hitter park is above 1, a pitcher park below', () => {
    expect(pf.deriveParkFactors(games({ hrPerGame: 3 }))['1'].hr_base).toBeGreaterThan(1);
    expect(pf.deriveParkFactors(games({ vid: 2, hrPerGame: 1 }))['2'].hr_base).toBeLessThan(1);
  });

  it('HONEST-ABSENT below the games floor', () => {
    const thin = pf.deriveParkFactors(games({ seasons: ['2025'], per: 40, hrPerGame: 4 }))['1'];
    expect(thin.state).toBe('absent');
    expect(thin.hr_base).toBeNull();
    expect(thin.reason).toMatch(/floor/);
  });

  it('regresses toward neutral by sample size — a small park cannot shout', () => {
    const few = pf.deriveParkFactors(games({ seasons: ['2024', '2025'], hrPerGame: 4 }))['1'];
    const many = pf.deriveParkFactors(games({ seasons: ['2021', '2022', '2023', '2024', '2025'], hrPerGame: 4 }))['1'];
    expect(Math.abs(many.hr_base - 1)).toBeGreaterThan(Math.abs(few.hr_base - 1));
  });

  it('fine conditioning waits for its own larger floor', () => {
    expect(pf.deriveParkFactors(games({}))['1'].fine_available).toBe(false);
    expect(pf.deriveParkFactors(games({ seasons: ['2021', '2022', '2023', '2024', '2025', '2026'] }))['1'].fine_available).toBe(true);
  });
});

describe('structural breaks — a changed park is a NEW park', () => {
  it('detects a CONFIRMED step', () => {
    const r = pf.detectRegime([
      { season: '2021', factor: 1.00 }, { season: '2022', factor: 1.02 },
      { season: '2023', factor: 1.30 }, { season: '2024', factor: 1.28 }, { season: '2025', factor: 1.31 }]);
    expect(r.broke).toBe(true);
    expect(r.start).toBe('2023');
  });

  it('does NOT break on one odd season — a single year is noise', () => {
    expect(pf.detectRegime([
      { season: '2021', factor: 1.00 }, { season: '2022', factor: 1.30 },
      { season: '2023', factor: 1.01 }, { season: '2024', factor: 0.99 }]).broke).toBe(false);
  });

  it('uses ONLY post-break seasons so the old park cannot dilute the new one', () => {
    const f = pf.deriveParkFactors([
      ...games({ seasons: ['2021', '2022'], hrPerGame: 1 }),
      ...games({ seasons: ['2023', '2024', '2025'], hrPerGame: 4 })])['1'];
    expect(f.regime_broke).toBe(true);
    expect(f.regime_start).toBe('2023');
    expect(f.hr_base).toBeGreaterThan(1);
  });
});

describe('three honest states — absent vs present vs dome', () => {
  it('a dome is weather_na but its park factor still APPLIES', () => {
    const f = pf.deriveParkFactors(games({ venue: 'Rogers Centre', hrPerGame: 3 }))['1'];
    expect(f.weather_na).toBe(true);
    expect(f.state).toBe('present'); // N/A is NOT absent
  });

  it('absent and weather_na are independent', () => {
    const t = pf.deriveParkFactors(games({ venue: 'Rogers Centre', seasons: ['2025'], per: 30 }))['1'];
    expect(t.state).toBe('absent');
    expect(t.weather_na).toBe(true);
  });
});

describe('composable coefficient — weather multiplies onto it next order', () => {
  const coors = { state: 'present', hr_base: 1.28, run_base: 1.18, venue: 'Coors Field', weather_na: false };
  const env = (f, stat) => ({ multiplier: pf.parkMultiplier({ factor: f, statType: stat }), label: 'PARK', venue: f.venue, weather_na: f.weather_na });

  it('emits a MULTIPLIER and 1.0 is a true no-op', () => {
    expect(pf.parkMultiplier({ factor: coors, statType: 'home_runs' })).toBe(1.28);
    expect(pf.parkMultiplier({ factor: coors, statType: 'strikeouts' })).toBe(1);
    expect(pf.parkMultiplier({ factor: { state: 'absent' }, statType: 'home_runs' })).toBe(1);
  });

  it('composes multiplicatively — the property weather depends on', () => {
    const park = pf.parkMultiplier({ factor: coors, statType: 'home_runs' });
    const a = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: { multiplier: park * 1.08 } });
    const b = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: { multiplier: park } });
    expect(a.delta).toBeGreaterThan(b.delta);
  });

  it('DIRECTIONAL BY PROP-OWNER — the sign lives in the STAT, not the park', () => {
    expect(pf.parkMultiplier({ factor: coors, statType: 'home_runs' }))
      .toBe(pf.parkMultiplier({ factor: coors, statType: 'home_runs_allowed' }));
  });

  it('fires at a strong park and mirrors on the under', () => {
    const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(coors, 'home_runs') });
    const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'home_runs', environment: env(coors, 'home_runs') });
    expect(over.delta).toBeGreaterThan(0);
    expect(under.delta).toBeCloseTo(-over.delta, 3);
    expect(over.adjustments[0].venue).toBe('Coors Field');
  });

  it('does NOTHING at a thin park, or for a stat the park is silent on', () => {
    const thin = { state: 'absent', venue: 'New Park', weather_na: false };
    expect(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(thin, 'home_runs') }).delta).toBe(0);
    expect(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'strikeouts', environment: env(coors, 'strikeouts') }).delta).toBe(0);
  });

  it('the environment nudge is capped — a park is a lean, not a re-forecast', () => {
    const absurd = { state: 'present', hr_base: 5, run_base: 5, venue: 'X', weather_na: false };
    expect(Math.abs(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(absurd, 'home_runs') }).delta)).toBeLessThan(0.09);
  });
});
