"""Smoke tests — verify imports, paths, basic instantiation, and isolation.

Run with:
    PYTHONPATH=/path/to/yslootahtech python3 -m unittest \
        Project.Sanad.tests.test_smoke -v

These tests do NOT require any third-party dependency. They prove that
the project loads cleanly and that subsystem failures stay isolated.
"""

from __future__ import annotations

import os
import sys
import unittest
from pathlib import Path


class TestConfig(unittest.TestCase):
    """Path resolution and config loading."""

    def test_base_dir_auto_detect(self):
        from Project.Sanad.config import BASE_DIR
        self.assertTrue(BASE_DIR.exists(), f"BASE_DIR missing: {BASE_DIR}")
        self.assertEqual(BASE_DIR.name, "Sanad")

    def test_data_dirs_exist(self):
        from Project.Sanad.config import (
            DATA_DIR, MOTIONS_DIR, AUDIO_RECORDINGS_DIR,
            MOTION_RECORDINGS_DIR, LOGS_DIR, SCRIPTS_DIR,
        )
        for d in (DATA_DIR, MOTIONS_DIR, AUDIO_RECORDINGS_DIR,
                  MOTION_RECORDINGS_DIR, LOGS_DIR, SCRIPTS_DIR):
            self.assertTrue(d.exists(), f"Missing: {d}")

    def test_skills_file_resolves(self):
        from Project.Sanad.config import SKILLS_FILE, MOTIONS_DIR
        self.assertEqual(SKILLS_FILE.parent, MOTIONS_DIR)

    def test_dds_interface_default(self):
        from Project.Sanad.config import DDS_NETWORK_INTERFACE
        self.assertIsInstance(DDS_NETWORK_INTERFACE, str)

    def test_env_override_dds_interface(self):
        os.environ["SANAD_DDS_INTERFACE"] = "test_iface"
        try:
            import importlib
            import Project.Sanad.config as cfg
            importlib.reload(cfg)
            self.assertEqual(cfg.DDS_NETWORK_INTERFACE, "test_iface")
        finally:
            del os.environ["SANAD_DDS_INTERFACE"]
            import importlib
            import Project.Sanad.config as cfg
            importlib.reload(cfg)

    def test_load_config_handles_missing(self):
        from Project.Sanad.config import load_config
        result = load_config()
        self.assertIsInstance(result, dict)

    def test_local_tts_paths(self):
        from Project.Sanad.config import (
            LOCAL_TTS_MODEL_PATH, LOCAL_TTS_HIFIGAN_PATH, LOCAL_TTS_XVECTOR_PATH,
        )
        self.assertIn("speecht5_tts_clartts_ar", LOCAL_TTS_MODEL_PATH)
        self.assertIn("speecht5_hifigan", LOCAL_TTS_HIFIGAN_PATH)


class TestSkillRegistry(unittest.TestCase):
    """SkillRegistry CRUD + atomic writes + validation."""

    def setUp(self):
        from Project.Sanad.core.skill_registry import SkillRegistry
        self.sr = SkillRegistry()

    def test_load(self):
        skills = self.sr.list_skills()
        self.assertIsInstance(skills, list)

    def test_invalid_sync_mode_rejected(self):
        from Project.Sanad.core.skill_registry import Skill
        bad = Skill(id="test_invalid", sync_mode="garbage")
        with self.assertRaises(ValueError):
            self.sr.add(bad)

    def test_update_missing_returns_none(self):
        result = self.sr.update("nonexistent_id_12345", {"description": "x"})
        self.assertIsNone(result)

    def test_delete_missing_returns_none(self):
        result = self.sr.delete("nonexistent_id_12345")
        self.assertIsNone(result)


class TestEventBus(unittest.TestCase):
    """EventBus emit_sync handles missing event loop and async handlers."""

    def test_emit_sync_no_handlers(self):
        from Project.Sanad.core.event_bus import EventBus
        bus = EventBus()
        # Should not raise
        bus.emit_sync("nonexistent.event", value=1)

    def test_emit_sync_sync_handler(self):
        from Project.Sanad.core.event_bus import EventBus
        bus = EventBus()
        captured = []
        bus.on("test.event", lambda **kw: captured.append(kw))
        bus.emit_sync("test.event", value=42)
        self.assertEqual(captured, [{"value": 42}])

    def test_emit_sync_async_handler_no_loop(self):
        from Project.Sanad.core.event_bus import EventBus
        bus = EventBus()
        async def handler(**kw):
            pass
        bus.on("test.async", handler)
        # No running loop — should warn but not crash
        bus.emit_sync("test.async", value=1)

    def test_handler_exception_isolated(self):
        from Project.Sanad.core.event_bus import EventBus
        bus = EventBus()
        results = []
        def good(**kw):
            results.append("ok")
        def bad(**kw):
            raise RuntimeError("intentional")
        bus.on("test.iso", bad)
        bus.on("test.iso", good)
        bus.emit_sync("test.iso", x=1)
        # Good handler still ran
        self.assertEqual(results, ["ok"])


