from __future__ import annotations

import dataclasses
import typing
import traceback

import sqlalchemy

from ..core import app
from . import entities as pipeline_entities
from ..entity.persistence import pipeline as persistence_pipeline
from . import stage
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.events as events
from ..utils import importutil
from ..api.http.authz import WorkspaceRequiredError
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
from .config_coercion import coerce_pipeline_config
from .pool import get_query_execution_context

import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query

from . import (
    resprule,
    bansess,
    cntfilter,
    process,
    longtext,
    respback,
    wrapper,
    preproc,
    ratelimit,
    msgtrun,
)

importutil.import_modules_in_pkgs(
    [
        resprule,
        bansess,
        cntfilter,
        process,
        longtext,
        respback,
        wrapper,
        preproc,
        ratelimit,
        msgtrun,
    ]
)


class StageInstContainer:
    """阶段实例容器"""

    inst_name: str

    inst: stage.PipelineStage

    def __init__(self, inst_name: str, inst: stage.PipelineStage):
        self.inst_name = inst_name
        self.inst = inst


class RuntimePipeline:
    """运行时流水线"""

    ap: app.Application

    pipeline_entity: persistence_pipeline.LegacyPipeline
    """流水线实体"""

    stage_containers: list[StageInstContainer]
    """阶段实例容器"""

    bound_plugins: list[str] | None
    """绑定到此流水线的插件列表（格式：author/plugin_name），None表示启用所有"""

    bound_mcp_servers: list[str] | None
    """绑定到此流水线的MCP服务器列表（格式：uuid），None表示启用所有"""

    enable_all_plugins: bool
    """是否启用所有插件"""

    enable_all_mcp_servers: bool
    """是否启用所有MCP服务器"""

    execution_context: ExecutionContext

    workspace_uuid: str

    placement_generation: int

    def __init__(
        self,
        ap: app.Application,
        pipeline_entity: persistence_pipeline.LegacyPipeline,
        stage_containers: list[StageInstContainer],
        execution_context: ExecutionContext,
    ):
        if not isinstance(execution_context, ExecutionContext):
            raise WorkspaceRequiredError('RuntimePipeline requires an ExecutionContext')
        if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
            raise WorkspaceRequiredError('RuntimePipeline requires an instance and Workspace')
        if execution_context.placement_generation <= 0:
            raise WorkspaceRequiredError('RuntimePipeline requires a positive placement generation')
        if pipeline_entity.workspace_uuid != execution_context.workspace_uuid:
            raise WorkspaceRequiredError('RuntimePipeline entity Workspace does not match its ExecutionContext')
        if execution_context.pipeline_uuid not in (None, pipeline_entity.uuid):
            raise WorkspaceRequiredError('RuntimePipeline UUID does not match its ExecutionContext')

        self.ap = ap
        self.pipeline_entity = pipeline_entity
        self.stage_containers = stage_containers
        self.execution_context = dataclasses.replace(
            execution_context,
            pipeline_uuid=pipeline_entity.uuid,
        )
        self.workspace_uuid = self.execution_context.workspace_uuid
        self.placement_generation = self.execution_context.placement_generation

        # Extract bound plugins and MCP servers from extensions_preferences
        extensions_prefs = pipeline_entity.extensions_preferences or {}
        self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)
        self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)
        local_agent_config = (pipeline_entity.config or {}).get('ai', {}).get('local-agent', {})
        self.mcp_resource_attachments = local_agent_config.get(
            'mcp-resources',
            extensions_prefs.get('mcp_resources', []),
        )
        self.mcp_resource_agent_read_enabled = local_agent_config.get(
            'mcp-resource-agent-read-enabled',
            extensions_prefs.get('mcp_resource_agent_read_enabled', True),
        )

        if self.enable_all_plugins:
            # None indicates to use all available plugins
            self.bound_plugins = None
        else:
            plugin_list = extensions_prefs.get('plugins', [])
            self.bound_plugins = [f'{p["author"]}/{p["name"]}' for p in plugin_list] if plugin_list else []

        if self.enable_all_mcp_servers:
            # None indicates to use all available MCP servers
            self.bound_mcp_servers = None
        else:
            mcp_server_list = extensions_prefs.get('mcp_servers', [])
            self.bound_mcp_servers = mcp_server_list if mcp_server_list else []

    async def _assert_execution_active(
        self,
        query: pipeline_query.Query | None = None,
    ) -> ExecutionContext:
        """Fail closed when this runtime or query belongs to a stale placement."""

        execution_context = self.execution_context if query is None else get_query_execution_context(query)
        if (
            execution_context.instance_uuid != self.execution_context.instance_uuid
            or execution_context.workspace_uuid != self.workspace_uuid
            or execution_context.placement_generation != self.placement_generation
            or execution_context.pipeline_uuid != self.pipeline_entity.uuid
        ):
            raise WorkspaceInvariantError('Query execution scope does not match RuntimePipeline')
        binding = await self.ap.workspace_service.get_execution_binding(
            execution_context.workspace_uuid,
            expected_generation=execution_context.placement_generation,
        )
        if binding.instance_uuid != execution_context.instance_uuid:
            raise WorkspaceInvariantError('RuntimePipeline instance does not match the active Workspace binding')
        return execution_context

    async def run(self, query: pipeline_query.Query):
        if (
            query.instance_uuid != self.execution_context.instance_uuid
            or query.workspace_uuid != self.workspace_uuid
            or query.placement_generation != self.placement_generation
            or query.pipeline_uuid != self.pipeline_entity.uuid
        ):
            raise WorkspaceRequiredError('Query execution scope does not match RuntimePipeline')
        await self._assert_execution_active(query)
        query.pipeline_config = self.pipeline_entity.config
        # Store bound plugins and MCP servers in query for filtering
        query.variables['_pipeline_bound_plugins'] = self.bound_plugins
        query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
        query.variables['_pipeline_mcp_resource_attachments'] = self.mcp_resource_attachments
        query.variables['_pipeline_mcp_resource_agent_read_enabled'] = self.mcp_resource_agent_read_enabled

        # Record query start for monitoring
        try:
            # Get bot name from bot_uuid
            bot_name = 'WebChat'
            if query.bot_uuid:
                try:
                    bot = await self.ap.bot_service.get_bot(
                        query.workspace_uuid,
                        query.bot_uuid,
                        include_secret=False,
                    )
                    if bot:
                        bot_name = bot.get('name', 'Unknown')
                except Exception:
                    pass

            # Store for later use in process_query
            query.variables['_monitoring_bot_name'] = bot_name
            query.variables['_monitoring_pipeline_name'] = self.pipeline_entity.name
        except Exception as e:
            self.ap.logger.error(f'Failed to prepare monitoring data: {e}')

        await self.process_query(query)

    async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult):
        """检查输出"""
        await self._assert_execution_active(query)
        if result.user_notice:
            # 处理str类型

            if isinstance(result.user_notice, str):
                result.user_notice = platform_message.MessageChain([platform_message.Plain(text=result.user_notice)])
            elif isinstance(result.user_notice, list):
                result.user_notice = platform_message.MessageChain(*result.user_notice)

            if query.pipeline_config['output']['misc']['at-sender'] and isinstance(
                query.message_event, platform_events.GroupMessage
            ):
                result.user_notice.insert(0, platform_message.At(target=query.message_event.sender.id))
            stream_output_supported = await query.adapter.is_stream_output_supported()
            await self._assert_execution_active(query)
            if stream_output_supported and query.resp_messages:
                await query.adapter.reply_message_chunk(
                    message_source=query.message_event,
                    bot_message=query.resp_messages[-1],
                    message=result.user_notice,
                    quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
                    is_final=[msg.is_final for msg in query.resp_messages][-1],
                )
            else:
                await query.adapter.reply_message(
                    message_source=query.message_event,
                    message=result.user_notice,
                    quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
                )
        if result.debug_notice:
            self.ap.logger.debug(result.debug_notice)
        if result.console_notice:
            self.ap.logger.info(result.console_notice)
        if result.error_notice:
            self.ap.logger.error(result.error_notice)
            # Mark query as having error
            query.variables['_monitoring_has_error'] = True
            # Record error to monitoring system
            try:
                await self._assert_execution_active(query)
                bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
                pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
                message_id = query.variables.get('_monitoring_message_id', '')
                session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'

                # Update message status to error
                if message_id:
                    await self.ap.monitoring_service.update_message_status(
                        get_query_execution_context(query),
                        message_id=message_id,
                        status='error',
                        level='error',
                    )

                # Record error log
                await self.ap.monitoring_service.record_error(
                    get_query_execution_context(query),
                    bot_id=query.bot_uuid or 'unknown',
                    bot_name=bot_name,
                    pipeline_id=self.pipeline_entity.uuid,
                    pipeline_name=pipeline_name,
                    error_type='PipelineError',
                    error_message=result.error_notice,
                    session_id=session_id,
                    stack_trace=result.debug_notice if result.debug_notice else None,
                    message_id=message_id,
                )
            except Exception as e:
                self.ap.logger.error(f'Failed to record error to monitoring: {e}')

    async def _execute_from_stage(
        self,
        stage_index: int,
        query: pipeline_query.Query,
    ):
        """从指定阶段开始执行，实现了责任链模式和基于生成器的阶段分叉功能。

        如何看懂这里为什么这么写？
        去问 GPT-4:
            Q1: 现在有一个责任链，其中有多个stage，query对象在其中传递，stage.process可能返回Result也有可能返回typing.AsyncGenerator[Result, None]，
                如果返回的是生成器，需要挨个生成result，检查是否result中是否要求继续，如果要求继续就进行下一个stage。如果此次生成器产生的result处理完了，就继续生成下一个result，
                调用后续的stage，直到该生成器全部生成完。责任链中可能有多个stage会返回生成器
            Q2: 不是这样的，你可能理解有误。如果我们责任链上有这些Stage：

                A B C D E F G

                如果所有的stage都返回Result，且所有Result都要求继续，那么执行顺序是：

                A B C D E F G

                现在假设C返回的是AsyncGenerator，那么执行顺序是：

                A B C D E F G C D E F G C D E F G ...
            Q3: 但是如果不止一个stage会返回生成器呢？
        """
        i = stage_index

        while i < len(self.stage_containers):
            await self._assert_execution_active(query)
            stage_container = self.stage_containers[i]

            query.current_stage_name = stage_container.inst_name  # 标记到 Query 对象里

            result = stage_container.inst.process(query, stage_container.inst_name)

            if isinstance(result, typing.Coroutine):
                result = await result
                await self._assert_execution_active(query)

            if isinstance(result, pipeline_entities.StageProcessResult):  # 直接返回结果
                self.ap.logger.debug(
                    f'Stage {stage_container.inst_name} processed query {query.query_id} res {result.result_type}'
                )
                await self._check_output(query, result)

                if result.result_type == pipeline_entities.ResultType.INTERRUPT:
                    self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
                    break
                elif result.result_type == pipeline_entities.ResultType.CONTINUE:
                    query = result.new_query
            elif isinstance(result, typing.AsyncGenerator):  # 生成器
                self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen')

                iterator = result.__aiter__()
                while True:
                    await self._assert_execution_active(query)
                    try:
                        sub_result = await anext(iterator)
                    except StopAsyncIteration:
                        break
                    await self._assert_execution_active(query)
                    self.ap.logger.debug(
                        f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}'
                    )
                    await self._check_output(query, sub_result)

                    if sub_result.result_type == pipeline_entities.ResultType.INTERRUPT:
                        self.ap.logger.debug(f'Stage {stage_container.inst_name} interrupted query {query.query_id}')
                        break
                    elif sub_result.result_type == pipeline_entities.ResultType.CONTINUE:
                        query = sub_result.new_query
                        await self._execute_from_stage(i + 1, query)
                break

            i += 1

    async def process_query(self, query: pipeline_query.Query):
        """处理请求"""
        await self._assert_execution_active(query)
        # Get monitoring metadata
        bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
        pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')

        # Get runner name from pipeline config
        runner_name = None
        if query.pipeline_config and 'ai' in query.pipeline_config and 'runner' in query.pipeline_config['ai']:
            runner_name = query.pipeline_config['ai']['runner'].get('runner')

        # Record query start and store message_id
        message_id = ''
        try:
            from . import monitoring_helper

            message_id = await monitoring_helper.MonitoringHelper.record_query_start(
                ap=self.ap,
                query=query,
                bot_id=query.bot_uuid or 'unknown',
                bot_name=bot_name,
                pipeline_id=self.pipeline_entity.uuid,
                pipeline_name=pipeline_name,
                runner_name=runner_name,
            )
            # Store message_id in query variables for LLM call monitoring
            query.variables['_monitoring_message_id'] = message_id
            # Notify adapter so it can map platform-specific IDs to monitoring message ID
            if hasattr(query.adapter, 'on_monitoring_message_created'):
                await self._assert_execution_active(query)
                await query.adapter.on_monitoring_message_created(query, message_id)
        except Exception as e:
            self.ap.logger.error(f'Failed to record query start: {e}')

        try:
            # Get bound plugins for this pipeline
            bound_plugins = query.variables.get('_pipeline_bound_plugins', None)

            # ======== 触发 MessageReceived 事件 ========
            event_type = (
                events.PersonMessageReceived
                if query.launcher_type == provider_session.LauncherTypes.PERSON
                else events.GroupMessageReceived
            )

            event_obj = event_type(
                query=query,
                launcher_type=query.launcher_type.value,
                launcher_id=query.launcher_id,
                sender_id=query.sender_id,
                message_event=query.message_event,
                message_chain=query.message_chain,
            )

            await self._assert_execution_active(query)
            event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
            await self._assert_execution_active(query)

            if event_ctx.is_prevented_default():
                self.ap.logger.debug(
                    f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
                )
                return

            self.ap.logger.debug(f'Processing query {query.query_id}')

            await self._execute_from_stage(0, query)

            # Record query success only if no error occurred during processing
            if not query.variables.get('_monitoring_has_error', False):
                try:
                    await self._assert_execution_active(query)
                    await monitoring_helper.MonitoringHelper.record_query_success(
                        ap=self.ap,
                        message_id=message_id,
                        query=query,
                    )
                except Exception as e:
                    self.ap.logger.error(f'Failed to record query success: {e}')

                # Record bot response message
                try:
                    await self._assert_execution_active(query)
                    await monitoring_helper.MonitoringHelper.record_query_response(
                        ap=self.ap,
                        query=query,
                        bot_id=query.bot_uuid or 'unknown',
                        bot_name=bot_name,
                        pipeline_id=self.pipeline_entity.uuid,
                        pipeline_name=pipeline_name,
                        runner_name=runner_name,
                    )
                except Exception as e:
                    self.ap.logger.error(f'Failed to record query response: {e}')

        except WorkspaceError as e:
            self.ap.logger.info(f'Dropped query {query.query_id} because its Workspace execution binding is stale: {e}')
        except Exception as e:
            inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
            self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
            self.ap.logger.error(f'Traceback: {traceback.format_exc()}')

            # Record query error
            try:
                from . import monitoring_helper

                await self._assert_execution_active(query)
                await monitoring_helper.MonitoringHelper.record_query_error(
                    ap=self.ap,
                    query=query,
                    bot_id=query.bot_uuid or 'unknown',
                    bot_name=bot_name,
                    pipeline_id=self.pipeline_entity.uuid,
                    pipeline_name=pipeline_name,
                    error=e,
                    runner_name=runner_name,
                )
            except Exception as me:
                self.ap.logger.error(f'Failed to record query error: {me}')

        finally:
            self.ap.logger.debug(f'Query {query.query_id} processed')
            await self.ap.query_pool.remove_query(query)


