//! Tests for the hall-call destination dispatch strategy.

use crate::components::{Accel, Orientation, Rider, RiderPhase, Speed, Weight};
use crate::config::{
    BuildingConfig, ElevatorConfig, GroupConfig, LineConfig, PassengerSpawnConfig, SimConfig,
    SimulationParams,
};
use crate::dispatch::destination::{ASSIGNED_CAR_KEY, AssignedCar, DestinationDispatch};
use crate::entity::ElevatorId;
use crate::sim::Simulation;
use crate::stop::{StopConfig, StopId};

// ── Config helpers ────────────────────────────────────────────────────────────

/// Single-elevator 3-stop config.
fn single_car_config() -> SimConfig {
    SimConfig {
        building: BuildingConfig {
            name: "DCS Test".into(),
            stops: vec![
                StopConfig {
                    id: StopId(0),
                    name: "G".into(),
                    position: 0.0,
                },
                StopConfig {
                    id: StopId(1),
                    name: "F2".into(),
                    position: 4.0,
                },
                StopConfig {
                    id: StopId(2),
                    name: "F3".into(),
                    position: 8.0,
                },
            ],
            lines: None,
            groups: None,
        },
        elevators: vec![ElevatorConfig {
            id: 0,
            name: "Solo".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),
        },
    }
}

/// 4-stop, 1-line, 2-car config. Both cars serve all 4 stops in the same group.
fn two_cars_same_group_config() -> SimConfig {
    SimConfig {
        building: BuildingConfig {
            name: "DCS Two Car".into(),
            stops: vec![
                StopConfig {
                    id: StopId(0),
                    name: "G".into(),
                    position: 0.0,
                },
                StopConfig {
                    id: StopId(1),
                    name: "F2".into(),
                    position: 4.0,
                },
                StopConfig {
                    id: StopId(2),
                    name: "F3".into(),
                    position: 8.0,
                },
                StopConfig {
                    id: StopId(3),
                    name: "F4".into(),
                    position: 12.0,
                },
            ],
            lines: Some(vec![LineConfig {
                id: 1,
                name: "Main".into(),
                serves: vec![StopId(0), StopId(1), StopId(2), StopId(3)],
                elevators: vec![
                    ElevatorConfig {
                        id: 1,
                        name: "A".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,
                    },
                    ElevatorConfig {
                        id: 2,
                        name: "B".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(3),
                        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,
                    },
                ],
                orientation: Orientation::Vertical,
                position: None,
                min_position: None,
                max_position: None,
                max_cars: None,
            }]),
            groups: Some(vec![GroupConfig {
                id: 0,
                name: "Main".into(),
                lines: vec![1],
                dispatch: crate::dispatch::BuiltinStrategy::Destination,
                reposition: None,
                hall_call_mode: Some(crate::dispatch::HallCallMode::Destination),
                ack_latency_ticks: None,
            }]),
        },
        elevators: vec![],
        simulation: SimulationParams {
            ticks_per_second: 60.0,
        },
        passenger_spawning: PassengerSpawnConfig {
            mean_interval_ticks: 120,
            weight_range: (50.0, 100.0),
        },
    }
}

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

#[test]
fn sticky_assignment_persists_across_ticks() {
    let mut sim = Simulation::new(&single_car_config(), DestinationDispatch::new()).unwrap();
    // single_car_config has no explicit groups, so Simulation::new creates
    // a default group in Classic mode. DCS requires Destination.
    for g in sim.groups_mut() {
        g.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
    }
    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    let rid = sim.spawn_rider(StopId(0), StopId(2), 75.0).unwrap();

    sim.step();
    let first = sim.world().ext::<AssignedCar>(rid.entity());
    assert!(first.is_some(), "rider should be assigned after first tick");

    // Step the sim many times; assignment must never change.
    for _ in 0..500 {
        sim.step();
        if sim
            .world()
            .rider(rid.entity())
            .is_some_and(|r| r.phase() == RiderPhase::Arrived)
        {
            break;
        }
        let cur = sim.world().ext::<AssignedCar>(rid.entity());
        assert_eq!(cur, first, "assignment must be sticky");
    }
}

