"""
Unit tests for MessageAggregator (aggregator) module.

Tests cover:
- Message buffering and merging
- Timer-based flush behavior
- MAX_BUFFER_MESSAGES limit
- Aggregation enabled/disabled
- Config delay clamping
"""

from __future__ import annotations

import pytest
import asyncio
import contextvars
from contextlib import asynccontextmanager
from unittest.mock import Mock, AsyncMock
from importlib import import_module
from types import SimpleNamespace

from tests.factories import (
    FakeApp,
    text_chain,
    friend_message_event,
    mock_adapter,
)

import langbot_plugin.api.entities.builtin.provider.session as provider_session

from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.pipeline.pool import (
    ExecutionContextMismatchError,
    ExecutionContextRequiredError,
    bind_execution_context,
)
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError


def execution_context(
    workspace_uuid='workspace-test',
    *,
    bot_uuid='test-bot',
    pipeline_uuid=None,
    placement_generation=1,
):
    return ExecutionContext(
        instance_uuid='instance-test',
        workspace_uuid=workspace_uuid,
        placement_generation=placement_generation,
        bot_uuid=bot_uuid,
        pipeline_uuid=pipeline_uuid,
    )


def aggregation_key(
    context,
    *,
    launcher_type=provider_session.LauncherTypes.PERSON,
    launcher_id=12345,
    bot_uuid='test-bot',
    pipeline_uuid=None,
):
    return (
        context.instance_uuid,
        context.workspace_uuid,
        context.placement_generation,
        bot_uuid,
        pipeline_uuid,
        launcher_type.value,
        launcher_id,
    )


def get_aggregator_module():
    """Lazy import to avoid circular import issues."""
    return import_module('langbot.pkg.pipeline.aggregator')


def make_aggregator_app():
    """Create a FakeApp with necessary mocks for aggregator tests."""
    app = FakeApp()
    # Ensure query_pool has add_query method
    app.query_pool.add_query = AsyncMock()

    async def resolve_context(
        context,
        *,
        bot_uuid,
        pipeline_uuid,
        query_uuid=None,
    ):
        if context is None:
            raise ExecutionContextRequiredError('ExecutionContext required in test')
        return bind_execution_context(
            context,
            bot_uuid=bot_uuid,
            pipeline_uuid=pipeline_uuid,
            query_uuid=query_uuid,
        )

    app.query_pool.resolve_execution_context = AsyncMock(side_effect=resolve_context)
    # Add pipeline_mgr mock
    app.pipeline_mgr = AsyncMock()
    app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=None)
    app.workspace_service = Mock()
    app.workspace_service.get_execution_binding = AsyncMock(
        return_value=Mock(
            instance_uuid='instance-test',
            workspace_uuid='workspace-test',
            placement_generation=1,
        )
    )
    return app


def enable_aggregation(app, *, delay=10.0):
    pipeline = Mock()
    pipeline.pipeline_entity.config = {
        'trigger': {
            'message-aggregation': {
                'enabled': True,
                'delay': delay,
            }
        }
    }
    app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=pipeline)


def scoped_message_kwargs(context, *, launcher_id=12345, text='hello'):
    chain = text_chain(text)
    return {
        'execution_context': context,
        'bot_uuid': context.bot_uuid,
        'launcher_type': provider_session.LauncherTypes.PERSON,
        'launcher_id': launcher_id,
        'sender_id': launcher_id,
        'message_event': friend_message_event(chain),
        'message_chain': chain,
        'adapter': mock_adapter(),
        'pipeline_uuid': context.pipeline_uuid,
    }


class TestPendingMessage:
    """Tests for PendingMessage dataclass."""

    def test_pending_message_creation(self):
        """PendingMessage should be created with correct fields."""
        aggregator = get_aggregator_module()

        chain = text_chain('hello')
        event = friend_message_event(chain)
        adapter = mock_adapter()

        pending = aggregator.PendingMessage(
            execution_context=execution_context(pipeline_uuid='test-pipeline'),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain,
            adapter=adapter,
            pipeline_uuid='test-pipeline',
        )

        assert pending.bot_uuid == 'test-bot'
        assert pending.launcher_type == provider_session.LauncherTypes.PERSON
        assert pending.message_chain == chain
        assert pending.timestamp is not None


