from __future__ import annotations

import asyncio
import contextlib
import dataclasses
import functools
import json
import re
import time
import traceback
import uuid
import sqlalchemy

from ..core import app, entities as core_entities, taskmgr

from ..discover import engine

from ..entity.persistence import bot as persistence_bot
from ..entity.persistence import pipeline as persistence_pipeline
from ..entity.persistence import workspace as persistence_workspace

from ..entity.errors import platform as platform_errors
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
from ..api.http.authz import WorkspaceRequiredError
from ..workspace.errors import WorkspaceInvariantError

from .logger import EventLogger

import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter


class RuntimeBot:
    """运行时机器人"""

    ap: app.Application

    bot_entity: persistence_bot.Bot

    enable: bool

    adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter

    task_wrapper: taskmgr.TaskWrapper | None

    task_context: taskmgr.TaskContext

    logger: EventLogger

    execution_context: ExecutionContext

    workspace_uuid: str

    placement_generation: int

    def __init__(
        self,
        ap: app.Application,
        bot_entity: persistence_bot.Bot,
        adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
        logger: EventLogger,
        execution_context: ExecutionContext,
    ):
        if not isinstance(execution_context, ExecutionContext):
            raise WorkspaceRequiredError('RuntimeBot requires an ExecutionContext')
        if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
            raise WorkspaceRequiredError('RuntimeBot requires an instance and Workspace')
        if execution_context.placement_generation <= 0:
            raise WorkspaceRequiredError('RuntimeBot requires a positive placement generation')
        entity_workspace_uuid = getattr(bot_entity, 'workspace_uuid', None)
        if entity_workspace_uuid != execution_context.workspace_uuid:
            raise WorkspaceRequiredError('RuntimeBot entity Workspace does not match its ExecutionContext')
        if execution_context.bot_uuid not in (None, bot_entity.uuid):
            raise WorkspaceRequiredError('RuntimeBot bot UUID does not match its ExecutionContext')

        self.ap = ap
        self.bot_entity = bot_entity
        self.execution_context = dataclasses.replace(execution_context, bot_uuid=bot_entity.uuid)
        self.workspace_uuid = self.execution_context.workspace_uuid
        self.placement_generation = self.execution_context.placement_generation
        self.enable = bot_entity.enable
        self.adapter = adapter
        self.task_context = taskmgr.TaskContext()
        self.task_wrapper = None
        self.logger = logger
        self._shutdown_lock = asyncio.Lock()
        self._shutdown_complete = False

    async def assert_execution_active(self) -> None:
        """Fail closed when this long-lived adapter belongs to a stale placement."""

        await self.ap.workspace_service.get_execution_binding(
            self.workspace_uuid,
            expected_generation=self.placement_generation,
        )

    @staticmethod
    def _match_operator(actual: str, operator: str, expected: str) -> bool:
        """Evaluate a single operator condition."""
        if operator == 'eq':
            return actual == expected
        elif operator == 'neq':
            return actual != expected
        elif operator == 'contains':
            return expected in actual
        elif operator == 'not_contains':
            return expected not in actual
        elif operator == 'starts_with':
            return actual.startswith(expected)
        elif operator == 'regex':
            try:
                return bool(re.search(expected, actual))
            except re.error:
                return False
        return False

    PIPELINE_DISCARD = '__discard__'
    PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'

    def resolve_pipeline_uuid(
        self,
        launcher_type: str,
        launcher_id: str,
        message_text: str,
        message_element_types: list[str] | None = None,
    ) -> tuple[str | None, bool]:
        """Resolve pipeline UUID based on routing rules.

        Rules are evaluated in order; first match wins.
        Falls back to use_pipeline_uuid if no rule matches.

        Rule types:
          - launcher_type: session type ("person" / "group")
          - launcher_id: session / group id
          - message_content: message text content
          - message_has_element: message contains element of given type
            (Image, Voice, File, Forward, Face, At, AtAll, Quote)
            Operators: eq (has), neq (doesn't have)

        Operators: eq, neq, contains, not_contains, starts_with, regex

        When pipeline_uuid is ``__discard__``, the message should be
        silently dropped by the caller.

        Returns:
            tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
            when a routing rule matched, False when falling back to default.
        """
        rules = self.bot_entity.pipeline_routing_rules or []
        element_type_set = set(message_element_types or [])

        for rule in rules:
            rule_type = rule.get('type')
            operator = rule.get('operator', 'eq')
            rule_value = rule.get('value', '')
            target_uuid = rule.get('pipeline_uuid')
            if not rule_type or not target_uuid:
                continue

            if rule_type == 'launcher_type':
                if self._match_operator(launcher_type, operator, rule_value):
                    return target_uuid, True
            elif rule_type == 'launcher_id':
                if self._match_operator(str(launcher_id), operator, str(rule_value)):
                    return target_uuid, True
            elif rule_type == 'message_content':
                if self._match_operator(message_text, operator, rule_value):
                    return target_uuid, True
            elif rule_type == 'message_has_element':
                has_element = rule_value in element_type_set
                if operator == 'eq' and has_element:
                    return target_uuid, True
                elif operator == 'neq' and not has_element:
                    return target_uuid, True

        return self.bot_entity.use_pipeline_uuid, False

    def resolve_event_pipeline_uuid(
        self,
        adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
        launcher_type: str,
        launcher_id: str,
        message_text: str,
        message_element_types: list[str] | None = None,
    ) -> tuple[str | None, bool]:
        """Resolve a pipeline, honoring a trusted per-task adapter override."""

        get_override = getattr(adapter, 'get_pipeline_uuid_override', None)
        if callable(get_override):
            override = get_override()
            if override:
                return str(override), False
        return self.resolve_pipeline_uuid(
            launcher_type,
            launcher_id,
            message_text,
            message_element_types,
        )

    async def _record_discarded_message(
        self,
        launcher_type: provider_session.LauncherTypes,
        launcher_id: str | int,
        sender_id: str | int,
        message_event: platform_events.MessageEvent,
        message_chain: platform_message.MessageChain,
    ) -> None:
        """Record a discarded message in the monitoring system."""
        try:
            if hasattr(message_chain, 'model_dump'):
                message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
            else:
                message_content = str(message_chain)

            sender_name = None
            if hasattr(message_event, 'sender'):
                if hasattr(message_event.sender, 'nickname'):
                    sender_name = message_event.sender.nickname
                elif hasattr(message_event.sender, 'member_name'):
                    sender_name = message_event.sender.member_name

            # Use the same session_id format as monitoring_helper.py
            session_id = f'{launcher_type}_{launcher_id}'
            platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)

            await self.ap.monitoring_service.record_message(
                self.execution_context,
                bot_id=self.bot_entity.uuid,
                bot_name=self.bot_entity.name or self.bot_entity.uuid,
                pipeline_id=self.PIPELINE_DISCARD,
                pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
                message_content=message_content,
                session_id=session_id,
                status='discarded',
                level='info',
                platform=platform,
                user_id=str(sender_id),
                user_name=sender_name,
            )

            # Ensure the session exists so the message appears in the session monitor.
            # Don't overwrite pipeline info — a session may have messages from
            # multiple pipelines; discarding shouldn't change the displayed pipeline.
            session_updated = await self.ap.monitoring_service.update_session_activity(
                self.execution_context,
                session_id,
            )
            if not session_updated:
                # No session yet (first message for this launcher was discarded).
                await self.ap.monitoring_service.record_session_start(
                    self.execution_context,
                    session_id=session_id,
                    bot_id=self.bot_entity.uuid,
                    bot_name=self.bot_entity.name or self.bot_entity.uuid,
                    pipeline_id=self.PIPELINE_DISCARD,
                    pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
                    platform=platform,
                    user_id=str(sender_id),
                    user_name=sender_name,
                )
        except Exception as e:
            await self.logger.error(f'Failed to record discarded message: {e}')

    async def initialize(self):
        def tenant_scoped_listener(listener):
            """Bind adapter callbacks to a Workspace without holding a DB transaction."""

            @functools.wraps(listener)
            async def wrapped(*args, **kwargs):
                tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
                cloud_runtime = (
                    getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
                )
                if cloud_runtime:
                    if not callable(tenant_scope):
                        raise RuntimeError('Cloud platform callbacks require an explicit tenant scope')
                    async with tenant_scope(self.workspace_uuid):
                        return await listener(*args, **kwargs)
                return await listener(*args, **kwargs)

            return wrapped

        async def on_friend_message(
            event: platform_events.FriendMessage,
            adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
        ):
            await self.assert_execution_active()
            image_components = [
                component for component in event.message_chain if isinstance(component, platform_message.Image)
            ]

            await self.logger.info(
                f'{event.message_chain}',
                images=image_components,
                message_session_id=f'person_{event.sender.id}',
            )

            # Push to webhooks and check if pipeline should be skipped
            skip_pipeline = False
            if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
                skip_pipeline = await self.ap.webhook_pusher.push_person_message(
                    self.execution_context,
                    event,
                    self.bot_entity.uuid,
                    adapter.__class__.__name__,
                )

            # Only add to query pool if no webhook requested to skip pipeline
            if not skip_pipeline:
                launcher_id = event.sender.id

                if hasattr(adapter, 'get_launcher_id'):
                    custom_launcher_id = adapter.get_launcher_id(event)
                    if custom_launcher_id:
                        launcher_id = custom_launcher_id

                message_text = str(event.message_chain)
                element_types = [comp.type for comp in event.message_chain]
                pipeline_uuid, routed_by_rule = self.resolve_event_pipeline_uuid(
                    adapter,
                    'person',
                    launcher_id,
                    message_text,
                    element_types,
                )

                if pipeline_uuid == self.PIPELINE_DISCARD:
                    await self.logger.info('Person message discarded by routing rule')
                    await self._record_discarded_message(
                        provider_session.LauncherTypes.PERSON,
                        launcher_id,
                        event.sender.id,
                        event,
                        event.message_chain,
                    )
                    return

                await self.ap.msg_aggregator.add_message(
                    bot_uuid=self.bot_entity.uuid,
                    launcher_type=provider_session.LauncherTypes.PERSON,
                    launcher_id=launcher_id,
                    sender_id=event.sender.id,
                    message_event=event,
                    message_chain=event.message_chain,
                    adapter=adapter,
                    pipeline_uuid=pipeline_uuid,
                    routed_by_rule=routed_by_rule,
                    execution_context=self.execution_context,
                )
            else:
                await self.logger.info('Pipeline skipped for person message due to webhook response')

        async def on_group_message(
            event: platform_events.GroupMessage,
            adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
        ):
            await self.assert_execution_active()
            image_components = [
                component for component in event.message_chain if isinstance(component, platform_message.Image)
            ]

            await self.logger.info(
                f'{event.message_chain}',
                images=image_components,
                message_session_id=f'group_{event.group.id}',
            )

            # Push to webhooks and check if pipeline should be skipped
            skip_pipeline = False
            if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
                skip_pipeline = await self.ap.webhook_pusher.push_group_message(
                    self.execution_context,
                    event,
                    self.bot_entity.uuid,
                    adapter.__class__.__name__,
                )

            # Only add to query pool if no webhook requested to skip pipeline
            if not skip_pipeline:
                launcher_id = event.group.id

                if hasattr(adapter, 'get_launcher_id'):
                    custom_launcher_id = adapter.get_launcher_id(event)
                    if custom_launcher_id:
                        launcher_id = custom_launcher_id

                message_text = str(event.message_chain)
                element_types = [comp.type for comp in event.message_chain]
                pipeline_uuid, routed_by_rule = self.resolve_event_pipeline_uuid(
                    adapter,
                    'group',
                    launcher_id,
                    message_text,
                    element_types,
                )

                if pipeline_uuid == self.PIPELINE_DISCARD:
                    await self.logger.info('Group message discarded by routing rule')
                    await self._record_discarded_message(
                        provider_session.LauncherTypes.GROUP,
                        launcher_id,
                        event.sender.id,
                        event,
                        event.message_chain,
                    )
                    return

                await self.ap.msg_aggregator.add_message(
                    bot_uuid=self.bot_entity.uuid,
                    launcher_type=provider_session.LauncherTypes.GROUP,
                    launcher_id=launcher_id,
                    sender_id=event.sender.id,
                    message_event=event,
                    message_chain=event.message_chain,
                    adapter=adapter,
                    pipeline_uuid=pipeline_uuid,
                    routed_by_rule=routed_by_rule,
                    execution_context=self.execution_context,
                )
            else:
                await self.logger.info('Pipeline skipped for group message due to webhook response')

        self.adapter.register_listener(platform_events.FriendMessage, tenant_scoped_listener(on_friend_message))
        self.adapter.register_listener(platform_events.GroupMessage, tenant_scoped_listener(on_group_message))

        # Register feedback listener (only effective on adapters that support it)
        async def on_feedback(
            event: platform_events.FeedbackEvent,
            adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
        ):
            try:
                await self.assert_execution_active()
                # Resolve pipeline name
                pipeline_name = ''
                if self.bot_entity.use_pipeline_uuid:
                    try:
                        pipeline_result = await self.ap.persistence_mgr.execute_async(
                            sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
                                persistence_pipeline.LegacyPipeline.workspace_uuid == self.workspace_uuid,
                                persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid,
                            )
                        )
                        pipeline_row = pipeline_result.first()
                        if pipeline_row:
                            pipeline_name = pipeline_row[0]
                    except Exception:
                        pass

                await self.ap.monitoring_service.record_feedback(
                    self.execution_context,
                    feedback_id=event.feedback_id,
                    feedback_type=event.feedback_type,
                    feedback_content=event.feedback_content,
                    inaccurate_reasons=event.inaccurate_reasons,
                    bot_id=self.bot_entity.uuid,
                    bot_name=self.bot_entity.name,
                    pipeline_id=self.bot_entity.use_pipeline_uuid or '',
                    pipeline_name=pipeline_name,
                    session_id=event.session_id,
                    message_id=event.message_id,
                    stream_id=event.stream_id,
                    user_id=event.user_id,
                    platform=adapter.__class__.__name__,
                )
                await self.logger.info(
                    f'Recorded feedback: feedback_id={event.feedback_id}, type={event.feedback_type}'
                )
            except Exception:
                await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')

        self.adapter.register_listener(platform_events.FeedbackEvent, tenant_scoped_listener(on_feedback))

    async def run(self):
        async def exception_wrapper():
            try:
                self.task_context.set_current_action('Running...')
                await self.adapter.run_async()
                self.task_context.set_current_action('Exited.')
            except Exception as e:
                if isinstance(e, asyncio.CancelledError):
                    self.task_context.set_current_action('Exited.')
                    return

                traceback_str = traceback.format_exc()
                self.task_context.set_current_action('Exited with error.')
                await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback_str}')

        self.task_wrapper = self.ap.task_mgr.create_task(
            exception_wrapper(),
            kind='platform-adapter',
            name=f'platform-adapter-{self.adapter.__class__.__name__}',
            context=self.task_context,
            scopes=[
                core_entities.LifecycleControlScope.APPLICATION,
                core_entities.LifecycleControlScope.PLATFORM,
            ],
            instance_uuid=self.execution_context.instance_uuid,
            workspace_uuid=self.execution_context.workspace_uuid,
            placement_generation=(self.execution_context.placement_generation),
        )

    async def shutdown(self):
        async with self._shutdown_lock:
            if self._shutdown_complete:
                return

            wrapper = self.task_wrapper
            self.task_wrapper = None
            try:
                await asyncio.wait_for(self.adapter.kill(), timeout=15)
            finally:
                if wrapper is not None:
                    self.ap.task_mgr.cancel_task(wrapper.id)
                    if wrapper.task is not asyncio.current_task():
                        with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError):
                            await asyncio.wait_for(wrapper.task, timeout=5)
            self._shutdown_complete = True