#[test]
fn loading_respects_assignment_other_car_skips() {
    // Two cars, both can serve the rider's trip. If we manually override
    // the DCS assignment to point at car B, car A must skip the rider even
    // if A arrives first.
    let mut sim = Simulation::new(
        &two_cars_same_group_config(),
        // Strategy only used as default; we override per-group below.
        DestinationDispatch::new(),
    )
    .unwrap();

    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    // Identify the two elevators.
    let elevs: Vec<_> = sim
        .world()
        .iter_elevators()
        .map(|(eid, _, _)| eid)
        .collect();
    assert_eq!(elevs.len(), 2);
    // Car starting at position 0 is A; the other is B.
    let car_a = elevs
        .iter()
        .copied()
        .find(|&e| {
            sim.world()
                .position(e)
                .is_some_and(|p| p.value.abs() < 1e-9)
        })
        .unwrap();
    let car_b = elevs.iter().copied().find(|e| *e != car_a).unwrap();

    // Rider wants to go from F2 (pos 4) to F3 (pos 8).
    let rid = sim.spawn_rider(StopId(1), StopId(2), 75.0).unwrap();

    // Force sticky assignment to car B (the one at pos 12, farther away)
    // and seed B's queue with the rider's pickup + drop-off so DCS's normal
    // queue-driven movement applies to the forced assignment too.
    sim.world_mut()
        .insert_ext(rid.entity(), AssignedCar(car_b), ASSIGNED_CAR_KEY);
    let f2 = sim.stop_entity(StopId(1)).unwrap();
    let f3 = sim.stop_entity(StopId(2)).unwrap();
    sim.push_destination(ElevatorId::from(car_b), f2).unwrap();
    sim.push_destination(ElevatorId::from(car_b), f3).unwrap();

    // Run many ticks. The rider must never board car A.
    for _ in 0..2000 {
        sim.step();
        if sim
            .world()
            .rider(rid.entity())
            .is_some_and(|r| r.phase() == RiderPhase::Arrived)
        {
            break;
        }
        // If the rider is aboard an elevator, it must be car B.
        if let Some(rider) = sim.world().rider(rid.entity()) {
            match rider.phase() {
                RiderPhase::Boarding(e) | RiderPhase::Riding(e) | RiderPhase::Exiting(e) => {
                    assert_eq!(e, car_b, "rider must only board its assigned car");
                }
                _ => {}
            }
        }
    }
    assert!(
        sim.world()
            .rider(rid.entity())
            .is_some_and(|r| r.phase() == RiderPhase::Arrived),
        "rider should eventually arrive via assigned car"
    );
}

#[test]
fn unassigned_manual_board_riders_still_work() {
    // A rider without a Route has no destination known at hall-call time,
    // so DCS must not assign them. The existing manual-board behaviour
    // (attach rider via `build_rider` with no destination) must
    // be preserved.
    let mut sim = Simulation::new(&single_car_config(), DestinationDispatch::new()).unwrap();
    for g in sim.groups_mut() {
        g.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
    }
    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    // Standard spawn: has a Route → DCS should assign.
    let routed = sim.spawn_rider(StopId(0), StopId(2), 75.0).unwrap();

    // Manual rider: set up a rider at stop 0 without a Route. We do this by
    // spawning and then removing the Route component via world mutation
    // below — easiest here is just to check that a routed rider gets an
    // assignment while we reuse the sim.
    sim.step();
    assert!(
        sim.world().ext::<AssignedCar>(routed.entity()).is_some(),
        "routed rider should be assigned"
    );

    // Run to completion.
    for _ in 0..2000 {
        sim.step();
        if sim
            .world()
            .rider(routed.entity())
            .is_some_and(|r| r.phase() == RiderPhase::Arrived)
        {
            break;
        }
    }
    assert!(
        sim.world()
            .rider(routed.entity())
            .is_some_and(|r| r.phase() == RiderPhase::Arrived)
    );
}

