"""
Unit tests for PipelineService.

Tests pipeline CRUD operations including:
- Pipeline listing with sorting
- Pipeline creation with default config
- Pipeline update with bot sync
- Pipeline copy functionality
- Extensions preferences management

Source: src/langbot/pkg/api/http/service/pipeline.py
"""

from __future__ import annotations

import pytest
from unittest.mock import AsyncMock, Mock, patch, mock_open
from types import SimpleNamespace
import uuid
import json

from langbot.pkg.api.http.service.pipeline import PipelineService, default_stage_order
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
from langbot.pkg.workspace.errors import WorkspaceNotFoundError


pytestmark = pytest.mark.asyncio

WORKSPACE_UUID = 'workspace-a'


def _create_mock_pipeline(
    pipeline_uuid: str = None,
    name: str = 'Test Pipeline',
    description: str = 'Test Description',
    is_default: bool = False,
    stages: list = None,
    config: dict = None,
    extensions_preferences: dict = None,
) -> Mock:
    """Helper to create mock LegacyPipeline entity."""
    pipeline = Mock(spec=LegacyPipeline)
    pipeline.uuid = pipeline_uuid or str(uuid.uuid4())
    pipeline.name = name
    pipeline.description = description
    pipeline.emoji = '⚙️'
    pipeline.is_default = is_default
    pipeline.for_version = '1.0.0'
    pipeline.stages = stages or default_stage_order.copy()
    pipeline.config = config or {}
    pipeline.extensions_preferences = extensions_preferences or {
        'enable_all_plugins': True,
        'enable_all_mcp_servers': True,
        'plugins': [],
        'mcp_servers': [],
    }
    return pipeline


def _create_mock_result(items: list = None, first_item=None):
    """Create mock result object for persistence queries."""
    result = Mock()
    result.all = Mock(return_value=items or [])
    result.first = Mock(return_value=first_item)
    return result


class TestPipelineServiceGetPipelineMetadata:
    """Tests for get_pipeline_metadata method."""

    async def test_get_pipeline_metadata_returns_list(self):
        """Returns list of pipeline metadata configs."""
        # Setup
        ap = SimpleNamespace()
        ap.pipeline_config_meta_trigger = {'trigger': {}}
        ap.pipeline_config_meta_safety = {'safety': {}}
        ap.pipeline_config_meta_ai = {'ai': {}}
        ap.pipeline_config_meta_output = {'output': {}}

        service = PipelineService(ap)

        # Execute
        result = await service.get_pipeline_metadata(
            WORKSPACE_UUID,
        )

        # Verify
        assert len(result) == 4
        assert 'trigger' in result[0]
        assert 'safety' in result[1]
        assert 'ai' in result[2]
        assert 'output' in result[3]


