import ast
import json
import re
import subprocess
import sys
from pathlib import Path

import pytest

import nanobot.channels._setup as channel_setup_module
import nanobot.channels.registry as registry_module
from nanobot.channels._setup import channel_setup_spec
from nanobot.channels.plugin import ChannelPlugin, load_channel_package
from nanobot.channels.registry import channel_default_enabled, discover_plugins

EXPECTED_CHANNELS = {
    "dingtalk",
    "discord",
    "email",
    "feishu",
    "matrix",
    "mattermost",
    "mochat",
    "msteams",
    "napcat",
    "qq",
    "signal",
    "slack",
    "telegram",
    "websocket",
    "wecom",
    "weixin",
    "whatsapp",
}


def test_channel_setup_spec_derives_route_and_secret_metadata() -> None:
    slack = channel_setup_spec("slack")

    assert slack is not None
    assert slack.secrets == {"appToken", "botToken"}
    assert slack.route_field_types == {
        "appToken": "secret",
        "botToken": "secret",
        "groupPolicy": ("enum", {"mention", "open", "allowlist"}),
    }
    assert slack.simple_required_fields == ("appToken", "botToken")
    assert slack.fields["groupPolicy"].default == "mention"
    group_policy = next(
        field
        for field in slack.to_public_dict("slack")["fields"]
        if field["field"] == "groupPolicy"
    )
    assert group_policy["default_value"] == "mention"


def test_matrix_setup_requires_one_complete_login_method() -> None:
    matrix = channel_setup_spec("matrix")

    assert matrix is not None
    base = {
        "homeserver": "https://matrix.example",
        "userId": "@nanobot:matrix.example",
    }
    assert matrix.is_configured(base | {"password": "secret"})
    assert matrix.is_configured(base | {"accessToken": "token", "deviceId": "DEVICE"})
    assert not matrix.is_configured(base | {"accessToken": "token"})


def test_channel_setup_spec_separates_writable_and_snapshot_fields() -> None:
    matrix = channel_setup_spec("matrix")
    discord = channel_setup_spec("discord")

    assert matrix is not None
    assert discord is not None
    assert "allowFrom" not in matrix.route_field_types
    assert "allowFrom" in matrix.snapshot_fields
    assert "allowFrom" in discord.route_field_types
    assert "allowFrom" not in discord.snapshot_fields


def test_webui_forms_have_writable_mattermost_and_whatsapp_contracts() -> None:
    mattermost = channel_setup_spec("mattermost")
    whatsapp = channel_setup_spec("whatsapp")

    assert mattermost is not None
    assert whatsapp is not None
    assert mattermost.route_field_types["serverUrl"] == "string"
    assert mattermost.route_field_types["token"] == "secret"
    assert whatsapp.route_field_types["allowFrom"] == "list"
    assert whatsapp.route_field_types["groupPolicy"] == (
        "enum",
        {"mention", "open"},
    )


def test_every_channel_is_a_self_contained_package() -> None:
    channel_dir = Path(channel_setup_module.__file__).parent
    package_names = {path.parent.name for path in channel_dir.glob("*/manifest.py")}

    assert not hasattr(channel_setup_module, "CHANNEL_SETUP_SPECS")
    assert package_names == EXPECTED_CHANNELS
    assert set(discover_plugins()) == EXPECTED_CHANNELS
    for name in EXPECTED_CHANNELS:
        package_dir = channel_dir / name
        assert (package_dir / "__init__.py").is_file()
        assert (package_dir / "manifest.py").is_file()
        assert (package_dir / "runtime.py").is_file()
        assert not (channel_dir / f"{name}.py").exists()

        plugin = load_channel_package(name)
        assert plugin is not None
        assert plugin.name == name
        assert plugin.runtime.startswith(f"nanobot.channels.{name}.runtime:")
        assert plugin.setup is channel_setup_spec(name)
        if plugin.webui is not None:
            assert (package_dir / plugin.webui).is_file()


def test_channel_locales_cover_authoritative_setup_contracts() -> None:
    channel_dir = Path(channel_setup_module.__file__).parent
    for name in EXPECTED_CHANNELS:
        plugin = load_channel_package(name)
        assert plugin is not None
        if plugin.webui is None or plugin.setup is None:
            continue
        english = json.loads(
            (channel_dir / name / "webui" / "locales" / "en.json").read_text(encoding="utf-8")
        )
        setup_messages = english["setup"]
        field_messages = setup_messages.get("fields", {})
        for field_name, field in plugin.setup.fields.items():
            if not field.writable:
                continue
            message_key = re.sub(r"[^A-Za-z0-9_-]+", "_", field_name)
            assert message_key in field_messages, f"{name} field {field_name} has no locale copy"
        if plugin.setup.official_url:
            assert setup_messages.get("officialLabel"), f"{name} has no localized official label"