#[test]
fn closer_car_is_preferred_when_matching_direction() {
    // Two cars start far apart. A rider at F2 → F3 should be assigned
    // to the closer car (car A at pos 0), not the distant car B at pos 12.
    let mut sim =
        Simulation::new(&two_cars_same_group_config(), DestinationDispatch::new()).unwrap();
    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    let elevs: Vec<_> = sim
        .world()
        .iter_elevators()
        .map(|(eid, _, _)| eid)
        .collect();
    let car_a = elevs
        .iter()
        .copied()
        .find(|&e| sim.world().position(e).map_or(0.0, |p| p.value) < 1.0)
        .unwrap();

    // Rider at F2 → F3: pickup distance to car A = 4, to car B = 8.
    let rid = sim.spawn_rider(StopId(1), StopId(2), 75.0).unwrap();

    sim.step();
    let assigned = sim
        .world()
        .ext::<AssignedCar>(rid.entity())
        .expect("rider should be assigned");
    assert_eq!(assigned.0, car_a, "closer car should be preferred");
}

#[test]
fn up_peak_scenario_delivers_all_riders() {
    let mut sim =
        Simulation::new(&two_cars_same_group_config(), DestinationDispatch::new()).unwrap();
    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    // 20 riders from the lobby (StopId(0)) to upper floors, alternating.
    let mut riders = Vec::new();
    for i in 0..20 {
        let dest = StopId(1 + (i % 3));
        let rid = sim.spawn_rider(StopId(0), dest, 75.0).expect("spawn");
        riders.push(rid);
    }

    // Run until everybody arrives, or bail.
    for _ in 0..20_000 {
        sim.step();
        let done = riders.iter().all(|&rid| {
            sim.world()
                .rider(rid.entity())
                .is_some_and(|r| r.phase() == RiderPhase::Arrived)
        });
        if done {
            break;
        }
    }

    for &rid in &riders {
        let phase = sim.world().rider(rid.entity()).map(Rider::phase);
        assert_eq!(
            phase,
            Some(RiderPhase::Arrived),
            "rider {rid:?} not delivered"
        );
    }
    assert_eq!(sim.metrics().total_delivered(), 20);
}

/// `DestinationDispatch` must be a no-op when the group is in
/// `HallCallMode::Classic` — running DCS there would commit
/// assignments based on post-board destinations a real collective-
/// control controller wouldn't yet know. Regression guard against
/// accidentally re-enabling DCS in Classic groups.
#[test]
fn dcs_gated_to_destination_mode() {
    let mut sim = Simulation::new(&single_car_config(), DestinationDispatch::new()).unwrap();
    // Leave the group in its default Classic mode — DCS should skip.
    assert_eq!(
        sim.groups()[0].hall_call_mode(),
        crate::dispatch::HallCallMode::Classic,
        "default group mode should still be Classic for this test",
    );
    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    let rid = sim.spawn_rider(StopId(0), StopId(2), 75.0).unwrap();

    // Step enough ticks that DCS would have assigned by now in Destination
    // mode. In Classic it stays None because pre_dispatch early-returns.
    for _ in 0..10 {
        sim.step();
    }
    assert!(
        sim.world().ext::<AssignedCar>(rid.entity()).is_none(),
        "DCS must not assign when group is in Classic mode",
    );
}

// ── Sticky-assignment cleanup on car loss (#245) ─────────────────────