class TestPipelineServiceGetPipelines:
    """Tests for get_pipelines method."""

    async def test_get_pipelines_empty_list(self):
        """Returns empty list when no pipelines exist."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        mock_result = _create_mock_result([])
        ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
        ap.persistence_mgr.serialize_model = Mock(
            side_effect=lambda model_cls, entity: {
                'uuid': entity.uuid,
                'name': entity.name,
            }
        )

        service = PipelineService(ap)

        # Execute
        result = await service.get_pipelines(
            WORKSPACE_UUID,
        )

        # Verify
        assert result == []

    async def test_get_pipelines_returns_sorted_by_created_at_desc(self):
        """Returns pipelines sorted by created_at descending by default."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()

        pipeline1 = _create_mock_pipeline(pipeline_uuid='uuid-1', name='Pipeline 1')
        pipeline2 = _create_mock_pipeline(pipeline_uuid='uuid-2', name='Pipeline 2')

        mock_result = _create_mock_result([pipeline1, pipeline2])
        ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
        ap.persistence_mgr.serialize_model = Mock(
            side_effect=lambda model_cls, entity: {
                'uuid': entity.uuid,
                'name': entity.name,
            }
        )

        service = PipelineService(ap)

        # Execute
        result = await service.get_pipelines(
            WORKSPACE_UUID,
        )

        # Verify
        assert len(result) == 2
        ap.persistence_mgr.execute_async.assert_called_once()

    async def test_get_pipelines_sort_by_updated_at_asc(self):
        """Returns pipelines sorted by updated_at ascending."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()

        mock_result = _create_mock_result([])
        ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
        ap.persistence_mgr.serialize_model = Mock(return_value={})

        service = PipelineService(ap)

        # Execute
        await service.get_pipelines(WORKSPACE_UUID, sort_by='updated_at', sort_order='ASC')

        # Verify - execute was called with sort parameters
        ap.persistence_mgr.execute_async.assert_called_once()


class TestPipelineServiceGetPipeline:
    """Tests for get_pipeline method."""

    async def test_get_pipeline_by_uuid_found(self):
        """Returns pipeline when found by UUID."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()

        pipeline = _create_mock_pipeline(pipeline_uuid='test-uuid', name='Found Pipeline')
        mock_result = _create_mock_result(first_item=pipeline)
        ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
        ap.persistence_mgr.serialize_model = Mock(
            return_value={
                'uuid': 'test-uuid',
                'name': 'Found Pipeline',
                'stages': default_stage_order,
            }
        )

        service = PipelineService(ap)

        # Execute
        result = await service.get_pipeline(WORKSPACE_UUID, 'test-uuid')

        # Verify
        assert result is not None
        assert result['uuid'] == 'test-uuid'
        assert result['name'] == 'Found Pipeline'

    async def test_get_pipeline_by_uuid_not_found(self):
        """Returns None when pipeline not found."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()

        mock_result = _create_mock_result(first_item=None)
        ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)

        service = PipelineService(ap)

        # Execute
        result = await service.get_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')

        # Verify
        assert result is None


class TestPipelineServiceCreatePipeline:
    """Tests for create_pipeline method."""

    async def test_create_pipeline_max_limit_reached_raises(self):
        """Raises ValueError when max_pipelines limit reached."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.instance_config = SimpleNamespace()
        ap.instance_config.data = {'system': {'limitation': {'max_pipelines': 2}}}
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.load_pipeline = AsyncMock()
        ap.ver_mgr = SimpleNamespace()
        ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')

        mock_result = _create_mock_result([_create_mock_pipeline(), _create_mock_pipeline()])
        ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
        ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Pipeline 1'})

        service = PipelineService(ap)

        # Execute & Verify
        with pytest.raises(ValueError, match='Maximum number of pipelines'):
            await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})

    async def test_create_pipeline_no_limit(self):
        """Creates pipeline without limit when max_pipelines=-1."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.instance_config = SimpleNamespace()
        ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.load_pipeline = AsyncMock()
        ap.ver_mgr = SimpleNamespace()
        ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')

        service = PipelineService(ap)
        # Override get_pipelines to return empty list (no limit check issue)
        service.get_pipelines = AsyncMock(return_value=[])
        service.get_pipeline = AsyncMock(return_value={'uuid': 'new-uuid', 'name': 'New Pipeline'})

        # Mock persistence for insert
        ap.persistence_mgr.execute_async = AsyncMock()
        ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Pipeline'})

        # Mock the file read for default config - patch at the utils module level
        default_config = {'trigger': {}, 'safety': {}, 'ai': {}, 'output': {}}
        with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
            with patch(
                'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
            ):
                bot_uuid = await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})

        # Verify
        assert bot_uuid is not None
        assert len(bot_uuid) == 36  # UUID format

    async def test_create_pipeline_as_default(self):
        """Creates pipeline with is_default=True."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.instance_config = SimpleNamespace()
        ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.load_pipeline = AsyncMock()
        ap.ver_mgr = SimpleNamespace()
        ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')

        service = PipelineService(ap)
        service.get_pipelines = AsyncMock(return_value=[])
        service.get_pipeline = AsyncMock(
            return_value={'uuid': 'new-uuid', 'name': 'Default Pipeline', 'is_default': True}
        )

        ap.persistence_mgr.execute_async = AsyncMock()
        ap.persistence_mgr.serialize_model = Mock(
            return_value={'uuid': 'new-uuid', 'name': 'Default Pipeline', 'is_default': True}
        )

        # Mock the file read
        default_config = {}
        with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
            with patch(
                'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
            ):
                await service.create_pipeline(WORKSPACE_UUID, {'name': 'Default Pipeline'}, default=True)

        # Verify - execute was called
        ap.persistence_mgr.execute_async.assert_called()

    async def test_create_pipeline_sets_default_extensions_preferences(self):
        """Sets default extensions_preferences when not provided."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.instance_config = SimpleNamespace()
        ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.load_pipeline = AsyncMock()
        ap.ver_mgr = SimpleNamespace()
        ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')

        service = PipelineService(ap)
        service.get_pipelines = AsyncMock(return_value=[])
        service.get_pipeline = AsyncMock(
            return_value={
                'uuid': 'new-uuid',
                'extensions_preferences': {},
            }
        )

        insert_params = []

        async def mock_execute(query):
            params = query.compile().params
            if 'extensions_preferences' in params:
                insert_params.append(params)
            return Mock()

        ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
        ap.persistence_mgr.serialize_model = Mock(
            return_value={
                'uuid': 'new-uuid',
                'extensions_preferences': {},
            }
        )

        default_config = {}
        with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
            with patch(
                'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
            ):
                await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})

        assert len(insert_params) == 1
        assert insert_params[0]['extensions_preferences'] == {
            'enable_all_plugins': True,
            'enable_all_mcp_servers': True,
            'plugins': [],
            'mcp_servers': [],
            'mcp_resources': [],
            'mcp_resource_agent_read_enabled': True,
        }


class _MockResultWithBots:
    """Helper class to mock SQLAlchemy result with iterable .all() method."""

    def __init__(self, bots_list):
        self._bots_list = bots_list

    def all(self):
        return self._bots_list

    def first(self):
        return self._bots_list[0] if self._bots_list else None


class TestPipelineServiceUpdatePipeline:
    """Tests for update_pipeline method."""

    async def test_update_pipeline_removes_protected_fields(self):
        """Does not persist protected fields from update data."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.remove_pipeline = AsyncMock()
        ap.pipeline_mgr.load_pipeline = AsyncMock()
        ap.sess_mgr = SimpleNamespace()
        ap.sess_mgr.session_list = []
        ap.bot_service = None  # No bot_service when not updating name

        ap.persistence_mgr.execute_async = AsyncMock()

        service = PipelineService(ap)
        service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'Updated'})

        # Execute with protected fields - no name change, so no bot sync
        pipeline_data = {
            'uuid': 'should-be-removed',
            'for_version': 'should-be-removed',
            'stages': ['should-be-removed'],
            'is_default': True,
            'description': 'New description',  # Not name change, so no bot_service needed
        }
        await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', pipeline_data)

        update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params
        assert update_params['description'] == 'New description'
        assert 'should-be-removed' not in update_params.values()
        assert ['should-be-removed'] not in update_params.values()
        assert not any(value is True for value in update_params.values())

    async def test_update_pipeline_syncs_bot_names(self):
        """Updates bot use_pipeline_name when pipeline name changes."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.remove_pipeline = AsyncMock()
        ap.pipeline_mgr.load_pipeline = AsyncMock()
        ap.sess_mgr = SimpleNamespace()
        ap.sess_mgr.session_list = []
        ap.bot_service = SimpleNamespace()
        ap.bot_service.update_bot = AsyncMock()

        # Create proper mock Bot entities with uuid attribute
        mock_bot1 = Mock()
        mock_bot1.uuid = 'bot-uuid-1'
        mock_bot2 = Mock()
        mock_bot2.uuid = 'bot-uuid-2'

        # Create bot list
        bot_list = [mock_bot1, mock_bot2]

        # Create mock result using helper class
        bot_result = _MockResultWithBots(bot_list)

        # The order of calls in update_pipeline:
        # 1. UPDATE (line 125) - returns Mock (no result needed)
        # 2. SELECT bots (line 136) - returns bot_result with .all()
        call_count = 0

        async def mock_execute(query):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                # First call is the UPDATE - just return a Mock
                return Mock()
            elif call_count == 2:
                # Second call is the SELECT bots - return proper result
                return bot_result
            return Mock()  # Any additional calls

        ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
        ap.persistence_mgr.serialize_model = Mock(return_value={})

        service = PipelineService(ap)
        service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'New Name'})

        # Execute with name change
        await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', {'name': 'New Name'})

        # Verify - bot_service.update_bot was called for each bot
        assert ap.bot_service.update_bot.call_count == 2

    async def test_update_pipeline_clears_conversations(self):
        """Clears session conversations using this pipeline."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.remove_pipeline = AsyncMock()
        ap.pipeline_mgr.load_pipeline = AsyncMock()
        ap.sess_mgr = SimpleNamespace()

        # Mock session with conversation using this pipeline
        session = SimpleNamespace()
        session.using_conversation = SimpleNamespace()
        session.using_conversation.pipeline_uuid = 'test-uuid'
        ap.sess_mgr.session_list = [session]
        ap.bot_service = SimpleNamespace()

        ap.persistence_mgr.execute_async = AsyncMock()

        service = PipelineService(ap)
        service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})

        # Execute
        await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', {'description': 'Updated'})

        # Verify - conversation was cleared
        assert session.using_conversation is None


