import pool from '../db';
import { getTeamName, getTeamRoster, resolveCanonicalName } from './canonical-names';
import { buildNormalizedMlbMarketsForQuery } from './mlb-market-completion-engine';
import {
  getMlbPropLabel,
  getMlbPropRole,
  MarketCompletenessStatus,
  MarketSourceMapEntry,
  normalizeMlbPropType,
  normalizeText,
  NormalizedPlayerPropMarket,
  SUPPORTED_MLB_PROP_TYPES,
  titleCase,
} from './mlb-market-normalizer';
import {
  fetchCurrentEventOdds,
  fetchCurrentOdds,
  type TheOddsEventOddsSnapshot,
  getTheOddsPlayerPropMarketKey,
  getTheOddsSportKey,
  hasTheOddsApiConfigured,
  matchCurrentEvent,
  selectBestPlayerPropOutcome,
  type SelectedPlayerPropOutcome,
} from './the-odds';

export interface MlbPropCandidate {
  player: string;
  playerId?: string;
  eventId?: string;
  teamShort: string;
  statType: string;
  normalizedStatType: string;
  playerRole: 'batter' | 'pitcher' | 'unknown' | null;
  prop: string;
  marketLineValue: number;
  overOdds: number | null;
  underOdds: number | null;
  availableSides: Array<'over' | 'under'>;
  gameStart: string;
  marketName: string;
  source: 'player_prop_line' | 'theodds_live';
  completenessStatus?: MarketCompletenessStatus;
  completionMethod?: 'none' | 'source' | 'multi_source';
  isGapFilled?: boolean;
  sourceMap?: MarketSourceMapEntry[];
}

export interface MlbPropQueryParams {
  teamShort: string;
  teamName: string;
  opponentShort?: string;
  startsAt: string;
}

type NormalizedMlbPropQueryParams = {
  teamShort: string;
  teamName: string;
  opponentShort: string;
  startsAt: string;
};

const MLB_PROP_CANDIDATE_CACHE_TTL_MS = 30 * 1000;
const MLB_PROP_CANDIDATE_CACHE = new Map<string, { expiresAt: number; value: MlbPropCandidate[] }>();
const MLB_PROP_CANDIDATE_IN_FLIGHT = new Map<string, Promise<MlbPropCandidate[]>>();

type MlbPropSeed = {
  playerName: string;
  statType: string;
  marketKey: string;
  line: number;
};

function normalizeMlbPropStartsAt(value: string | null | undefined): string {
  const raw = String(value || '').trim();
  if (!raw) return '';
  const parsed = new Date(raw);
  return Number.isNaN(parsed.getTime()) ? raw : parsed.toISOString();
}

function normalizeMlbPropQueryParams(params: MlbPropQueryParams): NormalizedMlbPropQueryParams {
  return {
    teamShort: String(params.teamShort || '').trim().toUpperCase(),
    teamName: String(params.teamName || '').trim(),
    opponentShort: String(params.opponentShort || '').trim().toUpperCase(),
    startsAt: normalizeMlbPropStartsAt(params.startsAt),
  };
}

function getMlbPropCandidateCacheKey(mode: 'local' | 'direct', params: NormalizedMlbPropQueryParams): string {
  return JSON.stringify({
    mode,
    teamShort: params.teamShort,
    teamName: String(params.teamName || '').trim().toLowerCase(),
    opponentShort: params.opponentShort,
    startsAt: params.startsAt,
  });
}

async function withCachedMlbPropCandidates(
  mode: 'local' | 'direct',
  params: MlbPropQueryParams,
  loader: (normalized: NormalizedMlbPropQueryParams) => Promise<MlbPropCandidate[]>,
): Promise<MlbPropCandidate[]> {
  const normalized = normalizeMlbPropQueryParams(params);
  const cacheKey = getMlbPropCandidateCacheKey(mode, normalized);
  const cached = MLB_PROP_CANDIDATE_CACHE.get(cacheKey);
  if (cached && cached.expiresAt > Date.now()) {
    return cached.value.slice();
  }

  const inFlight = MLB_PROP_CANDIDATE_IN_FLIGHT.get(cacheKey);
  if (inFlight) {
    return (await inFlight).slice();
  }

  const pending = loader(normalized)
    .then((value) => {
      MLB_PROP_CANDIDATE_CACHE.set(cacheKey, {
        expiresAt: Date.now() + MLB_PROP_CANDIDATE_CACHE_TTL_MS,
        value,
      });
      return value;
    })
    .finally(() => {
      MLB_PROP_CANDIDATE_IN_FLIGHT.delete(cacheKey);
    });
  MLB_PROP_CANDIDATE_IN_FLIGHT.set(cacheKey, pending);

  return (await pending).slice();
}

