from __future__ import annotations

import asyncio
import sys
from dataclasses import dataclass
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from modules.im.base import BaseIMClient, BaseIMConfig, MessageContext
from modules.im.multi import MultiIMClient
from modules.settings_manager import MultiSettingsManager
from config.v2_sessions import ActivePollInfo
from core.processing_indicator import ProcessingIndicatorService
from modules.agents.base import AgentRequest
from modules.agents.opencode.agent import OpenCodeAgent
from modules.agents.opencode.poll_loop import OpenCodePollLoop


@dataclass
class _StubConfig(BaseIMConfig):
    def validate(self) -> None:
        return None


class _StubClient(BaseIMClient):
    def __init__(self, name: str, *, supports_editing: bool = True):
        super().__init__(_StubConfig())
        self.name = name
        self._supports_editing = supports_editing
        self.sent = []
        self.removed = []
        self.dismissed = []

    async def send_message(self, context, text, parse_mode=None, reply_to=None):
        self.sent.append((context.platform, context.channel_id, text))
        return self.name

    async def send_message_with_buttons(self, context, text, keyboard, parse_mode=None):
        return self.name

    async def edit_message(self, context, message_id, text=None, keyboard=None, parse_mode=None):
        return True

    def supports_message_editing(self, context=None):
        return self._supports_editing

    async def remove_inline_keyboard(self, context, message_id, text=None, parse_mode=None):
        self.removed.append((context.platform, message_id, text))
        return True

    async def dismiss_form_message(self, context):
        self.dismissed.append((context.platform, context.message_id))

    async def answer_callback(self, callback_id, text=None, show_alert=False):
        return True

    def register_handlers(self):
        return None

    def run(self):
        return None

    async def get_user_info(self, user_id: str):
        return {"id": user_id, "name": self.name}

    async def get_channel_info(self, channel_id: str):
        return {"id": channel_id, "name": self.name}

    async def send_dm(self, user_id: str, text: str, **kwargs):
        self.sent.append(("dm", user_id, text))
        return self.name

    async def download_file(self, file_info, max_bytes=None, timeout_seconds=30):
        self.sent.append(("download", file_info.get("platform"), file_info.get("name")))
        return b"data"

    async def download_file_to_path(self, file_info, target_path, max_bytes=None, timeout_seconds=30):
        self.sent.append(("download_to_path", file_info.get("platform"), target_path))
        from modules.im.base import FileDownloadResult

        return FileDownloadResult(True, target_path)

    async def clear_typing_indicator(self, context):
        self.sent.append(("clear_typing", context.platform, context.user_id, (context.platform_specific or {}).get("context_token")))
        return True

    async def delete_message(self, context, message_id):
        self.sent.append(("delete", context.platform, context.channel_id, message_id))
        return True

    def format_markdown(self, text: str) -> str:
        return text


def test_multi_settings_manager_routes_scoped_keys(tmp_path):
    manager = MultiSettingsManager(
        ["slack", "wechat"], settings_file=str(tmp_path / "settings.json"), primary_platform="slack"
    )

    manager.set_custom_cwd("wechat::user-1", "/tmp/wx")
    manager.set_custom_cwd("slack::C123", "/tmp/slack")

    assert manager.get_custom_cwd("wechat::user-1") == "/tmp/wx"
    assert manager.get_custom_cwd("slack::C123") == "/tmp/slack"
    assert manager.managers["slack"].sessions is manager.sessions
    assert manager.managers["wechat"].sessions is manager.sessions


def test_multi_im_client_routes_send_by_context_platform():
    slack = _StubClient("slack")
    wechat = _StubClient("wechat")
    client = MultiIMClient({"slack": slack, "wechat": wechat}, primary_platform="slack")

    asyncio.run(client.send_message(MessageContext(user_id="u", channel_id="c", platform="wechat"), "hello"))

    assert slack.sent == []
    assert wechat.sent == [("wechat", "c", "hello")]


def test_multi_im_client_routes_message_edit_capability_by_context_platform():
    slack = _StubClient("slack")
    wechat = _StubClient("wechat", supports_editing=False)
    client = MultiIMClient({"slack": slack, "wechat": wechat}, primary_platform="slack")

    assert client.supports_message_editing(MessageContext(user_id="u", channel_id="c", platform="slack"))
    assert not client.supports_message_editing(MessageContext(user_id="u", channel_id="c", platform="wechat"))


