"""
test_emergency.py — Unit tests for Issue #169 emergency behavior modules.

Covers:
  ObstacleDetector   — proximity thresholds, speed gate
  FallDetector       — tilt levels, floor drop
  StuckDetector      — timeout accumulation, reset on motion
  BumpDetector       — jerk thresholds, first-call safety
  AlertManager       — severity mapping, escalation, suppression
  RecoverySequencer  — full sequence, retry, give-up
  EmergencyFSM       — all state transitions and guard conditions
"""

import math
import sys
import os

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

import pytest

from saltybot_emergency.threat_detector import (
    BumpDetector,
    FallDetector,
    ObstacleDetector,
    StuckDetector,
    ThreatEvent,
    ThreatLevel,
    ThreatType,
    highest_threat,
)
from saltybot_emergency.alert_manager import Alert, AlertLevel, AlertManager
from saltybot_emergency.recovery_sequencer import (
    RecoveryInputs,
    RecoverySequencer,
    RecoveryState,
)
from saltybot_emergency.emergency_fsm import (
    EmergencyFSM,
    EmergencyInputs,
    EmergencyState,
)


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

def _obs(**kw):
    d = dict(stop_distance_m=0.30, critical_distance_m=0.10, min_speed_ms=0.05)
    d.update(kw)
    return ObstacleDetector(**d)

def _fall(**kw):
    d = dict(minor_tilt_rad=0.20, major_tilt_rad=0.35,
             critical_tilt_rad=0.52, floor_drop_m=0.15)
    d.update(kw)
    return FallDetector(**d)

def _stuck(**kw):
    d = dict(stuck_timeout_s=3.0, min_cmd_ms=0.05, moving_threshold_ms=0.05)
    d.update(kw)
    return StuckDetector(**d)

def _bump(**kw):
    d = dict(jerk_threshold_ms3=8.0, critical_jerk_threshold_ms3=25.0)
    d.update(kw)
    return BumpDetector(**d)

def _alert_mgr(**kw):
    d = dict(major_count_threshold=3, escalation_window_s=10.0, suppression_s=1.0)
    d.update(kw)
    return AlertManager(**d)

def _seq(**kw):
    d = dict(
        reverse_speed_ms=-0.15, reverse_distance_m=0.30,
        angular_speed_rads=0.60, turn_angle_rad=1.5708,
        retry_timeout_s=3.0, clear_hold_s=0.5, max_retries=3,
    )
    d.update(kw)
    return RecoverySequencer(**d)

def _fsm(**kw):
    d = dict(
        stopped_ms=0.03, major_count_threshold=3, escalation_window_s=10.0,
        suppression_s=0.0,  # disable suppression for cleaner tests
        reverse_speed_ms=-0.15, reverse_distance_m=0.30,
        angular_speed_rads=0.60, turn_angle_rad=1.5708,
        retry_timeout_s=3.0, clear_hold_s=0.5, max_retries=3,
    )
    d.update(kw)
    return EmergencyFSM(**d)

def _major_threat(threat_type=ThreatType.OBSTACLE_PROXIMITY, ts=0.0):
    return ThreatEvent(threat_type=threat_type, level=ThreatLevel.MAJOR,
                       value=1.0, detail="test", timestamp_s=ts)

def _critical_threat(ts=0.0):
    return ThreatEvent(threat_type=ThreatType.OBSTACLE_PROXIMITY,
                       level=ThreatLevel.CRITICAL, value=0.05,
                       detail="critical test", timestamp_s=ts)

def _minor_threat(ts=0.0):
    return ThreatEvent(threat_type=ThreatType.FALL_RISK, level=ThreatLevel.MINOR,
                       value=0.21, detail="tilt", timestamp_s=ts)

def _clear_threat():
    return ThreatEvent.clear()

def _inp(threat=None, speed=0.0, ack=False):
    return EmergencyInputs(
        threat=threat or _clear_threat(),
        robot_speed_ms=speed,
        acknowledge=ack,
    )


# ══════════════════════════════════════════════════════════════════════════════
# ObstacleDetector
# ══════════════════════════════════════════════════════════════════════════════