function stripMlbMarketSuffix(value: string): string {
  return String(value || '')
    .replace(/\s+(Strikeouts|Earned Runs|Outs Recorded|Outs|Hits Allowed|Walks Allowed|Walks|Hits \+ Runs \+ RBIs|Hits|Total Bases|Runs|RBIs|Runs Batted In|Home Runs|Stolen Bases|Doubles|Triples|Singles|Fantasy Score|Fantasy Points)\s+Over\/Under$/i, '')
    .trim();
}

function sanitizeMlbCandidatePlayerName(market: NormalizedPlayerPropMarket): string {
  const direct = stripMlbMarketSuffix(market.playerName || '');
  if (direct) return direct;

  const fromMarketName = stripMlbMarketSuffix(market.marketName || '');
  if (fromMarketName) return fromMarketName;

  const playerId = String(market.playerId || '');
  if (playerId.endsWith('_MLB')) {
    const stem = playerId.replace(/_\d+_MLB$/i, '').replace(/_/g, ' ').trim();
    if (stem) return titleCase(stem.toLowerCase());
  }

  return String(market.playerName || '').trim();
}

function shouldSuppressLowValueMlbCandidate(market: NormalizedPlayerPropMarket): boolean {
  const playerRole = getMlbPropRole(market.statType);
  if (playerRole !== 'batter') return false;

  const line = Number(market.line);
  const availableSides = Array.isArray(market.availableSides) ? market.availableSides : [];
  if (availableSides.length !== 1 || availableSides[0] !== 'under') return false;

  if (!Number.isFinite(line)) return false;
  if (line <= 0.5) return true;
  if (line <= 1.5) return true;

  const statNorm = normalizeText(market.normalizedStatType || market.statType || '');
  return statNorm === 'fantasyscore'
    || statNorm === 'battinghitsrunsrbi'
    || statNorm === 'battingtotalbases'
    || statNorm === 'battingsingles';
}

function isPitcherProp(propType: string): boolean {
  return getMlbPropRole(propType) === 'pitcher';
}

function buildMlbTheOddsMarketKeyMap(): Map<string, string> {
  const pairs = Object.keys(SUPPORTED_MLB_PROP_TYPES)
    .map((statType) => [getTheOddsPlayerPropMarketKey('mlb', statType), statType] as const)
    .filter((entry): entry is readonly [string, string] => Boolean(entry[0]));
  return new Map<string, string>(pairs);
}

function collectMlbPropSeedsFromSnapshot(
  snapshot: TheOddsEventOddsSnapshot,
  params: {
    rosterSet: Set<string>;
    confirmedLineupSet: Set<string> | null;
  },
): MlbPropSeed[] {
  const marketKeyToStatType = buildMlbTheOddsMarketKeyMap();
  const seeds = new Map<string, MlbPropSeed>();

  for (const bookmaker of snapshot.bookmakers || []) {
    for (const market of bookmaker.markets || []) {
      const statType = marketKeyToStatType.get(String(market.key || ''));
      if (!statType) continue;

      for (const outcome of market.outcomes || []) {
        const direction = normalizeText(String(outcome.name || ''));
        if (direction !== 'over' && direction !== 'under') continue;

        const rawPlayer = String(outcome.description || '').trim();
        const line = Number(outcome.point);
        if (!rawPlayer || !Number.isFinite(line)) continue;

        const canonicalPlayer = resolveCanonicalName(rawPlayer, 'mlb');
        const playerKey = normalizeText(canonicalPlayer);
        if (!playerKey || !params.rosterSet.has(playerKey)) continue;
        if (params.confirmedLineupSet && !isPitcherProp(statType) && !params.confirmedLineupSet.has(playerKey)) continue;

        const seedKey = `${playerKey}||${statType}||${line}`;
        if (!seeds.has(seedKey)) {
          seeds.set(seedKey, {
            playerName: canonicalPlayer,
            statType,
            marketKey: String(market.key),
            line,
          });
        }
      }
    }
  }

  return Array.from(seeds.values());
}

