"""Unit tests for vesc_mqtt_relay_node — pure-logic helpers.

Does not require ROS2, paho-mqtt, or a live VESC/broker.
Tests cover the two payload extractors and the rate-limiting logic.
"""

import json
import sys
import time
import types

# ── Stub out ROS2 / paho so the module can be imported without them ───────────

def _make_rclpy_stub():
    rclpy = types.ModuleType("rclpy")
    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):
            class _P:
                value = None
            return _P()
        def create_subscription(self, *a, **kw): pass
        def create_publisher(self, *a, **kw): pass
        def create_timer(self, *a, **kw): pass
        def get_clock(self): return None
        def get_logger(self):
            class _L:
                def info(self, *a, **kw): pass
                def warning(self, *a, **kw): pass
                def error(self, *a, **kw): pass
                def debug(self, *a, **kw): pass
            return _L()
        def destroy_node(self): pass

    node_mod.Node = _Node
    rclpy.node = node_mod
    rclpy.init = lambda *a, **kw: None
    rclpy.spin = lambda *a, **kw: None
    rclpy.try_shutdown = lambda *a, **kw: None

    std_msgs_mod   = types.ModuleType("std_msgs")
    std_msgs_msg   = types.ModuleType("std_msgs.msg")
    class _String:
        data: str = ""
    std_msgs_msg.String = _String
    std_msgs_mod.msg = std_msgs_msg

    paho_mod   = types.ModuleType("paho")
    paho_mqtt  = types.ModuleType("paho.mqtt")
    paho_client = types.ModuleType("paho.mqtt.client")
    class _Client:
        def __init__(self, *a, **kw): pass
        def connect_async(self, *a, **kw): pass
        def loop_start(self): pass
        def loop_stop(self): pass
        def disconnect(self): pass
        def publish(self, *a, **kw): pass
        def reconnect_delay_set(self, *a, **kw): pass
        on_connect    = None
        on_disconnect = None
    paho_client.Client = _Client
    paho_mqtt.client = paho_client
    paho_mod.mqtt = paho_mqtt

    for name, mod in [
        ("rclpy", rclpy),
        ("rclpy.node", node_mod),
        ("std_msgs", std_msgs_mod),
        ("std_msgs.msg", std_msgs_msg),
        ("paho", paho_mod),
        ("paho.mqtt", paho_mqtt),
        ("paho.mqtt.client", paho_client),
    ]:
        sys.modules.setdefault(name, mod)


_make_rclpy_stub()

from saltybot_phone.vesc_mqtt_relay_node import (
    _MQTT_VESC_LEFT,
    _MQTT_VESC_RIGHT,
    _MQTT_VESC_COMBINED,
    _extract_combined_payload,
    _extract_motor_payload,
)


# ---------------------------------------------------------------------------
# _extract_motor_payload
# ---------------------------------------------------------------------------

class TestExtractMotorPayload:
    """Covers field extraction and type coercion for per-motor JSON."""

    def _sample(self, **overrides):
        base = {
            "can_id":       61,
            "rpm":          1500,
            "current_a":    12.34,
            "current_in_a": 10.0,
            "duty_cycle":   0.4500,
            "voltage_v":    25.20,
            "temp_fet_c":   45.5,
            "temp_motor_c": 62.0,
            "fault_code":   0,
            "fault_name":   "NONE",
            "alive":        True,
            "stamp":        1000.0,
        }
        base.update(overrides)
        return base

    def test_required_keys_present(self):
        p = _extract_motor_payload(self._sample())
        for key in ("rpm", "current_a", "voltage_v", "temperature_c",
                    "duty_cycle", "fault_code", "ts"):
            assert key in p, f"missing key: {key}"

    def test_rpm_is_int(self):
        p = _extract_motor_payload(self._sample(rpm=1500))
        assert isinstance(p["rpm"], int)
        assert p["rpm"] == 1500

    def test_temperature_maps_to_temp_fet_c(self):
        p = _extract_motor_payload(self._sample(temp_fet_c=55.5))
        assert p["temperature_c"] == 55.5

    def test_voltage_v(self):
        p = _extract_motor_payload(self._sample(voltage_v=24.8))
        assert p["voltage_v"] == 24.8

    def test_duty_cycle(self):
        p = _extract_motor_payload(self._sample(duty_cycle=0.75))
        assert p["duty_cycle"] == 0.75

    def test_fault_code_zero(self):
        p = _extract_motor_payload(self._sample(fault_code=0))
        assert p["fault_code"] == 0

    def test_fault_code_nonzero(self):
        p = _extract_motor_payload(self._sample(fault_code=3))
        assert p["fault_code"] == 3

    def test_ts_from_stamp(self):
        p = _extract_motor_payload(self._sample(stamp=12345.678))
        assert p["ts"] == 12345.678

    def test_negative_rpm(self):
        p = _extract_motor_payload(self._sample(rpm=-3000))
        assert p["rpm"] == -3000

    def test_negative_current(self):
        p = _extract_motor_payload(self._sample(current_a=-5.0))
        assert p["current_a"] == -5.0

    def test_negative_duty_cycle(self):
        p = _extract_motor_payload(self._sample(duty_cycle=-0.5))
        assert p["duty_cycle"] == -0.5

    def test_missing_required_key_raises(self):
        bad = self._sample()
        del bad["rpm"]
        try:
            _extract_motor_payload(bad)
            assert False, "expected KeyError"
        except KeyError:
            pass

    def test_missing_stamp_uses_current_time(self):
        data = self._sample()
        del data["stamp"]
        before = time.time()
        p = _extract_motor_payload(data)
        after = time.time()
        assert before <= p["ts"] <= after

    def test_json_roundtrip(self):
        p = _extract_motor_payload(self._sample())
        raw = json.dumps(p)
        restored = json.loads(raw)
        assert restored["rpm"] == p["rpm"]
        assert restored["fault_code"] == p["fault_code"]


