//! Pure exploit logic: configuration, helpers, and the `adjust_profile` function.
//!
//! This module is the stateless core of [`ExploitativeDecider`].  Its entry
//! point is [`adjust_profile`], a pure function that takes a baseline
//! [`BotProfile`], a [`TableSnapshot`] that may carry a [`crate::analysis::player_stats::StatsRegistry`]
//! borrow, and an [`ExploitConfig`] carrying all deviation thresholds and
//! multipliers. It returns a cloned, adjusted profile — no side effects.
//!
//! [`ExploitativeDecider`]: crate::bot::exploitative_decider::ExploitativeDecider

use crate::analysis::gto::solver_config::BetSize;
use crate::analysis::player_stats::PlayerStats;
use crate::bot::betting_strategy::Percentage;
use crate::bot::profile::BotProfile;
use crate::bot::table_snapshot::{SeatInfo, TableSnapshot};

// ── ExploitConfig ─────────────────────────────────────────────────────────────

/// Thresholds, multipliers, and sample-size gates that drive [`adjust_profile`].
///
/// All fields are public so callers can tune individual rules without
/// constructing the entire struct from scratch.  [`Default`] ships with
/// the canonical heuristic values described in EPIC-27.
///
/// # Examples
///
/// ```
/// use pkcore::bot::exploit::ExploitConfig;
///
/// let cfg = ExploitConfig::default();
/// assert!(cfg.fold_to_cbet_high_threshold > 0.5);
/// assert!(cfg.min_hands_light < cfg.min_hands_heavy);
/// ```
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "bot-training", derive(serde::Serialize, serde::Deserialize))]
pub struct ExploitConfig {
    /// Fold-to-c-bet rate above which we c-bet more aggressively.
    pub fold_to_cbet_high_threshold: f64,
    /// Fold-to-c-bet rate below which we stop merging weak c-bets.
    pub fold_to_cbet_low_threshold: f64,
    /// VPIP above which we treat the opponent as a calling station.
    pub vpip_calling_station_threshold: f64,
    /// PFR below which a loose-passive label requires larger bet sizing.
    pub pfr_passive_threshold: f64,
    /// PFR below which we respect the opponent's 3-bet range.
    pub pfr_nit_threshold: f64,
    /// Aggression factor above which we widen our calldown range.
    pub aggression_factor_threshold: f64,
    /// WTSD rate above which we reduce bluffing.
    pub wtsd_threshold: f64,
    /// 3-bet percentage above which we flatten more and 4-bet bluff less.
    pub three_bet_pct_threshold: f64,

    /// Multiplier applied to `postflop_cbet_frequency` vs a high fold-to-cbet opponent.
    pub fold_to_cbet_high_multiplier: f64,
    /// Multiplier applied to `postflop_cbet_frequency` vs a sticky opponent.
    pub fold_to_cbet_low_multiplier: f64,
    /// Multiplier applied to `bluff_frequency` vs a calling station.
    pub bluff_vs_station_multiplier: f64,
    /// Multiplier applied to `bluff_frequency` vs a high-WTSD opponent.
    pub bluff_vs_wtsd_multiplier: f64,
    /// Multiplier applied to `aggression_factor` when respecting a tight 3-bet range.
    pub aggression_vs_nit_multiplier: f64,
    /// Multiplier applied to `aggression_factor` vs a frequent 3-bettor.
    pub aggression_vs_three_bettor_multiplier: f64,

    /// Minimum hands dealt before light-sample rules (c-bet, wtsd, 3-bet%) fire.
    pub min_hands_light: u64,
    /// Minimum hands dealt before heavy-sample rules (vpip, pfr, AF) fire.
    pub min_hands_heavy: u64,
}

impl Default for ExploitConfig {
    /// Returns the canonical EPIC-27 heuristic defaults.
    fn default() -> Self {
        Self {
            fold_to_cbet_high_threshold: 0.60,
            fold_to_cbet_low_threshold: 0.30,
            vpip_calling_station_threshold: 0.40,
            pfr_passive_threshold: 0.10,
            pfr_nit_threshold: 0.08,
            aggression_factor_threshold: 4.0,
            wtsd_threshold: 0.35,
            three_bet_pct_threshold: 0.12,

            fold_to_cbet_high_multiplier: 1.4,
            fold_to_cbet_low_multiplier: 0.6,
            bluff_vs_station_multiplier: 0.5,
            bluff_vs_wtsd_multiplier: 0.4,
            aggression_vs_nit_multiplier: 0.75,
            aggression_vs_three_bettor_multiplier: 0.80,

            min_hands_light: 30,
            min_hands_heavy: 50,
        }
    }
}

// ── Internal helpers ──────────────────────────────────────────────────────────