class TestObstacleDetector:

    def test_clear_when_far(self):
        ev = _obs().update(0.5, 0.3)
        assert ev.level == ThreatLevel.CLEAR

    def test_major_within_stop_distance(self):
        ev = _obs(stop_distance_m=0.30).update(0.25, 0.3)
        assert ev.level == ThreatLevel.MAJOR
        assert ev.threat_type == ThreatType.OBSTACLE_PROXIMITY

    def test_critical_within_critical_distance(self):
        ev = _obs(critical_distance_m=0.10).update(0.05, 0.3)
        assert ev.level == ThreatLevel.CRITICAL

    def test_clear_when_stopped(self):
        """Obstacle detection suppressed when not moving."""
        ev = _obs(min_speed_ms=0.05).update(0.05, 0.01)
        assert ev.level == ThreatLevel.CLEAR

    def test_active_at_min_speed(self):
        ev = _obs(min_speed_ms=0.05).update(0.20, 0.06)
        assert ev.level == ThreatLevel.MAJOR

    def test_value_is_distance(self):
        ev = _obs().update(0.20, 0.3)
        assert ev.value == pytest.approx(0.20, abs=1e-9)


# ══════════════════════════════════════════════════════════════════════════════
# FallDetector
# ══════════════════════════════════════════════════════════════════════════════

class TestFallDetector:

    def test_clear_on_flat(self):
        ev = _fall().update(0.0, 0.0)
        assert ev.level == ThreatLevel.CLEAR

    def test_minor_moderate_tilt(self):
        ev = _fall(minor_tilt_rad=0.20, major_tilt_rad=0.35).update(0.25, 0.0)
        assert ev.level == ThreatLevel.MINOR

    def test_major_high_tilt(self):
        ev = _fall(major_tilt_rad=0.35, critical_tilt_rad=0.52).update(0.40, 0.0)
        assert ev.level == ThreatLevel.MAJOR

    def test_critical_extreme_tilt(self):
        ev = _fall(critical_tilt_rad=0.52).update(0.60, 0.0)
        assert ev.level == ThreatLevel.CRITICAL

    def test_major_on_floor_drop(self):
        ev = _fall(floor_drop_m=0.15).update(0.0, 0.0, floor_drop_m=0.20)
        assert ev.level == ThreatLevel.MAJOR
        assert "drop" in ev.detail.lower()

    def test_roll_triggers_same_as_pitch(self):
        """Roll beyond minor threshold also fires."""
        ev = _fall(minor_tilt_rad=0.20).update(0.0, 0.25)
        assert ev.level == ThreatLevel.MINOR


# ══════════════════════════════════════════════════════════════════════════════
# StuckDetector
# ══════════════════════════════════════════════════════════════════════════════

class TestStuckDetector:

    def test_clear_when_not_commanded(self):
        s = _stuck(stuck_timeout_s=1.0, min_cmd_ms=0.05)
        ev = s.update(0.01, 0.0, dt=1.0)  # cmd below threshold
        assert ev.level == ThreatLevel.CLEAR

    def test_clear_when_moving(self):
        s = _stuck(stuck_timeout_s=1.0)
        ev = s.update(0.2, 0.2, dt=1.0)   # actually moving
        assert ev.level == ThreatLevel.CLEAR

    def test_major_after_timeout(self):
        s = _stuck(stuck_timeout_s=3.0, min_cmd_ms=0.05, moving_threshold_ms=0.05)
        for _ in range(6):
            ev = s.update(0.2, 0.0, dt=0.5)   # cmd=0.2, actual=0 → stuck
        assert ev.level == ThreatLevel.MAJOR

    def test_no_major_before_timeout(self):
        s = _stuck(stuck_timeout_s=3.0)
        ev = s.update(0.2, 0.0, dt=1.0)   # only 1s — not yet
        assert ev.level == ThreatLevel.CLEAR

    def test_reset_on_motion_resume(self):
        s = _stuck(stuck_timeout_s=1.0)
        s.update(0.2, 0.0, dt=0.8)        # accumulate stuck time
        s.update(0.2, 0.3, dt=0.1)        # motion resumes → reset
        ev = s.update(0.2, 0.0, dt=0.3)   # only 0.3s since reset → still clear
        assert ev.level == ThreatLevel.CLEAR

    def test_stuck_time_property(self):
        s = _stuck(stuck_timeout_s=5.0)
        s.update(0.2, 0.0, dt=1.0)
        s.update(0.2, 0.0, dt=1.0)
        assert s.stuck_time == pytest.approx(2.0, abs=1e-6)


# ══════════════════════════════════════════════════════════════════════════════
# BumpDetector
# ══════════════════════════════════════════════════════════════════════════════