class TestBrainCallbackWhitelist(unittest.TestCase):
    """Brain._resolve_callback rejects non-whitelisted modules (RCE block)."""

    def setUp(self):
        from Project.Sanad.core.brain import Brain
        self.brain = Brain()

    def test_rce_blocked_os(self):
        cb = self.brain._resolve_callback("os:system")
        self.assertIsNone(cb, "os:system must be rejected")

    def test_rce_blocked_subprocess(self):
        cb = self.brain._resolve_callback("subprocess:run")
        self.assertIsNone(cb, "subprocess:run must be rejected")

    def test_rce_blocked_eval(self):
        cb = self.brain._resolve_callback("builtins:eval")
        self.assertIsNone(cb)

    def test_empty_callback_returns_none(self):
        self.assertIsNone(self.brain._resolve_callback(""))
        self.assertIsNone(self.brain._resolve_callback(None))

    def test_invalid_format_returns_none(self):
        self.assertIsNone(self.brain._resolve_callback("no_colon"))

    def test_whitelisted_prefix_attempted(self):
        # Module doesn't exist but the prefix is allowed — must NOT be rejected
        # by the whitelist (it'll fail at import_module instead)
        cb = self.brain._resolve_callback("Project.Sanad.motion.nonexistent_module:fn")
        self.assertIsNone(cb)  # fails at import, not at whitelist


class TestWakePhraseMatching(unittest.TestCase):
    """Token-based phrase matching — no false positives on substrings."""

    def test_no_false_positive_substring(self):
        from Project.Sanad.voice.text_utils import match_phrase
        # 'this' contains 'hi' as substring — must NOT match
        result = match_phrase("this is a test", {"hi_action": {"hi"}})
        self.assertIsNone(result)

    def test_exact_word_match(self):
        from Project.Sanad.voice.text_utils import match_phrase
        result = match_phrase("hi there friend", {"greet": {"hi"}})
        self.assertEqual(result, "greet")

    def test_multi_word_phrase_all_required(self):
        from Project.Sanad.voice.text_utils import match_phrase
        # All words must appear
        result = match_phrase("please shake hands", {"act": {"shake hands"}})
        self.assertEqual(result, "act")
        result = match_phrase("just shake", {"act": {"shake hands"}})
        self.assertIsNone(result)

    def test_longest_phrase_wins(self):
        from Project.Sanad.voice.text_utils import match_phrase
        sets = {
            "short": {"hi"},
            "long":  {"hi five"},
        }
        # When both match, prefer the more-specific (longer) phrase
        result = match_phrase("hi five there", sets)
        self.assertEqual(result, "long")

    def test_normalize_arabic(self):
        from Project.Sanad.voice.text_utils import normalize_arabic
        out = normalize_arabic("مَرْحَبًا")
        self.assertNotIn("\u064b", out)  # tashkeel removed


