"""test_sensor_health.py — Unit tests for sensor_health_node (Issue #566).

Runs entirely offline: no ROS2 runtime, no hardware, no MQTT broker required.
"""

import json
import sys
import time
import types
import unittest
from unittest.mock import MagicMock, patch

# ── Stub ROS2 and sensor_msgs so tests run offline ────────────────────────────

def _install_stubs():
    # rclpy
    rclpy = types.ModuleType("rclpy")
    rclpy.init = lambda **_: None
    rclpy.spin = lambda _: None
    rclpy.shutdown = lambda: None

    node_mod = types.ModuleType("rclpy.node")
    class _Node:
        def __init__(self, *a, **kw): pass
        def declare_parameter(self, *a, **kw): pass
        def get_parameter(self, name):
            defaults = {
                "check_hz": 1.0, "mqtt_broker": "localhost",
                "mqtt_port": 1883, "mqtt_topic": "saltybot/health",
                "mqtt_enabled": False,
            }
            m = MagicMock(); m.value = defaults.get(name, False)
            return m
        def get_logger(self): return MagicMock()
        def get_clock(self): return MagicMock()
        def create_subscription(self, *a, **kw): pass
        def create_publisher(self, *a, **kw): return MagicMock()
        def create_timer(self, *a, **kw): pass
        def destroy_node(self): pass
    node_mod.Node = _Node
    rclpy.node = node_mod

    qos_mod = types.ModuleType("rclpy.qos")
    for attr in ("QoSProfile", "HistoryPolicy", "ReliabilityPolicy", "DurabilityPolicy"):
        setattr(qos_mod, attr, MagicMock())
    rclpy.qos = qos_mod

    sys.modules.setdefault("rclpy",      rclpy)
    sys.modules.setdefault("rclpy.node", rclpy.node)
    sys.modules.setdefault("rclpy.qos",  rclpy.qos)

    # diagnostic_msgs
    diag = types.ModuleType("diagnostic_msgs")
    diag_msg = types.ModuleType("diagnostic_msgs.msg")

    class _DiagStatus:
        OK    = 0
        WARN  = 1
        ERROR = 2
        def __init__(self):
            self.level = 0; self.name = ""; self.message = ""
            self.hardware_id = ""; self.values = []

    class _DiagArray:
        def __init__(self):
            self.header = MagicMock(); self.status = []

    class _KeyValue:
        def __init__(self, key="", value=""):
            self.key = key; self.value = value

    diag_msg.DiagnosticStatus = _DiagStatus
    diag_msg.DiagnosticArray  = _DiagArray
    diag_msg.KeyValue         = _KeyValue
    diag.msg = diag_msg
    sys.modules.setdefault("diagnostic_msgs",     diag)
    sys.modules.setdefault("diagnostic_msgs.msg", diag_msg)

    # sensor_msgs
    sens = types.ModuleType("sensor_msgs")
    sens_msg = types.ModuleType("sensor_msgs.msg")
    for cls_name in ("Image", "CameraInfo", "Imu", "LaserScan"):
        setattr(sens_msg, cls_name, MagicMock)
    sens.msg = sens_msg
    sys.modules.setdefault("sensor_msgs",     sens)
    sys.modules.setdefault("sensor_msgs.msg", sens_msg)

    # std_msgs
    std = types.ModuleType("std_msgs")
    std_msg = types.ModuleType("std_msgs.msg")
    class _String:
        def __init__(self, data=""): self.data = data
    std_msg.String = _String
    std.msg = std_msg
    sys.modules.setdefault("std_msgs",     std)
    sys.modules.setdefault("std_msgs.msg", std_msg)


_install_stubs()

from saltybot_health_monitor.sensor_health_node import (  # noqa: E402
    SensorWatcher, OK, WARN, ERROR, _LEVEL_NAMES,
)


# ── SensorWatcher: initial state ──────────────────────────────────────────────

class TestSensorWatcherInitial(unittest.TestCase):

    def setUp(self):
        self.w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=True)

    def test_initial_age_is_inf(self):
        self.assertEqual(self.w.age_s(), float("inf"))

    def test_initial_level_is_error(self):
        # Never received → age=inf ≥ error_s → ERROR
        self.assertEqual(self.w.level(), ERROR)

    def test_initial_count_zero(self):
        self.assertEqual(self.w.msg_count(), 0)

    def test_initial_rate_zero(self):
        self.assertAlmostEqual(self.w.rate_hz(), 0.0)

    def test_name_stored(self):
        self.assertEqual(self.w.name, "lidar")

    def test_topic_stored(self):
        self.assertEqual(self.w.topic, "/scan")

    def test_critical_stored(self):
        self.assertTrue(self.w.critical)


# ── SensorWatcher: after receiving messages ───────────────────────────────────

class TestSensorWatcherAfterMessage(unittest.TestCase):

    def setUp(self):
        self.w = SensorWatcher("/imu/data", "imu", warn_s=0.5, error_s=2.0, critical=True)
        self.w.on_message(None)  # simulate message receipt

    def test_age_is_small_after_message(self):
        self.assertLess(self.w.age_s(), 0.1)

    def test_level_ok_immediately_after(self):
        self.assertEqual(self.w.level(), OK)

    def test_count_increments(self):
        self.w.on_message(None)
        self.assertEqual(self.w.msg_count(), 2)

    def test_multiple_messages(self):
        for _ in range(10):
            self.w.on_message(None)
        self.assertEqual(self.w.msg_count(), 11)


