"""
test_camera_power_manager.py — Unit tests for _camera_power_manager.py (Issue #375).

Covers:
  - Mode sensor configurations
  - Speed-driven upgrade transitions
  - Speed-driven downgrade with hysteresis
  - Scenario overrides (CROSSING, EMERGENCY, PARKED, INDOOR)
  - Battery low cap
  - Idle → SOCIAL transition
  - Safety invariants (rear CSI in ACTIVE/FULL, CROSSING cannot downgrade)
  - Reset
  - ModeDecision fields
"""

from __future__ import annotations

import pytest

from saltybot_bringup._camera_power_manager import (
    ActiveSensors,
    CameraMode,
    CameraPowerFSM,
    MODE_SENSORS,
    ModeDecision,
    Scenario,
    _SPD_ACTIVE_DOWN,
    _SPD_ACTIVE_UP,
    _SPD_FULL_DOWN,
    _SPD_FULL_UP,
    _SPD_MOTION,
)


# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────

class _FakeClock:
    """Injectable monotonic clock for deterministic tests."""
    def __init__(self, t: float = 0.0) -> None:
        self.t = t
    def __call__(self) -> float:
        return self.t
    def advance(self, dt: float) -> None:
        self.t += dt


def _fsm(hold: float = 5.0, idle: float = 30.0, bat_low: float = 20.0,
         clock=None) -> CameraPowerFSM:
    c = clock or _FakeClock()
    return CameraPowerFSM(
        downgrade_hold_s=hold,
        idle_to_social_s=idle,
        battery_low_pct=bat_low,
        clock=c,
    )


# ─────────────────────────────────────────────────────────────────────────────
# Mode sensor configurations
# ─────────────────────────────────────────────────────────────────────────────

class TestModeSensors:

    def test_sleep_no_sensors(self):
        s = MODE_SENSORS[CameraMode.SLEEP]
        assert s.active_count == 0

    def test_social_webcam_only(self):
        s = MODE_SENSORS[CameraMode.SOCIAL]
        assert s.webcam is True
        assert s.csi_front is False
        assert s.realsense is False
        assert s.lidar is False

    def test_aware_front_realsense_lidar(self):
        s = MODE_SENSORS[CameraMode.AWARE]
        assert s.csi_front is True
        assert s.realsense is True
        assert s.lidar is True
        assert s.csi_rear is False
        assert s.csi_left is False
        assert s.csi_right is False
        assert s.uwb is False

    def test_active_front_rear_realsense_lidar_uwb(self):
        s = MODE_SENSORS[CameraMode.ACTIVE]
        assert s.csi_front is True
        assert s.csi_rear is True
        assert s.realsense is True
        assert s.lidar is True
        assert s.uwb is True
        assert s.csi_left is False
        assert s.csi_right is False

    def test_full_all_sensors(self):
        s = MODE_SENSORS[CameraMode.FULL]
        assert s.csi_front is True
        assert s.csi_rear is True
        assert s.csi_left is True
        assert s.csi_right is True
        assert s.realsense is True
        assert s.lidar is True
        assert s.uwb is True
        assert s.webcam is False  # webcam not needed at speed

    def test_mode_sensor_counts_increase(self):
        counts = [MODE_SENSORS[m].active_count for m in CameraMode]
        assert counts == sorted(counts), "Higher modes should have more sensors"

    def test_safety_rear_csi_in_active(self):
        assert MODE_SENSORS[CameraMode.ACTIVE].csi_rear is True

    def test_safety_rear_csi_in_full(self):
        assert MODE_SENSORS[CameraMode.FULL].csi_rear is True


# ─────────────────────────────────────────────────────────────────────────────
# Speed-driven upgrades (instant)
# ─────────────────────────────────────────────────────────────────────────────

