use anyhow::Result;
use sqlx::SqlitePool;
use super::league_stats::{BatterLeagueStats, PitcherLeagueStats, StatDistribution};
use super::weights::{StatWeight, BATTER_WEIGHTS, PITCHER_WEIGHTS};
use crate::db::models::{BatterCard, PitcherCard, Player};
use crate::db::queries::{get_cached_batter_score, get_cached_pitcher_score};

/// Convert a raw stat value to a standardized score (-3 to +3).
///
/// Uses standard deviation thresholds from the league mean.
/// A value of 0 always returns +3 (best possible).
pub fn standardize_value(value: f64, distribution: &StatDistribution, high_is_better: bool) -> i32 {
    if value == 0.0 {
        return 3;
    }

    let avg = distribution.avg;
    let stdev = distribution.stdev;

    let base_score = if value > avg + 2.0 * stdev {
        -3
    } else if value > avg + stdev {
        -2
    } else if value > avg + 0.33 * stdev {
        -1
    } else if value > avg - 0.33 * stdev {
        0
    } else if value > avg - stdev {
        1
    } else if value > avg - 2.0 * stdev {
        2
    } else {
        3
    };

    if high_is_better { -base_score } else { base_score }
}

/// Calculate weighted score for a single stat.
pub fn calculate_weighted_score(
    value: f64,
    distribution: &StatDistribution,
    stat_weight: &StatWeight,
) -> f64 {
    let std_score = standardize_value(value, distribution, stat_weight.high_is_better);
    std_score as f64 * stat_weight.weight as f64
}

static DEFAULT_DIST: StatDistribution = StatDistribution { avg: 0.0, stdev: 1.0 };

/// Map (stat_name, pitcher_hand) to the batter card field value.
/// pitcher_hand: "L" → vs left-handed pitchers, "R" → vs right-handed pitchers.
pub(crate) fn get_batter_stat(card: &BatterCard, stat: &str, pitcher_hand: &str) -> f64 {
    match (stat, pitcher_hand) {
        ("so", "L") => card.so_vlhp,
        ("so", "R") => card.so_vrhp,
        ("bb", "L") => card.bb_vlhp,
        ("bb", "R") => card.bb_vrhp,
        ("hit", "L") => card.hit_vlhp,
        ("hit", "R") => card.hit_vrhp,
        ("ob", "L") => card.ob_vlhp,
        ("ob", "R") => card.ob_vrhp,
        ("tb", "L") => card.tb_vlhp,
        ("tb", "R") => card.tb_vrhp,
        ("hr", "L") => card.hr_vlhp,
        ("hr", "R") => card.hr_vrhp,
        ("bphr", "L") => card.bphr_vlhp,
        ("bphr", "R") => card.bphr_vrhp,
        ("bp1b", "L") => card.bp1b_vlhp,
        ("bp1b", "R") => card.bp1b_vrhp,
        ("dp", "L") => card.dp_vlhp,
        ("dp", "R") => card.dp_vrhp,
        _ => 0.0,
    }
}

/// Map (stat_name, pitcher_hand) to the batter league distribution for that stat.
pub(crate) fn get_batter_dist<'a>(stats: &'a BatterLeagueStats, stat: &str, pitcher_hand: &str) -> &'a StatDistribution {
    match (stat, pitcher_hand) {
        ("so", "L") => &stats.so_vlhp,
        ("so", "R") => &stats.so_vrhp,
        ("bb", "L") => &stats.bb_vlhp,
        ("bb", "R") => &stats.bb_vrhp,
        ("hit", "L") => &stats.hit_vlhp,
        ("hit", "R") => &stats.hit_vrhp,
        ("ob", "L") => &stats.ob_vlhp,
        ("ob", "R") => &stats.ob_vrhp,
        ("tb", "L") => &stats.tb_vlhp,
        ("tb", "R") => &stats.tb_vrhp,
        ("hr", "L") => &stats.hr_vlhp,
        ("hr", "R") => &stats.hr_vrhp,
        ("bphr", "L") => &stats.bphr_vlhp,
        ("bphr", "R") => &stats.bphr_vrhp,
        ("bp1b", "L") => &stats.bp1b_vlhp,
        ("bp1b", "R") => &stats.bp1b_vrhp,
        ("dp", "L") => &stats.dp_vlhp,
        ("dp", "R") => &stats.dp_vrhp,
        _ => &DEFAULT_DIST,
    }
}

