//! Tests for the repositioning system and ETD dispatch overhaul.

use crate::arrival_log::{ArrivalLog, CurrentTick};
use crate::builder::SimulationBuilder;
use crate::components::{Accel, ElevatorPhase, RiderPhase, Speed, Weight};
use crate::dispatch::etd::EtdDispatch;
use crate::dispatch::reposition::{
    DemandWeighted, NearestIdle, PredictiveParking, ReturnToLobby, SpreadEvenly,
};
use crate::dispatch::{
    self, BuiltinReposition, DispatchManifest, ElevatorGroup, LineInfo, RepositionStrategy,
};
use crate::entity::EntityId;
use crate::events::Event;
use crate::ids::GroupId;
use crate::stop::{StopConfig, StopId};

// ===== Helpers =====

use crate::components::{Elevator, Position, Rider, Route, Stop, Velocity};
use crate::config::ElevatorConfig;
use crate::dispatch::{DispatchDecision, RiderInfo};
use crate::door::DoorState;
use crate::tagged_metrics::MetricTags;
use crate::world::World;
use std::collections::HashSet;

/// Build a `World` with `n` stops evenly spaced (0.0, 10.0, 20.0, ...)
/// and return `(world, stop_entities)`.
fn test_world_n(n: usize) -> (World, Vec<EntityId>) {
    let mut world = World::new();
    let stops: Vec<_> = (0..n)
        .map(|i| {
            let eid = world.spawn();
            world.set_stop(
                eid,
                Stop {
                    name: format!("Stop {i}"),
                    position: i as f64 * 10.0,
                },
            );
            eid
        })
        .collect();
    (world, stops)
}

fn test_group(stop_entities: &[EntityId], elevator_entities: Vec<EntityId>) -> ElevatorGroup {
    ElevatorGroup::new(
        GroupId(0),
        "Default".into(),
        vec![LineInfo::new(
            EntityId::default(),
            elevator_entities,
            stop_entities.to_vec(),
        )],
    )
}

fn spawn_elevator(world: &mut World, position: f64) -> EntityId {
    let eid = world.spawn();
    world.set_position(eid, Position { value: position });
    world.set_velocity(eid, Velocity { value: 0.0 });
    world.set_elevator(
        eid,
        Elevator {
            phase: ElevatorPhase::Idle,
            door: DoorState::Closed,
            max_speed: Speed::from(2.0),
            acceleration: Accel::from(1.5),
            deceleration: Accel::from(2.0),
            weight_capacity: Weight::from(800.0),
            current_load: Weight::from(0.0),
            riders: vec![],
            target_stop: None,
            door_transition_ticks: 5,
            door_open_ticks: 10,
            line: EntityId::default(),
            repositioning: false,
            restricted_stops: HashSet::new(),
            inspection_speed_factor: 0.25,
            going_up: true,
            going_down: true,
            move_count: 0,
            door_command_queue: Vec::new(),
            manual_target_velocity: None,
            bypass_load_up_pct: None,
            bypass_load_down_pct: None,
        },
    );
    eid
}

fn add_demand(manifest: &mut DispatchManifest, world: &mut World, stop: EntityId, weight: f64) {
    let dummy = world.spawn();
    manifest
        .waiting_at_stop
        .entry(stop)
        .or_default()
        .push(RiderInfo {
            id: dummy,
            destination: None,
            weight: Weight::from(weight),
            wait_ticks: 0,
        });
}

// ===== Repositioning Strategy Tests =====