class TestSessionBuffer:
    """Tests for SessionBuffer dataclass."""

    def test_session_buffer_creation(self):
        """SessionBuffer should be created with correct fields."""
        aggregator = get_aggregator_module()

        context = execution_context()
        key = aggregation_key(context)
        buffer = aggregator.SessionBuffer(
            aggregation_key=key,
            execution_context=context,
        )

        assert buffer.aggregation_key == key
        assert buffer.messages == []
        assert buffer.timer_task is None
        assert buffer.last_message_time is not None

    def test_session_buffer_with_messages(self):
        """SessionBuffer should accept initial messages."""
        aggregator = get_aggregator_module()

        chain = text_chain('hello')
        event = friend_message_event(chain)
        adapter = mock_adapter()

        pending = aggregator.PendingMessage(
            execution_context=execution_context(),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain,
            adapter=adapter,
            pipeline_uuid=None,
        )

        context = execution_context()
        buffer = aggregator.SessionBuffer(
            aggregation_key=aggregation_key(context),
            execution_context=context,
            messages=[pending],
        )

        assert len(buffer.messages) == 1


class TestMessageAggregatorInit:
    """Tests for MessageAggregator initialization."""

    def test_aggregator_init(self):
        """MessageAggregator should initialize with correct fields."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        assert agg.ap == app
        assert agg.buffers == {}
        assert isinstance(agg.lock, asyncio.Lock)


class TestMessageAggregatorSessionId:
    """Tests for scoped aggregation key generation."""

    def test_session_id_format(self):
        """Session ID should be correctly formatted."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        context = execution_context()
        session_id = agg._get_aggregation_key(
            context,
            bot_uuid='bot-123',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=45678,
            pipeline_uuid=None,
        )

        assert session_id == (
            'instance-test',
            'workspace-test',
            1,
            'bot-123',
            None,
            'person',
            45678,
        )

    def test_session_id_different_launchers(self):
        """Different launcher types should produce different IDs."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        context = execution_context()
        person_id = agg._get_aggregation_key(
            context,
            bot_uuid='bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=123,
            pipeline_uuid=None,
        )

        group_id = agg._get_aggregation_key(
            context,
            bot_uuid='bot',
            launcher_type=provider_session.LauncherTypes.GROUP,
            launcher_id=123,
            pipeline_uuid=None,
        )

        assert person_id != group_id


class TestMessageAggregatorConfig:
    """Tests for aggregation config retrieval."""

    @pytest.mark.asyncio
    async def test_config_none_pipeline(self):
        """None pipeline_uuid should return default config."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        enabled, delay = await agg._get_aggregation_config(execution_context(), None)

        assert enabled == False
        assert delay == 1.5

    @pytest.mark.asyncio
    async def test_config_pipeline_not_found(self):
        """Non-existent pipeline should return default config."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=None)
        agg = aggregator.MessageAggregator(app)

        enabled, delay = await agg._get_aggregation_config(
            execution_context(pipeline_uuid='unknown-pipeline'),
            'unknown-pipeline',
        )

        assert enabled == False
        assert delay == 1.5

    @pytest.mark.asyncio
    async def test_config_enabled(self):
        """Pipeline with enabled aggregation should return True."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()

        mock_pipeline = Mock()
        mock_pipeline.pipeline_entity = Mock()
        mock_pipeline.pipeline_entity.config = {
            'trigger': {
                'message-aggregation': {
                    'enabled': True,
                    'delay': 2.0,
                }
            }
        }
        app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)

        agg = aggregator.MessageAggregator(app)

        enabled, delay = await agg._get_aggregation_config(
            execution_context(pipeline_uuid='test-pipeline'),
            'test-pipeline',
        )

        assert enabled == True
        assert delay == 2.0

    @pytest.mark.asyncio
    async def test_config_delay_clamped_low(self):
        """Delay below 1.0 should be clamped to 1.0."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()

        mock_pipeline = Mock()
        mock_pipeline.pipeline_entity = Mock()
        mock_pipeline.pipeline_entity.config = {
            'trigger': {
                'message-aggregation': {
                    'enabled': True,
                    'delay': 0.5,  # Below minimum
                }
            }
        }
        app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)

        agg = aggregator.MessageAggregator(app)

        enabled, delay = await agg._get_aggregation_config(
            execution_context(pipeline_uuid='test-pipeline'),
            'test-pipeline',
        )

        assert delay == 1.0  # Clamped to minimum

    @pytest.mark.asyncio
    async def test_config_delay_clamped_high(self):
        """Delay above 10.0 should be clamped to 10.0."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()

        mock_pipeline = Mock()
        mock_pipeline.pipeline_entity = Mock()
        mock_pipeline.pipeline_entity.config = {
            'trigger': {
                'message-aggregation': {
                    'enabled': True,
                    'delay': 15.0,  # Above maximum
                }
            }
        }
        app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)

        agg = aggregator.MessageAggregator(app)

        enabled, delay = await agg._get_aggregation_config(
            execution_context(pipeline_uuid='test-pipeline'),
            'test-pipeline',
        )

        assert delay == 10.0  # Clamped to maximum

    @pytest.mark.asyncio
    async def test_config_delay_invalid_type(self):
        """Invalid delay type should use default."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()

        mock_pipeline = Mock()
        mock_pipeline.pipeline_entity = Mock()
        mock_pipeline.pipeline_entity.config = {
            'trigger': {
                'message-aggregation': {
                    'enabled': True,
                    'delay': 'invalid',  # Not a number
                }
            }
        }
        app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)

        agg = aggregator.MessageAggregator(app)

        enabled, delay = await agg._get_aggregation_config(
            execution_context(pipeline_uuid='test-pipeline'),
            'test-pipeline',
        )

        assert delay == 1.5  # Default


class TestMessageAggregatorAddMessage:
    """Tests for add_message behavior."""

    @pytest.mark.asyncio
    async def test_disabled_adds_to_query_pool(self):
        """Disabled aggregation should directly add to query_pool."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        chain = text_chain('hello')
        event = friend_message_event(chain)
        adapter = mock_adapter()

        await agg.add_message(
            execution_context=execution_context(),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain,
            adapter=adapter,
            pipeline_uuid=None,  # None -> disabled
        )

        # Should have called query_pool.add_query
        assert app.query_pool.add_query.called

    @pytest.mark.asyncio
    async def test_enabled_buffers_message(self):
        """Enabled aggregation should buffer message."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()

        mock_pipeline = Mock()
        mock_pipeline.pipeline_entity = Mock()
        mock_pipeline.pipeline_entity.config = {
            'trigger': {
                'message-aggregation': {
                    'enabled': True,
                    'delay': 2.0,
                }
            }
        }
        app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)

        agg = aggregator.MessageAggregator(app)

        chain = text_chain('hello')
        event = friend_message_event(chain)
        adapter = mock_adapter()

        await agg.add_message(
            execution_context=execution_context(pipeline_uuid='test-pipeline'),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain,
            adapter=adapter,
            pipeline_uuid='test-pipeline',
        )

        # Should have buffered the message
        assert len(agg.buffers) == 1

    @pytest.mark.asyncio
    async def test_max_buffer_flushes_immediately(self):
        """Reaching MAX_BUFFER_MESSAGES should flush immediately."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()

        mock_pipeline = Mock()
        mock_pipeline.pipeline_entity = Mock()
        mock_pipeline.pipeline_entity.config = {
            'trigger': {
                'message-aggregation': {
                    'enabled': True,
                    'delay': 10.0,  # Long delay
                }
            }
        }
        app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=mock_pipeline)

        agg = aggregator.MessageAggregator(app)

        chain = text_chain('hello')
        event = friend_message_event(chain)
        adapter = mock_adapter()

        # Add messages up to MAX_BUFFER_MESSAGES
        for i in range(aggregator.MAX_BUFFER_MESSAGES):
            await agg.add_message(
                execution_context=execution_context(pipeline_uuid='test-pipeline'),
                bot_uuid='test-bot',
                launcher_type=provider_session.LauncherTypes.PERSON,
                launcher_id=12345,
                sender_id=12345,
                message_event=event,
                message_chain=chain,
                adapter=adapter,
                pipeline_uuid='test-pipeline',
            )

        # Buffer should be flushed (empty or no buffer)
        context = execution_context(pipeline_uuid='test-pipeline')
        session_id = agg._get_aggregation_key(
            context,
            'test-bot',
            provider_session.LauncherTypes.PERSON,
            12345,
            'test-pipeline',
        )
        assert session_id not in agg.buffers or len(agg.buffers[session_id].messages) == 0