class TestSpeedUpgrades:

    def test_no_motion_stays_social(self):
        f = _fsm()
        d = f.update(speed_mps=0.0)
        assert d.mode == CameraMode.SOCIAL

    def test_slow_motion_upgrades_to_aware(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_MOTION + 0.1)
        assert d.mode == CameraMode.AWARE

    def test_5kmh_upgrades_to_active(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_ACTIVE_UP + 0.1)
        assert d.mode == CameraMode.ACTIVE

    def test_15kmh_upgrades_to_full(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_FULL_UP + 0.1)
        assert d.mode == CameraMode.FULL

    def test_upgrades_skip_intermediate_modes(self):
        """At 20 km/h from rest, jumps directly to FULL."""
        f = _fsm()
        d = f.update(speed_mps=20.0 / 3.6)
        assert d.mode == CameraMode.FULL

    def test_upgrade_is_immediate_no_hold(self):
        """Upgrades do NOT require hold time."""
        clock = _FakeClock(0.0)
        f = _fsm(hold=10.0, clock=clock)
        # Still at t=0
        d = f.update(speed_mps=_SPD_FULL_UP + 0.5)
        assert d.mode == CameraMode.FULL

    def test_exactly_at_motion_threshold(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_MOTION)
        assert d.mode == CameraMode.AWARE

    def test_exactly_at_active_threshold(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_ACTIVE_UP)
        assert d.mode == CameraMode.ACTIVE

    def test_exactly_at_full_threshold(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_FULL_UP)
        assert d.mode == CameraMode.FULL

    def test_negative_speed_clamped_to_zero(self):
        f = _fsm()
        d = f.update(speed_mps=-1.0)
        assert d.mode == CameraMode.SOCIAL


# ─────────────────────────────────────────────────────────────────────────────
# Downgrade hysteresis
# ─────────────────────────────────────────────────────────────────────────────

class TestDowngradeHysteresis:

    def _reach_full(self, f: CameraPowerFSM, clock: _FakeClock) -> None:
        f.update(speed_mps=_SPD_FULL_UP + 0.5)
        assert f.mode == CameraMode.FULL

    def test_downgrade_not_immediate(self):
        clock = _FakeClock(0.0)
        f = _fsm(hold=5.0, clock=clock)
        self._reach_full(f, clock)
        # Drop to below FULL threshold but don't advance clock
        d = f.update(speed_mps=0.0)
        assert d.mode == CameraMode.FULL  # still held

    def test_downgrade_after_hold_expires(self):
        clock = _FakeClock(0.0)
        f = _fsm(hold=5.0, clock=clock)
        self._reach_full(f, clock)
        f.update(speed_mps=0.0)   # first low-speed call starts the hold timer
        clock.advance(5.1)
        d = f.update(speed_mps=0.0)  # hold expired — downgrade to AWARE (not SOCIAL;
        # SOCIAL requires an additional idle timer from AWARE, see TestIdleToSocial)
        assert d.mode == CameraMode.AWARE

    def test_downgrade_cancelled_by_speed_spike(self):
        """If speed spikes back up during hold, downgrade is cancelled."""
        clock = _FakeClock(0.0)
        f = _fsm(hold=5.0, clock=clock)
        self._reach_full(f, clock)
        clock.advance(3.0)
        f.update(speed_mps=0.0)    # start downgrade timer
        clock.advance(1.0)
        f.update(speed_mps=_SPD_FULL_UP + 1.0)  # back to full speed
        clock.advance(3.0)         # would have expired hold if not cancelled
        d = f.update(speed_mps=0.0)
        # Hold restarted; only 3s elapsed since the cancellation reset
        assert d.mode == CameraMode.FULL

    def test_full_to_active_hysteresis_band(self):
        """Speed in [_SPD_FULL_DOWN, _SPD_FULL_UP) while in FULL → stays FULL (hold pending)."""
        clock = _FakeClock(0.0)
        f = _fsm(hold=5.0, clock=clock)
        self._reach_full(f, clock)
        # Speed in hysteresis band (between down and up thresholds)
        mid = (_SPD_FULL_DOWN + _SPD_FULL_UP) / 2.0
        d = f.update(speed_mps=mid)
        assert d.mode == CameraMode.FULL  # pending downgrade, not yet applied

    def test_hold_zero_downgrades_immediately(self):
        clock = _FakeClock(0.0)
        f = _fsm(hold=0.0, clock=clock)
        f.update(speed_mps=_SPD_FULL_UP + 0.5)
        # hold=0: downgrade applies on the very first low-speed call.
        # From FULL at speed=0 → AWARE (SOCIAL requires a separate idle timer).
        d = f.update(speed_mps=0.0)
        assert d.mode == CameraMode.AWARE

    def test_downgrade_full_to_active_at_13kmh(self):
        clock = _FakeClock(0.0)
        f = _fsm(hold=0.0, clock=clock)
        f.update(speed_mps=_SPD_FULL_UP + 0.5)   # → FULL
        d = f.update(speed_mps=_SPD_FULL_DOWN - 0.05)  # just below 13 km/h
        assert d.mode == CameraMode.ACTIVE

    def test_downgrade_active_to_aware(self):
        clock = _FakeClock(0.0)
        f = _fsm(hold=0.0, clock=clock)
        f.update(speed_mps=_SPD_ACTIVE_UP + 0.5)  # → ACTIVE
        d = f.update(speed_mps=_SPD_ACTIVE_DOWN - 0.05)
        assert d.mode == CameraMode.AWARE