function buildDirectMlbApiCandidate(
  snapshot: TheOddsEventOddsSnapshot,
  seed: MlbPropSeed,
  teamShort: string,
): MlbPropCandidate | null {
  const overOutcome = selectBestPlayerPropOutcome(snapshot, {
    marketKey: seed.marketKey,
    playerName: seed.playerName,
    direction: 'over',
    targetLine: seed.line,
    preferNearestLine: true,
  });
  const underOutcome = selectBestPlayerPropOutcome(snapshot, {
    marketKey: seed.marketKey,
    playerName: seed.playerName,
    direction: 'under',
    targetLine: seed.line,
    preferNearestLine: true,
  });

  const availableSides: Array<'over' | 'under'> = [];
  if (overOutcome?.odds != null) availableSides.push('over');
  if (underOutcome?.odds != null) availableSides.push('under');
  if (availableSides.length === 0) return null;

  const propLabel = getMlbPropLabel(seed.statType);
  if (!propLabel) return null;

  const sourceMap: MarketSourceMapEntry[] = [];
  if (overOutcome?.odds != null) {
    sourceMap.push({
      side: 'over',
      source: overOutcome.bookmakerKey,
      odds: overOutcome.odds,
      impliedProb: null,
      provider: 'theodds',
      sportsbookName: overOutcome.bookmakerTitle,
      sportsbookId: overOutcome.bookmakerKey,
      timestamp: overOutcome.marketUpdatedAt || overOutcome.bookmakerUpdatedAt || null,
      rawMarketId: null,
    });
  }
  if (underOutcome?.odds != null) {
    sourceMap.push({
      side: 'under',
      source: underOutcome.bookmakerKey,
      odds: underOutcome.odds,
      impliedProb: null,
      provider: 'theodds',
      sportsbookName: underOutcome.bookmakerTitle,
      sportsbookId: underOutcome.bookmakerKey,
      timestamp: underOutcome.marketUpdatedAt || underOutcome.bookmakerUpdatedAt || null,
      rawMarketId: null,
    });
  }

  const sameSource = Boolean(overOutcome?.bookmakerKey && underOutcome?.bookmakerKey && overOutcome.bookmakerKey === underOutcome.bookmakerKey);
  const completenessStatus: MarketCompletenessStatus =
    availableSides.length === 2
      ? sameSource ? 'source_complete' : 'multi_source_complete'
      : 'incomplete';
  const completionMethod: 'none' | 'source' | 'multi_source' =
    availableSides.length === 2
      ? sameSource ? 'source' : 'multi_source'
      : 'none';

  return {
    player: seed.playerName,
    eventId: snapshot.eventId,
    teamShort,
    statType: seed.statType,
    normalizedStatType: normalizeMlbPropType(seed.statType),
    playerRole: getMlbPropRole(seed.statType),
    prop: `${propLabel} ${seed.line}`,
    marketLineValue: seed.line,
    overOdds: overOutcome?.odds ?? null,
    underOdds: underOutcome?.odds ?? null,
    availableSides,
    gameStart: snapshot.commenceTime || '',
    marketName: `${seed.playerName} ${propLabel} Over/Under`,
    source: 'theodds_live',
    completenessStatus,
    completionMethod,
    isGapFilled: availableSides.length === 2 && !sameSource,
    sourceMap,
  };
}

function timestampValue(value: string | null | undefined): number {
  if (!value) return 0;
  const parsed = Date.parse(value);
  return Number.isFinite(parsed) ? parsed : 0;
}

function sameMarketLine(left: number | null | undefined, right: number | null | undefined): boolean {
  if (left == null || right == null) return false;
  return Math.abs(Number(left) - Number(right)) <= 0.001;
}

function buildTheOddsMlbSide(
  outcome: SelectedPlayerPropOutcome | null,
): NonNullable<NormalizedPlayerPropMarket['over']> | null {
  if (!outcome) return null;
  return {
    odds: outcome.odds,
    impliedProb: null,
    source: outcome.bookmakerKey,
    provider: 'theodds',
    sportsbookName: outcome.bookmakerTitle,
    sportsbookId: outcome.bookmakerKey,
    timestamp: outcome.marketUpdatedAt || outcome.bookmakerUpdatedAt || null,
    rawMarketId: null,
  };
}

