"""Tests for the platform adapter registry and dynamic Platform enum."""

import os
import pytest
from unittest.mock import MagicMock

from gateway.platform_registry import PlatformRegistry, PlatformEntry
from gateway.config import Platform, GatewayConfig


# ── Platform enum dynamic members ─────────────────────────────────────────


class TestPlatformEnumDynamic:
    """Test that Platform enum accepts unknown values for plugin platforms."""

    def test_builtin_members_still_work(self):
        assert Platform.TELEGRAM.value == "telegram"
        assert Platform("telegram") is Platform.TELEGRAM


    def test_dynamic_member_case_normalised(self):
        """Mixed case normalised to lowercase."""
        a = Platform("IRC")
        b = Platform("irc")
        assert a is b
        assert a.value == "irc"

    def test_dynamic_member_with_hyphens(self):
        """Registered plugin platforms with hyphens work once registered."""
        from gateway.platform_registry import platform_registry as _reg

        entry = PlatformEntry(
            name="my-platform",
            label="My Platform",
            adapter_factory=lambda cfg: MagicMock(),
            check_fn=lambda: True,
            source="plugin",
        )
        _reg.register(entry)
        try:
            p = Platform("my-platform")
            assert p.value == "my-platform"
            assert p.name == "MY_PLATFORM"
        finally:
            _reg.unregister("my-platform")


# ── PlatformRegistry ──────────────────────────────────────────────────────


class TestPlatformRegistry:
    """Test the PlatformRegistry itself."""

    def _make_entry(self, name="test", check_ok=True, validate_ok=True, factory_ok=True):
        adapter_mock = MagicMock()
        return PlatformEntry(
            name=name,
            label=name.title(),
            adapter_factory=lambda cfg, _m=adapter_mock: _m if factory_ok else (_ for _ in ()).throw(RuntimeError("factory error")),
            check_fn=lambda: check_ok,
            validate_config=lambda cfg: validate_ok,
            required_env=[],
            source="plugin",
        ), adapter_mock

    def test_register_and_get(self):
        reg = PlatformRegistry()
        entry, _ = self._make_entry("alpha")
        reg.register(entry)
        assert reg.get("alpha") is entry
        assert reg.is_registered("alpha")

    def test_get_unknown_returns_none(self):
        reg = PlatformRegistry()
        assert reg.get("nonexistent") is None

    def test_unregister(self):
        reg = PlatformRegistry()
        entry, _ = self._make_entry("beta")
        reg.register(entry)
        assert reg.unregister("beta") is True
        assert reg.get("beta") is None
        assert reg.unregister("beta") is False  # already gone


    def test_create_adapter_no_validate(self):
        """When validate_config is None, skip validation."""
        reg = PlatformRegistry()
        mock_adapter = MagicMock()
        entry = PlatformEntry(
            name="novalidate",
            label="NoValidate",
            adapter_factory=lambda cfg: mock_adapter,
            check_fn=lambda: True,
            validate_config=None,
            source="plugin",
        )
        reg.register(entry)
        assert reg.create_adapter("novalidate", MagicMock()) is mock_adapter


