//! Unit + integration tests for [`crate::traffic_detector::TrafficDetector`].

use crate::arrival_log::{ArrivalLog, DestinationLog};
use crate::entity::EntityId;
use crate::traffic_detector::{TrafficDetector, TrafficMode};
use crate::world::World;

use super::helpers::{default_config, run_until_done, scan};
use crate::sim::Simulation;
use crate::stop::StopId;

/// Spawn three anonymous entities in a scratch world. Stable order —
/// the returned `[lobby, f2, f3]` layout matches the detector's
/// "lobby-is-first" expectation.
fn fake_stops() -> (World, Vec<EntityId>) {
    let mut world = World::new();
    let lobby = world.spawn();
    let f2 = world.spawn();
    let f3 = world.spawn();
    (world, vec![lobby, f2, f3])
}

// ── Classifier — direct unit tests ──────────────────────────────────

#[test]
fn default_mode_is_idle() {
    let d = TrafficDetector::new();
    assert_eq!(d.current_mode(), TrafficMode::Idle);
}

/// An empty arrival log at any tick keeps the detector idle — no
/// demand means no mode to claim.
#[test]
fn empty_log_stays_idle() {
    let mut d = TrafficDetector::new();
    let log = ArrivalLog::default();
    let (_w, stops) = fake_stops();
    d.update(&log, &DestinationLog::default(), 60 * 60 * 10, &stops);
    assert_eq!(d.current_mode(), TrafficMode::Idle);
}

/// Classifier flips to `UpPeak` once ≥60% of arrivals are at the
/// lobby over the window.
#[test]
fn up_peak_trips_on_lobby_fraction() {
    let mut d = TrafficDetector::new().with_window_ticks(3_600);
    let mut log = ArrivalLog::default();
    let (_w, stops) = fake_stops();
    let lobby = stops[0];
    let f2 = stops[1];
    let f3 = stops[2];
    // 70 arrivals at lobby, 30 spread across upper floors → 70% lobby.
    for t in 0..70u64 {
        log.record(t * 50, lobby);
    }
    for t in 0..15u64 {
        log.record(t * 50, f2);
        log.record(t * 50, f3);
    }
    d.update(&log, &DestinationLog::default(), 3_500, &[lobby, f2, f3]);
    assert_eq!(d.current_mode(), TrafficMode::UpPeak);
}

/// Evenly distributed arrivals at a sustained rate classify as
/// `InterFloor`, not up-peak.
#[test]
fn inter_floor_uniform_distribution() {
    let mut d = TrafficDetector::new().with_window_ticks(3_600);
    let mut log = ArrivalLog::default();
    let (_w, stops) = fake_stops();
    for t in 0..60u64 {
        for &s in &stops {
            log.record(t * 10, s);
        }
    }
    d.update(&log, &DestinationLog::default(), 3_500, &stops);
    assert_eq!(d.current_mode(), TrafficMode::InterFloor);
}

/// Below the idle-rate threshold the classifier returns `Idle`
/// regardless of the lobby fraction — a trickle of lobby-only
/// arrivals during overnight hours isn't "up-peak."
#[test]
fn idle_rate_overrides_lobby_fraction() {
    let mut d = TrafficDetector::new().with_window_ticks(3_600);
    let mut log = ArrivalLog::default();
    let (_w, stops) = fake_stops();
    let lobby = stops[0];
    let f2 = stops[1];
    // 1 lobby arrival over the 3600-tick window = rate 1/3600 ≈
    // 0.00028/tick, under the 2/3600 ≈ 0.00056 default threshold.
    // Total rate is what the classifier checks first.
    log.record(100, lobby);
    d.update(&log, &DestinationLog::default(), 3_500, &[lobby, f2]);
    assert_eq!(d.current_mode(), TrafficMode::Idle);
}

/// Zero stops → Idle (no lobby to compare against).
#[test]
fn no_stops_is_idle() {
    let mut d = TrafficDetector::new();
    d.update(
        &ArrivalLog::default(),
        &DestinationLog::default(),
        1_000,
        &[],
    );
    assert_eq!(d.current_mode(), TrafficMode::Idle);
}

/// An empty arrival window with `idle_rate_threshold = 0.0` must
/// still classify as `Idle` — the docstring promises "min rate to
/// leave Idle," which a zero threshold trivially satisfies, but the
/// strict `<` in the rate comparison wouldn't on its own (0 < 0 is
/// false). Greptile regression pin for the #361 review.
#[test]
fn zero_threshold_with_empty_window_stays_idle() {
    let mut d = TrafficDetector::new().with_idle_rate_threshold(0.0);
    let (_w, stops) = fake_stops();
    d.update(
        &ArrivalLog::default(),
        &DestinationLog::default(),
        3_600,
        &stops,
    );
    assert_eq!(d.current_mode(), TrafficMode::Idle);
}