# ─────────────────────────────────────────────────────────────────────────────
# Scenario overrides
# ─────────────────────────────────────────────────────────────────────────────

class TestScenarioOverrides:

    def test_crossing_forces_full_from_rest(self):
        f = _fsm()
        d = f.update(speed_mps=0.0, scenario=Scenario.CROSSING)
        assert d.mode == CameraMode.FULL
        assert d.scenario_override is True

    def test_crossing_forces_full_from_aware(self):
        f = _fsm()
        f.update(speed_mps=0.5)   # AWARE
        d = f.update(speed_mps=0.5, scenario=Scenario.CROSSING)
        assert d.mode == CameraMode.FULL

    def test_emergency_forces_full(self):
        f = _fsm()
        d = f.update(speed_mps=0.0, scenario=Scenario.EMERGENCY)
        assert d.mode == CameraMode.FULL
        assert d.scenario_override is True

    def test_crossing_bypasses_hysteresis(self):
        """CROSSING forces FULL even if a downgrade is pending."""
        clock = _FakeClock(0.0)
        f = _fsm(hold=5.0, clock=clock)
        f.update(speed_mps=_SPD_FULL_UP + 1.0)   # FULL
        clock.advance(4.0)
        f.update(speed_mps=0.0)    # pending downgrade started
        d = f.update(speed_mps=0.0, scenario=Scenario.CROSSING)
        assert d.mode == CameraMode.FULL

    def test_parked_forces_social(self):
        f = _fsm()
        f.update(speed_mps=_SPD_FULL_UP + 1.0)   # FULL
        d = f.update(speed_mps=0.0, scenario=Scenario.PARKED)
        assert d.mode == CameraMode.SOCIAL
        assert d.scenario_override is True

    def test_indoor_caps_at_aware(self):
        f = _fsm()
        # Speed says ACTIVE but indoor caps to AWARE
        d = f.update(speed_mps=_SPD_ACTIVE_UP + 0.5, scenario=Scenario.INDOOR)
        assert d.mode == CameraMode.AWARE

    def test_indoor_cannot_reach_full(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_FULL_UP + 2.0, scenario=Scenario.INDOOR)
        assert d.mode == CameraMode.AWARE

    def test_outdoor_uses_speed_logic(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_FULL_UP + 0.5, scenario=Scenario.OUTDOOR)
        assert d.mode == CameraMode.FULL

    def test_unknown_scenario_uses_speed_logic(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_ACTIVE_UP + 0.5, scenario=Scenario.UNKNOWN)
        assert d.mode == CameraMode.ACTIVE

    def test_crossing_sets_all_csi_active(self):
        f = _fsm()
        d = f.update(speed_mps=0.0, scenario=Scenario.CROSSING)
        s = d.sensors
        assert s.csi_front and s.csi_rear and s.csi_left and s.csi_right

    def test_scenario_override_false_for_speed_transition(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_ACTIVE_UP + 0.5, scenario=Scenario.OUTDOOR)
        assert d.scenario_override is False


# ─────────────────────────────────────────────────────────────────────────────
# Battery low cap
# ─────────────────────────────────────────────────────────────────────────────

class TestBatteryLow:

    def test_battery_low_caps_at_aware(self):
        f = _fsm(bat_low=20.0)
        d = f.update(speed_mps=_SPD_FULL_UP + 1.0, battery_pct=15.0)
        assert d.mode == CameraMode.AWARE

    def test_battery_low_prevents_active(self):
        f = _fsm(bat_low=20.0)
        d = f.update(speed_mps=_SPD_ACTIVE_UP + 0.5, battery_pct=19.9)
        assert d.mode == CameraMode.AWARE

    def test_battery_full_no_cap(self):
        f = _fsm(bat_low=20.0)
        d = f.update(speed_mps=_SPD_FULL_UP + 1.0, battery_pct=100.0)
        assert d.mode == CameraMode.FULL

    def test_battery_at_threshold_not_capped(self):
        f = _fsm(bat_low=20.0)
        d = f.update(speed_mps=_SPD_FULL_UP + 1.0, battery_pct=20.0)
        # At exactly threshold — not below — so no cap
        assert d.mode == CameraMode.FULL

    def test_battery_low_allows_aware(self):
        f = _fsm(bat_low=20.0)
        d = f.update(speed_mps=_SPD_MOTION + 0.1, battery_pct=10.0)
        assert d.mode == CameraMode.AWARE


