import {
  americanOddsToImpliedProbability,
  buildPlayerPropSignal,
  normalizePlayerPropDirection,
  getPlayerPropSignalRank,
  groupQualifiedSignalsByPlayer,
} from '../services/player-prop-signals';
import {
  DEPRECATED_FORECAST_RESPONSE_ALIAS_MAP,
  DEPRECATED_FORECAST_RESPONSE_FIELDS,
  DEPRECATED_PLAYER_PROP_ALIAS_MAP,
  DEPRECATED_PLAYER_PROP_FIELDS,
  FORECAST_PUBLIC_CONTRACT_VERSION,
  PublicForecastBalanceFields,
  PublicForecastLegacyPayloadV1,
  PublicForecastModelSignals,
  PublicForecastInputQuality,
  PublicForecastPropHighlight,
  PublicForecastProjectedLines,
  PublicGroupedPlayer,
  PublicGroupedPlayerPropRow,
  PublicDigiviewEvidence,
  PublicMlbMatchupContext,
  PublicMlbPhaseContext,
  PublicMlbPropContext,
  PublicPlayerProp,
  PublicTopGameCard,
  PublicTopPickEntry,
  PublicTopPropCard,
} from './forecast-public-shared';

export {
  DEPRECATED_FORECAST_RESPONSE_ALIAS_MAP,
  DEPRECATED_FORECAST_RESPONSE_FIELDS,
  DEPRECATED_PLAYER_PROP_ALIAS_MAP,
  DEPRECATED_PLAYER_PROP_FIELDS,
  FORECAST_PUBLIC_CONTRACT_VERSION,
  type PublicForecastLegacyPayloadV1,
  type PublicForecastModelSignals,
  type PublicForecastInputQuality,
  type PublicForecastPropHighlight,
  type PublicForecastProjectedLines,
  type PublicGroupedPlayer,
  type PublicGroupedPlayerPropRow,
  type PublicDigiviewEvidence,
  type PublicMlbMatchupContext,
  type PublicMlbPhaseContext,
  type PublicMlbPropContext,
  type PublicPlayerProp,
  type PublicTopGameCard,
  type PublicTopPickEntry,
  type PublicTopPicksResponseV1,
  type PublicTopPropCard,
} from './forecast-public-shared';

const INTERNAL_FORECAST_BLOB_KEYS = new Set([
  'model_signals',
  'modelSignals',
  'input_quality',
  'inputQuality',
  'rie_signals',
  'rieSignals',
  'rag_insights',
  'ragInsights',
  'strategy_id',
  'strategyId',
  '_suppressed_props',
  '_suppression_at',
  '_suppression_run_id',
]);

function toNumber(value: any): number | null {
  if (value == null || value === '') return null;
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : null;
}

function toNullableString(value: any): string | null {
  if (value == null || value === '') return null;
  return String(value);
}

function toNullableBoolean(value: any): boolean | null {
  if (value == null || value === '') return null;
  if (typeof value === 'boolean') return value;
  const normalized = String(value).trim().toLowerCase();
  if (['true', '1', 'yes', 'y'].includes(normalized)) return true;
  if (['false', '0', 'no', 'n'].includes(normalized)) return false;
  return null;
}

function camelizeKey(key: string): string {
  return key.replace(/_([a-z])/g, (_match, char: string) => char.toUpperCase());
}

export function camelizeObjectKeys<T = Record<string, any>>(value: any): T | null {
  if (value == null) return null;
  if (Array.isArray(value)) {
    return value.map((entry) => camelizeObjectKeys(entry)) as T;
  }
  if (typeof value !== 'object') {
    return value as T;
  }

  const next: Record<string, any> = {};
  for (const [key, entry] of Object.entries(value)) {
    next[camelizeKey(key)] = camelizeObjectKeys(entry);
  }
  return next as T;
}

function buildDeprecatedAliasFields<
  TCanonical extends Record<string, any>,
  TMap extends Record<string, keyof TCanonical>,