def test_channel_manifests_only_import_contract_modules() -> None:
    channel_dir = Path(channel_setup_module.__file__).parent
    allowed_imports = {
        "nanobot.channels._manifest",
        "nanobot.channels.contracts",
        "nanobot.channels.plugin",
    }

    for name in EXPECTED_CHANNELS:
        manifest_path = channel_dir / name / "manifest.py"
        tree = ast.parse(manifest_path.read_text(encoding="utf-8"))
        imports: set[str] = set()
        for node in tree.body:
            if isinstance(node, ast.Import):
                imports.update(alias.name for alias in node.names)
            elif isinstance(node, ast.ImportFrom) and node.module:
                imports.add(node.module)
        allowed_channel_imports = {
            module
            for module in imports
            if module.startswith(f"nanobot.channels.{name}.")
            and not module.endswith(".runtime")
        }
        unexpected = imports - allowed_imports - allowed_channel_imports
        assert not unexpected, f"{name} imports runtime dependencies: {unexpected}"


def test_runtime_classes_do_not_declare_persisted_management_hooks() -> None:
    channel_dir = Path(channel_setup_module.__file__).parent
    management_hooks = {
        "feature_instances",
        "instance_specs",
        "runtime_name",
        "supports_multiple_instances",
        "update_instance_config",
    }
    for name in EXPECTED_CHANNELS:
        tree = ast.parse((channel_dir / name / "runtime.py").read_text(encoding="utf-8"))
        declared = {
            item.name
            for node in tree.body
            if isinstance(node, ast.ClassDef)
            for item in node.body
            if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
        }
        assert declared.isdisjoint(management_hooks), f"{name} runtime owns {declared & management_hooks}"


def test_feishu_package_manifest_owns_runtime_and_webui_metadata() -> None:
    plugin = load_channel_package("feishu")

    assert plugin is not None
    assert plugin.runtime == "nanobot.channels.feishu.runtime:FeishuChannel"
    assert plugin.dependencies == ("lark-oapi>=1.5.0,<2.0.0",)
    assert plugin.connector == "nanobot.channels.feishu.connect:FeishuConnectStore"
    assert plugin.management.multi_instance is True
    assert plugin.webui == "webui/index.tsx"


def test_weixin_package_manifest_owns_runtime_and_webui_metadata() -> None:
    plugin = load_channel_package("weixin")

    assert plugin is not None
    assert plugin.runtime == "nanobot.channels.weixin.runtime:WeixinChannel"
    assert plugin.dependencies == ("qrcode[pil]>=8.0", "pycryptodome>=3.20.0")
    assert plugin.connector == "nanobot.channels.weixin.connect:WeixinConnectStore"
    assert plugin.webui == "webui/index.tsx"


def test_package_manifests_do_not_import_runtimes() -> None:
    code = f"""
import sys
from nanobot.channels.plugin import load_channel_package

for name in {sorted(EXPECTED_CHANNELS)!r}:
    plugin = load_channel_package(name)
    assert plugin is not None
    assert f"nanobot.channels.{{name}}.runtime" not in sys.modules
"""
    result = subprocess.run(
        [sys.executable, "-c", code],
        capture_output=True,
        text=True,
        check=False,
    )

    assert result.returncode == 0, result.stderr


def test_channel_plugin_normalizes_webui_entry() -> None:
    plugin = ChannelPlugin(
        name="demo",
        display_name="Demo",
        runtime="example.demo.runtime:DemoChannel",
        webui="webui\\index.tsx",
    )

    assert plugin.webui == "webui/index.tsx"


def test_channel_plugin_name_must_match_package_identifier() -> None:
    with pytest.raises(ValueError, match="letters, digits, or underscores"):
        ChannelPlugin(
            name="google-chat",
            display_name="Google Chat",
            runtime="example.google_chat.runtime:GoogleChatChannel",
        )


def test_channel_plugin_rejects_invalid_runtime_import_path() -> None:
    with pytest.raises(ValueError, match="absolute import path"):
        ChannelPlugin(
            name="demo",
            display_name="Demo",
            runtime="../runtime:DemoChannel",
        )


def test_channel_default_enabled_uses_package_manifest(monkeypatch) -> None:
    plugin = ChannelPlugin(
        name="demo",
        display_name="Demo",
        runtime="example.demo.runtime:DemoChannel",
        default_enabled=True,
    )
    monkeypatch.setattr(
        registry_module,
        "load_channel_plugin",
        lambda name: plugin if name == "demo" else (_ for _ in ()).throw(ImportError()),
    )

    assert channel_default_enabled("demo") is True
    assert channel_default_enabled("missing") is False


def test_websocket_manifest_declares_the_only_default_enabled_channel() -> None:
    enabled = {
        name
        for name in EXPECTED_CHANNELS
        if (plugin := load_channel_package(name)) is not None and plugin.default_enabled
    }

    assert enabled == {"websocket"}
