"""Tests for MeetingMixin gRPC endpoints.

Tests cover:
- CreateMeeting: with title, without title (default), with metadata
- StopMeeting: success, already stopped (idempotent), not found
- ListMeetings: empty list, multiple meetings, state filtering, sorting
- GetMeeting: found, not found, with segments, with summary
- DeleteMeeting: success, not found
"""


from __future__ import annotations

from typing import cast
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4

import pytest

from noteflow.domain.entities import Meeting
from noteflow.domain.ports.repositories.identity import ProjectRepository, WorkspaceRepository
from noteflow.domain.value_objects import MeetingId, MeetingState
from noteflow.grpc._mixins.meeting import MeetingMixin
from noteflow.grpc.proto import noteflow_pb2

from .proto_types import (
    CreateMeetingRequestProto,
    DeleteMeetingRequestProto,
    DeleteMeetingResponseProto,
    GetMeetingRequestProto,
    ListMeetingsRequestProto,
    ListMeetingsResponseProto,
    MeetingProto,
    MeetingServicerProtocol,
    StopMeetingRequestProto,
)

# Test constants for pagination
PAGE_LIMIT_SMALL = 25
PAGE_OFFSET_STANDARD = 50


# ============================================================================
# Mock Infrastructure
# ============================================================================


class MockMeetingRepositoryProvider:
    """Mock repository provider as async context manager."""

    def __init__(
        self,
        meetings_repo: AsyncMock,
        segments_repo: AsyncMock,
        summaries_repo: AsyncMock,
        diarization_jobs_repo: AsyncMock | None = None,
        projects_repo: ProjectRepository | None = None,
        workspaces_repo: WorkspaceRepository | None = None,
        supports_projects: bool = False,
        supports_workspaces: bool = False,
    ) -> None:
        """Initialize with mock repositories."""
        self.meetings = meetings_repo
        self.segments = segments_repo
        self.summaries = summaries_repo
        self.diarization_jobs = diarization_jobs_repo or AsyncMock()
        self.supports_diarization_jobs = diarization_jobs_repo is not None
        self.projects: ProjectRepository = projects_repo or MagicMock(spec=ProjectRepository)
        self.workspaces: WorkspaceRepository = (
            workspaces_repo or MagicMock(spec=WorkspaceRepository)
        )
        self.supports_projects = supports_projects
        self.supports_workspaces = supports_workspaces
        self.commit = AsyncMock()

    async def __aenter__(self) -> MockMeetingRepositoryProvider:
        """Enter async context."""
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: object,
    ) -> None:
        """Exit async context."""


class MockMeetingMixinServicerHost(MeetingMixin):
    """Mock servicer host implementing required protocol for MeetingMixin.

    Implements the minimal ServicerHost protocol needed by MeetingMixin:
    - create_repository_provider()
    - active_streams, stop_requested for StopMeeting
    - audio_writers, close_audio_writer for StopMeeting
    - webhook_service for StopMeeting webhook triggers
    """

    def __init__(
        self,
        meetings_repo: AsyncMock,
        segments_repo: AsyncMock | None = None,
        summaries_repo: AsyncMock | None = None,
        diarization_jobs_repo: AsyncMock | None = None,
        projects_repo: ProjectRepository | None = None,
        workspaces_repo: WorkspaceRepository | None = None,
        webhook_service: MagicMock | None = None,
    ) -> None:
        """Initialize with mock repositories."""
        self._meetings_repo = meetings_repo
        self._segments_repo = segments_repo or AsyncMock()
        self._summaries_repo = summaries_repo or AsyncMock()
        self.diarization_jobs_repo = diarization_jobs_repo
        self._projects_repo: ProjectRepository = projects_repo or MagicMock(spec=ProjectRepository)
        self._workspaces_repo: WorkspaceRepository = (
            workspaces_repo or MagicMock(spec=WorkspaceRepository)
        )

        # Streaming state required by StopMeeting
        self.active_streams: set[str] = set()
        self.stop_requested: set[str] = set()
        self.audio_writers: dict[str, MagicMock] = {}

        # Webhook service (optional)
        self.webhook_service = webhook_service
        self.project_service = None

    def create_repository_provider(self) -> MockMeetingRepositoryProvider:
        """Create mock repository provider context manager."""
        return MockMeetingRepositoryProvider(
            self._meetings_repo,
            self._segments_repo,
            self._summaries_repo,
            self.diarization_jobs_repo,
            self._projects_repo,
            self._workspaces_repo,
            supports_projects=False,
            supports_workspaces=False,
        )

    def close_audio_writer(self, meeting_id: str) -> None:
        """Mock audio writer close."""
        self.audio_writers.pop(meeting_id, None)