class TestPipelineServiceDeletePipeline:
    """Tests for delete_pipeline method."""

    async def test_delete_pipeline_calls_remove_and_delete(self):
        """Calls both pipeline_mgr.remove_pipeline and persistence delete."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.persistence_mgr.execute_async = AsyncMock()
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.remove_pipeline = AsyncMock()

        service = PipelineService(ap)

        # Execute
        await service.delete_pipeline(WORKSPACE_UUID, 'test-uuid')

        # Verify
        ap.pipeline_mgr.remove_pipeline.assert_called_once_with(WORKSPACE_UUID, 'test-uuid')
        ap.persistence_mgr.execute_async.assert_called_once()

    async def test_delete_pipeline_nonexistent_uuid(self):
        """Delete operation completes even for nonexistent UUID."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.persistence_mgr.execute_async = AsyncMock()
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.remove_pipeline = AsyncMock()

        service = PipelineService(ap)

        # Execute - should not raise
        await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')

        # Verify
        ap.pipeline_mgr.remove_pipeline.assert_called_once()


class TestPipelineServiceCopyPipeline:
    """Tests for copy_pipeline method."""

    async def test_copy_pipeline_max_limit_reached_raises(self):
        """Raises ValueError when max_pipelines limit reached."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.instance_config = SimpleNamespace()
        ap.instance_config.data = {'system': {'limitation': {'max_pipelines': 2}}}
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.load_pipeline = AsyncMock()
        ap.ver_mgr = SimpleNamespace()
        ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')

        service = PipelineService(ap)
        # Mock get_pipelines to return 2 pipelines
        service.get_pipelines = AsyncMock(
            return_value=[
                {'uuid': 'uuid-1', 'name': 'Pipeline 1'},
                {'uuid': 'uuid-2', 'name': 'Pipeline 2'},
            ]
        )

        # Execute & Verify
        with pytest.raises(ValueError, match='Maximum number of pipelines'):
            await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')

    async def test_copy_pipeline_not_found_raises(self):
        """Raises ValueError when original pipeline not found."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.instance_config = SimpleNamespace()
        ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
        ap.pipeline_mgr = SimpleNamespace()
        ap.ver_mgr = SimpleNamespace()
        ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')

        service = PipelineService(ap)
        service.get_pipelines = AsyncMock(return_value=[])  # No limit check issue
        ap.persistence_mgr.execute_async = AsyncMock(
            return_value=_create_mock_result(first_item=None)  # Original not found
        )
        ap.persistence_mgr.serialize_model = Mock(return_value={})

        # Execute & Verify
        with pytest.raises(WorkspaceNotFoundError, match='Pipeline original-uuid not found'):
            await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')

    async def test_copy_pipeline_creates_copy(self):
        """Creates a copy with (Copy) suffix."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.instance_config = SimpleNamespace()
        ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.load_pipeline = AsyncMock()
        ap.ver_mgr = SimpleNamespace()
        ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')

        original = _create_mock_pipeline(
            pipeline_uuid='original-uuid',
            name='Original Pipeline',
            description='Original description',
            stages=['Stage1', 'Stage2'],
            config={'key': 'value'},
            extensions_preferences={'enable_all_plugins': False, 'plugins': ['plugin1']},
        )

        service = PipelineService(ap)
        service.get_pipelines = AsyncMock(return_value=[])  # No limit check issue

        # Mock persistence - get original, then insert, then get new
        ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=original))
        ap.persistence_mgr.serialize_model = Mock(
            return_value={
                'uuid': 'new-copy-uuid',
                'name': 'Original Pipeline (Copy)',
            }
        )

        service.get_pipeline = AsyncMock(
            return_value={
                'uuid': 'new-copy-uuid',
                'name': 'Original Pipeline (Copy)',
            }
        )

        # Execute
        new_uuid = await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')

        # Verify
        assert new_uuid is not None
        assert len(new_uuid) == 36  # UUID format

    async def test_copy_pipeline_is_not_default(self):
        """Copy is never set as default."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.instance_config = SimpleNamespace()
        ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.load_pipeline = AsyncMock()
        ap.ver_mgr = SimpleNamespace()
        ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')

        # Original is default
        original = _create_mock_pipeline(
            pipeline_uuid='original-uuid',
            name='Default Pipeline',
            is_default=True,
        )

        service = PipelineService(ap)
        service.get_pipelines = AsyncMock(return_value=[])
        ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=original))
        ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'copy-uuid', 'is_default': False})

        service.get_pipeline = AsyncMock(return_value={'uuid': 'copy-uuid', 'is_default': False})

        # Execute
        await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')

        # Verify - pipeline_mgr.load_pipeline called (copy created)
        ap.pipeline_mgr.load_pipeline.assert_called_once()