/// Classifier flips to `DownPeak` when ≥60% of destinations are
/// lobby. Needs arrivals to be distributed (otherwise `UpPeak`'s
/// higher precedence wins the mode).
#[test]
fn down_peak_trips_on_lobby_destination_fraction() {
    let mut d = TrafficDetector::new().with_window_ticks(3_600);
    let mut arrivals = ArrivalLog::default();
    let mut destinations = DestinationLog::default();
    let (_w, stops) = fake_stops();
    let lobby = stops[0];
    let f2 = stops[1];
    let f3 = stops[2];
    // 30 arrivals each at f2 and f3 (60 total, 0% lobby-origin),
    // 45 of their destinations are lobby (75% lobby-destination).
    for t in 0..30u64 {
        arrivals.record(t * 50, f2);
        arrivals.record(t * 50, f3);
    }
    for t in 0..45u64 {
        destinations.record(t * 50, lobby);
    }
    for t in 0..15u64 {
        destinations.record(t * 50, f2);
    }
    d.update(&arrivals, &destinations, 3_500, &[lobby, f2, f3]);
    assert_eq!(d.current_mode(), TrafficMode::DownPeak);
}

/// Up-peak wins precedence when both thresholds are met — documented
/// in `TrafficDetector::update`'s precedence paragraph.
#[test]
fn up_peak_beats_down_peak_when_both_trigger() {
    let mut d = TrafficDetector::new().with_window_ticks(3_600);
    let mut arrivals = ArrivalLog::default();
    let mut destinations = DestinationLog::default();
    let (_w, stops) = fake_stops();
    let lobby = stops[0];
    let f2 = stops[1];
    // Pathological: every arrival AND every destination at lobby.
    // `f2` appears only to avoid div-by-zero with zero non-lobby
    // stops; the log entries there are 0.
    for t in 0..100u64 {
        arrivals.record(t * 30, lobby);
        destinations.record(t * 30, lobby);
    }
    d.update(&arrivals, &destinations, 3_500, &[lobby, f2]);
    assert_eq!(d.current_mode(), TrafficMode::UpPeak);
}

#[test]
#[should_panic(expected = "down_peak_fraction must be finite and in [0, 1]")]
fn down_peak_fraction_out_of_range_panics() {
    let _ = TrafficDetector::new().with_down_peak_fraction(-0.1);
}

#[test]
#[should_panic(expected = "TrafficDetector::with_window_ticks requires a positive window")]
fn zero_window_panics() {
    let _ = TrafficDetector::new().with_window_ticks(0);
}

#[test]
#[should_panic(expected = "up_peak_fraction must be finite and in [0, 1]")]
fn out_of_range_up_peak_fraction_panics() {
    let _ = TrafficDetector::new().with_up_peak_fraction(1.5);
}

#[test]
#[should_panic(expected = "idle_rate_threshold must be finite and non-negative")]
fn nan_idle_rate_panics() {
    let _ = TrafficDetector::new().with_idle_rate_threshold(f64::NAN);
}

// ── Integration: Simulation auto-installs + auto-updates ────────────

/// `Simulation::new` must install a `TrafficDetector` as a world
/// resource so downstream strategies can read it without manual
/// plumbing.
#[test]
fn simulation_installs_traffic_detector_resource() {
    let sim = Simulation::new(&default_config(), scan()).unwrap();
    let present = sim.world().resource::<TrafficDetector>().is_some();
    assert!(
        present,
        "Simulation::new must insert a TrafficDetector resource by default"
    );
}

/// `Simulation::new` must install both logs — missing `DestinationLog`
/// silently disables `DownPeak` detection because rider-spawn's
/// `resource_mut` call no-ops when the resource isn't present.
#[test]
fn simulation_installs_both_arrival_and_destination_logs() {
    let sim = Simulation::new(&default_config(), scan()).unwrap();
    assert!(
        sim.world().resource::<ArrivalLog>().is_some(),
        "Simulation::new must insert an ArrivalLog resource"
    );
    assert!(
        sim.world().resource::<DestinationLog>().is_some(),
        "Simulation::new must insert a DestinationLog resource — \
         missing it silently disables DownPeak detection"
    );
}

/// End-to-end: spawning a rider must append to both logs. Catches the
/// regression where only `ArrivalLog` was installed in `new()` and
/// `DestinationLog` writes silently no-op'd.
#[test]
fn rider_spawn_appends_to_both_logs() {
    let mut sim = Simulation::new(&default_config(), scan()).unwrap();
    sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();
    let arrivals_len = sim.world().resource::<ArrivalLog>().unwrap().len();
    let destinations_len = sim.world().resource::<DestinationLog>().unwrap().len();
    assert_eq!(arrivals_len, 1, "arrival log must carry 1 entry");
    assert_eq!(destinations_len, 1, "destination log must carry 1 entry");
}