# ============================================================================
# Fixtures
# ============================================================================


@pytest.fixture
def meeting_mixin_meetings_repo() -> AsyncMock:
    """Create mock meetings repository with common methods."""
    repo = AsyncMock()
    repo.create = AsyncMock()
    repo.get = AsyncMock(return_value=None)
    repo.update = AsyncMock()
    repo.delete = AsyncMock(return_value=False)
    repo.list_all = AsyncMock(return_value=([], 0))
    return repo


@pytest.fixture
def meeting_mixin_segments_repo() -> AsyncMock:
    """Create mock segments repository."""
    repo = AsyncMock()
    repo.get_by_meeting = AsyncMock(return_value=[])
    return repo


@pytest.fixture
def meeting_mixin_summaries_repo() -> AsyncMock:
    """Create mock summaries repository."""
    repo = AsyncMock()
    repo.get_by_meeting = AsyncMock(return_value=None)
    return repo


@pytest.fixture
def meeting_mixin_servicer(
    meeting_mixin_meetings_repo: AsyncMock,
    meeting_mixin_segments_repo: AsyncMock,
    meeting_mixin_summaries_repo: AsyncMock,
) -> MeetingServicerProtocol:
    """Create servicer with mock repositories for meeting mixin tests."""
    # Cast required: proto stubs are excluded from analysis; mixin methods type as unknown.
    return cast(
        MeetingServicerProtocol,
        MockMeetingMixinServicerHost(
            meeting_mixin_meetings_repo,
            meeting_mixin_segments_repo,
            meeting_mixin_summaries_repo,
        ),
    )


# ============================================================================
# TestCreateMeeting
# ============================================================================