# ─────────────────────────────────────────────────────────────────────────────
# Idle → SOCIAL transition
# ─────────────────────────────────────────────────────────────────────────────

class TestIdleToSocial:

    def test_idle_transitions_to_social_after_timeout(self):
        clock = _FakeClock(0.0)
        f = _fsm(idle=10.0, hold=0.0, clock=clock)
        # Bring to AWARE
        f.update(speed_mps=_SPD_MOTION + 0.1)
        # Reduce to near-zero
        clock.advance(5.0)
        f.update(speed_mps=0.05)   # below 0.1, idle timer starts
        clock.advance(10.1)
        d = f.update(speed_mps=0.05)
        assert d.mode == CameraMode.SOCIAL

    def test_motion_resets_idle_timer(self):
        clock = _FakeClock(0.0)
        f = _fsm(idle=10.0, hold=0.0, clock=clock)
        f.update(speed_mps=_SPD_MOTION + 0.1)   # AWARE
        clock.advance(5.0)
        f.update(speed_mps=0.05)   # idle timer starts
        clock.advance(5.0)
        f.update(speed_mps=1.0)    # motion resets timer
        clock.advance(6.0)
        d = f.update(speed_mps=0.05)   # timer restarted, only 6s elapsed
        assert d.mode == CameraMode.AWARE

    def test_not_idle_when_moving(self):
        clock = _FakeClock(0.0)
        f = _fsm(idle=5.0, clock=clock)
        f.update(speed_mps=_SPD_MOTION + 0.1)
        clock.advance(100.0)
        d = f.update(speed_mps=_SPD_MOTION + 0.1)  # still moving
        assert d.mode == CameraMode.AWARE


# ─────────────────────────────────────────────────────────────────────────────
# Reset
# ─────────────────────────────────────────────────────────────────────────────

class TestReset:

    def test_reset_to_sleep(self):
        f = _fsm()
        f.update(speed_mps=_SPD_FULL_UP + 1.0)
        f.reset(CameraMode.SLEEP)
        assert f.mode == CameraMode.SLEEP

    def test_reset_clears_downgrade_timer(self):
        clock = _FakeClock(0.0)
        f = _fsm(hold=5.0, clock=clock)
        f.update(speed_mps=_SPD_FULL_UP + 1.0)
        f.update(speed_mps=0.0)  # pending downgrade started
        f.reset(CameraMode.AWARE)
        # After reset, pending downgrade should be cleared
        clock.advance(10.0)
        d = f.update(speed_mps=0.0)
        # From AWARE at speed 0 → idle timer not yet expired → stays AWARE (not SLEEP)
        # Actually: speed 0 < _SPD_MOTION → desired=SOCIAL, hold=5.0
        # With hold=5.0 and clock just advanced, pending since = now → no downgrade yet
        assert d.mode in (CameraMode.AWARE, CameraMode.SOCIAL)

    def test_reset_to_full(self):
        f = _fsm()
        f.reset(CameraMode.FULL)
        assert f.mode == CameraMode.FULL


# ─────────────────────────────────────────────────────────────────────────────
# ModeDecision fields
# ─────────────────────────────────────────────────────────────────────────────

class TestModeDecisionFields:

    def test_decision_has_mode(self):
        f = _fsm()
        d = f.update(speed_mps=0.0)
        assert isinstance(d.mode, CameraMode)

    def test_decision_has_sensors(self):
        f = _fsm()
        d = f.update(speed_mps=0.0)
        assert isinstance(d.sensors, ActiveSensors)

    def test_decision_sensors_match_mode(self):
        f = _fsm()
        d = f.update(speed_mps=_SPD_FULL_UP + 1.0)
        assert d.sensors == MODE_SENSORS[CameraMode.FULL]

    def test_decision_trigger_speed_recorded(self):
        f = _fsm()
        d = f.update(speed_mps=2.5)
        assert abs(d.trigger_speed_mps - 2.5) < 1e-6

    def test_decision_trigger_scenario_recorded(self):
        f = _fsm()
        d = f.update(speed_mps=0.0, scenario='indoor')
        assert d.trigger_scenario == 'indoor'

    def test_scenario_override_false_for_normal(self):
        f = _fsm()
        d = f.update(speed_mps=1.0)
        assert d.scenario_override is False

    def test_scenario_override_true_for_crossing(self):
        f = _fsm()
        d = f.update(speed_mps=0.0, scenario=Scenario.CROSSING)
        assert d.scenario_override is True