class TestEnsureDepsFn:
    """check_fn (PASSIVE probe) vs ensure_deps_fn (ACTIVE installer) split.

    Regression for #79812: Teams registered its passive probe as check_fn,
    so create_adapter() returned None before connect() could lazy-install —
    the SDK never installed.  The inverse wiring (active installer as
    check_fn) made status displays pip-install SDKs as a side effect.
    create_adapter() now runs ensure_deps_fn when check_fn is False.
    """

    def _entry(self, name, *, check_fn, ensure_deps_fn=None):
        adapter = MagicMock()
        entry = PlatformEntry(
            name=name,
            label=name.title(),
            adapter_factory=lambda cfg: adapter,
            check_fn=check_fn,
            ensure_deps_fn=ensure_deps_fn,
            source="plugin",
        )
        return entry, adapter

    def test_deps_present_skips_installer(self):
        """check_fn True → adapter created, ensure_deps_fn never called."""
        reg = PlatformRegistry()
        installer = MagicMock(return_value=True)
        entry, adapter = self._entry(
            "ready", check_fn=lambda: True, ensure_deps_fn=installer
        )
        reg.register(entry)
        assert reg.create_adapter("ready", MagicMock()) is adapter
        installer.assert_not_called()

    def test_missing_deps_runs_installer_then_creates(self):
        """check_fn False + ensure_deps_fn True → install runs, adapter created."""
        reg = PlatformRegistry()
        installer = MagicMock(return_value=True)
        entry, adapter = self._entry(
            "installable", check_fn=lambda: False, ensure_deps_fn=installer
        )
        reg.register(entry)
        assert reg.create_adapter("installable", MagicMock()) is adapter
        installer.assert_called_once()

    def test_install_failure_returns_none(self):
        """check_fn False + ensure_deps_fn False → no adapter."""
        reg = PlatformRegistry()
        installer = MagicMock(return_value=False)
        entry, _ = self._entry(
            "broken", check_fn=lambda: False, ensure_deps_fn=installer
        )
        reg.register(entry)
        assert reg.create_adapter("broken", MagicMock()) is None
        installer.assert_called_once()

    def test_no_installer_missing_deps_returns_none(self):
        """check_fn False + no ensure_deps_fn → hard block (legacy behavior)."""
        reg = PlatformRegistry()
        entry, _ = self._entry("blocked", check_fn=lambda: False)
        reg.register(entry)
        assert reg.create_adapter("blocked", MagicMock()) is None

    def test_installer_exception_returns_none(self):
        """ensure_deps_fn raising is caught, adapter not created."""
        reg = PlatformRegistry()

        def _boom():
            raise RuntimeError("pip exploded")

        entry, _ = self._entry(
            "explosive", check_fn=lambda: False, ensure_deps_fn=_boom
        )
        reg.register(entry)
        assert reg.create_adapter("explosive", MagicMock()) is None

    def test_check_fn_exception_falls_through_to_installer(self):
        """A raising check_fn is treated as deps-missing, installer still runs."""
        reg = PlatformRegistry()

        def _bad_probe():
            raise RuntimeError("probe error")

        installer = MagicMock(return_value=True)
        entry, adapter = self._entry(
            "flaky", check_fn=_bad_probe, ensure_deps_fn=installer
        )
        reg.register(entry)
        assert reg.create_adapter("flaky", MagicMock()) is adapter
        installer.assert_called_once()


# ── GatewayConfig integration ────────────────────────────────────────────


class TestGatewayConfigPluginPlatform:
    """Test that GatewayConfig parses and validates plugin platforms."""


    def test_get_connected_platforms_includes_registered_plugin(self):
        """Plugin platform with registry entry passes get_connected_platforms."""
        # Register a fake plugin platform
        from gateway.platform_registry import platform_registry as _reg

        test_entry = PlatformEntry(
            name="testplat",
            label="TestPlat",
            adapter_factory=lambda cfg: MagicMock(),
            check_fn=lambda: True,
            validate_config=lambda cfg: bool(cfg.extra.get("token")),
            source="plugin",
        )
        _reg.register(test_entry)
        try:
            data = {
                "platforms": {
                    "testplat": {"enabled": True, "extra": {"token": "abc"}},
                }
            }
            cfg = GatewayConfig.from_dict(data)
            connected = cfg.get_connected_platforms()
            connected_values = {p.value for p in connected}
            assert "testplat" in connected_values
        finally:
            _reg.unregister("testplat")


# ── Extended PlatformEntry fields ─────────────────────────────────────


class TestPlatformEntryExtendedFields:
    """Test the auth, message length, and display fields on PlatformEntry."""

    def test_default_field_values(self):
        entry = PlatformEntry(
            name="test",
            label="Test",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: True,
        )
        assert entry.allowed_users_env == ""
        assert entry.allow_all_env == ""
        assert entry.max_message_length == 0
        assert entry.pii_safe is False
        assert entry.emoji == "🔌"
        assert entry.allow_update_command is True


# ── Cron platform resolution ─────────────────────────────────────────


class TestCronPlatformResolution:
    """Test that cron delivery accepts plugin platform names."""

    def test_builtin_platform_resolves(self):
        """Built-in platform names resolve via Platform() call."""
        p = Platform("telegram")
        assert p is Platform.TELEGRAM


# ── platforms.py integration ──────────────────────────────────────────


class TestPlatformsMerge:
    """Test get_all_platforms() merges with registry."""


    def test_get_all_platforms_includes_plugin(self):
        from hermes_cli.platforms import get_all_platforms
        from gateway.platform_registry import platform_registry as _reg

        _reg.register(PlatformEntry(
            name="testmerge",
            label="TestMerge",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: True,
            source="plugin",
            emoji="🧪",
        ))
        try:
            merged = get_all_platforms()
            assert "testmerge" in merged
            assert "TestMerge" in merged["testmerge"].label
        finally:
            _reg.unregister("testmerge")


# ── apply_yaml_config_fn (PlatformEntry field + load_gateway_config dispatch) ──