/// Map (stat_name, batter_hand) to the pitcher card field value.
/// batter_hand: "L" → vs left-handed batters, "R" → vs right-handed batters.
pub(crate) fn get_pitcher_stat(card: &PitcherCard, stat: &str, batter_hand: &str) -> f64 {
    match (stat, batter_hand) {
        ("so", "L") => card.so_vlhb,
        ("so", "R") => card.so_vrhb,
        ("bb", "L") => card.bb_vlhb,
        ("bb", "R") => card.bb_vrhb,
        ("hit", "L") => card.hit_vlhb,
        ("hit", "R") => card.hit_vrhb,
        ("ob", "L") => card.ob_vlhb,
        ("ob", "R") => card.ob_vrhb,
        ("tb", "L") => card.tb_vlhb,
        ("tb", "R") => card.tb_vrhb,
        ("hr", "L") => card.hr_vlhb,
        ("hr", "R") => card.hr_vrhb,
        ("bphr", "L") => card.bphr_vlhb,
        ("bphr", "R") => card.bphr_vrhb,
        ("bp1b", "L") => card.bp1b_vlhb,
        ("bp1b", "R") => card.bp1b_vrhb,
        ("dp", "L") => card.dp_vlhb,
        ("dp", "R") => card.dp_vrhb,
        _ => 0.0,
    }
}

/// Map (stat_name, batter_hand) to the pitcher league distribution for that stat.
pub(crate) fn get_pitcher_dist<'a>(stats: &'a PitcherLeagueStats, stat: &str, batter_hand: &str) -> &'a StatDistribution {
    match (stat, batter_hand) {
        ("so", "L") => &stats.so_vlhb,
        ("so", "R") => &stats.so_vrhb,
        ("bb", "L") => &stats.bb_vlhb,
        ("bb", "R") => &stats.bb_vrhb,
        ("hit", "L") => &stats.hit_vlhb,
        ("hit", "R") => &stats.hit_vrhb,
        ("ob", "L") => &stats.ob_vlhb,
        ("ob", "R") => &stats.ob_vrhb,
        ("tb", "L") => &stats.tb_vlhb,
        ("tb", "R") => &stats.tb_vrhb,
        ("hr", "L") => &stats.hr_vlhb,
        ("hr", "R") => &stats.hr_vrhb,
        ("bphr", "L") => &stats.bphr_vlhb,
        ("bphr", "R") => &stats.bphr_vrhb,
        ("bp1b", "L") => &stats.bp1b_vlhb,
        ("bp1b", "R") => &stats.bp1b_vrhb,
        ("dp", "L") => &stats.dp_vlhb,
        ("dp", "R") => &stats.dp_vrhb,
        _ => &DEFAULT_DIST,
    }
}

/// Compute the batter component score for a given card, pitcher handedness, and league stats.
///
/// Sums weighted standardized scores across all batter stats.
/// pitcher_hand: "L" or "R" selects the appropriate split from the card.
pub fn calculate_batter_component(card: &BatterCard, pitcher_hand: &str, league_stats: &BatterLeagueStats) -> f64 {
    BATTER_WEIGHTS
        .iter()
        .map(|(stat, weight)| {
            let value = get_batter_stat(card, stat, pitcher_hand);
            let dist = get_batter_dist(league_stats, stat, pitcher_hand);
            calculate_weighted_score(value, dist, weight)
        })
        .sum()
}

/// Compute the pitcher component score for a given card, batter handedness, and league stats.
///
/// Sums weighted standardized scores across all pitcher stats.
/// batter_hand: "L" or "R" selects the appropriate split from the card.
pub fn calculate_pitcher_component(card: &PitcherCard, batter_hand: &str, league_stats: &PitcherLeagueStats) -> f64 {
    PITCHER_WEIGHTS
        .iter()
        .map(|(stat, weight)| {
            let value = get_pitcher_stat(card, stat, batter_hand);
            let dist = get_pitcher_dist(league_stats, stat, batter_hand);
            calculate_weighted_score(value, dist, weight)
        })
        .sum()
}

#[derive(Debug, Clone)]
pub struct MatchupResult {
    pub player: Player,
    pub rating: Option<f64>,
    pub tier: String,
    pub batter_hand: String,
    pub batter_split: String,
    pub pitcher_split: String,
    pub batter_component: Option<f64>,
    pub pitcher_component: Option<f64>,
}

impl MatchupResult {
    pub fn rating_display(&self) -> String {
        match self.rating {
            Some(r) if r >= 0.0 => format!("+{:.0}", r),
            Some(r) => format!("{:.0}", r),
            None => "N/A".to_string(),
        }
    }
}