>(canonical: TCanonical, map: TMap): { [K in keyof TMap]: TCanonical[TMap[K]] } {
  const result: Partial<{ [K in keyof TMap]: TCanonical[TMap[K]] }> = {};
  for (const [aliasKey, canonicalKey] of Object.entries(map) as Array<[keyof TMap, TMap[keyof TMap]]>) {
    result[aliasKey] = canonical[canonicalKey];
  }
  return result as { [K in keyof TMap]: TCanonical[TMap[K]] };
}

function omitInternalForecastBlobKeys(value: any): Record<string, any> {
  const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
  return Object.fromEntries(
    Object.entries(source).filter(([key]) => !INTERNAL_FORECAST_BLOB_KEYS.has(key)),
  );
}

function toRatePercent(value: any): number | null {
  const parsed = toNumber(value);
  if (parsed == null) return null;
  if (parsed <= 1 && parsed >= 0) return Math.round(parsed * 1000) / 10;
  return Math.round(parsed * 10) / 10;
}

export function buildPublicDigiviewEvidence(input: {
  payload: any;
  commentary?: string | null;
  piffLeg?: Record<string, any> | null;
  history?: {
    last5Samples?: Array<{ gameDate?: string | null; opponent?: string | null; value?: number | null; hit?: boolean | null }> | null;
    last10Samples?: Array<{ gameDate?: string | null; opponent?: string | null; value?: number | null; hit?: boolean | null }> | null;
    h2hSamples?: Array<{ gameDate?: string | null; opponent?: string | null; value?: number | null; hit?: boolean | null }> | null;
  } | null;
  digimonPick?: {
    verdict?: string | null;
    digiLine?: number | null;
    missRate?: number | null;
    misses?: string | null;
  } | null;
}): PublicDigiviewEvidence | null {
  const modelContext = input.payload?.model_context ?? input.payload?.modelContext ?? null;
  const piffLeg = input.piffLeg || null;
  const seasonAverage = toNumber(modelContext?.season_average ?? modelContext?.seasonAverage ?? piffLeg?.season_avg ?? piffLeg?.seasonAverage);
  const last5Average = toNumber(modelContext?.last5_average ?? modelContext?.last5Average ?? modelContext?.l5_average ?? modelContext?.l5Average ?? piffLeg?.l5_avg ?? piffLeg?.last5_avg);
  const last10Average = toNumber(modelContext?.last10_average ?? modelContext?.last10Average ?? modelContext?.l10_average ?? modelContext?.l10Average ?? piffLeg?.l10_avg ?? piffLeg?.last10_avg);
  const seasonHitRatePct = toRatePercent(modelContext?.season_hit_rate ?? modelContext?.seasonHitRate ?? modelContext?.season_hr ?? modelContext?.seasonHr ?? modelContext?.hit_rate ?? modelContext?.hitRate ?? piffLeg?.szn_hr ?? piffLeg?.season_hr);
  const last5HitRatePct = toRatePercent(modelContext?.last5_hit_rate ?? modelContext?.last5HitRate ?? modelContext?.l5_hr ?? modelContext?.l5Hr ?? piffLeg?.l5_hr);
  const last10HitRatePct = toRatePercent(modelContext?.last10_hit_rate ?? modelContext?.last10HitRate ?? modelContext?.l10_hr ?? modelContext?.l10Hr ?? piffLeg?.l10_hr);
  const h2hGames = toNumber(modelContext?.h2h_games ?? modelContext?.h2hGames ?? piffLeg?.h2h_games);
  const h2hHitRatePct = toRatePercent(modelContext?.h2h_hr ?? modelContext?.h2hHr ?? modelContext?.h2h_hit_rate ?? modelContext?.h2hHitRate ?? piffLeg?.h2h_hr);
  const historyWindows = [
    { label: 'Season', average: seasonAverage, hitRatePct: seasonHitRatePct, sampleSize: null },
    { label: 'L5', average: last5Average, hitRatePct: last5HitRatePct, sampleSize: 5 },
    { label: 'L10', average: last10Average, hitRatePct: last10HitRatePct, sampleSize: 10 },
    { label: 'H2H', average: null, hitRatePct: h2hHitRatePct, sampleSize: h2hGames },
  ].filter((row) => row.average != null || row.hitRatePct != null || row.sampleSize != null);
  const evidence: PublicDigiviewEvidence = {
    last5Samples: input.history?.last5Samples ?? null,
    last10Samples: input.history?.last10Samples ?? null,
    h2hSamples: input.history?.h2hSamples ?? null,
    historyWindows: historyWindows.length > 0 ? historyWindows : null,
    tierLabel: toNullableString(modelContext?.tier_label ?? modelContext?.tierLabel),
    seasonAverage,
    last5Average,
    last10Average,
    seasonHitRatePct,
    last5HitRatePct,
    last10HitRatePct,
    projectedMinutes: toNumber(modelContext?.projected_minutes ?? modelContext?.projectedMinutes),
    isHome: toNullableBoolean(modelContext?.is_home ?? modelContext?.isHome ?? piffLeg?.is_home),
    isBackToBack: toNullableBoolean(modelContext?.is_b2b ?? modelContext?.isB2b ?? modelContext?.back_to_back ?? modelContext?.backToBack ?? piffLeg?.is_b2b),
    dvpTier: toNullableString(modelContext?.dvp_tier ?? modelContext?.dvpTier ?? piffLeg?.dvp_tier),
    dvpRank: toNumber(modelContext?.dvp_rank ?? modelContext?.dvpRank ?? piffLeg?.dvp_rank),
    dvpAdjustment: toNumber(piffLeg?.dvp_adj),
    dvpAligned: toNullableBoolean(piffLeg?.dvp_aligned),
    dvpMisaligned: toNullableBoolean(piffLeg?.dvp_misaligned),
    h2hGames,
    h2hHitRatePct,
    h2hFlag: toNullableString(piffLeg?.h2h_flag),
    missRatePct: toRatePercent(modelContext?.miss_rate ?? modelContext?.missRate ?? input.digimonPick?.missRate),
    misses: toNullableString(modelContext?.misses ?? input.digimonPick?.misses),
    injuryContext: toNullableString(modelContext?.injury_context ?? modelContext?.injuryContext),
    volatility: toNumber(modelContext?.volatility ?? piffLeg?.volatility),
    contextSummary: toNullableString(modelContext?.context_summary ?? modelContext?.contextSummary),
    commentary: toNullableString(input.commentary),
    lineSource: toNullableString(piffLeg?.line_source),
    availableBooks: toNumber(piffLeg?.available_books),
    lineupStatus: toNullableString(piffLeg?.lineup_status),
    lineupCertainty: toNullableString(modelContext?.lineup_certainty ?? modelContext?.lineupCertainty ?? piffLeg?.lineup_certainty),
    lineupSource: toNullableString(piffLeg?.lineup_source),
    gapValue: toNumber(piffLeg?.gap),
    decisionContext: toNullableString(piffLeg?.decision_context),
    digimonVerdict: toNullableString(input.digimonPick?.verdict),
    digimonLine: toNumber(input.digimonPick?.digiLine),
    lockGate: (() => {
      const verdict = String(input.digimonPick?.verdict || '').trim().toUpperCase();
      if (!verdict) return null;
      return verdict === 'LOCK' || verdict === 'PLAY' || verdict === 'LEAN' ? 'pass' : 'fail';
    })(),
  };

  const hasValue = Object.values(evidence).some((value) => value != null && value !== '');
  return hasValue ? evidence : null;
}