/// Scales `p` by `factor`, clamping the result to `[0, 100]`.
///
/// # Examples
///
/// ```
/// use pkcore::bot::betting_strategy::Percentage;
/// use pkcore::bot::exploit::scale_percentage;
///
/// let p = Percentage::new(60).unwrap();
/// let halved = scale_percentage(p, 0.5);
/// assert_eq!(halved.value(), 30);
///
/// let capped = scale_percentage(p, 10.0);
/// assert_eq!(capped.value(), 100);
/// ```
#[must_use]
pub fn scale_percentage(p: Percentage, factor: f64) -> Percentage {
    let scaled = (p.as_f64() * factor).clamp(0.0, 1.0);
    // clamp guarantees 0.0..=1.0, so ×100 is 0.0..=100.0 — fits in u8.
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
    let rounded = (scaled * 100.0).round() as u8;
    Percentage::new(rounded).unwrap_or(p)
}

/// Returns the largest-stack active opponent from `state`.
///
/// In heads-up play this is the only opponent.  In multiway play the
/// heuristic chooses the player still in the pot with the most chips —
/// a reasonable proxy for the most dangerous opponent.  Returns `None`
/// when all other seats are inactive or empty.
///
/// # Examples
///
/// ```
/// use pkcore::bot::exploit::largest_active_opponent;
/// use pkcore::bot::table_snapshot::{SeatInfo, TableSnapshot};
/// use uuid::Uuid;
///
/// let stacks = vec![
///     SeatInfo { id: Uuid::default(), seat: 0, name: "Hero".into(), chips: 1000, bet: 0, is_active: true },
///     SeatInfo { id: Uuid::default(), seat: 1, name: "Villain".into(), chips: 800, bet: 0, is_active: true },
/// ];
/// // Build a minimal snapshot just for illustration — in practice you use
/// // TableSnapshot::from_table.
/// let opp = stacks.iter()
///     .filter(|s| s.is_active && s.seat != 0)
///     .max_by_key(|s| s.chips);
/// assert_eq!(opp.unwrap().name, "Villain");
/// ```
#[must_use]
pub fn largest_active_opponent<'a>(state: &'a TableSnapshot<'_>) -> Option<&'a SeatInfo> {
    state
        .stacks
        .iter()
        .filter(|s| s.is_active && s.seat != state.seat)
        .max_by_key(|s| s.chips)
}

// ── Core adjustment logic ─────────────────────────────────────────────────────

/// Derives an adjusted [`BotProfile`] for the current decision point.
///
/// Reads the largest active opponent's stats from `state.opponent_stats`,
/// applies each enabled deviation rule from the deviation table, and returns
/// a new profile clone.  Returns an unmodified clone when no stats are
/// available or when all sample sizes are below the configured thresholds.
///
/// All modifications are multiplicative on existing knobs so that a profile
/// with a zero value is never "exploited" into negative territory.
///
/// # Examples
///
/// ```
/// use pkcore::analysis::player_stats::StatsRegistry;
/// use pkcore::bot::exploit::{ExploitConfig, adjust_profile};
/// use pkcore::bot::profile::BotProfile;
/// use pkcore::bot::table_snapshot::TableSnapshot;
/// use pkcore::casino::game::ForcedBets;
/// use pkcore::casino::table::{Player, Seat, Seats, Table};
///
/// let seats = Seats::new(vec![
///     Seat::new(Player::new_with_chips("Alice".to_string(), 1_000)),
///     Seat::new(Player::new_with_chips("Bob".to_string(), 1_000)),
/// ]);
/// let table = Table::nlh_from_seats(seats, ForcedBets::new(50, 100));
/// let registry = StatsRegistry::new();
/// let snap = TableSnapshot::from_table_with_stats(&table, 0, &registry);
/// let profile = BotProfile::tight_aggressive();
/// let cfg = ExploitConfig::default();
///
/// let adjusted = adjust_profile(&profile, &snap, &cfg);
/// // No stats yet — output equals input.
/// assert_eq!(adjusted.betting_strategy.bluff_frequency,
///            profile.betting_strategy.bluff_frequency);
/// ```
#[must_use]
pub fn adjust_profile(profile: &BotProfile, state: &TableSnapshot<'_>, config: &ExploitConfig) -> BotProfile {
    let Some(registry) = state.opponent_stats else {
        return profile.clone();
    };

    let Some(opp_seat) = largest_active_opponent(state) else {
        return profile.clone();
    };

    let Some(stats) = registry.get(opp_seat.id) else {
        return profile.clone();
    };

    let mut adjusted = profile.clone();

    apply_cbet_rules(&mut adjusted, stats, config);
    apply_station_rules(&mut adjusted, stats, config);
    apply_nit_rules(&mut adjusted, stats, config);
    apply_aggro_rules(&mut adjusted, stats, config);
    apply_three_bettor_rules(&mut adjusted, stats, config);

    adjusted
}