class TestArmController(unittest.TestCase):
    """Arm controller in simulation mode."""

    def test_constructs(self):
        from Project.Sanad.motion.arm_controller import ArmController
        arm = ArmController()
        self.assertFalse(arm.is_busy)

    def test_action_registry(self):
        from Project.Sanad.motion.arm_controller import ACTIONS, ACTION_BY_NAME
        self.assertGreater(len(ACTIONS), 0)
        # SDK actions should be in the registry
        sdk_names = ["shake_hand", "high_five", "clap"]
        for name in sdk_names:
            self.assertIn(name, ACTION_BY_NAME)

    def test_motion_files_cache(self):
        from Project.Sanad.motion.arm_controller import ArmController
        arm = ArmController()
        first = arm.list_motion_files()
        second = arm.list_motion_files()
        # Same call twice — should hit cache
        self.assertEqual(len(first), len(second))

    def test_unknown_action_raises(self):
        from Project.Sanad.motion.arm_controller import ArmController
        arm = ArmController()
        with self.assertRaises(KeyError):
            arm.trigger_by_name("definitely_not_a_real_action_xyz")

    def test_arm_adapter_works(self):
        """The MacroPlayer arm adapter should work over a real ArmController."""
        from Project.Sanad.motion.arm_controller import ArmController
        from Project.Sanad.motion.macro_player import _ArmAdapter
        arm = ArmController()
        adapter = _ArmAdapter(arm)
        # All adapter methods should be callable without raising
        q = adapter.get_current_q()
        self.assertEqual(len(q), 29)
        self.assertIsInstance(adapter.state_age(), float)
        # wait_for_state with no real DDS subscriber should just return True
        # (since the adapter falls back when wait_for_state is missing) OR
        # block until timeout (if the method exists and waits). Either way
        # it should NOT raise.
        result = adapter.wait_for_state(timeout=0.05)
        self.assertIsInstance(result, bool)


class TestSafeIO(unittest.TestCase):
    """Path traversal protection + atomic writes."""

    def test_safe_filename_strips_traversal(self):
        from Project.Sanad.dashboard.routes._safe_io import safe_filename
        with self.assertRaises(Exception):
            safe_filename("..")
        with self.assertRaises(Exception):
            safe_filename("")
        with self.assertRaises(Exception):
            safe_filename(None)
        # Embedded path components should be stripped to basename
        self.assertEqual(safe_filename("../../etc/passwd"), "passwd")
        self.assertEqual(safe_filename("foo.wav"), "foo.wav")

    def test_safe_path_under_blocks_escape(self):
        from Project.Sanad.dashboard.routes._safe_io import safe_path_under
        from Project.Sanad.config import MOTIONS_DIR
        # Normal name → ok
        p = safe_path_under(MOTIONS_DIR, "foo.jsonl")
        self.assertTrue(str(p).startswith(str(MOTIONS_DIR.resolve())))
        # Traversal attempt → rejected (basename strip means it's just "passwd"
        # under MOTIONS_DIR, which is safe)
        p2 = safe_path_under(MOTIONS_DIR, "../../etc/passwd")
        self.assertTrue(str(p2).startswith(str(MOTIONS_DIR.resolve())))

    def test_atomic_write_text(self):
        import tempfile
        from Project.Sanad.dashboard.routes._safe_io import atomic_write_text
        with tempfile.TemporaryDirectory() as td:
            target = Path(td) / "test.txt"
            atomic_write_text(target, "hello\nworld")
            self.assertEqual(target.read_text(), "hello\nworld")

    def test_atomic_write_json(self):
        import tempfile
        import json
        from Project.Sanad.dashboard.routes._safe_io import atomic_write_json
        with tempfile.TemporaryDirectory() as td:
            target = Path(td) / "test.json"
            atomic_write_json(target, {"a": 1, "b": [1, 2, 3]})
            self.assertEqual(json.loads(target.read_text()), {"a": 1, "b": [1, 2, 3]})


class TestGeminiClientStructure(unittest.TestCase):
    """GeminiVoiceClient structural tests — no actual websocket."""

    def setUp(self):
        try:
            from Project.Sanad.gemini.client import GeminiVoiceClient
            self.client = GeminiVoiceClient()
        except ImportError:
            self.skipTest("websockets not installed")

    def test_initial_state(self):
        self.assertFalse(self.client.connected)
        self.assertIsNone(self.client.session_owner)

    def test_send_audio_chunk_when_disconnected(self):
        """Should return False, not raise — the no-op-forever bug is fixed."""
        import asyncio
        result = asyncio.run(self.client.send_audio_chunk("dGVzdA=="))
        self.assertFalse(result)

    def test_acquire_session_returns_guard(self):
        """acquire_session should be sync and return a context manager."""
        guard = self.client.acquire_session("test")
        # Has __aenter__/__aexit__
        self.assertTrue(hasattr(guard, "__aenter__"))
        self.assertTrue(hasattr(guard, "__aexit__"))

    def test_session_lock_exclusive(self):
        """Two consecutive acquires should serialize, not deadlock."""
        import asyncio

        events = []

        async def consumer(name):
            async with self.client.acquire_session(name):
                events.append(f"{name}:enter")
                await asyncio.sleep(0.05)
                events.append(f"{name}:exit")

        async def runner():
            await asyncio.gather(consumer("A"), consumer("B"))

        asyncio.run(runner())
        # Either A fully runs then B, or B fully runs then A — never interleaved
        self.assertIn("A:enter", events)
        self.assertIn("B:enter", events)
        # The exit of one comes before the enter of the other
        a_exit = events.index("A:exit")
        b_enter = events.index("B:enter")
        a_enter = events.index("A:enter")
        b_exit = events.index("B:exit")
        ok1 = (a_enter < a_exit < b_enter < b_exit)
        ok2 = (b_enter < b_exit < a_enter < a_exit)
        self.assertTrue(ok1 or ok2, f"Lock not exclusive: {events}")


