"""
VYNDR MLB Blueprint
Pitcher profiles, batter profiles, matchup data, bullpen state, catcher framing,
ABS challenge system, TTO decay, opponent quality, lineup protection, day/night splits,
and the full 14-step MLB grade pipeline.

All data sourced via pybaseball with ThreadPoolExecutor(max_workers=3) to respect
rate limits. Grade endpoint restricted to 20/min at the app-level limiter.
"""

import os
import json
import logging
import traceback
from datetime import datetime, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed

import numpy as np
from flask import Blueprint, request, jsonify

from utils.bayesian import (
    bayesian_blend, BAYESIAN_WEIGHTS, GRADE_THRESHOLDS,
    data_sufficiency_modifier
)
from utils.edge_calculator import calculate_real_edge, quarter_kelly
from utils.context_aggregator import aggregate_context_adjustments
from utils.archetypes import (
    PITCHER_DIMENSIONS, BATTER_DIMENSIONS,
    get_archetype_scores, blend_archetype_weights,
    DEFAULT_MLB_PITCHER_WEIGHTS, DEFAULT_MLB_BATTER_WEIGHTS
)
from utils.data_warehouse import fetch_with_cache, store_grade_log
from utils.retry import api_call_with_retry
from utils.weather import get_weather_adjustment
from utils.blind_spot_detector import check_blind_spots

logger = logging.getLogger('vyndr')

mlb_bp = Blueprint('mlb', __name__)

# pybaseball import — graceful fallback
try:
    import pybaseball as pb
    HAS_PYBASEBALL = True
except ImportError:
    HAS_PYBASEBALL = False
    logger.warning('[mlb] pybaseball not installed — profile endpoints will return stubs')

# statsapi for roster/bullpen data
try:
    import statsapi
    HAS_STATSAPI = True
except ImportError:
    HAS_STATSAPI = False
    logger.warning('[mlb] statsapi not installed — bullpen/roster endpoints limited')

# Max 3 concurrent pybaseball calls to avoid rate-limiting
_executor = ThreadPoolExecutor(max_workers=3)

# MLB kill condition stat types
PITCHING_STATS = [
    'strikeouts', 'earned_runs', 'outs_recorded', 'walks_allowed',
    'hits_allowed', 'pitches_thrown',
]
HITTING_STATS = [
    'hits', 'total_bases', 'home_runs', 'rbis', 'runs_scored',
    'strikeouts_batter', 'walks', 'stolen_bases',
]
ALL_MLB_STATS = PITCHING_STATS + HITTING_STATS

# Global offset — calibrated via Brier score optimization
GLOBAL_OFFSET = float(os.environ.get('MLB_GLOBAL_OFFSET', '0.0'))

# Minimum data requirements
MIN_GAMES_PITCHER = 3
MIN_GAMES_BATTER = 10
MIN_PA_MATCHUP = 10


# ============================================================
# 1. MLB DATA ENDPOINTS
# ============================================================


@mlb_bp.route('/pitcher-profile/<pitcher_id>', methods=['GET'])
def get_pitcher_profile(pitcher_id):
    """
    Return comprehensive pitcher profile for grading.

    Includes: pitch mix, velocity, velocity delta, whiff rate, CSW%, Zone%, K/BB rate,
    ERA, FIP, GB/FB rate, pitches per inning, innings per start, pitch count, days rest,
    season IP, handedness, TTO splits, opponent quality.

    Uses pybaseball with max 3 concurrent fetches via ThreadPoolExecutor.

    Args:
        pitcher_id: MLB pitcher ID (Statcast/FanGraphs).

    Returns:
        JSON pitcher profile or error.
    """
    try:
        profile = _build_pitcher_profile(pitcher_id)
        if not profile:
            return jsonify({'error': 'Pitcher not found', 'pitcher_id': pitcher_id}), 404
        return jsonify(profile)
    except Exception as e:
        logger.error(f'[mlb] Pitcher profile error: {e}\n{traceback.format_exc()}')
        return jsonify({'error': str(e), 'pitcher_id': pitcher_id}), 500


@mlb_bp.route('/batter-profile/<batter_id>', methods=['GET'])
def get_batter_profile(batter_id):
    """
    Return comprehensive batter profile for grading.

    Includes: exit velo, hard hit%, barrel%, launch angle, xwOBA, wOBA, K%, BB%,
    chase rate, first pitch swing rate, sprint speed, ISO, platoon splits,
    RISP/home-road/day-night splits, handedness, lineup position.

    Args:
        batter_id: MLB batter ID (Statcast/FanGraphs).

    Returns:
        JSON batter profile or error.
    """
    try:
        profile = _build_batter_profile(batter_id)
        if not profile:
            return jsonify({'error': 'Batter not found', 'batter_id': batter_id}), 404
        return jsonify(profile)
    except Exception as e:
        logger.error(f'[mlb] Batter profile error: {e}\n{traceback.format_exc()}')
        return jsonify({'error': str(e), 'batter_id': batter_id}), 500


@mlb_bp.route('/matchup/<pitcher_id>/<batter_id>', methods=['GET'])
def get_matchup(pitcher_id, batter_id):
    """
    Head-to-head career stats between pitcher and batter.

    If fewer than 10 plate appearances, returns null result with
    insufficient_sample flag set to true.

    Args:
        pitcher_id: MLB pitcher ID.
        batter_id: MLB batter ID.

    Returns:
        JSON matchup data or insufficient_sample response.
    """
    try:
        matchup = _fetch_h2h_matchup(pitcher_id, batter_id)
        if matchup is None:
            return jsonify({
                'pitcher_id': pitcher_id,
                'batter_id': batter_id,
                'data': None,
                'insufficient_sample': True,
                'note': f'Fewer than {MIN_PA_MATCHUP} career PA between these players'
            })
        return jsonify({
            'pitcher_id': pitcher_id,
            'batter_id': batter_id,
            'data': matchup,
            'insufficient_sample': False
        })
    except Exception as e:
        logger.error(f'[mlb] Matchup error: {e}\n{traceback.format_exc()}')
        return jsonify({'error': str(e)}), 500


@mlb_bp.route('/bullpen-state/<team_id>', methods=['GET'])
def get_bullpen_state(team_id):
    """
    Return bullpen workload state for a team.

    Includes: IP thrown over last 3 days per reliever, taxed flag (true if
    aggregate bullpen IP > 9.0 in trailing 3 days), and starter leash
    adjustment factor (shorter leash when bullpen is rested, longer when taxed).

    Args:
        team_id: MLB team ID.

    Returns:
        JSON bullpen state.
    """
    try:
        state = _build_bullpen_state(team_id)
        return jsonify(state)
    except Exception as e:
        logger.error(f'[mlb] Bullpen state error: {e}\n{traceback.format_exc()}')
        return jsonify({'error': str(e), 'team_id': team_id}), 500


@mlb_bp.route('/catcher-framing/<catcher_id>', methods=['GET'])
def get_catcher_framing(catcher_id):
    """
    Return catcher framing metrics.

    Framing runs per game and a strikeout adjustment value between -0.5 and +0.5
    representing how much the catcher's framing shifts called strike probability,
    which flows into the K projection for the opposing lineup.

    Args:
        catcher_id: MLB catcher ID.

    Returns:
        JSON framing data.
    """
    try:
        framing = _build_catcher_framing(catcher_id)
        return jsonify(framing)
    except Exception as e:
        logger.error(f'[mlb] Catcher framing error: {e}\n{traceback.format_exc()}')
        return jsonify({'error': str(e), 'catcher_id': catcher_id}), 500


# ============================================================
# 2. ABS CHALLENGE SYSTEM
# ============================================================


