"""Tests for ProjectMixin gRPC endpoints.

Tests cover:
- CreateProject: basic creation, with settings, with slug
- GetProject: found, not found
- GetProjectBySlug: found, not found
- ListProjects: empty list, multiple projects, pagination
- UpdateProject: name, description, settings
- ArchiveProject: success, default project error
- RestoreProject: success, not found
- DeleteProject: success, not found (returns success=false)
- AddProjectMember: success
- UpdateProjectMemberRole: success, not found
- RemoveProjectMember: success, not found (returns success=false)
- ListProjectMembers: empty list, multiple members
"""


from __future__ import annotations

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

import pytest

from noteflow.application.services.project_service import ProjectService
from noteflow.domain.entities.project import (
    ExportRules,
    Project,
    ProjectSettings,
    TriggerRules,
)
from noteflow.domain.identity.entities import ProjectMembership
from noteflow.domain.identity.roles import ProjectRole
from noteflow.domain.ports.repositories.identity import (
    ProjectMembershipRepository,
    ProjectRepository,
    WorkspaceRepository,
)
from noteflow.domain.value_objects import ExportFormat
from noteflow.grpc._mixins.project import ProjectMembershipMixin, ProjectMixin
from noteflow.grpc.proto import noteflow_pb2

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


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

    def __init__(
        self,
        supports_projects: bool = True,
        supports_workspaces: bool = True,
        projects_repo: ProjectRepository | None = None,
        memberships_repo: ProjectMembershipRepository | None = None,
        workspaces_repo: WorkspaceRepository | None = None,
    ) -> None:
        """Initialize provider with capability flags."""
        self.supports_projects = supports_projects
        self.supports_workspaces = supports_workspaces
        self.projects: ProjectRepository = projects_repo or MagicMock(spec=ProjectRepository)
        self.project_memberships: ProjectMembershipRepository = (
            memberships_repo or MagicMock(spec=ProjectMembershipRepository)
        )
        self.workspaces: WorkspaceRepository = workspaces_repo or MagicMock(spec=WorkspaceRepository)
        self.commit = AsyncMock()

    async def __aenter__(self) -> MockProjectRepositoryProvider:
        """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 MockProjectServicerHost(ProjectMixin, ProjectMembershipMixin):
    """Mock servicer host implementing required protocol for ProjectMixin.

    Implements the minimal ServicerHost protocol needed by ProjectMixin
    and ProjectMembershipMixin:
    - project_service for all project operations
    - create_repository_provider() for UnitOfWork context
    """

    def __init__(
        self,
        project_service: MagicMock | None,
        supports_projects: bool = True,
        supports_workspaces: bool = True,
    ) -> None:
        """Initialize with mock project service and repository provider."""
        self.project_service = project_service
        self._supports_projects = supports_projects
        self._supports_workspaces = supports_workspaces

    def create_repository_provider(self) -> MockProjectRepositoryProvider:
        """Create mock repository provider context manager."""
        return MockProjectRepositoryProvider(
            supports_projects=self._supports_projects,
            supports_workspaces=self._supports_workspaces,
        )


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


@pytest.fixture
def mockproject_service() -> MagicMock:
    """Create mock project service with common methods."""
    service = MagicMock(spec=ProjectService)
    # Project CRUD
    service.create_project = AsyncMock(return_value=None)
    service.get_project = AsyncMock(return_value=None)
    service.get_project_by_slug = AsyncMock(return_value=None)
    service.list_projects = AsyncMock(return_value=[])
    service.update_project = AsyncMock(return_value=None)
    service.archive_project = AsyncMock(return_value=None)
    service.restore_project = AsyncMock(return_value=None)
    service.delete_project = AsyncMock(return_value=False)
    # Membership
    service.add_project_member = AsyncMock(return_value=None)
    service.update_project_member_role = AsyncMock(return_value=None)
    service.remove_project_member = AsyncMock(return_value=False)
    service.list_project_members = AsyncMock(return_value=[])
    return service


@pytest.fixture
def project_mixin_servicer(
    mockproject_service: MagicMock,
) -> MockProjectServicerHost:
    """Create servicer with mock project service."""
    return MockProjectServicerHost(mockproject_service)


@pytest.fixture
def sample_project() -> Project:
    """Create sample project for testing."""
    return Project(
        id=uuid4(),
        workspace_id=uuid4(),
        name="Test Project",
        slug="test-project",
        description="A test project",
        is_default=False,
        settings=ProjectSettings(),
    )


@pytest.fixture
def sample_project_with_settings() -> Project:
    """Create sample project with full settings."""
    template_id = uuid4()
    return Project(
        id=uuid4(),
        workspace_id=uuid4(),
        name="Configured Project",
        slug="configured",
        settings=ProjectSettings(
            export_rules=ExportRules(
                default_format=ExportFormat.PDF,
                include_audio=True,
                include_timestamps=True,
                template_id=template_id,
            ),
            trigger_rules=TriggerRules(
                auto_start_enabled=True,
                calendar_match_patterns=["*standup*"],
                app_match_patterns=["Zoom"],
            ),
            rag_enabled=True,
            default_summarization_template="professional",
        ),
    )


@pytest.fixture
def sample_membership() -> ProjectMembership:
    """Create sample project membership."""
    return ProjectMembership(
        project_id=uuid4(),
        user_id=uuid4(),
        role=ProjectRole.EDITOR,
    )


# ============================================================================
# TestCreateProject
# ============================================================================


class TestCreateProject:
    """Tests for CreateProject RPC."""

    async def test_create_project_basic(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
        sample_project: Project,
    ) -> None:
        """CreateProject creates project with provided name."""
        mockproject_service.create_project.return_value = sample_project

        request = noteflow_pb2.CreateProjectRequest(
            workspace_id=str(sample_project.workspace_id),
            name="Test Project",
        )
        response = await project_mixin_servicer.CreateProject(request, mock_grpc_context)

        # Response is ProjectProto directly (not wrapped)
        assert response.id == str(sample_project.id), "ID should match"
        assert response.name == "Test Project", "Name should match"
        mockproject_service.create_project.assert_called_once()

    async def test_create_project_with_slug(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
        sample_project: Project,
    ) -> None:
        """CreateProject accepts optional slug."""
        mockproject_service.create_project.return_value = sample_project

        request = noteflow_pb2.CreateProjectRequest(
            workspace_id=str(sample_project.workspace_id),
            name="Test Project",
            slug="test-project",
        )
        response = await project_mixin_servicer.CreateProject(request, mock_grpc_context)

        # Response is ProjectProto directly
        assert response.slug == "test-project", "Slug should match"

    async def test_create_project_with_settings(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
        sample_project_with_settings: Project,
    ) -> None:
        """CreateProject accepts settings."""
        mockproject_service.create_project.return_value = sample_project_with_settings

        request = noteflow_pb2.CreateProjectRequest(
            workspace_id=str(sample_project_with_settings.workspace_id),
            name="Configured Project",
            settings=noteflow_pb2.ProjectSettingsProto(
                rag_enabled=True,
                export_rules=noteflow_pb2.ExportRulesProto(
                    default_format=noteflow_pb2.EXPORT_FORMAT_PDF,
                ),
            ),
        )
        response = await project_mixin_servicer.CreateProject(request, mock_grpc_context)

        # Response is ProjectProto directly
        assert response.settings.rag_enabled is True, "rag_enabled should be set"


# ============================================================================
# TestGetProject
# ============================================================================


class TestGetProject:
    """Tests for GetProject RPC."""

    async def test_get_project_found(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
        sample_project: Project,
    ) -> None:
        """GetProject returns project when found."""
        mockproject_service.get_project.return_value = sample_project

        request = noteflow_pb2.GetProjectRequest(project_id=str(sample_project.id))
        response = await project_mixin_servicer.GetProject(request, mock_grpc_context)

        # Response is ProjectProto directly
        assert response.id == str(sample_project.id), "ID should match"
        assert response.name == sample_project.name, "Name should match"

    async def test_get_project_not_found(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetProject aborts with NOT_FOUND when project doesn't exist."""
        mockproject_service.get_project.return_value = None

        request = noteflow_pb2.GetProjectRequest(project_id=str(uuid4()))

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestGetProjectBySlug
# ============================================================================