function serializeLegacyForecastPropHighlight(value: any): PublicForecastPropHighlight {
  return {
    player: toNullableString(value?.player),
    prop: toNullableString(value?.prop),
    recommendation: toNullableString(value?.recommendation),
    reasoning: toNullableString(value?.reasoning),
  };
}

function serializeLegacyProjectedLines(value: any): PublicForecastProjectedLines | null {
  if (!value || typeof value !== 'object') return null;
  return {
    moneyline: value.moneyline
      ? {
          home: toNumber(value.moneyline.home),
          away: toNumber(value.moneyline.away),
        }
      : null,
    spread: value.spread
      ? {
          home: toNumber(value.spread.home),
          away: toNumber(value.spread.away),
        }
      : null,
    total: toNumber(value.total),
  };
}

export function serializePublicForecastBlob(value: any): PublicForecastLegacyPayloadV1 {
  const source = omitInternalForecastBlobKeys(value);
  const serialized: PublicForecastLegacyPayloadV1 = { ...source };

  if ('prop_highlights' in source) {
    serialized.prop_highlights = Array.isArray(source.prop_highlights)
      ? source.prop_highlights.map((entry) => serializeLegacyForecastPropHighlight(entry))
      : null;
  }

  if ('projected_lines' in source) {
    serialized.projected_lines = serializeLegacyProjectedLines(source.projected_lines);
  }

  if ('mlb_phase_context' in source) {
    const mlbPhaseContext = serializeMlbPhaseContext(
      source.mlb_phase_context,
      source.mlb_matchup_context,
    );
    serialized.mlb_phase_context = mlbPhaseContext
      ? {
          phase_label: mlbPhaseContext.phaseLabel,
          k_rank: mlbPhaseContext.kRank,
          lineup_certainty: mlbPhaseContext.lineupCertainty,
          park_factor: mlbPhaseContext.parkFactor,
          weather_impact: mlbPhaseContext.weatherImpact,
        }
      : null;
  }

  if ('mlb_matchup_context' in source) {
    serialized.mlb_matchup_context = source.mlb_matchup_context && typeof source.mlb_matchup_context === 'object'
      ? {
          log5_home_win_prob: toNumber(source.mlb_matchup_context.log5_home_win_prob),
          implied_spread: toNumber(source.mlb_matchup_context.implied_spread),
          strength_diff: toNullableString(source.mlb_matchup_context.strength_diff),
          park_runs_factor: toNumber(source.mlb_matchup_context.park_runs_factor),
          park_hr_factor: toNumber(source.mlb_matchup_context.park_hr_factor),
          home_runs_created: toNumber(source.mlb_matchup_context.home_runs_created),
          away_runs_created: toNumber(source.mlb_matchup_context.away_runs_created),
          home_pythag_win_pct: toNumber(source.mlb_matchup_context.home_pythag_win_pct),
          away_pythag_win_pct: toNumber(source.mlb_matchup_context.away_pythag_win_pct),
          home_projected_wrc_plus: toNumber(source.mlb_matchup_context.home_projected_wrc_plus),
          away_projected_wrc_plus: toNumber(source.mlb_matchup_context.away_projected_wrc_plus),
          home_team_wrc_plus: toNumber(source.mlb_matchup_context.home_team_wrc_plus),
          away_team_wrc_plus: toNumber(source.mlb_matchup_context.away_team_wrc_plus),
          home_starter_fip: toNumber(source.mlb_matchup_context.home_starter_fip),
          away_starter_fip: toNumber(source.mlb_matchup_context.away_starter_fip),
        }
      : null;
  }

  return serialized;
}

