import { beforeEach, describe, expect, it, vi } from 'vitest';

const poolQuery = vi.fn();

vi.mock('../../db', () => ({ default: { query: poolQuery, end: vi.fn() } }));
vi.mock('../../services/grok', () => ({ generateForecast: vi.fn(), generateTeamProps: vi.fn() }));
vi.mock('../../models/forecast', () => ({ getCachedForecast: vi.fn(), cacheForecast: vi.fn(), updateCachedOdds: vi.fn() }));
vi.mock('../../services/forecast-builder', () => ({ buildForecastFromCuratedEvent: vi.fn() }));
vi.mock('../../services/canonical-names', () => ({
  resolveCanonicalName: vi.fn((name: string) => name),
  getTeamRoster: vi.fn((teamShort: string, league: string) => {
    if (league === 'mlb' && teamShort === 'NYM') return ['Juan Soto', 'Francisco Lindor', 'David Peterson'];
    if (league === 'nba' && teamShort === 'NYK') return ['Jalen Brunson', 'Karl-Anthony Towns', 'Mikal Bridges'];
    return [];
  }),
}));
vi.mock('../../services/rie/signals/mlb-phase-signal', () => ({ mlbPhaseSignal: { collect: vi.fn() } }));
vi.mock('../../services/mlb-snapshot', () => ({
  buildStoredMlbSnapshotFromPhase: vi.fn((phase: any) => phase),
  summarizeMlbOperationalAlerts: vi.fn(() => []),
}));
vi.mock('../../services/clv-picks', () => ({ recordClvPick: vi.fn() }));
vi.mock('../../services/insight-source', () => ({
  fetchInsightSourceData: vi.fn(async () => ({ sharpMoves: [], lineMovements: [] })),
}));