class TestGetProjectBySlug:
    """Tests for GetProjectBySlug RPC."""

    async def test_get_project_by_slug_found(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
        sample_project: Project,
    ) -> None:
        """GetProjectBySlug returns project when found."""
        mockproject_service.get_project_by_slug.return_value = sample_project

        request = noteflow_pb2.GetProjectBySlugRequest(
            workspace_id=str(sample_project.workspace_id),
            slug="test-project",
        )
        response = await project_mixin_servicer.GetProjectBySlug(request, mock_grpc_context)

        # Response is ProjectProto directly
        assert response.slug == "test-project", "Slug should match"

    async def test_get_project_by_slug_not_found(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetProjectBySlug aborts with NOT_FOUND when project doesn't exist."""
        mockproject_service.get_project_by_slug.return_value = None

        request = noteflow_pb2.GetProjectBySlugRequest(
            workspace_id=str(uuid4()),
            slug="nonexistent",
        )

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestListProjects
# ============================================================================


class TestListProjects:
    """Tests for ListProjects RPC."""

    async def test_list_projects_empty(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListProjects returns empty list when no projects exist."""
        mockproject_service.list_projects.return_value = []

        request = noteflow_pb2.ListProjectsRequest(workspace_id=str(uuid4()))
        response = await project_mixin_servicer.ListProjects(request, mock_grpc_context)

        assert len(response.projects) == 0, "Should return empty list"

    async def test_list_projects_returns_multiple(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListProjects returns multiple projects."""
        workspace_id = uuid4()
        projects = [
            Project(id=uuid4(), workspace_id=workspace_id, name=f"Project {i}")
            for i in range(3)
        ]
        mockproject_service.list_projects.return_value = projects

        request = noteflow_pb2.ListProjectsRequest(workspace_id=str(workspace_id))
        response = await project_mixin_servicer.ListProjects(request, mock_grpc_context)

        assert len(response.projects) == 3, "Should return all projects"

    async def test_list_projects_with_pagination(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListProjects respects pagination parameters."""
        workspace_id = uuid4()
        mockproject_service.list_projects.return_value = []

        request = noteflow_pb2.ListProjectsRequest(
            workspace_id=str(workspace_id),
            limit=10,
            offset=5,
        )
        await project_mixin_servicer.ListProjects(request, mock_grpc_context)

        mockproject_service.list_projects.assert_called_once()
        call_kwargs = mockproject_service.list_projects.call_args[1]
        assert call_kwargs["limit"] == 10, "Limit should be passed"
        assert call_kwargs["offset"] == 5, "Offset should be passed"


# ============================================================================
# TestUpdateProject
# ============================================================================


class TestUpdateProject:
    """Tests for UpdateProject RPC."""

    async def test_update_project_name(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
        sample_project: Project,
    ) -> None:
        """UpdateProject updates project name."""
        updated_project = Project(
            id=sample_project.id,
            workspace_id=sample_project.workspace_id,
            name="Updated Name",
        )
        mockproject_service.update_project.return_value = updated_project

        request = noteflow_pb2.UpdateProjectRequest(
            project_id=str(sample_project.id),
            name="Updated Name",
        )
        response = await project_mixin_servicer.UpdateProject(request, mock_grpc_context)

        # Response is ProjectProto directly
        assert response.name == "Updated Name", "Name should be updated"

    async def test_update_project_not_found(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateProject aborts with NOT_FOUND when project doesn't exist."""
        mockproject_service.update_project.return_value = None

        request = noteflow_pb2.UpdateProjectRequest(
            project_id=str(uuid4()),
            name="Updated Name",
        )

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestArchiveProject
# ============================================================================


class TestArchiveProject:
    """Tests for ArchiveProject RPC."""

    async def test_archive_project_success(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
        sample_project: Project,
    ) -> None:
        """ArchiveProject archives project successfully."""
        sample_project.archive()
        mockproject_service.archive_project.return_value = sample_project

        request = noteflow_pb2.ArchiveProjectRequest(project_id=str(sample_project.id))
        response = await project_mixin_servicer.ArchiveProject(request, mock_grpc_context)

        # Response is ProjectProto directly
        assert response.archived_at > 0, "archived_at should be set"

    async def test_archive_default_project_fails(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ArchiveProject fails for default project (mixin converts to gRPC error)."""
        from noteflow.domain.errors import CannotArchiveDefaultProjectError

        mockproject_service.archive_project.side_effect = CannotArchiveDefaultProjectError(
            "test-id"
        )

        request = noteflow_pb2.ArchiveProjectRequest(project_id=str(uuid4()))

        # Mixin catches the error and calls abort_failed_precondition
        with pytest.raises(AssertionError, match="Unreachable"):
            await project_mixin_servicer.ArchiveProject(request, mock_grpc_context)

        mock_grpc_context.abort.assert_called_once()

    async def test_archive_project_not_found(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ArchiveProject aborts with NOT_FOUND when project doesn't exist."""
        mockproject_service.archive_project.return_value = None

        request = noteflow_pb2.ArchiveProjectRequest(project_id=str(uuid4()))

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestRestoreProject
# ============================================================================


class TestRestoreProject:
    """Tests for RestoreProject RPC."""

    async def test_restore_project_success(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
        sample_project: Project,
    ) -> None:
        """RestoreProject restores archived project."""
        mockproject_service.restore_project.return_value = sample_project

        request = noteflow_pb2.RestoreProjectRequest(project_id=str(sample_project.id))
        response = await project_mixin_servicer.RestoreProject(request, mock_grpc_context)

        # Response is ProjectProto directly
        assert response.archived_at == 0, "archived_at should be cleared"

    async def test_restore_project_not_found(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RestoreProject aborts with NOT_FOUND when project doesn't exist."""
        mockproject_service.restore_project.return_value = None

        request = noteflow_pb2.RestoreProjectRequest(project_id=str(uuid4()))

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestDeleteProject
# ============================================================================


class TestDeleteProject:
    """Tests for DeleteProject RPC."""

    async def test_delete_project_success(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteProject deletes project successfully."""
        mockproject_service.delete_project.return_value = True

        request = noteflow_pb2.DeleteProjectRequest(project_id=str(uuid4()))
        response = await project_mixin_servicer.DeleteProject(request, mock_grpc_context)

        assert response.success is True, "Should return success"

    async def test_delete_project_not_found_returns_success_false(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteProject returns success=false when project doesn't exist."""
        mockproject_service.delete_project.return_value = False

        request = noteflow_pb2.DeleteProjectRequest(project_id=str(uuid4()))
        response = await project_mixin_servicer.DeleteProject(request, mock_grpc_context)

        # Mixin returns success=false without aborting
        assert response.success is False, "Should return success=false"


# ============================================================================
# TestAddProjectMember
# ============================================================================


class TestAddProjectMember:
    """Tests for AddProjectMember RPC."""

    async def test_add_project_member_success(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
        sample_membership: ProjectMembership,
    ) -> None:
        """AddProjectMember adds member successfully."""
        mockproject_service.add_project_member.return_value = sample_membership

        request = noteflow_pb2.AddProjectMemberRequest(
            project_id=str(sample_membership.project_id),
            user_id=str(sample_membership.user_id),
            role=noteflow_pb2.PROJECT_ROLE_EDITOR,
        )
        response = await project_mixin_servicer.AddProjectMember(request, mock_grpc_context)

        # Response is ProjectMembershipProto directly
        assert response.user_id == str(sample_membership.user_id), "User ID should match"
        assert response.role == noteflow_pb2.PROJECT_ROLE_EDITOR, "Role should match"

    async def test_add_project_member_not_found(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """AddProjectMember aborts with NOT_FOUND when project doesn't exist."""
        mockproject_service.add_project_member.return_value = None

        request = noteflow_pb2.AddProjectMemberRequest(
            project_id=str(uuid4()),
            user_id=str(uuid4()),
            role=noteflow_pb2.PROJECT_ROLE_EDITOR,
        )

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestUpdateProjectMemberRole
# ============================================================================


class TestUpdateProjectMemberRole:
    """Tests for UpdateProjectMemberRole RPC."""

    async def test_update_project_member_role_success(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateProjectMemberRole updates role successfully."""
        updated_membership = ProjectMembership(
            project_id=uuid4(),
            user_id=uuid4(),
            role=ProjectRole.ADMIN,
        )
        mockproject_service.update_project_member_role.return_value = updated_membership

        request = noteflow_pb2.UpdateProjectMemberRoleRequest(
            project_id=str(updated_membership.project_id),
            user_id=str(updated_membership.user_id),
            role=noteflow_pb2.PROJECT_ROLE_ADMIN,
        )
        response = await project_mixin_servicer.UpdateProjectMemberRole(
            request, mock_grpc_context
        )

        # Response is ProjectMembershipProto directly
        assert response.role == noteflow_pb2.PROJECT_ROLE_ADMIN, "Role should be updated"

    async def test_update_project_member_role_not_found(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateProjectMemberRole aborts with NOT_FOUND when membership doesn't exist."""
        mockproject_service.update_project_member_role.return_value = None

        request = noteflow_pb2.UpdateProjectMemberRoleRequest(
            project_id=str(uuid4()),
            user_id=str(uuid4()),
            role=noteflow_pb2.PROJECT_ROLE_ADMIN,
        )

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestRemoveProjectMember
# ============================================================================


class TestRemoveProjectMember:
    """Tests for RemoveProjectMember RPC."""

    async def test_remove_project_member_success(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RemoveProjectMember removes member successfully."""
        mockproject_service.remove_project_member.return_value = True

        request = noteflow_pb2.RemoveProjectMemberRequest(
            project_id=str(uuid4()),
            user_id=str(uuid4()),
        )
        response = await project_mixin_servicer.RemoveProjectMember(request, mock_grpc_context)

        assert response.success is True, "Should return success"

    async def test_remove_project_member_not_found(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RemoveProjectMember returns success=false when membership doesn't exist."""
        mockproject_service.remove_project_member.return_value = False

        request = noteflow_pb2.RemoveProjectMemberRequest(
            project_id=str(uuid4()),
            user_id=str(uuid4()),
        )
        response = await project_mixin_servicer.RemoveProjectMember(request, mock_grpc_context)

        # Mixin returns success=false without aborting
        assert response.success is False, "Should return success=false"


# ============================================================================
# TestListProjectMembers
# ============================================================================


class TestListProjectMembers:
    """Tests for ListProjectMembers RPC."""

    async def test_list_project_members_empty(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListProjectMembers returns empty list when no members exist."""
        mockproject_service.list_project_members.return_value = []

        request = noteflow_pb2.ListProjectMembersRequest(project_id=str(uuid4()))
        response = await project_mixin_servicer.ListProjectMembers(request, mock_grpc_context)

        assert len(response.members) == 0, "Should return empty list"

    async def test_list_project_members_returns_multiple(
        self,
        project_mixin_servicer: MockProjectServicerHost,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListProjectMembers returns multiple members."""
        project_id = uuid4()
        members = [
            ProjectMembership(
                project_id=project_id,
                user_id=uuid4(),
                role=ProjectRole.EDITOR,
            )
            for _ in range(3)
        ]
        mockproject_service.list_project_members.return_value = members

        request = noteflow_pb2.ListProjectMembersRequest(project_id=str(project_id))
        response = await project_mixin_servicer.ListProjectMembers(request, mock_grpc_context)

        assert len(response.members) == 3, "Should return all members"


# ============================================================================
# TestProjectServiceNotConfigured
# ============================================================================


class TestProjectServiceNotConfigured:
    """Tests for when project service is not configured."""

    async def test_create_project_fails_without_service(
        self,
        mock_grpc_context: MagicMock,
    ) -> None:
        """CreateProject aborts when project service is not configured."""
        servicer = MockProjectServicerHost(project_service=None)

        request = noteflow_pb2.CreateProjectRequest(
            workspace_id=str(uuid4()),
            name="Test",
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_get_project_fails_without_service(
        self,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetProject aborts when project service is not configured."""
        servicer = MockProjectServicerHost(project_service=None)

        request = noteflow_pb2.GetProjectRequest(project_id=str(uuid4()))

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestDatabaseNotConfigured
# ============================================================================


class TestDatabaseNotConfigured:
    """Tests for when database (supports_projects) is not configured."""

    async def test_create_project_fails_without_database(
        self,
        mockproject_service: MagicMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """CreateProject aborts when database doesn't support projects."""
        servicer = MockProjectServicerHost(
            project_service=mockproject_service,
            supports_projects=False,
        )

        request = noteflow_pb2.CreateProjectRequest(
            workspace_id=str(uuid4()),
            name="Test",
        )

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

        mock_grpc_context.abort.assert_called_once()