def test_multi_im_client_annotates_inbound_context_platform():
    slack = _StubClient("slack")
    wechat = _StubClient("wechat")
    client = MultiIMClient({"slack": slack, "wechat": wechat}, primary_platform="slack")
    captured: list[str | None] = []

    async def on_message(context: MessageContext, text: str):
        captured.append(context.platform)

    client.register_callbacks(on_message=on_message)

    callback = wechat.on_message_callback
    assert callback is not None
    asyncio.run(callback(MessageContext(user_id="u", channel_id="c"), "hello"))

    assert captured == ["wechat"]


def test_multi_im_client_routes_scoped_identity_lookups():
    slack = _StubClient("slack")
    wechat = _StubClient("wechat")
    client = MultiIMClient({"slack": slack, "wechat": wechat}, primary_platform="slack")

    user_info = asyncio.run(client.get_user_info("wechat::wx-user"))
    channel_info = asyncio.run(client.get_channel_info("wechat::wx-chat"))
    asyncio.run(client.send_dm("wechat::wx-user", "hello"))

    assert user_info == {"id": "wx-user", "name": "wechat"}
    assert channel_info == {"id": "wx-chat", "name": "wechat"}
    assert wechat.sent[-1] == ("dm", "wx-user", "hello")


def test_active_poll_info_round_trips_restored_typing_context():
    poll = ActivePollInfo(
        opencode_session_id="ses-1",
        base_session_id="base-1",
        channel_id="chan-1",
        thread_id="thread-1",
        settings_key="chan-1",
        working_path="/tmp/work",
        user_id="user-1",
        platform="wechat",
        typing_indicator_active=True,
        context_token="ctx-1",
        processing_indicator={
            "platform": "wechat",
            "user_id": "user-1",
            "channel_id": "chan-1",
            "thread_id": "thread-1",
            "context_token": "ctx-1",
            "typing_indicator_active": True,
        },
    )

    restored = ActivePollInfo.from_dict(poll.to_dict())

    assert restored.platform == "wechat"
    assert restored.typing_indicator_active is True
    assert restored.context_token == "ctx-1"
    assert restored.processing_indicator["context_token"] == "ctx-1"


def test_opencode_restored_ack_preserves_wechat_typing_context():
    captured = []
    wechat = _StubClient("wechat")

    class _StubAgent:
        def __init__(self):
            self.controller = type(
                "Controller",
                (),
                {
                    "config": type("Config", (), {"platform": "wechat", "ack_mode": "typing", "language": "en"})(),
                    "im_client": wechat,
                    "get_im_client_for_context": lambda self, context: wechat,
                },
            )()
            self.controller.processing_indicator = ProcessingIndicatorService(self.controller)

        async def _remove_ack_reaction(self, request):
            captured.append(request)
            await self.controller.processing_indicator.finish(request)

    poll = ActivePollInfo(
        opencode_session_id="ses-1",
        base_session_id="base-1",
        channel_id="chan-1",
        thread_id="thread-1",
        settings_key="chan-1",
        working_path="/tmp/work",
        user_id="user-1",
        platform="wechat",
        typing_indicator_active=True,
        context_token="ctx-1",
        processing_indicator={
            "platform": "wechat",
            "user_id": "user-1",
            "channel_id": "chan-1",
            "thread_id": "thread-1",
            "context_token": "ctx-1",
            "typing_indicator_active": True,
        },
    )
    loop = OpenCodePollLoop(_StubAgent())

    asyncio.run(loop.remove_restored_ack(poll))

    request = captured[0]
    assert request.typing_indicator_active is False
    assert request.context.platform == "wechat"
    assert request.context.platform_specific == {"platform": "wechat", "context_token": "ctx-1"}
    assert wechat.sent == [("clear_typing", "wechat", "user-1", "ctx-1")]