#[test]
fn disable_assigned_car_clears_sticky_and_lets_other_car_deliver() {
    // Two cars, A and B. A rider is assigned to A; A is disabled before
    // it can pick up. The cleanup hook in `disable()` must clear the
    // sticky assignment so B can take the rider.
    let mut sim =
        Simulation::new(&two_cars_same_group_config(), DestinationDispatch::new()).unwrap();
    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    let elevs: Vec<_> = sim
        .world()
        .iter_elevators()
        .map(|(eid, _, _)| eid)
        .collect();
    let car_a = elevs
        .iter()
        .copied()
        .find(|&e| sim.world().position(e).map_or(0.0, |p| p.value) < 1.0)
        .unwrap();
    let car_b = elevs.iter().copied().find(|e| *e != car_a).unwrap();

    // Rider at F2 → F3 — DCS will assign to the closer car (A).
    let rid = sim.spawn_rider(StopId(1), StopId(2), 75.0).unwrap();
    sim.step();
    let assigned = sim.world().ext::<AssignedCar>(rid.entity()).unwrap();
    assert_eq!(
        assigned.0, car_a,
        "precondition: rider assigned to closer car A"
    );

    sim.disable(car_a).unwrap();
    assert!(
        sim.world().ext::<AssignedCar>(rid.entity()).is_none(),
        "stale assignment to disabled car must be cleared"
    );

    // Run to completion — B must deliver.
    for _ in 0..10_000 {
        sim.step();
        if sim
            .world()
            .rider(rid.entity())
            .is_some_and(|r| r.phase() == RiderPhase::Arrived)
        {
            break;
        }
    }
    assert_eq!(
        sim.world().rider(rid.entity()).map(Rider::phase),
        Some(RiderPhase::Arrived),
        "rider should be delivered by the surviving car"
    );
    let new_assignment = sim.world().ext::<AssignedCar>(rid.entity()).map(|a| a.0);
    assert_eq!(
        new_assignment,
        Some(car_b),
        "rider should be re-assigned to car B"
    );
}

#[test]
fn remove_assigned_car_clears_sticky_and_lets_other_car_deliver() {
    // Same scenario as disable, but with `remove_elevator` (which calls
    // disable internally and then despawns).
    let mut sim =
        Simulation::new(&two_cars_same_group_config(), DestinationDispatch::new()).unwrap();
    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    let elevs: Vec<_> = sim
        .world()
        .iter_elevators()
        .map(|(eid, _, _)| eid)
        .collect();
    let car_a = elevs
        .iter()
        .copied()
        .find(|&e| sim.world().position(e).map_or(0.0, |p| p.value) < 1.0)
        .unwrap();

    let rid = sim.spawn_rider(StopId(1), StopId(2), 75.0).unwrap();
    sim.step();
    assert_eq!(
        sim.world().ext::<AssignedCar>(rid.entity()).map(|a| a.0),
        Some(car_a),
        "precondition: rider assigned to A"
    );

    sim.remove_elevator(car_a).unwrap();
    assert!(
        sim.world().ext::<AssignedCar>(rid.entity()).is_none(),
        "assignment to removed car must be cleared"
    );
    assert!(!sim.world().is_alive(car_a), "car A should be despawned");

    for _ in 0..10_000 {
        sim.step();
        if sim
            .world()
            .rider(rid.entity())
            .is_some_and(|r| r.phase() == RiderPhase::Arrived)
        {
            break;
        }
    }
    assert_eq!(
        sim.world().rider(rid.entity()).map(Rider::phase),
        Some(RiderPhase::Arrived),
        "rider should be delivered by the surviving car after removal"
    );
}

#[test]
fn loading_ignores_dangling_assignment_to_dead_car() {
    // Defense-in-depth: even if cleanup misses an assignment, loading
    // must not strand the rider behind a dead reference. Forge a sticky
    // assignment pointing at a despawned EntityId and verify the live
    // car still picks the rider up.
    let mut sim = Simulation::new(&single_car_config(), DestinationDispatch::new()).unwrap();
    for g in sim.groups_mut() {
        g.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
    }
    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    let rid = sim.spawn_rider(StopId(0), StopId(2), 75.0).unwrap();

    // Forge a dangling AssignedCar pointing at a despawned id, *before*
    // any tick runs DCS pre_dispatch (which would otherwise overwrite).
    let dead_id = sim.world_mut().spawn();
    sim.world_mut().despawn(dead_id);
    sim.world_mut()
        .insert_ext(rid.entity(), AssignedCar(dead_id), ASSIGNED_CAR_KEY);

    // Step. The dangling AssignedCar exercises BOTH defense layers:
    // DCS pre_dispatch (layer 2) detects target is dead, drops the
    // extension, and re-assigns to the live car. Loading's liveness
    // check (layer 3) is the fallback if cleanup is missed entirely.
    for _ in 0..10_000 {
        sim.step();
        if sim
            .world()
            .rider(rid.entity())
            .is_some_and(|r| r.phase() == RiderPhase::Arrived)
        {
            break;
        }
    }
    assert_eq!(
        sim.world().rider(rid.entity()).map(Rider::phase),
        Some(RiderPhase::Arrived),
        "rider must be delivered despite dangling AssignedCar"
    );
}