pub fn get_tier(rating: Option<f64>) -> &'static str {
    match rating {
        None => "--",
        Some(r) if r >= 40.0 => "A",
        Some(r) if r >= 20.0 => "B",
        Some(r) if r >= -19.0 => "C",
        Some(r) if r >= -39.0 => "D",
        Some(_) => "F",
    }
}

/// Calculate batter vs pitcher matchup result.
///
/// Resolves switch hitter handedness, computes batter and pitcher components,
/// and combines them with pitcher inversion (good pitcher hurts batter rating).
pub fn calculate_matchup(
    player: &Player,
    batter_card: Option<&BatterCard>,
    pitcher: &Player,
    pitcher_card: Option<&PitcherCard>,
    batter_league_stats: &BatterLeagueStats,
    pitcher_league_stats: &PitcherLeagueStats,
) -> MatchupResult {
    let batter_hand = player.hand.as_deref().unwrap_or("R");
    let pitcher_hand = pitcher.hand.as_deref().unwrap_or("R");

    // Switch hitter bats opposite of pitcher's hand
    let effective_hand = if batter_hand == "S" {
        if pitcher_hand == "R" { "L" } else { "R" }
    } else {
        batter_hand
    };

    let batter_split = if pitcher_hand == "L" { "vLHP" } else { "vRHP" };
    let pitcher_split = if effective_hand == "L" { "vLHB" } else { "vRHB" };

    let Some(batter_card) = batter_card else {
        return MatchupResult {
            player: player.clone(),
            rating: None,
            tier: "--".to_string(),
            batter_hand: effective_hand.to_string(),
            batter_split: batter_split.to_string(),
            pitcher_split: pitcher_split.to_string(),
            batter_component: None,
            pitcher_component: None,
        };
    };

    let batter_component = calculate_batter_component(batter_card, pitcher_hand, batter_league_stats);

    let (pitcher_component, total) = if let Some(pc) = pitcher_card {
        let pc_score = calculate_pitcher_component(pc, effective_hand, pitcher_league_stats);
        (Some(pc_score), batter_component + (-pc_score))
    } else {
        (None, batter_component)
    };

    MatchupResult {
        player: player.clone(),
        rating: Some(total),
        tier: get_tier(Some(total)).to_string(),
        batter_hand: effective_hand.to_string(),
        batter_split: batter_split.to_string(),
        pitcher_split: pitcher_split.to_string(),
        batter_component: Some(batter_component),
        pitcher_component,
    }
}

/// Calculate matchups for a full roster against one pitcher, sorted best-first.
///
/// Results are sorted descending by rating (rated players first, then unrated).
pub fn calculate_team_matchups(
    batters: &[(Player, Option<BatterCard>)],
    pitcher: &Player,
    pitcher_card: Option<&PitcherCard>,
    batter_league_stats: &BatterLeagueStats,
    pitcher_league_stats: &PitcherLeagueStats,
) -> Vec<MatchupResult> {
    let mut results: Vec<MatchupResult> = batters
        .iter()
        .map(|(player, batter_card)| {
            calculate_matchup(
                player,
                batter_card.as_ref(),
                pitcher,
                pitcher_card,
                batter_league_stats,
                pitcher_league_stats,
            )
        })
        .collect();

    results.sort_by(|a, b| match (&b.rating, &a.rating) {
        (Some(br), Some(ar)) => br.partial_cmp(ar).unwrap_or(std::cmp::Ordering::Equal),
        (Some(_), None) => std::cmp::Ordering::Greater, // b rated, a not → b first
        (None, Some(_)) => std::cmp::Ordering::Less,    // a rated, b not → a first
        (None, None) => std::cmp::Ordering::Equal,
    });

    results
}

