"""test_services.py — Verify social-bot ROS2 services are available and respond.

Services tested:
  /social/enroll           (saltybot_social_msgs/EnrollPerson)
  /social/persons/list     (saltybot_social_msgs/ListPersons)
  /social/persons/delete   (saltybot_social_msgs/DeletePerson)
  /social/persons/update   (saltybot_social_msgs/UpdatePerson)
  /social/query_mood       (saltybot_social_msgs/QueryMood)
  /social/nav/set_mode     (std_srvs/SetString or saltybot_social_msgs/SetNavMode)

Each test:
  1. Verifies the service is available (wait_for_service <= 10s)
  2. Calls the service with a valid request
  3. Asserts response is non-None and contains expected fields
"""

from __future__ import annotations

import os
import sys
import time
import unittest

import rclpy
from rclpy.node import Node

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

SERVICE_AVAILABILITY_TIMEOUT_S = 10.0


class TestSocialServices(unittest.TestCase):
    """Integration tests for social-bot ROS2 service interface."""

    @classmethod
    def setUpClass(cls):
        if not rclpy.ok():
            rclpy.init()
        cls.node = rclpy.create_node("service_test_probe")
        cls.checker = ServiceChecker(cls.node)

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

    # ── Enroll service ─────────────────────────────────────────────────────────

    def test_enroll_service_available(self):
        """/ social/enroll must be available within 10s."""
        try:
            from saltybot_social_msgs.srv import EnrollPerson
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        available = self.checker.is_available(
            "/social/enroll", EnrollPerson,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertTrue(available, "/social/enroll service not available")

    def test_enroll_service_responds(self):
        """EnrollPerson service must respond with success or graceful failure."""
        try:
            from saltybot_social_msgs.srv import EnrollPerson
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        req = EnrollPerson.Request()
        req.name = "TestPerson_CI"
        req.n_samples = 1        # minimal for CI
        req.mode = "face"

        response = self.checker.call_service(
            "/social/enroll", EnrollPerson, req,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertIsNotNone(response, "/social/enroll did not respond")
        # Response can be success=False (no camera data) — that's fine
        # We just need a response, not success
        self.assertTrue(
            hasattr(response, "success"),
            "EnrollPerson response missing 'success' field"
        )

    # ── List persons service ───────────────────────────────────────────────────

    def test_list_persons_service_available(self):
        """/social/persons/list must be available within 10s."""
        try:
            from saltybot_social_msgs.srv import ListPersons
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        available = self.checker.is_available(
            "/social/persons/list", ListPersons,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertTrue(available, "/social/persons/list service not available")

    def test_list_persons_service_responds(self):
        """/social/persons/list must return a list (possibly empty)."""
        try:
            from saltybot_social_msgs.srv import ListPersons
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        req = ListPersons.Request()
        response = self.checker.call_service(
            "/social/persons/list", ListPersons, req,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertIsNotNone(response, "/social/persons/list did not respond")
        self.assertTrue(
            hasattr(response, "persons"),
            "ListPersons response missing 'persons' field"
        )
        self.assertIsInstance(response.persons, list)

    # ── Delete person service ─────────────────────────────────────────────────

    def test_delete_person_service_available(self):
        """/social/persons/delete must be available."""
        try:
            from saltybot_social_msgs.srv import DeletePerson
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        available = self.checker.is_available(
            "/social/persons/delete", DeletePerson,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertTrue(available, "/social/persons/delete not available")

    def test_delete_nonexistent_person_returns_graceful_error(self):
        """Deleting a non-existent person must not crash — graceful False."""
        try:
            from saltybot_social_msgs.srv import DeletePerson
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        req = DeletePerson.Request()
        req.person_id = 99999   # definitely doesn't exist

        response = self.checker.call_service(
            "/social/persons/delete", DeletePerson, req,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertIsNotNone(response)
        # Should return success=False, not crash
        self.assertFalse(
            response.success,
            "Deleting non-existent person should return success=False"
        )

    # ── Update person service ─────────────────────────────────────────────────

    def test_update_person_service_available(self):
        """/social/persons/update must be available."""
        try:
            from saltybot_social_msgs.srv import UpdatePerson
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        available = self.checker.is_available(
            "/social/persons/update", UpdatePerson,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertTrue(available, "/social/persons/update not available")

    # ── Mood query service ────────────────────────────────────────────────────

    def test_query_mood_service_available(self):
        """/social/query_mood must be available (personality_node)."""
        try:
            from saltybot_social_msgs.srv import QueryMood
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        available = self.checker.is_available(
            "/social/query_mood", QueryMood,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertTrue(available, "/social/query_mood not available")

    def test_query_mood_returns_valid_state(self):
        """QueryMood must return a valid mood with valence in [-1, 1]."""
        try:
            from saltybot_social_msgs.srv import QueryMood
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        req = QueryMood.Request()
        response = self.checker.call_service(
            "/social/query_mood", QueryMood, req,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertIsNotNone(response, "/social/query_mood did not respond")
        if hasattr(response, "mood"):
            mood = response.mood
            if hasattr(mood, "valence"):
                self.assertGreaterEqual(mood.valence, -1.0)
                self.assertLessEqual(mood.valence, 1.0)


# ── Nav set-mode service ──────────────────────────────────────────────────────

class TestNavSetModeService(unittest.TestCase):
    """/social/nav/set_mode — change follow mode at runtime."""

    # Valid follow modes understood by social_nav_node
    VALID_MODES = ["shadow", "lead", "side", "orbit", "loose", "tight", "stop"]

    @classmethod
    def setUpClass(cls):
        if not rclpy.ok():
            rclpy.init()
        cls.node = rclpy.create_node("nav_set_mode_test_probe")
        cls.checker = ServiceChecker(cls.node)

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

    def _try_import_set_mode_srv(self):
        """Return the SetNavMode srv type, falling back to std_srvs."""
        try:
            # Custom service (future): saltybot_social_msgs/srv/SetNavMode
            from saltybot_social_msgs.srv import SetNavMode
            return SetNavMode, "custom"
        except ImportError:
            pass
        try:
            # Generic string service fallback (std_srvs doesn't have SetString
            # in Humble; use rcl_interfaces/srv/SetParameters as canary)
            from std_srvs.srv import Trigger
            return Trigger, "trigger"
        except ImportError:
            return None, None

    def test_nav_set_mode_service_available(self):
        """/social/nav/set_mode must be available within 10s."""
        srv_type, variant = self._try_import_set_mode_srv()
        if srv_type is None:
            self.skipTest("No suitable srv type found for /social/nav/set_mode")

        available = self.checker.is_available(
            "/social/nav/set_mode", srv_type,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertTrue(
            available,
            "/social/nav/set_mode service not available within "
            f"{SERVICE_AVAILABILITY_TIMEOUT_S}s"
        )

    def test_nav_set_mode_shadow_responds(self):
        """Setting mode 'shadow' must return a response (not crash)."""
        srv_type, variant = self._try_import_set_mode_srv()
        if srv_type is None:
            self.skipTest("No suitable srv type for /social/nav/set_mode")

        if variant == "custom":
            req = srv_type.Request()
            req.mode = "shadow"
        else:
            req = srv_type.Request()  # Trigger: no payload

        response = self.checker.call_service(
            "/social/nav/set_mode", srv_type, req,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertIsNotNone(
            response, "/social/nav/set_mode did not respond to 'shadow' mode"
        )
        if variant == "custom":
            self.assertTrue(
                hasattr(response, "success"),
                "SetNavMode response missing 'success' field"
            )

    def test_nav_set_mode_invalid_mode_returns_graceful_error(self):
        """Sending an invalid mode string must not crash the node."""
        srv_type, variant = self._try_import_set_mode_srv()
        if srv_type is None or variant != "custom":
            self.skipTest("Custom SetNavMode srv required for invalid-mode test")

        req = srv_type.Request()
        req.mode = "teleport_to_moon"   # definitely invalid

        response = self.checker.call_service(
            "/social/nav/set_mode", srv_type, req,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        self.assertIsNotNone(
            response, "/social/nav/set_mode crashed on invalid mode"
        )
        # Must return success=False for unknown mode, not crash
        self.assertFalse(
            response.success,
            "Invalid mode 'teleport_to_moon' should return success=False"
        )

    def test_nav_set_mode_cycles_all_valid_modes(self):
        """Cycle through all valid modes and verify each returns a response."""
        srv_type, variant = self._try_import_set_mode_srv()
        if srv_type is None or variant != "custom":
            self.skipTest("Custom SetNavMode srv required for mode cycling test")

        for mode in self.VALID_MODES:
            req = srv_type.Request()
            req.mode = mode
            response = self.checker.call_service(
                "/social/nav/set_mode", srv_type, req,
                timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
            )
            self.assertIsNotNone(
                response, f"/social/nav/set_mode did not respond to mode '{mode}'"
            )

    def test_nav_mode_topic_reflects_service_call(self):
        """/social/nav/mode topic must reflect the mode set via the service."""
        from std_msgs.msg import String
        srv_type, variant = self._try_import_set_mode_srv()
        if srv_type is None or variant != "custom":
            self.skipTest("Custom SetNavMode srv required for mode-reflect test")

        # Subscribe to mode topic
        received_modes = []
        sub = self.node.create_subscription(
            String, "/social/nav/mode",
            lambda msg: received_modes.append(msg.data), 10
        )

        # Request LEAD mode
        req = srv_type.Request()
        req.mode = "lead"
        response = self.checker.call_service(
            "/social/nav/set_mode", srv_type, req,
            timeout_s=SERVICE_AVAILABILITY_TIMEOUT_S
        )
        if response is None or not response.success:
            self.node.destroy_subscription(sub)
            self.skipTest("/social/nav/set_mode did not accept 'lead' mode")

        # Wait for mode topic to update
        import rclpy as _rclpy
        deadline = time.time() + 3.0
        while time.time() < deadline and "lead" not in received_modes:
            _rclpy.spin_once(self.node, timeout_sec=0.1)
        self.node.destroy_subscription(sub)

        self.assertIn(
            "lead", received_modes,
            "/social/nav/mode did not reflect 'lead' after service call"
        )


class TestServiceResponseTimes(unittest.TestCase):
    """Verify services respond within acceptable latency."""

    SLA_MS = 500.0   # services must respond within 500ms

    @classmethod
    def setUpClass(cls):
        if not rclpy.ok():
            rclpy.init()
        cls.node = rclpy.create_node("service_latency_probe")
        cls.checker = ServiceChecker(cls.node)

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

    def _measure_service_latency(self, svc_name: str, srv_type, request) -> float:
        """Return response time in ms, or -1 on failure."""
        client = self.node.create_client(srv_type, svc_name)
        if not client.wait_for_service(timeout_sec=10.0):
            self.node.destroy_client(client)
            return -1.0
        t0 = time.perf_counter()
        future = client.call_async(request)
        deadline = time.time() + 5.0
        while not future.done() and time.time() < deadline:
            rclpy.spin_once(self.node, timeout_sec=0.05)
        self.node.destroy_client(client)
        if future.done():
            return (time.perf_counter() - t0) * 1000
        return -1.0

    def test_list_persons_response_time(self):
        """ListPersons must respond within 500ms."""
        try:
            from saltybot_social_msgs.srv import ListPersons
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        latency_ms = self._measure_service_latency(
            "/social/persons/list", ListPersons, ListPersons.Request()
        )
        self.assertGreater(latency_ms, 0,
                           "/social/persons/list did not respond")
        self.assertLess(
            latency_ms, self.SLA_MS,
            f"/social/persons/list response time {latency_ms:.0f}ms > {self.SLA_MS}ms SLA"
        )

    def test_query_mood_response_time(self):
        """QueryMood must respond within 500ms."""
        try:
            from saltybot_social_msgs.srv import QueryMood
        except ImportError:
            self.skipTest("saltybot_social_msgs not installed")

        latency_ms = self._measure_service_latency(
            "/social/query_mood", QueryMood, QueryMood.Request()
        )
        self.assertGreater(latency_ms, 0,
                           "/social/query_mood did not respond")
        self.assertLess(
            latency_ms, self.SLA_MS,
            f"/social/query_mood response time {latency_ms:.0f}ms > {self.SLA_MS}ms SLA"
        )
