"""test_launch.py — Launch integration test: verify all social-bot nodes start.

Uses launch_testing framework to:
  1. Start social_test.launch.py
  2. Verify all expected nodes appear in the graph within 30s
  3. Verify no node exits with an error code
  4. Verify key topics are advertising (even if no messages yet)

Run with:
  pytest test_launch.py -v
  ros2 launch launch_testing launch_test.py <path>/test_launch.py
"""

import os
import sys
import time
import unittest

import launch
import launch_ros
import launch_testing
import launch_testing.actions
import launch_testing.markers
import pytest
import rclpy
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, TimerAction
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch_ros.actions import Node

import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from saltybot_social_tests.test_helpers import NodeChecker, poll_until


# ── Nodes that must be alive within 30s in default CI mode ───────────────────
REQUIRED_NODES = [
    "orchestrator_node",
    "face_recognition_node",
    "tracking_fusion_node",
    "mock_sensor_pub",
]

# Nodes only required in full mode (SOCIAL_TEST_FULL=1)
FULL_MODE_NODES = [
    "speech_pipeline_node",
    "conversation_node",
    "tts_node",
]

NODE_STARTUP_TIMEOUT_S = 30.0


# ── launch_testing fixture ────────────────────────────────────────────────────

@pytest.mark.launch_test
def generate_test_description():
    """Return the LaunchDescription to use for the launch test."""
    tests_pkg = get_package_share_directory("saltybot_social_tests")
    launch_file = os.path.join(tests_pkg, "launch", "social_test.launch.py")

    return LaunchDescription([
        IncludeLaunchDescription(
            PythonLaunchDescriptionSource(launch_file),
            launch_arguments={
                "enable_speech": "false",
                "enable_llm":    "false",
                "enable_tts":    "false",
            }.items(),
        ),
        # Signal launch_testing that setup is done
        launch_testing.actions.ReadyToTest(),
    ])


# ── Test cases ────────────────────────────────────────────────────────────────

class TestSocialBotLaunch(unittest.TestCase):
    """Integration tests that run against the launched social-bot stack."""

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

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

    def _spin_briefly(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)

    # ── Test 1: All required nodes alive within 30s ───────────────────────────

    def test_required_nodes_start(self):
        """All required nodes must appear in the graph within 30 seconds."""
        results = self.checker.wait_for_nodes(
            REQUIRED_NODES, timeout_s=NODE_STARTUP_TIMEOUT_S
        )
        missing = [n for n, found in results.items() if not found]
        self.assertEqual(
            missing, [],
            f"Nodes did not start within {NODE_STARTUP_TIMEOUT_S}s: {missing}"
        )

    # ── Test 2: Expected topics are advertised ────────────────────────────────

    def test_expected_topics_advertised(self):
        """Key topics must exist in the topic graph."""
        self._spin_briefly(2.0)

        required_topics = [
            "/social/orchestrator/state",
            "/social/faces/detections",
            "/social/tracking/fused_target",
            # mock publishers
            "/camera/color/image_raw",
            "/uwb/target",
        ]

        all_topics = {name for name, _ in self.node.get_topic_names_and_types()}
        missing = [t for t in required_topics if t not in all_topics]
        self.assertEqual(
            missing, [],
            f"Topics not advertised: {missing}\nAll topics: {sorted(all_topics)}"
        )

    # ── Test 3: Orchestrator publishes state ──────────────────────────────────

    def test_orchestrator_state_publishes(self):
        """orchestrator_node must publish /social/orchestrator/state within 5s."""
        from std_msgs.msg import String
        received = []

        sub = self.node.create_subscription(
            String, "/social/orchestrator/state",
            lambda msg: received.append(msg.data), 10
        )
        deadline = time.time() + 5.0
        while time.time() < deadline and not received:
            rclpy.spin_once(self.node, timeout_sec=0.2)

        self.node.destroy_subscription(sub)
        self.assertTrue(
            len(received) > 0,
            "orchestrator_node did not publish /social/orchestrator/state within 5s"
        )
        # Validate JSON structure
        import json
        state = json.loads(received[0])
        self.assertIn("state", state)
        self.assertIn(state["state"],
                      ["idle", "listening", "thinking", "speaking", "throttled"])

    # ── Test 4: Face detection topic advertised at correct QoS ───────────────

    def test_face_detection_topic_qos(self):
        """Face detection topic must exist and accept BEST_EFFORT subscribers."""
        from rclpy.qos import QoSProfile, QoSReliabilityPolicy
        from saltybot_social_msgs.msg import FaceDetectionArray

        received = []
        qos = QoSProfile(reliability=QoSReliabilityPolicy.BEST_EFFORT, depth=10)
        sub = self.node.create_subscription(
            FaceDetectionArray, "/social/faces/detections",
            lambda msg: received.append(True), qos
        )
        deadline = time.time() + 5.0
        while time.time() < deadline and not received:
            rclpy.spin_once(self.node, timeout_sec=0.2)
        self.node.destroy_subscription(sub)
        self.assertTrue(
            len(received) > 0,
            "/social/faces/detections received no messages within 5s"
        )

    # ── Test 5: No node exits with error immediately ──────────────────────────

    def test_no_immediate_node_exits(self):
        """All required nodes should still be alive after 5s (no instant crash)."""
        time.sleep(2.0)
        self._spin_briefly(1.0)
        results = self.checker.wait_for_nodes(REQUIRED_NODES, timeout_s=2.0)
        crashed = [n for n, found in results.items() if not found]
        self.assertEqual(
            crashed, [],
            f"Nodes crashed or exited: {crashed}"
        )


# ── Post-shutdown checks (run after the launch is torn down) ──────────────────

@launch_testing.post_shutdown_test()
class TestSocialBotShutdownBehavior(unittest.TestCase):
    """Tests that run after the launch has been shut down."""

    def test_processes_exited_cleanly(self, proc_info):
        """All launched processes must exit with code 0 or SIGTERM (-15)."""
        # Allow SIGTERM (returncode -15) as a graceful shutdown
        launch_testing.asserts.assertExitCodes(
            proc_info,
            allowable_exit_codes=[0, -15, launch_testing.asserts.EXIT_OK],
        )