function mergeMlbMarketWithTheOddsSnapshot(
  market: NormalizedPlayerPropMarket,
  snapshot: TheOddsEventOddsSnapshot,
): NormalizedPlayerPropMarket {
  if (market.availableSides.includes('over') && market.availableSides.includes('under')) {
    return market;
  }

  const marketKey = getTheOddsPlayerPropMarketKey('mlb', market.statType);
  if (!marketKey) return market;

  const overOutcome = selectBestPlayerPropOutcome(snapshot, {
    marketKey,
    playerName: market.playerName,
    direction: 'over',
    targetLine: market.line,
    preferNearestLine: true,
  });
  const underOutcome = selectBestPlayerPropOutcome(snapshot, {
    marketKey,
    playerName: market.playerName,
    direction: 'under',
    targetLine: market.line,
    preferNearestLine: true,
  });

  const verifiedLines = [overOutcome?.line, underOutcome?.line].filter((value): value is number => value != null);
  if (verifiedLines.length > 0 && !verifiedLines.some((line) => sameMarketLine(line, market.line))) {
    return market;
  }

  const nextOver = market.over || buildTheOddsMlbSide(overOutcome);
  const nextUnder = market.under || buildTheOddsMlbSide(underOutcome);
  if (!nextOver && !nextUnder) return market;

  const nextSides: Array<'over' | 'under'> = [];
  if (nextOver?.odds != null || market.availableSides.includes('over')) nextSides.push('over');
  if (nextUnder?.odds != null || market.availableSides.includes('under')) nextSides.push('under');
  if (nextSides.length < 2) {
    return {
      ...market,
      over: nextOver,
      under: nextUnder,
      availableSides: nextSides,
      updatedAt: timestampValue(nextOver?.timestamp) > timestampValue(market.updatedAt)
        ? nextOver?.timestamp || market.updatedAt
        : timestampValue(nextUnder?.timestamp) > timestampValue(market.updatedAt)
          ? nextUnder?.timestamp || market.updatedAt
          : market.updatedAt,
    };
  }

  const addedSourceMap = [...market.sourceMap];
  if (!market.over && nextOver) {
    addedSourceMap.push({
      side: 'over',
      source: nextOver.source,
      odds: nextOver.odds,
      impliedProb: nextOver.impliedProb,
      provider: nextOver.provider,
      sportsbookName: nextOver.sportsbookName,
      sportsbookId: nextOver.sportsbookId,
      timestamp: nextOver.timestamp,
      rawMarketId: nextOver.rawMarketId,
    });
  }
  if (!market.under && nextUnder) {
    addedSourceMap.push({
      side: 'under',
      source: nextUnder.source,
      odds: nextUnder.odds,
      impliedProb: nextUnder.impliedProb,
      provider: nextUnder.provider,
      sportsbookName: nextUnder.sportsbookName,
      sportsbookId: nextUnder.sportsbookId,
      timestamp: nextUnder.timestamp,
      rawMarketId: nextUnder.rawMarketId,
    });
  }

  const overSource = nextOver?.source || null;
  const underSource = nextUnder?.source || null;
  const sameSource = Boolean(overSource && underSource && overSource === underSource);
  const updatedAt = [market.updatedAt, nextOver?.timestamp || null, nextUnder?.timestamp || null]
    .sort((a, b) => timestampValue(b) - timestampValue(a))[0] || market.updatedAt;

  return {
    ...market,
    over: nextOver,
    under: nextUnder,
    primarySource: sameSource ? overSource : market.primarySource || overSource || underSource,
    completionMethod: sameSource ? 'source' : 'multi_source',
    completenessStatus: sameSource ? 'source_complete' : 'multi_source_complete',
    isGapFilled: true,
    availableSides: nextSides,
    sourceMap: addedSourceMap,
    rawMarketCount: market.rawMarketCount + Number(!market.over && nextOver != null) + Number(!market.under && nextUnder != null),
    updatedAt,
  };
}

