/* ============================================================
   Session 74 — PUBLIC PARK BASE (source-pluggable) + GAME-CONTEXT CAPTURE.
   ============================================================ */

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

describe('public base — commodity, and labelled as such', () => {
  it('converts the 100-indexed public table to a multiplier', () => {
    const col = pb.resolveParkBase({ teamAbbr: 'COL' });
    expect(col.state).toBe('present');
    expect(col.hr_base).toBe(1.28); // 128 → 1.28
    expect(col.run_base).toBe(1.2);
  });

  it('NEVER claims the public number is proprietary', () => {
    const col = pb.resolveParkBase({ teamAbbr: 'COL' });
    expect(col.provenance.proprietary).toBe(false);
    expect(col.provenance.label).toBe('PUBLIC PARK FACTOR');
    expect(col.provenance.note).toMatch(/Not a VYNDR derivation/);
  });

  it('a pitcher park reads below 1', () => {
    expect(pb.resolveParkBase({ teamAbbr: 'SF' }).hr_base).toBeLessThan(1);
  });

  it('HONEST-ABSENT where even the PUBLIC factor is thin/unstable', () => {
    // A relocated club in a temporary venue: a public number here is no more
    // trustworthy than ours would be.
    for (const t of ['ATH', 'SAC']) {
      const r = pb.resolveParkBase({ teamAbbr: t });
      expect(r.state).toBe('absent');
      expect(r.hr_base).toBeNull();
      expect(r.reason).toMatch(/thin\/unstable/);
    }
  });

  it('unknown team → absent, never a neutral guess', () => {
    expect(pb.resolveParkBase({ teamAbbr: 'ZZZ' }).state).toBe('absent');
    expect(pb.resolveParkBase({}).state).toBe('absent');
  });

  it('flags domes weather_na while the park factor STILL applies', () => {
    const tb = pb.resolveParkBase({ teamAbbr: 'TB' });
    expect(tb.weather_na).toBe(true);
    expect(tb.state).toBe('present'); // N/A is not absent
  });
});

describe('SOURCE-PLUGGABLE — same slot, no rearchitecting', () => {
  const derived = { state: 'present', hr_base: 1.11, run_base: 1.05, venue: 'Coors Field', weather_na: false, games: 162, seasons: 4 };

  it('self-derived swaps into the same shape and IS proprietary', () => {
    const d = pb.resolveParkBase({ teamAbbr: 'COL', source: 'derived', derived });
    expect(d.state).toBe('present');
    expect(d.hr_base).toBe(1.11);
    expect(d.provenance.proprietary).toBe(true);
    expect(d.provenance.label).toBe('VYNDR PARK FACTOR');
    // identical keys to the public answer → callers never branch on source
    const p = pb.resolveParkBase({ teamAbbr: 'COL' });
    expect(Object.keys(d)).toEqual(expect.arrayContaining(Object.keys(p)));
  });

  it('derived-but-unavailable is absent, never silently falls back', () => {
    const r = pb.resolveParkBase({ teamAbbr: 'COL', source: 'derived', derived: null });
    expect(r.state).toBe('absent');
    expect(r.reason).toMatch(/self-derived factor not available/);
  });
});

describe('composes into the challenger, directional by prop-owner', () => {
  const env = (b, stat) => ({ multiplier: pf.parkMultiplier({ factor: b, statType: stat }), label: b.provenance.label, venue: b.venue, weather_na: b.weather_na });
  const col = pb.resolveParkBase({ teamAbbr: 'COL' });

  it('hitter HR and pitcher HR-allowed move the SAME way at the same park', () => {
    const h = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(col, 'home_runs') });
    const p = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs_allowed', environment: env(col, 'home_runs_allowed') });
    expect(h.delta).toBeGreaterThan(0);
    expect(p.delta).toBe(h.delta);
  });

  it('mirrors on the under and does nothing for an unrelated stat', () => {
    const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(col, 'home_runs') });
    const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'home_runs', environment: env(col, 'home_runs') });
    expect(under.delta).toBeCloseTo(-over.delta, 3);
    expect(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'strikeouts', environment: env(col, 'strikeouts') }).delta).toBe(0);
  });

  it('an absent park adjusts nothing', () => {
    const ath = pb.resolveParkBase({ teamAbbr: 'ATH' });
    expect(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(ath, 'home_runs') }).delta).toBe(0);
  });
});