def test_opencode_prompt_disables_question_tool_for_all_platforms():
    calls = []

    class _Server:
        async def ensure_running(self):
            return None

        async def list_messages(self, session_id, directory):
            return []

        async def prompt_async(self, **kwargs):
            calls.append(kwargs)

        async def mark_run_active(self, session_id):
            return None

        async def mark_run_inactive(self, session_id):
            return None

        def get_default_agent_from_config(self):
            return None

        def get_agent_model_from_config(self, _agent):
            return None

        def get_agent_reasoning_effort_from_config(self, _agent):
            return None

    class _SessionManager:
        async def ensure_working_dir(self, path):
            return None

        async def get_or_create_session_id(self, request, server):
            return "oc-session"

        def set_request_session(self, *args):
            return None

        def mark_initialized(self, session_id):
            return False

    class _Sessions:
        def add_active_poll(self, **kwargs):
            return None

        def remove_active_poll(self, session_id):
            return None

    class _PollLoop:
        async def run_prompt_poll(self, *args, **kwargs):
            return "done", True

    async def _get_server():
        return _Server()

    async def _async_noop():
        return None

    class _Controller:
        def __init__(self):
            self.config = type(
                "Config",
                (),
                {
                    "platform": "slack",
                    "reply_enhancements": True,
                    "show_pages_prompt": True,
                    "remote_access": None,
                    "language": "en",
                    "opencode": type(
                        "OpenCodeConfig",
                        (),
                        {
                            "default_model": "gpt-5.4",
                            "default_provider": "openai",
                            "default_reasoning_effort": "high",
                        },
                    )(),
                },
            )()
            self.im_client = _StubClient("slack")
            self.settings_manager = type("Settings", (), {"sessions": _Sessions()})()
            self.sessions = self.settings_manager.sessions
            self.processing_indicator = type("Processing", (), {"snapshot_request": lambda self, request: {}})()

        def get_opencode_overrides(self, context):
            return None, None, None

    agent = OpenCodeAgent.__new__(OpenCodeAgent)
    agent.controller = _Controller()
    agent.config = agent.controller.config
    agent.im_client = agent.controller.im_client
    agent.settings_manager = agent.controller.settings_manager
    agent.sessions = agent.controller.sessions
    agent.opencode_config = type("OpenCodeConfig", (), {"error_retry_limit": 0})()
    agent._session_manager = _SessionManager()
    agent._poll_loop = _PollLoop()
    agent._get_server = _get_server
    agent._delete_ack = lambda request: _async_noop()
    agent._remove_ack_reaction = lambda request: _async_noop()
    agent.emit_result_message = lambda *args, **kwargs: _async_noop()

    async def _run():
        request = AgentRequest(
            context=MessageContext(
                user_id="u",
                channel_id="c",
                platform="slack",
                platform_specific={"agent_session_id": "ses_test"},
            ),
            message="hello",
            working_path="/tmp/work",
            base_session_id="base",
            composite_session_id="base:/tmp/work",
            session_key="slack::c",
        )
        await agent._process_message(request)

    asyncio.run(_run())

    assert calls
    assert calls[0]["tools"] == {"question": False}
    assert calls[0]["model"] == {"providerID": "openai", "modelID": "gpt-5.4"}
    assert calls[0]["reasoning_effort"] == "high"


def test_opencode_normal_text_matching_legacy_question_prefix_is_processed():
    processed = []

    class _Controller:
        def __init__(self):
            self.config = type("Config", (), {})()
            self.im_client = _StubClient("slack")
            self.settings_manager = type("Settings", (), {"sessions": object()})()

    class _SessionManager:
        def get_session_lock(self, base_session_id):
            return asyncio.Lock()

        def pop_request_session(self, base_session_id):
            return None

    agent = OpenCodeAgent.__new__(OpenCodeAgent)
    agent.controller = _Controller()
    agent.config = agent.controller.config
    agent.im_client = agent.controller.im_client
    agent.settings_manager = agent.controller.settings_manager
    agent._session_manager = _SessionManager()
    agent._active_requests = {}

    async def _process_message(request):
        processed.append(request.message)

    agent._process_message = _process_message

    async def _run():
        request = AgentRequest(
            context=MessageContext(user_id="u", channel_id="c", platform="slack"),
            message="opencode_question:choose:1",
            working_path="/tmp/work",
            base_session_id="base",
            composite_session_id="base:/tmp/work",
            session_key="slack::c",
        )
        await agent.handle_message(request)

    asyncio.run(_run())

    assert processed == ["opencode_question:choose:1"]