async function overlayNormalizedMlbMarketsWithTheOdds(
  markets: NormalizedPlayerPropMarket[],
  params: MlbPropQueryParams,
): Promise<NormalizedPlayerPropMarket[]> {
  if (markets.length === 0 || !hasTheOddsApiConfigured()) return markets;

  const sportKey = getTheOddsSportKey('mlb');
  if (!sportKey) return markets;

  const marketKeys = Array.from(new Set(
    markets
      .filter((market) => !(market.availableSides.includes('over') && market.availableSides.includes('under')))
      .map((market) => getTheOddsPlayerPropMarketKey('mlb', market.statType))
      .filter((value): value is string => Boolean(value)),
  ));
  if (marketKeys.length === 0) return markets;

  const eventShape = markets[0];
  const homeTeamShort = String(eventShape.homeTeam || '').toUpperCase();
  const awayTeamShort = String(eventShape.awayTeam || '').toUpperCase();
  const homeTeam = getTeamName(homeTeamShort, 'mlb') || (homeTeamShort === params.teamShort.toUpperCase() ? params.teamName : homeTeamShort);
  const awayTeam = getTeamName(awayTeamShort, 'mlb') || (awayTeamShort === params.teamShort.toUpperCase() ? params.teamName : awayTeamShort);
  if (!homeTeam || !awayTeam) return markets;

  try {
    const events = await fetchCurrentOdds({
      sportKey,
      markets: ['h2h'],
    });
    const matchedEvent = matchCurrentEvent(events, {
      homeTeam,
      awayTeam,
      startsAt: params.startsAt,
    });
    if (!matchedEvent) return markets;

    const snapshot = await fetchCurrentEventOdds({
      sportKey,
      eventId: matchedEvent.id,
      markets: marketKeys,
    });

    return markets.map((market) => mergeMlbMarketWithTheOddsSnapshot(market, snapshot));
  } catch (err: any) {
    console.warn(`[mlb-prop-markets] The Odds overlay failed: ${err.message}`);
    return markets;
  }
}

const mlbTeamIdCache = new Map<string, Promise<number | null>>();
const mlbRosterCache = new Map<string, Promise<string[]>>();
const MLB_TEAM_SHORT_ALIASES: Record<string, string[]> = {
  ARI: ['ARI', 'AZ'],
  AZ: ['AZ', 'ARI'],
  WSH: ['WSH', 'WSN'],
  WSN: ['WSN', 'WSH'],
  SF: ['SF', 'SFG'],
  SFG: ['SFG', 'SF'],
  SD: ['SD', 'SDP'],
  SDP: ['SDP', 'SD'],
  TB: ['TB', 'TBR'],
  TBR: ['TBR', 'TB'],
  KC: ['KC', 'KCR'],
  KCR: ['KCR', 'KC'],
  CWS: ['CWS', 'CHW'],
  CHW: ['CHW', 'CWS'],
  OAK: ['OAK', 'ATH'],
  ATH: ['ATH', 'OAK'],
};

function getMlbTeamShortVariants(teamShort: string): string[] {
  const normalized = (teamShort || '').trim().toUpperCase();
  return MLB_TEAM_SHORT_ALIASES[normalized] || [normalized];
}

async function fetchMlbTeamId(teamShort: string): Promise<number | null> {
  const normalized = teamShort.toUpperCase();
  if (!mlbTeamIdCache.has(normalized)) {
    mlbTeamIdCache.set(normalized, (async () => {
      const variants = getMlbTeamShortVariants(normalized);
      const response = await fetch('https://statsapi.mlb.com/api/v1/teams?sportId=1', {
        signal: AbortSignal.timeout(10000),
      });
      if (!response.ok) {
        throw new Error(`MLB StatsAPI teams ${response.status} ${response.statusText}`);
      }
      const data = await response.json() as any;
      const team = (data?.teams || []).find((entry: any) => variants.includes(String(entry?.abbreviation || '').toUpperCase()));
      return team?.id != null ? Number(team.id) : null;
    })());
  }
  return mlbTeamIdCache.get(normalized)!;
}