/// Deferred commitment: when `commitment_window_ticks` is `Some(window)`,
/// a rider's sticky assignment is re-evaluated each pass until the
/// assigned car is within `window` ticks of the rider's origin. The
/// original car stays chosen once inside the window, even if a closer
/// car appears. Models KONE Polaris's two-button reallocation regime
/// (DCS calls remain fixed on press; two-button hall calls re-allocate
/// continuously until commitment).
#[test]
fn commitment_window_reassigns_when_current_car_is_far() {
    let mut sim = Simulation::new(
        &two_cars_same_group_config(),
        // B's travel time to origin is ~240 ticks at 60 Hz (8 units /
        // 2 m/s = 4 s); a 180-tick window is still outside, so the
        // commitment has not yet latched.
        DestinationDispatch::new().with_commitment_window_ticks(180),
    )
    .unwrap();
    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    // Identify the two elevators by name.
    let elevs: Vec<_> = sim
        .world()
        .iter_elevators()
        .map(|(eid, _, _)| eid)
        .collect();
    // Map by config name: A at stop 0 (pos 0), B at stop 3 (pos 12).
    let car_a = elevs[0];
    let car_b = elevs[1];

    // Spawn a rider at stop 1 (pos 4) going to stop 2 (pos 8). Force the
    // sticky assignment onto the far car (B, pos 12 → 8 units to origin).
    let rid = sim.spawn_rider(StopId(1), StopId(2), 70.0).unwrap();
    sim.world_mut()
        .insert_ext(rid.entity(), AssignedCar(car_b), ASSIGNED_CAR_KEY);

    // One dispatch pass: B is outside the 180-tick commitment window,
    // so the rider is free to migrate to car A (ETA ~120 ticks).
    sim.step();

    assert_eq!(
        sim.world().ext::<AssignedCar>(rid.entity()),
        Some(AssignedCar(car_a)),
        "a far-away sticky assignment must be re-evaluated when outside the commitment window"
    );
}

/// Counterpart to the reassignment case: a car already inside the
/// commitment window keeps its rider even if a closer car appears.
#[test]
fn commitment_window_locks_when_current_car_is_close() {
    let mut sim = Simulation::new(
        &two_cars_same_group_config(),
        // B's ETA is ~240 ticks at 60 Hz; a 600-tick window comfortably
        // covers it, so the sticky assignment stays locked.
        DestinationDispatch::new().with_commitment_window_ticks(600),
    )
    .unwrap();
    sim.world_mut()
        .register_ext::<AssignedCar>(ASSIGNED_CAR_KEY);

    let elevs: Vec<_> = sim
        .world()
        .iter_elevators()
        .map(|(eid, _, _)| eid)
        .collect();
    let car_a = elevs[0]; // at pos 0
    let car_b = elevs[1]; // at pos 12

    // Spawn rider at stop 1 (pos 4). Sticky to B. B's ETA ≈ 240 ticks,
    // well inside the 600-tick commitment window — reassignment denied.
    let rid = sim.spawn_rider(StopId(1), StopId(2), 70.0).unwrap();
    sim.world_mut()
        .insert_ext(rid.entity(), AssignedCar(car_b), ASSIGNED_CAR_KEY);

    sim.step();

    assert_eq!(
        sim.world().ext::<AssignedCar>(rid.entity()),
        Some(AssignedCar(car_b)),
        "inside the commitment window, sticky must hold even when a closer car is available"
    );
    // A didn't steal the rider.
    assert_ne!(
        sim.world().ext::<AssignedCar>(rid.entity()),
        Some(AssignedCar(car_a)),
    );
}