# 控制QQ消息输入输出的类
class PlatformManager:
    # ====== 4.0 ======
    ap: app.Application = None

    bots: list[RuntimeBot]

    websocket_proxy_bots: dict[str, RuntimeBot]

    adapter_components: list[engine.Component]

    adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]]

    def __init__(self, ap: app.Application = None):
        self.ap = ap
        self._bots_by_key: dict[tuple[str, str, str], RuntimeBot] = {}
        self._bot_keys_by_workspace: dict[
            str,
            set[tuple[str, str, str]],
        ] = {}
        self._bot_keys_by_uuid: dict[str, set[tuple[str, str, str]]] = {}
        self.websocket_proxy_bots = {}
        self.adapter_components = []
        self.adapter_dict = {}
        self._scope_generations: dict[tuple[str, str], int] = {}
        self._proxy_last_accessed: dict[str, float] = {}
        self._runtime_mutation_lock = asyncio.Lock()

    @staticmethod
    def _runtime_bot_key(bot: RuntimeBot) -> tuple[str, str, str]:
        context = getattr(bot, 'execution_context', None)
        instance_uuid = str(getattr(context, 'instance_uuid', '__test_instance__'))
        workspace_uuid = str(
            getattr(bot, 'workspace_uuid', None) or getattr(context, 'workspace_uuid', '__test_workspace__')
        )
        bot_uuid = str(
            getattr(getattr(bot, 'bot_entity', None), 'uuid', None)
            or getattr(context, 'bot_uuid', None)
            or f'__runtime_{id(bot)}'
        )
        return instance_uuid, workspace_uuid, bot_uuid

    def _register_runtime_bot(self, bot: RuntimeBot) -> RuntimeBot | None:
        key = self._runtime_bot_key(bot)
        previous = self._bots_by_key.get(key)
        self._bots_by_key[key] = bot
        self._bot_keys_by_workspace.setdefault(key[1], set()).add(key)
        self._bot_keys_by_uuid.setdefault(key[2], set()).add(key)
        return previous

    def _pop_runtime_bot(
        self,
        key: tuple[str, str, str],
    ) -> RuntimeBot | None:
        bot = self._bots_by_key.pop(key, None)
        if bot is None:
            return None
        workspace_keys = self._bot_keys_by_workspace.get(key[1])
        if workspace_keys is not None:
            workspace_keys.discard(key)
            if not workspace_keys:
                self._bot_keys_by_workspace.pop(key[1], None)
        uuid_keys = self._bot_keys_by_uuid.get(key[2])
        if uuid_keys is not None:
            uuid_keys.discard(key)
            if not uuid_keys:
                self._bot_keys_by_uuid.pop(key[2], None)
        return bot

    @property
    def bots(self) -> list[RuntimeBot]:
        """Compatibility view over the indexed platform runtime registry."""

        return list(self._bots_by_key.values())

    @bots.setter
    def bots(self, bots: list[RuntimeBot]) -> None:
        self._bots_by_key = {}
        self._bot_keys_by_workspace = {}
        self._bot_keys_by_uuid = {}
        for bot in bots:
            self._register_runtime_bot(bot)

    def _max_workspace_proxies(self) -> int:
        instance_data = getattr(
            getattr(self.ap, 'instance_config', None),
            'data',
            {},
        )
        value = instance_data.get('system', {}).get('websocket_retention', {}).get('max_workspace_proxies', 1024)
        try:
            return max(int(value), 1)
        except (TypeError, ValueError):
            return 1024

    async def _evict_idle_websocket_proxy_unlocked(self) -> None:
        """Make room without interrupting a live socket or in-flight query."""

        if len(self.websocket_proxy_bots) < self._max_workspace_proxies():
            return
        from .sources.websocket_manager import WebSocketScope, ws_connection_manager

        for workspace_uuid in sorted(
            self.websocket_proxy_bots,
            key=lambda item: self._proxy_last_accessed.get(item, 0.0),
        ):
            proxy_bot = self.websocket_proxy_bots[workspace_uuid]
            listener_tasks = getattr(proxy_bot.adapter, 'inbound_listener_tasks', ())
            if any(not task.done() for task in tuple(listener_tasks)):
                continue
            scope = WebSocketScope.from_context(proxy_bot.execution_context)
            if ws_connection_manager.get_stats(scope=scope)['total_connections'] > 0:
                continue
            self.websocket_proxy_bots.pop(workspace_uuid, None)
            self._proxy_last_accessed.pop(workspace_uuid, None)
            await proxy_bot.shutdown()
            if not self._bot_keys_by_workspace.get(workspace_uuid):
                self._scope_generations.pop(
                    (proxy_bot.execution_context.instance_uuid, workspace_uuid),
                    None,
                )
            return
        raise RuntimeError('WebSocket Workspace proxy capacity reached and every proxy is active')

    async def _observe_execution_context(self, context: ExecutionContext) -> None:
        """Shutdown superseded Workspace adapters when placement advances."""

        async with self._runtime_mutation_lock:
            await self._observe_execution_context_unlocked(context)

    async def _observe_execution_context_unlocked(self, context: ExecutionContext) -> None:
        scope = (context.instance_uuid, context.workspace_uuid)
        previous_generation = self._scope_generations.get(scope)
        if previous_generation is not None and context.placement_generation < previous_generation:
            raise WorkspaceInvariantError('Platform runtime placement generation rolled back')
        if previous_generation == context.placement_generation:
            return
        if previous_generation is not None:
            proxy_bot = self.websocket_proxy_bots.pop(context.workspace_uuid, None)
            self._proxy_last_accessed.pop(context.workspace_uuid, None)
            if proxy_bot is not None and proxy_bot.enable:
                await proxy_bot.shutdown()
            for key in tuple(self._bot_keys_by_workspace.get(context.workspace_uuid, ())):
                bot = self._pop_runtime_bot(key)
                if bot is None:
                    continue
                if bot.enable:
                    await bot.shutdown()
        self._scope_generations[scope] = context.placement_generation

    async def initialize(self):
        # delete all bot log images
        await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')

        disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []

        self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
        adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
        for component in self.adapter_components:
            if component.metadata.name in disabled_adapters:
                continue
            adapter_dict[component.metadata.name] = component.get_python_component_class()
        self.adapter_dict = adapter_dict

        # Filter out disabled adapters from components list (for API responses)
        if disabled_adapters:
            self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]

        await self.load_bots_from_db()

        # OSS may have no persisted bots.  Its singleton Workspace still needs
        # a debug WebSocket proxy.  SaaS creates proxies lazily from an explicit
        # request/runtime context instead of guessing among Workspaces.
        if not self.websocket_proxy_bots:
            try:
                binding = await self.ap.workspace_service.get_execution_binding()
            except Exception:
                pass
            else:
                await self.get_websocket_proxy_bot(
                    ExecutionContext(
                        instance_uuid=binding.instance_uuid,
                        workspace_uuid=binding.workspace_uuid,
                        placement_generation=binding.placement_generation,
                        trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
                    )
                )

    @property
    def websocket_proxy_bot(self) -> RuntimeBot:
        """Compatibility accessor that is safe only for a singleton Workspace."""

        if len(self.websocket_proxy_bots) != 1:
            raise WorkspaceRequiredError('An explicit Workspace is required for the WebSocket proxy bot')
        return next(iter(self.websocket_proxy_bots.values()))

    @websocket_proxy_bot.setter
    def websocket_proxy_bot(self, runtime_bot: RuntimeBot) -> None:
        """Keep isolated tests that inject one proxy bot working."""

        workspace_uuid = getattr(runtime_bot, 'workspace_uuid', '__test_singleton__')
        self.websocket_proxy_bots = {workspace_uuid: runtime_bot}

    @staticmethod
    def _normalize_execution_context(
        context: ExecutionContext | RequestContext,
        *,
        bot_uuid: str | None = None,
        pipeline_uuid: str | None = None,
    ) -> ExecutionContext:
        if isinstance(context, RequestContext):
            return ExecutionContext.from_request(
                context,
                bot_uuid=bot_uuid,
                pipeline_uuid=pipeline_uuid,
            )
        if not isinstance(context, ExecutionContext):
            raise WorkspaceRequiredError('Runtime operations require an ExecutionContext')
        if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
            raise WorkspaceRequiredError('Runtime operations require an instance and Workspace')
        if context.placement_generation <= 0:
            raise WorkspaceRequiredError('Runtime operations require a positive placement generation')
        updates = {}
        if bot_uuid is not None:
            if context.bot_uuid not in (None, bot_uuid):
                raise WorkspaceRequiredError('Runtime bot UUID does not match its ExecutionContext')
            updates['bot_uuid'] = bot_uuid
        if pipeline_uuid is not None:
            if context.pipeline_uuid not in (None, pipeline_uuid):
                raise WorkspaceRequiredError('Runtime pipeline UUID does not match its ExecutionContext')
            updates['pipeline_uuid'] = pipeline_uuid
        return dataclasses.replace(context, **updates) if updates else context

    async def get_websocket_proxy_bot(
        self,
        context: ExecutionContext | RequestContext,
    ) -> RuntimeBot:
        execution_context = self._normalize_execution_context(context)
        await self.ap.workspace_service.get_execution_binding(
            execution_context.workspace_uuid,
            expected_generation=execution_context.placement_generation,
        )
        async with self._runtime_mutation_lock:
            await self.ap.workspace_service.get_execution_binding(
                execution_context.workspace_uuid,
                expected_generation=execution_context.placement_generation,
            )
            await self._observe_execution_context_unlocked(execution_context)
            existing = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
            if existing is not None:
                if existing.placement_generation != execution_context.placement_generation:
                    raise WorkspaceRequiredError('WebSocket proxy placement generation is stale')
                self._proxy_last_accessed[execution_context.workspace_uuid] = time.monotonic()
                return existing

            await self._evict_idle_websocket_proxy_unlocked()
            binding = await self.ap.workspace_service.get_execution_binding(
                execution_context.workspace_uuid,
                expected_generation=execution_context.placement_generation,
            )
            websocket_adapter_class = self.adapter_dict['websocket']
            websocket_logger = EventLogger(
                name='websocket-adapter',
                ap=self.ap,
                execution_context=execution_context,
                owner='websocket-proxy-bot',
            )
            websocket_adapter_inst = websocket_adapter_class({}, websocket_logger, ap=self.ap)
            proxy_context = dataclasses.replace(
                execution_context,
                instance_uuid=binding.instance_uuid,
                bot_uuid='websocket-proxy-bot',
            )
            runtime_bot = RuntimeBot(
                ap=self.ap,
                bot_entity=persistence_bot.Bot(
                    uuid='websocket-proxy-bot',
                    workspace_uuid=binding.workspace_uuid,
                    name='WebSocket',
                    description='',
                    adapter='websocket',
                    adapter_config={},
                    enable=True,
                ),
                adapter=websocket_adapter_inst,
                logger=websocket_logger,
                execution_context=proxy_context,
            )
            await runtime_bot.initialize()
            self.websocket_proxy_bots[binding.workspace_uuid] = runtime_bot
            self._proxy_last_accessed[binding.workspace_uuid] = time.monotonic()
            return runtime_bot

    def get_running_adapters(
        self,
        context: ExecutionContext | RequestContext,
    ) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
        execution_context = self._normalize_execution_context(context)
        return [
            bot.adapter
            for bot in self.bots
            if bot.enable
            and bot.workspace_uuid == execution_context.workspace_uuid
            and bot.placement_generation == execution_context.placement_generation
        ]

    async def load_bots_from_db(self):
        self.ap.logger.info('Loading bots from db...')

        async with self._runtime_mutation_lock:
            old_bots = [*self.websocket_proxy_bots.values(), *self.bots]
            self.websocket_proxy_bots = {}
            self._proxy_last_accessed = {}
            self.bots = []
            self._scope_generations = {}
            for bot in old_bots:
                if not bot.enable:
                    continue
                try:
                    await bot.shutdown()
                except Exception as exc:
                    self.ap.logger.warning(f'Failed to stop old platform runtime during reload: {exc}')

        list_bindings = getattr(
            self.ap.workspace_service,
            'list_active_execution_bindings',
            None,
        )
        tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
        cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
        if cloud_runtime:
            if not callable(list_bindings) or not callable(tenant_uow):
                raise RuntimeError('Cloud platform loading requires explicit instance discovery and tenant UoWs')
            for binding in await list_bindings():
                try:
                    async with tenant_uow(binding.workspace_uuid):
                        await self._load_workspace_bots(
                            binding.workspace_uuid,
                            _binding=binding,
                        )
                except Exception as exc:
                    self.ap.logger.error(
                        f'Failed to load Workspace bots for {binding.workspace_uuid}: {exc}\n{traceback.format_exc()}'
                    )
            return

        instance_uow = getattr(self.ap.persistence_mgr, 'instance_discovery_uow', None)
        tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
        if callable(instance_uow) and callable(tenant_scope):
            async with instance_uow(self.ap.workspace_service.instance_uuid) as discovery:
                workspace_uuids = list(
                    (
                        await discovery.session.scalars(
                            sqlalchemy.select(persistence_workspace.WorkspaceExecutionState.workspace_uuid)
                            .where(
                                persistence_workspace.WorkspaceExecutionState.instance_uuid
                                == self.ap.workspace_service.instance_uuid,
                                persistence_workspace.WorkspaceExecutionState.state
                                == persistence_workspace.WorkspaceExecutionStatus.ACTIVE.value,
                                persistence_workspace.WorkspaceExecutionState.write_fenced.is_(False),
                            )
                            .order_by(persistence_workspace.WorkspaceExecutionState.workspace_uuid)
                        )
                    ).all()
                )
        else:
            # Compatibility for lightweight tests and pre-tenancy managers.
            result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
            workspace_uuids = sorted({bot.workspace_uuid for bot in result.all()})

        for workspace_uuid in workspace_uuids:
            try:
                if callable(tenant_scope):
                    async with tenant_scope(workspace_uuid):
                        await self._load_workspace_bots(workspace_uuid)
                else:
                    await self._load_workspace_bots(workspace_uuid)
            except Exception as e:
                self.ap.logger.error(
                    f'Failed to load Workspace bots for {workspace_uuid}: {e}\n{traceback.format_exc()}'
                )

    async def _load_workspace_bots(
        self,
        workspace_uuid: str,
        *,
        _binding=None,
    ) -> None:
        result = await self.ap.persistence_mgr.execute_async(
            sqlalchemy.select(persistence_bot.Bot)
            .where(persistence_bot.Bot.workspace_uuid == workspace_uuid)
            .order_by(persistence_bot.Bot.uuid)
        )
        binding = _binding
        for bot in result.all():
            try:
                if binding is None:
                    binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid)
                execution_context = ExecutionContext(
                    instance_uuid=binding.instance_uuid,
                    workspace_uuid=binding.workspace_uuid,
                    placement_generation=binding.placement_generation,
                    bot_uuid=bot.uuid,
                    trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
                )
                await self.load_bot(
                    execution_context,
                    bot,
                    _binding_validated=True,
                )
            except platform_errors.AdapterNotFoundError as e:
                self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}')
            except Exception as e:
                self.ap.logger.error(f'Failed to load bot {bot.uuid}: {e}\n{traceback.format_exc()}')

    async def load_bot(
        self,
        context: ExecutionContext | RequestContext,
        bot_entity: persistence_bot.Bot | sqlalchemy.Row[persistence_bot.Bot] | dict,
        *,
        _binding_validated: bool = False,
    ) -> RuntimeBot:
        """加载机器人"""
        if isinstance(bot_entity, sqlalchemy.Row):
            bot_entity = persistence_bot.Bot(**bot_entity._mapping)
        elif isinstance(bot_entity, dict):
            bot_entity = persistence_bot.Bot(**bot_entity)

        execution_context = self._normalize_execution_context(context, bot_uuid=bot_entity.uuid)
        if bot_entity.workspace_uuid != execution_context.workspace_uuid:
            raise WorkspaceRequiredError('Bot entity Workspace does not match its runtime context')
        if not _binding_validated:
            await self.ap.workspace_service.get_execution_binding(
                execution_context.workspace_uuid,
                expected_generation=execution_context.placement_generation,
            )
        async with self._runtime_mutation_lock:
            if not _binding_validated:
                await self.ap.workspace_service.get_execution_binding(
                    execution_context.workspace_uuid,
                    expected_generation=execution_context.placement_generation,
                )
            await self._observe_execution_context_unlocked(execution_context)

            logger = EventLogger(
                name=f'platform-adapter-{bot_entity.name}',
                ap=self.ap,
                execution_context=execution_context,
                owner=bot_entity.uuid,
            )

            if bot_entity.adapter not in self.adapter_dict:
                raise platform_errors.AdapterNotFoundError(bot_entity.adapter)

            adapter_inst = self.adapter_dict[bot_entity.adapter](
                bot_entity.adapter_config,
                logger,
            )
            if hasattr(adapter_inst, 'ap'):
                adapter_inst.ap = self.ap

            # 如果 adapter 支持 set_bot_uuid 方法，设置 bot_uuid（用于统一 webhook）
            if hasattr(adapter_inst, 'set_bot_uuid'):
                adapter_inst.set_bot_uuid(bot_entity.uuid)

            runtime_bot = RuntimeBot(
                ap=self.ap,
                bot_entity=bot_entity,
                adapter=adapter_inst,
                logger=logger,
                execution_context=execution_context,
            )

            await runtime_bot.initialize()

            bot_key = self._runtime_bot_key(runtime_bot)
            existing_bot = self._bots_by_key.get(bot_key)
            if existing_bot is not None and existing_bot is not runtime_bot:
                try:
                    if existing_bot.enable:
                        await existing_bot.shutdown()
                except BaseException:
                    if runtime_bot.enable:
                        await runtime_bot.shutdown()
                    raise
            self._register_runtime_bot(runtime_bot)

            return runtime_bot

    async def get_bot_by_uuid(
        self,
        context: ExecutionContext | RequestContext,
        bot_uuid: str,
    ) -> RuntimeBot | None:
        execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
        await self.ap.workspace_service.get_execution_binding(
            execution_context.workspace_uuid,
            expected_generation=execution_context.placement_generation,
        )
        await self._observe_execution_context(execution_context)
        proxy_bot = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
        if proxy_bot and proxy_bot.bot_entity.uuid == bot_uuid:
            if proxy_bot.placement_generation != execution_context.placement_generation:
                return None
            return proxy_bot
        bot = self._bots_by_key.get(
            (
                execution_context.instance_uuid,
                execution_context.workspace_uuid,
                bot_uuid,
            )
        )
        if bot is None or bot.placement_generation != execution_context.placement_generation:
            return None
        return bot

    async def resolve_public_bot(self, route_key: str) -> RuntimeBot | None:
        """Resolve an opaque public bot UUID without consulting request headers."""

        try:
            normalized = str(uuid.UUID(route_key))
        except (ValueError, AttributeError, TypeError):
            return None
        keys = tuple(self._bot_keys_by_uuid.get(normalized, ()))
        if len(keys) != 1:
            return None
        key = keys[0]
        bot = self._bots_by_key.get(key)
        if bot is None:
            return None
        try:
            await self.ap.workspace_service.get_execution_binding(
                bot.workspace_uuid,
                expected_generation=bot.placement_generation,
            )
        except Exception:
            return None
        if self._bots_by_key.get(key) is bot:
            return bot
        return None

    async def remove_bot(
        self,
        context: ExecutionContext | RequestContext,
        bot_uuid: str,
    ) -> None:
        execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
        await self.ap.workspace_service.get_execution_binding(
            execution_context.workspace_uuid,
            expected_generation=execution_context.placement_generation,
        )
        async with self._runtime_mutation_lock:
            await self.ap.workspace_service.get_execution_binding(
                execution_context.workspace_uuid,
                expected_generation=execution_context.placement_generation,
            )
            await self._observe_execution_context_unlocked(execution_context)
            key = (
                execution_context.instance_uuid,
                execution_context.workspace_uuid,
                bot_uuid,
            )
            bot = self._bots_by_key.get(key)
            if bot is not None and bot.placement_generation == execution_context.placement_generation:
                if bot.enable:
                    await bot.shutdown()
                self._pop_runtime_bot(key)

    def get_available_adapters_info(self) -> list[dict]:
        return [
            component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'websocket'
        ]

    def get_available_adapter_info_by_name(self, name: str) -> dict | None:
        for component in self.adapter_components:
            if component.metadata.name == name:
                return component.to_plain_dict()
        return None

    def get_available_adapter_manifest_by_name(self, name: str) -> engine.Component | None:
        for component in self.adapter_components:
            if component.metadata.name == name:
                return component
        return None

    async def run(self):
        # This method will only be called when the application launching
        for proxy_bot in self.websocket_proxy_bots.values():
            await proxy_bot.run()

        for bot in self.bots:
            if bot.enable:
                await bot.run()

    async def shutdown(self):
        async with self._runtime_mutation_lock:
            runtime_bots = [*self.websocket_proxy_bots.values(), *self.bots]
            self.websocket_proxy_bots = {}
            self._proxy_last_accessed = {}
            self.bots = []
            for bot in runtime_bots:
                if not bot.enable:
                    continue
                try:
                    await bot.shutdown()
                except Exception as exc:
                    self.ap.logger.warning(f'Failed to stop platform runtime during shutdown: {exc}')
        self.ap.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.PLATFORM)
