"""test_audio_monitoring.py — Integration tests for audio pipeline and monitoring.

Verifies:
  - Audio pipeline node is alive (/saltybot/audio/state, /saltybot/speech/transcribed_text)
  - VAD (voice activity detection) node is alive and publishing
  - TTS service is available
  - Health monitor publishes /saltybot/system_health
  - Battery bridge publishes /saltybot/battery (voltage, SoC)
  - Diagnostics aggregator publishes /diagnostics
  - Event logger is running and subscribed to emergency events
  - Docking node exposes /saltybot/dock and /saltybot/undock services
  - Node watchdog is alive and monitoring

Run with:
  pytest test_audio_monitoring.py -v -s
  (Requires full stack launched)
"""

import time
import unittest

import pytest
import rclpy
from std_msgs.msg import Bool, String, Float32
from diagnostic_msgs.msg import DiagnosticArray

from saltybot_tests.test_helpers import NodeChecker, TopicMonitor


# ── Audio pipeline nodes and topics ───────────────────────────────────────────

AUDIO_NODES = [
    "audio_pipeline_node",  # Microphone capture + ASR pipeline
    "vad_node",             # Voice activity detection
]

AUDIO_TOPICS = [
    "/saltybot/audio/state",             # Pipeline state (IDLE/LISTENING/PROCESSING)
    "/saltybot/audio/status",            # Audio system status
    "/saltybot/speech/transcribed_text", # ASR output text
]

# ── Monitoring nodes and topics ────────────────────────────────────────────────

MONITORING_NODES = [
    "health_monitor_node",  # System health watchdog
    "event_logger_node",    # ROS event logger (emergency/docking/diagnostics)
]

MONITORING_TOPICS = [
    "/saltybot/system_health",  # Overall system health string
    "/diagnostics",             # ROS2 diagnostics aggregator
]

BATTERY_TOPICS = [
    "/saltybot/battery",         # Battery state (voltage + SoC)
]

# ── Docking services ──────────────────────────────────────────────────────────

DOCKING_SERVICES = [
    "/saltybot/dock",    # Trigger autonomous docking
    "/saltybot/undock",  # Undock from charger
]

NODE_TIMEOUT_S = 30.0


class TestAudioPipeline(unittest.TestCase):
    """Verify audio pipeline nodes are alive and publishing."""

    @classmethod
    def setUpClass(cls):
        rclpy.init()
        cls.node = rclpy.create_node("audio_test_probe")
        cls.node_checker = NodeChecker(cls.node)
        cls.topic_monitor = TopicMonitor(cls.node)

    @classmethod
    def tearDownClass(cls):
        cls.topic_monitor.cleanup()
        cls.node.destroy_node()
        rclpy.shutdown()

    def _spin(self, duration_s: float = 0.5) -> None:
        deadline = time.time() + duration_s
        while time.time() < deadline:
            rclpy.spin_once(self.node, timeout_sec=0.1)

    def test_01_audio_pipeline_node_alive(self):
        """Audio pipeline node must be running."""
        results = self.node_checker.wait_for_nodes(["audio_pipeline_node"], timeout_s=NODE_TIMEOUT_S)
        self.assertTrue(
            results.get("audio_pipeline_node", False),
            "Audio pipeline node not running — check microphone hardware and audio deps"
        )

    def test_02_vad_node_alive(self):
        """VAD (voice activity detection) node must be running."""
        results = self.node_checker.wait_for_nodes(["vad_node"], timeout_s=NODE_TIMEOUT_S)
        self.assertTrue(
            results.get("vad_node", False),
            "VAD node not running — Silero VAD model may not be loaded"
        )

    def test_03_audio_topics_advertised(self):
        """Audio pipeline must advertise state and status topics."""
        self._spin(2.0)
        all_topics = {name for name, _ in self.node.get_topic_names_and_types()}
        missing = [t for t in AUDIO_TOPICS if t not in all_topics]
        self.assertEqual(
            missing, [],
            f"Audio topics not advertised: {missing}"
        )

    def test_04_audio_state_publishes(self):
        """Audio pipeline must publish state messages within 5s of startup."""
        self.topic_monitor.subscribe_to_topic("/saltybot/audio/state", String)
        self._spin(5.0)
        count = self.topic_monitor.get_message_count("/saltybot/audio/state")
        self.assertGreater(
            count, 0,
            "Audio pipeline state not publishing — node may be stuck in initialization"
        )

    def test_05_audio_level_topic_available(self):
        """Audio level (/saltybot/audio_level) must be advertised for WebUI meter."""
        self._spin(2.0)
        all_topics = {name for name, _ in self.node.get_topic_names_and_types()}
        self.assertIn(
            "/saltybot/audio_level",
            all_topics,
            "/saltybot/audio_level not advertised — AudioMeter WebUI component will not work"
        )

    def test_06_speech_activity_topic_available(self):
        """Speech activity (/social/speech/is_speaking) must be advertised."""
        self._spin(2.0)
        all_topics = {name for name, _ in self.node.get_topic_names_and_types()}
        self.assertIn(
            "/social/speech/is_speaking",
            all_topics,
            "/social/speech/is_speaking not advertised — VAD bridge may be missing"
        )