class TestBumpDetector:

    def test_clear_on_first_call(self):
        """No jerk on first sample (no previous value)."""
        ev = _bump().update(0.0, 0.0, 9.81, dt=0.05)
        assert ev.level == ThreatLevel.CLEAR

    def test_major_on_jerk(self):
        b = _bump(jerk_threshold_ms3=5.0, critical_jerk_threshold_ms3=20.0)
        b.update(0.0, 0.0, 9.81, dt=0.05)    # seed → dyn_mag = 0
        # ax=4.5: raw≈10.79, dyn≈0.98, jerk≈9.8 m/s³ → MAJOR (5.0 < 9.8 < 20.0)
        ev = b.update(4.5, 0.0, 9.81, dt=0.1)
        assert ev.level == ThreatLevel.MAJOR

    def test_critical_on_severe_jerk(self):
        b = _bump(jerk_threshold_ms3=5.0, critical_jerk_threshold_ms3=20.0)
        b.update(0.0, 0.0, 9.81, dt=0.05)
        # Very large spike
        ev = b.update(50.0, 0.0, 9.81, dt=0.1)
        assert ev.level == ThreatLevel.CRITICAL

    def test_clear_on_gentle_acceleration(self):
        b = _bump(jerk_threshold_ms3=8.0)
        b.update(0.0, 0.0, 9.81, dt=0.05)
        ev = b.update(0.1, 0.0, 9.81, dt=0.05)   # tiny change
        assert ev.level == ThreatLevel.CLEAR


# ══════════════════════════════════════════════════════════════════════════════
# highest_threat
# ══════════════════════════════════════════════════════════════════════════════

class TestHighestThreat:

    def test_empty_returns_clear(self):
        assert highest_threat([]).level == ThreatLevel.CLEAR

    def test_picks_highest(self):
        a = ThreatEvent(level=ThreatLevel.MINOR)
        b = ThreatEvent(level=ThreatLevel.CRITICAL)
        c = ThreatEvent(level=ThreatLevel.MAJOR)
        assert highest_threat([a, b, c]).level == ThreatLevel.CRITICAL

    def test_single_item(self):
        ev = ThreatEvent(level=ThreatLevel.MAJOR)
        assert highest_threat([ev]) is ev


# ══════════════════════════════════════════════════════════════════════════════
# AlertManager
# ══════════════════════════════════════════════════════════════════════════════

class TestAlertManager:

    def test_clear_returns_none(self):
        am = _alert_mgr()
        assert am.update(_clear_threat()) is None

    def test_minor_threat_gives_minor_alert(self):
        am = _alert_mgr(suppression_s=0.0)
        alert = am.update(_minor_threat(ts=0.0))
        assert alert is not None
        assert alert.level == AlertLevel.MINOR

    def test_major_threat_gives_major_alert(self):
        am = _alert_mgr(suppression_s=0.0)
        alert = am.update(_major_threat(ts=0.0))
        assert alert is not None
        assert alert.level == AlertLevel.MAJOR

    def test_critical_threat_gives_critical_alert(self):
        am = _alert_mgr(suppression_s=0.0)
        alert = am.update(_critical_threat(ts=0.0))
        assert alert is not None
        assert alert.level == AlertLevel.CRITICAL

    def test_suppression_blocks_duplicate(self):
        am = _alert_mgr(suppression_s=5.0)
        am.update(_major_threat(ts=0.0))
        alert = am.update(_major_threat(ts=1.0))   # within 5s window
        assert alert is None

    def test_suppression_expires(self):
        am = _alert_mgr(suppression_s=2.0)
        am.update(_major_threat(ts=0.0))
        alert = am.update(_major_threat(ts=3.0))   # after 2s window
        assert alert is not None

    def test_escalation_major_to_critical(self):
        """After major_count_threshold major alerts, next one becomes CRITICAL."""
        am = _alert_mgr(major_count_threshold=3, escalation_window_s=60.0,
                        suppression_s=0.0)
        for i in range(3):
            am.update(_major_threat(ts=float(i)))
        # 4th should be escalated
        alert = am.update(_major_threat(ts=4.0))
        assert alert is not None
        assert alert.level == AlertLevel.CRITICAL

    def test_escalation_resets_after_window(self):
        """Major alerts outside the window don't contribute to escalation."""
        am = _alert_mgr(major_count_threshold=3, escalation_window_s=5.0,
                        suppression_s=0.0)
        am.update(_major_threat(ts=0.0))
        am.update(_major_threat(ts=1.0))
        am.update(_major_threat(ts=2.0))
        # All 3 are old; new one at t=10 is outside window
        alert = am.update(_major_threat(ts=10.0))
        assert alert is not None
        assert alert.level == AlertLevel.MAJOR   # not escalated

    def test_reset_clears_escalation_state(self):
        am = _alert_mgr(major_count_threshold=2, suppression_s=0.0)
        am.update(_major_threat(ts=0.0))
        am.update(_major_threat(ts=1.0))  # now at threshold
        am.reset()
        alert = am.update(_major_threat(ts=2.0))
        assert alert.level == AlertLevel.MAJOR  # back to major after reset