/// Buildings with below-grade stops must still classify a lobby-heavy
/// rush as `UpPeak`. Regression for the position-sort bug in
/// `refresh_traffic_detector`: sorting by position handed `stops[0]`
/// to whichever stop sat lowest on the shaft (a basement), so an
/// honest 76 %-Lobby morning rush registered ~0 % at the synthetic
/// "lobby" and never tripped the threshold. The fix is to feed the
/// detector stops in declaration order — `BuildingConfig` puts the
/// lobby first by convention.
#[test]
fn up_peak_detected_in_building_with_basement() {
    use crate::components::{Accel, Speed, Weight};
    use crate::config::{
        BuildingConfig, ElevatorConfig, PassengerSpawnConfig, SimConfig, SimulationParams,
    };
    use crate::stop::StopConfig;

    // Lobby declared first; basements after the upper floor so the
    // position sort would put `B1` at `stops[0]` if reintroduced. The
    // upper floor exists only to give riders a destination — peak
    // classification reads the origin distribution, not destinations.
    let cfg = SimConfig {
        schema_version: crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
        building: BuildingConfig {
            name: "Basement-fronted tower".into(),
            stops: vec![
                StopConfig {
                    id: StopId(0),
                    name: "Lobby".into(),
                    position: 0.0,
                },
                StopConfig {
                    id: StopId(1),
                    name: "Floor 2".into(),
                    position: 4.0,
                },
                StopConfig {
                    id: StopId(2),
                    name: "B1".into(),
                    position: -4.0,
                },
            ],
            lines: None,
            groups: None,
        },
        elevators: vec![ElevatorConfig {
            id: 0,
            name: "Main".into(),
            max_speed: Speed::from(2.0),
            acceleration: Accel::from(1.5),
            deceleration: Accel::from(2.0),
            weight_capacity: Weight::from(800.0),
            starting_stop: StopId(0),
            door_open_ticks: 10,
            door_transition_ticks: 5,
            restricted_stops: Vec::new(),
            #[cfg(feature = "energy")]
            energy_profile: None,
            service_mode: None,
            inspection_speed_factor: 0.25,
            bypass_load_up_pct: None,
            bypass_load_down_pct: None,
        }],
        simulation: SimulationParams {
            ticks_per_second: 60.0,
        },
        passenger_spawning: PassengerSpawnConfig {
            mean_interval_ticks: 120,
            weight_range: (50.0, 100.0),
        },
    };
    let mut sim = Simulation::new(&cfg, scan()).unwrap();

    // Seed the arrival log directly — we want to test the classifier's
    // reading of an existing distribution, not the spawn pipeline. 70
    // arrivals at the lobby vs 0 elsewhere is a clean ≥60 % up-peak.
    // Records are stamped at tick 0 so a single `step()` can see them
    // all in its rolling window (the metrics phase reads
    // `arrivals_in_window(..., now=1, ...)`, which only counts records
    // with `tick <= now`).
    let lobby = sim.stop_entity(StopId(0)).unwrap();
    {
        let log = sim
            .world_mut()
            .resource_mut::<ArrivalLog>()
            .expect("arrival log auto-installed");
        for _ in 0..70u64 {
            log.record(0, lobby);
        }
    }

    // One step is enough — the metrics phase runs every tick and reads
    // the seeded log.
    sim.step();

    let mode = sim
        .world()
        .resource::<TrafficDetector>()
        .expect("detector installed")
        .current_mode();
    assert_eq!(
        mode,
        TrafficMode::UpPeak,
        "70/70 lobby-origin arrivals must trip UpPeak even when basement \
         floors at negative positions exist (got {mode:?} — the position-sort \
         bug would return InterFloor here)"
    );
}

/// The metrics phase must refresh the detector each tick. After a
/// burst of lobby-spawns and enough sim time for the window to fill,
/// the detector's `last_update_tick` must be recent relative to the
/// current tick (within one tick — the metrics phase runs with
/// `ctx.tick` reflecting the just-completed tick, which trails
/// `sim.current_tick()` by at most 1).
#[test]
fn metrics_phase_refreshes_detector_last_update_tick() {
    let mut sim = Simulation::new(&default_config(), scan()).unwrap();
    sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();
    let _ = run_until_done(&mut sim, 20_000);
    let now = sim.current_tick();
    let detector = sim
        .world()
        .resource::<TrafficDetector>()
        .unwrap_or_else(|| panic!("detector installed"));
    let delta = now.saturating_sub(detector.last_update_tick());
    assert!(
        delta <= 1,
        "metrics phase must refresh the detector every tick (delta={delta}, now={now})"
    );
    assert!(
        detector.last_update_tick() > 0,
        "detector was never updated"
    );
}
