"""Tests for EntitiesMixin gRPC endpoints.

Tests cover:
- ExtractEntities: extraction with entities returned, meeting not found, NER disabled
- UpdateEntity: success, entity not found, invalid entity_id format
- DeleteEntity: success, entity not found, invalid entity_id format
"""

from __future__ import annotations

from typing import TYPE_CHECKING
from unittest.mock import AsyncMock
from uuid import uuid4

import pytest

from noteflow.application.services.ner_service import ExtractionResult
from noteflow.domain.entities.named_entity import EntityCategory, NamedEntity
from noteflow.domain.value_objects import MeetingId
from noteflow.grpc._mixins._types import GrpcContext
from noteflow.grpc._mixins.entities import EntitiesMixin
from noteflow.grpc.proto import noteflow_pb2

if TYPE_CHECKING:
    from unittest.mock import MagicMock


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

    def __init__(
        self,
        entities_repo: AsyncMock,
        meetings_repo: AsyncMock | None = None,
    ) -> None:
        """Initialize with mock entity repository."""
        self.supports_entities = True
        self.entities = entities_repo
        self.meetings = meetings_repo or AsyncMock()
        self.commit = AsyncMock()

    async def __aenter__(self) -> MockRepositoryProvider:
        """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 MockServicerHost(EntitiesMixin):
    """Mock servicer host implementing required protocol."""

    def __init__(
        self,
        entities_repo: AsyncMock,
        meetings_repo: AsyncMock | None = None,
        ner_service: AsyncMock | None = None,
    ) -> None:
        """Initialize with mock repositories and optional NER service."""
        self._entities_repo = entities_repo
        self._meetings_repo = meetings_repo or AsyncMock()
        self.ner_service = ner_service

    def create_repository_provider(self) -> MockRepositoryProvider:
        """Create mock repository provider context manager."""
        return MockRepositoryProvider(self._entities_repo, self._meetings_repo)

    # Type stubs for mixin methods to fix type inference
    if TYPE_CHECKING:
        async def ExtractEntities(
            self,
            request: noteflow_pb2.ExtractEntitiesRequest,
            context: GrpcContext,
        ) -> noteflow_pb2.ExtractEntitiesResponse: ...

        async def UpdateEntity(
            self,
            request: noteflow_pb2.UpdateEntityRequest,
            context: GrpcContext,
        ) -> noteflow_pb2.UpdateEntityResponse: ...

        async def DeleteEntity(
            self,
            request: noteflow_pb2.DeleteEntityRequest,
            context: GrpcContext,
        ) -> noteflow_pb2.DeleteEntityResponse: ...


def create_sample_entity(
    meeting_id: MeetingId | None = None,
    text: str = "Test Entity",
    category: EntityCategory = EntityCategory.PERSON,
    is_pinned: bool = False,
) -> NamedEntity:
    """Create a sample NamedEntity for testing."""
    return NamedEntity(
        id=uuid4(),
        meeting_id=meeting_id,
        text=text,
        normalized_text=text.lower(),
        category=category,
        segment_ids=[0, 1, 2],
        confidence=0.95,
        is_pinned=is_pinned,
    )


@pytest.fixture
def mock_entities_repo() -> AsyncMock:
    """Create mock entity repository with common methods.

    Returns:
        AsyncMock with get, update, delete methods.
    """
    repo = AsyncMock()
    repo.get = AsyncMock(return_value=None)
    repo.update = AsyncMock(return_value=None)
    repo.delete = AsyncMock(return_value=False)
    return repo


@pytest.fixture
def mockner_service() -> AsyncMock:
    """Create mock NER service.

    Returns:
        AsyncMock with extract_entities method.
    """
    service = AsyncMock()
    service.extract_entities = AsyncMock()
    return service


class TestExtractEntities:
    """Tests for ExtractEntities RPC."""

    @pytest.fixture
    def servicer(
        self,
        mock_entities_repo: AsyncMock,
        mock_meetings_repo: AsyncMock,
        mockner_service: AsyncMock,
    ) -> MockServicerHost:
        """Create servicer with mock NER service."""
        return MockServicerHost(
            mock_entities_repo,
            mock_meetings_repo,
            mockner_service,
        )

    async def test_returns_extracted_entities(
        self,
        servicer: MockServicerHost,
        mockner_service: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ExtractEntities returns entities from NER service."""
        meeting_id = MeetingId(uuid4())
        entity1 = create_sample_entity(meeting_id, "John Doe", EntityCategory.PERSON)
        entity2 = create_sample_entity(meeting_id, "Acme Corp", EntityCategory.COMPANY)

        mockner_service.extract_entities.return_value = ExtractionResult(
            entities=[entity1, entity2],
            cached=False,
            total_count=2,
        )

        request = noteflow_pb2.ExtractEntitiesRequest(
            meeting_id=str(meeting_id),
            force_refresh=False,
        )
        response = await servicer.ExtractEntities(request, mock_grpc_context)

        assert len(response.entities) == 2, "should return 2 entities"
        assert response.total_count == 2, "total count should be 2"
        assert response.cached is False, "should not be cached"

        assert response.entities[0].text == "John Doe", "first entity text should match"
        assert response.entities[0].category == "person", "first entity category should be person"
        assert response.entities[1].text == "Acme Corp", "second entity text should match"
        assert response.entities[1].category == "company", "second entity category should be company"

    async def test_returns_cached_entities(
        self,
        servicer: MockServicerHost,
        mockner_service: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ExtractEntities returns cached result when available."""
        meeting_id = MeetingId(uuid4())
        entity = create_sample_entity(meeting_id, "Cached Person", EntityCategory.PERSON)

        mockner_service.extract_entities.return_value = ExtractionResult(
            entities=[entity],
            cached=True,
            total_count=1,
        )

        request = noteflow_pb2.ExtractEntitiesRequest(
            meeting_id=str(meeting_id),
            force_refresh=False,
        )
        response = await servicer.ExtractEntities(request, mock_grpc_context)

        assert response.cached is True, "should indicate cached result"
        assert len(response.entities) == 1, "should return 1 cached entity"
        mockner_service.extract_entities.assert_called_once_with(
            meeting_id=meeting_id,
            force_refresh=False,
        )

    async def test_force_refresh_bypasses_cache(
        self,
        servicer: MockServicerHost,
        mockner_service: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ExtractEntities with force_refresh re-extracts entities."""
        meeting_id = MeetingId(uuid4())
        entity = create_sample_entity(meeting_id, "Fresh Entity", EntityCategory.PERSON)

        mockner_service.extract_entities.return_value = ExtractionResult(
            entities=[entity],
            cached=False,
            total_count=1,
        )

        request = noteflow_pb2.ExtractEntitiesRequest(
            meeting_id=str(meeting_id),
            force_refresh=True,
        )
        response = await servicer.ExtractEntities(request, mock_grpc_context)

        assert response.cached is False, "should not be cached with force_refresh"
        mockner_service.extract_entities.assert_called_once_with(
            meeting_id=meeting_id,
            force_refresh=True,
        )

    async def test_aborts_when_meeting_not_found(
        self,
        servicer: MockServicerHost,
        mockner_service: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ExtractEntities aborts when meeting does not exist."""
        meeting_id = str(uuid4())
        mockner_service.extract_entities.side_effect = ValueError("Meeting not found")

        request = noteflow_pb2.ExtractEntitiesRequest(
            meeting_id=meeting_id,
            force_refresh=False,
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_aborts_whenner_service_not_configured(
        self,
        mock_entities_repo: AsyncMock,
        mock_meetings_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ExtractEntities aborts when NER service is None."""
        servicer = MockServicerHost(
            mock_entities_repo,
            mock_meetings_repo,
            ner_service=None,
        )

        request = noteflow_pb2.ExtractEntitiesRequest(
            meeting_id=str(uuid4()),
            force_refresh=False,
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_aborts_when_feature_disabled(
        self,
        servicer: MockServicerHost,
        mockner_service: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ExtractEntities aborts when NER feature is disabled."""
        mockner_service.extract_entities.side_effect = RuntimeError(
            "NER extraction is disabled by feature flag"
        )

        request = noteflow_pb2.ExtractEntitiesRequest(
            meeting_id=str(uuid4()),
            force_refresh=False,
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_extract_aborts_with_invalid_meeting_id_format(
        self,
        servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ExtractEntities aborts with invalid meeting_id format."""
        request = noteflow_pb2.ExtractEntitiesRequest(
            meeting_id="not-a-uuid",
            force_refresh=False,
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_returns_empty_entities_when_none_found(
        self,
        servicer: MockServicerHost,
        mockner_service: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ExtractEntities returns empty list when no entities extracted."""
        mockner_service.extract_entities.return_value = ExtractionResult(
            entities=[],
            cached=False,
            total_count=0,
        )

        request = noteflow_pb2.ExtractEntitiesRequest(
            meeting_id=str(uuid4()),
            force_refresh=False,
        )
        response = await servicer.ExtractEntities(request, mock_grpc_context)

        assert len(response.entities) == 0, "should return empty list"
        assert response.total_count == 0, "total count should be 0"

    async def test_includes_pinned_status_in_response(
        self,
        servicer: MockServicerHost,
        mockner_service: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ExtractEntities includes is_pinned status for each entity."""
        meeting_id = MeetingId(uuid4())
        pinned_entity = create_sample_entity(
            meeting_id, "Important Person", EntityCategory.PERSON, is_pinned=True
        )

        mockner_service.extract_entities.return_value = ExtractionResult(
            entities=[pinned_entity],
            cached=True,
            total_count=1,
        )

        request = noteflow_pb2.ExtractEntitiesRequest(
            meeting_id=str(meeting_id),
            force_refresh=False,
        )
        response = await servicer.ExtractEntities(request, mock_grpc_context)

        assert response.entities[0].is_pinned is True, "entity should be marked as pinned"


class TestUpdateEntity:
    """Tests for UpdateEntity RPC."""

    @pytest.fixture
    def servicer(
        self,
        mock_entities_repo: AsyncMock,
        mock_meetings_repo: AsyncMock,
    ) -> MockServicerHost:
        """Create servicer with mock repositories."""
        return MockServicerHost(mock_entities_repo, mock_meetings_repo)

    async def test_updates_entity_text(
        self,
        servicer: MockServicerHost,
        mock_entities_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateEntity successfully updates entity text."""
        meeting_id = MeetingId(uuid4())
        entity = create_sample_entity(meeting_id, "Original Name", EntityCategory.PERSON)

        updated_entity = create_sample_entity(meeting_id, "Updated Name", EntityCategory.PERSON)
        updated_entity.id = entity.id

        mock_entities_repo.get.return_value = entity
        mock_entities_repo.update.return_value = updated_entity

        request = noteflow_pb2.UpdateEntityRequest(
            meeting_id=str(meeting_id),
            entity_id=str(entity.id),
            text="Updated Name",
        )
        response = await servicer.UpdateEntity(request, mock_grpc_context)

        assert response.entity.text == "Updated Name", "entity text should be updated"
        mock_entities_repo.update.assert_called_once_with(
            entity_id=entity.id,
            text="Updated Name",
            category=None,
        )

    async def test_updates_entity_category(
        self,
        servicer: MockServicerHost,
        mock_entities_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateEntity successfully updates entity category."""
        meeting_id = MeetingId(uuid4())
        entity = create_sample_entity(meeting_id, "Test Name", EntityCategory.PERSON)

        updated_entity = create_sample_entity(meeting_id, "Test Name", EntityCategory.COMPANY)
        updated_entity.id = entity.id

        mock_entities_repo.get.return_value = entity
        mock_entities_repo.update.return_value = updated_entity

        request = noteflow_pb2.UpdateEntityRequest(
            meeting_id=str(meeting_id),
            entity_id=str(entity.id),
            category="company",
        )
        response = await servicer.UpdateEntity(request, mock_grpc_context)

        assert response.entity.category == "company", "entity category should be updated"

    async def test_update_aborts_when_entity_not_found(
        self,
        servicer: MockServicerHost,
        mock_entities_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateEntity aborts when entity does not exist."""
        mock_entities_repo.get.return_value = None

        request = noteflow_pb2.UpdateEntityRequest(
            meeting_id=str(uuid4()),
            entity_id=str(uuid4()),
            text="New Text",
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_update_aborts_when_entity_belongs_to_different_meeting(
        self,
        servicer: MockServicerHost,
        mock_entities_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateEntity aborts when entity belongs to a different meeting."""
        entity_meeting_id = MeetingId(uuid4())
        request_meeting_id = MeetingId(uuid4())
        entity = create_sample_entity(entity_meeting_id, "Test", EntityCategory.PERSON)

        mock_entities_repo.get.return_value = entity

        request = noteflow_pb2.UpdateEntityRequest(
            meeting_id=str(request_meeting_id),
            entity_id=str(entity.id),
            text="New Text",
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_update_aborts_with_invalid_entity_id_format(
        self,
        servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateEntity aborts with invalid entity_id format."""
        request = noteflow_pb2.UpdateEntityRequest(
            meeting_id=str(uuid4()),
            entity_id="not-a-uuid",
            text="New Text",
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_update_aborts_with_invalid_meeting_id_format(
        self,
        servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateEntity aborts with invalid meeting_id format."""
        request = noteflow_pb2.UpdateEntityRequest(
            meeting_id="not-a-uuid",
            entity_id=str(uuid4()),
            text="New Text",
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_aborts_with_invalid_category(
        self,
        servicer: MockServicerHost,
        mock_entities_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateEntity aborts with invalid category value."""
        meeting_id = MeetingId(uuid4())
        entity = create_sample_entity(meeting_id, "Test", EntityCategory.PERSON)

        mock_entities_repo.get.return_value = entity
        mock_entities_repo.update.side_effect = ValueError("Invalid category")

        request = noteflow_pb2.UpdateEntityRequest(
            meeting_id=str(meeting_id),
            entity_id=str(entity.id),
            category="invalid_category",
        )

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

        mock_grpc_context.abort.assert_called_once()


class TestDeleteEntity:
    """Tests for DeleteEntity RPC."""

    @pytest.fixture
    def servicer(
        self,
        mock_entities_repo: AsyncMock,
        mock_meetings_repo: AsyncMock,
    ) -> MockServicerHost:
        """Create servicer with mock repositories."""
        return MockServicerHost(mock_entities_repo, mock_meetings_repo)

    async def test_deletes_entity_successfully(
        self,
        servicer: MockServicerHost,
        mock_entities_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteEntity successfully removes entity."""
        meeting_id = MeetingId(uuid4())
        entity = create_sample_entity(meeting_id, "To Delete", EntityCategory.PERSON)

        mock_entities_repo.get.return_value = entity
        mock_entities_repo.delete.return_value = True

        request = noteflow_pb2.DeleteEntityRequest(
            meeting_id=str(meeting_id),
            entity_id=str(entity.id),
        )
        response = await servicer.DeleteEntity(request, mock_grpc_context)

        assert response.success is True, "delete should succeed"
        mock_entities_repo.delete.assert_called_once_with(entity.id)

    async def test_delete_aborts_when_entity_not_found(
        self,
        servicer: MockServicerHost,
        mock_entities_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteEntity aborts when entity does not exist."""
        mock_entities_repo.get.return_value = None

        request = noteflow_pb2.DeleteEntityRequest(
            meeting_id=str(uuid4()),
            entity_id=str(uuid4()),
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_delete_aborts_when_entity_belongs_to_different_meeting(
        self,
        servicer: MockServicerHost,
        mock_entities_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteEntity aborts when entity belongs to a different meeting."""
        entity_meeting_id = MeetingId(uuid4())
        request_meeting_id = MeetingId(uuid4())
        entity = create_sample_entity(entity_meeting_id, "Test", EntityCategory.PERSON)

        mock_entities_repo.get.return_value = entity

        request = noteflow_pb2.DeleteEntityRequest(
            meeting_id=str(request_meeting_id),
            entity_id=str(entity.id),
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_aborts_when_delete_fails(
        self,
        servicer: MockServicerHost,
        mock_entities_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteEntity aborts when delete operation fails."""
        meeting_id = MeetingId(uuid4())
        entity = create_sample_entity(meeting_id, "Test", EntityCategory.PERSON)

        mock_entities_repo.get.return_value = entity
        mock_entities_repo.delete.return_value = False

        request = noteflow_pb2.DeleteEntityRequest(
            meeting_id=str(meeting_id),
            entity_id=str(entity.id),
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_delete_aborts_with_invalid_entity_id_format(
        self,
        servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteEntity aborts with invalid entity_id format."""
        request = noteflow_pb2.DeleteEntityRequest(
            meeting_id=str(uuid4()),
            entity_id="not-a-uuid",
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_delete_aborts_with_invalid_meeting_id_format(
        self,
        servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteEntity aborts with invalid meeting_id format."""
        request = noteflow_pb2.DeleteEntityRequest(
            meeting_id="not-a-uuid",
            entity_id=str(uuid4()),
        )

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

        mock_grpc_context.abort.assert_called_once()


class TestDatabaseNotSupported:
    """Tests for when database/entities are not available."""

    @pytest.fixture
    def servicer_no_db(
        self,
        mock_entities_repo: AsyncMock,
        mockner_service: AsyncMock,
    ) -> MockServicerHost:
        """Create servicer with database not supported."""
        servicer = MockServicerHost(
            mock_entities_repo,
            ner_service=mockner_service,
        )
        provider = MockRepositoryProvider(mock_entities_repo)
        provider.supports_entities = False
        object.__setattr__(servicer, "create_repository_provider", lambda: provider)
        return servicer

    async def test_update_entity_aborts_without_database(
        self,
        servicer_no_db: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateEntity aborts when database not available."""
        request = noteflow_pb2.UpdateEntityRequest(
            meeting_id=str(uuid4()),
            entity_id=str(uuid4()),
            text="New Text",
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_delete_entity_aborts_without_database(
        self,
        servicer_no_db: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteEntity aborts when database not available."""
        request = noteflow_pb2.DeleteEntityRequest(
            meeting_id=str(uuid4()),
            entity_id=str(uuid4()),
        )

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

        mock_grpc_context.abort.assert_called_once()