class TestSystemHealthMonitor(unittest.TestCase):
    """Verify system health monitor is running and publishing."""

    @classmethod
    def setUpClass(cls):
        rclpy.init()
        cls.node = rclpy.create_node("health_monitor_test_probe")
        cls.node_checker = NodeChecker(cls.node)
        cls.topic_monitor = TopicMonitor(cls.node)

    @classmethod
    def tearDownClass(cls):
        cls.topic_monitor.cleanup()
        cls.node.destroy_node()
        rclpy.shutdown()

    def _spin(self, duration_s: float = 0.5) -> None:
        deadline = time.time() + duration_s
        while time.time() < deadline:
            rclpy.spin_once(self.node, timeout_sec=0.1)

    def test_01_health_monitor_alive(self):
        """Health monitor node must be running."""
        results = self.node_checker.wait_for_nodes(
            ["health_monitor_node"], timeout_s=NODE_TIMEOUT_S
        )
        self.assertTrue(
            results.get("health_monitor_node", False),
            "Health monitor node not running"
        )

    def test_02_system_health_publishes(self):
        """System health topic must publish within 5s."""
        self.topic_monitor.subscribe_to_topic("/saltybot/system_health", String)
        self._spin(5.0)
        count = self.topic_monitor.get_message_count("/saltybot/system_health")
        self.assertGreater(
            count, 0,
            "/saltybot/system_health not publishing — health monitor may be stuck"
        )

    def test_03_health_report_valid_json(self):
        """System health must contain a recognizable status (HEALTHY/DEGRADED/CRITICAL)."""
        received = []

        def on_health(msg):
            received.append(msg.data)

        sub = self.node.create_subscription(String, "/saltybot/system_health", on_health, 5)
        self._spin(5.0)
        self.node.destroy_subscription(sub)

        if not received:
            pytest.skip("No system health messages received (node not publishing yet)")

        last_msg = received[-1]
        valid_states = {"HEALTHY", "DEGRADED", "CRITICAL", "UNKNOWN"}
        has_valid_state = any(state in last_msg for state in valid_states)
        self.assertTrue(
            has_valid_state,
            f"System health message does not contain a valid state: '{last_msg}'"
        )