def apply_abs_challenge_system(projection, stat_type, pitcher_profile,
                               batter_profile, catcher_id, umpire_id,
                               game_context):
    """
    Apply the Automated Ball-Strike (ABS) challenge system effects to a projection.

    Four effects modeled:

    1. Zone Standardization (~2-3%): ABS tightens the zone to a consistent rectangle,
       removing umpire-specific tendencies. Pitchers who benefit from expanded zones
       see a 2-3% reduction in called strikes.

    2. Batter Discipline Score (player-specific, NOT flat): Combines chase_rate and
       bb_rate into a discipline score. Disciplined batters (low chase, high walk)
       benefit MORE from challenges because they recognize true balls. Undisciplined
       batters gain less — they swing at pitches regardless.

    3. Catcher Framing Reduction: Framing advantage is reduced proportional to batter
       discipline. Against disciplined batters, framing is cut by 50%. Against
       undisciplined batters, framing retains 80% of its effect (they swing before
       the call matters).

    4. Pitcher Edge Vulnerability: Corner painters (high zone%, low whiff rate on
       pitches outside the zone) lose the most. Their borderline calls get overturned
       more frequently.

    Args:
        projection: Float base projection for the stat.
        stat_type: Stat type string (e.g., 'strikeouts').
        pitcher_profile: Dict with pitcher metrics.
        batter_profile: Dict with batter metrics.
        catcher_id: MLB catcher ID for framing lookup.
        umpire_id: MLB umpire ID (unused in ABS — zone is standardized).
        game_context: Dict with game-level context.

    Returns:
        Tuple of (adjusted_projection, discipline_score).
    """
    if not game_context.get('abs_enabled', False):
        discipline_score = _calculate_discipline_score(batter_profile)
        return projection, discipline_score

    adjusted = projection

    # --- Effect 1: Zone standardization (~2-3%) ---
    zone_pct = pitcher_profile.get('zone_pct_season', 0.45)
    # Pitchers above league-average zone% lose more called strikes
    zone_std_impact = max(0.0, (zone_pct - 0.44)) * 0.15  # ~2-3% for high-zone pitchers
    zone_std_impact = min(zone_std_impact, 0.03)

    if stat_type in ('strikeouts', 'outs_recorded'):
        adjusted *= (1.0 - zone_std_impact)
    elif stat_type in ('walks', 'walks_allowed'):
        adjusted *= (1.0 + zone_std_impact)

    # --- Effect 2: Batter discipline score (player-specific) ---
    discipline_score = _calculate_discipline_score(batter_profile)

    # Disciplined batters benefit more from challenges
    # Scale: 0.0 (undisciplined, no benefit) to 1.0 (elite discipline, max benefit)
    challenge_impact = discipline_score * 0.025  # up to 2.5% for elite discipline
    if stat_type in ('strikeouts', 'strikeouts_batter'):
        adjusted *= (1.0 - challenge_impact)  # fewer Ks for disciplined batters
    elif stat_type == 'walks':
        adjusted *= (1.0 + challenge_impact)  # more walks

    # --- Effect 3: Catcher framing reduction ---
    framing_data = _build_catcher_framing(catcher_id)
    raw_framing_adj = framing_data.get('k_adjustment', 0.0)

    # Reduce framing proportional to discipline:
    # 50% retained vs disciplined batters, 80% retained vs undisciplined
    framing_retention = 0.80 - (discipline_score * 0.30)  # 0.50 to 0.80
    framing_retention = max(0.50, min(0.80, framing_retention))
    effective_framing = raw_framing_adj * framing_retention

    if stat_type in ('strikeouts', 'outs_recorded'):
        adjusted += effective_framing

    # --- Effect 4: Pitcher edge vulnerability ---
    # Corner painters: high zone%, low o-swing whiff rate
    whiff_rate = pitcher_profile.get('whiff_rate_season', 0.24)
    is_corner_painter = zone_pct > 0.48 and whiff_rate < 0.22
    if is_corner_painter:
        corner_penalty = 0.02  # 2% additional K reduction
        if stat_type in ('strikeouts', 'outs_recorded'):
            adjusted *= (1.0 - corner_penalty)
        elif stat_type in ('walks', 'walks_allowed'):
            adjusted *= (1.0 + corner_penalty)

    logger.info(
        f'[mlb] ABS applied: stat={stat_type}, base={projection:.3f}, '
        f'adjusted={adjusted:.3f}, discipline={discipline_score:.3f}, '
        f'framing_retention={framing_retention:.2f}'
    )

    return round(adjusted, 4), round(discipline_score, 4)


def _calculate_discipline_score(batter_profile):
    """
    Calculate batter discipline score from chase rate and walk rate.

    Discipline is player-specific — NOT a flat league-wide adjustment.
    Combines two signals:
      - Low chase rate = recognizes balls better
      - High BB% = patient approach converts to walks

    Score range: 0.0 (free swinger) to 1.0 (elite discipline).

    Args:
        batter_profile: Dict with chase_rate and bb_rate keys.

    Returns:
        Float discipline score between 0.0 and 1.0.
    """
    chase_rate = batter_profile.get('chase_rate', 0.30)
    bb_rate = batter_profile.get('bb_rate', 0.08)

    # Lower chase = more disciplined (league avg ~0.28-0.30)
    chase_component = max(0.0, min(1.0, (0.35 - chase_rate) / 0.15))

    # Higher BB% = more disciplined (league avg ~0.08)
    walk_component = max(0.0, min(1.0, (bb_rate - 0.04) / 0.10))

    # Weighted blend: chase rate matters more for ABS challenge decisions
    discipline = (chase_component * 0.60) + (walk_component * 0.40)
    return round(max(0.0, min(1.0, discipline)), 4)


# ============================================================
# 3. TTO DECAY
# ============================================================


def apply_tto_adjustment(k_projection, pitcher_profile, expected_innings):
    """
    Apply Times Through the Order decay to a strikeout projection.

    Batters see the pitcher's arsenal more clearly each time through.
    K rate drops ~15% from 1st TTO to 3rd TTO on average. Pitchers with
    deep arsenals (4+ pitch types) decay less. Two-pitch pitchers decay more.

    Args:
        k_projection: Base strikeout projection (float).
        pitcher_profile: Dict with pitch_mix, arsenal_size, pitches_per_inning.
        expected_innings: Float expected innings pitched.

    Returns:
        Float adjusted K projection accounting for TTO decay.
    """
    arsenal_size = pitcher_profile.get('arsenal_size', 3)
    pitches_per_inning = pitcher_profile.get('pitches_per_inning', 16.0)

    # Estimate times through the order based on expected innings
    # 1st TTO: innings 1-3, 2nd TTO: innings 4-6, 3rd TTO: innings 7+
    if expected_innings <= 3.0:
        tto_factor = 1.0  # only facing lineup once
    elif expected_innings <= 6.0:
        # Blend of 1st and 2nd TTO
        pct_second_tto = (expected_innings - 3.0) / 3.0
        base_decay = 0.92  # ~8% K rate drop in 2nd TTO
        tto_factor = 1.0 - (pct_second_tto * (1.0 - base_decay))
    else:
        # 3rd TTO territory
        pct_third_tto = min(1.0, (expected_innings - 6.0) / 3.0)
        second_tto_decay = 0.92
        third_tto_decay = 0.85  # ~15% K rate drop by 3rd TTO
        tto_factor = second_tto_decay - (pct_third_tto * (second_tto_decay - third_tto_decay))

    # Arsenal depth modifier: 4+ pitches decay less, 2 pitches decay more
    if arsenal_size >= 5:
        arsenal_modifier = 0.60  # only 60% of the normal decay
    elif arsenal_size >= 4:
        arsenal_modifier = 0.80
    elif arsenal_size <= 2:
        arsenal_modifier = 1.25  # 25% more decay
    else:
        arsenal_modifier = 1.0  # 3-pitch mix = baseline

    # Apply arsenal-adjusted decay
    decay_amount = (1.0 - tto_factor) * arsenal_modifier
    adjusted_factor = max(0.80, 1.0 - decay_amount)  # floor at 20% reduction

    adjusted = k_projection * adjusted_factor

    logger.debug(
        f'[mlb] TTO adjustment: base_k={k_projection:.2f}, innings={expected_innings}, '
        f'arsenal={arsenal_size}, factor={adjusted_factor:.3f}, adjusted={adjusted:.2f}'
    )

    return round(adjusted, 3)


# ============================================================
# 4. OPPONENT QUALITY (PLATOON-SPECIFIC)
# ============================================================


def get_platoon_specific_opponent_quality(pitcher_profile, opposing_lineup,
                                         pitcher_hand):
    """
    Calculate opponent quality accounting for platoon splits.

    A RHP facing a lineup stacked with lefties is in more trouble than the
    same RHP facing a righty-heavy lineup. Generic team wOBA misses this.

    Args:
        pitcher_profile: Dict with pitcher handedness and stats.
        opposing_lineup: List of batter dicts with handedness and wOBA splits.
        pitcher_hand: 'L' or 'R'.

    Returns:
        Float opponent quality score (0.0 = weak, 1.0 = elite).
    """
    if not opposing_lineup:
        return 0.50  # neutral default

    platoon_wobas = []
    for batter in opposing_lineup:
        batter_hand = batter.get('handedness', 'R')
        # Use the split that matches the matchup
        if pitcher_hand == 'R':
            woba = batter.get('woba_vs_rhp', batter.get('woba', 0.320))
        else:
            woba = batter.get('woba_vs_lhp', batter.get('woba', 0.320))

        platoon_wobas.append(woba)

    if not platoon_wobas:
        return 0.50

    avg_woba = np.mean(platoon_wobas)

    # Normalize: league avg wOBA ~0.315, scale 0-1
    # 0.280 = weak lineup (0.0), 0.350 = elite lineup (1.0)
    quality = max(0.0, min(1.0, (avg_woba - 0.280) / 0.070))
    return round(quality, 4)


def weight_starts_by_opponent_quality(game_logs, pitcher_hand):
    """
    Weight a pitcher's recent starts by the quality of the opposing lineup faced.

    A 10-K game against the 2024 White Sox means less than 10 Ks against
    the Dodgers. Weight each start by platoon-specific opponent quality.

    Args:
        game_logs: List of game log dicts with opposing_lineup and stat values.
        pitcher_hand: 'L' or 'R'.

    Returns:
        List of (weight, game_log) tuples.
    """
    weighted = []
    for game in game_logs:
        lineup = game.get('opposing_lineup', [])
        opp_quality = get_platoon_specific_opponent_quality({}, lineup, pitcher_hand)
        # Weight range: 0.7 (weak opponent) to 1.3 (elite opponent)
        weight = 0.7 + (opp_quality * 0.6)
        weighted.append((round(weight, 3), game))

    return weighted


def calculate_weighted_trend(game_logs, stat_key, pitcher_hand, window=10):
    """
    Calculate a weighted trend line using opponent-quality-adjusted game logs.

    More recent games are weighted higher (recency), and games against
    better opponents are weighted higher (quality). Dual-weighted.

    Args:
        game_logs: List of game log dicts, most recent first.
        stat_key: Stat key to trend (e.g., 'strikeouts').
        pitcher_hand: 'L' or 'R'.
        window: Number of recent games to include.

    Returns:
        Dict with weighted_avg, raw_avg, trend_direction, sample_size.
    """
    recent = game_logs[:window]
    if not recent:
        return {'weighted_avg': None, 'raw_avg': None, 'trend_direction': 'neutral',
                'sample_size': 0}

    opp_weighted = weight_starts_by_opponent_quality(recent, pitcher_hand)

    total_weight = 0.0
    weighted_sum = 0.0
    raw_values = []

    for i, (opp_weight, game) in enumerate(opp_weighted):
        val = game.get(stat_key)
        if val is None:
            continue
        # Recency weight: most recent = 1.0, oldest = 0.5
        recency_weight = 1.0 - (i * 0.5 / max(len(opp_weighted) - 1, 1))
        combined_weight = opp_weight * recency_weight
        weighted_sum += val * combined_weight
        total_weight += combined_weight
        raw_values.append(val)

    if total_weight == 0 or not raw_values:
        return {'weighted_avg': None, 'raw_avg': None, 'trend_direction': 'neutral',
                'sample_size': 0}

    weighted_avg = weighted_sum / total_weight
    raw_avg = np.mean(raw_values)

    # Trend direction: compare first half to second half
    mid = len(raw_values) // 2
    if mid > 0:
        first_half = np.mean(raw_values[:mid])
        second_half = np.mean(raw_values[mid:])
        if second_half > first_half * 1.10:
            direction = 'up'
        elif second_half < first_half * 0.90:
            direction = 'down'
        else:
            direction = 'stable'
    else:
        direction = 'neutral'

    return {
        'weighted_avg': round(weighted_avg, 3),
        'raw_avg': round(raw_avg, 3),
        'trend_direction': direction,
        'sample_size': len(raw_values)
    }