async function fetchMlbRoster(teamShort: string): Promise<string[]> {
  const normalized = teamShort.toUpperCase();
  if (!mlbRosterCache.has(normalized)) {
    mlbRosterCache.set(normalized, (async () => {
      const teamId = await fetchMlbTeamId(normalized);
      if (!teamId) {
        return getTeamRoster(normalized, 'mlb');
      }

      const response = await fetch(`https://statsapi.mlb.com/api/v1/teams/${teamId}/roster`, {
        signal: AbortSignal.timeout(10000),
      });
      if (!response.ok) {
        throw new Error(`MLB StatsAPI roster ${response.status} ${response.statusText}`);
      }
      const data = await response.json() as any;
      const roster = Array.isArray(data?.roster) ? data.roster : [];
      const names = roster
        .map((entry: any) => String(entry?.person?.fullName || '').trim())
        .filter(Boolean);
      return names.length > 0 ? names : getTeamRoster(normalized, 'mlb');
    })());
  }
  return mlbRosterCache.get(normalized)!;
}

async function fetchConfirmedLineupSet(teamShort: string, startsAt: string): Promise<Set<string> | null> {
  const variants = getMlbTeamShortVariants(teamShort);
  const { rows } = await pool.query(
    `SELECT "homeTeam", "awayTeam", "homeStatus", "awayStatus", "homePlayers", "awayPlayers"
     FROM "GameLineup"
     WHERE league = 'mlb'
       AND "gameDate" = DATE($1::timestamptz)
       AND ("homeTeam" = ANY($2::text[]) OR "awayTeam" = ANY($2::text[]))
     ORDER BY "updatedAt" DESC
     LIMIT 1`,
    [startsAt, variants],
  ).catch(() => ({ rows: [] as any[] }));

  const row = rows[0];
  if (!row) return null;

  const isHome = variants.includes(String(row.homeTeam || '').toUpperCase());
  const status = String(isHome ? row.homeStatus : row.awayStatus || '');
  const players = (isHome ? row.homePlayers : row.awayPlayers) || [];
  if (!status.toLowerCase().includes('confirmed') || !Array.isArray(players) || players.length === 0) {
    return null;
  }

  return new Set(players.map((player: any) => normalizeText(player?.name || '')));
}

export function mapNormalizedMarketsToCandidates(
  markets: NormalizedPlayerPropMarket[],
  rosterSet: Set<string>,
  confirmedLineupSet: Set<string> | null,
  teamShort: string,
): MlbPropCandidate[] {
  const candidates = markets
    .filter((market) => {
      if (shouldSuppressLowValueMlbCandidate(market)) {
        return false;
      }

      const availableSides = Array.isArray(market.availableSides) ? market.availableSides : [];
      const hasAnyPricedSide =
        (availableSides.includes('over') && market.over?.odds != null)
        || (availableSides.includes('under') && market.under?.odds != null);
      return hasAnyPricedSide;
    })
    .map((market) => {
      const propLabel = getMlbPropLabel(market.statType);
      if (!propLabel) return null;

      const rawPlayer = sanitizeMlbCandidatePlayerName(market);
      const canonicalPlayer = resolveCanonicalName(rawPlayer, 'mlb');
      const playerNorm = normalizeText(canonicalPlayer);
      if (!rosterSet.has(playerNorm)) return null;
      if (confirmedLineupSet && !isPitcherProp(market.statType) && !confirmedLineupSet.has(playerNorm)) return null;

      return {
        player: canonicalPlayer,
        playerId: market.playerId,
        eventId: market.eventId,
        teamShort,
        statType: market.statType,
        normalizedStatType: market.normalizedStatType || normalizeMlbPropType(market.statType),
        playerRole: getMlbPropRole(market.statType),
        prop: `${propLabel} ${market.line}`,
        marketLineValue: market.line,
        overOdds: market.over?.odds ?? null,
        underOdds: market.under?.odds ?? null,
        availableSides: market.availableSides,
        gameStart: market.startsAt || '',
        marketName: market.marketName || `${canonicalPlayer} ${propLabel} Over/Under`,
        source: 'player_prop_line' as const,
        completenessStatus: market.completenessStatus,
        completionMethod: market.completionMethod,
        isGapFilled: market.isGapFilled,
        sourceMap: market.sourceMap,
      } satisfies MlbPropCandidate;
    })
    .filter((candidate): candidate is NonNullable<typeof candidate> => candidate != null);

  return candidates.sort((a, b) => {
    const pitcherPriority = a.playerRole === 'pitcher' ? 1 : 0;
    const pitcherPriorityB = b.playerRole === 'pitcher' ? 1 : 0;
    if (pitcherPriority !== pitcherPriorityB) return pitcherPriorityB - pitcherPriority;
    return a.player.localeCompare(b.player);
  });
}

