//! EPIC-27 Phase 3a — `ExploitativeDecider` integration smoke test.
//!
//! Exercises the full runtime path:
//!   `SimTable` runs hands → `HandHistory` ingested into `StatsRegistry` →
//!   wrapper reads registry via `TableSnapshot::opponent_stats` → deviations fire.
//!
//! These tests do *not* assert exact chip outcomes — 1,000 heads-up hands carry
//! too much variance for that.  They assert structural invariants:
//!
//! - The session completes the requested number of hands without error.
//! - Chips are conserved (zero-sum: seat deltas sum to zero).
//! - The `StatsRegistry` accumulates meaningful data on the opponent.
//! - The exploit wrapper with no stats is a transparent pass-through (same as
//!   bare `RuleBasedDecider`, proven by unit tests; here we just verify no crash).

use pkcore::analysis::player_stats::StatsRegistry;
use pkcore::bot::decider::{BotDecider, RuleBasedDecider};
use pkcore::bot::exploitative_decider::ExploitativeDecider;
use pkcore::bot::profile::BotProfile;
use pkcore::bot::sim::SimTable;
use pkcore::casino::game::ForcedBets;
use pkcore::casino::table::{Player, Seat, Seats, Table};
use uuid::Uuid;

const SB: usize = 50;
const BB: usize = 100;
/// Deep stacks make busting in 1,000 hands essentially impossible.
const STARTING_CHIPS: usize = 1_000_000_000;
const HANDS: usize = 1_000;
/// Fixed seed for all three tests in this file.
///
/// `RuleBasedDecider` uses a thread-local RNG by default, which made earlier
/// versions of these tests flake on CI (rare RNG outliers triggered an
/// `ActionIsntFinished` stall or a no-rule-fires assertion).  Pinning to a
/// known seed via `SimTable::with_seed` makes every hand sequence reproducible.
///
/// Seed 0 was the smallest seed in `0..64` that produces stats outside the
/// `adjust_profile` neutral zone — the empirical sweep showed every seed in
/// that range fires `apply_nit_rules` (PFR < 0.08), so any small seed works;
/// 0 is chosen for simplicity.
const EXPLOIT_SMOKE_SEED: u64 = 0;

fn two_player_table(name_a: &str, name_b: &str) -> (Table, Uuid, Uuid) {
    let player_a = Player::new_with_chips(name_a.to_string(), STARTING_CHIPS);
    let player_b = Player::new_with_chips(name_b.to_string(), STARTING_CHIPS);
    let id_a = player_a.id;
    let id_b = player_b.id;
    let seats = Seats::new(vec![Seat::new(player_a), Seat::new(player_b)]);
    let table = Table::nlh_from_seats(seats, ForcedBets::new(SB, BB));
    (table, id_a, id_b)
}

/// TAG-exploit vs LP over 1,000 hands with a live `StatsRegistry`.
///
/// Assertions:
/// - All 1,000 hands complete without error.
/// - Chips are conserved (zero-sum).
/// - The registry accumulates at least one hand of stats on each player.
/// - The LP opponent's `hands_dealt` equals `hands_played` (ingestion is
///   wired correctly end-to-end).
#[test]
fn exploit_decider_completes_hands_and_accumulates_stats() {
    let (table, id_exploit, id_lp) = two_player_table("TAG_exploit", "LP_static");

    let bots: Vec<(u8, BotProfile, Box<dyn BotDecider>)> = vec![
        (
            0,
            BotProfile::tight_aggressive(),
            Box::new(ExploitativeDecider::wrap(RuleBasedDecider)),
        ),
        (1, BotProfile::loose_passive(), Box::new(RuleBasedDecider)),
    ];

    let mut sim = SimTable::new_with_registry(table, bots, StatsRegistry::new()).with_seed(EXPLOIT_SMOKE_SEED);
    let result = sim.run_n_hands(HANDS).expect("session must complete without error");

    assert_eq!(
        result.hands_played, HANDS,
        "expected {HANDS} hands, got {}",
        result.hands_played
    );

    // Zero-sum: all seat deltas must sum to zero.
    let total_delta: i64 = result.net_chips.values().sum();
    assert_eq!(total_delta, 0, "chips must be conserved; total delta was {total_delta}");

    let stats = sim.stats().expect("registry must be attached");
    assert!(!stats.is_empty(), "registry must contain per-player entries");

    // Both players must have been dealt into the registry.
    let exploit_stats = stats.get(id_exploit).expect("exploit bot must have stats entry");
    let lp_stats = stats.get(id_lp).expect("LP bot must have stats entry");

    let expected = u64::try_from(HANDS).expect("fits u64");
    assert_eq!(
        exploit_stats.hands_dealt, expected,
        "exploit bot hands_dealt must equal hands_played"
    );
    assert_eq!(
        lp_stats.hands_dealt, expected,
        "LP bot hands_dealt must equal hands_played"
    );
}