# ============================================================
# 5. LINEUP PROTECTION
# ============================================================


def calculate_lineup_protection(batter_order_pos, lineup_data):
    """
    Calculate lineup protection adjustment for a batter.

    Batters with strong hitters behind them see better pitches — pitchers
    cannot pitch around them. A #3 hitter with a stacked #4 and #5
    behind them gets an offensive boost. A cleanup hitter followed by
    weak bats gets pitched around.

    Protection is most relevant for: hits, total_bases, home_runs, rbis.
    Minimal impact on strikeouts (pitchers still try to get Ks).

    Args:
        batter_order_pos: 1-indexed lineup position (1-9).
        lineup_data: List of 9 batter dicts with wOBA/OPS values, in order.

    Returns:
        Dict with protection_score (0.0-1.0), adjustment_factor, and
        protecting_batters info.
    """
    if not lineup_data or batter_order_pos < 1 or batter_order_pos > 9:
        return {
            'protection_score': 0.50,
            'adjustment_factor': 1.0,
            'protecting_batters': []
        }

    idx = batter_order_pos - 1
    # Look at next 2 hitters (with wraparound for #8 and #9)
    protectors = []
    for offset in [1, 2]:
        protector_idx = (idx + offset) % 9
        if protector_idx < len(lineup_data):
            protectors.append(lineup_data[protector_idx])

    if not protectors:
        return {
            'protection_score': 0.50,
            'adjustment_factor': 1.0,
            'protecting_batters': []
        }

    # Average wOBA of protecting hitters
    protector_wobas = [p.get('woba', 0.315) for p in protectors]
    avg_protector_woba = np.mean(protector_wobas)

    # Scale: 0.280 wOBA = no protection, 0.380 wOBA = elite protection
    protection_score = max(0.0, min(1.0, (avg_protector_woba - 0.280) / 0.100))

    # Adjustment factor: 0.98 (no protection) to 1.04 (elite protection)
    adjustment_factor = 0.98 + (protection_score * 0.06)

    return {
        'protection_score': round(protection_score, 4),
        'adjustment_factor': round(adjustment_factor, 4),
        'protecting_batters': [
            {
                'name': p.get('name', 'Unknown'),
                'woba': p.get('woba', 0.315)
            }
            for p in protectors
        ]
    }


# ============================================================
# 6. DAY / NIGHT SPLITS
# ============================================================


def calculate_day_night_adjustment(player_profile, stat_type, is_day_game):
    """
    Calculate day/night split adjustment for a player.

    Some players have significant splits between day and night games.
    Only applied when the difference exceeds 5% to avoid noise.

    Day games: first pitch before 5:00 PM local time.
    Night games: first pitch at or after 5:00 PM local time.

    Args:
        player_profile: Dict with day_avg and night_avg for the stat type.
        stat_type: Stat type string.
        is_day_game: Boolean — true if day game.

    Returns:
        Float adjustment to projection (positive = boost, negative = penalty).
    """
    day_key = f'{stat_type}_day'
    night_key = f'{stat_type}_night'

    day_avg = player_profile.get(day_key, player_profile.get('splits', {}).get(day_key))
    night_avg = player_profile.get(night_key, player_profile.get('splits', {}).get(night_key))

    if day_avg is None or night_avg is None:
        return 0.0

    overall_avg = (day_avg + night_avg) / 2.0
    if overall_avg == 0:
        return 0.0

    # Only apply if >5% difference
    pct_diff = abs(day_avg - night_avg) / overall_avg
    if pct_diff < 0.05:
        return 0.0

    relevant_avg = day_avg if is_day_game else night_avg
    adjustment = relevant_avg - overall_avg

    logger.debug(
        f'[mlb] Day/night split: stat={stat_type}, day={day_avg:.3f}, '
        f'night={night_avg:.3f}, is_day={is_day_game}, adj={adjustment:.3f}'
    )

    return round(adjustment, 4)


# ============================================================
# 7. GRADE ENDPOINT — FULL 14-STEP MLB PIPELINE
# ============================================================


# NOTE: Rate limit applied at app level — @limiter.limit("20 per minute")
# The limiter is registered on the Flask app in app.py and applies to this
# blueprint route via url_prefix. To enforce 20/min specifically on this
# endpoint, add the decorator when importing limiter from the app module.