class TestCreateMeeting:
    """Tests for CreateMeeting RPC."""

    async def test_create_meeting_with_title(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """CreateMeeting creates meeting with provided title."""
        expected_meeting = Meeting.create(title="Team Standup")
        meeting_mixin_meetings_repo.create.return_value = expected_meeting

        request: CreateMeetingRequestProto = noteflow_pb2.CreateMeetingRequest(
            title="Team Standup"
        )
        response: MeetingProto = await meeting_mixin_servicer.CreateMeeting(
            request, mock_grpc_context
        )

        assert response.id != "", "Response should have meeting ID"
        assert response.title == "Team Standup", "Title should match request"
        assert response.state == MeetingState.CREATED.value, "State should be CREATED"
        meeting_mixin_meetings_repo.create.assert_called_once()

    async def test_create_meeting_without_title_uses_default(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """CreateMeeting uses timestamped default title when not provided."""
        # Meeting.create() generates default title with timestamp
        expected_meeting = Meeting.create(title="")
        meeting_mixin_meetings_repo.create.return_value = expected_meeting

        request: CreateMeetingRequestProto = noteflow_pb2.CreateMeetingRequest()
        response: MeetingProto = await meeting_mixin_servicer.CreateMeeting(
            request, mock_grpc_context
        )

        assert response.id != "", "Response should have meeting ID"
        assert "Meeting" in response.title, "Default title should contain 'Meeting'"
        meeting_mixin_meetings_repo.create.assert_called_once()

    async def test_grpc_create_meeting_with_metadata(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """CreateMeeting preserves metadata from request."""
        metadata = {"project": "NoteFlow", "priority": "high"}
        expected_meeting = Meeting.create(title="Planning", metadata=metadata)
        meeting_mixin_meetings_repo.create.return_value = expected_meeting

        request: CreateMeetingRequestProto = noteflow_pb2.CreateMeetingRequest(
            title="Planning",
            metadata=metadata,
        )
        response: MeetingProto = await meeting_mixin_servicer.CreateMeeting(
            request, mock_grpc_context
        )

        assert response.metadata["project"] == "NoteFlow", "Project metadata should match"
        assert response.metadata["priority"] == "high", "Priority metadata should match"
        meeting_mixin_meetings_repo.create.assert_called_once()

        # Verify the meeting passed to create had metadata
        created_meeting = meeting_mixin_meetings_repo.create.call_args[0][0]
        assert created_meeting.metadata == metadata, "Created meeting should have metadata"

    async def test_create_meeting_commits_transaction(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """CreateMeeting commits the transaction after creation."""
        expected_meeting = Meeting.create(title="Commit Test")
        meeting_mixin_meetings_repo.create.return_value = expected_meeting

        request: CreateMeetingRequestProto = noteflow_pb2.CreateMeetingRequest(title="Commit Test")
        await meeting_mixin_servicer.CreateMeeting(request, mock_grpc_context)

        # Verify create was called (commit happens in context manager)
        meeting_mixin_meetings_repo.create.assert_called_once()


# ============================================================================
# TestStopMeeting
# ============================================================================


class TestStopMeeting:
    """Tests for StopMeeting RPC."""

    async def test_stop_recording_meeting_transitions_to_stopped(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """StopMeeting transitions RECORDING meeting to STOPPED."""
        meeting_id = MeetingId(uuid4())
        recording_meeting = Meeting.create(title="Recording Meeting")
        recording_meeting.id = meeting_id
        recording_meeting.start_recording()
        meeting_mixin_meetings_repo.get.return_value = recording_meeting

        request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
            meeting_id=str(meeting_id)
        )
        response: MeetingProto = await meeting_mixin_servicer.StopMeeting(
            request, mock_grpc_context
        )

        assert response.id == str(meeting_id), "Response ID should match"
        assert response.state == MeetingState.STOPPED.value, "State should be STOPPED"
        meeting_mixin_meetings_repo.update.assert_called_once()

    async def test_stop_is_idempotent_for_stopped_meeting(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """StopMeeting returns success for already STOPPED meeting (idempotent)."""
        meeting_id = MeetingId(uuid4())
        stopped_meeting = Meeting.create(title="Already Stopped")
        stopped_meeting.id = meeting_id
        stopped_meeting.state = MeetingState.STOPPED
        meeting_mixin_meetings_repo.get.return_value = stopped_meeting

        request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
            meeting_id=str(meeting_id)
        )
        response: MeetingProto = await meeting_mixin_servicer.StopMeeting(
            request, mock_grpc_context
        )

        assert response.state == MeetingState.STOPPED.value, "State should remain STOPPED"
        meeting_mixin_meetings_repo.update.assert_not_called()

    async def test_stop_is_idempotent_for_stopping_meeting(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """StopMeeting returns success for STOPPING meeting (idempotent)."""
        meeting_id = MeetingId(uuid4())
        stopping_meeting = Meeting.create(title="Currently Stopping")
        stopping_meeting.id = meeting_id
        stopping_meeting.state = MeetingState.STOPPING
        meeting_mixin_meetings_repo.get.return_value = stopping_meeting

        request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
            meeting_id=str(meeting_id)
        )
        response: MeetingProto = await meeting_mixin_servicer.StopMeeting(
            request, mock_grpc_context
        )

        assert response.state == MeetingState.STOPPING.value, "State should remain STOPPING"
        meeting_mixin_meetings_repo.update.assert_not_called()

    async def test_stop_is_idempotent_for_completed_meeting(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """StopMeeting returns success for COMPLETED meeting (idempotent)."""
        meeting_id = MeetingId(uuid4())
        completed_meeting = Meeting.create(title="Already Completed")
        completed_meeting.id = meeting_id
        completed_meeting.state = MeetingState.COMPLETED
        meeting_mixin_meetings_repo.get.return_value = completed_meeting

        request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
            meeting_id=str(meeting_id)
        )
        response: MeetingProto = await meeting_mixin_servicer.StopMeeting(
            request, mock_grpc_context
        )

        assert response.state == MeetingState.COMPLETED.value, "State should remain COMPLETED"
        meeting_mixin_meetings_repo.update.assert_not_called()

    async def test_stop_meeting_aborts_for_not_found(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """StopMeeting aborts with NOT_FOUND for nonexistent meeting."""
        meeting_mixin_meetings_repo.get.return_value = None
        meeting_id = uuid4()

        request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
            meeting_id=str(meeting_id)
        )

        with pytest.raises(AssertionError, match="Unreachable"):
            await meeting_mixin_servicer.StopMeeting(request, mock_grpc_context)

        mock_grpc_context.abort.assert_called_once()

    async def test_stop_meeting_rejects_invalid_meeting_id(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        mock_grpc_context: MagicMock,
    ) -> None:
        """StopMeeting aborts with INVALID_ARGUMENT for invalid meeting_id."""
        request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(meeting_id="not-a-uuid")

        with pytest.raises(AssertionError, match="Unreachable"):
            await meeting_mixin_servicer.StopMeeting(request, mock_grpc_context)

        mock_grpc_context.abort.assert_called_once()

    async def test_stop_meeting_closes_audio_writer(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """StopMeeting closes audio writer if present."""
        meeting_id = MeetingId(uuid4())
        recording_meeting = Meeting.create(title="Recording with Audio")
        recording_meeting.id = meeting_id
        recording_meeting.start_recording()
        meeting_mixin_meetings_repo.get.return_value = recording_meeting

        # Add mock audio writer
        mock_writer = MagicMock()
        meeting_mixin_servicer.audio_writers[str(meeting_id)] = mock_writer

        request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
            meeting_id=str(meeting_id)
        )
        await meeting_mixin_servicer.StopMeeting(request, mock_grpc_context)

        assert str(meeting_id) not in meeting_mixin_servicer.audio_writers, (
            "Audio writer should be removed"
        )

    async def test_stop_meeting_triggers_webhooks(
        self,
        meeting_mixin_meetings_repo: AsyncMock,
        meeting_mixin_segments_repo: AsyncMock,
        meeting_mixin_summaries_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """StopMeeting triggers recording.stopped and meeting.completed webhooks."""
        mockwebhook_service = MagicMock()
        mockwebhook_service.trigger_recording_stopped = AsyncMock()
        mockwebhook_service.trigger_meeting_completed = AsyncMock()

        # Cast required: proto stubs are excluded from analysis; mixin methods type as unknown.
        servicer: MeetingServicerProtocol = cast(
            MeetingServicerProtocol,
            MockMeetingMixinServicerHost(
                meeting_mixin_meetings_repo,
                meeting_mixin_segments_repo,
                meeting_mixin_summaries_repo,
                webhook_service=mockwebhook_service,
            ),
        )

        meeting_id = MeetingId(uuid4())
        recording_meeting = Meeting.create(title="Webhook Test")
        recording_meeting.id = meeting_id
        recording_meeting.start_recording()
        meeting_mixin_meetings_repo.get.return_value = recording_meeting

        request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
            meeting_id=str(meeting_id)
        )
        await servicer.StopMeeting(request, mock_grpc_context)

        mockwebhook_service.trigger_recording_stopped.assert_called_once()
        mockwebhook_service.trigger_meeting_completed.assert_called_once()


# ============================================================================
# TestListMeetings
# ============================================================================


class TestListMeetings:
    """Tests for ListMeetings RPC."""

    async def test_list_meetings_returns_empty_list(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListMeetings returns empty response when no meetings exist."""
        meeting_mixin_meetings_repo.list_all.return_value = ([], 0)

        request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest()
        response: ListMeetingsResponseProto = await meeting_mixin_servicer.ListMeetings(
            request, mock_grpc_context
        )

        expected_empty = 0
        assert response.total_count == expected_empty, "Total count should be 0"
        assert len(response.meetings) == expected_empty, "Should return no meetings"

    async def test_list_meetings_returns_multiple_meetings(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListMeetings returns all meetings."""
        meetings = [
            Meeting.create(title="Meeting 1"),
            Meeting.create(title="Meeting 2"),
            Meeting.create(title="Meeting 3"),
        ]
        expected_count = len(meetings)
        meeting_mixin_meetings_repo.list_all.return_value = (meetings, expected_count)

        request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest()
        response: ListMeetingsResponseProto = await meeting_mixin_servicer.ListMeetings(
            request, mock_grpc_context
        )

        assert response.total_count == expected_count, "Total count should be 3"
        assert len(response.meetings) == expected_count, "Should return 3 meetings"
        assert response.meetings[0].title == "Meeting 1", "First meeting title should match"
        assert response.meetings[1].title == "Meeting 2", "Second meeting title should match"
        assert response.meetings[2].title == "Meeting 3", "Third meeting title should match"

    async def test_list_meetings_respects_limit(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListMeetings respects limit parameter."""
        meeting_mixin_meetings_repo.list_all.return_value = ([], 0)

        request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest(limit=PAGE_LIMIT_SMALL)
        await meeting_mixin_servicer.ListMeetings(request, mock_grpc_context)

        call_kwargs = meeting_mixin_meetings_repo.list_all.call_args[1]
        assert call_kwargs["limit"] == PAGE_LIMIT_SMALL, "Limit should be passed to repository"

    async def test_list_meetings_uses_default_limit_of_100(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListMeetings uses default limit of 100 when not specified."""
        meeting_mixin_meetings_repo.list_all.return_value = ([], 0)

        request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest()
        await meeting_mixin_servicer.ListMeetings(request, mock_grpc_context)

        call_kwargs = meeting_mixin_meetings_repo.list_all.call_args[1]
        assert call_kwargs["limit"] == 100, "Default limit should be 100"

    async def test_list_meetings_respects_offset(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListMeetings respects offset parameter."""
        meeting_mixin_meetings_repo.list_all.return_value = ([], 0)

        request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest(
            offset=PAGE_OFFSET_STANDARD
        )
        await meeting_mixin_servicer.ListMeetings(request, mock_grpc_context)

        call_kwargs = meeting_mixin_meetings_repo.list_all.call_args[1]
        assert call_kwargs["offset"] == PAGE_OFFSET_STANDARD, (
            "Offset should be passed to repository"
        )

    @pytest.mark.parametrize(
        ("proto_sort_order", "expected_desc"),
        [
            (noteflow_pb2.SORT_ORDER_CREATED_ASC, False),
            (noteflow_pb2.SORT_ORDER_CREATED_DESC, True),
            (noteflow_pb2.SORT_ORDER_UNSPECIFIED, True),
        ],
        ids=["ascending", "descending", "default_descending"],
    )
    async def test_list_meetings_respects_sort_order(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        proto_sort_order: int,
        expected_desc: bool,
    ) -> None:
        """ListMeetings respects sort_order parameter."""
        meeting_mixin_meetings_repo.list_all.return_value = ([], 0)

        request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest(
            sort_order=proto_sort_order
        )
        await meeting_mixin_servicer.ListMeetings(request, mock_grpc_context)

        call_kwargs = meeting_mixin_meetings_repo.list_all.call_args[1]
        assert call_kwargs["sort_desc"] == expected_desc, f"Sort desc should be {expected_desc}"

    async def test_list_meetings_filters_by_state(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListMeetings filters by meeting state."""
        meeting_mixin_meetings_repo.list_all.return_value = ([], 0)

        request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest(
            states=[
                noteflow_pb2.MEETING_STATE_RECORDING,
                noteflow_pb2.MEETING_STATE_STOPPED,
            ]
        )
        await meeting_mixin_servicer.ListMeetings(request, mock_grpc_context)

        call_kwargs = meeting_mixin_meetings_repo.list_all.call_args[1]
        assert call_kwargs["states"] is not None, "States should be passed to repository"
        assert MeetingState.RECORDING in call_kwargs["states"], "RECORDING should be in filter"
        assert MeetingState.STOPPED in call_kwargs["states"], "STOPPED should be in filter"


# ============================================================================
# TestGetMeeting
# ============================================================================


class TestGetMeeting:
    """Tests for GetMeeting RPC."""

    async def test_get_meeting_returns_meeting_by_id(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetMeeting returns meeting when found."""
        meeting_id = MeetingId(uuid4())
        meeting = Meeting.create(title="Test Meeting")
        meeting.id = meeting_id
        meeting_mixin_meetings_repo.get.return_value = meeting

        request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(meeting_id=str(meeting_id))
        response: MeetingProto = await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)

        assert response.id == str(meeting_id), "Response ID should match"
        assert response.title == "Test Meeting", "Title should match"
        meeting_mixin_meetings_repo.get.assert_called_once_with(meeting_id)

    async def test_get_meeting_aborts_when_not_found(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetMeeting aborts with NOT_FOUND for nonexistent meeting."""
        meeting_mixin_meetings_repo.get.return_value = None
        meeting_id = uuid4()

        request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(meeting_id=str(meeting_id))

        with pytest.raises(AssertionError, match="Unreachable"):
            await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)

        mock_grpc_context.abort.assert_called_once()

    async def test_get_meeting_rejects_invalid_id(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetMeeting aborts with INVALID_ARGUMENT for invalid meeting_id."""
        request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(meeting_id="not-a-uuid")

        with pytest.raises(AssertionError, match="Unreachable"):
            await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)

        mock_grpc_context.abort.assert_called_once()

    async def test_get_meeting_includes_segments_when_requested(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        meeting_mixin_segments_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetMeeting loads segments when include_segments is True."""
        from noteflow.domain.entities import Segment

        meeting_id = MeetingId(uuid4())
        meeting = Meeting.create(title="Meeting with Segments")
        meeting.id = meeting_id
        meeting_mixin_meetings_repo.get.return_value = meeting

        segments = [
            Segment(
                segment_id=0,
                text="Hello world",
                start_time=0.0,
                end_time=1.0,
                meeting_id=meeting_id,
            ),
            Segment(
                segment_id=1,
                text="Goodbye world",
                start_time=1.0,
                end_time=2.0,
                meeting_id=meeting_id,
            ),
        ]
        meeting_mixin_segments_repo.get_by_meeting.return_value = segments

        request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(
            meeting_id=str(meeting_id),
            include_segments=True,
        )
        response: MeetingProto = await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)

        expected_segment_count = len(segments)
        assert len(response.segments) == expected_segment_count, "Should include 2 segments"
        assert response.segments[0].text == "Hello world", "First segment text should match"
        meeting_mixin_segments_repo.get_by_meeting.assert_called_once_with(meeting.id)

    async def test_get_meeting_includes_summary_when_requested(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        meeting_mixin_summaries_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetMeeting loads summary when include_summary is True."""
        from noteflow.domain.entities import Summary

        meeting_id = MeetingId(uuid4())
        meeting = Meeting.create(title="Meeting with Summary")
        meeting.id = meeting_id
        meeting_mixin_meetings_repo.get.return_value = meeting

        summary = Summary(
            meeting_id=meeting_id,
            executive_summary="This was a productive meeting.",
            key_points=[],
            action_items=[],
        )
        meeting_mixin_summaries_repo.get_by_meeting.return_value = summary

        request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(
            meeting_id=str(meeting_id),
            include_summary=True,
        )
        response: MeetingProto = await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)

        assert response.summary is not None, "Summary should be included"
        assert response.summary.executive_summary == "This was a productive meeting.", (
            "executive summary should match"
        )
        meeting_mixin_summaries_repo.get_by_meeting.assert_called_once_with(meeting.id)

    async def test_get_meeting_excludes_segments_when_not_requested(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        meeting_mixin_segments_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetMeeting does not load segments when include_segments is False."""
        meeting_id = MeetingId(uuid4())
        meeting = Meeting.create(title="Meeting without Segments")
        meeting.id = meeting_id
        meeting_mixin_meetings_repo.get.return_value = meeting

        request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(
            meeting_id=str(meeting_id),
            include_segments=False,
        )
        await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)

        meeting_mixin_segments_repo.get_by_meeting.assert_not_called()


# ============================================================================
# TestDeleteMeeting
# ============================================================================


class TestDeleteMeeting:
    """Tests for DeleteMeeting RPC."""

    async def test_delete_meeting_succeeds_for_existing(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteMeeting returns success=true for existing meeting."""
        meeting_mixin_meetings_repo.delete.return_value = True
        meeting_id = uuid4()

        request: DeleteMeetingRequestProto = noteflow_pb2.DeleteMeetingRequest(
            meeting_id=str(meeting_id)
        )
        response: DeleteMeetingResponseProto = await meeting_mixin_servicer.DeleteMeeting(
            request, mock_grpc_context
        )

        assert response.success is True, "Should return success=true"
        meeting_mixin_meetings_repo.delete.assert_called_once()

    async def test_delete_meeting_aborts_when_not_found(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteMeeting aborts with NOT_FOUND for nonexistent meeting."""
        meeting_mixin_meetings_repo.delete.return_value = False
        meeting_id = uuid4()

        request: DeleteMeetingRequestProto = noteflow_pb2.DeleteMeetingRequest(
            meeting_id=str(meeting_id)
        )

        with pytest.raises(AssertionError, match="Unreachable"):
            await meeting_mixin_servicer.DeleteMeeting(request, mock_grpc_context)

        mock_grpc_context.abort.assert_called_once()

    async def test_delete_meeting_rejects_invalid_id(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteMeeting aborts with INVALID_ARGUMENT for invalid meeting_id."""
        request: DeleteMeetingRequestProto = noteflow_pb2.DeleteMeetingRequest(
            meeting_id="not-a-uuid"
        )

        with pytest.raises(AssertionError, match="Unreachable"):
            await meeting_mixin_servicer.DeleteMeeting(request, mock_grpc_context)

        mock_grpc_context.abort.assert_called_once()

    async def test_delete_meeting_commits_after_success(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteMeeting commits transaction after successful deletion."""
        meeting_mixin_meetings_repo.delete.return_value = True
        meeting_id = uuid4()

        request: DeleteMeetingRequestProto = noteflow_pb2.DeleteMeetingRequest(
            meeting_id=str(meeting_id)
        )
        response: DeleteMeetingResponseProto = await meeting_mixin_servicer.DeleteMeeting(
            request, mock_grpc_context
        )

        assert response.success is True, "Delete should succeed"
        # The commit is called on the context manager, which happens in the mixin


# ============================================================================
# TestMeetingProtoConversion
# ============================================================================


class TestMeetingProtoConversion:
    """Tests for meeting proto conversion."""

    async def test_meeting_proto_includes_all_fields(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """Meeting proto includes all expected fields."""
        meeting_id = MeetingId(uuid4())
        meeting = Meeting.create(
            title="Full Meeting",
            metadata={"key": "value"},
        )
        meeting.id = meeting_id
        meeting_mixin_meetings_repo.get.return_value = meeting

        request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(meeting_id=str(meeting_id))
        response: MeetingProto = await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)

        assert response.id == str(meeting_id), "ID should be string UUID"
        assert response.title == "Full Meeting", "Title should match"
        assert response.state == MeetingState.CREATED.value, "State should be CREATED"
        assert response.created_at > 0, "Created_at should be positive timestamp"
        assert response.metadata["key"] == "value", "Metadata should be preserved"

    async def test_list_response_excludes_segments_for_performance(
        self,
        meeting_mixin_servicer: MeetingServicerProtocol,
        meeting_mixin_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListMeetings response excludes segments for performance."""
        meeting = Meeting.create(title="Meeting")
        meeting_mixin_meetings_repo.list_all.return_value = ([meeting], 1)

        request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest()
        response: ListMeetingsResponseProto = await meeting_mixin_servicer.ListMeetings(
            request, mock_grpc_context
        )

        # Segments are excluded from list response
        expected_empty = 0
        assert len(response.meetings[0].segments) == expected_empty, (
            "Segments should be empty in list"
        )