/// Calculate batter vs pitcher matchup using pre-computed scores from the DB cache.
///
/// Same switch-hitter resolution and pitcher inversion as `calculate_matchup`.
/// Returns a result with `rating=None` if the batter card is missing or its
/// cache entry is absent. Pitcher cache misses fall back to batter-only rating.
pub async fn calculate_matchup_cached(
    pool: &SqlitePool,
    player: &Player,
    batter_card: Option<&BatterCard>,
    pitcher: &Player,
    pitcher_card: Option<&PitcherCard>,
) -> Result<MatchupResult> {
    let batter_hand = player.hand.as_deref().unwrap_or("R");
    let pitcher_hand = pitcher.hand.as_deref().unwrap_or("R");

    // Switch hitter bats opposite of pitcher's hand
    let effective_hand = if batter_hand == "S" {
        if pitcher_hand == "R" { "L" } else { "R" }
    } else {
        batter_hand
    };

    let batter_split_label = if pitcher_hand == "L" { "vLHP" } else { "vRHP" };
    let batter_split_key = if pitcher_hand == "L" { "vlhp" } else { "vrhp" };
    let pitcher_split_label = if effective_hand == "L" { "vLHB" } else { "vRHB" };
    let pitcher_split_key = if effective_hand == "L" { "vlhb" } else { "vrhb" };

    let no_rating = || MatchupResult {
        player: player.clone(),
        rating: None,
        tier: "--".to_string(),
        batter_hand: effective_hand.to_string(),
        batter_split: batter_split_label.to_string(),
        pitcher_split: pitcher_split_label.to_string(),
        batter_component: None,
        pitcher_component: None,
    };

    let Some(batter_card) = batter_card else {
        return Ok(no_rating());
    };

    let Some(batter_cache) =
        get_cached_batter_score(pool, batter_card.id, batter_split_key).await?
    else {
        return Ok(no_rating());
    };

    let batter_score = batter_cache.total_score;

    let pitcher_score = if let Some(pc) = pitcher_card {
        get_cached_pitcher_score(pool, pc.id, pitcher_split_key)
            .await?
            .map(|c| c.total_score)
    } else {
        None
    };

    let total = match pitcher_score {
        Some(ps) => batter_score + (-ps),
        None => batter_score,
    };

    Ok(MatchupResult {
        player: player.clone(),
        rating: Some(total),
        tier: get_tier(Some(total)).to_string(),
        batter_hand: effective_hand.to_string(),
        batter_split: batter_split_label.to_string(),
        pitcher_split: pitcher_split_label.to_string(),
        batter_component: Some(batter_score),
        pitcher_component: pitcher_score,
    })
}