# ─────────────────────────────────────────────────────────────────────────────
# Active sensor counts (RAM budget)
# ─────────────────────────────────────────────────────────────────────────────

class TestActiveSensors:

    def test_active_sensor_count_non_negative(self):
        for m, s in MODE_SENSORS.items():
            assert s.active_count >= 0

    def test_sleep_zero_sensors(self):
        assert MODE_SENSORS[CameraMode.SLEEP].active_count == 0

    def test_full_seven_sensors(self):
        # csi x4 + realsense + lidar + uwb = 7
        assert MODE_SENSORS[CameraMode.FULL].active_count == 7

    def test_active_sensors_correct(self):
        # csi_front + csi_rear + realsense + lidar + uwb = 5
        assert MODE_SENSORS[CameraMode.ACTIVE].active_count == 5

    def test_aware_sensors_correct(self):
        # csi_front + realsense + lidar = 3
        assert MODE_SENSORS[CameraMode.AWARE].active_count == 3

    def test_social_sensors_correct(self):
        # webcam = 1
        assert MODE_SENSORS[CameraMode.SOCIAL].active_count == 1


# ─────────────────────────────────────────────────────────────────────────────
# Mode labels
# ─────────────────────────────────────────────────────────────────────────────

class TestModeLabels:

    def test_all_modes_have_labels(self):
        for m in CameraMode:
            assert isinstance(m.label, str)
            assert len(m.label) > 0

    def test_sleep_label(self):
        assert CameraMode.SLEEP.label == 'SLEEP'

    def test_full_label(self):
        assert CameraMode.FULL.label == 'FULL'


# ─────────────────────────────────────────────────────────────────────────────
# Integration: typical ride scenario
# ─────────────────────────────────────────────────────────────────────────────

class TestIntegrationRideScenario:
    """Simulates a typical follow-me trip: start→walk→jog→sprint→crossing→indoor."""

    def test_full_ride(self):
        clock = _FakeClock(0.0)
        f = _fsm(hold=2.0, idle=5.0, clock=clock)

        # Starting up
        d = f.update(0.0, Scenario.OUTDOOR, 100.0)
        assert d.mode == CameraMode.SOCIAL

        # Walking pace (~3 km/h)
        d = f.update(0.8, Scenario.OUTDOOR, 95.0)
        assert d.mode == CameraMode.AWARE

        # Jogging (~7 km/h)
        d = f.update(2.0, Scenario.OUTDOOR, 90.0)
        assert d.mode == CameraMode.ACTIVE

        # High-speed following (~20 km/h)
        d = f.update(5.6, Scenario.OUTDOOR, 85.0)
        assert d.mode == CameraMode.FULL

        # Street crossing — even at slow speed, stays FULL
        d = f.update(1.0, Scenario.CROSSING, 85.0)
        assert d.mode == CameraMode.FULL
        assert d.scenario_override is True

        # Back to outdoor walk
        clock.advance(3.0)
        d = f.update(1.0, Scenario.OUTDOOR, 80.0)
        assert d.mode == CameraMode.FULL  # hold not expired yet

        clock.advance(2.1)
        d = f.update(0.8, Scenario.OUTDOOR, 80.0)
        assert d.mode == CameraMode.AWARE  # hold expired, down to walk speed

        # Enter supermarket (indoor cap)
        d = f.update(0.8, Scenario.INDOOR, 78.0)
        assert d.mode == CameraMode.AWARE

        # Park and wait
        d = f.update(0.0, Scenario.PARKED, 75.0)
        assert d.mode == CameraMode.SOCIAL

        # Low battery during fast follow
        d = f.update(5.6, Scenario.OUTDOOR, 15.0)
        assert d.mode == CameraMode.AWARE  # battery cap