# ══════════════════════════════════════════════════════════════════════════════
# RecoverySequencer
# ══════════════════════════════════════════════════════════════════════════════

class TestRecoverySequencer:

    def _trigger(self, seq):
        return seq.tick(RecoveryInputs(trigger=True, dt=0.02))

    def test_idle_on_init(self):
        seq = _seq()
        assert seq.state == RecoveryState.IDLE

    def test_trigger_starts_reversing(self):
        seq = _seq()
        out = self._trigger(seq)
        assert seq.state == RecoveryState.REVERSING

    def test_reversing_backward_velocity(self):
        seq = _seq(reverse_speed_ms=-0.15)
        self._trigger(seq)
        out = seq.tick(RecoveryInputs(dt=0.02))
        assert out.cmd_linear < 0.0

    def test_reversing_completes_to_turning(self):
        seq = _seq(reverse_speed_ms=-1.0, reverse_distance_m=0.5)
        self._trigger(seq)
        for _ in range(30):
            out = seq.tick(RecoveryInputs(dt=0.02))
        assert seq.state == RecoveryState.TURNING

    def test_turning_positive_angular(self):
        seq = _seq(reverse_speed_ms=-1.0, reverse_distance_m=0.1,
                   angular_speed_rads=1.0)
        self._trigger(seq)
        # Skip through reversing quickly
        for _ in range(20):
            seq.tick(RecoveryInputs(dt=0.02))
        if seq.state == RecoveryState.TURNING:
            out = seq.tick(RecoveryInputs(dt=0.02))
            assert out.cmd_angular > 0.0

    def test_retrying_increments_count(self):
        seq = _seq(reverse_speed_ms=-1.0, reverse_distance_m=0.05,
                   angular_speed_rads=10.0, turn_angle_rad=0.1)
        self._trigger(seq)
        for _ in range(100):
            seq.tick(RecoveryInputs(dt=0.02))
        assert seq.state == RecoveryState.RETRYING
        assert seq.retry_count == 1

    def test_threat_cleared_returns_idle(self):
        seq = _seq(reverse_speed_ms=-1.0, reverse_distance_m=0.05,
                   angular_speed_rads=10.0, turn_angle_rad=0.1,
                   clear_hold_s=0.1)
        self._trigger(seq)
        # Fast-forward to RETRYING
        for _ in range(100):
            seq.tick(RecoveryInputs(dt=0.02))
        assert seq.state == RecoveryState.RETRYING
        # Feed cleared ticks until clear_hold met
        for _ in range(20):
            seq.tick(RecoveryInputs(threat_cleared=True, dt=0.02))
        assert seq.state == RecoveryState.IDLE

    def test_max_retries_gives_up(self):
        seq = _seq(reverse_speed_ms=-1.0, reverse_distance_m=0.05,
                   angular_speed_rads=10.0, turn_angle_rad=0.1,
                   retry_timeout_s=0.1, max_retries=2)
        self._trigger(seq)
        for _ in range(500):
            out = seq.tick(RecoveryInputs(threat_cleared=False, dt=0.05))
            if seq.state == RecoveryState.GAVE_UP:
                break
        assert seq.state == RecoveryState.GAVE_UP

    def test_reset_returns_to_idle(self):
        seq = _seq()
        self._trigger(seq)
        seq.reset()
        assert seq.state == RecoveryState.IDLE
        assert seq.retry_count == 0


# ══════════════════════════════════════════════════════════════════════════════
# EmergencyFSM
# ══════════════════════════════════════════════════════════════════════════════