class TestApplyYamlConfigFnField:
    """The hook field itself — defaults, custom values, signature."""

    def test_default_is_none(self):
        entry = PlatformEntry(
            name="test",
            label="Test",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: True,
        )
        assert entry.apply_yaml_config_fn is None


class TestApplyYamlConfigFnDispatch:
    """End-to-end dispatch through load_gateway_config().

    Each test registers a temporary PlatformEntry, writes a config.yaml in
    a tmp HERMES_HOME, calls load_gateway_config(), and asserts the hook
    was invoked correctly.  Cleanup unregisters the entry.
    """

    def _write_config(self, tmp_path, content: str):
        hermes_home = tmp_path / ".hermes"
        hermes_home.mkdir()
        (hermes_home / "config.yaml").write_text(content, encoding="utf-8")
        return hermes_home

    def _register_hook(self, name, hook_fn):
        from gateway.platform_registry import platform_registry as _reg

        entry = PlatformEntry(
            name=name,
            label=name.title(),
            adapter_factory=lambda cfg: None,
            check_fn=lambda: True,
            source="plugin",
            apply_yaml_config_fn=hook_fn,
        )
        _reg.register(entry)
        return _reg


    def test_hook_exception_swallowed(self, tmp_path, monkeypatch):
        """A misbehaving hook never aborts load_gateway_config()."""

        def _bad_hook(yaml_cfg, platform_cfg):
            raise RuntimeError("plugin author bug")

        # Also register a well-behaved hook to ensure dispatch continues
        # iterating after a bad one.
        good_called = {"count": 0}

        def _good_hook(yaml_cfg, platform_cfg):
            good_called["count"] += 1
            return None

        from gateway.platform_registry import platform_registry as _reg
        _reg.register(PlatformEntry(
            name="mybadplat",
            label="MyBad",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: True,
            source="plugin",
            apply_yaml_config_fn=_bad_hook,
        ))
        _reg.register(PlatformEntry(
            name="mygoodplat",
            label="MyGood",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: True,
            source="plugin",
            apply_yaml_config_fn=_good_hook,
        ))
        try:
            home = self._write_config(
                tmp_path,
                "mybadplat:\n  k: v\n"
                "mygoodplat:\n  k: v\n",
            )
            monkeypatch.setenv("HERMES_HOME", str(home))

            # Must not raise.
            from gateway.config import load_gateway_config
            load_gateway_config()

            assert good_called["count"] == 1
        finally:
            _reg.unregister("mybadplat")
            _reg.unregister("mygoodplat")


    def test_env_var_takes_precedence_when_hook_uses_getenv_guard(
        self, tmp_path, monkeypatch
    ):
        """The standard `not os.getenv(...)` guard preserves env > YAML."""
        env_var = "MYPRECPLAT_FLAG"
        monkeypatch.setenv(env_var, "preexisting")

        def _hook(yaml_cfg, platform_cfg):
            if "flag" in platform_cfg and not os.getenv(env_var):
                os.environ[env_var] = str(platform_cfg["flag"]).lower()
            return None

        reg = self._register_hook("myprecplat", _hook)
        try:
            home = self._write_config(
                tmp_path, "myprecplat:\n  flag: yaml-value\n",
            )
            monkeypatch.setenv("HERMES_HOME", str(home))

            from gateway.config import load_gateway_config
            load_gateway_config()

            # Pre-existing env var was NOT clobbered by the hook.
            assert os.environ.get(env_var) == "preexisting"
        finally:
            reg.unregister("myprecplat")
            os.environ.pop(env_var, None)


class TestPluginPlatformSharedKeyBridge:
    """Plugin-registered platforms get the same shared-key bridging as built-ins.

    Without this, plugin authors using ``apply_yaml_config_fn`` would have to
    re-implement bridging for every common key (``unauthorized_dm_behavior``,
    ``notice_delivery``, ``reply_prefix``, ``require_mention``, ``dm_policy``,
    ``allow_from``, etc.) — defeating the hook's whole point of letting
    plugins focus on their *platform-specific* keys.
    """

    def _write_config(self, tmp_path, content: str):
        hermes_home = tmp_path / ".hermes"
        hermes_home.mkdir()
        (hermes_home / "config.yaml").write_text(content, encoding="utf-8")
        return hermes_home

    def test_shared_keys_bridged_for_plugin_platform(self, tmp_path, monkeypatch):
        """A plugin platform's ``require_mention``/``dm_policy``/etc. flow into
        ``PlatformConfig.extra`` without the plugin needing its own bridge."""
        from gateway.platform_registry import platform_registry as _reg

        _reg.register(PlatformEntry(
            name="mysharedplat",
            label="MySharedPlat",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: True,
            source="plugin",
        ))
        try:
            home = self._write_config(
                tmp_path,
                "mysharedplat:\n"
                "  require_mention: true\n"
                "  dm_policy: allow\n"
                "  reply_prefix: \"→ \"\n"
                "  allow_from: [\"alice\", \"bob\"]\n",
            )
            monkeypatch.setenv("HERMES_HOME", str(home))

            from gateway.config import load_gateway_config, Platform
            cfg = load_gateway_config()

            plat = Platform("mysharedplat")
            assert plat in cfg.platforms
            extra = cfg.platforms[plat].extra
            assert extra.get("require_mention") is True
            assert extra.get("dm_policy") == "allow"
            assert extra.get("reply_prefix") == "→ "
            assert extra.get("allow_from") == ["alice", "bob"]
        finally:
            _reg.unregister("mysharedplat")