class TestDiagnosticsAggregator(unittest.TestCase):
    """Verify ROS2 diagnostics aggregator is publishing."""

    @classmethod
    def setUpClass(cls):
        rclpy.init()
        cls.node = rclpy.create_node("diagnostics_test_probe")
        cls.topic_monitor = TopicMonitor(cls.node)

    @classmethod
    def tearDownClass(cls):
        cls.topic_monitor.cleanup()
        cls.node.destroy_node()
        rclpy.shutdown()

    def _spin(self, duration_s: float = 0.5) -> None:
        deadline = time.time() + duration_s
        while time.time() < deadline:
            rclpy.spin_once(self.node, timeout_sec=0.1)

    def test_01_diagnostics_topic_advertised(self):
        """ROS2 /diagnostics topic must be advertised."""
        self._spin(2.0)
        all_topics = {name for name, _ in self.node.get_topic_names_and_types()}
        self.assertIn(
            "/diagnostics",
            all_topics,
            "/diagnostics topic not advertised — diagnostics aggregator not running"
        )

    def test_02_diagnostics_publishes(self):
        """Diagnostics aggregator must publish at least one message within 5s."""
        received = []

        def on_diag(msg):
            received.append(msg)

        sub = self.node.create_subscription(DiagnosticArray, "/diagnostics", on_diag, 10)
        deadline = time.time() + 5.0
        while time.time() < deadline and not received:
            rclpy.spin_once(self.node, timeout_sec=0.1)

        self.node.destroy_subscription(sub)
        self.assertTrue(
            len(received) > 0,
            "/diagnostics not publishing within 5s"
        )

    def test_03_diagnostics_contains_entries(self):
        """Diagnostics must contain at least one status entry."""
        received = []

        def on_diag(msg):
            received.append(msg)

        sub = self.node.create_subscription(DiagnosticArray, "/diagnostics", on_diag, 5)
        self._spin(5.0)
        self.node.destroy_subscription(sub)

        if not received:
            pytest.skip("No diagnostics messages received")

        last_msg = received[-1]
        self.assertGreater(
            len(last_msg.status), 0,
            "Diagnostics message has zero status entries"
        )


class TestBatteryMonitoring(unittest.TestCase):
    """Verify battery monitoring pipeline is active."""

    @classmethod
    def setUpClass(cls):
        rclpy.init()
        cls.node = rclpy.create_node("battery_test_probe")
        cls.topic_monitor = TopicMonitor(cls.node)

    @classmethod
    def tearDownClass(cls):
        cls.topic_monitor.cleanup()
        cls.node.destroy_node()
        rclpy.shutdown()

    def _spin(self, duration_s: float = 0.5) -> None:
        deadline = time.time() + duration_s
        while time.time() < deadline:
            rclpy.spin_once(self.node, timeout_sec=0.1)

    def test_01_battery_topic_advertised(self):
<<<<<<< HEAD
        """Battery topic must be advertised (from ESP32 bridge)."""
=======
        """Battery topic must be advertised (from ESP32-S3 bridge)."""
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)
        self._spin(5.0)
        all_topics = {name for name, _ in self.node.get_topic_names_and_types()}

        battery_topics = [
            t for t in all_topics
            if "battery" in t.lower()
        ]
        self.assertGreater(
            len(battery_topics), 0,
            f"No battery topics advertised. Topics: {sorted(all_topics)}"
        )

    def test_02_battery_data_publishes(self):
        """Battery bridge must publish battery state within 5s of startup."""
        received = []

        def on_battery(msg):
            received.append(msg)

        # Try common battery topic names
        sub = self.node.create_subscription(String, "/saltybot/battery", on_battery, 5)
        deadline = time.time() + 5.0
        while time.time() < deadline and not received:
            rclpy.spin_once(self.node, timeout_sec=0.1)

        self.node.destroy_subscription(sub)

        if not received:
<<<<<<< HEAD
            pytest.skip("Battery data not publishing (ESP32 bridge may be disabled in test mode)")
=======
            pytest.skip("Battery data not publishing (ESP32-S3 bridge may be disabled in test mode)")
>>>>>>> 291dd68 (feat: remove all STM32/Mamba/BlackPill references — ESP32-S3 only)