class TestEmergencyFSMBasic:

    def test_initial_state_nominal(self):
        fsm = _fsm()
        assert fsm.state == EmergencyState.NOMINAL

    def test_nominal_stays_on_clear(self):
        fsm = _fsm()
        out = fsm.tick(_inp())
        assert fsm.state == EmergencyState.NOMINAL
        assert out.cmd_override is False

    def test_minor_alert_no_override(self):
        fsm = _fsm()
        out = fsm.tick(_inp(_minor_threat(ts=0.0)))
        assert fsm.state == EmergencyState.NOMINAL
        assert out.cmd_override is False
        assert out.alert is not None
        assert out.alert.level == AlertLevel.MINOR

    def test_major_threat_enters_stopping(self):
        fsm = _fsm()
        out = fsm.tick(_inp(_major_threat()))
        assert fsm.state == EmergencyState.STOPPING
        assert out.cmd_override is True

    def test_critical_threat_enters_stopping_critical_pending(self):
        fsm = _fsm()
        fsm.tick(_inp(_critical_threat()))
        assert fsm.state == EmergencyState.STOPPING
        assert fsm._critical_pending is True


class TestEmergencyFSMStopping:

    def test_stopping_commands_zero(self):
        fsm = _fsm()
        fsm.tick(_inp(_major_threat()))
        out = fsm.tick(_inp(_major_threat(), speed=0.5))
        assert out.cmd_linear  == pytest.approx(0.0, abs=1e-9)
        assert out.cmd_angular == pytest.approx(0.0, abs=1e-9)

    def test_stopped_enters_recovering(self):
        fsm = _fsm(stopped_ms=0.03)
        fsm.tick(_inp(_major_threat()))
        out = fsm.tick(_inp(_major_threat(), speed=0.01))  # below stopped_ms
        assert fsm.state == EmergencyState.RECOVERING

    def test_critical_pending_enters_escalated(self):
        fsm = _fsm(stopped_ms=0.03)
        fsm.tick(_inp(_critical_threat()))
        fsm.tick(_inp(_critical_threat(), speed=0.01))  # stopped → ESCALATED
        assert fsm.state == EmergencyState.ESCALATED


class TestEmergencyFSMRecovering:

    def _reach_recovering(self, fsm):
        fsm.tick(_inp(_major_threat()))
        fsm.tick(_inp(_major_threat(), speed=0.0))  # stopped → RECOVERING
        assert fsm.state == EmergencyState.RECOVERING

    def test_recovering_has_cmd_override(self):
        fsm = _fsm()
        self._reach_recovering(fsm)
        out = fsm.tick(_inp(_clear_threat()))
        assert out.cmd_override is True

    def test_recovering_gave_up_escalates(self):
        fsm = _fsm(max_retries=1, retry_timeout_s=0.05)
        self._reach_recovering(fsm)
        # Drive recovery to GAVE_UP by feeding many non-clearing ticks
        for _ in range(500):
            out = fsm.tick(_inp(_major_threat()))
            if fsm.state == EmergencyState.ESCALATED:
                break
        assert fsm.state == EmergencyState.ESCALATED


class TestEmergencyFSMEscalated:

    def _reach_escalated(self, fsm):
        fsm.tick(_inp(_critical_threat()))
        fsm.tick(_inp(_critical_threat(), speed=0.0))
        assert fsm.state == EmergencyState.ESCALATED

    def test_escalated_emits_critical_alert_once(self):
        fsm = _fsm()
        self._reach_escalated(fsm)
        out1 = fsm.tick(_inp())
        out2 = fsm.tick(_inp())
        assert out1.alert is not None
        assert out1.alert.level == AlertLevel.CRITICAL
        assert out2.alert is None   # suppressed after first emission

    def test_escalated_e_stop_asserted(self):
        fsm = _fsm()
        self._reach_escalated(fsm)
        out = fsm.tick(_inp())
        assert out.e_stop is True

    def test_escalated_stays_without_ack(self):
        fsm = _fsm()
        self._reach_escalated(fsm)
        for _ in range(5):
            fsm.tick(_inp())
        assert fsm.state == EmergencyState.ESCALATED

    def test_acknowledge_returns_to_nominal(self):
        fsm = _fsm()
        self._reach_escalated(fsm)
        fsm.tick(_inp(ack=True))
        assert fsm.state == EmergencyState.NOMINAL

    def test_reset_returns_to_nominal(self):
        fsm = _fsm()
        self._reach_escalated(fsm)
        fsm.reset()
        assert fsm.state == EmergencyState.NOMINAL

    def test_e_stop_cleared_on_ack(self):
        fsm = _fsm()
        self._reach_escalated(fsm)
        out = fsm.tick(_inp(ack=True))
        assert out.e_stop is False