def test_opencode_process_message_removes_active_poll_when_question_tool_aborts():
    removed = []
    ack_removed = []

    class _Server:
        async def ensure_running(self):
            return None

        async def list_messages(self, session_id, directory):
            return []

        async def prompt_async(self, **kwargs):
            return None

        async def mark_run_active(self, session_id):
            return None

        async def mark_run_inactive(self, session_id):
            return None

        def get_default_agent_from_config(self):
            return None

        def get_agent_model_from_config(self, _agent):
            return None

        def get_agent_reasoning_effort_from_config(self, _agent):
            return None

    class _SessionManager:
        async def ensure_working_dir(self, path):
            return None

        async def get_or_create_session_id(self, request, server):
            return "oc-session"

        def set_request_session(self, *args):
            return None

        def mark_initialized(self, session_id):
            return False

    class _Sessions:
        def add_active_poll(self, **kwargs):
            return None

        def remove_active_poll(self, session_id):
            removed.append(session_id)

    class _PollLoop:
        async def run_prompt_poll(self, *args, **kwargs):
            return None, False

    class _Controller:
        def __init__(self):
            self.config = type(
                "Config",
                (),
                {
                    "platform": "slack",
                    "reply_enhancements": True,
                    "show_pages_prompt": True,
                    "remote_access": None,
                    "language": "en",
                },
            )()
            self.im_client = _StubClient("slack")
            self.settings_manager = type("Settings", (), {"sessions": _Sessions()})()
            self.sessions = self.settings_manager.sessions
            self.processing_indicator = type("Processing", (), {"snapshot_request": lambda self, request: {}})()

        def get_opencode_overrides(self, context):
            return None, None, None

    async def _get_server():
        return _Server()

    async def _async_noop():
        return None

    async def _remove_ack(request):
        ack_removed.append(request.base_session_id)

    agent = OpenCodeAgent.__new__(OpenCodeAgent)
    agent.controller = _Controller()
    agent.config = agent.controller.config
    agent.im_client = agent.controller.im_client
    agent.settings_manager = agent.controller.settings_manager
    agent.sessions = agent.controller.sessions
    agent.opencode_config = type("OpenCodeConfig", (), {"error_retry_limit": 0})()
    agent._session_manager = _SessionManager()
    agent._poll_loop = _PollLoop()
    agent._get_server = _get_server
    agent._delete_ack = lambda request: _async_noop()
    agent._remove_ack_reaction = _remove_ack

    request = AgentRequest(
        context=MessageContext(
            user_id="u",
            channel_id="c",
            platform="slack",
            platform_specific={"agent_session_id": "ses_test"},
        ),
        message="hello",
        working_path="/tmp/work",
        base_session_id="base",
        composite_session_id="base:/tmp/work",
        session_key="slack::c",
    )

    asyncio.run(agent._process_message(request))

    assert removed == ["oc-session"]
    assert ack_removed == ["base"]


def test_opencode_poll_aborts_disabled_question_toolcall():
    emitted = []
    aborted = []

    class _Formatter:
        def format_toolcall(self, *args, **kwargs):
            return "tool"

    class _Controller:
        def _t(self, key):
            return f"translated:{key}"

        async def emit_agent_message(self, context, message_type, text, parse_mode=None, *, is_error=False, level="normal"):
            emitted.append((message_type, text))

    class _Agent:
        opencode_config = type("OpenCodeConfig", (), {"error_retry_limit": 0})()
        controller = _Controller()
        im_client = type("IM", (), {"formatter": _Formatter()})()

        def _get_formatter(self, context):
            return _Formatter()

        def _to_relative_path(self, path, working_path):
            return path

        def _extract_response_text(self, message):
            return ""

    class _Server:
        async def list_messages(self, session_id, directory):
            return [
                {
                    "info": {"id": "msg-1", "role": "assistant"},
                    "parts": [
                        {
                            "type": "tool",
                            "id": "part-1",
                            "tool": "question",
                            "state": {"status": "pending", "input": {"questions": []}},
                        }
                    ],
                }
            ]

        async def abort_session(self, session_id, directory):
            aborted.append((session_id, directory))
            return True

    request = AgentRequest(
        context=MessageContext(user_id="u", channel_id="c", platform="slack"),
        message="hello",
        working_path="/tmp/work",
        base_session_id="base",
        composite_session_id="base:/tmp/work",
        session_key="slack::c",
    )

    loop = OpenCodePollLoop(_Agent())
    final_text, should_emit = asyncio.run(
        loop.run_prompt_poll(
            request,
            _Server(),
            "oc-session",
            agent_to_use=None,
            model_dict=None,
            reasoning_effort=None,
            baseline_message_ids=set(),
        )
    )

    assert final_text is None
    assert should_emit is False
    assert aborted == [("oc-session", "/tmp/work")]
    # A disabled-question abort is a terminal FAILURE → emitted as an error RESULT
    # (the outbound chokepoint turns the dot red), not a bare notify that never
    # settles the dot.
    assert emitted[0][0] == "result"
    assert emitted[0][1] == "translated:error.opencodeQuestionToolDisabled"