/**
 * Deprecated alias shipped for v1 backward compatibility only.
 * `forecastBalance` is the canonical public field.
 */
export function buildForecastBalanceFields(total: number): PublicForecastBalanceFields {
  const canonical = { forecastBalance: total };
  return {
    ...canonical,
    ...buildDeprecatedAliasFields(canonical, DEPRECATED_FORECAST_RESPONSE_ALIAS_MAP),
  };
}

export function serializeMlbPhaseContext(value: any, matchupContext?: any): PublicMlbPhaseContext | null {
  if (!value || typeof value !== 'object') return null;

  const directPhaseLabel = toNullableString(value.phase_label);
  const directKRank = toNumber(value.k_rank);
  const directLineupCertainty = toNullableString(value.lineup_certainty);
  const directParkFactor = toNumber(value.park_factor);
  const derivedParkFactor = (() => {
    const runsFactor = toNumber(matchupContext?.park_runs_factor);
    if (runsFactor == null) return null;
    return Math.round(runsFactor * 100);
  })();
  const directWeatherImpact = toNullableString(value.weather_impact);

  const lineupHome = toNullableString(value?.context?.lineups?.homeStatus)?.toLowerCase() || null;
  const lineupAway = toNullableString(value?.context?.lineups?.awayStatus)?.toLowerCase() || null;
  const derivedLineupCertainty =
    lineupHome?.includes('confirmed') && lineupAway?.includes('confirmed')
      ? 'confirmed'
      : lineupHome || lineupAway
        ? (lineupHome?.includes('confirmed') || lineupAway?.includes('confirmed') ? 'mixed' : 'projected')
        : null;

  const weather = value?.context?.weather;
  const weatherRunBias = toNumber(value?.context?.weatherRunBias);
  const derivedWeatherTone = weatherRunBias == null
    ? null
    : weatherRunBias >= 0.04
      ? 'positive'
      : weatherRunBias <= -0.04
        ? 'negative'
        : 'neutral';
  const weatherBits = [
    weather?.temperatureF != null ? `${Math.round(Number(weather.temperatureF))}F` : null,
    weather?.windMph != null ? `${Math.round(Number(weather.windMph))} mph wind` : null,
    toNullableString(weather?.conditions)?.toLowerCase() || null,
  ].filter(Boolean);
  const derivedWeatherImpact = derivedWeatherTone
    ? (weatherBits.length > 0 ? `${derivedWeatherTone} (${weatherBits.join(', ')})` : derivedWeatherTone)
    : null;

  const homeKbb = toNumber(value?.firstFive?.homeStarter?.kBbPct);
  const awayKbb = toNumber(value?.firstFive?.awayStarter?.kBbPct);
  const strongestStarterKbb = [homeKbb, awayKbb].filter((entry): entry is number => entry != null).sort((a, b) => b - a)[0] ?? null;
  const derivedKRank = strongestStarterKbb == null
    ? null
    : Math.max(1, Math.min(10, Math.round(strongestStarterKbb * 40)));

  const hints = value?.applicationHints;
  const derivedPhaseLabel = hints
    ? (
        hints.propMatrixReady && hints.fullGameSideReady && hints.f5SideReady
          ? 'Ready'
          : hints.propMatrixReady || hints.fullGameSideReady || hints.f5SideReady
            ? 'Developing'
            : null
      )
    : null;

  return {
    phaseLabel: directPhaseLabel ?? derivedPhaseLabel,
    kRank: directKRank ?? derivedKRank,
    lineupCertainty: directLineupCertainty ?? derivedLineupCertainty,
    parkFactor: directParkFactor ?? derivedParkFactor,
    weatherImpact: directWeatherImpact ?? derivedWeatherImpact,
  };
}