@mlb_bp.route('/grade', methods=['POST'])
def grade_mlb_prop():
    """
    Full MLB grade pipeline — 14 steps from raw request to final grade.

    Steps:
        1.  Kill conditions check (injuries, scratched, suspended, etc.)
        2.  Regime check (recent role change, IL return, trade, callup)
        3.  Build pitcher profile (pybaseball + cache)
        4.  Build batter profile (pybaseball + cache)
        5.  Detect archetypes + blend weights (pitcher dimensions or batter dimensions)
        6.  Calculate PTI/BCS sub-scores (Pitcher Threat Index / Batter Context Score)
        7.  Context adjustments (weather, park, ABS, day/night, lineup, bullpen, TTO,
            catcher framing, opponent quality, umpire)
        8.  Aggregate context adjustments into single modifier
        9.  Bayesian blend (prior + recent + context with stat-specific weights)
        10. Calculate real edge + quarter-Kelly
        11. Abstention check (edge < threshold or confidence < floor)
        12. Apply global offset + map to letter grade
        13. Minimum data sufficiency check (degrade confidence if sparse)
        14. Log everything to data warehouse for calibration

    Request body:
        {
            "player_name": str,
            "player_id": str,
            "stat_type": str,
            "line": float,
            "over_under": "over" | "under",
            "odds": int (American),
            "pitcher_id": str (optional, for batter props),
            "catcher_id": str (optional),
            "umpire_id": str (optional),
            "game_id": str (optional),
            "user_id": str (optional),
            "game_context": dict (optional)
        }

    Returns:
        JSON with grade, confidence, edge, sub_scores, adjustments, kelly,
        abstain flag, and full audit trail.
    """
    data = request.get_json()
    if not data:
        return jsonify({'error': 'Request body required'}), 400

    player_name = data.get('player_name', '')
    player_id = data.get('player_id', '')
    stat_type = data.get('stat_type', '')
    line = data.get('line')
    over_under = data.get('over_under', 'over')
    odds = data.get('odds', -110)
    pitcher_id = data.get('pitcher_id')
    catcher_id = data.get('catcher_id')
    umpire_id = data.get('umpire_id')
    game_id = data.get('game_id')
    user_id = data.get('user_id')
    game_context = data.get('game_context', {})

    if not stat_type or line is None:
        return jsonify({'error': 'stat_type and line are required'}), 400

    if stat_type not in ALL_MLB_STATS:
        return jsonify({'error': f'Unsupported stat_type: {stat_type}'}), 400

    is_pitcher_prop = stat_type in PITCHING_STATS
    audit_trail = []
    start_time = datetime.utcnow()

    try:
        # --- Step 1: Kill conditions ---
        kill_conditions = _check_kill_conditions(player_id, stat_type, game_context)
        audit_trail.append({'step': 1, 'name': 'kill_conditions', 'result': kill_conditions})

        if kill_conditions.get('hard_kill'):
            return jsonify({
                'grade': 'KILL',
                'confidence': 0,
                'edge': 0.0,
                'kill_conditions': kill_conditions,
                'reason': kill_conditions.get('reason', 'Hard kill condition triggered'),
                'abstain': True,
                'audit_trail': audit_trail
            })

        # --- Step 2: Regime check ---
        regime = _check_regime(player_id, stat_type, game_context)
        audit_trail.append({'step': 2, 'name': 'regime_check', 'result': regime})

        # --- Step 3: Build pitcher profile ---
        if is_pitcher_prop:
            pitcher_profile = _build_pitcher_profile(player_id)
            batter_profile = {}
        else:
            pitcher_profile = _build_pitcher_profile(pitcher_id) if pitcher_id else {}
            batter_profile = _build_batter_profile(player_id)

        audit_trail.append({'step': 3, 'name': 'pitcher_profile',
                            'has_data': bool(pitcher_profile)})

        # --- Step 4: Build batter profile ---
        audit_trail.append({'step': 4, 'name': 'batter_profile',
                            'has_data': bool(batter_profile)})

        # --- Step 5: Detect archetypes + blend weights ---
        if is_pitcher_prop and pitcher_profile:
            archetype_scores = get_archetype_scores(pitcher_profile, PITCHER_DIMENSIONS)
            blended_weights = blend_archetype_weights(
                pitcher_profile, PITCHER_DIMENSIONS, DEFAULT_MLB_PITCHER_WEIGHTS
            )
        elif batter_profile:
            archetype_scores = get_archetype_scores(batter_profile, BATTER_DIMENSIONS)
            blended_weights = blend_archetype_weights(
                batter_profile, BATTER_DIMENSIONS, DEFAULT_MLB_BATTER_WEIGHTS
            )
        else:
            archetype_scores = {}
            blended_weights = {}

        audit_trail.append({'step': 5, 'name': 'archetypes',
                            'scores': {k: round(v, 3) for k, v in archetype_scores.items()}})

        # --- Step 6: Calculate PTI/BCS sub-scores ---
        sub_scores = _calculate_sub_scores(
            stat_type, pitcher_profile, batter_profile, game_context, blended_weights
        )
        audit_trail.append({'step': 6, 'name': 'sub_scores', 'result': sub_scores})

        # --- Step 7: Context adjustments ---
        context_factors = {}

        # Weather
        weather_adj = get_weather_adjustment(game_context)
        context_factors['weather_adj'] = weather_adj

        # Park factor
        park_factor = game_context.get('park_factor', {}).get(stat_type, 1.0)
        context_factors['park_factor_adj'] = round(park_factor - 1.0, 4)

        # ABS challenge system
        active_profile = pitcher_profile if is_pitcher_prop else batter_profile
        abs_adj, discipline_score = apply_abs_challenge_system(
            line, stat_type, pitcher_profile, batter_profile,
            catcher_id, umpire_id, game_context
        )
        context_factors['abs_adj'] = round((abs_adj - line) / max(line, 0.01), 4)

        # Day/night
        is_day = game_context.get('is_day_game', False)
        day_night_adj = calculate_day_night_adjustment(active_profile, stat_type, is_day)
        context_factors['day_night_adj'] = day_night_adj

        # Lineup protection (batter props only)
        if not is_pitcher_prop:
            order_pos = batter_profile.get('lineup_position', 5)
            lineup = game_context.get('lineup_data', [])
            protection = calculate_lineup_protection(order_pos, lineup)
            context_factors['lineup_protection_adj'] = round(
                protection['adjustment_factor'] - 1.0, 4
            )
        else:
            context_factors['lineup_protection_adj'] = 0.0

        # Opponent quality
        opposing_lineup = game_context.get('opposing_lineup', [])
        pitcher_hand = pitcher_profile.get('handedness', 'R')
        opp_quality = get_platoon_specific_opponent_quality(
            pitcher_profile, opposing_lineup, pitcher_hand
        )
        # For pitcher props, stronger lineup = worse for pitcher (negative adj)
        # For batter props, stronger opposing pitcher = worse for batter
        if is_pitcher_prop:
            context_factors['opponent_quality_adj'] = round((opp_quality - 0.5) * -0.06, 4)
        else:
            context_factors['opponent_quality_adj'] = round((opp_quality - 0.5) * 0.04, 4)

        # Bullpen state (pitcher props, long-form stats)
        if is_pitcher_prop and stat_type in ('strikeouts', 'outs_recorded', 'pitches_thrown'):
            team_id = pitcher_profile.get('team_id')
            if team_id:
                bullpen = _build_bullpen_state(team_id)
                leash_adj = bullpen.get('starter_leash_adj', 0.0)
                context_factors['bullpen_state_adj'] = leash_adj
            else:
                context_factors['bullpen_state_adj'] = 0.0
        else:
            context_factors['bullpen_state_adj'] = 0.0

        # TTO decay (strikeout props only)
        if stat_type == 'strikeouts' and is_pitcher_prop:
            expected_ip = pitcher_profile.get('innings_per_start', 5.5)
            tto_factor = apply_tto_adjustment(1.0, pitcher_profile, expected_ip)
            context_factors['tto_decay_adj'] = round(tto_factor - 1.0, 4)
        else:
            context_factors['tto_decay_adj'] = 0.0

        # Catcher framing
        if catcher_id and stat_type in ('strikeouts', 'outs_recorded'):
            framing = _build_catcher_framing(catcher_id)
            context_factors['catcher_framing_adj'] = framing.get('k_adjustment', 0.0)
        else:
            context_factors['catcher_framing_adj'] = 0.0

        audit_trail.append({'step': 7, 'name': 'context_adjustments',
                            'factors': context_factors})

        # --- Step 8: Aggregate context adjustments ---
        context_adjustment = aggregate_context_adjustments(context_factors)
        audit_trail.append({'step': 8, 'name': 'aggregate_context',
                            'total_adjustment': context_adjustment})

        # --- Step 9: Bayesian blend ---
        stat_weights = BAYESIAN_WEIGHTS.get(stat_type, BAYESIAN_WEIGHTS['default'])
        prior_projection = sub_scores.get('season_avg', line)
        recent_projection = sub_scores.get('recent_avg', line)
        context_projection = line * (1.0 + context_adjustment)

        blended_projection = bayesian_blend(
            prior_projection, recent_projection, context_projection, stat_weights
        )

        # Apply regime penalty if active
        if regime.get('regime_active'):
            regime_penalty = regime.get('confidence_penalty', 0.0)
            blended_projection *= (1.0 - regime_penalty * 0.01)

        audit_trail.append({'step': 9, 'name': 'bayesian_blend',
                            'prior': prior_projection, 'recent': recent_projection,
                            'context': round(context_projection, 3),
                            'blended': round(blended_projection, 3)})

        # --- Step 10: Real edge + quarter-Kelly ---
        if over_under == 'over':
            model_prob = _projection_to_probability(blended_projection, line, 'over')
        else:
            model_prob = _projection_to_probability(blended_projection, line, 'under')

        edge_result = calculate_real_edge(model_prob, odds)
        kelly_result = quarter_kelly(model_prob, odds)

        audit_trail.append({'step': 10, 'name': 'edge_kelly',
                            'model_prob': round(model_prob, 4),
                            'edge': edge_result, 'kelly': kelly_result})

        # --- Step 11: Abstention check ---
        abstain = False
        abstain_reason = None
        real_edge = edge_result.get('real_edge', 0.0)

        if real_edge < 0.02:
            abstain = True
            abstain_reason = f'Edge {real_edge:.3f} below 2% threshold'
        elif model_prob < 0.52 and model_prob > 0.48:
            abstain = True
            abstain_reason = f'Coin flip zone: model_prob={model_prob:.3f}'

        # Kill condition soft kills — allow grade but flag
        soft_kills = kill_conditions.get('soft_kills', [])
        if len(soft_kills) >= 2:
            abstain = True
            abstain_reason = f'{len(soft_kills)} soft kill conditions active'

        audit_trail.append({'step': 11, 'name': 'abstention_check',
                            'abstain': abstain, 'reason': abstain_reason})

        # --- Step 12: Global offset + grade ---
        adjusted_edge = real_edge + GLOBAL_OFFSET
        confidence = _edge_to_confidence(adjusted_edge, model_prob)

        # Apply kill condition penalty
        if soft_kills:
            confidence -= len(soft_kills) * 10
            confidence = max(30, confidence)

        grade = _confidence_to_grade(confidence, adjusted_edge)

        audit_trail.append({'step': 12, 'name': 'grade_assignment',
                            'adjusted_edge': round(adjusted_edge, 4),
                            'confidence': confidence, 'grade': grade})

        # --- Step 13: Minimum data sufficiency check ---
        games_played = (
            pitcher_profile.get('games_started', 0) if is_pitcher_prop
            else batter_profile.get('games_played', 0)
        )
        min_games = MIN_GAMES_PITCHER if is_pitcher_prop else MIN_GAMES_BATTER

        data_sufficient = games_played >= min_games
        if not data_sufficient:
            sufficiency_mod = data_sufficiency_modifier(games_played, min_games)
            confidence = int(confidence * sufficiency_mod)
            confidence = max(30, confidence)
            # May downgrade the letter grade
            grade = _confidence_to_grade(confidence, adjusted_edge)

        audit_trail.append({'step': 13, 'name': 'data_sufficiency',
                            'games_played': games_played, 'min_required': min_games,
                            'sufficient': data_sufficient, 'final_confidence': confidence})

        # --- Step 14: Log everything ---
        elapsed_ms = (datetime.utcnow() - start_time).total_seconds() * 1000

        grade_log = {
            'player_name': player_name,
            'player_id': player_id,
            'stat_type': stat_type,
            'line': line,
            'over_under': over_under,
            'odds': odds,
            'grade': grade,
            'confidence': confidence,
            'model_probability': round(model_prob, 4),
            'real_edge': round(real_edge, 4),
            'kelly_fraction': kelly_result.get('kelly_fraction', 0.0),
            'blended_projection': round(blended_projection, 3),
            'context_adjustment': round(context_adjustment, 4),
            'archetype_scores': archetype_scores,
            'sub_scores': sub_scores,
            'context_factors': context_factors,
            'kill_conditions': kill_conditions,
            'regime': regime,
            'abstain': abstain,
            'abstain_reason': abstain_reason,
            'data_sufficient': data_sufficient,
            'global_offset': GLOBAL_OFFSET,
            'sport': 'mlb',
            'elapsed_ms': round(elapsed_ms, 1),
            'timestamp': datetime.utcnow().isoformat(),
            'user_id': user_id,
            'game_id': game_id
        }

        # Non-blocking log to warehouse
        try:
            store_grade_log(grade_log)
        except Exception as log_err:
            logger.warning(f'[mlb] Grade log storage failed: {log_err}')

        # Check for blind spots
        blind_spots = check_blind_spots(grade_log, 'mlb')

        audit_trail.append({'step': 14, 'name': 'logged',
                            'elapsed_ms': round(elapsed_ms, 1),
                            'blind_spots': blind_spots})

        logger.info(
            f'[mlb] Grade complete: {player_name} {stat_type} {over_under} {line} '
            f'-> {grade} ({confidence}%) edge={real_edge:.3f} in {elapsed_ms:.0f}ms'
        )

        return jsonify({
            'player_name': player_name,
            'player_id': player_id,
            'stat_type': stat_type,
            'line': line,
            'over_under': over_under,
            'odds': odds,
            'grade': grade,
            'confidence': confidence,
            'model_probability': round(model_prob, 4),
            'real_edge': round(real_edge, 4),
            'kelly_fraction': kelly_result.get('kelly_fraction', 0.0),
            'kelly_unit_size': kelly_result.get('unit_size', 0.0),
            'blended_projection': round(blended_projection, 3),
            'sub_scores': sub_scores,
            'context_factors': context_factors,
            'context_adjustment': round(context_adjustment, 4),
            'archetype_scores': {k: round(v, 3) for k, v in archetype_scores.items()},
            'blended_weights': {k: round(v, 3) for k, v in blended_weights.items()},
            'kill_conditions': kill_conditions,
            'regime': regime,
            'abstain': abstain,
            'abstain_reason': abstain_reason,
            'data_sufficient': data_sufficient,
            'discipline_score': discipline_score,
            'blind_spots': blind_spots,
            'elapsed_ms': round(elapsed_ms, 1),
            'audit_trail': audit_trail
        })

    except Exception as e:
        logger.error(f'[mlb] Grade pipeline error: {e}\n{traceback.format_exc()}')
        return jsonify({
            'error': str(e),
            'grade': 'ERROR',
            'confidence': 0,
            'audit_trail': audit_trail
        }), 500