class TestDockingServices(unittest.TestCase):
    """Verify autonomous docking services are available."""

    @classmethod
    def setUpClass(cls):
        rclpy.init()
        cls.node = rclpy.create_node("docking_test_probe")
        cls.node_checker = NodeChecker(cls.node)

    @classmethod
    def tearDownClass(cls):
        cls.node.destroy_node()
        rclpy.shutdown()

    def _spin(self, duration_s: float = 0.5) -> None:
        deadline = time.time() + duration_s
        while time.time() < deadline:
            rclpy.spin_once(self.node, timeout_sec=0.1)

    def test_01_docking_node_alive(self):
        """Docking node must be running."""
        results = self.node_checker.wait_for_nodes(["docking_node"], timeout_s=NODE_TIMEOUT_S)
        self.assertTrue(
            results.get("docking_node", False),
            "Docking node not running"
        )

    def test_02_docking_services_available(self):
        """Dock and undock services must be available."""
        self._spin(3.0)
        all_services = {name for name, _ in self.node.get_service_names_and_types()}
        missing = [s for s in DOCKING_SERVICES if s not in all_services]
        self.assertEqual(
            missing, [],
            f"Docking services not available: {missing}"
        )

    def test_03_docking_status_topic_advertised(self):
        """Docking status topic must be advertised."""
        self._spin(2.0)
        all_topics = {name for name, _ in self.node.get_topic_names_and_types()}
        docking_topics = [t for t in all_topics if "docking" in t.lower()]
        self.assertGreater(
            len(docking_topics), 0,
            f"No docking status topics advertised. All topics: {sorted(all_topics)}"
        )


class TestEventLogger(unittest.TestCase):
    """Verify event logger node is running and subscribing to events."""

    @classmethod
    def setUpClass(cls):
        rclpy.init()
        cls.node = rclpy.create_node("event_logger_test_probe")
        cls.node_checker = NodeChecker(cls.node)

    @classmethod
    def tearDownClass(cls):
        cls.node.destroy_node()
        rclpy.shutdown()

    def test_01_event_logger_alive(self):
        """Event logger node must be running."""
        results = self.node_checker.wait_for_nodes(
            ["event_logger_node"], timeout_s=NODE_TIMEOUT_S
        )
        self.assertTrue(
            results.get("event_logger_node", False),
            "Event logger node not running"
        )

    def test_02_emergency_event_topic_advertised(self):
        """Emergency event topic must be advertised."""
        deadline = time.time() + 5.0
        while time.time() < deadline:
            rclpy.spin_once(self.node, timeout_sec=0.2)

        all_topics = {name for name, _ in self.node.get_topic_names_and_types()}
        self.assertIn(
            "/saltybot/emergency",
            all_topics,
            "/saltybot/emergency topic not advertised — emergency node may not be running"
        )

    def test_03_docking_status_topic_subscribed(self):
        """Event logger must be subscribed to /saltybot/docking_status."""
        deadline = time.time() + 5.0
        while time.time() < deadline:
            rclpy.spin_once(self.node, timeout_sec=0.2)

        all_topics = {name for name, _ in self.node.get_topic_names_and_types()}
        self.assertIn(
            "/saltybot/docking_status",
            all_topics,
            "/saltybot/docking_status topic not advertised — docking status missing"
        )


class TestNodeWatchdog(unittest.TestCase):
    """Verify node watchdog is monitoring the system."""

    @classmethod
    def setUpClass(cls):
        rclpy.init()
        cls.node = rclpy.create_node("watchdog_test_probe")
        cls.node_checker = NodeChecker(cls.node)
        cls.topic_monitor = TopicMonitor(cls.node)

    @classmethod
    def tearDownClass(cls):
        cls.topic_monitor.cleanup()
        cls.node.destroy_node()
        rclpy.shutdown()

    def _spin(self, duration_s: float = 0.5) -> None:
        deadline = time.time() + duration_s
        while time.time() < deadline:
            rclpy.spin_once(self.node, timeout_sec=0.1)

    def test_01_node_watchdog_alive(self):
        """Node watchdog must be running."""
        results = self.node_checker.wait_for_nodes(
            ["node_watchdog"], timeout_s=NODE_TIMEOUT_S
        )
        self.assertTrue(
            results.get("node_watchdog", False),
            "Node watchdog not running — crashed nodes will not be detected or restarted"
        )

    def test_02_watchdog_status_publishes(self):
        """Node watchdog must publish its monitoring status."""
        self._spin(2.0)
        all_topics = {name for name, _ in self.node.get_topic_names_and_types()}
        watchdog_topics = [t for t in all_topics if "watchdog" in t.lower()]
        self.assertGreater(
            len(watchdog_topics), 0,
            f"No watchdog status topics found. Topics: {sorted(all_topics)}"
        )


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