export async function fetchMlbPropCandidates(params: MlbPropQueryParams): Promise<MlbPropCandidate[]> {
  return withCachedMlbPropCandidates('local', params, async (normalized) => {
    let roster: string[] = [];
    try {
      roster = await fetchMlbRoster(normalized.teamShort);
    } catch (err: any) {
      console.warn(`[mlb-prop-markets] StatsAPI roster fallback failed for ${normalized.teamShort}: ${err.message}`);
      roster = getTeamRoster(normalized.teamShort, 'mlb');
    }
    if (roster.length === 0) return [];

    const rosterSet = new Set(roster.map((name) => normalizeText(name)));
    const confirmedLineupSet = await fetchConfirmedLineupSet(normalized.teamShort, normalized.startsAt);
    const normalizedMarkets = await buildNormalizedMlbMarketsForQuery({
      startsAt: normalized.startsAt,
      teamShort: normalized.teamShort,
      opponentShort: normalized.opponentShort || null,
    });
    const overlaidMarkets = await overlayNormalizedMlbMarketsWithTheOdds(normalizedMarkets, normalized);

    return mapNormalizedMarketsToCandidates(overlaidMarkets, rosterSet, confirmedLineupSet, normalized.teamShort);
  });
}

export async function fetchDirectMlbPropCandidates(params: MlbPropQueryParams): Promise<MlbPropCandidate[]> {
  return withCachedMlbPropCandidates('direct', params, async (normalized) => {
    if (!hasTheOddsApiConfigured()) return [];

    let roster: string[] = [];
    try {
      roster = await fetchMlbRoster(normalized.teamShort);
    } catch (err: any) {
      console.warn(`[mlb-prop-markets] Direct API roster fallback failed for ${normalized.teamShort}: ${err.message}`);
      roster = getTeamRoster(normalized.teamShort, 'mlb');
    }
    if (roster.length === 0) return [];

    const sportKey = getTheOddsSportKey('mlb');
    if (!sportKey) return [];

    const opponentShort = normalized.opponentShort;
    const opponentTeamName = getTeamName(opponentShort, 'mlb') || opponentShort;
    if (!normalized.teamName || !opponentTeamName) return [];

    const marketKeys = Array.from(new Set(
      Object.keys(SUPPORTED_MLB_PROP_TYPES)
        .map((statType) => getTheOddsPlayerPropMarketKey('mlb', statType))
        .filter((value): value is string => Boolean(value)),
    ));
    if (marketKeys.length === 0) return [];

    try {
      const events = await fetchCurrentOdds({
        sportKey,
        markets: ['h2h'],
      });
      const matchedEvent = matchCurrentEvent(events, {
        homeTeam: normalized.teamName,
        awayTeam: opponentTeamName,
        startsAt: normalized.startsAt,
      }) || matchCurrentEvent(events, {
        homeTeam: opponentTeamName,
        awayTeam: normalized.teamName,
        startsAt: normalized.startsAt,
      });
      if (!matchedEvent) return [];

      const snapshot = await fetchCurrentEventOdds({
        sportKey,
        eventId: matchedEvent.id,
        markets: marketKeys,
      });

      const rosterSet = new Set(roster.map((name) => normalizeText(name)));
      const confirmedLineupSet = await fetchConfirmedLineupSet(normalized.teamShort, normalized.startsAt);
      const seeds = collectMlbPropSeedsFromSnapshot(snapshot, {
        rosterSet,
        confirmedLineupSet,
      });

      return seeds
        .map((seed) => buildDirectMlbApiCandidate(snapshot, seed, normalized.teamShort))
        .filter((candidate): candidate is MlbPropCandidate => candidate != null)
        .sort((a, b) => {
          const pitcherPriority = a.playerRole === 'pitcher' ? 1 : 0;
          const pitcherPriorityB = b.playerRole === 'pitcher' ? 1 : 0;
          if (pitcherPriority !== pitcherPriorityB) return pitcherPriorityB - pitcherPriority;
          return a.player.localeCompare(b.player);
        });
    } catch (err: any) {
      console.warn(`[mlb-prop-markets] Direct The Odds fetch failed for ${normalized.teamShort}: ${err.message}`);
      return [];
    }
  });
}