// ── Deviation rule groups ─────────────────────────────────────────────────────

fn apply_cbet_rules(profile: &mut BotProfile, stats: &PlayerStats, config: &ExploitConfig) {
    if stats.hands_dealt < config.min_hands_light {
        return;
    }
    if let Some(ftc) = stats.fold_to_cbet_pct() {
        if ftc > config.fold_to_cbet_high_threshold {
            profile.range_strategy.postflop_cbet_frequency = scale_percentage(
                profile.range_strategy.postflop_cbet_frequency,
                config.fold_to_cbet_high_multiplier,
            );
        } else if ftc < config.fold_to_cbet_low_threshold {
            profile.range_strategy.postflop_cbet_frequency = scale_percentage(
                profile.range_strategy.postflop_cbet_frequency,
                config.fold_to_cbet_low_multiplier,
            );
        }
    }
    if let Some(wtsd) = stats.wtsd()
        && wtsd > config.wtsd_threshold
    {
        profile.betting_strategy.bluff_frequency = scale_percentage(
            profile.betting_strategy.bluff_frequency,
            config.bluff_vs_wtsd_multiplier,
        );
    }
}

fn apply_station_rules(profile: &mut BotProfile, stats: &PlayerStats, config: &ExploitConfig) {
    if stats.hands_dealt < config.min_hands_heavy {
        return;
    }
    let Some(vpip) = stats.vpip() else {
        return;
    };
    if vpip > config.vpip_calling_station_threshold {
        profile.betting_strategy.bluff_frequency = scale_percentage(
            profile.betting_strategy.bluff_frequency,
            config.bluff_vs_station_multiplier,
        );
        if let Some(pfr) = stats.pfr()
            && pfr < config.pfr_passive_threshold
        {
            // Loose-passive opponent — size up for value.
            profile.betting_strategy.preferred_bet_sizes = vec![BetSize::two_thirds_pot(), BetSize::pot()];
        }
    }
}

fn apply_nit_rules(profile: &mut BotProfile, stats: &PlayerStats, config: &ExploitConfig) {
    if stats.hands_dealt < config.min_hands_heavy {
        return;
    }
    if let Some(pfr) = stats.pfr()
        && pfr < config.pfr_nit_threshold
    {
        // Tight 3-bet range — fold marginal spots, be less aggressive vs their 3-bets.
        profile.betting_strategy.aggression_factor = scale_percentage(
            profile.betting_strategy.aggression_factor,
            config.aggression_vs_nit_multiplier,
        );
    }
}

fn apply_aggro_rules(profile: &mut BotProfile, stats: &PlayerStats, config: &ExploitConfig) {
    if stats.hands_dealt < config.min_hands_heavy {
        return;
    }
    if let Some(af) = stats.aggression_factor()
        && af > config.aggression_factor_threshold
    {
        // Aggro opponent — widen calldown by lowering the value threshold.
        let current = profile.betting_strategy.value_threshold.unwrap_or(0.55);
        profile.betting_strategy.value_threshold = Some((current * 0.85).max(0.30));
    }
}