class PipelineManager:
    """流水线管理器"""

    ap: app.Application

    pipelines: list[RuntimePipeline]

    stage_dict: dict[str, type[stage.PipelineStage]]

    def __init__(self, ap: app.Application):
        self.ap = ap
        self._pipelines_by_key: dict[
            tuple[str, str, str],
            RuntimePipeline,
        ] = {}
        self._pipeline_keys_by_scope: dict[
            tuple[str, str],
            set[tuple[str, str, str]],
        ] = {}
        self._scope_generations: dict[tuple[str, str], int] = {}

    @property
    def pipelines(self) -> list[RuntimePipeline]:
        """Compatibility view over the indexed runtime pipeline registry."""

        return list(self._pipelines_by_key.values())

    @pipelines.setter
    def pipelines(self, pipelines: list[RuntimePipeline]) -> None:
        self._pipelines_by_key = {}
        self._pipeline_keys_by_scope = {}
        for pipeline in pipelines:
            context = pipeline.execution_context
            pipeline_uuid = (
                getattr(getattr(pipeline, 'pipeline_entity', None), 'uuid', None) or context.pipeline_uuid or ''
            )
            key = (
                context.instance_uuid,
                pipeline.workspace_uuid,
                pipeline_uuid,
            )
            self._pipelines_by_key[key] = pipeline
            self._pipeline_keys_by_scope.setdefault(key[:2], set()).add(key)

    def _observe_execution_context(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('Pipeline runtime placement generation rolled back')
        if previous_generation == context.placement_generation:
            return
        if previous_generation is not None:
            for key in self._pipeline_keys_by_scope.pop(scope, ()):
                self._pipelines_by_key.pop(key, None)
        self._scope_generations[scope] = context.placement_generation

    async def initialize(self):
        self.stage_dict = {name: cls for name, cls in stage.preregistered_stages.items()}

        await self.load_pipelines_from_db()

    async def load_pipelines_from_db(self):
        self.ap.logger.info('Loading pipelines from db...')

        self._pipelines_by_key = {}
        self._pipeline_keys_by_scope = {}
        self._scope_generations = {}
        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 pipeline loading requires explicit instance discovery and tenant UoWs')
            for binding in await list_bindings():
                async with tenant_uow(binding.workspace_uuid):
                    result = await self.ap.persistence_mgr.execute_async(
                        sqlalchemy.select(persistence_pipeline.LegacyPipeline)
                        .where(persistence_pipeline.LegacyPipeline.workspace_uuid == binding.workspace_uuid)
                        .order_by(persistence_pipeline.LegacyPipeline.uuid)
                    )
                    for pipeline in result.all():
                        await self.load_pipeline(
                            ExecutionContext(
                                instance_uuid=binding.instance_uuid,
                                workspace_uuid=binding.workspace_uuid,
                                placement_generation=binding.placement_generation,
                                pipeline_uuid=pipeline.uuid,
                                trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
                            ),
                            pipeline,
                            _binding_validated=True,
                        )
            return

        # Compatibility path for isolated manager tests and older embedders.
        result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))

        pipelines = result.all()

        # load pipelines
        for pipeline in pipelines:
            binding = await self.ap.workspace_service.get_execution_binding(pipeline.workspace_uuid)
            await self.load_pipeline(
                ExecutionContext(
                    instance_uuid=binding.instance_uuid,
                    workspace_uuid=binding.workspace_uuid,
                    placement_generation=binding.placement_generation,
                    pipeline_uuid=pipeline.uuid,
                    trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
                ),
                pipeline,
            )

    @staticmethod
    def _normalize_execution_context(
        context: ExecutionContext | RequestContext,
        pipeline_uuid: str,
    ) -> ExecutionContext:
        if isinstance(context, RequestContext):
            return ExecutionContext.from_request(context, pipeline_uuid=pipeline_uuid)
        if not isinstance(context, ExecutionContext):
            raise WorkspaceRequiredError('Pipeline runtime operations require an ExecutionContext')
        if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
            raise WorkspaceRequiredError('Pipeline runtime operations require an instance and Workspace')
        if context.placement_generation <= 0:
            raise WorkspaceRequiredError('Pipeline runtime operations require a positive placement generation')
        if context.pipeline_uuid not in (None, pipeline_uuid):
            raise WorkspaceRequiredError('Pipeline UUID does not match its ExecutionContext')
        return dataclasses.replace(context, pipeline_uuid=pipeline_uuid)

    async def load_pipeline(
        self,
        context: ExecutionContext | RequestContext,
        pipeline_entity: persistence_pipeline.LegacyPipeline
        | sqlalchemy.Row[persistence_pipeline.LegacyPipeline]
        | dict,
        *,
        _binding_validated: bool = False,
    ):
        if isinstance(pipeline_entity, sqlalchemy.Row):
            pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity._mapping)
        elif isinstance(pipeline_entity, dict):
            pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)

        execution_context = self._normalize_execution_context(context, pipeline_entity.uuid)
        if pipeline_entity.workspace_uuid != execution_context.workspace_uuid:
            raise WorkspaceRequiredError('Pipeline 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,
            )
        self._observe_execution_context(execution_context)

        coerce_pipeline_config(
            pipeline_entity.config,
            getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
            getattr(self.ap, 'pipeline_config_meta_safety', {'name': 'safety', 'stages': []}),
            getattr(self.ap, 'pipeline_config_meta_ai', {'name': 'ai', 'stages': []}),
            getattr(self.ap, 'pipeline_config_meta_output', {'name': 'output', 'stages': []}),
        )

        # initialize stage containers according to pipeline_entity.stages
        stage_containers: list[StageInstContainer] = []
        for stage_name in pipeline_entity.stages:
            stage_containers.append(StageInstContainer(inst_name=stage_name, inst=self.stage_dict[stage_name](self.ap)))

        for stage_container in stage_containers:
            await stage_container.inst.initialize(pipeline_entity.config)

        # Stage initialization can yield while a Workspace is being moved.
        # Revalidate before publishing the runtime assembled above.
        if not _binding_validated:
            await self.ap.workspace_service.get_execution_binding(
                execution_context.workspace_uuid,
                expected_generation=execution_context.placement_generation,
            )
        self._observe_execution_context(execution_context)
        runtime_pipeline = RuntimePipeline(
            self.ap,
            pipeline_entity,
            stage_containers,
            execution_context,
        )
        key = (
            execution_context.instance_uuid,
            execution_context.workspace_uuid,
            pipeline_entity.uuid,
        )
        self._pipelines_by_key[key] = runtime_pipeline
        self._pipeline_keys_by_scope.setdefault(key[:2], set()).add(key)

    async def get_pipeline_by_uuid(
        self,
        context: ExecutionContext | RequestContext,
        uuid: str,
    ) -> RuntimePipeline | None:
        execution_context = self._normalize_execution_context(context, uuid)
        await self.ap.workspace_service.get_execution_binding(
            execution_context.workspace_uuid,
            expected_generation=execution_context.placement_generation,
        )
        self._observe_execution_context(execution_context)
        key = (
            execution_context.instance_uuid,
            execution_context.workspace_uuid,
            uuid,
        )
        pipeline = self._pipelines_by_key.get(key)
        if pipeline is not None and pipeline.placement_generation == execution_context.placement_generation:
            return pipeline
        if not self._pipeline_keys_by_scope.get(key[:2]):
            self._scope_generations.pop(
                (execution_context.instance_uuid, execution_context.workspace_uuid),
                None,
            )
        return None

    async def remove_pipeline(
        self,
        context: ExecutionContext | RequestContext,
        uuid: str,
    ) -> None:
        execution_context = self._normalize_execution_context(context, uuid)
        await self.ap.workspace_service.get_execution_binding(
            execution_context.workspace_uuid,
            expected_generation=execution_context.placement_generation,
        )
        self._observe_execution_context(execution_context)
        key = (
            execution_context.instance_uuid,
            execution_context.workspace_uuid,
            uuid,
        )
        if self._pipelines_by_key.pop(key, None) is not None:
            scope_keys = self._pipeline_keys_by_scope.get(key[:2])
            if scope_keys is not None:
                scope_keys.discard(key)
                if not scope_keys:
                    self._pipeline_keys_by_scope.pop(key[:2], None)
                    self._scope_generations.pop(key[:2], None)
            return