/// Calculate cached matchups for a full roster against one pitcher, sorted best-first.
///
/// Iterates all batters, calling `calculate_matchup_cached` for each, then sorts
/// descending by rating (rated players first, unrated last).
pub async fn calculate_team_matchups_cached(
    pool: &SqlitePool,
    batters: &[(Player, Option<BatterCard>)],
    pitcher: &Player,
    pitcher_card: Option<&PitcherCard>,
) -> Result<Vec<MatchupResult>> {
    let mut results = Vec::with_capacity(batters.len());
    for (player, batter_card) in batters {
        let result =
            calculate_matchup_cached(pool, player, batter_card.as_ref(), pitcher, pitcher_card)
                .await?;
        results.push(result);
    }

    results.sort_by(|a, b| match (&b.rating, &a.rating) {
        (Some(br), Some(ar)) => br.partial_cmp(ar).unwrap_or(std::cmp::Ordering::Equal),
        (Some(_), None) => std::cmp::Ordering::Greater, // b rated, a not → b first
        (None, Some(_)) => std::cmp::Ordering::Less,    // a rated, b not → a first
        (None, None) => std::cmp::Ordering::Equal,
    });

    Ok(results)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dist(avg: f64, stdev: f64) -> StatDistribution {
        StatDistribution { avg, stdev }
    }

    // --- standardize_value tests ---

    #[test]
    fn zero_value_always_returns_3() {
        let d = dist(5.0, 1.0);
        assert_eq!(standardize_value(0.0, &d, true), 3);
        assert_eq!(standardize_value(0.0, &d, false), 3);
    }

    #[test]
    fn value_far_above_mean_is_minus3_base() {
        // value > avg + 2*stdev => base_score = -3
        let d = dist(5.0, 1.0);
        // high_is_better=true: -(-3) = 3
        assert_eq!(standardize_value(7.5, &d, true), 3);
        // high_is_better=false: -3
        assert_eq!(standardize_value(7.5, &d, false), -3);
    }

    #[test]
    fn value_far_below_mean_is_plus3_base() {
        // value <= avg - 2*stdev => base_score = 3
        let d = dist(5.0, 1.0);
        // high_is_better=true: -(3) = -3
        assert_eq!(standardize_value(2.5, &d, true), -3);
        // high_is_better=false: 3
        assert_eq!(standardize_value(2.5, &d, false), 3);
    }

    #[test]
    fn value_at_mean_returns_zero_base() {
        // avg - 0.33*stdev < value < avg + 0.33*stdev => base_score = 0
        let d = dist(5.0, 1.0);
        assert_eq!(standardize_value(5.0, &d, true), 0);
        assert_eq!(standardize_value(5.0, &d, false), 0);
    }

    #[test]
    fn score_ranges_are_symmetric() {
        // Walk through all 7 buckets with a clean distribution
        let d = dist(10.0, 2.0);

        // base_score mapping (raw, not flipped):
        //   > 14.0 (avg+2*std) => -3
        //   > 12.0 (avg+std)   => -2
        //   > 10.66 (avg+0.33*std) => -1
        //   > 9.34 (avg-0.33*std)  => 0
        //   > 8.0 (avg-std)    => 1
        //   > 6.0 (avg-2*std)  => 2
        //   <= 6.0             => 3

        // Test with high_is_better=false (no flip)
        assert_eq!(standardize_value(15.0, &d, false), -3);
        assert_eq!(standardize_value(13.0, &d, false), -2);
        assert_eq!(standardize_value(11.0, &d, false), -1);
        assert_eq!(standardize_value(10.0, &d, false), 0);
        assert_eq!(standardize_value(9.0, &d, false), 1);
        assert_eq!(standardize_value(7.0, &d, false), 2);
        assert_eq!(standardize_value(5.0, &d, false), 3);
    }

    // --- calculate_weighted_score tests ---

    #[test]
    fn weighted_score_multiplies_std_by_weight() {
        let d = dist(10.0, 2.0);
        let w = StatWeight { weight: 5, high_is_better: true };
        // value=15.0 => base=-3, flipped=3 => 3*5=15.0
        assert!((calculate_weighted_score(15.0, &d, &w) - 15.0).abs() < f64::EPSILON);
    }

    #[test]
    fn weighted_score_zero_value() {
        let d = dist(10.0, 2.0);
        let w = StatWeight { weight: 3, high_is_better: false };
        // value=0.0 => always 3 => 3*3=9.0
        assert!((calculate_weighted_score(0.0, &d, &w) - 9.0).abs() < f64::EPSILON);
    }

    // --- get_tier tests ---

    #[test]
    fn tier_none_returns_dash() {
        assert_eq!(get_tier(None), "--");
    }

    #[test]
    fn tier_a_at_40_and_above() {
        assert_eq!(get_tier(Some(40.0)), "A");
        assert_eq!(get_tier(Some(50.0)), "A");
    }

    #[test]
    fn tier_b_between_20_and_39() {
        assert_eq!(get_tier(Some(39.0)), "B");
        assert_eq!(get_tier(Some(20.0)), "B");
    }

    #[test]
    fn tier_c_between_neg19_and_19() {
        assert_eq!(get_tier(Some(19.0)), "C");
        assert_eq!(get_tier(Some(0.0)), "C");
        assert_eq!(get_tier(Some(-19.0)), "C");
    }

    #[test]
    fn tier_d_between_neg39_and_neg20() {
        assert_eq!(get_tier(Some(-20.0)), "D");
        assert_eq!(get_tier(Some(-39.0)), "D");
    }

    #[test]
    fn tier_f_at_neg40_and_below() {
        assert_eq!(get_tier(Some(-40.0)), "F");
    }

    // --- MatchupResult::rating_display tests ---

    fn make_result(rating: Option<f64>) -> MatchupResult {
        use crate::db::models::Player;
        MatchupResult {
            player: Player {
                id: 1,
                name: "Test".to_string(),
                season: 13,
                team_id: None,
                swar: None,
                card_image: None,
                card_image_alt: None,
                headshot: None,
                vanity_card: None,
                pos_1: None,
                pos_2: None,
                pos_3: None,
                pos_4: None,
                pos_5: None,
                pos_6: None,
                pos_7: None,
                pos_8: None,
                hand: None,
                injury_rating: None,
                il_return: None,
                demotion_week: None,
                strat_code: None,
                bbref_id: None,
                sbaplayer_id: None,
                last_game: None,
                last_game2: None,
                synced_at: None,
            },
            rating,
            tier: get_tier(rating).to_string(),
            batter_hand: "R".to_string(),
            batter_split: "vRHP".to_string(),
            pitcher_split: "vRHB".to_string(),
            batter_component: None,
            pitcher_component: None,
        }
    }

    #[test]
    fn rating_display_positive() {
        assert_eq!(make_result(Some(35.0)).rating_display(), "+35");
    }

    #[test]
    fn rating_display_negative() {
        assert_eq!(make_result(Some(-25.0)).rating_display(), "-25");
    }

    #[test]
    fn rating_display_zero() {
        assert_eq!(make_result(Some(0.0)).rating_display(), "+0");
    }

    #[test]
    fn rating_display_none() {
        assert_eq!(make_result(None).rating_display(), "N/A");
    }

    // --- calculate_batter_component / calculate_pitcher_component tests ---

    /// Build a zeroed BatterCard. All stat fields default to 0.0.
    /// When every stat value is 0.0, standardize_value always returns 3 (the best possible).
    /// Total batter component = 3 * sum(weights) = 3 * 22 = 66.
    fn make_batter_card_zeroed() -> BatterCard {
        BatterCard {
            id: 1,
            player_id: 1,
            so_vlhp: 0.0, bb_vlhp: 0.0, hit_vlhp: 0.0, ob_vlhp: 0.0,
            tb_vlhp: 0.0, hr_vlhp: 0.0, dp_vlhp: 0.0,
            bphr_vlhp: 0.0, bp1b_vlhp: 0.0,
            so_vrhp: 0.0, bb_vrhp: 0.0, hit_vrhp: 0.0, ob_vrhp: 0.0,
            tb_vrhp: 0.0, hr_vrhp: 0.0, dp_vrhp: 0.0,
            bphr_vrhp: 0.0, bp1b_vrhp: 0.0,
            stealing: None, steal_rating: None, speed: None,
            bunt: None, hit_run: None, fielding: None,
            catcher_arm: None, catcher_pb: None, catcher_t: None,
            rating_vl: None, rating_vr: None, rating_overall: None,
            imported_at: None, source: None,
        }
    }

    /// Build a zeroed PitcherCard. All stat fields default to 0.0.
    /// Total pitcher component = 3 * sum(weights) = 3 * 23 = 69.
    fn make_pitcher_card_zeroed() -> PitcherCard {
        PitcherCard {
            id: 1,
            player_id: 1,
            so_vlhb: 0.0, bb_vlhb: 0.0, hit_vlhb: 0.0, ob_vlhb: 0.0,
            tb_vlhb: 0.0, hr_vlhb: 0.0, dp_vlhb: 0.0,
            bphr_vlhb: 0.0, bp1b_vlhb: 0.0,
            so_vrhb: 0.0, bb_vrhb: 0.0, hit_vrhb: 0.0, ob_vrhb: 0.0,
            tb_vrhb: 0.0, hr_vrhb: 0.0, dp_vrhb: 0.0,
            bphr_vrhb: 0.0, bp1b_vrhb: 0.0,
            hold_rating: None, endurance_start: None, endurance_relief: None,
            endurance_close: None, fielding_range: None, fielding_error: None,
            wild_pitch: None, balk: None, batting_rating: None,
            rating_vlhb: None, rating_vrhb: None, rating_overall: None,
            imported_at: None, source: None,
        }
    }

    #[test]
    fn batter_component_all_zeros_gives_max_score() {
        // All card stats = 0.0 → standardize_value always returns 3 for every stat.
        // Total = 3 * sum(batter weights) = 3 * 22 = 66.
        let card = make_batter_card_zeroed();
        let stats = BatterLeagueStats::default();
        let score = calculate_batter_component(&card, "R", &stats);
        assert!((score - 66.0).abs() < f64::EPSILON, "expected 66.0, got {}", score);
    }

    #[test]
    fn pitcher_component_all_zeros_gives_max_score() {
        // All card stats = 0.0 → standardize_value always returns 3 for every stat.
        // Total = 3 * sum(pitcher weights) = 3 * 23 = 69.
        let card = make_pitcher_card_zeroed();
        let stats = PitcherLeagueStats::default();
        let score = calculate_pitcher_component(&card, "R", &stats);
        assert!((score - 69.0).abs() < f64::EPSILON, "expected 69.0, got {}", score);
    }

    #[test]
    fn batter_component_uses_correct_split() {
        // Verifies that pitcher_hand="L" reads vlhp fields and "R" reads vrhp fields.
        //
        // Set ob_vlhp=1.0 (all other stats remain 0.0).
        // With default league stats (avg=0, stdev=1):
        //   standardize(1.0, {avg=0, stdev=1}, high=true):
        //     1.0 > 0+0.33*1=0.33 → base_score=-1, high=true → +1
        //   ob weight = 5 → contribution = 1*5 = 5
        // All other stats=0 → 3 * weight each.
        // Total for vL: (3*1)+(3*1)+(3*2)+(1*5)+(3*5)+(3*2)+(3*3)+(3*1)+(3*2) = 3+3+6+5+15+6+9+3+6 = 56
        let mut card = make_batter_card_zeroed();
        card.ob_vlhp = 1.0;
        let stats = BatterLeagueStats::default();

        let score_vl = calculate_batter_component(&card, "L", &stats);
        let score_vr = calculate_batter_component(&card, "R", &stats);

        assert!((score_vl - 56.0).abs() < f64::EPSILON, "vL score expected 56.0, got {}", score_vl);
        assert!((score_vr - 66.0).abs() < f64::EPSILON, "vR score expected 66.0, got {}", score_vr);
    }

    #[test]
    fn pitcher_component_uses_correct_split() {
        // Verifies that batter_hand="L" reads vlhb fields and "R" reads vrhb fields.
        //
        // Set so_vlhb=1.0 (all other stats remain 0.0).
        // With default league stats (avg=0, stdev=1):
        //   standardize(1.0, {avg=0, stdev=1}, high=true):
        //     1.0 > 0.33 → base_score=-1, high=true → +1
        //   so weight = 3 → contribution = 1*3 = 3
        // All other vlhb stats=0 → 3 * weight each.
        // Total vL: so(1*3)+bb(3*1)+hit(3*2)+ob(3*5)+tb(3*2)+hr(3*5)+bphr(3*2)+bp1b(3*1)+dp(3*2) = 3+3+6+15+6+15+6+3+6 = 63
        let mut card = make_pitcher_card_zeroed();
        card.so_vlhb = 1.0;
        let stats = PitcherLeagueStats::default();

        let score_vl = calculate_pitcher_component(&card, "L", &stats);
        let score_vr = calculate_pitcher_component(&card, "R", &stats);

        assert!((score_vl - 63.0).abs() < f64::EPSILON, "vL score expected 63.0, got {}", score_vl);
        assert!((score_vr - 69.0).abs() < f64::EPSILON, "vR score expected 69.0, got {}", score_vr);
    }

    // --- calculate_matchup tests ---

    fn make_player(hand: Option<&str>) -> Player {
        Player {
            id: 1,
            name: "Test".to_string(),
            season: 13,
            team_id: None,
            swar: None,
            card_image: None,
            card_image_alt: None,
            headshot: None,
            vanity_card: None,
            pos_1: None, pos_2: None, pos_3: None, pos_4: None,
            pos_5: None, pos_6: None, pos_7: None, pos_8: None,
            hand: hand.map(|s| s.to_string()),
            injury_rating: None,
            il_return: None,
            demotion_week: None,
            strat_code: None,
            bbref_id: None,
            sbaplayer_id: None,
            last_game: None,
            last_game2: None,
            synced_at: None,
        }
    }

    #[test]
    fn switch_hitter_vs_right_bats_left() {
        // Switch hitter (S) vs right-handed pitcher → bats left → split vRHP / vLHB
        let batter = make_player(Some("S"));
        let pitcher = make_player(Some("R"));
        let batter_card = make_batter_card_zeroed();
        let pitcher_card = make_pitcher_card_zeroed();
        let b_stats = BatterLeagueStats::default();
        let p_stats = PitcherLeagueStats::default();

        let result = calculate_matchup(
            &batter, Some(&batter_card), &pitcher, Some(&pitcher_card), &b_stats, &p_stats,
        );

        assert_eq!(result.batter_hand, "L");
        assert_eq!(result.batter_split, "vRHP");
        assert_eq!(result.pitcher_split, "vLHB");
    }

    #[test]
    fn switch_hitter_vs_left_bats_right() {
        // Switch hitter (S) vs left-handed pitcher → bats right → split vLHP / vRHB
        let batter = make_player(Some("S"));
        let pitcher = make_player(Some("L"));
        let batter_card = make_batter_card_zeroed();
        let pitcher_card = make_pitcher_card_zeroed();
        let b_stats = BatterLeagueStats::default();
        let p_stats = PitcherLeagueStats::default();

        let result = calculate_matchup(
            &batter, Some(&batter_card), &pitcher, Some(&pitcher_card), &b_stats, &p_stats,
        );

        assert_eq!(result.batter_hand, "R");
        assert_eq!(result.batter_split, "vLHP");
        assert_eq!(result.pitcher_split, "vRHB");
    }

    #[test]
    fn left_hitter_vs_right_pitcher_uses_vrhp_split() {
        // Regular L batter vs R pitcher → split vRHP / vLHB
        let batter = make_player(Some("L"));
        let pitcher = make_player(Some("R"));
        let batter_card = make_batter_card_zeroed();
        let b_stats = BatterLeagueStats::default();
        let p_stats = PitcherLeagueStats::default();

        let result = calculate_matchup(
            &batter, Some(&batter_card), &pitcher, None, &b_stats, &p_stats,
        );

        assert_eq!(result.batter_hand, "L");
        assert_eq!(result.batter_split, "vRHP");
        assert_eq!(result.pitcher_split, "vLHB");
        assert!(result.rating.is_some());
    }

    #[test]
    fn no_batter_card_gives_none_rating_and_dash_tier() {
        // Missing batter card → rating=None, tier="--"
        let batter = make_player(Some("R"));
        let pitcher = make_player(Some("R"));
        let b_stats = BatterLeagueStats::default();
        let p_stats = PitcherLeagueStats::default();

        let result = calculate_matchup(&batter, None, &pitcher, None, &b_stats, &p_stats);

        assert!(result.rating.is_none());
        assert_eq!(result.tier, "--");
        assert!(result.batter_component.is_none());
        assert!(result.pitcher_component.is_none());
    }

    #[test]
    fn no_pitcher_card_uses_batter_only_rating() {
        // No pitcher card → total = batter_component only (all zeros → 66.0)
        let batter = make_player(Some("R"));
        let pitcher = make_player(Some("R"));
        let batter_card = make_batter_card_zeroed();
        let b_stats = BatterLeagueStats::default();
        let p_stats = PitcherLeagueStats::default();

        let result = calculate_matchup(
            &batter, Some(&batter_card), &pitcher, None, &b_stats, &p_stats,
        );

        let rating = result.rating.expect("should have a rating");
        assert!((rating - 66.0).abs() < f64::EPSILON, "expected 66.0, got {}", rating);
        assert!(result.pitcher_component.is_none());
    }

    #[test]
    fn pitcher_component_is_inverted_in_total() {
        // Both cards zeroed: batter=66, pitcher=69 (vRHB).
        // total = 66 + (-69) = -3
        let batter = make_player(Some("R"));
        let pitcher = make_player(Some("R"));
        let batter_card = make_batter_card_zeroed();
        let pitcher_card = make_pitcher_card_zeroed();
        let b_stats = BatterLeagueStats::default();
        let p_stats = PitcherLeagueStats::default();

        let result = calculate_matchup(
            &batter, Some(&batter_card), &pitcher, Some(&pitcher_card), &b_stats, &p_stats,
        );

        let rating = result.rating.expect("should have a rating");
        assert!((rating - (-3.0)).abs() < f64::EPSILON, "expected -3.0, got {}", rating);
    }

    // --- calculate_team_matchups tests ---

    #[test]
    fn team_matchups_sorted_rated_first_descending() {
        // Build 3 batters: two with cards (different ratings), one without.
        // Sorting: rated descending, then unrated last.
        let pitcher = make_player(Some("R"));
        let p_stats = PitcherLeagueStats::default();

        // batter A: no card → None rating
        let batter_a = make_player(Some("R"));

        // batter B: zeroed card → 66.0 (batter-only, no pitcher card)
        let mut batter_b = make_player(Some("R"));
        batter_b.id = 2;
        let card_b = make_batter_card_zeroed();

        // batter C: card with ob_vrhp=1.0 → score 56.0
        let mut batter_c = make_player(Some("R"));
        batter_c.id = 3;
        let mut card_c = make_batter_card_zeroed();
        card_c.ob_vrhp = 1.0;

        let batters: Vec<(Player, Option<BatterCard>)> = vec![
            (batter_a, None),
            (batter_b, Some(card_b)),
            (batter_c, Some(card_c)),
        ];

        let b_stats = BatterLeagueStats::default();
        let results = calculate_team_matchups(&batters, &pitcher, None, &b_stats, &p_stats);

        assert_eq!(results.len(), 3);
        // First: batter_b (66.0)
        assert!((results[0].rating.unwrap() - 66.0).abs() < f64::EPSILON);
        // Second: batter_c (56.0)
        assert!((results[1].rating.unwrap() - 56.0).abs() < f64::EPSILON);
        // Third: batter_a (None)
        assert!(results[2].rating.is_none());
    }
}