def test_opencode_poll_emits_error_result_on_retry_exhaustion():
    # A completed assistant message carrying an error, with retries exhausted
    # (error_retry_limit=0) and the auth-recovery path declining (non-auth error),
    # is a terminal FAILURE. It must (a) emit an ERROR result so the dot turns red
    # and (b) return should_emit=False so the caller does NOT then emit the idle
    # "(No response from OpenCode)" warning that would reset the dot to idle (Codex P2).
    emitted = []

    class _AuthSvc:
        async def maybe_emit_auth_recovery_message(self, context, backend, message):
            return False  # non-auth error → caller emits the terminal result itself

    class _Formatter:
        def format_toolcall(self, *args, **kwargs):
            return "tool"

    class _Controller:
        agent_auth_service = _AuthSvc()

        def _t(self, key):
            return f"translated:{key}"

        async def emit_agent_message(self, context, message_type, text, parse_mode=None, *, is_error=False, level="normal"):
            emitted.append((message_type, is_error))

    class _Agent:
        opencode_config = type("OpenCodeConfig", (), {"error_retry_limit": 0})()
        controller = _Controller()
        im_client = type("IM", (), {"formatter": _Formatter()})()

        def _get_formatter(self, context):
            return _Formatter()

        def _to_relative_path(self, path, working_path):
            return path

        def _extract_response_text(self, message):
            return ""

    class _Server:
        async def list_messages(self, session_id, directory):
            return [
                {
                    "info": {
                        "id": "msg-err",
                        "role": "assistant",
                        "time": {"completed": 1},
                        "error": {"name": "ProviderError", "data": {"message": "rate limited"}},
                    },
                    "parts": [],
                }
            ]

    request = AgentRequest(
        context=MessageContext(user_id="u", channel_id="c", platform="slack"),
        message="hello",
        working_path="/tmp/work",
        base_session_id="base",
        composite_session_id="base:/tmp/work",
        session_key="slack::c",
    )

    loop = OpenCodePollLoop(_Agent())
    final_text, should_emit = asyncio.run(
        loop.run_prompt_poll(
            request,
            _Server(),
            "oc-session",
            agent_to_use=None,
            model_dict=None,
            reasoning_effort=None,
            baseline_message_ids=set(),
        )
    )

    assert final_text is None
    # should_emit False → caller skips the idle "(No response)" warning that would
    # otherwise reset the dot we just turned red.
    assert should_emit is False
    assert ("result", True) in emitted
    assert not any(mtype == "notify" for mtype, _ in emitted)


def test_processing_indicator_handle_is_source_of_truth_for_backend_cleanup():
    wechat = _StubClient("wechat")

    class _Controller:
        def __init__(self):
            self.config = type("Config", (), {"platform": "wechat", "ack_mode": "typing", "language": "en"})()
            self.im_client = wechat
            self.settings_manager = type("Settings", (), {})()
            self.processing_indicator = ProcessingIndicatorService(self)

        def get_im_client_for_context(self, context):
            return wechat

    controller = _Controller()
    handle = controller.processing_indicator.handle_from_snapshot(
        {
            "platform": "wechat",
            "user_id": "user-1",
            "channel_id": "chan-1",
            "context_token": "ctx-1",
            "typing_indicator_active": True,
        }
    )
    request = type(
        "Request",
        (),
        {
            "context": handle.context,
            "ack_message_id": None,
            "ack_reaction_message_id": None,
            "ack_reaction_emoji": None,
            "typing_indicator_active": False,
            "typing_indicator_task": None,
            "processing_indicator": handle,
        },
    )()

    asyncio.run(controller.processing_indicator.finish(request))

    assert request.typing_indicator_active is False
    assert handle.typing_indicator_active is False
    assert wechat.sent == [("clear_typing", "wechat", "user-1", "ctx-1")]