# ============================================================
# INTERNAL HELPERS — PROFILE BUILDERS
# ============================================================


def _build_pitcher_profile(pitcher_id):
    """
    Build comprehensive pitcher profile using pybaseball with concurrent fetches.

    Submits up to 3 parallel data requests via ThreadPoolExecutor:
      - Statcast pitch-level data (velocity, whiff, pitch mix)
      - Season-level stats (ERA, FIP, K/BB, IP)
      - Game logs (recent starts, pitch counts, days rest)

    Args:
        pitcher_id: MLB pitcher ID.

    Returns:
        Dict with full pitcher profile, or empty dict on failure.
    """
    if not pitcher_id:
        return {}

    cache_key = f'mlb_pitcher_{pitcher_id}'
    cached = fetch_with_cache(cache_key, None, data_type='player_stats')
    if cached:
        return cached

    if not HAS_PYBASEBALL:
        return _stub_pitcher_profile(pitcher_id)

    profile = {'pitcher_id': pitcher_id}

    # Submit concurrent fetches
    futures = {}
    futures['statcast'] = _executor.submit(_fetch_pitcher_statcast, pitcher_id)
    futures['season'] = _executor.submit(_fetch_pitcher_season_stats, pitcher_id)
    futures['game_logs'] = _executor.submit(_fetch_pitcher_game_logs, pitcher_id)

    for key, future in futures.items():
        try:
            result = future.result(timeout=15)
            if result:
                profile.update(result)
        except Exception as e:
            logger.warning(f'[mlb] Pitcher fetch {key} failed for {pitcher_id}: {e}')

    # Derive computed fields
    profile['arsenal_size'] = len(profile.get('pitch_mix', {}))
    k_rate = profile.get('k_rate_season', 0.0)
    bb_rate = profile.get('bb_rate_season', 0.0)
    profile['k_bb_ratio'] = round(k_rate / max(bb_rate, 0.01), 2)

    # Cache the built profile
    fetch_with_cache(cache_key, lambda: profile, data_type='player_stats')

    return profile


def _fetch_pitcher_statcast(pitcher_id):
    """
    Fetch Statcast pitch-level data for a pitcher.

    Returns pitch mix percentages, average velocity per pitch type,
    velocity delta (fastball vs off-speed), whiff rate, CSW%, and Zone%.

    Args:
        pitcher_id: MLB pitcher ID.

    Returns:
        Dict with Statcast-derived metrics.
    """
    try:
        end_date = datetime.now().strftime('%Y-%m-%d')
        start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d')
        data = pb.statcast_pitcher(start_date, end_date, int(pitcher_id))

        if data is None or data.empty:
            return {}

        result = {}

        # Pitch mix
        pitch_counts = data['pitch_type'].value_counts(normalize=True)
        result['pitch_mix'] = {k: round(v, 3) for k, v in pitch_counts.items()
                               if k and str(k) != 'nan'}

        # Velocity by pitch type
        velo_by_type = data.groupby('pitch_type')['release_speed'].mean()
        result['velocity_by_pitch'] = {
            k: round(v, 1) for k, v in velo_by_type.items()
            if k and str(k) != 'nan'
        }

        # Fastball velocity (FF, SI, FC)
        fb_types = ['FF', 'SI', 'FC']
        fb_data = data[data['pitch_type'].isin(fb_types)]
        if not fb_data.empty:
            result['fb_velo_season'] = round(fb_data['release_speed'].mean(), 1)
        else:
            result['fb_velo_season'] = None

        # Off-speed velocity
        os_types = ['CH', 'CU', 'SL', 'KC', 'EP', 'FS']
        os_data = data[data['pitch_type'].isin(os_types)]
        if not os_data.empty:
            result['offspeed_velo'] = round(os_data['release_speed'].mean(), 1)
        else:
            result['offspeed_velo'] = None

        # Velocity delta
        if result.get('fb_velo_season') and result.get('offspeed_velo'):
            result['velocity_delta'] = round(
                result['fb_velo_season'] - result['offspeed_velo'], 1
            )

        # Whiff rate
        swings = data[data['description'].isin([
            'swinging_strike', 'swinging_strike_blocked',
            'foul', 'foul_tip', 'hit_into_play',
            'hit_into_play_no_out', 'hit_into_play_score'
        ])]
        whiffs = data[data['description'].isin([
            'swinging_strike', 'swinging_strike_blocked'
        ])]
        if len(swings) > 0:
            result['whiff_rate_season'] = round(len(whiffs) / len(swings), 4)
        else:
            result['whiff_rate_season'] = None

        # CSW% (Called Strike + Whiff rate)
        total_pitches = len(data)
        called_strikes = len(data[data['description'] == 'called_strike'])
        whiff_count = len(whiffs)
        if total_pitches > 0:
            result['csw_pct'] = round((called_strikes + whiff_count) / total_pitches, 4)
        else:
            result['csw_pct'] = None

        # Zone%
        in_zone = data[data['zone'].between(1, 9)]
        if total_pitches > 0:
            result['zone_pct_season'] = round(len(in_zone) / total_pitches, 4)
        else:
            result['zone_pct_season'] = None

        return result

    except Exception as e:
        logger.warning(f'[mlb] Statcast pitcher fetch failed: {e}')
        return {}


def _fetch_pitcher_season_stats(pitcher_id):
    """
    Fetch season-level stats for a pitcher: ERA, FIP, GB/FB rate,
    K rate, BB rate, pitches per inning, innings per start, season IP,
    handedness, and TTO splits.

    Args:
        pitcher_id: MLB pitcher ID.

    Returns:
        Dict with season-level metrics.
    """
    try:
        # Use pitching_stats for current season
        current_year = datetime.now().year
        stats = pb.pitching_stats(current_year, current_year, qual=0)

        if stats is None or stats.empty:
            return {}

        # Find pitcher by ID (key_mlbam or IDfg)
        pid = int(pitcher_id)
        row = None
        for id_col in ['key_mlbam', 'IDfg', 'playerid']:
            if id_col in stats.columns:
                matches = stats[stats[id_col] == pid]
                if not matches.empty:
                    row = matches.iloc[0]
                    break

        if row is None:
            return {}

        result = {
            'era': _safe_float(row, 'ERA'),
            'fip': _safe_float(row, 'FIP'),
            'gb_fb_ratio': _safe_float(row, 'GB/FB'),
            'k_rate_season': _safe_float(row, 'K%', scale=0.01),
            'bb_rate_season': _safe_float(row, 'BB%', scale=0.01),
            'season_ip': _safe_float(row, 'IP'),
            'games_started': _safe_int(row, 'GS'),
            'handedness': row.get('Throws', 'R') if hasattr(row, 'get') else 'R',
        }

        # Pitches per inning and innings per start (derived)
        ip = result.get('season_ip', 0)
        gs = result.get('games_started', 0)
        pitches = _safe_float(row, 'Pitches')

        if ip and ip > 0 and pitches:
            result['pitches_per_inning'] = round(pitches / ip, 1)
        else:
            result['pitches_per_inning'] = 16.0  # league average fallback

        if gs and gs > 0 and ip:
            result['innings_per_start'] = round(ip / gs, 1)
        else:
            result['innings_per_start'] = 5.5

        # TTO splits (approximated from rate stats if available)
        result['tto_splits'] = {
            'first_time': {'k_rate': result.get('k_rate_season', 0.22) * 1.08},
            'second_time': {'k_rate': result.get('k_rate_season', 0.22) * 0.95},
            'third_time': {'k_rate': result.get('k_rate_season', 0.22) * 0.85}
        }

        return result

    except Exception as e:
        logger.warning(f'[mlb] Season stats fetch failed: {e}')
        return {}