describe('game-context capture — game grain, named purpose, finals only', () => {
  const payload = {
    dates: [{
      date: '2025-07-19',
      games: [
        { gamePk: 777094, season: '2025', status: { detailedState: 'Final' },
          venue: { id: 14, name: 'Rogers Centre' },
          teams: { home: { team: { name: 'Toronto Blue Jays' } }, away: { team: { name: 'New York Yankees' } } },
          linescore: { teams: { home: { runs: 5 }, away: { runs: 4 } } },
          scoringPlays: [
            { result: { event: 'Home Run' }, about: { halfInning: 'bottom' } },
            { result: { event: 'Home Run' }, about: { halfInning: 'top' } },
            { result: { event: 'Single' }, about: { halfInning: 'top' } },
          ] },
        { gamePk: 777095, season: '2025', status: { detailedState: 'In Progress' },
          venue: { id: 15, name: 'Other Park' }, linescore: { teams: { home: { runs: 2 }, away: { runs: 1 } } } },
      ],
    }],
  };

  it('one row per game, keyed for dedupe', () => {
    const rows = gc.rowsFromSchedule(payload);
    expect(rows).toHaveLength(1); // the in-progress game is excluded
    expect(rows[0].game_id).toBe('mlb:777094');
    expect(new Set(rows.map((r) => r.game_id)).size).toBe(rows.length);
  });

  it('EXCLUDES non-final games — in-progress totals are not results', () => {
    expect(gc.rowsFromSchedule(payload).every((r) => r.status === 'Final')).toBe(true);
  });

  it('captures exactly the NAMED-PURPOSE fields', () => {
    const r = gc.rowsFromSchedule(payload)[0];
    expect(r.venue_id).toBe(14);          // → self-derived park factors
    expect(r.total_runs).toBe(9);         // → run-environment factors
    expect(r.total_hr).toBe(2);           // → HR park factors
    expect(r.home_hr).toBe(1);
    expect(r.away_hr).toBe(1);
    // No speculative fields hoarded "just in case".
    expect(Object.keys(r).sort()).toEqual([
      'away_hr', 'away_runs', 'away_team', 'game_date', 'game_id', 'home_hr',
      'home_runs', 'home_team', 'season', 'sport', 'status', 'total_hr',
      'total_runs', 'venue_id', 'venue_name',
    ]);
  });

  it('HR totals come from scoring plays — every HR scores, so none are missed', () => {
    const r = gc.rowsFromSchedule(payload)[0];
    expect(r.total_hr).toBe(2); // the Single is not counted
  });

  it('a game with no scoring plays reports HR as ABSENT, not zero', () => {
    const noPlays = { dates: [{ date: '2025-07-19', games: [{ gamePk: 1, status: { detailedState: 'Final' }, venue: { id: 1 }, linescore: { teams: { home: { runs: 0 }, away: { runs: 0 } } } }] }] };
    expect(gc.rowsFromSchedule(noPlays)[0].total_hr).toBeNull();
  });

  it('upserts on game_id — capture is idempotent per date', async () => {
    let opts = null;
    const sb = { from: () => ({ upsert: async (_r, o) => { opts = o; return { error: null }; } }) };
    await gc.captureDate('2025-07-19', { sb, fetchJson: async () => payload });
    expect(opts.onConflict).toBe('game_id');
  });

  it('one call per DATE — not per game, not per prop', () => {
    expect(gc.SCHEDULE_URL('2025-07-19')).toMatch(/startDate=2025-07-19&endDate=2025-07-19/);
    expect(gc.SCHEDULE_URL('2025-07-19')).toMatch(/hydrate=linescore,venue,scoringplays/);
  });
});

describe('accrual target — "later" is measurable', () => {
  it('states the per-venue floor and the season estimate', async () => {
    const sb = { from: () => ({ select: () => ({ eq: () => ({ limit: async () => ({ data: [
      ...Array(120).fill({ venue_id: 14, venue_name: 'Rogers Centre' }),
      ...Array(160).fill({ venue_id: 19, venue_name: 'Coors Field' }),
    ], error: null }) }) }) }) };
    const s = await gc.accrualStatus({ sb });
    expect(s.min_games_per_venue).toBe(pf.MIN_GAMES);
    expect(s.venues_ready).toBe(1);           // Coors has 160 ≥ 150
    expect(s.seasons_needed_estimate).toBeGreaterThan(1); // ~2 seasons
    expect(s.venues.find((v) => v.venue_id === 14).remaining).toBe(30);
  });
});