// 1. SpreadEvenly: 2 idle elevators, 5 stops — verify they spread apart.
#[test]
fn spread_evenly_distributes_elevators() {
    let (mut world, stops) = test_world_n(5);
    // Elevator A at stop 0 (pos 0.0), Elevator B at stop 2 (pos 20.0).
    let elev_a = spawn_elevator(&mut world, 0.0);
    let elev_b = spawn_elevator(&mut world, 20.0);
    let group = test_group(&stops, vec![elev_a, elev_b]);

    let idle = vec![(elev_a, 0.0), (elev_b, 20.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = SpreadEvenly;
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    // Both should move to new positions (they shouldn't stay where they are).
    assert!(
        !result.is_empty(),
        "at least one elevator should be repositioned"
    );
    // They should go to different stops.
    if result.len() == 2 {
        assert_ne!(result[0].1, result[1].1, "should spread to different stops");
    }
    // With the no-spurious-movement tiebreak, elev_a (first iterated) is
    // processed while `occupied` is empty, so every stop ties at INFINITY
    // and the tie-break picks the stop closest to elev_a's current
    // position — stops[0], which elev_a is already at. No move is pushed.
    // Only elev_b (with `occupied=[0]` after elev_a is placed) actually
    // moves, landing on stops[4] as the farthest-from-occupied.
    assert_eq!(result.len(), 1);
    assert_eq!(result[0], (elev_b, stops[4]));
}

/// Regression for the "cars travel to the top at sim start" bug: a
/// single idle car already parked at a stop, with no other occupied
/// positions to spread against, must not be told to move. Pre-fix,
/// `max_by` resolved the all-INFINITY tie by returning the last stop
/// in iteration order — so every car got shipped to the topmost stop
/// whenever the sim started with no demand.
#[test]
fn spread_evenly_no_movement_with_lone_idle_car() {
    let (mut world, stops) = test_world_n(5);
    let elev = spawn_elevator(&mut world, 0.0); // at stops[0]
    let group = test_group(&stops, vec![elev]);

    let idle = vec![(elev, 0.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = SpreadEvenly;
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    assert!(
        result.is_empty(),
        "a lone idle car at a stop must not be repositioned; got {result:?}"
    );
}

// SpreadEvenly edge case: single stop — no movement needed if already there.
#[test]
fn spread_evenly_single_stop_no_movement() {
    let (mut world, stops) = test_world_n(1);
    let elev = spawn_elevator(&mut world, 0.0); // already at the only stop
    let group = test_group(&stops, vec![elev]);

    let idle = vec![(elev, 0.0)];
    let stop_pos = vec![(stops[0], 0.0)];

    let mut strategy = SpreadEvenly;
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);
    assert!(
        result.is_empty(),
        "no movement when already at the only stop"
    );
}

// SpreadEvenly edge case: all elevators at same position.
#[test]
fn spread_evenly_all_at_same_position() {
    let (mut world, stops) = test_world_n(3);
    let elev_a = spawn_elevator(&mut world, 10.0);
    let elev_b = spawn_elevator(&mut world, 10.0);
    let group = test_group(&stops, vec![elev_a, elev_b]);

    let idle = vec![(elev_a, 10.0), (elev_b, 10.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = SpreadEvenly;
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    // At least one should move away from the shared position.
    assert!(!result.is_empty());
    // They shouldn't both go to the same place.
    if result.len() == 2 {
        assert_ne!(result[0].1, result[1].1);
    }
}

// 2. ReturnToLobby: idle elevators return to home stop (index 0).
#[test]
fn return_to_lobby_sends_to_home() {
    let (mut world, stops) = test_world_n(3);
    let elev = spawn_elevator(&mut world, 20.0); // at stop 2
    let group = test_group(&stops, vec![elev]);

    let idle = vec![(elev, 20.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = ReturnToLobby::new();
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    assert_eq!(result.len(), 1);
    assert_eq!(result[0], (elev, stops[0]));
}

// PredictiveParking: idle car parks near the stop with the highest
// recent arrival rate (Otis Compass Infinity model).
#[test]
fn predictive_parking_targets_highest_arrival_rate_stop() {
    let (mut world, stops) = test_world_n(4);
    let elev = spawn_elevator(&mut world, 0.0);
    let group = test_group(&stops, vec![elev]);

    // Seed the arrival log: stop 2 gets 10 recent arrivals, stop 3 gets
    // 3, others zero. Current tick = 100, default window covers 0..100.
    let mut log = ArrivalLog::default();
    for _ in 0..10 {
        log.record(50, stops[2]);
    }
    for _ in 0..3 {
        log.record(80, stops[3]);
    }
    world.insert_resource(log);
    world.insert_resource(CurrentTick(100));

    let idle = vec![(elev, 0.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = PredictiveParking::new();
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    assert_eq!(result.len(), 1, "expected one parking move");
    assert_eq!(
        result[0],
        (elev, stops[2]),
        "highest recent-arrival stop must be the parking target"
    );
}

/// Regression: a car already en route to the hottest stop must not
/// be "double-booked" by an idle sibling getting assigned to the same
/// stop. Pre-fix, `assign_greedy_by_score` built its `occupied` list
/// from *current* positions of non-idle cars — so a car halfway to
/// the hot stop looked free at the hot stop, and the idle car sailed
/// in behind it.
#[test]
fn predictive_parking_skips_stop_already_targeted_by_moving_car() {
    let (mut world, stops) = test_world_n(4);
    let idle_car = spawn_elevator(&mut world, 0.0);
    // `moving_car` sits halfway between stops[0] and stops[2], already
    // committed to stops[2] via `Repositioning`. Its current position
    // (5.0) is *not* on stops[2] (20.0), which is exactly the gap the
    // old `occupied = [current position]` logic failed to cover.
    let moving_car = spawn_elevator(&mut world, 5.0);
    if let Some(car) = world.elevator_mut(moving_car) {
        car.phase = ElevatorPhase::Repositioning(stops[2]);
        car.target_stop = Some(stops[2]);
        car.repositioning = true;
    }
    let group = test_group(&stops, vec![idle_car, moving_car]);

    // Seed arrivals so stops[2] is hottest (10), stops[3] next (3).
    // With the fix, idle_car should head to stops[3] because stops[2]
    // is already covered by moving_car's target.
    let mut log = ArrivalLog::default();
    for _ in 0..10 {
        log.record(50, stops[2]);
    }
    for _ in 0..3 {
        log.record(80, stops[3]);
    }
    world.insert_resource(log);
    world.insert_resource(CurrentTick(100));

    // Only idle_car is in `idle_elevators` — matches how
    // `systems::reposition` filters input.
    let idle = vec![(idle_car, 0.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = PredictiveParking::new();
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    assert_eq!(
        result.len(),
        1,
        "idle car should still park — just not at the already-targeted stop"
    );
    assert_ne!(
        result[0].1, stops[2],
        "must not double-book stops[2]: moving_car is already en route there"
    );
    assert_eq!(
        result[0],
        (idle_car, stops[3]),
        "idle car should fall through to the next-hottest unoccupied stop"
    );
}

/// Same regression applied to `SpreadEvenly`: a car already heading
/// to an end-of-shaft stop must "occupy" that destination for spread
/// purposes, so the idle car spreads to the *other* extreme.
#[test]
fn spread_evenly_respects_target_of_moving_car() {
    let (mut world, stops) = test_world_n(5);
    let idle_car = spawn_elevator(&mut world, 20.0); // middle
    let moving_car = spawn_elevator(&mut world, 10.0); // mid-low
    if let Some(car) = world.elevator_mut(moving_car) {
        // Heading to stops[4] (the top). Its current position (10.0)
        // is actually at stops[1], but spread should treat stops[4]
        // as occupied.
        car.phase = ElevatorPhase::MovingToStop(stops[4]);
        car.target_stop = Some(stops[4]);
    }
    let group = test_group(&stops, vec![idle_car, moving_car]);

    let idle = vec![(idle_car, 20.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = SpreadEvenly;
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    // With moving_car's *target* (stops[4], pos 40.0) in the occupied
    // list, the farthest-from-occupied stop from idle_car's position
    // (20.0) is stops[0] (pos 0.0, distance 40). Pre-fix the occupied
    // list would have had moving_car's current position (10.0), and
    // stops[4] would have looked like the best spread target for the
    // idle car — directly colliding with moving_car's destination.
    assert_eq!(result.len(), 1, "expected one spread move");
    assert_eq!(
        result[0],
        (idle_car, stops[0]),
        "idle car should spread to the opposite end from the already-targeted stops[4]"
    );
}

#[test]
fn predictive_parking_no_movement_when_log_empty() {
    let (mut world, stops) = test_world_n(4);
    let elev = spawn_elevator(&mut world, 0.0);
    let group = test_group(&stops, vec![elev]);

    world.insert_resource(ArrivalLog::default());
    world.insert_resource(CurrentTick(100));

    let idle = vec![(elev, 0.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = PredictiveParking::new();
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    assert!(
        result.is_empty(),
        "no recent arrivals ⇒ no basis to move; got {result:?}"
    );
}

#[test]
fn predictive_parking_spreads_across_hot_stops() {
    let (mut world, stops) = test_world_n(4);
    let elev_a = spawn_elevator(&mut world, 0.0);
    let elev_b = spawn_elevator(&mut world, 4.0);
    let group = test_group(&stops, vec![elev_a, elev_b]);

    // Two hot stops (different counts so the ordering is determinstic);
    // two cars should end up at each rather than both at the hottest.
    let mut log = ArrivalLog::default();
    for _ in 0..10 {
        log.record(50, stops[2]);
    }
    for _ in 0..5 {
        log.record(50, stops[3]);
    }
    world.insert_resource(log);
    world.insert_resource(CurrentTick(100));

    let idle = vec![(elev_a, 0.0), (elev_b, 4.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = PredictiveParking::new();
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    let targets: std::collections::HashSet<_> = result.iter().map(|(_, s)| *s).collect();
    assert!(
        targets.contains(&stops[2]) && targets.contains(&stops[3]),
        "both hot stops must receive coverage, not both cars the single hottest; got {result:?}"
    );
}

/// Asymmetric topology: 3 idle cars but only 2 hot stops — the third
/// car must stay put rather than park on a cold stop or pile onto a
/// hot one. Catches a regression where `assign_greedy_by_score` kept
/// iterating after running out of hot stops.
#[test]
fn predictive_parking_asymmetric_extra_car_stays_put() {
    let (mut world, stops) = test_world_n(4);
    let elev_a = spawn_elevator(&mut world, 0.0);
    let elev_b = spawn_elevator(&mut world, 4.0);
    let elev_c = spawn_elevator(&mut world, 8.0);
    let group = test_group(&stops, vec![elev_a, elev_b, elev_c]);

    let mut log = ArrivalLog::default();
    for _ in 0..10 {
        log.record(50, stops[2]);
    }
    for _ in 0..5 {
        log.record(50, stops[3]);
    }
    world.insert_resource(log);
    world.insert_resource(CurrentTick(100));

    let idle = vec![(elev_a, 0.0), (elev_b, 4.0), (elev_c, 8.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = PredictiveParking::new();
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    // At most two moves — one per hot stop. No car parks on a cold
    // stop and no car doubles up on an already-claimed hot stop.
    assert!(
        result.len() <= 2,
        "must not generate more moves than hot stops; got {result:?}"
    );
    let targets: std::collections::HashSet<_> = result.iter().map(|(_, s)| *s).collect();
    for &(_, target) in &result {
        assert!(
            target == stops[2] || target == stops[3],
            "target {target:?} is not one of the hot stops"
        );
    }
    assert!(
        targets.len() == result.len(),
        "each hot stop must appear at most once; got {result:?}"
    );
}

// ReturnToLobby with `with_home(2)`.
#[test]
fn return_to_lobby_custom_home() {
    let (mut world, stops) = test_world_n(3);
    let elev = spawn_elevator(&mut world, 0.0); // at stop 0
    let group = test_group(&stops, vec![elev]);

    let idle = vec![(elev, 0.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = ReturnToLobby::with_home(2);
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    assert_eq!(result.len(), 1);
    assert_eq!(result[0], (elev, stops[2]));
}

// ReturnToLobby edge case: already at home.
#[test]
fn return_to_lobby_already_at_home() {
    let (mut world, stops) = test_world_n(3);
    let elev = spawn_elevator(&mut world, 0.0);
    let group = test_group(&stops, vec![elev]);

    let idle = vec![(elev, 0.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = ReturnToLobby::new();
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    assert!(result.is_empty(), "no movement when already at home");
}

// 3. DemandWeighted: elevators move toward high-demand stops.
#[test]
fn demand_weighted_prefers_high_demand() {
    let (mut world, stops) = test_world_n(3);
    // Elevator at stop 0.
    let elev = spawn_elevator(&mut world, 0.0);
    let group = test_group(&stops, vec![elev]);

    // Set up MetricTags with higher throughput at stop 2.
    let mut tags = MetricTags::default();
    tags.tag(stops[0], "stop0");
    tags.tag(stops[1], "stop1");
    tags.tag(stops[2], "stop2");

    // Simulate demand: record many deliveries at stop 2.
    for _ in 0..20 {
        tags.record_delivery(stops[2]);
    }
    // A few at stop 1.
    for _ in 0..3 {
        tags.record_delivery(stops[1]);
    }
    world.insert_resource(tags);

    let idle = vec![(elev, 0.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = DemandWeighted;
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    assert_eq!(result.len(), 1);
    // Should move toward the highest-demand stop (stop 2).
    assert_eq!(result[0], (elev, stops[2]));
}

// 4. NearestIdle: verify no movements generated.
#[test]
fn nearest_idle_returns_empty() {
    let (mut world, stops) = test_world_n(3);
    let elev = spawn_elevator(&mut world, 0.0);
    let group = test_group(&stops, vec![elev]);

    let idle = vec![(elev, 0.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = NearestIdle;
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    assert!(result.is_empty(), "NearestIdle should never generate moves");
}

// ── Mutation-coverage tests for the return-content of each strategy ──
// The three live strategies (SpreadEvenly, ReturnToLobby, DemandWeighted)
// had mutants of the form "replace reposition -> Vec<..> with vec![]" that
// were not killed by existing tests. These tests assert specific targets
// rather than just "is_empty or not".

#[test]
fn spread_evenly_sends_idle_car_to_specific_stop() {
    // 3 stops at 0/10/20, one idle car at position 0, one busy car at 20.
    // SpreadEvenly should send the idle car to the stop furthest from 20 —
    // that's stop 0, but the idle is already at 0, so no movement for idle.
    // Instead: put idle at 5 so it has to move, and test that target is 0.
    let (mut world, stops) = test_world_n(3);
    let idle_elev = spawn_elevator(&mut world, 5.0);
    let busy_elev = spawn_elevator(&mut world, 20.0);
    let group = test_group(&stops, vec![idle_elev, busy_elev]);

    let idle = vec![(idle_elev, 5.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = SpreadEvenly;
    let mut result = Vec::new();
    strategy.reposition(&idle, &stop_pos, &group, &world, &mut result);

    assert_eq!(result.len(), 1, "one assignment expected");
    let (elev, target) = result[0];
    assert_eq!(elev, idle_elev);
    assert_eq!(
        target, stops[0],
        "SpreadEvenly should pick the stop farthest from the busy car at 20 \
         (stop 0 at 0.0), got target index unknown"
    );
}

#[test]
fn spread_evenly_empty_inputs_return_empty() {
    // Kills `replace == with != in SpreadEvenly::reposition` on the
    // empty-guard clauses.
    let (world, stops) = test_world_n(3);
    let group = test_group(&stops, vec![]);
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = SpreadEvenly;
    let mut out = Vec::new();
    strategy.reposition(&[], &stop_pos, &group, &world, &mut out);
    assert!(out.is_empty(), "no idle elevators → empty result");
    out.clear();
    strategy.reposition(&[(EntityId::default(), 0.0)], &[], &group, &world, &mut out);
    assert!(out.is_empty(), "no stop positions → empty result");
}

#[test]
fn return_to_lobby_targets_the_home_stop_specifically() {
    // Kills `replace RepositionStrategy for NearestIdle::reposition -> Vec
    // with vec![]` (wrong strategy name but similar mutants exist on RTL)
    // AND the `home_stop_index` accessor mutations.
    let (mut world, stops) = test_world_n(3);
    let elev = spawn_elevator(&mut world, 15.0);
    let group = test_group(&stops, vec![elev]);

    let idle = vec![(elev, 15.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut r = Vec::new();
    let mut rtl = ReturnToLobby::new();
    rtl.reposition(&idle, &stop_pos, &group, &world, &mut r);
    assert_eq!(r, vec![(elev, stops[0])]);

    r.clear();
    let mut rtl2 = ReturnToLobby::with_home(2);
    rtl2.reposition(&idle, &stop_pos, &group, &world, &mut r);
    assert_eq!(r, vec![(elev, stops[2])]);
}

#[test]
fn return_to_lobby_skips_cars_already_at_home() {
    // Kills `replace > with >= in ReturnToLobby::reposition` on the
    // (pos - home_pos).abs() > 1e-6 threshold.
    let (mut world, stops) = test_world_n(3);
    let at_home = spawn_elevator(&mut world, 0.0);
    let away = spawn_elevator(&mut world, 20.0);
    let group = test_group(&stops, vec![at_home, away]);

    let idle = vec![(at_home, 0.0), (away, 20.0)];
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut rtl = ReturnToLobby::new();
    let mut r = Vec::new();
    rtl.reposition(&idle, &stop_pos, &group, &world, &mut r);
    assert_eq!(
        r,
        vec![(away, stops[0])],
        "only the car not at home should be reassigned"
    );
}

#[test]
fn demand_weighted_empty_inputs_return_empty() {
    // Kills the `|| → &&` mutant on DemandWeighted's empty-guard.
    let (world, stops) = test_world_n(3);
    let group = test_group(&stops, vec![]);
    let stop_pos: Vec<(EntityId, f64)> = stops
        .iter()
        .map(|&sid| (sid, world.stop_position(sid).unwrap()))
        .collect();

    let mut strategy = DemandWeighted;
    let mut out = Vec::new();
    strategy.reposition(&[], &stop_pos, &group, &world, &mut out);
    assert!(out.is_empty());
    out.clear();
    strategy.reposition(&[(EntityId::default(), 0.0)], &[], &group, &world, &mut out);
    assert!(out.is_empty());
}

// ===== Repositioning Integration Tests =====

/// Helper: build a simulation with 1 elevator, 3 stops, `ReturnToLobby` reposition.
/// Elevator starts at stop 2 (position 20.0), should reposition to stop 0 (position 0.0).
fn build_lobby_sim() -> crate::sim::Simulation {
    SimulationBuilder::new()
        .stops(vec![
            StopConfig {
                id: StopId(0),
                name: "Lobby".into(),
                position: 0.0,
            },
            StopConfig {
                id: StopId(1),
                name: "Mid".into(),
                position: 10.0,
            },
            StopConfig {
                id: StopId(2),
                name: "Top".into(),
                position: 20.0,
            },
        ])
        .elevators(vec![ElevatorConfig {
            id: 0,
            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(2),
            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,
        }])
        .dispatch(EtdDispatch::new())
        .reposition(ReturnToLobby::new(), BuiltinReposition::ReturnToLobby)
        .build()
        .unwrap()
}

// 5. Repositioning flag lifecycle.
#[test]
fn repositioning_flag_lifecycle() {
    let mut sim = build_lobby_sim();

    // Drain the initial events.
    sim.drain_events();

    // Step once to trigger repositioning (elevator at stop 2 should head to lobby).
    sim.step();

    let elevators = sim.world().elevator_ids();
    let eid = elevators[0];
    let car = sim.world().elevator(eid).unwrap();
    assert!(
        car.repositioning(),
        "elevator should be repositioning after first tick"
    );
    assert!(
        matches!(car.phase(), ElevatorPhase::Repositioning(_)),
        "elevator should be in Repositioning phase while repositioning"
    );

    // Run enough ticks for the elevator to arrive at lobby (20 units at 2.0 u/s, ~600 ticks).
    for _ in 0..2000 {
        sim.step();
    }

    // After arrival at lobby, it stays at lobby (ReturnToLobby is idempotent there).
    let car = sim.world().elevator(eid).unwrap();
    assert!(
        !car.repositioning(),
        "repositioning flag should be false after arrival at home stop"
    );
    assert_eq!(
        car.phase(),
        ElevatorPhase::Idle,
        "elevator should be Idle after repositioning arrival"
    );
}

// 6. Door skip on repositioning arrival: should emit ElevatorRepositioned, not ElevatorArrived.
#[test]
fn repositioning_arrival_skips_doors() {
    let mut sim = build_lobby_sim();
    sim.drain_events();

    // Run until repositioning completes (20 units at 2.0 u/s).
    for _ in 0..2000 {
        sim.step();
    }
    let events = sim.drain_events();

    // Should have ElevatorRepositioned event.
    let repositioned_count = events
        .iter()
        .filter(|e| matches!(e, Event::ElevatorRepositioned { .. }))
        .count();
    assert!(
        repositioned_count > 0,
        "should emit ElevatorRepositioned on arrival"
    );

    // Should have the initial ElevatorRepositioning event too.
    let repositioning_targets: Vec<(EntityId, EntityId)> = events
        .iter()
        .filter_map(|e| match e {
            Event::ElevatorRepositioning {
                elevator, to_stop, ..
            } => Some((*elevator, *to_stop)),
            _ => None,
        })
        .collect();

    // The repositioned stop should NOT have an ElevatorArrived event (doors skip).
    for (elev, stop) in &repositioning_targets {
        let arrived_at_same = events.iter().any(|e| {
            matches!(
                e,
                Event::ElevatorArrived { elevator, at_stop, .. }
                if *elevator == *elev && *at_stop == *stop
            )
        });
        assert!(
            !arrived_at_same,
            "repositioned elevator should NOT emit ElevatorArrived"
        );
    }

    // After repositioning, elevator should be Idle (not DoorOpening).
    let elevators = sim.world().elevator_ids();
    let car = sim.world().elevator(elevators[0]).unwrap();
    assert_eq!(
        car.phase(),
        ElevatorPhase::Idle,
        "elevator should be Idle after repositioning, not DoorOpening"
    );
}

// 7. Dispatch overrides repositioning: when a repositioned elevator goes Idle and a
//    rider appears, dispatch should assign it.
#[test]
fn dispatch_assigns_after_repositioning() {
    let mut sim = build_lobby_sim();
    sim.drain_events();

    // Let repositioning complete (elevator goes from stop 2 to lobby).
    for _ in 0..2000 {
        sim.step();
    }
    sim.drain_events();

    // Elevator should now be Idle at lobby with repositioning == false.
    let elevators = sim.world().elevator_ids();
    let car = sim.world().elevator(elevators[0]).unwrap();
    assert_eq!(car.phase(), ElevatorPhase::Idle);
    assert!(!car.repositioning());

    // Spawn a rider — dispatch should pick up the now-idle elevator.
    sim.spawn_rider(StopId(0), StopId(2), 70.0).unwrap();
    sim.step();

    let events = sim.drain_events();
    let assigned = events
        .iter()
        .any(|e| matches!(e, Event::ElevatorAssigned { .. }));
    assert!(
        assigned,
        "dispatch should assign an elevator after repositioning completes"
    );
}

// 8. Disabled elevator: should not be repositioned.
#[test]
fn disabled_elevator_not_repositioned() {
    let mut sim = build_lobby_sim();
    sim.drain_events();

    let elevators = sim.world().elevator_ids();
    let eid = elevators[0];
    // Disable the elevator before it can be repositioned.
    sim.disable(eid).unwrap();
    sim.drain_events();

    // Step to trigger reposition phase.
    sim.step();
    let events = sim.drain_events();

    // The disabled elevator should not have ElevatorRepositioning events.
    let repositioning_disabled = events.iter().any(|e| {
        matches!(
            e,
            Event::ElevatorRepositioning { elevator, .. } if *elevator == eid
        )
    });
    assert!(
        !repositioning_disabled,
        "disabled elevator should not be repositioned"
    );
}

// ===== ETD Dispatch Tests =====

// 9. Basic cost: closer elevator wins.
#[test]
fn etd_closer_elevator_wins() {
    let (mut world, stops) = test_world_n(4);
    let elev_a = spawn_elevator(&mut world, 0.0);
    let elev_b = spawn_elevator(&mut world, 30.0);
    let group = test_group(&stops, vec![elev_a, elev_b]);

    let mut manifest = DispatchManifest::default();
    // Demand at stop 1 (pos 10.0).
    add_demand(&mut manifest, &mut world, stops[1], 70.0);

    let mut etd = EtdDispatch::new();
    let elevators = vec![(elev_a, 0.0), (elev_b, 30.0)];
    let decisions = dispatch::assign(&mut etd, &elevators, &group, &manifest, &world).decisions;

    // elev_a is closer to stop 1 (distance 10) vs elev_b (distance 20).
    let a_dec = decisions.iter().find(|(e, _)| *e == elev_a).unwrap();
    assert_eq!(a_dec.1, DispatchDecision::GoToStop(stops[1]));

    let b_dec = decisions.iter().find(|(e, _)| *e == elev_b).unwrap();
    assert_eq!(b_dec.1, DispatchDecision::Idle);
}

// 10. Direction bonus: moving elevator with target ahead gets cost reduction.
#[test]
fn etd_direction_bonus() {
    let (mut world, stops) = test_world_n(4);
    // elev_a at 5.0, already moving up toward stop 3 (pos 30.0).
    let elev_a = spawn_elevator(&mut world, 5.0);
    world.elevator_mut(elev_a).unwrap().phase = ElevatorPhase::MovingToStop(stops[3]);
    world.elevator_mut(elev_a).unwrap().target_stop = Some(stops[3]);

    // elev_b at 5.0, idle.
    let elev_b = spawn_elevator(&mut world, 5.0);
    let group = test_group(&stops, vec![elev_a, elev_b]);

    let mut manifest = DispatchManifest::default();
    // Demand at stop 1 (pos 10.0), which is ahead of both elevators.
    add_demand(&mut manifest, &mut world, stops[1], 70.0);

    let mut etd = EtdDispatch::new();
    let elevators = vec![(elev_a, 5.0), (elev_b, 5.0)];
    let decisions = dispatch::assign(&mut etd, &elevators, &group, &manifest, &world).decisions;

    // elev_a is already moving up and stop 1 is ahead — should get direction bonus
    // (-0.5 * travel_time). elev_b is idle, bonus is -0.3 * travel_time.
    // So elev_a should be preferred.
    let a_dec = decisions.iter().find(|(e, _)| *e == elev_a).unwrap();
    assert_eq!(a_dec.1, DispatchDecision::GoToStop(stops[1]));
}

// 11. Rider delay: elevator with aboard riders heading opposite direction gets higher cost.
#[test]
fn etd_rider_delay_penalizes() {
    let (mut world, stops) = test_world_n(4);
    // elev_a at 20.0 with a rider heading to stop 3 (pos 30.0).
    let elev_a = spawn_elevator(&mut world, 20.0);
    let rider = world.spawn();
    world.set_rider(
        rider,
        Rider {
            phase: RiderPhase::Riding(elev_a),
            weight: Weight::from(70.0),
            current_stop: None,
            spawn_tick: 0,
            board_tick: Some(0),
        },
    );
    world.set_route(rider, Route::direct(stops[2], stops[3], GroupId(0)));
    world.elevator_mut(elev_a).unwrap().riders.push(rider);

    // elev_b at 20.0, no riders.
    let elev_b = spawn_elevator(&mut world, 20.0);
    let group = test_group(&stops, vec![elev_a, elev_b]);

    let mut manifest = DispatchManifest::default();
    // Demand at stop 0 (pos 0.0) — opposite direction from rider's destination.
    add_demand(&mut manifest, &mut world, stops[0], 70.0);

    let mut etd = EtdDispatch::new();
    let elevators = vec![(elev_a, 20.0), (elev_b, 20.0)];
    let decisions = dispatch::assign(&mut etd, &elevators, &group, &manifest, &world).decisions;

    // elev_b (no riders) should win because elev_a would delay its rider.
    let b_dec = decisions.iter().find(|(e, _)| *e == elev_b).unwrap();
    assert_eq!(b_dec.1, DispatchDecision::GoToStop(stops[0]));

    let a_dec = decisions.iter().find(|(e, _)| *e == elev_a).unwrap();
    assert_eq!(a_dec.1, DispatchDecision::Idle);
}

// 12. Door overhead: intervening pending stops raise cost enough to reorder
//     the optimal assignment away from a naive-distance match.
#[test]
fn etd_door_overhead_for_intervening_stops() {
    let (mut world, stops) = test_world_n(4);
    // elev_a at stop 1 (pos 10), elev_b at stop 2 (pos 20).
    // Two demands: stop 0 (pos 0) and stop 3 (pos 30). Symmetric by raw
    // distance — each elevator is 10 from its near demand and 20 from
    // the far demand, so pure nearest-car ties. Intervening overhead
    // breaks the tie: elev_a's 20-unit reach to stop 3 passes elev_b's
    // stop at pos 20 (intervening), while elev_b's 20-unit reach to
    // stop 0 passes elev_a's stop at pos 10 (intervening). With a high
    // door weight the optimal assignment is the "no intervening" one:
    // each car serves the demand on its own side.
    let elev_a = spawn_elevator(&mut world, 10.0);
    let elev_b = spawn_elevator(&mut world, 20.0);
    let group = test_group(&stops, vec![elev_a, elev_b]);

    let mut manifest = DispatchManifest::default();
    add_demand(&mut manifest, &mut world, stops[0], 70.0);
    add_demand(&mut manifest, &mut world, stops[3], 70.0);

    let mut etd = EtdDispatch::with_weights(1.0, 1.0, 10.0);
    let elevators = vec![(elev_a, 10.0), (elev_b, 20.0)];
    let decisions = dispatch::assign(&mut etd, &elevators, &group, &manifest, &world).decisions;

    let a_dec = decisions.iter().find(|(e, _)| *e == elev_a).unwrap();
    assert_eq!(
        a_dec.1,
        DispatchDecision::GoToStop(stops[0]),
        "elev_a should serve stop 0 (no intervening)"
    );
    let b_dec = decisions.iter().find(|(e, _)| *e == elev_b).unwrap();
    assert_eq!(
        b_dec.1,
        DispatchDecision::GoToStop(stops[3]),
        "elev_b should serve stop 3 (no intervening)"
    );
}

// 13. Weight configuration: with_weights(2.0, 0.5, 0.0) prioritizes travel time over delay.
#[test]
fn etd_custom_weights() {
    let (mut world, stops) = test_world_n(4);
    // elev_a at 0.0, elev_b at 20.0 with a rider heading to stop 3.
    let elev_a = spawn_elevator(&mut world, 0.0);
    let elev_b = spawn_elevator(&mut world, 20.0);

    let rider = world.spawn();
    world.set_rider(
        rider,
        Rider {
            phase: RiderPhase::Riding(elev_b),
            weight: Weight::from(70.0),
            current_stop: None,
            spawn_tick: 0,
            board_tick: Some(0),
        },
    );
    world.set_route(rider, Route::direct(stops[2], stops[3], GroupId(0)));
    world.elevator_mut(elev_b).unwrap().riders.push(rider);

    let group = test_group(&stops, vec![elev_a, elev_b]);

    let mut manifest = DispatchManifest::default();
    // Demand at stop 2 (pos 20.0).
    add_demand(&mut manifest, &mut world, stops[2], 70.0);

    // High wait weight (2.0), low delay weight (0.5), zero door weight.
    let mut etd = EtdDispatch::with_weights(2.0, 0.5, 0.0);
    let elevators = vec![(elev_a, 0.0), (elev_b, 20.0)];
    let decisions = dispatch::assign(&mut etd, &elevators, &group, &manifest, &world).decisions;

    // elev_b is at stop 2 (distance 0), elev_a is 20 away.
    // Even with rider delay, elev_b should win because wait_weight is high
    // and elev_b has zero travel time.
    let b_dec = decisions.iter().find(|(e, _)| *e == elev_b).unwrap();
    assert_eq!(b_dec.1, DispatchDecision::GoToStop(stops[2]));
}

// 14. Zero max_speed: returns INFINITY cost (elevator never assigned).
#[test]
fn etd_zero_max_speed_infinite_cost() {
    let (mut world, stops) = test_world_n(3);
    let elev_a = spawn_elevator(&mut world, 0.0);
    world.elevator_mut(elev_a).unwrap().max_speed = Speed::from(0.0);

    // Add a normal elevator to ensure demand is served by it instead.
    let elev_b = spawn_elevator(&mut world, 20.0);
    let group = test_group(&stops, vec![elev_a, elev_b]);

    let mut manifest = DispatchManifest::default();
    add_demand(&mut manifest, &mut world, stops[1], 70.0);

    let mut etd = EtdDispatch::new();
    let elevators = vec![(elev_a, 0.0), (elev_b, 20.0)];
    let decisions = dispatch::assign(&mut etd, &elevators, &group, &manifest, &world).decisions;

    // elev_a with max_speed=0 should NOT be assigned.
    let a_dec = decisions.iter().find(|(e, _)| *e == elev_a).unwrap();
    assert_eq!(a_dec.1, DispatchDecision::Idle);

    // elev_b should get the assignment.
    let b_dec = decisions.iter().find(|(e, _)| *e == elev_b).unwrap();
    assert_eq!(b_dec.1, DispatchDecision::GoToStop(stops[1]));
}

// ── builtin_id round-trip identity (regression for #411-style drift) ──

/// `set_reposition` now consults the strategy's own `builtin_id()` to
/// fill in the snapshot identity, so a caller passing a mismatched
/// `id` argument (or a stale config value) can't silently record the
/// wrong type. The strategy's runtime type always wins for built-ins.
#[test]
fn set_reposition_prefers_strategy_builtin_id_over_arg() {
    use crate::dispatch::BuiltinReposition;
    use crate::dispatch::reposition::SpreadEvenly;
    use crate::ids::GroupId;
    use crate::sim::Simulation;
    use crate::tests::helpers;

    let mut sim = Simulation::new(&helpers::default_config(), helpers::scan()).unwrap();
    // Caller claims ReturnToLobby while actually passing SpreadEvenly —
    // pre-fix this silently recorded ReturnToLobby, so a snapshot
    // round-trip would instantiate the wrong strategy on restore.
    sim.set_reposition(
        GroupId(0),
        Box::new(SpreadEvenly),
        BuiltinReposition::ReturnToLobby,
    );
    assert_eq!(
        sim.reposition_id(GroupId(0)),
        Some(&BuiltinReposition::SpreadEvenly),
        "strategy.builtin_id() must override the caller-supplied id \
         when the strategy identifies as a known built-in",
    );
}