class TestPluginEnablementGate:
    """Plugin platforms must NOT auto-enable on check_fn alone (#31116).

    When a plugin registers ``is_connected`` (the "did the user actually
    configure credentials" probe), ``load_gateway_config`` must consult it
    before flipping ``enabled = True``.  Without this gate, ``check_fn``
    semantics ("the SDK is importable") get conflated with "the user wants
    this platform on", and the gateway tries to connect to e.g. Discord
    with no token — emitting noisy retry-forever errors on every fresh
    install that has the plugin loaded.
    """

    def _write_config(self, tmp_path, content: str = ""):
        hermes_home = tmp_path / ".hermes"
        hermes_home.mkdir()
        (hermes_home / "config.yaml").write_text(content, encoding="utf-8")
        return hermes_home

    def test_plugin_with_is_connected_false_is_NOT_enabled(
        self, tmp_path, monkeypatch
    ):
        """check_fn=True + is_connected=False must NOT enable the platform.

        Reproduces #31116: Discord plugin loads, its check_fn lazy-installs
        discord.py and returns True, but the user has no DISCORD_BOT_TOKEN.
        Previously this auto-enabled Discord and the gateway spammed
        ``ERROR ... [Discord] No bot token configured`` on every reconnect.
        """
        from gateway.platform_registry import platform_registry as _reg

        _reg.register(PlatformEntry(
            name="myunconfiguredplat",
            label="MyUnconfigured",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: True,             # SDK available
            is_connected=lambda cfg: False,    # but user hasn't set credentials
            source="plugin",
        ))
        try:
            home = self._write_config(tmp_path)
            monkeypatch.setenv("HERMES_HOME", str(home))

            from gateway.config import load_gateway_config, Platform
            cfg = load_gateway_config()

            plat = Platform("myunconfiguredplat")
            # Either absent entirely, or present but explicitly disabled.
            if plat in cfg.platforms:
                assert cfg.platforms[plat].enabled is False, (
                    "Plugin with is_connected=False must NOT be auto-enabled"
                )
        finally:
            _reg.unregister("myunconfiguredplat")


    def test_is_connected_raises_does_not_enable(self, tmp_path, monkeypatch):
        """A buggy is_connected must not silently enable the platform.

        Treat a raising is_connected as "configuration unknown" — refuse to
        enable, log, and move on.  Anything else would re-introduce the
        #31116 bug for plugins whose probe has a transient failure.
        """
        from gateway.platform_registry import platform_registry as _reg

        def _bad_probe(cfg):
            raise RuntimeError("plugin bug")

        _reg.register(PlatformEntry(
            name="mybadprobeplat",
            label="MyBadProbe",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: True,
            is_connected=_bad_probe,
            source="plugin",
        ))
        try:
            home = self._write_config(tmp_path)
            monkeypatch.setenv("HERMES_HOME", str(home))

            from gateway.config import load_gateway_config, Platform
            cfg = load_gateway_config()

            plat = Platform("mybadprobeplat")
            if plat in cfg.platforms:
                assert cfg.platforms[plat].enabled is False
        finally:
            _reg.unregister("mybadprobeplat")


    def test_is_connected_failed_gate_does_not_leak_extras(
        self, tmp_path, monkeypatch
    ):
        """When the gate rejects, env-seeded extras must NOT leak onto
        ``config.platforms``.  A rejected plugin should be invisible, not
        present-but-partially-populated.
        """
        from gateway.platform_registry import platform_registry as _reg

        _reg.register(PlatformEntry(
            name="myrejectedplat",
            label="MyRejected",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: True,
            is_connected=lambda cfg: False,
            env_enablement_fn=lambda: {"some_key": "should-not-leak"},
            source="plugin",
        ))
        try:
            home = self._write_config(tmp_path)
            monkeypatch.setenv("HERMES_HOME", str(home))

            from gateway.config import load_gateway_config, Platform
            cfg = load_gateway_config()

            plat = Platform("myrejectedplat")
            if plat in cfg.platforms:
                assert cfg.platforms[plat].enabled is False
                assert "some_key" not in cfg.platforms[plat].extra, (
                    "Rejected plugin's env-seeded extras leaked onto "
                    "config.platforms"
                )
        finally:
            _reg.unregister("myrejectedplat")

    def test_missing_deps_with_installer_still_enables(
        self, tmp_path, monkeypatch
    ):
        """is_connected=True + check_fn=False + ensure_deps_fn set → ENABLED.

        The install is deferred to ``create_adapter()`` at gateway start
        (#79812).  Skipping enablement here would mean a configured platform
        whose SDK isn't installed yet never gets the chance to install it.
        """
        from gateway.platform_registry import platform_registry as _reg

        installer = MagicMock(return_value=True)
        _reg.register(PlatformEntry(
            name="myinstallableplat",
            label="MyInstallable",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: False,            # SDK not installed yet
            ensure_deps_fn=installer,          # ...but installable on demand
            is_connected=lambda cfg: True,     # user configured credentials
            source="plugin",
        ))
        try:
            home = self._write_config(tmp_path)
            monkeypatch.setenv("HERMES_HOME", str(home))

            from gateway.config import load_gateway_config, Platform
            cfg = load_gateway_config()

            plat = Platform("myinstallableplat")
            assert plat in cfg.platforms and cfg.platforms[plat].enabled, (
                "Configured platform with a registered installer must be "
                "enabled; the install runs at create_adapter() time"
            )
            # Config loading must NOT have run the installer (that's the
            # desktop boot-loop bug — see module docstring of
            # test_startup_no_eager_platform_install.py).
            installer.assert_not_called()
        finally:
            _reg.unregister("myinstallableplat")

    def test_missing_deps_without_installer_not_enabled(
        self, tmp_path, monkeypatch
    ):
        """is_connected=True + check_fn=False + NO ensure_deps_fn → skipped.

        Without an installer, missing deps are a hard block — enabling the
        platform would just queue guaranteed connect failures.
        """
        from gateway.platform_registry import platform_registry as _reg

        _reg.register(PlatformEntry(
            name="myhardblockplat",
            label="MyHardBlock",
            adapter_factory=lambda cfg: None,
            check_fn=lambda: False,
            is_connected=lambda cfg: True,
            source="plugin",
        ))
        try:
            home = self._write_config(tmp_path)
            monkeypatch.setenv("HERMES_HOME", str(home))

            from gateway.config import load_gateway_config, Platform
            cfg = load_gateway_config()

            plat = Platform("myhardblockplat")
            if plat in cfg.platforms:
                assert cfg.platforms[plat].enabled is False
        finally:
            _reg.unregister("myhardblockplat")