def _fetch_pitcher_game_logs(pitcher_id):
    """
    Fetch recent game logs for a pitcher.

    Returns pitch count history, days rest calculation, and recent
    start quality data.

    Args:
        pitcher_id: MLB pitcher ID.

    Returns:
        Dict with game log-derived metrics.
    """
    try:
        if not HAS_STATSAPI:
            return {}

        game_log = api_call_with_retry(
            lambda: statsapi.player_stat_data(int(pitcher_id), group='pitching',
                                              type='gameLog'),
            max_retries=2
        )

        if not game_log or 'stats' not in game_log:
            return {}

        stats_list = game_log['stats']
        if not stats_list:
            return {}

        # Extract recent starts
        recent_starts = []
        for stat_group in stats_list:
            for split in stat_group.get('stats', []):
                recent_starts.append(split)

        result = {}

        if recent_starts:
            last_start = recent_starts[0]
            result['last_pitch_count'] = last_start.get('numberOfPitches', 0)

            # Days rest from last start date
            last_date_str = last_start.get('date')
            if last_date_str:
                try:
                    last_date = datetime.strptime(last_date_str, '%Y-%m-%d')
                    result['days_rest'] = (datetime.now() - last_date).days
                except (ValueError, TypeError):
                    result['days_rest'] = None

            # Average pitch count from recent starts
            pitch_counts = [s.get('numberOfPitches', 0) for s in recent_starts[:5]
                            if s.get('numberOfPitches')]
            if pitch_counts:
                result['avg_pitch_count'] = round(np.mean(pitch_counts), 0)

        return result

    except Exception as e:
        logger.warning(f'[mlb] Game logs fetch failed: {e}')
        return {}


def _build_batter_profile(batter_id):
    """
    Build comprehensive batter profile using pybaseball with concurrent fetches.

    Submits up to 3 parallel data requests via ThreadPoolExecutor:
      - Statcast batted ball data (exit velo, barrel%, launch angle, xwOBA)
      - Season-level stats (wOBA, K%, BB%, ISO, OPS)
      - Plate discipline + splits (chase rate, first pitch swing, platoon, situational)

    Args:
        batter_id: MLB batter ID.

    Returns:
        Dict with full batter profile, or empty dict on failure.
    """
    if not batter_id:
        return {}

    cache_key = f'mlb_batter_{batter_id}'
    cached = fetch_with_cache(cache_key, None, data_type='player_stats')
    if cached:
        return cached

    if not HAS_PYBASEBALL:
        return _stub_batter_profile(batter_id)

    profile = {'batter_id': batter_id}

    futures = {}
    futures['statcast'] = _executor.submit(_fetch_batter_statcast, batter_id)
    futures['season'] = _executor.submit(_fetch_batter_season_stats, batter_id)
    futures['discipline'] = _executor.submit(_fetch_batter_discipline, batter_id)

    for key, future in futures.items():
        try:
            result = future.result(timeout=15)
            if result:
                profile.update(result)
        except Exception as e:
            logger.warning(f'[mlb] Batter fetch {key} failed for {batter_id}: {e}')

    # Cache the built profile
    fetch_with_cache(cache_key, lambda: profile, data_type='player_stats')

    return profile


def _fetch_batter_statcast(batter_id):
    """
    Fetch Statcast batted ball data for a batter.

    Returns exit velocity, hard hit%, barrel%, launch angle, and xwOBA.

    Args:
        batter_id: MLB batter ID.

    Returns:
        Dict with Statcast-derived hitting metrics.
    """
    try:
        end_date = datetime.now().strftime('%Y-%m-%d')
        start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d')
        data = pb.statcast_batter(start_date, end_date, int(batter_id))

        if data is None or data.empty:
            return {}

        result = {}

        # Exit velocity
        batted = data[data['launch_speed'].notna()]
        if not batted.empty:
            result['exit_velo'] = round(batted['launch_speed'].mean(), 1)
            result['hard_hit_pct'] = round(
                len(batted[batted['launch_speed'] >= 95]) / len(batted), 4
            )

            # Barrel% (95+ exit velo AND 25-30 degree launch angle range)
            barrels = batted[
                (batted['launch_speed'] >= 98) &
                (batted['launch_angle'].between(26, 30))
            ]
            result['barrel_pct'] = round(len(barrels) / len(batted), 4)

            result['launch_angle'] = round(batted['launch_angle'].mean(), 1)
        else:
            result['exit_velo'] = None
            result['hard_hit_pct'] = None
            result['barrel_pct'] = None
            result['launch_angle'] = None

        # xwOBA
        if 'estimated_woba_using_speedangle' in data.columns:
            xwoba_vals = data['estimated_woba_using_speedangle'].dropna()
            if not xwoba_vals.empty:
                result['xwoba'] = round(xwoba_vals.mean(), 4)

        return result

    except Exception as e:
        logger.warning(f'[mlb] Statcast batter fetch failed: {e}')
        return {}


def _fetch_batter_season_stats(batter_id):
    """
    Fetch season-level stats for a batter: wOBA, K%, BB%, ISO, sprint speed,
    handedness, games played.

    Args:
        batter_id: MLB batter ID.

    Returns:
        Dict with season-level batting metrics.
    """
    try:
        current_year = datetime.now().year
        stats = pb.batting_stats(current_year, current_year, qual=0)

        if stats is None or stats.empty:
            return {}

        bid = int(batter_id)
        row = None
        for id_col in ['key_mlbam', 'IDfg', 'playerid']:
            if id_col in stats.columns:
                matches = stats[stats[id_col] == bid]
                if not matches.empty:
                    row = matches.iloc[0]
                    break

        if row is None:
            return {}

        result = {
            'woba': _safe_float(row, 'wOBA'),
            'k_pct': _safe_float(row, 'K%', scale=0.01),
            'bb_rate': _safe_float(row, 'BB%', scale=0.01),
            'iso': _safe_float(row, 'ISO'),
            'sprint_speed': _safe_float(row, 'Spd'),
            'handedness': row.get('Bats', 'R') if hasattr(row, 'get') else 'R',
            'games_played': _safe_int(row, 'G'),
        }

        return result

    except Exception as e:
        logger.warning(f'[mlb] Batter season stats fetch failed: {e}')
        return {}


def _fetch_batter_discipline(batter_id):
    """
    Fetch plate discipline and split data for a batter.

    Returns chase rate, first pitch swing rate, platoon splits (vs LHP/RHP),
    RISP splits, home/road splits, day/night splits, and lineup position.

    Args:
        batter_id: MLB batter ID.

    Returns:
        Dict with discipline and split metrics.
    """
    try:
        current_year = datetime.now().year
        stats = pb.batting_stats(current_year, current_year, qual=0)

        if stats is None or stats.empty:
            return {}

        bid = int(batter_id)
        row = None
        for id_col in ['key_mlbam', 'IDfg', 'playerid']:
            if id_col in stats.columns:
                matches = stats[stats[id_col] == bid]
                if not matches.empty:
                    row = matches.iloc[0]
                    break

        if row is None:
            return {}

        result = {
            'chase_rate': _safe_float(row, 'O-Swing%', scale=0.01, default=0.30),
            'first_pitch_swing_rate': _safe_float(row, 'F-Strike%', scale=0.01, default=0.55),
            'platoon_splits': {
                'vs_rhp': {'woba': _safe_float(row, 'wOBA', default=0.315)},
                'vs_lhp': {'woba': _safe_float(row, 'wOBA', default=0.315)},
            },
            'splits': {},
            'lineup_position': 5,  # Default mid-order; populated from lineup data
        }

        # RISP, home/road, day/night splits require separate API calls
        # Populated when game_context provides lineup data or from cached splits
        return result

    except Exception as e:
        logger.warning(f'[mlb] Batter discipline fetch failed: {e}')
        return {}


def _fetch_h2h_matchup(pitcher_id, batter_id):
    """
    Fetch head-to-head career stats between a pitcher and batter.

    Returns None if fewer than MIN_PA_MATCHUP plate appearances exist
    between the two players, as small samples are unreliable.

    Args:
        pitcher_id: MLB pitcher ID.
        batter_id: MLB batter ID.

    Returns:
        Dict with H2H stats, or None if insufficient sample.
    """
    cache_key = f'mlb_h2h_{pitcher_id}_{batter_id}'
    cached = fetch_with_cache(cache_key, None, data_type='player_stats')
    if cached is not None:
        return cached if cached != '__INSUFFICIENT__' else None

    if not HAS_PYBASEBALL:
        return None

    try:
        end_date = datetime.now().strftime('%Y-%m-%d')
        # Look back 5 years for career H2H
        start_date = (datetime.now() - timedelta(days=365 * 5)).strftime('%Y-%m-%d')

        pitcher_data = pb.statcast_pitcher(start_date, end_date, int(pitcher_id))
        if pitcher_data is None or pitcher_data.empty:
            return None

        matchup_data = pitcher_data[pitcher_data['batter'] == int(batter_id)]

        if matchup_data.empty or len(matchup_data) < MIN_PA_MATCHUP:
            # Cache the insufficient result to avoid re-fetching
            fetch_with_cache(cache_key, lambda: '__INSUFFICIENT__', data_type='player_stats')
            return None

        pa = len(matchup_data)
        hits = len(matchup_data[matchup_data['events'].isin([
            'single', 'double', 'triple', 'home_run'
        ])])
        strikeouts = len(matchup_data[matchup_data['events'] == 'strikeout'])
        walks = len(matchup_data[matchup_data['events'].isin(['walk', 'hit_by_pitch'])])
        home_runs = len(matchup_data[matchup_data['events'] == 'home_run'])

        ab = pa - walks
        avg = round(hits / max(ab, 1), 3)

        result = {
            'plate_appearances': pa,
            'at_bats': ab,
            'hits': hits,
            'home_runs': home_runs,
            'strikeouts': strikeouts,
            'walks': walks,
            'avg': avg,
            'k_rate': round(strikeouts / max(pa, 1), 3),
            'bb_rate': round(walks / max(pa, 1), 3),
        }

        # xwOBA from Statcast
        if 'estimated_woba_using_speedangle' in matchup_data.columns:
            xwoba_vals = matchup_data['estimated_woba_using_speedangle'].dropna()
            if not xwoba_vals.empty:
                result['xwoba'] = round(xwoba_vals.mean(), 4)

        fetch_with_cache(cache_key, lambda: result, data_type='player_stats')
        return result

    except Exception as e:
        logger.warning(f'[mlb] H2H matchup fetch failed: {e}')
        return None