export function serializeMlbMatchupContext(value: any): PublicMlbMatchupContext | null {
  if (!value || typeof value !== 'object') return null;
  return {
    log5HomeWinProb: toNumber(value.log5_home_win_prob),
    impliedSpread: toNumber(value.implied_spread),
    strengthDiff: toNullableString(value.strength_diff),
    parkRunsFactor: toNumber(value.park_runs_factor),
    parkHrFactor: toNumber(value.park_hr_factor),
    homeRunsCreated: toNumber(value.home_runs_created),
    awayRunsCreated: toNumber(value.away_runs_created),
    homePythagWinPct: toNumber(value.home_pythag_win_pct),
    awayPythagWinPct: toNumber(value.away_pythag_win_pct),
    homeProjectedWrcPlus: toNumber(value.home_projected_wrc_plus),
    awayProjectedWrcPlus: toNumber(value.away_projected_wrc_plus),
    homeTeamWrcPlus: toNumber(value.home_team_wrc_plus),
    awayTeamWrcPlus: toNumber(value.away_team_wrc_plus),
    homeStarterFip: toNumber(value.home_starter_fip),
    awayStarterFip: toNumber(value.away_starter_fip),
  };
}

export function maybeBuildMlbPhaseContext(
  value: any,
  league: string | null | undefined,
  enabled: boolean,
  matchupContext?: any,
): { mlbPhaseContext?: PublicMlbPhaseContext | null } {
  if (!enabled || String(league || '').toLowerCase() !== 'mlb') {
    return {};
  }
  if (!value || typeof value !== 'object') {
    return {};
  }
  return { mlbPhaseContext: serializeMlbPhaseContext(value, matchupContext) };
}

export function maybeBuildMlbMatchupContext(
  value: any,
  league: string | null | undefined,
): { mlbMatchupContext?: PublicMlbMatchupContext | null } {
  if (String(league || '').toLowerCase() !== 'mlb') {
    return {};
  }
  if (!value || typeof value !== 'object') {
    return {};
  }
  return { mlbMatchupContext: serializeMlbMatchupContext(value) };
}