class TestMigratedPlatformWiring:
    """Every lazy-installable bundled platform must register the split:
    a PASSIVE check_fn plus an ACTIVE ensure_deps_fn (#79812).

    Behavior contract, not a snapshot: asserts the two fields are distinct
    callables (probe != installer), not specific function identities, so
    renames don't churn this test.
    """

    import pytest as _pytest

    @_pytest.mark.parametrize(
        "platform_name",
        [
            "teams", "telegram", "discord", "slack",
            "matrix", "dingtalk", "feishu", "wecom_callback",
        ],
    )
    def test_lazy_installable_platform_has_split_wiring(self, platform_name):
        from hermes_cli.plugins import discover_plugins

        discover_plugins()
        from gateway.platform_registry import platform_registry

        # Materialize deferred loaders (wecom_callback is registered by the
        # "wecom" manifest's loader; a cold get() by its own name misses).
        platform_registry.plugin_entries()
        entry = platform_registry.get(platform_name)
        assert entry is not None, f"{platform_name} not registered"
        assert entry.ensure_deps_fn is not None, (
            f"{platform_name} has a lazy-installable SDK but no "
            "ensure_deps_fn — its deps can never auto-install "
            "(the #79812 deadlock)"
        )
        assert entry.ensure_deps_fn is not entry.check_fn, (
            f"{platform_name} registered the same callable for the passive "
            "probe and the active installer — status displays would "
            "pip-install as a side effect"
        )