def _build_bullpen_state(team_id):
    """
    Build bullpen workload state for a team.

    Calculates aggregate reliever IP over the last 3 days. Sets taxed flag
    if bullpen IP > 9.0 (3+ innings/day average). Adjusts starter leash:
    shorter when bullpen is rested, longer when bullpen is taxed.

    Args:
        team_id: MLB team ID.

    Returns:
        Dict with reliever_ip_3d, is_taxed, starter_leash_adj.
    """
    cache_key = f'mlb_bullpen_{team_id}'
    cached = fetch_with_cache(cache_key, None, data_type='player_stats')
    if cached:
        return cached

    state = {
        'team_id': team_id,
        'reliever_ip_3d': {},
        'aggregate_ip_3d': 0.0,
        'is_taxed': False,
        'starter_leash_adj': 0.0,
        'note': 'Requires statsapi game data for full calculation'
    }

    if not HAS_STATSAPI:
        return state

    try:
        today = datetime.now()
        three_days_ago = (today - timedelta(days=3)).strftime('%m/%d/%Y')
        today_str = today.strftime('%m/%d/%Y')

        schedule = api_call_with_retry(
            lambda: statsapi.schedule(
                start_date=three_days_ago, end_date=today_str,
                team=int(team_id)
            ),
            max_retries=2
        )

        if not schedule:
            return state

        total_bullpen_ip = 0.0
        reliever_ip = {}

        for game in schedule:
            game_id = game.get('game_id')
            if not game_id:
                continue

            try:
                boxscore = statsapi.boxscore_data(game_id)
                # Parse reliever innings from boxscore
                for side in ['away', 'home']:
                    team_data = boxscore.get(side, {})
                    if str(team_data.get('team', {}).get('id')) != str(team_id):
                        continue
                    pitchers = team_data.get('pitchers', [])
                    # First pitcher is starter; rest are relievers
                    for pid in pitchers[1:]:
                        p_stats = team_data.get('players', {}).get(f'ID{pid}', {})
                        ip = float(p_stats.get('stats', {}).get('pitching', {}).get(
                            'inningsPitched', 0
                        ))
                        name = p_stats.get('person', {}).get('fullName', str(pid))
                        reliever_ip[name] = reliever_ip.get(name, 0.0) + ip
                        total_bullpen_ip += ip
            except Exception:
                continue

        state['reliever_ip_3d'] = {k: round(v, 1) for k, v in reliever_ip.items()}
        state['aggregate_ip_3d'] = round(total_bullpen_ip, 1)
        state['is_taxed'] = total_bullpen_ip > 9.0

        # Starter leash adjustment: taxed bullpen = longer leash (+0.5 IP expected),
        # rested bullpen = shorter leash (-0.3 IP expected, quicker hook)
        if total_bullpen_ip > 12.0:
            state['starter_leash_adj'] = 0.5  # very taxed
        elif total_bullpen_ip > 9.0:
            state['starter_leash_adj'] = 0.3  # moderately taxed
        elif total_bullpen_ip < 4.0:
            state['starter_leash_adj'] = -0.3  # very rested, short leash
        else:
            state['starter_leash_adj'] = 0.0

        fetch_with_cache(cache_key, lambda: state, data_type='player_stats')
        return state

    except Exception as e:
        logger.warning(f'[mlb] Bullpen state fetch failed: {e}')
        return state


def _build_catcher_framing(catcher_id):
    """
    Build catcher framing data.

    Returns framing runs per game and a strikeout adjustment between
    -0.5 and +0.5 that represents the catcher's impact on called strike
    probability. Elite framers (Realmuto, Contreras) add ~+0.3 K/game.
    Poor framers (some backup catchers) cost ~-0.3 K/game.

    Args:
        catcher_id: MLB catcher ID.

    Returns:
        Dict with framing_runs_per_game and k_adjustment.
    """
    if not catcher_id:
        return {'catcher_id': None, 'framing_runs_per_game': 0.0,
                'k_adjustment': 0.0}

    cache_key = f'mlb_framing_{catcher_id}'
    cached = fetch_with_cache(cache_key, None, data_type='player_stats')
    if cached:
        return cached

    # Framing data typically from Baseball Savant / Statcast
    # Approximated via catcher defensive stats when direct framing unavailable
    framing = {
        'catcher_id': catcher_id,
        'framing_runs_per_game': 0.0,
        'k_adjustment': 0.0,
        'note': 'Requires Baseball Savant framing data for precision values'
    }

    if HAS_PYBASEBALL:
        try:
            current_year = datetime.now().year
            catcher_stats = pb.batting_stats(current_year, current_year, qual=0)

            if catcher_stats is not None and not catcher_stats.empty:
                cid = int(catcher_id)
                for id_col in ['key_mlbam', 'IDfg', 'playerid']:
                    if id_col in catcher_stats.columns:
                        matches = catcher_stats[catcher_stats[id_col] == cid]
                        if not matches.empty:
                            row = matches.iloc[0]
                            # Framing proxy: use Def value if available
                            def_val = _safe_float(row, 'Def', default=0.0)
                            # Normalize to per-game framing runs (rough proxy)
                            games = _safe_int(row, 'G', default=1)
                            framing['framing_runs_per_game'] = round(
                                def_val / max(games, 1), 3
                            )
                            # K adjustment: scale framing runs to K impact
                            # Clamped to [-0.5, +0.5]
                            raw_adj = framing['framing_runs_per_game'] * 2.5
                            framing['k_adjustment'] = round(
                                max(-0.5, min(0.5, raw_adj)), 3
                            )
                            break
        except Exception as e:
            logger.warning(f'[mlb] Catcher framing fetch failed: {e}')

    fetch_with_cache(cache_key, lambda: framing, data_type='player_stats')
    return framing


# ============================================================
# GRADE PIPELINE HELPERS
# ============================================================


def _check_kill_conditions(player_id, stat_type, game_context):
    """
    Check all kill conditions for an MLB grade.

    Hard kills (grade = KILL, no further processing):
      - Player scratched from lineup
      - Player on IL
      - Player suspended
      - Game postponed / PPD

    Soft kills (grade capped, confidence reduced):
      - Pitcher on short rest (< 4 days)
      - Weather delay risk > 50%
      - Player returning from IL within 2 starts
      - Blowout risk (spread > 5 runs)

    Args:
        player_id: MLB player ID.
        stat_type: Stat type string.
        game_context: Dict with game/player context.

    Returns:
        Dict with hard_kill flag, soft_kills list, and reason.
    """
    result = {'hard_kill': False, 'soft_kills': [], 'reason': None}

    # Hard kills
    if game_context.get('scratched'):
        result['hard_kill'] = True
        result['reason'] = 'Player scratched from lineup'
        return result

    if game_context.get('on_il'):
        result['hard_kill'] = True
        result['reason'] = 'Player on injured list'
        return result

    if game_context.get('suspended'):
        result['hard_kill'] = True
        result['reason'] = 'Player suspended'
        return result

    if game_context.get('game_status') in ('postponed', 'ppd', 'cancelled'):
        result['hard_kill'] = True
        result['reason'] = 'Game postponed or cancelled'
        return result

    # Soft kills
    days_rest = game_context.get('days_rest')
    if days_rest is not None and days_rest < 4 and stat_type in PITCHING_STATS:
        result['soft_kills'].append({
            'type': 'short_rest',
            'detail': f'Pitcher on {days_rest} days rest (normal = 4-5)'
        })

    if game_context.get('weather_delay_risk', 0) > 0.50:
        result['soft_kills'].append({
            'type': 'weather_risk',
            'detail': f'Weather delay risk: {game_context["weather_delay_risk"]:.0%}'
        })

    if game_context.get('il_return_starts', 99) <= 2:
        result['soft_kills'].append({
            'type': 'il_return',
            'detail': f'IL return — only {game_context["il_return_starts"]} starts back'
        })

    spread = abs(game_context.get('spread', 0))
    if spread > 5.0:
        result['soft_kills'].append({
            'type': 'blowout_risk',
            'detail': f'Large spread: {spread} runs'
        })

    return result


def _check_regime(player_id, stat_type, game_context):
    """
    Check for recent regime changes that invalidate historical data.

    Regime changes: trade, callup, role change (starter to bullpen),
    IL return, lineup position change. When detected, historical data
    is down-weighted and confidence is penalized.

    Args:
        player_id: MLB player ID.
        stat_type: Stat type string.
        game_context: Dict with regime context.

    Returns:
        Dict with regime_active flag, regime_type, and confidence_penalty.
    """
    result = {'regime_active': False, 'regime_type': None, 'confidence_penalty': 0.0}

    regime_type = game_context.get('regime_type')
    if not regime_type:
        return result

    REGIME_PENALTIES = {
        'trade': 15.0,
        'callup': 20.0,
        'role_change': 15.0,
        'il_return': 10.0,
        'lineup_change': 5.0,
    }

    penalty = REGIME_PENALTIES.get(regime_type, 0.0)
    if penalty > 0:
        result['regime_active'] = True
        result['regime_type'] = regime_type
        result['confidence_penalty'] = penalty

    return result