export function serializeMlbPropContext(
  value: any,
  league: string | null | undefined,
  enabled: boolean,
): PublicMlbPropContext | null | undefined {
  if (!enabled || String(league || '').toLowerCase() !== 'mlb') return undefined;
  if (!value || typeof value !== 'object') return null;
  return {
    kRank: toNumber(value.k_rank),
    parkFactor: toNumber(value.park_factor),
    weatherImpact: toNullableString(value.weather_impact),
    handednessSplit: toNullableString(value.handedness_split),
    lineupCertainty: toNullableString(value.lineup_certainty),
  };
}

export function buildPublicGroupedPlayers(playerProps: PublicPlayerProp[]): PublicGroupedPlayer[] {
  const qualifiedSignals = playerProps
    .map((prop) => buildPlayerPropSignal({
      player: prop.player,
      team: prop.team,
      teamSide: prop.teamSide,
      league: prop.league,
      prop: prop.prop,
      statType: prop.statType,
      normalizedStatType: prop.normalizedStatType,
    marketLine: prop.marketLineValue ?? prop.line,
    odds: prop.odds,
    projectedProbability: prop.projectedProbability ?? prop.prob,
    projectedOutcome: prop.projectedOutcome ?? prop.projected_stat_value,
    edgePct: prop.edgePct ?? prop.edge,
    recommendation: prop.forecastDirection ?? prop.recommendation,
    playerRole: prop.playerRole,
    modelContext: prop.modelContext ?? null,
    marketQualityScore: prop.marketQualityScore ?? null,
    marketCompletenessStatus: prop.marketCompletenessStatus ?? null,
    sourceBacked: prop.sourceBacked ?? null,
  }))
    .filter((row): row is NonNullable<typeof row> => Boolean(row));

  return groupQualifiedSignalsByPlayer(qualifiedSignals)
    .map((group) => ({
      player: group.player,
      team: group.team,
      teamSide: group.teamSide,
      playerRole: group.playerRole,
      strongestSignal: group.strongestSignal,
      maxEdgePct: group.maxEdgePct,
      props: [...group.props].sort((a, b) => {
        if (a.signal !== b.signal) return getPlayerPropSignalRank(b.signal) - getPlayerPropSignalRank(a.signal);
        if ((b.edgePct ?? 0) !== (a.edgePct ?? 0)) return (b.edgePct ?? 0) - (a.edgePct ?? 0);
        if ((b.projectedProbability ?? 0) !== (a.projectedProbability ?? 0)) {
          return (b.projectedProbability ?? 0) - (a.projectedProbability ?? 0);
        }
        return a.propType.localeCompare(b.propType);
      }),
    }))
    .filter((group) => group.props.length > 0)
    .sort((a, b) => {
      if (a.strongestSignal !== b.strongestSignal) {
        return getPlayerPropSignalRank(b.strongestSignal) - getPlayerPropSignalRank(a.strongestSignal);
      }
      if (b.maxEdgePct !== a.maxEdgePct) return b.maxEdgePct - a.maxEdgePct;
      return a.player.localeCompare(b.player);
    });
}