class TestMessageAggregatorMerge:
    """Tests for message merging."""

    def test_merge_single_message(self):
        """Single message should return unchanged."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        chain = text_chain('hello')
        event = friend_message_event(chain)
        adapter = mock_adapter()

        pending = aggregator.PendingMessage(
            execution_context=execution_context(),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain,
            adapter=adapter,
            pipeline_uuid=None,
        )

        merged = agg._merge_messages([pending])

        assert merged.message_chain == chain

    def test_merge_multiple_messages(self):
        """Multiple messages should be merged with newline separator."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        chain1 = text_chain('hello')
        chain2 = text_chain('world')
        event = friend_message_event(chain1)
        adapter = mock_adapter()

        pending1 = aggregator.PendingMessage(
            execution_context=execution_context(),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain1,
            adapter=adapter,
            pipeline_uuid=None,
        )

        pending2 = aggregator.PendingMessage(
            execution_context=execution_context(),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain2,
            adapter=adapter,
            pipeline_uuid=None,
        )

        merged = agg._merge_messages([pending1, pending2])

        # Should contain both messages with separator
        merged_str = str(merged.message_chain)
        assert 'hello' in merged_str
        assert 'world' in merged_str

    def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(self):
        """Merged PendingMessage should keep routed_by_rule when any input was rule-routed."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        chain1 = text_chain('first')
        chain2 = text_chain('second')
        event = friend_message_event(chain1)
        adapter = mock_adapter()

        pending1 = aggregator.PendingMessage(
            execution_context=execution_context(pipeline_uuid='test-pipeline-uuid'),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain1,
            adapter=adapter,
            pipeline_uuid='test-pipeline-uuid',
            routed_by_rule=False,
        )

        pending2 = aggregator.PendingMessage(
            execution_context=execution_context(pipeline_uuid='test-pipeline-uuid'),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain2,
            adapter=adapter,
            pipeline_uuid='test-pipeline-uuid',
            routed_by_rule=True,
        )

        merged = agg._merge_messages([pending1, pending2])

        assert merged.routed_by_rule is True
        assert str(merged.message_chain) == 'first\nsecond'


class TestMessageAggregatorFlush:
    """Tests for buffer flush behavior."""

    @pytest.mark.asyncio
    async def test_flush_empty_buffer(self):
        """Flushing empty buffer should do nothing."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        context = execution_context()
        await agg._flush_buffer(aggregation_key(context), context)

        # Should not call query_pool
        assert not app.query_pool.add_query.called

    @pytest.mark.asyncio
    async def test_flush_single_message(self):
        """Flushing single message should add directly to query_pool."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        chain = text_chain('hello')
        event = friend_message_event(chain)
        adapter = mock_adapter()

        pending = aggregator.PendingMessage(
            execution_context=execution_context(),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain,
            adapter=adapter,
            pipeline_uuid=None,
        )

        context = execution_context()
        key = aggregation_key(context)
        buffer = aggregator.SessionBuffer(
            aggregation_key=key,
            execution_context=context,
            messages=[pending],
        )

        agg.buffers[key] = buffer

        await agg._flush_buffer(key, context)

        assert app.query_pool.add_query.called
        assert key not in agg.buffers

    @pytest.mark.asyncio
    async def test_flush_drops_buffer_when_placement_generation_is_stale(self):
        """A debounce timer cannot enqueue work after its placement is fenced."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        app.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError('stale generation')
        agg = aggregator.MessageAggregator(app)
        context = execution_context(placement_generation=3)
        pending = aggregator.PendingMessage(
            execution_context=context,
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=friend_message_event(text_chain('stale')),
            message_chain=text_chain('stale'),
            adapter=mock_adapter(),
            pipeline_uuid=None,
        )
        key = aggregation_key(context)
        agg.buffers[key] = aggregator.SessionBuffer(
            aggregation_key=key,
            execution_context=context,
            messages=[pending],
        )

        with pytest.raises(WorkspaceGenerationMismatchError):
            await agg._flush_buffer(key, context)

        app.workspace_service.get_execution_binding.assert_awaited_once_with(
            'workspace-test',
            expected_generation=3,
        )
        app.query_pool.add_query.assert_not_awaited()
        assert key not in agg.buffers