def _calculate_sub_scores(stat_type, pitcher_profile, batter_profile,
                          game_context, blended_weights):
    """
    Calculate PTI (Pitcher Threat Index) and BCS (Batter Context Score)
    sub-scores based on archetype-blended weights.

    For pitching props: PTI sub-scores (velocity trend, command trend,
    whiff trend, pitch mix shift, workload).
    For batting props: BCS sub-scores (power, discipline, speed,
    contact quality, situational).

    Args:
        stat_type: Stat type string.
        pitcher_profile: Dict with pitcher metrics.
        batter_profile: Dict with batter metrics.
        game_context: Dict with game context.
        blended_weights: Dict with archetype-blended weight profile.

    Returns:
        Dict with sub-scores and season/recent averages.
    """
    sub_scores = {'season_avg': None, 'recent_avg': None}

    if stat_type in PITCHING_STATS and pitcher_profile:
        # PTI sub-scores
        velo = pitcher_profile.get('fb_velo_season')
        whiff = pitcher_profile.get('whiff_rate_season')
        zone = pitcher_profile.get('zone_pct_season')
        era = pitcher_profile.get('era')

        sub_scores['velocity_trend'] = _normalize_score(velo, 88.0, 100.0) if velo else 0.5
        sub_scores['command_trend'] = _normalize_score(zone, 0.35, 0.55) if zone else 0.5
        sub_scores['whiff_trend'] = _normalize_score(whiff, 0.15, 0.40) if whiff else 0.5
        sub_scores['workload'] = 0.5  # neutral default; adjusted by pitch count data

        # Season and recent averages for Bayesian blend
        if stat_type == 'strikeouts':
            k_rate = pitcher_profile.get('k_rate_season', 0.22)
            ip_per_start = pitcher_profile.get('innings_per_start', 5.5)
            # Approximate K per game: K_rate * batters_faced_per_game
            # ~4.3 batters per inning on average
            batters_per_game = ip_per_start * 4.3
            sub_scores['season_avg'] = round(k_rate * batters_per_game, 2)
            sub_scores['recent_avg'] = sub_scores['season_avg']  # refined with game logs

    elif stat_type in HITTING_STATS and batter_profile:
        # BCS sub-scores
        exit_velo = batter_profile.get('exit_velo')
        hard_hit = batter_profile.get('hard_hit_pct')
        k_pct = batter_profile.get('k_pct')
        bb_rate = batter_profile.get('bb_rate')

        sub_scores['power'] = _normalize_score(exit_velo, 84.0, 95.0) if exit_velo else 0.5
        sub_scores['discipline'] = _normalize_score(
            bb_rate, 0.04, 0.14
        ) if bb_rate else 0.5
        sub_scores['contact_quality'] = _normalize_score(
            hard_hit, 0.25, 0.50
        ) if hard_hit else 0.5
        sub_scores['speed'] = 0.5  # populated from sprint speed

        # Season averages (stat-specific)
        woba = batter_profile.get('woba', 0.315)
        sub_scores['season_avg'] = _woba_to_stat_projection(woba, stat_type)
        sub_scores['recent_avg'] = sub_scores['season_avg']

    return sub_scores


def _projection_to_probability(projection, line, direction):
    """
    Convert a projection and line into a probability estimate.

    Uses a logistic function centered at the line value. The steepness
    depends on the stat's typical variance.

    Args:
        projection: Model's projected value.
        line: The prop line.
        direction: 'over' or 'under'.

    Returns:
        Float probability between 0.0 and 1.0.
    """
    if line <= 0:
        return 0.50

    # Normalize the gap relative to line
    gap = (projection - line) / max(line, 0.01)

    # Logistic curve steepness (stat-dependent, but use general default)
    k = 8.0  # moderate steepness

    # Probability of over
    prob_over = 1.0 / (1.0 + np.exp(-k * gap))

    if direction == 'over':
        return round(max(0.01, min(0.99, prob_over)), 4)
    else:
        return round(max(0.01, min(0.99, 1.0 - prob_over)), 4)


def _edge_to_confidence(edge, model_prob):
    """
    Convert real edge and model probability into a confidence score (30-95).

    Args:
        edge: Real edge after vig (float).
        model_prob: Model probability (float).

    Returns:
        Integer confidence score.
    """
    if edge <= 0:
        return 30

    # Base confidence from edge magnitude
    base = 50 + (edge * 300)  # 5% edge = 65 confidence
    base = min(90, base)

    # Bonus for strong model probability (not just edge)
    if model_prob > 0.65:
        base += 5
    elif model_prob > 0.60:
        base += 3

    return int(max(30, min(95, base)))


def _confidence_to_grade(confidence, edge):
    """
    Map confidence score and edge to a letter grade.

    Grade scale (locked — matches GRADE_THRESHOLDS):
        A+: confidence >= 90 AND edge >= 0.08
        A:  confidence >= 80 AND edge >= 0.05
        B+: confidence >= 72 AND edge >= 0.04
        B:  confidence >= 65 AND edge >= 0.03
        C+: confidence >= 58 AND edge >= 0.02
        C:  confidence >= 50
        D:  confidence < 50

    Args:
        confidence: Integer confidence score (30-95).
        edge: Real edge float.

    Returns:
        String letter grade.
    """
    if confidence >= 90 and edge >= 0.08:
        return 'A+'
    elif confidence >= 80 and edge >= 0.05:
        return 'A'
    elif confidence >= 72 and edge >= 0.04:
        return 'B+'
    elif confidence >= 65 and edge >= 0.03:
        return 'B'
    elif confidence >= 58 and edge >= 0.02:
        return 'C+'
    elif confidence >= 50:
        return 'C'
    else:
        return 'D'


# ============================================================
# UTILITY FUNCTIONS
# ============================================================


def _normalize_score(value, low, high):
    """
    Normalize a raw value to 0.0-1.0 scale given expected range.

    Args:
        value: Raw numeric value.
        low: Lower bound of expected range.
        high: Upper bound of expected range.

    Returns:
        Float between 0.0 and 1.0.
    """
    if value is None or high == low:
        return 0.5
    return round(max(0.0, min(1.0, (value - low) / (high - low))), 4)


def _woba_to_stat_projection(woba, stat_type):
    """
    Convert wOBA to an approximate per-game stat projection.

    Rough heuristic for seeding the Bayesian prior when detailed
    game logs are not yet available.

    Args:
        woba: Weighted on-base average.
        stat_type: Stat type string.

    Returns:
        Float approximate per-game projection.
    """
    WOBA_CONVERSIONS = {
        'hits': lambda w: round(w * 3.2, 2),
        'total_bases': lambda w: round(w * 5.5, 2),
        'home_runs': lambda w: round(max(0, (w - 0.280)) * 1.2, 2),
        'rbis': lambda w: round(w * 3.0, 2),
        'runs_scored': lambda w: round(w * 2.5, 2),
        'strikeouts_batter': lambda w: round(1.5 - (w * 1.5), 2),
        'walks': lambda w: round(w * 1.8, 2),
        'stolen_bases': lambda w: round(0.15, 2),
    }

    converter = WOBA_CONVERSIONS.get(stat_type)
    if converter:
        return converter(woba)
    return round(woba * 3.0, 2)


def _safe_float(row, key, scale=1.0, default=None):
    """
    Safely extract a float from a pandas row or dict.

    Args:
        row: Pandas Series or dict.
        key: Column/key name.
        scale: Multiplier (e.g., 0.01 to convert percentages).
        default: Default value if missing or non-numeric.

    Returns:
        Float value or default.
    """
    try:
        val = row[key] if hasattr(row, '__getitem__') else getattr(row, key, None)
        if val is None or (isinstance(val, float) and np.isnan(val)):
            return default
        return round(float(val) * scale, 4)
    except (KeyError, TypeError, ValueError, AttributeError):
        return default


def _safe_int(row, key, default=0):
    """
    Safely extract an integer from a pandas row or dict.

    Args:
        row: Pandas Series or dict.
        key: Column/key name.
        default: Default value if missing or non-numeric.

    Returns:
        Integer value or default.
    """
    try:
        val = row[key] if hasattr(row, '__getitem__') else getattr(row, key, None)
        if val is None or (isinstance(val, float) and np.isnan(val)):
            return default
        return int(val)
    except (KeyError, TypeError, ValueError, AttributeError):
        return default


def _stub_pitcher_profile(pitcher_id):
    """
    Return a stub pitcher profile when pybaseball is not installed.

    Args:
        pitcher_id: MLB pitcher ID.

    Returns:
        Dict with neutral default values.
    """
    return {
        'pitcher_id': pitcher_id,
        'fb_velo_season': 93.0,
        'offspeed_velo': 84.0,
        'velocity_delta': 9.0,
        'whiff_rate_season': 0.24,
        'csw_pct': 0.30,
        'zone_pct_season': 0.45,
        'k_rate_season': 0.22,
        'bb_rate_season': 0.08,
        'k_bb_ratio': 2.75,
        'era': 4.00,
        'fip': 4.00,
        'gb_fb_ratio': 1.20,
        'pitches_per_inning': 16.0,
        'innings_per_start': 5.5,
        'season_ip': 100.0,
        'handedness': 'R',
        'pitch_mix': {'FF': 0.50, 'SL': 0.25, 'CH': 0.15, 'CU': 0.10},
        'arsenal_size': 4,
        'tto_splits': {
            'first_time': {'k_rate': 0.24},
            'second_time': {'k_rate': 0.21},
            'third_time': {'k_rate': 0.19}
        },
        'games_started': 0,
        '_stub': True
    }


def _stub_batter_profile(batter_id):
    """
    Return a stub batter profile when pybaseball is not installed.

    Args:
        batter_id: MLB batter ID.

    Returns:
        Dict with neutral default values.
    """
    return {
        'batter_id': batter_id,
        'exit_velo': 88.5,
        'hard_hit_pct': 0.35,
        'barrel_pct': 0.06,
        'launch_angle': 12.0,
        'xwoba': 0.320,
        'woba': 0.315,
        'k_pct': 0.22,
        'bb_rate': 0.08,
        'chase_rate': 0.30,
        'first_pitch_swing_rate': 0.28,
        'sprint_speed': 27.0,
        'iso': 0.150,
        'handedness': 'R',
        'lineup_position': 5,
        'platoon_splits': {
            'vs_rhp': {'woba': 0.315},
            'vs_lhp': {'woba': 0.315}
        },
        'splits': {},
        'games_played': 0,
        '_stub': True
    }