describe('weather-report', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.resetModules();
    delete process.env.MLB_MARKETS_ENABLED;
  });

  it('generates MLB_F5 by default when the env flag is unset', async () => {
    const { shouldGenerateMlbF5 } = await import('../weather-report');

    expect(shouldGenerateMlbF5({
      event_id: 'evt1',
      league: 'mlb',
      home_team: 'Dodgers',
      away_team: 'Giants',
      home_short: 'LAD',
      away_short: 'SF',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: null,
      spread: null,
      total: null,
      prop_count: 10,
    } as any, [])).toBe(true);
  });

  it('does not generate MLB_F5 when MLB_MARKETS_ENABLED is explicitly false', async () => {
    process.env.MLB_MARKETS_ENABLED = 'false';
    const { shouldGenerateMlbF5 } = await import('../weather-report');

    expect(shouldGenerateMlbF5({
      event_id: 'evt1b',
      league: 'mlb',
      home_team: 'Dodgers',
      away_team: 'Giants',
      home_short: 'LAD',
      away_short: 'SF',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: null,
      spread: null,
      total: null,
      prop_count: 10,
    } as any, [])).toBe(false);
  });

  it('generates MLB_F5 only for mlb events without an existing MLB_F5 row', async () => {
    process.env.MLB_MARKETS_ENABLED = 'true';
    const { shouldGenerateMlbF5 } = await import('../weather-report');

    const mlbEvent = {
      event_id: 'evt1',
      league: 'mlb',
      home_team: 'Dodgers',
      away_team: 'Giants',
      home_short: 'LAD',
      away_short: 'SF',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: null,
      spread: null,
      total: null,
      prop_count: 10,
    };

    expect(shouldGenerateMlbF5(mlbEvent as any, [])).toBe(true);
    expect(shouldGenerateMlbF5(mlbEvent as any, [{ forecast_type: 'MLB_F5' }])).toBe(false);
    expect(shouldGenerateMlbF5({ ...mlbEvent, league: 'nba' } as any, [])).toBe(false);
  });

  it('expects explicit MLB market-family assets by default for MLB events', async () => {
    const { getExpectedAssetTypesForEvent } = await import('../weather-report');

    const expected = getExpectedAssetTypesForEvent({
      event_id: 'evt2',
      league: 'mlb',
      home_team: 'Dodgers',
      away_team: 'Giants',
      home_short: 'LAD',
      away_short: 'SF',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: { home: -140, away: 120 },
      spread: { home: { line: -1.5, odds: 130 }, away: { line: 1.5, odds: -150 } },
      total: { over: { line: 8.5, odds: -110 }, under: { line: 8.5, odds: -110 } },
      prop_count: 10,
    } as any, []);

    expect(expected).toEqual([
      'GAME_MARKETS',
      'GAME_TOTAL',
      'MLB_RUN_LINE',
      'MLB_F5',
      'MLB_F5_SIDE',
      'MLB_F5_TOTAL',
    ]);
  });

  it('identifies missing source-backed MLB assets even when GAME_MARKETS already exists', async () => {
    const { getMissingMlbSourceBackedAssetTypes } = await import('../weather-report');

    const missing = getMissingMlbSourceBackedAssetTypes({
      event_id: 'evt2b',
      league: 'mlb',
      home_team: 'Dodgers',
      away_team: 'Giants',
      home_short: 'LAD',
      away_short: 'SF',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: { home: -140, away: 120 },
      spread: { home: { line: -1.5, odds: 130 }, away: { line: 1.5, odds: -150 } },
      total: { over: { line: 8.5, odds: -110 }, under: { line: 8.5, odds: -110 } },
      prop_count: 10,
    } as any, [
      { forecast_type: 'GAME_MARKETS' },
      { forecast_type: 'MLB_F5' },
    ]);

    expect(missing).toEqual(['GAME_TOTAL', 'MLB_RUN_LINE']);
  });

  it('does not expect source-backed MLB assets when spread and total are absent', async () => {
    const { getExpectedAssetTypesForEvent } = await import('../weather-report');

    const expected = getExpectedAssetTypesForEvent({
      event_id: 'evt2c',
      league: 'mlb',
      home_team: 'Dodgers',
      away_team: 'Giants',
      home_short: 'LAD',
      away_short: 'SF',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: { home: -140, away: 120 },
      spread: null,
      total: null,
      prop_count: 10,
    } as any, []);

    expect(expected).toEqual([
      'GAME_MARKETS',
      'MLB_F5',
      'MLB_F5_SIDE',
      'MLB_F5_TOTAL',
    ]);
  });

  it('does not treat MLB events as fully cached when player props are still missing', async () => {
    const { hasRequiredAssetsForSkip } = await import('../weather-report');

    const mlbEvent = {
      event_id: 'evt3',
      league: 'mlb',
      home_team: 'Dodgers',
      away_team: 'Giants',
      home_short: 'LAD',
      away_short: 'SF',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: null,
      spread: null,
      total: null,
      prop_count: 10,
    };

    expect(hasRequiredAssetsForSkip(mlbEvent as any, [
      { forecast_type: 'GAME_MARKETS' },
      { forecast_type: 'TEAM_PROPS', team_id: 'LAD' },
      { forecast_type: 'TEAM_PROPS', team_id: 'SF' },
      { forecast_type: 'GAME_TOTAL' },
      { forecast_type: 'MLB_RUN_LINE' },
      { forecast_type: 'MLB_F5' },
      { forecast_type: 'MLB_F5_SIDE' },
      { forecast_type: 'MLB_F5_TOTAL' },
    ])).toBe(false);

    expect(hasRequiredAssetsForSkip(mlbEvent as any, [
      { forecast_type: 'GAME_MARKETS' },
      { forecast_type: 'TEAM_PROPS', team_id: 'LAD' },
      { forecast_type: 'TEAM_PROPS', team_id: 'SF' },
      { forecast_type: 'GAME_TOTAL' },
      { forecast_type: 'MLB_RUN_LINE' },
      { forecast_type: 'MLB_F5' },
      { forecast_type: 'MLB_F5_SIDE' },
      { forecast_type: 'MLB_F5_TOTAL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'LAD' },
      { forecast_type: 'PLAYER_PROP', team_id: 'LAD' },
      { forecast_type: 'PLAYER_PROP', team_id: 'LAD' },
      { forecast_type: 'PLAYER_PROP', team_id: 'LAD' },
      { forecast_type: 'PLAYER_PROP', team_id: 'LAD' },
      { forecast_type: 'PLAYER_PROP', team_id: 'SF' },
      { forecast_type: 'PLAYER_PROP', team_id: 'SF' },
      { forecast_type: 'PLAYER_PROP', team_id: 'SF' },
      { forecast_type: 'PLAYER_PROP', team_id: 'SF' },
      { forecast_type: 'PLAYER_PROP', team_id: 'SF' },
    ])).toBe(true);
  });

  it('still requires MLB player props for skip checks when explicit MLB markets are disabled', async () => {
    process.env.MLB_MARKETS_ENABLED = 'false';
    const { hasRequiredAssetsForSkip, canReuseTeamPropsAsset } = await import('../weather-report');

    const mlbEvent = {
      event_id: 'evt4',
      league: 'mlb',
      home_team: 'Braves',
      away_team: 'Royals',
      home_short: 'ATL',
      away_short: 'KC',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: null,
      spread: null,
      total: null,
      prop_count: 10,
    };

    expect(hasRequiredAssetsForSkip(mlbEvent as any, [
      { forecast_type: 'GAME_MARKETS' },
      { forecast_type: 'TEAM_PROPS', team_id: 'ATL' },
      { forecast_type: 'TEAM_PROPS', team_id: 'KC' },
    ])).toBe(false);

    expect(hasRequiredAssetsForSkip(mlbEvent as any, [
      { forecast_type: 'GAME_MARKETS' },
      { forecast_type: 'TEAM_PROPS', team_id: 'ATL' },
      { forecast_type: 'TEAM_PROPS', team_id: 'KC' },
      { forecast_type: 'PLAYER_PROP', team_id: 'ATL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'ATL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'ATL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'ATL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'ATL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'KC' },
      { forecast_type: 'PLAYER_PROP', team_id: 'KC' },
      { forecast_type: 'PLAYER_PROP', team_id: 'KC' },
      { forecast_type: 'PLAYER_PROP', team_id: 'KC' },
      { forecast_type: 'PLAYER_PROP', team_id: 'KC' },
    ])).toBe(true);

    expect(canReuseTeamPropsAsset({
      hasTeamProps: true,
      playerPropsCount: 0,
    })).toBe(false);

    expect(canReuseTeamPropsAsset({
      hasTeamProps: true,
      playerPropsCount: 5,
    })).toBe(true);
  });

  it('treats MLB events without spread and total as complete once the first-five family and props exist', async () => {
    const { hasRequiredAssetsForSkip } = await import('../weather-report');

    const mlbEvent = {
      event_id: 'evt4c',
      league: 'mlb',
      home_team: 'Braves',
      away_team: 'Royals',
      home_short: 'ATL',
      away_short: 'KC',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: { home: -125, away: 105 },
      spread: null,
      total: null,
      prop_count: 10,
    };

    expect(hasRequiredAssetsForSkip(mlbEvent as any, [
      { forecast_type: 'GAME_MARKETS' },
      { forecast_type: 'TEAM_PROPS', team_id: 'ATL' },
      { forecast_type: 'TEAM_PROPS', team_id: 'KC' },
      { forecast_type: 'PLAYER_PROP', team_id: 'ATL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'ATL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'ATL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'ATL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'ATL' },
      { forecast_type: 'PLAYER_PROP', team_id: 'KC' },
      { forecast_type: 'PLAYER_PROP', team_id: 'KC' },
      { forecast_type: 'PLAYER_PROP', team_id: 'KC' },
      { forecast_type: 'PLAYER_PROP', team_id: 'KC' },
      { forecast_type: 'PLAYER_PROP', team_id: 'KC' },
      { forecast_type: 'MLB_F5' },
      { forecast_type: 'MLB_F5_SIDE' },
      { forecast_type: 'MLB_F5_TOTAL' },
    ])).toBe(true);
  });

  it('requires player props to treat non-MLB events as reusable', async () => {
    const { hasRequiredAssetsForSkip, canReuseTeamPropsAsset } = await import('../weather-report');

    const nbaEvent = {
      event_id: 'evt4b',
      league: 'nba',
      home_team: 'Knicks',
      away_team: 'Celtics',
      home_short: 'NYK',
      away_short: 'BOS',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: null,
      spread: null,
      total: null,
      prop_count: 10,
    };

    expect(hasRequiredAssetsForSkip(nbaEvent as any, [
      { forecast_type: 'GAME_MARKETS' },
      { forecast_type: 'TEAM_PROPS', team_id: 'NYK' },
      { forecast_type: 'TEAM_PROPS', team_id: 'BOS' },
    ])).toBe(false);

    expect(hasRequiredAssetsForSkip(nbaEvent as any, [
      { forecast_type: 'GAME_MARKETS' },
      { forecast_type: 'TEAM_PROPS', team_id: 'NYK' },
      { forecast_type: 'TEAM_PROPS', team_id: 'BOS' },
      { forecast_type: 'PLAYER_PROP', team_id: 'NYK' },
      { forecast_type: 'PLAYER_PROP', team_id: 'NYK' },
      { forecast_type: 'PLAYER_PROP', team_id: 'NYK' },
      { forecast_type: 'PLAYER_PROP', team_id: 'NYK' },
      { forecast_type: 'PLAYER_PROP', team_id: 'NYK' },
      { forecast_type: 'PLAYER_PROP', team_id: 'BOS' },
      { forecast_type: 'PLAYER_PROP', team_id: 'BOS' },
      { forecast_type: 'PLAYER_PROP', team_id: 'BOS' },
      { forecast_type: 'PLAYER_PROP', team_id: 'BOS' },
      { forecast_type: 'PLAYER_PROP', team_id: 'BOS' },
    ])).toBe(true);

    expect(canReuseTeamPropsAsset({
      hasTeamProps: true,
      playerPropsCount: 0,
    })).toBe(false);
  });

  it('reuses team props per side instead of event-wide player prop presence', async () => {
    const { canReuseTeamPropsAsset } = await import('../weather-report');

    expect(canReuseTeamPropsAsset({
      hasTeamProps: true,
      playerPropsCount: 5,
    })).toBe(true);

    expect(canReuseTeamPropsAsset({
      hasTeamProps: true,
      playerPropsCount: 4,
    })).toBe(false);
  });

  it('keeps non-EU leagues today-only but lets EU leagues use the lookahead window', async () => {
    const { isWeatherReportEventInWindow } = await import('../weather-report');
    const now = new Date('2026-04-15T16:00:00.000Z');

    expect(isWeatherReportEventInWindow('nba', '2026-04-15T23:00:00.000Z', now)).toBe(true);
    expect(isWeatherReportEventInWindow('nba', '2026-04-16T23:00:00.000Z', now)).toBe(false);
    expect(isWeatherReportEventInWindow('epl', '2026-04-18T11:30:00.000Z', now)).toBe(true);
    expect(isWeatherReportEventInWindow('epl', '2026-05-01T11:30:00.000Z', now)).toBe(false);
  });

  it('requires real odds before persisting extracted player props', async () => {
    const { shouldPersistExtractedPlayerProp } = await import('../weather-report');

    expect(shouldPersistExtractedPlayerProp({ league: 'mlb', teamShort: 'NYM', canonicalPlayer: 'Juan Soto', odds: -115 })).toBe(true);
    expect(shouldPersistExtractedPlayerProp({ league: 'mlb', teamShort: 'NYM', canonicalPlayer: 'Juan Soto', odds: null })).toBe(false);
    expect(shouldPersistExtractedPlayerProp({ league: 'mlb', teamShort: 'NYM', canonicalPlayer: 'Marcus Semien', odds: -105 })).toBe(false);
    expect(shouldPersistExtractedPlayerProp({ league: 'nba', teamShort: 'NYK', canonicalPlayer: 'Jalen Brunson', odds: 120 })).toBe(true);
    expect(shouldPersistExtractedPlayerProp({ league: 'nba', teamShort: 'NYK', canonicalPlayer: 'Kelly Oubre Jr', odds: 120 })).toBe(false);
    expect(shouldPersistExtractedPlayerProp({ league: 'nba', teamShort: 'NYK', canonicalPlayer: 'Jalen Brunson', odds: null })).toBe(false);
  });

  it('keeps source-backed team bundle props even when they do not qualify as standalone player-prop signals', async () => {
    const { buildSourceBackedTeamPropBundleEntry } = await import('../weather-report');

    const prop = buildSourceBackedTeamPropBundleEntry({
      league: 'nba',
      teamName: 'Charlotte Hornets',
      teamShort: 'CHA',
      teamSide: 'away',
      marketLookup: { odds: -128, source: 'fanduel' },
      prop: {
        player: 'Brandon Miller',
        prop: 'Points Under 19.5',
        stat_type: 'points',
        market_line_value: 19.5,
        recommendation: 'under',
        prob: 58,
        projected_stat_value: 17.5,
        edge: 5.8,
      },
    });

    expect(prop).toMatchObject({
      player: 'Brandon Miller',
      prop: 'Points Under 19.5',
      odds: -128,
      market_source: 'fanduel',
      market_implied_probability: 56.1,
      source_backed: true,
      signal_tier: 'COIN_FLIP',
      agreement_label: 'LOW',
      market_quality_label: 'GOOD',
      signal_table_row: null,
    });
  });

  it('keeps source-backed soccer team bundle props when the edge is flat', async () => {
    const { buildSourceBackedTeamPropBundleEntry } = await import('../weather-report');

    const prop = buildSourceBackedTeamPropBundleEntry({
      league: 'epl',
      teamName: 'Chelsea',
      teamShort: 'CHE',
      teamSide: 'away',
      marketLookup: { odds: -110, source: 'theoddsapi' },
      prop: {
        player: 'Cole Palmer',
        prop: 'Shots Over 2.5',
        stat_type: 'shots',
        market_line_value: 2.5,
        recommendation: 'over',
        prob: 52,
        projected_stat_value: 2.6,
        edge: 0,
      },
    });

    expect(prop).toMatchObject({
      player: 'Cole Palmer',
      prop: 'Shots Over 2.5',
      odds: -110,
      market_source: 'theoddsapi',
      market_implied_probability: 52.4,
      projected_probability: 52,
      edge_pct: 0,
      source_backed: true,
      signal_tier: 'COIN_FLIP',
      agreement_label: 'LOW',
      market_quality_label: 'FAIR',
      signal_table_row: null,
    });
  });

  it('normalizes stored standalone player-prop display fields from metadata instead of raw extractor text', async () => {
    const { buildStoredPlayerPropDisplayFields } = await import('../../services/player-prop-storage');

    expect(buildStoredPlayerPropDisplayFields({
      league: 'nhl',
      propText: 'Shots on Goal 2.5',
      statType: 'shots_on_goal',
      normalizedStatType: 'shots_on_goal',
      forecastDirection: 'UNDER',
      marketLine: 2.5,
      odds: -105,
    })).toEqual({
      prop: 'Shots on Goal Under 2.5',
      forecast: 'Shots on Goal Under 2.5',
      propType: 'Shots on Goal',
      sportsbookDisplay: 'Shots on Goal Under 2.5 (-105)',
    });
  });

  it('summarizes team-prop persistence drop-offs before extracted player props are stored', async () => {
    const {
      buildPlayerPropOddsLookupKey,
      summarizeTeamPropsPersistenceDiagnostics,
    } = await import('../weather-report');

    const diagnostics = summarizeTeamPropsPersistenceDiagnostics({
      league: 'mlb',
      teamName: 'New York Mets',
      teamShort: 'NYM',
      teamSide: 'home',
      propsResult: {
        metadata: {
          mlb_candidate_count: 12,
          mlb_publishable_candidate_count: 7,
          mlb_feed_row_count: 24,
        },
        props: [
          {
            player: 'Juan Soto',
            prop: 'Hits Over 1.5',
            stat_type: 'hits',
            market_line_value: 1.5,
            recommendation: 'over',
            prob: 62,
          },
          {
            player: 'Marcus Semien',
            prop: 'Hits Over 1.5',
            stat_type: 'hits',
            market_line_value: 1.5,
            recommendation: 'over',
            prob: 59,
          },
          {
            player: 'David Peterson',
            prop: 'Strikeouts Over 5.5',
            stat_type: 'strikeouts',
            market_line_value: 5.5,
            recommendation: 'over',
            prob: 57,
          },
        ],
      },
      propOddsLookup: new Map([
        [buildPlayerPropOddsLookupKey('Juan Soto', 'hits', 1.5, 'over'), { odds: -118, source: 'fanduel' }],
        [buildPlayerPropOddsLookupKey('Marcus Semien', 'hits', 1.5, 'over'), { odds: -105, source: 'draftkings' }],
      ]),
    });

    expect(diagnostics.filteredProps).toHaveLength(1);
    expect(diagnostics.filteredProps[0]).toMatchObject({
      player: 'Juan Soto',
      odds: -118,
      market_source: 'fanduel',
    });
    expect(diagnostics.diagnostics).toMatchObject({
      rawPropCount: 3,
      filteredPropCount: 1,
      publishablePropCount: 0,
      playerPropsStored: 0,
      droppedMissingPricing: 1,
      droppedNonRoster: 1,
      storeFailures: 0,
      candidateCount: 12,
      publishableCandidateCount: 7,
      feedRowCount: 24,
    });
    expect(diagnostics.diagnostics.missingPricingPlayers).toEqual(['David Peterson']);
    expect(diagnostics.diagnostics.nonRosterPlayers).toEqual(['Marcus Semien']);
  });

  it('uses normalized MLB market rows to recover one-way pricing the generic lookup misses', async () => {
    const { summarizeTeamPropsPersistenceDiagnostics } = await import('../weather-report');

    const diagnostics = summarizeTeamPropsPersistenceDiagnostics({
      league: 'mlb',
      teamName: 'Miami Marlins',
      teamShort: 'MIA',
      teamSide: 'home',
      propsResult: {
        metadata: {
          mlb_candidate_count: 27,
          mlb_publishable_candidate_count: 12,
          mlb_feed_row_count: 2366,
        },
        props: [
          {
            player: 'Sandy Alcantara',
            prop: 'Strikeouts Under 3.5',
            stat_type: 'pitching_strikeouts',
            market_line_value: 3.5,
            recommendation: 'under',
            prob: 59,
          },
          {
            player: 'Matthew Liberatore',
            prop: 'Outs Recorded Under 15.5',
            stat_type: 'pitching_outs',
            market_line_value: 15.5,
            recommendation: 'under',
            prob: 57,
          },
        ],
      },
      propOddsLookup: new Map(),
      mlbOddsLookup: new Map([
        ['sandy alcantara|pitching_strikeouts|3.5', { over: null, under: 105, primarySource: 'draftkings', completenessStatus: 'incomplete' }],
        ['matthew liberatore|pitching_outs|15.5', { over: null, under: -156, primarySource: 'draftkings', completenessStatus: 'incomplete' }],
      ]),
    });

    expect(diagnostics.filteredProps).toHaveLength(2);
    expect(diagnostics.filteredProps).toEqual(expect.arrayContaining([
      expect.objectContaining({
        player: 'Sandy Alcantara',
        odds: 105,
        market_source: 'draftkings',
      }),
      expect.objectContaining({
        player: 'Matthew Liberatore',
        odds: -156,
        market_source: 'draftkings',
      }),
    ]));
    expect(diagnostics.diagnostics.droppedMissingPricing).toBe(0);
    expect(diagnostics.diagnostics.missingPricingPlayers).toEqual([]);
  });

  it('uses MLB candidate odds and stat normalization when normalized rows are missing', async () => {
    const { summarizeTeamPropsPersistenceDiagnostics } = await import('../weather-report');

    const diagnostics = summarizeTeamPropsPersistenceDiagnostics({
      league: 'mlb',
      teamName: 'Milwaukee Brewers',
      teamShort: 'MIL',
      teamSide: 'home',
      propsResult: {
        props: [
          {
            player: 'Jacob Misiorowski',
            prop: 'Walks Allowed Under 2.5',
            stat_type: 'walks_allowed',
            market_line_value: 2.5,
            recommendation: 'under',
            prob: 58,
          },
          {
            player: 'Joey Ortiz',
            prop: 'RBIs Over 0.5',
            stat_type: 'RBIs',
            market_line_value: 0.5,
            recommendation: 'over',
            prob: 56,
          },
        ],
      },
      propOddsLookup: new Map(),
      mlbOddsLookup: new Map(),
      mlbCandidateOddsLookup: new Map([
        ['jacob misiorowski|pitching_basesonballs|2.5', { over: null, under: -125, primarySource: 'draftkings', completenessStatus: 'incomplete' }],
        ['joey ortiz|batting_rbi|0.5', { over: 190, under: -450, primarySource: 'fanduel', completenessStatus: 'source_complete' }],
      ]),
    });

    expect(diagnostics.filteredProps).toEqual(expect.arrayContaining([
      expect.objectContaining({
        player: 'Jacob Misiorowski',
        odds: -125,
        market_source: 'draftkings',
      }),
      expect.objectContaining({
        player: 'Joey Ortiz',
        odds: 190,
        market_source: 'fanduel',
      }),
    ]));
    expect(diagnostics.filteredProps).toHaveLength(2);
    expect(diagnostics.diagnostics.droppedMissingPricing).toBe(0);
  });

  it('falls back to a unique MLB player-line candidate when the stat type alias misses the exact odds key', async () => {
    const { summarizeTeamPropsPersistenceDiagnostics } = await import('../weather-report');

    const diagnostics = summarizeTeamPropsPersistenceDiagnostics({
      league: 'mlb',
      teamName: 'Los Angeles Dodgers',
      teamShort: 'LAD',
      teamSide: 'home',
      propsResult: {
        props: [
          {
            player: 'Yoshinobu Yamamoto',
            prop: 'Strikeouts Under 6.5',
            stat_type: 'strikeouts',
            market_line_value: 6.5,
            recommendation: 'under',
            prob: 58.5,
          },
        ],
      },
      propOddsLookup: new Map(),
      mlbOddsLookup: new Map(),
      mlbCandidateOddsLookup: new Map([
        ['yoshinobu yamamoto|pitching_strikeouts|6.5', { over: null, under: -117, primarySource: 'player_prop_line', completenessStatus: 'incomplete' }],
      ]),
    });

    expect(diagnostics.filteredProps).toEqual([
      expect.objectContaining({
        player: 'Yoshinobu Yamamoto',
        odds: -117,
        market_source: 'player_prop_line',
      }),
    ]);
    expect(diagnostics.diagnostics.droppedMissingPricing).toBe(0);
    expect(diagnostics.diagnostics.missingPricingPlayers).toEqual([]);
  });

  it('maps PlayerPropLine rows into a reusable cross-sport odds lookup', async () => {
    poolQuery.mockResolvedValueOnce({
      rows: [
        {
          playerExternalId: 'KELLY_OUBRE_JR_1_NBA',
          propType: 'steals',
          lineValue: 1.5,
          oddsAmerican: -200,
          market: 'under',
          vendor: 'fanduel',
          raw: { side: 'under', bookmaker: 'fanduel' },
        },
      ],
    });

    const { fetchEventPlayerPropOddsLookup, buildPlayerPropOddsLookupKey } = await import('../weather-report');

    const lookup = await fetchEventPlayerPropOddsLookup({
      event_id: 'evt5',
      league: 'nba',
      home_team: 'Hornets',
      away_team: 'Sixers',
      home_short: 'CHA',
      away_short: 'PHI',
      starts_at: '2026-03-28T22:00:00.000Z',
      moneyline: null,
      spread: null,
      total: null,
      prop_count: 10,
    } as any);

    expect(lookup.get(buildPlayerPropOddsLookupKey('Kelly Oubre Jr', 'steals', 1.5, 'under'))).toEqual({
      odds: -200,
      source: 'fanduel',
    });
  });

  it('rehydrates player props from cached team props without rerunning grok', async () => {
    const { rehydrateCachedTeamPropsForTeam } = await import('../weather-report');
    const { generateTeamProps } = await import('../../services/grok');

    poolQuery.mockImplementation(async (sql: string) => {
      if (sql.includes('FROM rm_team_props_cache')) {
        return {
          rows: [{
            props_data: {
              team: 'New York Mets',
              props: [
                {
                  player: 'Juan Soto',
                  prop: 'Hits Over 1.5',
                  recommendation: 'over',
                  reasoning: 'Cached team bundle already has the prop.',
                  edge: 14.8,
                  prob: 71,
                  odds: -115,
                  market_line_value: 1.5,
                  projected_stat_value: 1.9,
                  stat_type: 'batting_hits',
                },
              ],
            },
          }],
        };
      }
      if (sql.includes('FROM "PlayerPropLine"')) {
        return {
          rows: [{
            playerExternalId: 'JUAN_SOTO_1_MLB',
            propType: 'batting_hits',
            lineValue: 1.5,
            oddsAmerican: -115,
            market: 'over',
            vendor: 'fanduel',
            raw: { side: 'over', bookmaker: 'fanduel' },
          }],
        };
      }
      if (sql.includes('INSERT INTO rm_forecast_precomputed')) {
        return { rows: [{ id: 'asset-1' }] };
      }
      if (sql.includes('INSERT INTO rm_team_props_cache')) {
        return { rows: [] };
      }
      return { rows: [] };
    });

    const result = await rehydrateCachedTeamPropsForTeam({
      event_id: 'evt-cache-1',
      league: 'mlb',
      home_team: 'New York Mets',
      away_team: 'Atlanta Braves',
      home_short: 'NYM',
      away_short: 'ATL',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: null,
      spread: null,
      total: null,
      prop_count: 10,
    } as any, 'home', '2026-03-27', 'run-1');

    expect(result).toMatchObject({
      reusedCache: true,
      playerPropsExtracted: 1,
      assetWrites: 1,
    });
    expect(generateTeamProps).not.toHaveBeenCalled();
  });

  it('builds prop highlights from active player props before falling back to team bundles', async () => {
    const { buildAssetBackedPropHighlights } = await import('../weather-report');

    poolQuery.mockImplementation(async (sql: string) => {
      if (sql.includes('SELECT player_name, forecast_payload, confidence_score')) {
        return {
          rows: [
            {
              player_name: 'Jalen Brunson',
              forecast_payload: {
                prop: 'Points Over 27.5',
                recommendation: 'over',
                reasoning: 'Clean edge.',
              },
              confidence_score: 81,
            },
          ],
        };
      }
      if (sql.includes('SELECT forecast_payload')) {
        throw new Error('team-props fallback should not run when player props exist');
      }
      return { rows: [] };
    });

    await expect(buildAssetBackedPropHighlights('evt-highlights-1')).resolves.toEqual([
      {
        player: 'Jalen Brunson',
        prop: 'Points Over 27.5',
        recommendation: 'over',
        reasoning: 'Clean edge.',
      },
    ]);
  });

  it('hydrates empty stored prop_highlights from team bundles when player props are unavailable', async () => {
    const { hydrateStoredPropHighlights } = await import('../weather-report');

    poolQuery.mockImplementation(async (sql: string, params?: any[]) => {
      if (sql.includes('SELECT player_name, forecast_payload, confidence_score')) {
        return { rows: [] };
      }
      if (sql.includes('SELECT forecast_payload')) {
        return {
          rows: [
            {
              forecast_payload: {
                props: [
                  {
                    player: 'Karl-Anthony Towns',
                    prop: 'Rebounds Over 11.5',
                    recommendation: 'over',
                    reasoning: 'Board edge held up.',
                    prob: 68,
                    market_line_value: 11.5,
                    forecast_direction: 'OVER',
                    agreement_score: 72,
                    market_quality_label: 'GOOD',
                  },
                ],
              },
            },
          ],
        };
      }
      if (sql.includes('UPDATE rm_forecast_cache')) {
        expect(params?.[1]).toBe('evt-highlights-2');
        expect(JSON.parse(String(params?.[0]))).toEqual([
          {
            player: 'Karl-Anthony Towns',
            prop: 'Rebounds Over 11.5',
            recommendation: 'over',
            reasoning: 'Board edge held up.',
          },
        ]);
        return { rowCount: 1 };
      }
      if (sql.includes('UPDATE rm_forecast_precomputed')) {
        expect(params?.[1]).toBe('evt-highlights-2');
        expect(JSON.parse(String(params?.[0]))).toEqual([
          {
            player: 'Karl-Anthony Towns',
            prop: 'Rebounds Over 11.5',
            recommendation: 'over',
            reasoning: 'Board edge held up.',
          },
        ]);
        return { rowCount: 1 };
      }
      return { rows: [] };
    });

    await expect(hydrateStoredPropHighlights('evt-highlights-2')).resolves.toBeUndefined();
    expect(poolQuery).toHaveBeenCalled();
  });

  it('drops hollow team-bundle props when building prop highlights', async () => {
    const { buildAssetBackedPropHighlights } = await import('../weather-report');

    poolQuery.mockImplementation(async (sql: string) => {
      if (sql.includes('SELECT player_name, forecast_payload, confidence_score')) {
        return { rows: [] };
      }
      if (sql.includes('SELECT forecast_payload')) {
        return {
          rows: [
            {
              forecast_payload: {
                props: [
                  {
                    player: 'Bad Prop',
                    prop: 'Assists Under 3.5',
                    recommendation: 'under',
                    prob: 64,
                    market_line_value: 3.5,
                  },
                  {
                    player: 'Good Prop',
                    prop: 'Points Over 22.5',
                    recommendation: 'over',
                    reasoning: 'Clean enough.',
                    prob: 67,
                    market_line_value: 22.5,
                    forecast_direction: 'OVER',
                    agreement_score: 71,
                    market_quality_label: 'GOOD',
                  },
                ],
              },
            },
          ],
        };
      }
      return { rows: [] };
    });

    await expect(buildAssetBackedPropHighlights('evt-highlights-3')).resolves.toEqual([
      {
        player: 'Good Prop',
        prop: 'Points Over 22.5',
        recommendation: 'over',
        reasoning: 'Clean enough.',
      },
    ]);
  });

  it('skips replay persistence when a newer team-props write already landed', async () => {
    const { replayTeamPropsForTeam } = await import('../weather-report');
    const { generateTeamProps } = await import('../../services/grok');

    vi.mocked(generateTeamProps).mockResolvedValue({
      team: 'New York Mets',
      summary: 'Replay should be skipped.',
      props: [
        {
          player: 'Juan Soto',
          prop: 'Hits Over 1.5',
          recommendation: 'over',
          reasoning: 'Fresh enough elsewhere.',
          edge: 14.8,
          prob: 71,
          odds: -115,
          market_line_value: 1.5,
          projected_stat_value: 1.9,
          stat_type: 'batting_hits',
        },
      ],
    } as any);

    poolQuery.mockImplementation(async (sql: string) => {
      if (sql.includes('SELECT MAX(generated_at) AS latest_generated_at')) {
        return { rows: [{ latest_generated_at: '2099-03-27T19:05:00.000Z' }] };
      }
      if (sql.includes("SET status = 'STALE'")) {
        throw new Error('stale write should not run');
      }
      if (sql.includes('INSERT INTO rm_forecast_precomputed')) {
        throw new Error('persist should not run');
      }
      return { rows: [] };
    });

    const result = await replayTeamPropsForTeam({
      event_id: 'evt-cache-2',
      league: 'mlb',
      home_team: 'New York Mets',
      away_team: 'Atlanta Braves',
      home_short: 'NYM',
      away_short: 'ATL',
      starts_at: '2026-03-27T19:00:00.000Z',
      moneyline: null,
      spread: null,
      total: null,
      prop_count: 10,
    } as any, 'home', '2026-03-27', 'run-2');

    expect(result).toMatchObject({
      success: true,
      playerPropsExtracted: 0,
      assetWrites: 0,
      staleWrites: 0,
    });
  });

  it('only inserts forecast-pick metadata columns that exist in the live schema', async () => {
    const { buildForecastPickInsertSpec } = await import('../weather-report');

    const legacySpec = buildForecastPickInsertSpec(new Set([
      'forecast_asset_id',
      'event_id',
      'pick_type',
      'selection',
      'line_value',
      'odds_snapshot',
      'confidence',
      'edge',
    ]), {
      assetId: 'asset-1',
      eventId: 'evt-1',
      recommendation: 'over',
      line: 27.5,
      odds: -115,
      confidence: 0.72,
      edge: 16.4,
      gradingCategory: 'PLAYER_PROPS',
      signalTier: 'GOOD',
      marketImpliedProbability: 52.4,
      projectedProbability: 68.8,
      projectedOutcome: 29.1,
    });

    expect(legacySpec.sql).not.toContain('grading_category');
    expect(legacySpec.sql).not.toContain('projected_outcome');
    expect(legacySpec.values).toHaveLength(8);

    const enrichedSpec = buildForecastPickInsertSpec(new Set([
      'forecast_asset_id',
      'event_id',
      'pick_type',
      'selection',
      'line_value',
      'odds_snapshot',
      'confidence',
      'edge',
      'grading_category',
      'signal_tier',
      'market_implied_probability',
      'projected_probability',
      'projected_outcome',
    ]), {
      assetId: 'asset-1',
      eventId: 'evt-1',
      recommendation: 'over',
      line: 27.5,
      odds: -115,
      confidence: 0.72,
      edge: 16.4,
      gradingCategory: 'PLAYER_PROPS',
      signalTier: 'GOOD',
      marketImpliedProbability: 52.4,
      projectedProbability: 68.8,
      projectedOutcome: 29.1,
    });

    expect(enrichedSpec.sql).toContain('grading_category');
    expect(enrichedSpec.sql).toContain('projected_outcome');
    expect(enrichedSpec.values).toHaveLength(13);
  });

  it('marks superseded RUNNING rows as failed before creating a new run', async () => {
    poolQuery
      .mockResolvedValueOnce({ rowCount: 2, rows: [{ id: 'old-1' }, { id: 'old-2' }] })
      .mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 'stale-1' }] });

    const { recoverStaleRuns } = await import('../weather-report');

    await expect(recoverStaleRuns('2026-03-28')).resolves.toBe(3);
    expect(poolQuery).toHaveBeenCalledTimes(2);
    expect(String(poolQuery.mock.calls[0]?.[0])).toContain("newer.status IN ('SUCCESS', 'PARTIAL', 'FAILED')");
    expect(String(poolQuery.mock.calls[1]?.[0])).toContain("created_at < NOW() - ($3::int * INTERVAL '1 minute')");
  });
});