export async function countMlbPropFeedRows(params: MlbPropQueryParams): Promise<number> {
  const { rows } = await pool.query(
    `SELECT COUNT(*)::int AS count
     FROM "PlayerPropLine"
     WHERE league = 'mlb'
       AND COALESCE("marketScope", 'full_game') = 'full_game'
       AND (("gameStart")::timestamptz AT TIME ZONE 'America/New_York')::date = (($1::timestamptz) AT TIME ZONE 'America/New_York')::date
       AND ("gameStart")::timestamptz BETWEEN ($1::timestamptz - INTERVAL '3 hours') AND ($1::timestamptz + INTERVAL '3 hours')
       AND ($3::text IS NULL OR (
         COALESCE("homeTeam", '') IN ($2::text, $3::text)
         AND COALESCE("awayTeam", '') IN ($2::text, $3::text)
       ))
       AND (
         COALESCE("homeTeam", '') = $2::text
         OR COALESCE("awayTeam", '') = $2::text
         OR COALESCE("homeTeam", '') = COALESCE($3::text, '')
         OR COALESCE("awayTeam", '') = COALESCE($3::text, '')
       )
       AND "propType" = ANY($4::text[])`,
    [
      params.startsAt,
      params.teamShort.toUpperCase(),
      params.opponentShort?.toUpperCase() || null,
      Object.keys(SUPPORTED_MLB_PROP_TYPES),
    ],
  );

  return Number(rows[0]?.count || 0);
}

export async function getMlbPropFeedSummary(params: MlbPropQueryParams): Promise<{
  playerCount: number;
  marketPropsCount: number;
}> {
  const { rows } = await pool.query(
    `SELECT
       COUNT(*)::int AS market_count,
       COUNT(DISTINCT COALESCE(
         NULLIF("playerExternalId", ''),
         NULLIF(raw #>> '{sportsgameodds,playerID}', ''),
         NULLIF(raw #>> '{player,id}', ''),
         NULLIF(
           regexp_replace(
             COALESCE(raw->>'description', "marketName", ''),
             '\\s+(over|under).*$',
             '',
             'i'
           ),
           ''
         ),
         NULLIF("marketName", '')
       ))::int AS player_count
     FROM "PlayerPropLine"
     WHERE league = 'mlb'
       AND COALESCE("marketScope", 'full_game') = 'full_game'
       AND (("gameStart")::timestamptz AT TIME ZONE 'America/New_York')::date = (($1::timestamptz) AT TIME ZONE 'America/New_York')::date
       AND ("gameStart")::timestamptz BETWEEN ($1::timestamptz - INTERVAL '3 hours') AND ($1::timestamptz + INTERVAL '3 hours')
       AND ($3::text IS NULL OR (
         COALESCE("homeTeam", '') IN ($2::text, $3::text)
         AND COALESCE("awayTeam", '') IN ($2::text, $3::text)
       ))
       AND (
         COALESCE("homeTeam", '') = $2::text
         OR COALESCE("awayTeam", '') = $2::text
         OR COALESCE("homeTeam", '') = COALESCE($3::text, '')
         OR COALESCE("awayTeam", '') = COALESCE($3::text, '')
       )
       AND "propType" = ANY($4::text[])`,
    [
      params.startsAt,
      params.teamShort.toUpperCase(),
      params.opponentShort?.toUpperCase() || null,
      Object.keys(SUPPORTED_MLB_PROP_TYPES),
    ],
  );

  return {
    playerCount: Number(rows[0]?.player_count || 0),
    marketPropsCount: Number(rows[0]?.market_count || 0),
  };
}

export const __mlbPropMarketInternals = {
  mergeMlbMarketWithTheOddsSnapshot,
  overlayNormalizedMlbMarketsWithTheOdds,
  mapNormalizedMarketsToCandidates,
  getMlbTeamShortVariants,
  sanitizeMlbCandidatePlayerName,
  shouldSuppressLowValueMlbCandidate,
};