# ── SensorWatcher: staleness thresholds ──────────────────────────────────────

class TestSensorWatcherStaleness(unittest.TestCase):

    def _make_stale(self, seconds_ago: float) -> SensorWatcher:
        """Return a watcher whose last_rx was `seconds_ago` seconds in the past."""
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        w.on_message(None)
        # Backdate the last_rx directly
        with w._lock:
            w._last_rx -= seconds_ago
        return w

    def test_ok_when_fresh(self):
        w = self._make_stale(0.5)
        self.assertEqual(w.level(), OK)

    def test_warn_at_warn_threshold(self):
        w = self._make_stale(1.1)
        self.assertEqual(w.level(), WARN)

    def test_error_at_error_threshold(self):
        w = self._make_stale(3.1)
        self.assertEqual(w.level(), ERROR)

    def test_age_matches_backdated_time(self):
        w = self._make_stale(2.0)
        self.assertAlmostEqual(w.age_s(), 2.0, delta=0.1)

    def test_warn_level_between_thresholds(self):
        w = self._make_stale(2.0)   # 1.0 < 2.0 < 3.0
        self.assertEqual(w.level(), WARN)


# ── SensorWatcher: diagnostic_status output ──────────────────────────────────

class TestSensorWatcherDiagStatus(unittest.TestCase):

    def test_never_received_is_error(self):
        w = SensorWatcher("/camera/color/image_raw", "camera_color",
                          warn_s=1.0, error_s=3.0, critical=True)
        ds = w.diagnostic_status()
        self.assertEqual(ds.level, ERROR)
        self.assertIn("no data", ds.message)

    def test_ok_status_message(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        w.on_message(None)
        ds = w.diagnostic_status()
        self.assertEqual(ds.level, OK)
        self.assertIn("OK", ds.message)

    def test_warn_status_message(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        w.on_message(None)
        with w._lock:
            w._last_rx -= 1.5
        ds = w.diagnostic_status()
        self.assertEqual(ds.level, WARN)
        self.assertIn("WARN", ds.message)

    def test_hardware_id_is_topic(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        w.on_message(None)
        ds = w.diagnostic_status()
        self.assertEqual(ds.hardware_id, "/scan")

    def test_kv_keys_present(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        w.on_message(None)
        ds = w.diagnostic_status()
        kv_keys = {kv.key for kv in ds.values}
        for expected in ("topic", "age_s", "rate_hz", "count", "warn_s", "error_s"):
            self.assertIn(expected, kv_keys)

    def test_age_inf_displayed_as_inf(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        ds = w.diagnostic_status()
        kv = {kv.key: kv.value for kv in ds.values}
        self.assertEqual(kv["age_s"], "inf")


# ── SensorWatcher: summary_dict output ───────────────────────────────────────

class TestSensorWatcherSummaryDict(unittest.TestCase):

    def test_never_received_age_is_none(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        d = w.summary_dict()
        self.assertIsNone(d["age_s"])
        self.assertEqual(d["status"], "ERROR")

    def test_ok_status_string(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        w.on_message(None)
        d = w.summary_dict()
        self.assertEqual(d["status"], "OK")

    def test_warn_status_string(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        w.on_message(None)
        with w._lock:
            w._last_rx -= 1.5
        d = w.summary_dict()
        self.assertEqual(d["status"], "WARN")

    def test_error_status_string(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        w.on_message(None)
        with w._lock:
            w._last_rx -= 5.0
        d = w.summary_dict()
        self.assertEqual(d["status"], "ERROR")

    def test_age_rounded(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        w.on_message(None)
        d = w.summary_dict()
        self.assertIsInstance(d["age_s"], float)

    def test_critical_flag(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=True)
        self.assertTrue(w.summary_dict()["critical"])

    def test_non_critical_flag(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        self.assertFalse(w.summary_dict()["critical"])

    def test_count_in_summary(self):
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        for _ in range(5):
            w.on_message(None)
        self.assertEqual(w.summary_dict()["count"], 5)


# ── Level name mapping ─────────────────────────────────────────────────────────

class TestLevelNames(unittest.TestCase):

    def test_ok_name(self):
        self.assertEqual(_LEVEL_NAMES[OK], "OK")

    def test_warn_name(self):
        self.assertEqual(_LEVEL_NAMES[WARN], "WARN")

    def test_error_name(self):
        self.assertEqual(_LEVEL_NAMES[ERROR], "ERROR")


# ── Thread safety ─────────────────────────────────────────────────────────────

class TestSensorWatcherThreadSafety(unittest.TestCase):

    def test_concurrent_messages(self):
        import threading
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        N = 500
        threads = [threading.Thread(target=w.on_message, args=(None,)) for _ in range(N)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        self.assertEqual(w.msg_count(), N)

    def test_concurrent_reads(self):
        import threading
        w = SensorWatcher("/scan", "lidar", warn_s=1.0, error_s=3.0, critical=False)
        w.on_message(None)
        errors = []
        def read_loop():
            for _ in range(100):
                try:
                    w.level()
                    w.age_s()
                    w.rate_hz()
                except Exception as e:
                    errors.append(e)
        threads = [threading.Thread(target=read_loop) for _ in range(5)]
        for t in threads: t.start()
        for t in threads: t.join()
        self.assertEqual(errors, [])


if __name__ == "__main__":
    unittest.main()