/// After 1,000 hands, the LP opponent's accumulated stats must push
/// `adjust_profile` to produce a measurably different profile — proving that
/// the full ingestion → deviation pipeline fires, not just that the wrapper
/// passes through unchanged.
///
/// LP bots play almost every hand (high VPIP) and rarely fold to c-bets, so
/// both the calling-station rule and the c-bet-sticky rule should fire once
/// enough data accumulates.
#[test]
fn accumulated_lp_stats_cause_profile_deviation() {
    use pkcore::bot::exploit::{ExploitConfig, adjust_profile};
    use pkcore::bot::table_snapshot::TableSnapshot;
    use pkcore::casino::game::ForcedBets;
    use pkcore::casino::table::{Player, Seat, Seats, Table};

    let (table, _id_exploit, id_lp) = two_player_table("TAG_exploit", "LP_static");

    let bots: Vec<(u8, BotProfile, Box<dyn BotDecider>)> = vec![
        (
            0,
            BotProfile::tight_aggressive(),
            Box::new(ExploitativeDecider::wrap(RuleBasedDecider)),
        ),
        (1, BotProfile::loose_passive(), Box::new(RuleBasedDecider)),
    ];

    let mut sim = SimTable::new_with_registry(table, bots, StatsRegistry::new()).with_seed(EXPLOIT_SMOKE_SEED);
    sim.run_n_hands(HANDS).expect("session must complete");

    let stats = sim.stats().expect("registry must be attached");
    let lp_stats = stats.get(id_lp).expect("LP bot must have a stats entry");

    // Enough hands for the light-sample rules (c-bet, wtsd, 3-bet%) to fire.
    assert!(
        lp_stats.hands_dealt >= 30,
        "LP must have at least 30 hands dealt for rules to fire; got {}",
        lp_stats.hands_dealt
    );

    // Now call adjust_profile directly with the real accumulated stats to verify
    // the deviation pipeline produces a different profile.  Build a minimal
    // snapshot that references the registry so opponent_stats is Some.
    // Build a minimal snapshot table whose LP seat carries id_lp so that
    // largest_active_opponent resolves to the real LP entry in the registry.
    let snap_player_lp = {
        let mut p = Player::new_with_chips("LP_static".to_string(), 1_000_000_000);
        p.id = id_lp;
        p
    };
    let snap_seats2 = Seats::new(vec![
        Seat::new(Player::new_with_chips("TAG_exploit".to_string(), 1_000_000_000)),
        Seat::new(snap_player_lp),
    ]);
    let snap_table = Table::nlh_from_seats(snap_seats2, ForcedBets::new(SB, BB));
    let snap = TableSnapshot::from_table_with_stats(&snap_table, 0, stats);

    let baseline = BotProfile::tight_aggressive();
    let config = ExploitConfig::default();
    let adjusted = adjust_profile(&baseline, &snap, &config);

    // At least one deviation rule must have fired — either bluff_frequency or
    // aggression_factor should differ from the baseline.
    let bluff_changed = adjusted.betting_strategy.bluff_frequency != baseline.betting_strategy.bluff_frequency;
    let aggr_changed = adjusted.betting_strategy.aggression_factor != baseline.betting_strategy.aggression_factor;
    let cbet_changed =
        adjusted.range_strategy.postflop_cbet_frequency != baseline.range_strategy.postflop_cbet_frequency;

    // Diagnostic note: the test fails on the rare CI run where all opponent
    // stats happen to land simultaneously inside the "neutral zone" defined
    // by ExploitConfig::default (vpip ≤ 0.40, pfr ≥ 0.08, wtsd ≤ 0.35,
    // tbet ≤ 0.12, ftc in 0.30..=0.60, af ≤ 4.0). The thread-local
    // rand::rng() used by RuleBasedDecider makes this non-deterministic.
    // Dumping every relevant stat alongside its threshold so the next CI
    // failure tells us exactly which stat fell where.
    assert!(
        bluff_changed || aggr_changed || cbet_changed,
        "adjust_profile must deviate from baseline when LP stats are available.\n\
         hands={}\n\
         vpip={:?}            (calling-station threshold:  > 0.40)\n\
         pfr={:?}             (nit threshold:              < 0.08, passive threshold: < 0.10)\n\
         wtsd={:?}            (high-WTSD threshold:        > 0.35)\n\
         fold_to_cbet={:?}    (high: > 0.60, low: < 0.30; in 0.30..=0.60 -> no fire)\n\
         three_bet_pct={:?}   (frequent-3bettor threshold: > 0.12)\n\
         aggression_factor={:?} (aggro threshold:          > 4.0)\n\
         bluff: {} vs {}, aggr: {} vs {}, cbet: {} vs {}",
        lp_stats.hands_dealt,
        lp_stats.vpip(),
        lp_stats.pfr(),
        lp_stats.wtsd(),
        lp_stats.fold_to_cbet_pct(),
        lp_stats.three_bet_pct(),
        lp_stats.aggression_factor(),
        adjusted.betting_strategy.bluff_frequency.value(),
        baseline.betting_strategy.bluff_frequency.value(),
        adjusted.betting_strategy.aggression_factor.value(),
        baseline.betting_strategy.aggression_factor.value(),
        adjusted.range_strategy.postflop_cbet_frequency.value(),
        baseline.range_strategy.postflop_cbet_frequency.value(),
    );
}

/// Without a registry, `ExploitativeDecider::wrap(RuleBasedDecider)` must run
/// cleanly as a transparent pass-through.  Chips are conserved and the session
/// completes the requested number of hands.
#[test]
fn exploit_wrapper_no_stats_conserves_chips() {
    let (table, _id_a, _id_b) = two_player_table("TAG_exploit", "LP_static");

    let bots: Vec<(u8, BotProfile, Box<dyn BotDecider>)> = vec![
        (
            0,
            BotProfile::tight_aggressive(),
            Box::new(ExploitativeDecider::wrap(RuleBasedDecider)),
        ),
        (1, BotProfile::loose_passive(), Box::new(RuleBasedDecider)),
    ];

    // SimTable::new has no registry — opponent_stats will be None on every snapshot.
    let mut sim = SimTable::new(table, bots).with_seed(EXPLOIT_SMOKE_SEED);
    let result = sim.run_n_hands(HANDS).expect("session must complete without error");

    assert_eq!(result.hands_played, HANDS);

    let total_delta: i64 = result.net_chips.values().sum();
    assert_eq!(total_delta, 0, "chips must be conserved without a registry");
}