class TestMessageAggregatorFlushAll:
    """Tests for flush_all behavior."""

    @pytest.mark.asyncio
    async def test_flush_all_empty(self):
        """flush_all with no buffers should do nothing."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        await agg.flush_all()

        # Should not call query_pool
        assert not app.query_pool.add_query.called

    @pytest.mark.asyncio
    async def test_flush_all_with_buffers(self):
        """flush_all should flush all pending buffers."""
        aggregator = get_aggregator_module()

        app = make_aggregator_app()
        agg = aggregator.MessageAggregator(app)

        chain = text_chain('hello')
        event = friend_message_event(chain)
        adapter = mock_adapter()

        # Create two buffers
        pending1 = aggregator.PendingMessage(
            execution_context=execution_context(),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=12345,
            sender_id=12345,
            message_event=event,
            message_chain=chain,
            adapter=adapter,
            pipeline_uuid=None,
        )

        pending2 = aggregator.PendingMessage(
            execution_context=execution_context(),
            bot_uuid='test-bot',
            launcher_type=provider_session.LauncherTypes.PERSON,
            launcher_id=67890,
            sender_id=67890,
            message_event=event,
            message_chain=chain,
            adapter=adapter,
            pipeline_uuid=None,
        )

        context = execution_context()
        key1 = aggregation_key(context, launcher_id=12345)
        key2 = aggregation_key(context, launcher_id=67890)
        buffer1 = aggregator.SessionBuffer(
            aggregation_key=key1,
            execution_context=context,
            messages=[pending1],
        )
        buffer2 = aggregator.SessionBuffer(
            aggregation_key=key2,
            execution_context=context,
            messages=[pending2],
        )

        agg.buffers[key1] = buffer1
        agg.buffers[key2] = buffer2

        await agg.flush_all()

        # Both buffers should be flushed
        assert len(agg.buffers) == 0
        assert app.query_pool.add_query.call_count == 2


class TestMessageAggregatorWorkspaceIsolation:
    """Regression coverage for fail-closed and cross-workspace behavior."""

    @pytest.mark.asyncio
    async def test_missing_execution_context_fails_closed(self):
        app = make_aggregator_app()
        agg = get_aggregator_module().MessageAggregator(app)
        kwargs = scoped_message_kwargs(execution_context())
        kwargs['execution_context'] = None

        with pytest.raises(ExecutionContextRequiredError):
            await agg.add_message(**kwargs)

        app.query_pool.add_query.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_same_launcher_in_two_workspaces_uses_separate_buffers(self):
        app = make_aggregator_app()
        enable_aggregation(app)
        agg = get_aggregator_module().MessageAggregator(app)

        await agg.add_message(**scoped_message_kwargs(execution_context('workspace-a', pipeline_uuid='test-pipeline')))
        await agg.add_message(**scoped_message_kwargs(execution_context('workspace-b', pipeline_uuid='test-pipeline')))

        assert len(agg.buffers) == 2
        assert {key[1] for key in agg.buffers} == {'workspace-a', 'workspace-b'}
        await agg.flush_all()

    @pytest.mark.asyncio
    async def test_new_buffer_uses_scope_counter_without_global_scan(self):
        class NoGlobalIterationDict(dict):
            def __iter__(self):
                raise AssertionError('aggregation admission scanned all buffers')

            def items(self):
                raise AssertionError('aggregation admission scanned all buffers')

            def values(self):
                raise AssertionError('aggregation admission scanned all buffers')

        app = make_aggregator_app()
        enable_aggregation(app)
        agg = get_aggregator_module().MessageAggregator(app)
        agg.max_buffers = 2_000
        agg.max_buffers_per_workspace = 2_000
        existing = {
            (
                'instance-test',
                f'workspace-{index}',
                1,
                'bot',
                'pipeline',
                'person',
                index,
            ): object()
            for index in range(1_000)
        }
        agg.buffers = NoGlobalIterationDict(existing)
        agg._buffer_counts_by_scope = {key[:3]: 1 for key in existing}
        context = execution_context(
            'workspace-target',
            pipeline_uuid='test-pipeline',
        )

        await agg.add_message(**scoped_message_kwargs(context))

        key = aggregation_key(
            context,
            pipeline_uuid='test-pipeline',
        )
        assert key in agg.buffers
        assert agg._buffer_counts_by_scope[key[:3]] == 1
        timer_task = agg.buffers[key].timer_task
        assert timer_task is not None
        timer_task.cancel()
        await asyncio.gather(timer_task, return_exceptions=True)
        await agg._flush_buffer(key, context)
        assert key[:3] not in agg._buffer_counts_by_scope

    @pytest.mark.asyncio
    async def test_same_launcher_in_two_bots_uses_separate_buffers(self):
        app = make_aggregator_app()
        enable_aggregation(app)
        agg = get_aggregator_module().MessageAggregator(app)

        await agg.add_message(
            **scoped_message_kwargs(execution_context(bot_uuid='bot-a', pipeline_uuid='test-pipeline'))
        )
        await agg.add_message(
            **scoped_message_kwargs(execution_context(bot_uuid='bot-b', pipeline_uuid='test-pipeline'))
        )

        assert len(agg.buffers) == 2
        assert {key[3] for key in agg.buffers} == {'bot-a', 'bot-b'}
        await agg.flush_all()

    @pytest.mark.asyncio
    async def test_timer_receives_exact_captured_execution_context(self, monkeypatch):
        app = make_aggregator_app()
        enable_aggregation(app)
        agg = get_aggregator_module().MessageAggregator(app)
        request_value = contextvars.ContextVar('aggregator_request_value', default=None)
        token = request_value.set('request-scope')
        observed = []

        async def delayed_flush(*args):
            observed.append((request_value.get(), args[2]))

        monkeypatch.setattr(agg, '_delayed_flush', delayed_flush)
        context = execution_context(pipeline_uuid='test-pipeline')

        try:
            await agg.add_message(**scoped_message_kwargs(context))
            await asyncio.sleep(0)
        finally:
            request_value.reset(token)

        assert observed == [(None, context)]
        await agg.flush_all()

    @pytest.mark.asyncio
    async def test_delayed_flush_opens_explicit_workspace_uow(self, monkeypatch):
        app = make_aggregator_app()
        app.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
        scopes = []

        @asynccontextmanager
        async def tenant_uow(workspace_uuid):
            scopes.append(workspace_uuid)
            yield

        app.persistence_mgr.tenant_uow = tenant_uow
        agg = get_aggregator_module().MessageAggregator(app)
        flush = AsyncMock()
        monkeypatch.setattr(agg, '_flush_buffer', flush)
        context = execution_context('workspace-a', pipeline_uuid='test-pipeline')
        key = aggregation_key(context, pipeline_uuid='test-pipeline')

        await agg._delayed_flush(key, 0, context)

        assert scopes == ['workspace-a']
        flush.assert_awaited_once_with(key, context)

    @pytest.mark.asyncio
    async def test_flush_rejects_context_from_another_workspace(self):
        app = make_aggregator_app()
        enable_aggregation(app)
        agg = get_aggregator_module().MessageAggregator(app)
        context_a = execution_context('workspace-a', pipeline_uuid='test-pipeline')
        context_b = execution_context('workspace-b', pipeline_uuid='test-pipeline')
        await agg.add_message(**scoped_message_kwargs(context_a))
        key = next(iter(agg.buffers))

        with pytest.raises(ExecutionContextMismatchError):
            await agg._flush_buffer(key, context_b)

        assert key in agg.buffers
        await agg.flush_all()

    def test_merge_rejects_messages_from_different_workspaces(self):
        app = make_aggregator_app()
        agg = get_aggregator_module().MessageAggregator(app)
        aggregator = get_aggregator_module()

        with pytest.raises(ExecutionContextMismatchError):
            agg._merge_messages(
                [
                    aggregator.PendingMessage(**scoped_message_kwargs(execution_context('workspace-a'))),
                    aggregator.PendingMessage(**scoped_message_kwargs(execution_context('workspace-b'))),
                ]
            )

    @pytest.mark.asyncio
    async def test_flush_all_preserves_each_workspace_context(self):
        app = make_aggregator_app()
        enable_aggregation(app)
        agg = get_aggregator_module().MessageAggregator(app)
        await agg.add_message(**scoped_message_kwargs(execution_context('workspace-a', pipeline_uuid='test-pipeline')))
        await agg.add_message(**scoped_message_kwargs(execution_context('workspace-b', pipeline_uuid='test-pipeline')))

        await agg.flush_all()

        forwarded_workspaces = {
            call.kwargs['execution_context'].workspace_uuid for call in app.query_pool.add_query.await_args_list
        }
        assert forwarded_workspaces == {'workspace-a', 'workspace-b'}