def test_processing_indicator_clear_policy_comes_from_platform_registry():
    slack = _StubClient("slack")

    class _Controller:
        def __init__(self):
            self.config = type("Config", (), {"platform": "slack", "ack_mode": "typing", "language": "en"})()
            self.im_client = slack

        def get_im_client_for_context(self, context):
            return slack

    controller = _Controller()
    service = ProcessingIndicatorService(controller)
    handle = service.handle_from_snapshot(
        {
            "platform": "slack",
            "user_id": "user-1",
            "channel_id": "chan-1",
            "typing_indicator_active": True,
        }
    )

    asyncio.run(service.finish(handle))

    assert handle.typing_indicator_active is False
    assert slack.sent == []


def test_processing_indicator_message_delete_policy_comes_from_platform_registry():
    telegram = _StubClient("telegram")

    class _Controller:
        def __init__(self):
            self.config = type("Config", (), {"platform": "telegram", "ack_mode": "message", "language": "en"})()
            self.im_client = telegram

        def get_im_client_for_context(self, context):
            return telegram

    service = ProcessingIndicatorService(_Controller())
    handle = service.handle_from_snapshot(
        {
            "platform": "telegram",
            "user_id": "user-1",
            "channel_id": "chat-1",
            "ack_message_id": "ack-1",
            "ack_message_channel_id": "chat-1",
        }
    )
    request = type("Request", (), {"context": handle.context, "ack_message_id": "ack-1", "processing_indicator": handle})()

    asyncio.run(service.delete_ack_message(request))

    assert request.ack_message_id is None
    assert handle.ack_message_id is None
    assert telegram.sent == [("delete", "telegram", "chat-1", "ack-1")]


def test_multi_im_client_routes_download_by_file_info_platform():
    slack = _StubClient("slack")
    wechat = _StubClient("wechat")
    client = MultiIMClient({"slack": slack, "wechat": wechat}, primary_platform="slack")

    asyncio.run(client.download_file_to_path({"platform": "wechat", "name": "a.jpg"}, "/tmp/a.jpg"))

    assert slack.sent == []
    assert wechat.sent == [("download_to_path", "wechat", "/tmp/a.jpg")]


def test_multi_im_client_routes_remove_inline_keyboard_by_context_platform():
    slack = _StubClient("slack")
    lark = _StubClient("lark")
    client = MultiIMClient({"slack": slack, "lark": lark}, primary_platform="slack")

    asyncio.run(
        client.remove_inline_keyboard(
            MessageContext(user_id="u", channel_id="c", platform="lark"),
            "om_123",
        )
    )

    assert slack.removed == []
    assert lark.removed == [("lark", "om_123", None)]


def test_multi_im_client_routes_dismiss_form_message_by_context_platform():
    slack = _StubClient("slack")
    lark = _StubClient("lark")
    client = MultiIMClient({"slack": slack, "lark": lark}, primary_platform="slack")

    asyncio.run(
        client.dismiss_form_message(
            MessageContext(user_id="u", channel_id="c", platform="lark", message_id="om_456")
        )
    )

    assert slack.dismissed == []
    assert lark.dismissed == [("lark", "om_456")]


def test_multi_im_client_on_ready_fires_only_after_all_platforms():
    """on_ready callback must wait for all platform clients to be ready."""
    slack = _StubClient("slack")
    wechat = _StubClient("wechat")
    client = MultiIMClient({"slack": slack, "wechat": wechat}, primary_platform="slack")

    ready_calls: list[bool] = []

    async def _on_ready():
        ready_calls.append(True)

    client.register_callbacks(on_message=None, on_ready=_on_ready)

    # Simulate only Slack becoming ready — on_ready should NOT fire yet
    slack_on_ready = slack.on_ready_callback
    assert slack_on_ready is not None
    asyncio.run(slack_on_ready())
    assert ready_calls == [], "on_ready fired before all platforms were ready"

    # Now simulate WeChat becoming ready — on_ready should fire exactly once
    wechat_on_ready = wechat.on_ready_callback
    assert wechat_on_ready is not None
    asyncio.run(wechat_on_ready())
    assert ready_calls == [True], "on_ready should fire exactly once after all platforms are ready"

    # Calling again should not fire a second time
    asyncio.run(wechat_on_ready())
    assert len(ready_calls) == 1, "on_ready should not fire more than once"