export function serializePublicPlayerProp(input: {
  assetId: string;
  player: string | null | undefined;
  team: string | null | undefined;
  teamSide: 'home' | 'away' | null | undefined;
  league: string | null | undefined;
  prop: string | null | undefined;
  locked: boolean;
  confidence: number | null | undefined;
  payload: any;
  marketType: string | null;
  marketFamily: string | null;
  marketOrigin: string | null;
  sourceBacked: boolean | null;
  playerRole: string | null;
  includeMlbPropContext: boolean;
}): PublicPlayerProp | null {
  const projectedOutcome = toNumber(input.payload?.projected_stat_value);
  const statType = toNullableString(input.payload?.stat_type);
  const normalizedStatType = toNullableString(input.payload?.normalized_stat_type ?? input.payload?.stat_type);
  const marketLineValue = toNumber(input.payload?.market_line_value ?? input.payload?.line);
  const projectedProbability = toNumber(input.payload?.projected_probability ?? input.payload?.prob);
  const explicitEdgePct = toNumber(input.payload?.edge_pct ?? input.payload?.edge);
  const signal = buildPlayerPropSignal({
    player: input.player,
    team: input.team,
    teamSide: input.teamSide,
    league: input.league,
    prop: input.prop,
    statType,
    normalizedStatType,
    marketLine: marketLineValue,
    odds: input.payload?.odds ?? null,
    projectedProbability,
    projectedOutcome,
    edgePct: explicitEdgePct,
    recommendation: input.payload?.forecast_direction ?? input.payload?.recommendation ?? null,
    playerRole: input.payload?.player_role ?? input.playerRole ?? null,
    modelContext: input.payload?.model_context ?? null,
  });
  const mlbPropContext = serializeMlbPropContext(
    input.payload?.model_context,
    input.league,
    input.includeMlbPropContext,
  );

  const base: PublicPlayerProp = {
    assetId: input.assetId,
    player: toNullableString(input.player),
    team: toNullableString(input.team),
    teamSide: input.teamSide ?? null,
    league: toNullableString(input.league),
    prop: toNullableString(input.prop),
    locked: input.locked,
    confidence: input.confidence ?? null,
  };

  const canonicalSignalFields = {
    projectedOutcome,
    statType,
    normalizedStatType,
    marketLine: marketLineValue,
    marketLineValue,
    projectedProbability: projectedProbability ?? undefined,
  };

  const enrichedBase: PublicPlayerProp = {
    ...base,
    recommendation: toNullableString(input.payload?.recommendation),
    reasoning: toNullableString(input.payload?.reasoning),
    edge: toNumber(input.payload?.edge),
    edgePct: explicitEdgePct ?? undefined,
    odds: toNumber(input.payload?.odds),
    ...buildDeprecatedAliasFields(canonicalSignalFields, DEPRECATED_PLAYER_PROP_ALIAS_MAP),
    ...canonicalSignalFields,
    marketImpliedProbability: americanOddsToImpliedProbability(toNumber(input.payload?.odds)) ?? undefined,
    forecastDirection: normalizePlayerPropDirection(
      input.payload?.forecast_direction ?? input.payload?.recommendation ?? null,
    ) ?? undefined,
    gradingCategory: toNullableString(input.payload?.grading_category),
    marketType: input.marketType,
    marketFamily: input.marketFamily,
    marketOrigin: input.marketOrigin,
    sourceBacked: input.sourceBacked,
    playerRole: toNullableString(input.payload?.player_role ?? input.playerRole),
    modelContext: camelizeObjectKeys(input.payload?.model_context),
    ...(input.includeMlbPropContext ? { mlbPropContext } : {}),
    agreementScore: toNumber(input.payload?.agreement_score) ?? undefined,
    agreementLabel: toNullableString(input.payload?.agreement_label) as PublicPlayerProp['agreementLabel'],
    agreementSources: Array.isArray(input.payload?.agreement_sources)
      ? input.payload.agreement_sources.map((value: any) => String(value))
      : undefined,
    marketQualityScore: toNumber(input.payload?.market_quality_score) ?? undefined,
    marketQualityLabel: toNullableString(input.payload?.market_quality_label) as PublicPlayerProp['marketQualityLabel'],
    marketCompletenessStatus: (toNullableString(input.payload?.market_completeness_status) as PublicPlayerProp['marketCompletenessStatus']) ?? undefined,
    resultOutcome: toNullableString(input.payload?.result_outcome),
    closingLineValue: toNumber(input.payload?.closing_line_value),
    closingOddsSnapshot: toNumber(input.payload?.closing_odds_snapshot),
  };

  if (input.locked) {
    return base;
  }

  if (!signal) {
    return enrichedBase;
  }

  return {
    ...enrichedBase,
    projectedProbability: signal.projectedProbability,
    marketImpliedProbability: signal.marketImpliedProbability,
    signalTier: signal.signalTier,
    forecastDirection: signal.forecastDirection,
    agreementScore: signal.agreementScore,
    agreementLabel: signal.agreementLabel,
    agreementSources: signal.agreementSources,
    marketQualityScore: signal.marketQualityScore,
    marketQualityLabel: signal.marketQualityLabel,
    signalTableRow: signal.tableRow,
  };
}