class TestPipelineServiceUpdatePipelineExtensions:
    """Tests for update_pipeline_extensions method."""

    async def test_update_extensions_pipeline_not_found_raises(self):
        """Raises ValueError when pipeline not found."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        mock_result = _create_mock_result(first_item=None)
        ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)

        service = PipelineService(ap)

        # Execute & Verify
        with pytest.raises(WorkspaceNotFoundError, match='Pipeline nonexistent-uuid not found'):
            await service.update_pipeline_extensions(WORKSPACE_UUID, 'nonexistent-uuid', [])

    async def test_update_extensions_sets_plugins(self):
        """Updates plugins in extensions_preferences."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.remove_pipeline = AsyncMock()
        ap.pipeline_mgr.load_pipeline = AsyncMock()

        original_pipeline = _create_mock_pipeline(extensions_preferences={'enable_all_plugins': True, 'plugins': []})

        call_count = 0

        async def mock_execute(query):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return _create_mock_result(first_item=original_pipeline)
            return Mock()

        ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
        ap.persistence_mgr.serialize_model = Mock(
            return_value={
                'uuid': 'test-uuid',
                'extensions_preferences': {
                    'enable_all_plugins': False,
                    'plugins': [{'plugin_uuid': 'plugin-1'}],
                },
            }
        )

        service = PipelineService(ap)
        service.get_pipeline = AsyncMock(
            return_value={
                'uuid': 'test-uuid',
                'extensions_preferences': {
                    'enable_all_plugins': False,
                    'plugins': [{'plugin_uuid': 'plugin-1'}],
                },
            }
        )

        # Execute
        bound_plugins = [{'plugin_uuid': 'plugin-1'}]
        await service.update_pipeline_extensions(
            WORKSPACE_UUID,
            'test-uuid',
            bound_plugins=bound_plugins,
            enable_all_plugins=False,
        )

        # Verify
        ap.persistence_mgr.execute_async.assert_called()

    async def test_update_extensions_sets_mcp_servers(self):
        """Updates MCP servers in extensions_preferences."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.remove_pipeline = AsyncMock()
        ap.pipeline_mgr.load_pipeline = AsyncMock()

        original_pipeline = _create_mock_pipeline()

        call_count = 0

        async def mock_execute(query):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return _create_mock_result(first_item=original_pipeline)
            return Mock()

        ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
        ap.persistence_mgr.serialize_model = Mock(
            return_value={
                'uuid': 'test-uuid',
                'extensions_preferences': {
                    'enable_all_mcp_servers': False,
                    'mcp_servers': ['mcp-server-1'],
                },
            }
        )

        service = PipelineService(ap)
        service.get_pipeline = AsyncMock(
            return_value={
                'uuid': 'test-uuid',
                'extensions_preferences': {'mcp_servers': ['mcp-server-1']},
            }
        )

        # Execute
        await service.update_pipeline_extensions(
            WORKSPACE_UUID,
            'test-uuid',
            bound_plugins=[],
            bound_mcp_servers=['mcp-server-1'],
            enable_all_mcp_servers=False,
        )

        # Verify
        ap.persistence_mgr.execute_async.assert_called()

    async def test_update_extensions_none_mcp_servers_keeps_existing(self):
        """Does not modify mcp_servers when bound_mcp_servers is None."""
        # Setup
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.remove_pipeline = AsyncMock()
        ap.pipeline_mgr.load_pipeline = AsyncMock()

        original_pipeline = _create_mock_pipeline(
            extensions_preferences={
                'enable_all_plugins': True,
                'enable_all_mcp_servers': True,
                'plugins': [],
                'mcp_servers': ['existing-server'],
            }
        )

        call_count = 0

        async def mock_execute(query):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return _create_mock_result(first_item=original_pipeline)
            return Mock()

        ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
        ap.persistence_mgr.serialize_model = Mock(
            return_value={'uuid': 'test-uuid', 'extensions_preferences': {'mcp_servers': ['existing-server']}}
        )

        service = PipelineService(ap)
        service.get_pipeline = AsyncMock(
            return_value={'uuid': 'test-uuid', 'extensions_preferences': {'mcp_servers': ['existing-server']}}
        )

        # Execute - bound_mcp_servers is None (not provided)
        await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])

        # Verify - persistence was called
        ap.persistence_mgr.execute_async.assert_called()

    async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self):
        """Does not reset mcp_resource_agent_read_enabled when omitted by older clients."""
        ap = SimpleNamespace()
        ap.persistence_mgr = SimpleNamespace()
        ap.pipeline_mgr = SimpleNamespace()
        ap.pipeline_mgr.remove_pipeline = AsyncMock()
        ap.pipeline_mgr.load_pipeline = AsyncMock()

        original_pipeline = _create_mock_pipeline(
            extensions_preferences={
                'enable_all_plugins': True,
                'enable_all_mcp_servers': True,
                'plugins': [],
                'mcp_servers': [],
                'mcp_resources': [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}],
                'mcp_resource_agent_read_enabled': False,
            }
        )

        call_count = 0

        async def mock_execute(query):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return _create_mock_result(first_item=original_pipeline)
            return Mock()

        ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
        ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'test-uuid'})

        service = PipelineService(ap)
        service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})

        await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])

        assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False
        assert original_pipeline.extensions_preferences['mcp_resources'] == [
            {'server_uuid': 'srv-1', 'uri': 'file:///README.md'}
        ]


class TestPipelineSecretRoundtrip:
    async def test_resource_view_redacts_runner_secrets_without_mutating_serialized_data(self):
        raw = {
            'uuid': 'pipeline-secret',
            'config': {
                'ai': {
                    'n8n': {
                        'webhook-url': 'https://hook.invalid/bearer-secret',
                        'headers': {'Authorization': 'Bearer secret'},
                    }
                }
            },
        }
        pipeline = _create_mock_pipeline(pipeline_uuid='pipeline-secret')
        ap = SimpleNamespace(
            persistence_mgr=SimpleNamespace(
                execute_async=AsyncMock(return_value=_create_mock_result([pipeline])),
                serialize_model=Mock(return_value=raw),
            )
        )

        redacted = await PipelineService(ap).get_pipelines(WORKSPACE_UUID)

        assert redacted[0]['config']['ai']['n8n']['webhook-url'] == '***'
        assert redacted[0]['config']['ai']['n8n']['headers']['Authorization'] == '***'
        assert raw['config']['ai']['n8n']['webhook-url'] == 'https://hook.invalid/bearer-secret'

    async def test_masked_runner_config_update_restores_existing_secret(self):
        raw_config = {
            'ai': {
                'n8n': {
                    'webhook-url': 'https://hook.invalid/bearer-secret',
                    'headers': {'Authorization': 'Bearer secret'},
                    'timeout': 30,
                }
            }
        }
        current_pipeline = {'uuid': 'pipeline-secret', 'config': raw_config}
        write_result = Mock(rowcount=1)
        ap = SimpleNamespace(
            persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=write_result)),
            pipeline_mgr=SimpleNamespace(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock()),
            sess_mgr=SimpleNamespace(session_list=[]),
        )
        service = PipelineService(ap)
        service.get_pipeline = AsyncMock(side_effect=[current_pipeline, current_pipeline])

        await service.update_pipeline(
            WORKSPACE_UUID,
            'pipeline-secret',
            {
                'config': {
                    'ai': {
                        'n8n': {
                            'webhook-url': '***',
                            'headers': {'Authorization': '***'},
                            'timeout': 60,
                        }
                    }
                }
            },
        )

        statement = ap.persistence_mgr.execute_async.await_args.args[0]
        stored_config = next(value.value for column, value in statement._values.items() if column.key == 'config')
        assert stored_config == {
            'ai': {
                'n8n': {
                    'webhook-url': 'https://hook.invalid/bearer-secret',
                    'headers': {'Authorization': 'Bearer secret'},
                    'timeout': 60,
                }
            }
        }


class TestDefaultStageOrder:
    """Tests for default_stage_order constant."""

    def test_default_stage_order_not_empty(self):
        """Default stage order is not empty."""
        assert len(default_stage_order) > 0

    def test_default_stage_order_contains_key_stages(self):
        """Default stage order contains key processing stages."""
        assert 'MessageProcessor' in default_stage_order
        assert 'SendResponseBackStage' in default_stage_order