fn apply_three_bettor_rules(profile: &mut BotProfile, stats: &PlayerStats, config: &ExploitConfig) {
    if stats.hands_dealt < config.min_hands_light {
        return;
    }
    if let Some(tbet) = stats.three_bet_pct()
        && tbet > config.three_bet_pct_threshold
    {
        // Frequent 3-bettor — flat more, 4-bet bluff less.
        profile.betting_strategy.aggression_factor = scale_percentage(
            profile.betting_strategy.aggression_factor,
            config.aggression_vs_three_bettor_multiplier,
        );
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(non_snake_case)]
mod bot__exploit_tests {
    use super::*;
    use crate::analysis::player_stats::StatsRegistry;
    use crate::bot::profile::BotProfile;
    use crate::bot::table_snapshot::TableSnapshot;
    use crate::casino::game::ForcedBets;
    use crate::casino::table::{Player, Seat, Seats, Table};

    fn two_player_table() -> Table {
        let seats = Seats::new(vec![
            Seat::new(Player::new_with_chips("Alice".to_string(), 1_000)),
            Seat::new(Player::new_with_chips("Bob".to_string(), 1_000)),
        ]);
        Table::nlh_from_seats(seats, ForcedBets::new(50, 100))
    }

    fn snap_with_registry<'a>(table: &'a Table, registry: &'a StatsRegistry) -> TableSnapshot<'a> {
        TableSnapshot::from_table_with_stats(table, 0, registry)
    }

    fn snap_no_stats(table: &Table) -> TableSnapshot<'_> {
        TableSnapshot::from_table(table, 0)
    }

    #[test]
    fn adjust_profile_no_stats_is_identity() {
        let table = two_player_table();
        let snap = snap_no_stats(&table);
        let profile = BotProfile::tight_aggressive();
        let cfg = ExploitConfig::default();

        let adjusted = adjust_profile(&profile, &snap, &cfg);
        assert_eq!(
            profile.betting_strategy.bluff_frequency,
            adjusted.betting_strategy.bluff_frequency
        );
        assert_eq!(
            profile.range_strategy.postflop_cbet_frequency,
            adjusted.range_strategy.postflop_cbet_frequency
        );
    }

    #[test]
    fn adjust_profile_high_fold_to_cbet_increases_cbet_frequency() {
        let table = two_player_table();
        let mut registry = StatsRegistry::new();
        let opp_id = table.seats.0[1].player.id;

        let stats = crate::analysis::player_stats::PlayerStats {
            hands_dealt: 50,
            fold_to_cbet_count: 40,
            fold_to_cbet_opportunities: 50,
            ..Default::default()
        };
        // fold_to_cbet_pct = 0.8 > 0.6
        registry.insert_for_test(opp_id, stats);

        let snap = snap_with_registry(&table, &registry);
        let profile = BotProfile::tight_aggressive();
        let cfg = ExploitConfig::default();

        let adjusted = adjust_profile(&profile, &snap, &cfg);
        assert!(
            adjusted.range_strategy.postflop_cbet_frequency > profile.range_strategy.postflop_cbet_frequency,
            "Expected cbet frequency to increase vs high fold-to-cbet opponent"
        );
    }

    #[test]
    fn adjust_profile_calling_station_reduces_bluff() {
        let table = two_player_table();
        let mut registry = StatsRegistry::new();
        let opp_id = table.seats.0[1].player.id;

        let stats = crate::analysis::player_stats::PlayerStats {
            hands_dealt: 60,
            hands_voluntarily_played: 36, // vpip = 0.6
            went_to_showdown: 24,         // wtsd = 0.4
            ..Default::default()
        };
        registry.insert_for_test(opp_id, stats);

        let snap = snap_with_registry(&table, &registry);
        let profile = BotProfile::tight_aggressive();
        let cfg = ExploitConfig::default();

        let adjusted = adjust_profile(&profile, &snap, &cfg);
        assert!(
            adjusted.betting_strategy.bluff_frequency < profile.betting_strategy.bluff_frequency,
            "Expected bluff frequency to decrease vs calling station"
        );
    }

    #[test]
    fn adjust_profile_thin_sample_no_change() {
        let table = two_player_table();
        let mut registry = StatsRegistry::new();
        let opp_id = table.seats.0[1].player.id;

        let stats = crate::analysis::player_stats::PlayerStats {
            hands_dealt: 5,
            hands_voluntarily_played: 4, // vpip = 0.8 — would fire if sample sufficient
            fold_to_cbet_count: 4,
            fold_to_cbet_opportunities: 5, // fold_to_cbet = 0.8 — would fire if sufficient
            ..Default::default()
        };
        registry.insert_for_test(opp_id, stats);

        let snap = snap_with_registry(&table, &registry);
        let profile = BotProfile::tight_aggressive();
        let cfg = ExploitConfig::default();

        let adjusted = adjust_profile(&profile, &snap, &cfg);
        assert_eq!(
            profile.betting_strategy.bluff_frequency, adjusted.betting_strategy.bluff_frequency,
            "Thin sample should produce no adjustment"
        );
        assert_eq!(
            profile.range_strategy.postflop_cbet_frequency, adjusted.range_strategy.postflop_cbet_frequency,
            "Thin sample should produce no adjustment"
        );
    }

    #[test]
    fn adjust_profile_clamps_to_valid_percentage() {
        let table = two_player_table();
        let mut registry = StatsRegistry::new();
        let opp_id = table.seats.0[1].player.id;

        let stats = crate::analysis::player_stats::PlayerStats {
            hands_dealt: 200,
            fold_to_cbet_count: 200,
            fold_to_cbet_opportunities: 200, // fold_to_cbet = 1.0 — extreme input
            ..Default::default()
        };
        registry.insert_for_test(opp_id, stats);

        let snap = snap_with_registry(&table, &registry);
        // Profile with already-high cbet frequency
        let mut profile = BotProfile::tight_aggressive();
        profile.range_strategy.postflop_cbet_frequency = Percentage::new(90).unwrap();
        let cfg = ExploitConfig::default();

        let adjusted = adjust_profile(&profile, &snap, &cfg);
        let freq = adjusted.range_strategy.postflop_cbet_frequency;
        assert!(freq.value() <= 100, "cbet frequency must not exceed 100 after scaling");
    }
}
