"""test_system_monitor.py — Unit tests for saltybot_system_monitor (Issue #631).

All tests are ROS2-free: they only import pure-Python modules from
saltybot_system_monitor.jetson_stats.
"""

from __future__ import annotations

import math
import sys
import time
from unittest.mock import patch, MagicMock

import pytest

# ---------------------------------------------------------------------------
# Ensure the package under test is importable (no ROS2 required)
# ---------------------------------------------------------------------------

import os
import importlib

_PKG_DIR = os.path.join(
    os.path.dirname(__file__), "..", "saltybot_system_monitor"
)
sys.path.insert(0, os.path.join(_PKG_DIR, ".."))  # adds src/ to path

from saltybot_system_monitor.jetson_stats import (
    Alert,
    AlertChecker,
    AlertThresholds,
    CpuCore,
    DiskStats,
    JetsonStats,
    JtopReader,
    MockReader,
    TegrastatsParser,
    TegrastatsReader,
    make_reader,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_stats(**kw) -> JetsonStats:
    """Build a minimal JetsonStats with safe defaults, overriding with kw."""
    defaults = dict(
        timestamp=time.monotonic(),
        cpu_cores=[CpuCore(index=0, usage_pct=10.0, freq_mhz=1200)],
        cpu_avg_pct=10.0,
        gpu_pct=5.0,
        gpu_freq_mhz=500,
        ram_used_mb=4096,
        ram_total_mb=16384,
        ram_pct=25.0,
        swap_used_mb=0,
        swap_total_mb=8192,
        temperatures={"CPU": 45.0, "GPU": 42.0},
        temp_max_c=45.0,
        fan_pct=20.0,
        power_total_mw=8000,
        power_cpu_mw=3000,
        power_gpu_mw=2000,
        disk_used_gb=50.0,
        disk_total_gb=500.0,
        disk_pct=10.0,
        source="test",
    )
    defaults.update(kw)
    return JetsonStats(**defaults)


_DEFAULT_THRESH = AlertThresholds()
_CHECKER = AlertChecker()


def _alerts(**kw) -> list[Alert]:
    return _CHECKER.check(_make_stats(**kw), _DEFAULT_THRESH)


def _alert_components(**kw) -> set[str]:
    return {a.component for a in _alerts(**kw)}


# ===========================================================================
# CpuCore
# ===========================================================================

class TestCpuCore:
    def test_basic(self):
        c = CpuCore(index=2, usage_pct=55.5, freq_mhz=1800)
        assert c.index == 2
        assert c.usage_pct == pytest.approx(55.5)
        assert c.freq_mhz == 1800

    def test_default_freq(self):
        c = CpuCore(index=0, usage_pct=0.0)
        assert c.freq_mhz == 0


# ===========================================================================
# JetsonStats
# ===========================================================================

class TestJetsonStats:
    def test_fields_present(self):
        s = _make_stats()
        assert hasattr(s, "cpu_avg_pct")
        assert hasattr(s, "gpu_pct")
        assert hasattr(s, "ram_pct")
        assert hasattr(s, "temp_max_c")
        assert hasattr(s, "fan_pct")
        assert hasattr(s, "power_total_mw")
        assert hasattr(s, "disk_pct")
        assert hasattr(s, "source")

    def test_source_default(self):
        s = _make_stats(source="jtop")
        assert s.source == "jtop"


# ===========================================================================
# AlertThresholds
# ===========================================================================

class TestAlertThresholds:
    def test_defaults(self):
        t = AlertThresholds()
        assert t.cpu_pct   == pytest.approx(85.0)
        assert t.gpu_pct   == pytest.approx(85.0)
        assert t.temp_c    == pytest.approx(80.0)
        assert t.disk_pct  == pytest.approx(90.0)
        assert t.ram_pct   == pytest.approx(90.0)
        assert t.power_mw  == pytest.approx(30_000.0)

    def test_custom(self):
        t = AlertThresholds(cpu_pct=70.0, temp_c=75.0)
        assert t.cpu_pct == pytest.approx(70.0)
        assert t.temp_c  == pytest.approx(75.0)


# ===========================================================================
# AlertChecker — no alerts in normal range
# ===========================================================================

class TestAlertCheckerNoAlerts:
    def test_no_alert_all_ok(self):
        assert _alerts() == []

    def test_cpu_just_below_warn(self):
        # 84.9 < 85.0 — no alert
        assert "cpu" not in _alert_components(cpu_avg_pct=84.9)

    def test_gpu_just_below_warn(self):
        assert "gpu" not in _alert_components(gpu_pct=84.9)

    def test_temp_just_below_warn(self):
        s = _make_stats(temperatures={"CPU": 79.9}, temp_max_c=79.9)
        assert _CHECKER.check(s, _DEFAULT_THRESH) == []

    def test_disk_just_below_warn(self):
        assert "disk" not in _alert_components(disk_pct=89.9)

    def test_ram_just_below_warn(self):
        assert "ram" not in _alert_components(ram_pct=89.9)


# ===========================================================================
# AlertChecker — WARN level
# ===========================================================================

class TestAlertCheckerWarn:
    def test_cpu_warn_at_threshold(self):
        comps = _alert_components(cpu_avg_pct=85.0)
        assert "cpu" in comps
        alerts = _alerts(cpu_avg_pct=85.0)
        cpu_alert = next(a for a in alerts if a.component == "cpu")
        assert cpu_alert.level == Alert.WARN

    def test_gpu_warn(self):
        alerts = _alerts(gpu_pct=90.0)
        gpu_alert = next(a for a in alerts if a.component == "gpu")
        assert gpu_alert.level == Alert.WARN

    def test_temp_warn(self):
        s = _make_stats(temperatures={"AO": 80.0}, temp_max_c=80.0)
        alerts = _CHECKER.check(s, _DEFAULT_THRESH)
        temp_alerts = [a for a in alerts if "temp" in a.component.lower()]
        assert any(a.level == Alert.WARN for a in temp_alerts)

    def test_disk_warn(self):
        alerts = _alerts(disk_pct=90.0)
        disk_alert = next(a for a in alerts if a.component == "disk")
        assert disk_alert.level == Alert.WARN

    def test_ram_warn(self):
        alerts = _alerts(ram_pct=90.0)
        ram_alert = next(a for a in alerts if a.component == "ram")
        assert ram_alert.level == Alert.WARN

    def test_power_warn(self):
        alerts = _alerts(power_total_mw=30_000)
        power_alert = next(a for a in alerts if a.component == "power")
        assert power_alert.level == Alert.WARN

    def test_alert_carries_value_and_threshold(self):
        alerts = _alerts(cpu_avg_pct=87.0)
        a = next(x for x in alerts if x.component == "cpu")
        assert a.value    == pytest.approx(87.0)
        assert a.threshold == pytest.approx(85.0)


# ===========================================================================
# AlertChecker — ERROR level (threshold + escalation)
# ===========================================================================

class TestAlertCheckerError:
    def test_cpu_error_at_95(self):
        # 85 + 10% = 95 → ERROR
        alerts = _alerts(cpu_avg_pct=95.0)
        a = next(x for x in alerts if x.component == "cpu")
        assert a.level == Alert.ERROR

    def test_gpu_error_at_95(self):
        alerts = _alerts(gpu_pct=95.0)
        a = next(x for x in alerts if x.component == "gpu")
        assert a.level == Alert.ERROR

    def test_temp_error_at_90(self):
        # 80 + 10 = 90 → ERROR
        s = _make_stats(temperatures={"CPU": 90.0}, temp_max_c=90.0)
        alerts = _CHECKER.check(s, _DEFAULT_THRESH)
        a = next(x for x in alerts if "temp" in x.component.lower())
        assert a.level == Alert.ERROR

    def test_disk_error_at_95(self):
        # 90 + 5 = 95 → ERROR
        alerts = _alerts(disk_pct=95.0)
        a = next(x for x in alerts if x.component == "disk")
        assert a.level == Alert.ERROR

    def test_ram_error_at_95(self):
        alerts = _alerts(ram_pct=95.0)
        a = next(x for x in alerts if x.component == "ram")
        assert a.level == Alert.ERROR

    def test_multiple_errors(self):
        alerts = _alerts(cpu_avg_pct=99.0, gpu_pct=99.0, disk_pct=99.0)
        comps = {a.component for a in alerts if a.level == Alert.ERROR}
        assert "cpu" in comps
        assert "gpu" in comps
        assert "disk" in comps


# ===========================================================================
# TegrastatsParser
# ===========================================================================

class TestTegrastatsParser:
    P = TegrastatsParser()

    # ── RAM ────────────────────────────────────────────────────────────────

    def test_ram(self):
        s = self.P.parse("RAM 2048/16384MB (lfb 512x4MB) SWAP 256/8192MB")
        assert s.ram_used_mb  == 2048
        assert s.ram_total_mb == 16384
        assert s.ram_pct      == pytest.approx(2048 / 16384 * 100, rel=1e-3)

    def test_ram_zero_total_doesnt_crash(self):
        # Shouldn't happen in practice, but parser must not divide-by-zero
        s = self.P.parse("RAM 0/0MB SWAP 0/0MB CPU [] GPC_FREQ 0%@0")
        assert s.ram_pct == pytest.approx(0.0)

    # ── SWAP ───────────────────────────────────────────────────────────────

    def test_swap(self):
        s = self.P.parse("RAM 1024/8192MB (lfb 4x4MB) SWAP 512/4096MB")
        assert s.swap_used_mb  == 512
        assert s.swap_total_mb == 4096

    # ── CPU ────────────────────────────────────────────────────────────────

    def test_cpu_single_core(self):
        s = self.P.parse("RAM 1000/8000MB SWAP 0/0MB CPU [50%@1200]")
        assert len(s.cpu_cores) == 1
        assert s.cpu_cores[0].usage_pct  == pytest.approx(50.0)
        assert s.cpu_cores[0].freq_mhz   == 1200
        assert s.cpu_avg_pct             == pytest.approx(50.0)

    def test_cpu_four_cores(self):
        s = self.P.parse(
            "RAM 4096/16384MB SWAP 0/8192MB "
            "CPU [10%@1000,20%@1200,30%@1400,40%@1600]"
        )
        assert len(s.cpu_cores) == 4
        assert s.cpu_cores[0].usage_pct == pytest.approx(10.0)
        assert s.cpu_cores[3].usage_pct == pytest.approx(40.0)
        assert s.cpu_avg_pct            == pytest.approx(25.0)

    def test_cpu_idle_core(self):
        # off/0 means core is offline — should be parsed as 0% usage
        s = self.P.parse(
            "RAM 4096/16384MB SWAP 0/8192MB CPU [10%@1200,off,20%@1400,off]"
        )
        # off cores are skipped or treated as 0
        assert len(s.cpu_cores) >= 2

    # ── GPU ────────────────────────────────────────────────────────────────

    def test_gpu(self):
        s = self.P.parse(
            "RAM 4096/16384MB SWAP 0/8192MB CPU [50%@1200] GPC_FREQ 75%@850"
        )
        assert s.gpu_pct      == pytest.approx(75.0)
        assert s.gpu_freq_mhz == 850

    def test_gpu_absent(self):
        s = self.P.parse("RAM 4096/16384MB SWAP 0/8192MB CPU [50%@1200]")
        assert s.gpu_pct      == pytest.approx(0.0)
        assert s.gpu_freq_mhz == 0

    # ── Temperatures ───────────────────────────────────────────────────────

    def test_temperatures(self):
        s = self.P.parse(
            "RAM 4096/16384MB SWAP 0/8192MB CPU [50%@1200] "
            "CPU@45.5C GPU@42.0C AO@38.0C"
        )
        assert "CPU" in s.temperatures
        assert s.temperatures["CPU"] == pytest.approx(45.5)
        assert s.temperatures["GPU"] == pytest.approx(42.0)
        assert s.temp_max_c          == pytest.approx(45.5)

    def test_temp_max_computed(self):
        s = self.P.parse(
            "RAM 4096/16384MB SWAP 0/8192MB CPU [50%@1200] "
            "CPU@55.0C GPU@72.0C"
        )
        assert s.temp_max_c == pytest.approx(72.0)

    # ── Power ──────────────────────────────────────────────────────────────

    def test_power_vdd_in(self):
        s = self.P.parse(
            "RAM 4096/16384MB SWAP 0/8192MB CPU [50%@1200] "
            "VDD_IN 8000mW/9000mW VDD_CPU_CV 3000mW/3500mW VDD_GPU_SOC 2000mW/2500mW"
        )
        assert s.power_total_mw == 8000
        assert s.power_cpu_mw   == 3000
        assert s.power_gpu_mw   == 2000

    def test_power_absent(self):
        s = self.P.parse("RAM 4096/16384MB SWAP 0/8192MB CPU [50%@1200]")
        assert s.power_total_mw == 0

    # ── Full realistic line ────────────────────────────────────────────────

    def test_full_tegrastats_line(self):
        line = (
            "RAM 5012/15625MB (lfb 1x2MB) SWAP 0/7812MB "
            "CPU [12%@1190,8%@1190,15%@1190,9%@1190,5%@1190,7%@1190] "
            "GPC_FREQ 0%@318 CPU@47.218C GPU@44.125C Tdiode@45.25C AO@47.5C "
            "thermal@46.7C VDD_IN 6391mW/6391mW VDD_CPU_CV 2042mW/2042mW "
            "VDD_GPU_SOC 1499mW/1499mW"
        )
        s = self.P.parse(line)
        assert len(s.cpu_cores) == 6
        assert s.gpu_pct      == pytest.approx(0.0)
        assert s.ram_used_mb  == 5012
        assert s.ram_total_mb == 15625
        assert s.temperatures["CPU"] == pytest.approx(47.218)
        assert s.power_total_mw == 6391
        assert s.source == "tegrastats"


# ===========================================================================
# MockReader
# ===========================================================================

class TestMockReader:
    def test_returns_jetson_stats(self):
        r = MockReader()
        s = r.read()
        assert isinstance(s, JetsonStats)

    def test_source_is_mock(self):
        s = MockReader().read()
        assert s.source == "mock"

    def test_deterministic(self):
        r = MockReader()
        s1 = r.read()
        s2 = r.read()
        assert s1.cpu_avg_pct == s2.cpu_avg_pct
        assert s1.gpu_pct     == s2.gpu_pct

    def test_overrides(self):
        r = MockReader(cpu_avg_pct=99.9, gpu_pct=88.8)
        s = r.read()
        assert s.cpu_avg_pct == pytest.approx(99.9)
        assert s.gpu_pct     == pytest.approx(88.8)

    def test_override_temperatures(self):
        r = MockReader(temperatures={"CPU": 75.0}, temp_max_c=75.0)
        s = r.read()
        assert s.temperatures == {"CPU": 75.0}
        assert s.temp_max_c   == pytest.approx(75.0)


# ===========================================================================
# TegrastatsReader availability check
# ===========================================================================

class TestTegrastatsReader:
    def test_is_available_true_when_found(self):
        r = TegrastatsReader()
        with patch("shutil.which", return_value="/usr/bin/tegrastats"):
            assert r.is_available() is True

    def test_is_available_false_when_missing(self):
        r = TegrastatsReader()
        with patch("shutil.which", return_value=None):
            assert r.is_available() is False


# ===========================================================================
# JtopReader availability check
# ===========================================================================

class TestJtopReader:
    def test_is_available_false_without_jtop(self):
        r = JtopReader()
        with patch.dict(sys.modules, {"jtop": None}):
            # If jtop is not importable, should return False
            # (actual behaviour depends on HAS_JTOP at import time)
            result = r.is_available()
            assert isinstance(result, bool)


# ===========================================================================
# make_reader factory
# ===========================================================================

class TestMakeReader:
    def test_mock_returns_mock_reader(self):
        r = make_reader(prefer="mock")
        assert isinstance(r, MockReader)

    def test_auto_with_no_backends_falls_back_to_mock(self):
        with (
            patch("shutil.which", return_value=None),
            patch("saltybot_system_monitor.jetson_stats.HAS_JTOP", False),
        ):
            r = make_reader(prefer="auto")
            assert isinstance(r, MockReader)

    def test_tegrastats_prefer(self):
        with patch("shutil.which", return_value="/usr/bin/tegrastats"):
            r = make_reader(prefer="tegrastats")
            assert isinstance(r, TegrastatsReader)

    def test_auto_prefers_tegrastats_over_mock(self):
        with (
            patch("shutil.which", return_value="/usr/bin/tegrastats"),
            patch("saltybot_system_monitor.jetson_stats.HAS_JTOP", False),
        ):
            r = make_reader(prefer="auto")
            assert isinstance(r, TegrastatsReader)


# ===========================================================================
# Edge cases
# ===========================================================================

class TestEdgeCases:
    P = TegrastatsParser()

    def test_parse_empty_string(self):
        s = self.P.parse("")
        assert isinstance(s, JetsonStats)
        assert s.ram_used_mb  == 0
        assert s.cpu_cores    == []

    def test_parse_partial_line(self):
        s = self.P.parse("RAM 1024/8192MB")
        assert s.ram_used_mb  == 1024
        assert s.cpu_cores    == []

    def test_alert_checker_empty_temps(self):
        s = _make_stats(temperatures={}, temp_max_c=0.0)
        alerts = _CHECKER.check(s, _DEFAULT_THRESH)
        temp_alerts = [a for a in alerts if "temp" in a.component.lower()]
        assert temp_alerts == []

    def test_alert_checker_multiple_temp_sensors(self):
        s = _make_stats(
            temperatures={"CPU": 82.0, "GPU": 50.0, "AO": 40.0},
            temp_max_c=82.0,
        )
        alerts = _CHECKER.check(s, _DEFAULT_THRESH)
        temp_alerts = [a for a in alerts if "temp" in a.component.lower()]
        assert len(temp_alerts) >= 1
        # Only CPU temp should fire
        assert all("CPU" in a.component or a.value >= 80.0 for a in temp_alerts)
