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

const mocked = vi.hoisted(() => ({
  poolQuery: vi.fn(),
  fetchTeamPropMarketCandidates: vi.fn(),
  countMlbPropFeedRows: vi.fn(),
  fetchMlbPropCandidates: vi.fn(),
  collectMlbPhaseSignal: vi.fn(),
  fetch: vi.fn(),
  loadPiffPropsForDate: vi.fn(() => ({})),
  loadTodaysPiffProps: vi.fn(() => ({})),
  loadDigimonPicksForDate: vi.fn(() => ({})),
}));

vi.mock('../../db', () => ({ default: { query: mocked.poolQuery } }));
vi.mock('../piff', () => ({
  getPiffPropsForGame: vi.fn(() => []),
  formatPiffForPrompt: vi.fn(() => ''),
  loadPiffPropsForDate: mocked.loadPiffPropsForDate,
  loadTodaysPiffProps: mocked.loadTodaysPiffProps,
}));
vi.mock('../digimon', () => ({
  getDigimonForGame: vi.fn(() => []),
  formatDigimonForPrompt: vi.fn(() => ''),
  loadDigimonPicksForDate: mocked.loadDigimonPicksForDate,
  loadTodaysDigimonPicks: vi.fn(() => ({})),
}));
vi.mock('../canonical-names', () => ({
  formatRosterForPrompt: vi.fn(() => ''),
  isPlayerOnTeam: vi.fn(() => true),
  resolveCanonicalName: vi.fn((name: string) => name),
}));
vi.mock('../team-prop-market-candidates', () => ({
  fetchTeamPropMarketCandidates: mocked.fetchTeamPropMarketCandidates,
  formatTeamPropMarketCandidatesForPrompt: vi.fn(() => ''),
  validateTeamPropsAgainstMarketCandidates: vi.fn((props) => props),
}));
vi.mock('../mlb-prop-markets', () => ({
  countMlbPropFeedRows: mocked.countMlbPropFeedRows,
  fetchMlbPropCandidates: mocked.fetchMlbPropCandidates,
}));
vi.mock('../rie/signals/mlb-phase-signal', () => ({
  mlbPhaseSignal: { collect: mocked.collectMlbPhaseSignal },
}));