class TestAudioDevices(unittest.TestCase):
    """audio_devices module — pure helpers tested without pactl."""

    def test_profiles_defined(self):
        from Project.Sanad.voice import audio_devices as ad
        self.assertGreater(len(ad.PROFILES), 0)
        ids = {p.id for p in ad.PROFILES}
        self.assertIn("anker_powerconf", ids)
        self.assertIn("hollyland_builtin", ids)
        self.assertIn("builtin", ids)

    def test_pactl_available_no_crash(self):
        from Project.Sanad.voice import audio_devices as ad
        # Should not raise, just return False if pactl missing
        result = ad.pactl_available()
        self.assertIsInstance(result, bool)

    def test_find_first_match(self):
        from Project.Sanad.voice import audio_devices as ad
        items = [
            {"name": "alsa_output.usb-Anker_PowerConf_A3321-DEV-SN1-01.analog-stereo",
             "description": "", "index": "0"},
            {"name": "alsa_output.platform-sound.analog-stereo",
             "description": "", "index": "1"},
            {"name": "alsa_input.usb-Shenzhen_Hollyland_Technology_Co._Ltd_Wireless_microphone_C63X223T6MX-01.analog-stereo",
             "description": "", "index": "5"},
        ]
        # Anker pattern matches (multi-pattern: "powerconf,anker")
        m = ad.find_first_match(items, "powerconf,anker")
        self.assertIsNotNone(m)
        self.assertIn("Anker_PowerConf", m["name"])
        # Built-in pattern matches
        m2 = ad.find_first_match(items, "platform-sound")
        self.assertIsNotNone(m2)
        # Hollyland — matches by "hollyland" OR "wireless_microphone"
        m3 = ad.find_first_match(items, "hollyland,wireless_microphone")
        self.assertIsNotNone(m3)
        self.assertIn("Hollyland", m3["name"])
        # Case-insensitive
        m4 = ad.find_first_match(items, "HOLLYLAND")
        self.assertIsNotNone(m4)
        # No match returns None
        m5 = ad.find_first_match(items, "nonexistent")
        self.assertIsNone(m5)
        # Empty pattern returns None
        m6 = ad.find_first_match(items, "")
        self.assertIsNone(m6)
        # Different USB port — Anker on SN2-03 instead of SN1-01
        items_port2 = [
            {"name": "alsa_output.usb-Anker_PowerConf_A3321-DEV-SN2-03.analog-stereo",
             "description": "", "index": "0"},
        ]
        m7 = ad.find_first_match(items_port2, "powerconf,anker")
        self.assertIsNotNone(m7, "Must match Anker regardless of USB port suffix")

    def test_profile_detection_with_jetson_devices(self):
        """Simulate the exact PulseAudio names from the G1 Jetson and verify
        all three profiles match correctly."""
        from Project.Sanad.voice import audio_devices as ad

        fake_sinks = [
            {"name": "alsa_output.platform-sound.analog-stereo", "description": "", "index": "0"},
            {"name": "alsa_output.usb-Anker_PowerConf_A3321-DEV-SN1-01.analog-stereo", "description": "", "index": "1"},
        ]
        fake_sources = [
            {"name": "alsa_output.platform-sound.analog-stereo.monitor", "description": "", "index": "0"},
            {"name": "alsa_input.platform-sound.analog-stereo", "description": "", "index": "1"},
            {"name": "alsa_output.usb-Anker_PowerConf_A3321-DEV-SN1-01.analog-stereo.monitor", "description": "", "index": "3"},
            {"name": "alsa_input.usb-Anker_PowerConf_A3321-DEV-SN1-01.mono-fallback", "description": "", "index": "4"},
            {"name": "alsa_input.usb-Shenzhen_Hollyland_Technology_Co._Ltd_Wireless_microphone_C63X223T6MX-01.analog-stereo", "description": "", "index": "5"},
        ]

        # Patch list_sinks/list_sources
        orig_sinks = ad.list_sinks
        orig_sources = ad.list_sources
        ad.list_sinks = lambda: fake_sinks
        ad.list_sources = lambda: fake_sources
        try:
            detected = ad.detect_plugged_profiles()
            detected_ids = [d["profile"]["id"] for d in detected]
            self.assertIn("hollyland_builtin", detected_ids,
                          "Hollyland + built-in must be detected")
            self.assertIn("anker_powerconf", detected_ids,
                          "Anker PowerConf must be detected")
            self.assertIn("builtin", detected_ids,
                          "Built-in must be detected")

            # Verify Hollyland gets the correct sink and source
            holly = next(d for d in detected if d["profile"]["id"] == "hollyland_builtin")
            self.assertIn("platform-sound", holly["sink"]["name"])
            self.assertIn("Hollyland", holly["source"]["name"])

            # Verify Anker gets Anker sink AND Anker source (not built-in)
            anker = next(d for d in detected if d["profile"]["id"] == "anker_powerconf")
            self.assertIn("PowerConf", anker["sink"]["name"])
            self.assertIn("PowerConf", anker["source"]["name"])
        finally:
            ad.list_sinks = orig_sinks
            ad.list_sources = orig_sources

    def test_profile_detection_different_usb_port(self):
        """Verify that Anker is detected even when plugged into a different port
        (serial suffix changes from SN1-01 to SN2-03)."""
        from Project.Sanad.voice import audio_devices as ad

        fake_sinks = [
            {"name": "alsa_output.usb-Anker_PowerConf_A3321-DEV-SN2-03.analog-stereo", "description": "", "index": "1"},
        ]
        fake_sources = [
            {"name": "alsa_input.usb-Anker_PowerConf_A3321-DEV-SN2-03.mono-fallback", "description": "", "index": "4"},
        ]

        orig_sinks = ad.list_sinks
        orig_sources = ad.list_sources
        ad.list_sinks = lambda: fake_sinks
        ad.list_sources = lambda: fake_sources
        try:
            detected = ad.detect_plugged_profiles()
            detected_ids = [d["profile"]["id"] for d in detected]
            self.assertIn("anker_powerconf", detected_ids,
                          "Anker must be detected regardless of USB port")
        finally:
            ad.list_sinks = orig_sinks
            ad.list_sources = orig_sources

    def test_status_no_crash_without_pactl(self):
        from Project.Sanad.voice import audio_devices as ad
        # Should return a dict even without pactl
        s = ad.status()
        self.assertIsInstance(s, dict)
        self.assertIn("pactl_available", s)
        self.assertIn("current", s)
        self.assertIn("profiles", s)
        # current always has these keys
        cur = s["current"]
        self.assertIn("sink", cur)
        self.assertIn("source", cur)
        self.assertIn("source_kind", cur)

    def test_load_save_state_atomic(self):
        from Project.Sanad.voice import audio_devices as ad
        # Round-trip
        original = ad.load_state()
        try:
            ad.save_state({"profile_id": "_test_unit", "sink": "x", "source": "y"})
            self.assertEqual(ad.load_state()["profile_id"], "_test_unit")
        finally:
            ad.save_state(original)

    def test_select_unknown_profile_rejected(self):
        from Project.Sanad.voice import audio_devices as ad
        result = ad.select_profile("definitely_not_a_real_profile")
        self.assertFalse(result["ok"])


class TestIsolation(unittest.TestCase):
    """Failure isolation: one missing dep doesn't take down others."""

    def test_main_module_imports_with_missing_deps(self):
        """main.py must import even when third-party deps are missing."""
        import importlib
        if "Project.Sanad.main" in sys.modules:
            del sys.modules["Project.Sanad.main"]
        m = importlib.import_module("Project.Sanad.main")
        # Critical subsystems must be present even with missing deps
        self.assertIsNotNone(m.brain, "brain must always be available")

    def test_subsystem_status_reported(self):
        import Project.Sanad.main as m
        self.assertTrue(hasattr(m, "SUBSYSTEMS"))
        self.assertIn("brain", m.SUBSYSTEMS)
        self.assertIn("arm", m.SUBSYSTEMS)


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