# ---------------------------------------------------------------------------
# _extract_combined_payload
# ---------------------------------------------------------------------------

class TestExtractCombinedPayload:
    def _sample(self, **overrides):
        base = {
            "voltage_v":       25.2,
            "total_current_a": 18.5,
            "left_rpm":        1400,
            "right_rpm":       1420,
            "left_alive":      True,
            "right_alive":     True,
            "stamp":           2000.0,
        }
        base.update(overrides)
        return base

    def test_required_keys_present(self):
        p = _extract_combined_payload(self._sample())
        for key in ("voltage_v", "total_current_a", "left_rpm", "right_rpm",
                    "left_alive", "right_alive", "ts"):
            assert key in p, f"missing key: {key}"

    def test_voltage_v(self):
        p = _extract_combined_payload(self._sample(voltage_v=24.0))
        assert p["voltage_v"] == 24.0

    def test_total_current_a(self):
        p = _extract_combined_payload(self._sample(total_current_a=30.5))
        assert p["total_current_a"] == 30.5

    def test_rpms_are_int(self):
        p = _extract_combined_payload(self._sample(left_rpm=1000, right_rpm=1050))
        assert isinstance(p["left_rpm"], int)
        assert isinstance(p["right_rpm"], int)

    def test_alive_flags(self):
        p = _extract_combined_payload(self._sample(left_alive=True, right_alive=False))
        assert p["left_alive"] is True
        assert p["right_alive"] is False

    def test_ts_from_stamp(self):
        p = _extract_combined_payload(self._sample(stamp=9999.1))
        assert p["ts"] == 9999.1

    def test_missing_stamp_uses_current_time(self):
        data = self._sample()
        del data["stamp"]
        before = time.time()
        p = _extract_combined_payload(data)
        after = time.time()
        assert before <= p["ts"] <= after

    def test_json_roundtrip(self):
        p = _extract_combined_payload(self._sample())
        raw = json.dumps(p)
        restored = json.loads(raw)
        assert restored["voltage_v"] == p["voltage_v"]


# ---------------------------------------------------------------------------
# Rate-limit logic (isolated, no ROS2 / paho)
# ---------------------------------------------------------------------------

class TestRateLimit:
    """
    Exercise the rate-limiting gate that lives inside VescMqttRelayNode._on_vesc.
    We test the guard condition directly without instantiating the ROS2 node.
    """

    def _make_gate(self, rate_hz: float):
        """
        Return a stateful callable that mirrors the rate-limit check in the node.
        Returns True if a publish should proceed, False if the message is dropped.
        """
        min_interval = 1.0 / rate_hz
        state = {"last": 0.0}

        def gate(now: float) -> bool:
            if now - state["last"] < min_interval:
                return False
            state["last"] = now
            return True

        return gate

    def test_first_message_always_passes(self):
        gate = self._make_gate(5.0)
        assert gate(time.monotonic()) is True

    def test_immediate_second_message_dropped(self):
        gate = self._make_gate(5.0)
        t = time.monotonic()
        gate(t)
        assert gate(t + 0.001) is False  # 1 ms < 200 ms interval

    def test_message_after_interval_passes(self):
        gate = self._make_gate(5.0)
        t = time.monotonic()
        gate(t)
        assert gate(t + 0.201) is True  # 201 ms > 200 ms interval

    def test_exactly_at_interval_dropped(self):
        gate = self._make_gate(5.0)
        t = time.monotonic()
        gate(t)
        # Exactly at the boundary is strictly less-than, so it's dropped
        assert gate(t + 0.2) is False

    def test_10hz_rate(self):
        gate = self._make_gate(10.0)
        t = time.monotonic()
        gate(t)
        assert gate(t + 0.09) is False   # 90 ms < 100 ms
        assert gate(t + 0.101) is True   # 101 ms > 100 ms

    def test_1hz_rate(self):
        gate = self._make_gate(1.0)
        t = time.monotonic()
        gate(t)
        assert gate(t + 0.999) is False
        assert gate(t + 1.001) is True

    def test_multiple_topics_independent(self):
        gate_left  = self._make_gate(5.0)
        gate_right = self._make_gate(5.0)
        t = time.monotonic()
        gate_left(t)
        gate_right(t)
        # left: drop, right: drop
        assert gate_left(t + 0.05) is False
        assert gate_right(t + 0.05) is False
        # advance only left past interval
        assert gate_left(t + 0.21) is True
        assert gate_right(t + 0.21) is True


# ---------------------------------------------------------------------------
# MQTT topic constant checks
# ---------------------------------------------------------------------------

class TestTopicConstants:
    def test_left_topic(self):
        assert _MQTT_VESC_LEFT == "saltybot/phone/vesc_left"

    def test_right_topic(self):
        assert _MQTT_VESC_RIGHT == "saltybot/phone/vesc_right"

    def test_combined_topic(self):
        assert _MQTT_VESC_COMBINED == "saltybot/phone/vesc_combined"


if __name__ == "__main__":
    import pytest
    pytest.main([__file__, "-v"])