describe('generateTeamProps', () => {
  beforeEach(() => {
    vi.resetModules();
    vi.clearAllMocks();
    vi.stubGlobal('fetch', mocked.fetch);
    mocked.poolQuery.mockResolvedValue({ rows: [] });
    mocked.fetchTeamPropMarketCandidates.mockResolvedValue([]);
    mocked.countMlbPropFeedRows.mockResolvedValue(0);
    mocked.fetchMlbPropCandidates.mockResolvedValue([]);
    mocked.collectMlbPhaseSignal.mockResolvedValue({ available: false, rawData: null });
    mocked.loadPiffPropsForDate.mockReturnValue({});
    mocked.loadTodaysPiffProps.mockReturnValue({});
  });

  it('suppresses non-MLB team props when no priced source-backed markets exist', async () => {
    mocked.fetchTeamPropMarketCandidates.mockResolvedValue([]);

    const { generateTeamProps } = await import('../grok');
    const result = await generateTeamProps({
      teamName: 'Phoenix Suns',
      teamShort: 'PHX',
      opponentName: 'Utah Jazz',
      opponentShort: 'UTA',
      league: 'nba',
      isHome: true,
      startsAt: '2026-03-28T23:30:00.000Z',
      moneyline: { home: -140, away: 120 },
      spread: { home: { line: -3.5 }, away: { line: 3.5 } },
      total: { over: { line: 229.5 }, under: { line: 229.5 } },
    });

    expect(result.props).toEqual([]);
    expect(result.metadata).toMatchObject({
      source_market_candidate_count: 0,
      suppressed_reason: 'no_source_market_candidates',
    });
    expect(result.summary).toContain('source market feed');
    expect(mocked.fetchTeamPropMarketCandidates).toHaveBeenCalledWith(expect.objectContaining({
      teamName: 'Phoenix Suns',
      opponentName: 'Utah Jazz',
      teamShort: 'PHX',
      opponentShort: 'UTA',
    }));
  });

  it('falls back to publishable MLB candidates when the model returns no valid props', async () => {
    mocked.countMlbPropFeedRows.mockResolvedValue(243);
    mocked.fetchMlbPropCandidates
      .mockResolvedValueOnce([
        {
          player: 'Brendan Donovan',
          statType: 'batting_hits',
          marketLineValue: 1.5,
          availableSides: ['over', 'under'],
          overOdds: 115,
          underOdds: -145,
          prop: 'Hits 1.5',
          teamShort: 'STL',
          normalizedStatType: 'batting_hits',
          playerRole: 'batter',
          gameStart: '2026-03-28T23:15:00.000Z',
          marketName: 'Brendan Donovan Hits Over/Under',
          source: 'player_prop_line',
        },
        {
          player: 'Sonny Gray',
          statType: 'pitching_strikeouts',
          marketLineValue: 5.5,
          availableSides: ['over', 'under'],
          overOdds: -110,
          underOdds: -120,
          prop: 'Strikeouts 5.5',
          teamShort: 'STL',
          normalizedStatType: 'pitching_strikeouts',
          playerRole: 'pitcher',
          gameStart: '2026-03-28T23:15:00.000Z',
          marketName: 'Sonny Gray Strikeouts Over/Under',
          source: 'player_prop_line',
        },
      ])
      .mockResolvedValueOnce([
        {
          player: 'Yandy Diaz',
          statType: 'batting_totalBases',
          marketLineValue: 1.5,
          availableSides: ['over', 'under'],
          overOdds: 120,
          underOdds: -150,
          prop: 'Total Bases 1.5',
          teamShort: 'TB',
          normalizedStatType: 'batting_totalBases',
          playerRole: 'batter',
          gameStart: '2026-03-28T23:15:00.000Z',
          marketName: 'Yandy Diaz Total Bases Over/Under',
          source: 'player_prop_line',
        },
      ]);
    mocked.collectMlbPhaseSignal.mockResolvedValue({
      available: true,
      rawData: {
        firstFive: {
          sideScore: 0.61,
          homeStarter: { name: 'Sonny Gray', fip: 3.4, xfip: 3.7, whip: 1.14, kBbPct: 0.21 },
          awayStarter: { name: 'Joe Boyle', fip: 5.2, xfip: 4.9, whip: 1.48, kBbPct: 0.08 },
          homeOffense: { fullWrcPlus: 104, recentWrcPlus: 109, projectedWrcPlus: 112 },
          awayOffense: { fullWrcPlus: 97, recentWrcPlus: 94, projectedWrcPlus: 95 },
        },
        bullpen: {
          sideScore: 0.55,
          homeBullpen: { fip: 3.8, xfip: 3.9, whip: 1.21, kPct: 0.25, workload: { fatigueScore: 0.42 } },
          awayBullpen: { fip: 4.7, xfip: 4.6, whip: 1.34, kPct: 0.21, workload: { fatigueScore: 0.68 } },
        },
        context: {
          sideScore: 0.58,
          weatherRunBias: 0.07,
          lineups: { homeStatus: 'confirmed', awayStatus: 'projected' },
          weather: { temperatureF: 74, windMph: 8, windDirection: 'out', precipitationChance: 10, conditions: 'Clear' },
          venue: { indoor: false },
          travel: { home: { hoursSinceLastGame: 24, travelMiles: 0 }, away: { hoursSinceLastGame: 24, travelMiles: 870 } },
          injuries: { homeSeverity: 0.1, awaySeverity: 0.2 },
        },
      },
    });
    mocked.fetch.mockResolvedValue({
      ok: true,
      json: async () => ({
        choices: [{
          message: {
            content: JSON.stringify({
              team: 'St. Louis Cardinals',
              summary: 'Model summary',
              props: [],
            }),
          },
        }],
        usage: {},
      }),
    });

    const { generateTeamProps } = await import('../grok');
    const result = await generateTeamProps({
      teamName: 'St. Louis Cardinals',
      teamShort: 'STL',
      opponentName: 'Tampa Bay Rays',
      opponentShort: 'TB',
      league: 'mlb',
      isHome: true,
      startsAt: '2026-03-28T23:15:00.000Z',
      moneyline: { home: -118, away: 100 },
      spread: { home: { line: -1.5 }, away: { line: 1.5 } },
      total: { over: { line: 8.5 }, under: { line: 8.5 } },
    });

    expect(result.summary).toBe('Model summary');
    expect(result.metadata).toMatchObject({
      mlb_feed_row_count: 243,
      mlb_candidate_count: 2,
      mlb_publishable_candidate_count: 2,
    });
    expect(result.props).toHaveLength(2);
    const donovanProp = result.props.find((prop) => prop.player === 'Brendan Donovan');
    const grayProp = result.props.find((prop) => prop.player === 'Sonny Gray');

    expect(donovanProp).toMatchObject({
      player: 'Brendan Donovan',
      recommendation: 'over',
      stat_type: 'batting_hits',
      market_line_value: 1.5,
      prob: expect.any(Number),
      edge: expect.any(Number),
    });
    expect(donovanProp?.prop).toContain('Hits Over 1.5');
    expect(donovanProp?.projected_stat_value).toBeUndefined();
    expect(donovanProp?.reasoning).toContain('source-backed MLB fallback');
    expect(donovanProp?.model_context?.phase_support_direction).toBe('over');
    expect(donovanProp?.model_context?.has_modeled_projection).toBe(false);
    expect(grayProp).toMatchObject({
      player: 'Sonny Gray',
      recommendation: 'over',
      stat_type: 'pitching_strikeouts',
      market_line_value: 5.5,
    });
    expect(grayProp?.projected_stat_value).toBeUndefined();
  });

  it('lifts the best suppressed MLB candidates when a team is still below floor', async () => {
    const { __mlbPropInternals } = await import('../grok');
    const selected = __mlbPropInternals.selectMlbCandidatesForPublishing([
      {
        player: 'Sonny Gray',
        statType: 'pitching_strikeouts',
        marketLineValue: 5.5,
        recommendationHint: 'over',
        edge: 0.12,
        prob: 0.62,
        source: 'player_prop_line',
        propLabel: 'Strikeouts 5.5',
        overOdds: -110,
        underOdds: -120,
        publishScore: 0.62,
        suppressionReason: null,
      },
      {
        player: 'Brendan Donovan',
        statType: 'batting_hits',
        marketLineValue: 1.5,
        recommendationHint: 'over',
        edge: 0.08,
        prob: 0.58,
        source: 'player_prop_line',
        propLabel: 'Hits 1.5',
        overOdds: 115,
        underOdds: -145,
        publishScore: 0.58,
        suppressionReason: null,
      },
      {
        player: 'Masyn Winn',
        statType: 'batting_hits',
        marketLineValue: 1.5,
        recommendationHint: 'over',
        edge: 0.03,
        prob: 0.47,
        source: 'player_prop_line',
        propLabel: 'Hits 1.5',
        overOdds: 120,
        underOdds: -150,
        publishScore: 0.47,
        suppressionReason: 'low model confidence',
      },
      {
        player: 'Nolan Arenado',
        statType: 'batting_totalBases',
        marketLineValue: 1.5,
        recommendationHint: 'under',
        edge: 0.04,
        prob: 0.53,
        source: 'player_prop_line',
        propLabel: 'Total Bases 1.5',
        overOdds: null,
        underOdds: -135,
        publishScore: 0.53,
        suppressionReason: 'low-information one-sided hitter under',
      },
      {
        player: 'Willson Contreras',
        statType: 'batting_RBI',
        marketLineValue: 0.5,
        recommendationHint: 'under',
        edge: 0.06,
        prob: 0.57,
        source: 'player_prop_line',
        propLabel: 'RBIs 0.5',
        overOdds: null,
        underOdds: -165,
        publishScore: 0.57,
        suppressionReason: 'low-information binary under',
      },
      {
        player: 'Lars Nootbaar',
        statType: 'batting_runs',
        marketLineValue: 0.5,
        recommendationHint: 'under',
        edge: 0.01,
        prob: 0.51,
        source: 'player_prop_line',
        propLabel: 'Runs 0.5',
        overOdds: null,
        underOdds: -125,
        publishScore: 0.51,
        suppressionReason: 'low-information binary under',
      },
    ], 5);

    expect(selected).toHaveLength(5);
    expect(selected.map((candidate) => candidate.player)).toEqual(expect.arrayContaining([
      'Sonny Gray',
      'Brendan Donovan',
      'Masyn Winn',
      'Nolan Arenado',
      'Willson Contreras',
    ]));
    expect(selected.filter((candidate) => candidate.reactivatedForFloor)).toHaveLength(3);
  });

  it('tops up validated MLB props to five when publishable candidates exist', async () => {
    mocked.countMlbPropFeedRows.mockResolvedValue(243);
    mocked.fetchMlbPropCandidates
      .mockResolvedValueOnce([
        {
          player: 'Brendan Donovan',
          statType: 'batting_hits',
          marketLineValue: 1.5,
          availableSides: ['over', 'under'],
          overOdds: 115,
          underOdds: -145,
          prop: 'Hits 1.5',
          teamShort: 'STL',
          normalizedStatType: 'batting_hits',
          playerRole: 'batter',
          gameStart: '2026-03-28T23:15:00.000Z',
          marketName: 'Brendan Donovan Hits Over/Under',
          source: 'player_prop_line',
        },
        {
          player: 'Sonny Gray',
          statType: 'pitching_strikeouts',
          marketLineValue: 5.5,
          availableSides: ['over', 'under'],
          overOdds: -110,
          underOdds: -120,
          prop: 'Strikeouts 5.5',
          teamShort: 'STL',
          normalizedStatType: 'pitching_strikeouts',
          playerRole: 'pitcher',
          gameStart: '2026-03-28T23:15:00.000Z',
          marketName: 'Sonny Gray Strikeouts Over/Under',
          source: 'player_prop_line',
        },
        {
          player: 'Nolan Arenado',
          statType: 'batting_totalBases',
          marketLineValue: 1.5,
          availableSides: ['over', 'under'],
          overOdds: 120,
          underOdds: -150,
          prop: 'Total Bases 1.5',
          teamShort: 'STL',
          normalizedStatType: 'batting_totalBases',
          playerRole: 'batter',
          gameStart: '2026-03-28T23:15:00.000Z',
          marketName: 'Nolan Arenado Total Bases Over/Under',
          source: 'player_prop_line',
        },
        {
          player: 'Lars Nootbaar',
          statType: 'batting_runs',
          marketLineValue: 0.5,
          availableSides: ['over', 'under'],
          overOdds: 130,
          underOdds: -165,
          prop: 'Runs 0.5',
          teamShort: 'STL',
          normalizedStatType: 'batting_runs',
          playerRole: 'batter',
          gameStart: '2026-03-28T23:15:00.000Z',
          marketName: 'Lars Nootbaar Runs Over/Under',
          source: 'player_prop_line',
        },
        {
          player: 'Willson Contreras',
          statType: 'batting_RBI',
          marketLineValue: 0.5,
          availableSides: ['over', 'under'],
          overOdds: 145,
          underOdds: -185,
          prop: 'RBIs 0.5',
          teamShort: 'STL',
          normalizedStatType: 'batting_RBI',
          playerRole: 'batter',
          gameStart: '2026-03-28T23:15:00.000Z',
          marketName: 'Willson Contreras RBIs Over/Under',
          source: 'player_prop_line',
        },
      ])
      .mockResolvedValueOnce([
        {
          player: 'Yandy Diaz',
          statType: 'batting_totalBases',
          marketLineValue: 1.5,
          availableSides: ['over', 'under'],
          overOdds: 120,
          underOdds: -150,
          prop: 'Total Bases 1.5',
          teamShort: 'TB',
          normalizedStatType: 'batting_totalBases',
          playerRole: 'batter',
          gameStart: '2026-03-28T23:15:00.000Z',
          marketName: 'Yandy Diaz Total Bases Over/Under',
          source: 'player_prop_line',
        },
      ]);
    mocked.collectMlbPhaseSignal.mockResolvedValue({
      available: true,
      rawData: {
        firstFive: {
          sideScore: 0.61,
          homeStarter: { name: 'Sonny Gray', fip: 3.4, xfip: 3.7, whip: 1.14, kBbPct: 0.21 },
          awayStarter: { name: 'Joe Boyle', fip: 5.2, xfip: 4.9, whip: 1.48, kBbPct: 0.08 },
          homeOffense: { fullWrcPlus: 104, recentWrcPlus: 109, projectedWrcPlus: 112 },
          awayOffense: { fullWrcPlus: 97, recentWrcPlus: 94, projectedWrcPlus: 95 },
        },
        bullpen: {
          sideScore: 0.55,
          homeBullpen: { fip: 3.8, xfip: 3.9, whip: 1.21, kPct: 0.25, workload: { fatigueScore: 0.42 } },
          awayBullpen: { fip: 4.7, xfip: 4.6, whip: 1.34, kPct: 0.21, workload: { fatigueScore: 0.68 } },
        },
        context: {
          sideScore: 0.58,
          weatherRunBias: 0.07,
          lineups: { homeStatus: 'confirmed', awayStatus: 'projected' },
          weather: { temperatureF: 74, windMph: 8, windDirection: 'out', precipitationChance: 10, conditions: 'Clear' },
          venue: { indoor: false },
          travel: { home: { hoursSinceLastGame: 24, travelMiles: 0 }, away: { hoursSinceLastGame: 24, travelMiles: 870 } },
          injuries: { homeSeverity: 0.1, awaySeverity: 0.2 },
        },
      },
    });
    mocked.fetch.mockResolvedValue({
      ok: true,
      json: async () => ({
        choices: [{
          message: {
            content: JSON.stringify({
              team: 'St. Louis Cardinals',
              summary: 'Model summary',
              props: [
                {
                  player: 'Brendan Donovan',
                  prop: 'Hits Over 1.5',
                  recommendation: 'over',
                  reasoning: "Rain Man projects Brendan Donovan for ~1.8 hits tonight. That supports O only while current markets are at 1.5 (or better). Here's how the model got there: exact source-backed match. The signal fades if current markets move above ~1.8.",
                  edge: 8.4,
                  prob: 63,
                  projected_stat_value: 1.8,
                  stat_type: 'batting_hits',
                  market_line_value: 1.5,
                },
                {
                  player: 'Sonny Gray',
                  prop: 'Pitching Strikeouts Over 5.5',
                  recommendation: 'over',
                  reasoning: "Rain Man projects Sonny Gray for ~6.3 pitching strikeouts tonight. That supports O only while current markets are at 5.5 (or better). Here's how the model got there: exact source-backed match. The signal fades if current markets move above ~6.3.",
                  edge: 7.5,
                  prob: 61,
                  projected_stat_value: 6.3,
                  stat_type: 'pitching_strikeouts',
                  market_line_value: 5.5,
                },
              ],
            }),
          },
        }],
        usage: {},
      }),
    });

    const { generateTeamProps } = await import('../grok');
    const result = await generateTeamProps({
      teamName: 'St. Louis Cardinals',
      teamShort: 'STL',
      opponentName: 'Tampa Bay Rays',
      opponentShort: 'TB',
      league: 'mlb',
      isHome: true,
      startsAt: '2026-03-28T23:15:00.000Z',
      moneyline: { home: -118, away: 100 },
      spread: { home: { line: -1.5 }, away: { line: 1.5 } },
      total: { over: { line: 8.5 }, under: { line: 8.5 } },
    });

    expect(result.props).toHaveLength(5);
    expect(result.props.map((prop) => prop.player)).toEqual(expect.arrayContaining([
      'Brendan Donovan',
      'Sonny Gray',
      'Nolan Arenado',
      'Lars Nootbaar',
      'Willson Contreras',
    ]));
  });

  it('falls back to exact non-MLB source-backed markets when the model returns no valid props', async () => {
    mocked.fetchTeamPropMarketCandidates.mockResolvedValue([
      {
        player: 'Devin Booker',
        statType: 'points',
        normalizedStatType: 'points',
        marketLineValue: 28.5,
        overOdds: -118,
        underOdds: -102,
        availableSides: ['over', 'under'],
        source: 'fanduel',
        marketName: 'Devin Booker Points Over/Under',
        propLabel: 'Points 28.5',
      },
      {
        player: 'Grayson Allen',
        statType: 'assists',
        normalizedStatType: 'assists',
        marketLineValue: 3.5,
        overOdds: -105,
        underOdds: -115,
        availableSides: ['over', 'under'],
        source: 'draftkings',
        marketName: 'Grayson Allen Assists Over/Under',
        propLabel: 'Assists 3.5',
      },
    ]);
    const piffMap = {
      'nba:PHX': [
        {
          name: 'Devin Booker',
          team: 'PHX',
          stat: 'points',
          line: 28.5,
          edge: 0.11,
          prob: 0.64,
          direction: 'over',
          tier: 2,
          tier_label: 'T2_STRONG',
          dvp_rank: 21,
          dvp_tier: 'EASY',
          is_home: true,
        },
      ],
    };
    mocked.loadTodaysPiffProps.mockReturnValue(piffMap);
    mocked.loadPiffPropsForDate.mockReturnValue(piffMap);
    mocked.fetch.mockResolvedValue({
      ok: true,
      json: async () => ({
        choices: [{
          message: {
            content: JSON.stringify({
              team: 'Phoenix Suns',
              summary: 'Model summary',
              props: [],
            }),
          },
        }],
        usage: {},
      }),
    });

    const { generateTeamProps } = await import('../grok');
    const result = await generateTeamProps({
      teamName: 'Phoenix Suns',
      teamShort: 'PHX',
      opponentName: 'Utah Jazz',
      opponentShort: 'UTA',
      league: 'nba',
      isHome: true,
      startsAt: '2026-03-28T23:30:00.000Z',
      moneyline: { home: -140, away: 120 },
      spread: { home: { line: -3.5 }, away: { line: 3.5 } },
      total: { over: { line: 229.5 }, under: { line: 229.5 } },
    });

    expect(result.summary).toContain('source-backed fallback');
    expect(result.props).toHaveLength(1);
    expect(result.props[0]).toMatchObject({
      player: 'Devin Booker',
      recommendation: 'over',
      stat_type: 'points',
      market_line_value: 28.5,
      projected_stat_value: 31.0,
      prob: 64,
      edge: 11,
      model_context: {
        tier_label: 'T2_STRONG',
        dvp_rank: 21,
        dvp_tier: 'EASY',
        projection_basis: 'source_market_piff_exact',
      },
    });
    expect(result.props[0].reasoning).toContain('Rain Man projects Devin Booker');
    expect(result.props[0].prop).toBe('Points Over 28.5');
  });

  it('does not invent fallback props from exact source-backed markets without PIFF support', async () => {
    mocked.fetchTeamPropMarketCandidates.mockResolvedValue([
      {
        player: 'Devin Booker',
        statType: 'points',
        normalizedStatType: 'points',
        marketLineValue: 28.5,
        overOdds: -118,
        underOdds: -102,
        availableSides: ['over', 'under'],
        source: 'fanduel',
        marketName: 'Devin Booker Points Over/Under',
        propLabel: 'Points 28.5',
      },
    ]);
    mocked.loadTodaysPiffProps.mockReturnValue({});
    mocked.loadPiffPropsForDate.mockReturnValue({});
    mocked.fetch.mockResolvedValue({
      ok: true,
      json: async () => ({
        choices: [{
          message: {
            content: JSON.stringify({
              team: 'Phoenix Suns',
              summary: 'Model summary',
              props: [],
            }),
          },
        }],
        usage: {},
      }),
    });

    const { generateTeamProps } = await import('../grok');
    const result = await generateTeamProps({
      teamName: 'Phoenix Suns',
      teamShort: 'PHX',
      opponentName: 'Utah Jazz',
      opponentShort: 'UTA',
      league: 'nba',
      isHome: true,
      startsAt: '2026-03-28T23:30:00.000Z',
      moneyline: { home: -140, away: 120 },
      spread: { home: { line: -3.5 }, away: { line: 3.5 } },
      total: { over: { line: 229.5 }, under: { line: 229.5 } },
    });

    expect(result.props).toHaveLength(1);
    expect(result.props[0]).toMatchObject({
      player: 'Devin Booker',
      recommendation: 'over',
      market_line_value: 28.5,
      signal_tier: 'FAIR',
      forecast_direction: 'OVER',
    });
  });

  it('enriches validated exact source-backed props with PIFF probability and signal metadata', async () => {
    mocked.fetchTeamPropMarketCandidates.mockResolvedValue([
      {
        player: 'Devin Booker',
        statType: 'points',
        normalizedStatType: 'points',
        marketLineValue: 28.5,
        overOdds: -118,
        underOdds: -102,
        availableSides: ['over', 'under'],
        source: 'fanduel',
        marketName: 'Devin Booker Points Over/Under',
        propLabel: 'Points 28.5',
        completenessStatus: 'source_complete',
      },
    ]);
    const piffMap = {
      'nba:PHX': [
        {
          name: 'Devin Booker',
          team: 'PHX',
          stat: 'points',
          line: 28.5,
          edge: 0.11,
          prob: 0.64,
          direction: 'over',
          tier: 2,
          tier_label: 'T2_STRONG',
          dvp_rank: 21,
          dvp_tier: 'EASY',
          is_home: true,
        },
      ],
    };
    mocked.loadTodaysPiffProps.mockReturnValue(piffMap);
    mocked.loadPiffPropsForDate.mockReturnValue(piffMap);
    mocked.fetch.mockResolvedValue({
      ok: true,
      json: async () => ({
        choices: [{
          message: {
            content: JSON.stringify({
              team: 'Phoenix Suns',
              summary: 'Model summary',
              props: [{
                player: 'Devin Booker',
                prop: 'Points Over 28.5',
                recommendation: 'over',
                reasoning: 'Model matched the market cleanly.',
                stat_type: 'points',
                market_line_value: 28.5,
              }],
            }),
          },
        }],
        usage: {},
      }),
    });

    const { generateTeamProps } = await import('../grok');
    const result = await generateTeamProps({
      teamName: 'Phoenix Suns',
      teamShort: 'PHX',
      opponentName: 'Utah Jazz',
      opponentShort: 'UTA',
      league: 'nba',
      isHome: true,
      startsAt: '2026-03-28T23:30:00.000Z',
      moneyline: { home: -140, away: 120 },
      spread: { home: { line: -3.5 }, away: { line: 3.5 } },
      total: { over: { line: 229.5 }, under: { line: 229.5 } },
    });

    expect(result.props).toHaveLength(1);
    expect(result.props[0]).toMatchObject({
      player: 'Devin Booker',
      recommendation: 'over',
      prob: 64,
      edge: expect.any(Number),
      odds: -118,
      projected_stat_value: 31.0,
      market_source: 'fanduel',
      projected_probability: 64,
      model_context: expect.objectContaining({
        tier_label: 'T2_STRONG',
        projection_basis: 'source_market_piff_exact',
      }),
    });
  });

  it('builds team prop prompts from the registry-supported stat menu instead of stale hard-coded limits', async () => {
    mocked.fetchTeamPropMarketCandidates.mockResolvedValue([
      {
        player: 'Devin Booker',
        statType: 'threes',
        normalizedStatType: 'threes',
        marketLineValue: 3.5,
        overOdds: -110,
        underOdds: -110,
        availableSides: ['over', 'under'],
        source: 'fanduel',
        marketName: 'Devin Booker 3PT Made Over/Under',
        propLabel: '3PT Made 3.5',
      },
    ]);
    mocked.fetch.mockResolvedValue({
      ok: true,
      json: async () => ({
        choices: [{
          message: {
            content: JSON.stringify({
              team: 'Phoenix Suns',
              summary: 'Model summary',
              props: [{
                player: 'Devin Booker',
                prop: '3PT Made Over 3.5',
                recommendation: 'over',
                reasoning: "Rain Man projects Devin Booker for ~4.2 threes tonight. That supports O only while current markets are at 3.5 (or better). Here's how the model got there: exact source-backed match. The signal fades if current markets move above ~4.2.",
                edge: 6.2,
                prob: 61,
                projected_stat_value: 4.2,
                stat_type: 'threes',
                market_line_value: 3.5,
              }],
            }),
          },
        }],
        usage: {},
      }),
    });

    const { generateTeamProps } = await import('../grok');
    await generateTeamProps({
      teamName: 'Phoenix Suns',
      teamShort: 'PHX',
      opponentName: 'Utah Jazz',
      opponentShort: 'UTA',
      league: 'nba',
      isHome: true,
      startsAt: '2026-03-28T23:30:00.000Z',
      moneyline: { home: -140, away: 120 },
      spread: { home: { line: -3.5 }, away: { line: 3.5 } },
      total: { over: { line: 229.5 }, under: { line: 229.5 } },
    });

    const requestBody = String(mocked.fetch.mock.calls[0]?.[1]?.body || '');
    expect(requestBody).toContain('3PT Made');
    expect(requestBody).toContain('Pts + Reb + Ast');
    expect(requestBody).not.toContain('generate ONLY main stat props');
  });

  it('tops up validated non-MLB props to five when source-backed markets exist', async () => {
    mocked.fetchTeamPropMarketCandidates.mockResolvedValue([
      {
        player: 'Devin Booker',
        statType: 'points',
        normalizedStatType: 'points',
        marketLineValue: 28.5,
        overOdds: -130,
        underOdds: 100,
        availableSides: ['over', 'under'],
        source: 'fanduel',
        marketName: 'Devin Booker Points Over/Under',
        propLabel: 'Points 28.5',
      },
      {
        player: 'Kevin Durant',
        statType: 'points',
        normalizedStatType: 'points',
        marketLineValue: 24.5,
        overOdds: -125,
        underOdds: 102,
        availableSides: ['over', 'under'],
        source: 'draftkings',
        marketName: 'Kevin Durant Points Over/Under',
        propLabel: 'Points 24.5',
      },
      {
        player: 'Bradley Beal',
        statType: 'assists',
        normalizedStatType: 'assists',
        marketLineValue: 6.5,
        overOdds: -120,
        underOdds: 100,
        availableSides: ['over', 'under'],
        source: 'betmgm',
        marketName: 'Bradley Beal Assists Over/Under',
        propLabel: 'Assists 6.5',
      },
      {
        player: 'Jusuf Nurkic',
        statType: 'rebounds',
        normalizedStatType: 'rebounds',
        marketLineValue: 8.5,
        overOdds: -118,
        underOdds: -102,
        availableSides: ['over', 'under'],
        source: 'fanatics',
        marketName: 'Jusuf Nurkic Rebounds Over/Under',
        propLabel: 'Rebounds 8.5',
      },
      {
        player: 'Grayson Allen',
        statType: 'threes',
        normalizedStatType: 'threes',
        marketLineValue: 2.5,
        overOdds: -135,
        underOdds: 108,
        availableSides: ['over', 'under'],
        source: 'espnbet',
        marketName: 'Grayson Allen 3PT Made Over/Under',
        propLabel: '3PT Made 2.5',
      },
    ]);
    const piffMap = {
      'nba:PHX': [
        { name: 'Devin Booker', team: 'PHX', stat: 'points', line: 28.5, edge: 0.11, prob: 0.64, direction: 'over', tier: 2, tier_label: 'T2_STRONG' },
        { name: 'Kevin Durant', team: 'PHX', stat: 'points', line: 24.5, edge: 0.09, prob: 0.61, direction: 'over', tier: 2, tier_label: 'T2_STRONG' },
        { name: 'Bradley Beal', team: 'PHX', stat: 'assists', line: 6.5, edge: 0.08, prob: 0.6, direction: 'over', tier: 3, tier_label: 'T3_SOLID' },
        { name: 'Jusuf Nurkic', team: 'PHX', stat: 'rebounds', line: 8.5, edge: 0.07, prob: 0.59, direction: 'over', tier: 3, tier_label: 'T3_SOLID' },
        { name: 'Grayson Allen', team: 'PHX', stat: 'threes', line: 2.5, edge: 0.06, prob: 0.58, direction: 'over', tier: 3, tier_label: 'T3_SOLID' },
      ],
    };
    mocked.loadTodaysPiffProps.mockReturnValue(piffMap);
    mocked.loadPiffPropsForDate.mockReturnValue(piffMap);
    mocked.fetch.mockResolvedValue({
      ok: true,
      json: async () => ({
        choices: [{
          message: {
            content: JSON.stringify({
              team: 'Phoenix Suns',
              summary: 'Model summary',
              props: [{
                player: 'Devin Booker',
                prop: 'Points Over 28.5',
                recommendation: 'over',
                reasoning: "Rain Man projects Devin Booker for ~31.2 points tonight. That supports O only while current markets are at 28.5 (or better). Here's how the model got there: exact source-backed match. The signal fades if current markets move above ~31.2.",
                edge: 8.4,
                prob: 63,
                projected_stat_value: 31.2,
                stat_type: 'points',
                market_line_value: 28.5,
              }],
            }),
          },
        }],
        usage: {},
      }),
    });

    const { generateTeamProps } = await import('../grok');
    const result = await generateTeamProps({
      teamName: 'Phoenix Suns',
      teamShort: 'PHX',
      opponentName: 'Utah Jazz',
      opponentShort: 'UTA',
      league: 'nba',
      isHome: true,
      startsAt: '2026-03-28T23:30:00.000Z',
      moneyline: { home: -140, away: 120 },
      spread: { home: { line: -3.5 }, away: { line: 3.5 } },
      total: { over: { line: 229.5 }, under: { line: 229.5 } },
    });

    expect(result.props).toHaveLength(5);
    expect(result.props.map((prop) => prop.player)).toEqual(expect.arrayContaining([
      'Devin Booker',
      'Kevin Durant',
      'Bradley Beal',
      'Jusuf Nurkic',
      'Grayson Allen',
    ]));
  });

  it('falls back to source-backed props when the team-props model returns malformed JSON', async () => {
    mocked.fetchTeamPropMarketCandidates.mockResolvedValue([
      {
        player: 'Devin Booker',
        statType: 'points',
        normalizedStatType: 'points',
        marketLineValue: 28.5,
        overOdds: -130,
        underOdds: 100,
        availableSides: ['over', 'under'],
        source: 'fanduel',
        marketName: 'Devin Booker Points Over/Under',
        propLabel: 'Points 28.5',
      },
      {
        player: 'Kevin Durant',
        statType: 'points',
        normalizedStatType: 'points',
        marketLineValue: 24.5,
        overOdds: -125,
        underOdds: 102,
        availableSides: ['over', 'under'],
        source: 'draftkings',
        marketName: 'Kevin Durant Points Over/Under',
        propLabel: 'Points 24.5',
      },
      {
        player: 'Bradley Beal',
        statType: 'assists',
        normalizedStatType: 'assists',
        marketLineValue: 6.5,
        overOdds: -120,
        underOdds: 100,
        availableSides: ['over', 'under'],
        source: 'betmgm',
        marketName: 'Bradley Beal Assists Over/Under',
        propLabel: 'Assists 6.5',
      },
      {
        player: 'Jusuf Nurkic',
        statType: 'rebounds',
        normalizedStatType: 'rebounds',
        marketLineValue: 8.5,
        overOdds: -118,
        underOdds: -102,
        availableSides: ['over', 'under'],
        source: 'fanatics',
        marketName: 'Jusuf Nurkic Rebounds Over/Under',
        propLabel: 'Rebounds 8.5',
      },
      {
        player: 'Grayson Allen',
        statType: 'threes',
        normalizedStatType: 'threes',
        marketLineValue: 2.5,
        overOdds: -135,
        underOdds: 108,
        availableSides: ['over', 'under'],
        source: 'espnbet',
        marketName: 'Grayson Allen 3PT Made Over/Under',
        propLabel: '3PT Made 2.5',
      },
    ]);
    const piffMap = {
      'nba:PHX': [
        { name: 'Devin Booker', team: 'PHX', stat: 'points', line: 28.5, edge: 0.11, prob: 0.64, direction: 'over', tier: 2, tier_label: 'T2_STRONG' },
        { name: 'Kevin Durant', team: 'PHX', stat: 'points', line: 24.5, edge: 0.09, prob: 0.61, direction: 'over', tier: 2, tier_label: 'T2_STRONG' },
        { name: 'Bradley Beal', team: 'PHX', stat: 'assists', line: 6.5, edge: 0.08, prob: 0.6, direction: 'over', tier: 3, tier_label: 'T3_SOLID' },
        { name: 'Jusuf Nurkic', team: 'PHX', stat: 'rebounds', line: 8.5, edge: 0.07, prob: 0.59, direction: 'over', tier: 3, tier_label: 'T3_SOLID' },
        { name: 'Grayson Allen', team: 'PHX', stat: 'threes', line: 2.5, edge: 0.06, prob: 0.58, direction: 'over', tier: 3, tier_label: 'T3_SOLID' },
      ],
    };
    mocked.loadTodaysPiffProps.mockReturnValue(piffMap);
    mocked.loadPiffPropsForDate.mockReturnValue(piffMap);
    mocked.fetch.mockResolvedValue({
      ok: true,
      json: async () => ({
        choices: [{
          message: {
            content: '{"team":"Phoenix Suns","summary":"broken","props":[{"player":"Devin Booker"',
          },
        }],
        usage: {},
      }),
    });

    const { generateTeamProps } = await import('../grok');
    const result = await generateTeamProps({
      teamName: 'Phoenix Suns',
      teamShort: 'PHX',
      opponentName: 'Utah Jazz',
      opponentShort: 'UTA',
      league: 'nba',
      isHome: true,
      startsAt: '2026-03-28T23:30:00.000Z',
      moneyline: { home: -140, away: 120 },
      spread: { home: { line: -3.5 }, away: { line: 3.5 } },
      total: { over: { line: 229.5 }, under: { line: 229.5 } },
    });

    expect(result.summary).toContain('source-backed fallback');
    expect(result.metadata).toMatchObject({
      source_market_candidate_count: 5,
      fallback_reason: 'llm_parse_failure',
    });
    expect(result.props).toHaveLength(5);
    expect(result.props.map((prop) => prop.player)).toEqual(expect.arrayContaining([
      'Devin Booker',
      'Kevin Durant',
      'Bradley Beal',
      'Jusuf Nurkic',
      'Grayson Allen',
    ]));
  });
});
