"""Tests for PreferencesMixin gRPC endpoints.

Tests cover:
- GetPreferences: retrieval with and without key filtering
- SetPreferences: merge and replace modes
- ETag-based conflict detection
- Invalid JSON handling
"""

from __future__ import annotations

import json
from datetime import UTC
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock

import pytest

from noteflow.grpc._mixins._types import GrpcContext
from noteflow.grpc._mixins.preferences import PreferencesMixin, compute_etag
from noteflow.grpc.proto import noteflow_pb2
from noteflow.infrastructure.persistence.repositories.preferences_repo import (
    PreferenceWithMetadata,
)

# Test constants
ETAG_HEX_DIGEST_LENGTH = 32

if TYPE_CHECKING:
    from datetime import datetime


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

    def __init__(self, preferences_repo: AsyncMock) -> None:
        """Initialize with mock preferences repository."""
        self.supports_preferences = True
        self.preferences = preferences_repo
        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(PreferencesMixin):
    """Mock servicer host implementing required protocol."""

    def __init__(self, preferences_repo: AsyncMock) -> None:
        """Initialize with mock preferences repository."""
        self._preferences_repo = preferences_repo

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

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

        async def SetPreferences(
            self,
            request: noteflow_pb2.SetPreferencesRequest,
            context: GrpcContext,
        ) -> noteflow_pb2.SetPreferencesResponse: ...


def create_pref_with_metadata(
    key: str,
    value: object,
    updated_at: datetime,
) -> PreferenceWithMetadata:
    """Create a PreferenceWithMetadata instance for testing."""
    return PreferenceWithMetadata(key=key, value=value, updated_at=updated_at)


class TestComputeEtag:
    """Tests for ETag computation."""

    def test_computes_deterministic_etag(self) -> None:
        """ETag is deterministic for same inputs."""
        prefs = {"key1": '"value1"', "key2": '"value2"'}
        updated_at = 1234567890.0

        etag1 = compute_etag(prefs, updated_at)
        etag2 = compute_etag(prefs, updated_at)

        assert etag1 == etag2, "ETag should be identical for same inputs"
        assert len(etag1) == ETAG_HEX_DIGEST_LENGTH, "ETag should be 32 chars (MD5 hex digest)"

    def test_different_values_produce_different_etag(self) -> None:
        """Different preference values produce different ETags."""
        prefs1 = {"key1": '"value1"'}
        prefs2 = {"key1": '"value2"'}
        updated_at = 1234567890.0

        etag1 = compute_etag(prefs1, updated_at)
        etag2 = compute_etag(prefs2, updated_at)

        assert etag1 != etag2, "Different values should produce different ETags"

    def test_different_timestamps_produce_different_etag(self) -> None:
        """Different timestamps produce different ETags."""
        prefs = {"key1": '"value1"'}

        etag1 = compute_etag(prefs, 1234567890.0)
        etag2 = compute_etag(prefs, 1234567891.0)

        assert etag1 != etag2, "Different timestamps should produce different ETags"


class TestGetPreferences:
    """Tests for GetPreferences RPC.

    Uses mock_grpc_context and mock_preferences_repo fixtures from conftest.py.
    """

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

    async def test_returns_all_preferences_when_no_keys_specified(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_datetime: datetime,
    ) -> None:
        """GetPreferences returns all preferences when no keys filter."""
        mock_preferences_repo.get_all_with_metadata.return_value = [
            create_pref_with_metadata("theme", "dark", sample_datetime),
            create_pref_with_metadata("volume", 80, sample_datetime),
        ]

        request = noteflow_pb2.GetPreferencesRequest()
        response = await servicer.GetPreferences(request, mock_grpc_context)

        assert response.preferences["theme"] == '"dark"', "theme should be JSON-encoded"
        assert response.preferences["volume"] == "80", "volume should be JSON-encoded"
        assert len(response.preferences) == 2, "should return all 2 preferences"
        assert response.etag, "should include ETag for sync"

    async def test_filters_by_keys_when_specified(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_datetime: datetime,
    ) -> None:
        """GetPreferences filters by keys when provided."""
        mock_preferences_repo.get_all_with_metadata.return_value = [
            create_pref_with_metadata("theme", "dark", sample_datetime),
        ]

        request = noteflow_pb2.GetPreferencesRequest(keys=["theme"])
        response = await servicer.GetPreferences(request, mock_grpc_context)

        mock_preferences_repo.get_all_with_metadata.assert_called_once_with(["theme"])
        assert "theme" in response.preferences, "filtered response should contain requested key"

    async def test_returns_empty_response_when_no_preferences(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetPreferences returns empty response when no preferences exist."""
        mock_preferences_repo.get_all_with_metadata.return_value = []

        request = noteflow_pb2.GetPreferencesRequest()
        response = await servicer.GetPreferences(request, mock_grpc_context)

        assert len(response.preferences) == 0, "empty preferences should return empty map"
        assert response.updated_at == 0.0, "empty preferences should have zero timestamp"
        assert response.etag, "response should include ETag even when empty"

    async def test_returns_correct_updated_at_timestamp(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetPreferences returns the maximum updated_at timestamp."""
        from datetime import datetime

        older = datetime(2024, 1, 10, 0, 0, 0, tzinfo=UTC)
        newer = datetime(2024, 1, 15, 0, 0, 0, tzinfo=UTC)

        mock_preferences_repo.get_all_with_metadata.return_value = [
            create_pref_with_metadata("old_pref", "value", older),
            create_pref_with_metadata("new_pref", "value", newer),
        ]

        request = noteflow_pb2.GetPreferencesRequest()
        response = await servicer.GetPreferences(request, mock_grpc_context)

        assert response.updated_at == newer.timestamp(), "should return max updated_at timestamp"


class TestSetPreferences:
    """Tests for SetPreferences RPC.

    Uses mock_grpc_context and mock_preferences_repo fixtures from conftest.py.
    """

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

    async def test_merge_mode_only_updates_provided_keys(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_datetime: datetime,
    ) -> None:
        """SetPreferences in merge mode only updates provided keys."""
        mock_preferences_repo.get_all_with_metadata.return_value = [
            create_pref_with_metadata("existing", "value", sample_datetime),
            create_pref_with_metadata("theme", "light", sample_datetime),
        ]

        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"theme": '"dark"'},
            merge=True,
        )
        response = await servicer.SetPreferences(request, mock_grpc_context)

        assert response.success is True, "merge mode should succeed"
        assert response.conflict is False, "merge mode should not report conflict"
        mock_preferences_repo.set_bulk.assert_called_once_with({"theme": "dark"})
        mock_preferences_repo.delete.assert_not_called()

    async def test_replace_mode_deletes_missing_keys(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_datetime: datetime,
    ) -> None:
        """SetPreferences in replace mode deletes keys not in request."""
        mock_preferences_repo.get_all_with_metadata.return_value = [
            create_pref_with_metadata("keep", "value", sample_datetime),
            create_pref_with_metadata("delete_me", "value", sample_datetime),
        ]

        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"keep": '"updated"'},
            merge=False,
        )
        response = await servicer.SetPreferences(request, mock_grpc_context)

        assert response.success is True, "replace mode should succeed"
        mock_preferences_repo.delete.assert_called_once_with("delete_me")
        mock_preferences_repo.set_bulk.assert_called_once_with({"keep": "updated"})

    async def test_detects_etag_conflict(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_datetime: datetime,
    ) -> None:
        """SetPreferences detects ETag conflict and returns server state."""
        mock_preferences_repo.get_all_with_metadata.return_value = [
            create_pref_with_metadata("theme", "dark", sample_datetime),
        ]

        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"theme": '"light"'},
            if_match="invalid_etag",
            merge=True,
        )
        response = await servicer.SetPreferences(request, mock_grpc_context)

        assert response.success is False, "should fail on ETag mismatch"
        assert response.conflict is True, "should indicate conflict"
        assert response.conflict_message, "should include conflict message"
        assert "theme" in response.server_preferences, "should return server preferences"
        mock_preferences_repo.set_bulk.assert_not_called()

    async def test_succeeds_with_matching_etag(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_datetime: datetime,
    ) -> None:
        """SetPreferences succeeds when ETag matches."""
        prefs = [create_pref_with_metadata("theme", "dark", sample_datetime)]
        mock_preferences_repo.get_all_with_metadata.return_value = prefs

        # Compute the correct ETag
        current_dict = {p.key: json.dumps(p.value) for p in prefs}
        correct_etag = compute_etag(current_dict, sample_datetime.timestamp())

        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"theme": '"light"'},
            if_match=correct_etag,
            merge=True,
        )
        response = await servicer.SetPreferences(request, mock_grpc_context)

        assert response.success is True, "matching ETag should succeed"
        assert response.conflict is False, "matching ETag should not report conflict"

    async def test_returns_updated_state_after_success(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_datetime: datetime,
    ) -> None:
        """SetPreferences returns updated server state after success."""
        from datetime import timedelta

        initial = [create_pref_with_metadata("theme", "light", sample_datetime)]
        updated = [
            create_pref_with_metadata(
                "theme", "dark", sample_datetime + timedelta(seconds=1)
            )
        ]

        mock_preferences_repo.get_all_with_metadata.side_effect = [initial, updated]

        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"theme": '"dark"'},
            merge=True,
        )
        response = await servicer.SetPreferences(request, mock_grpc_context)

        assert response.success is True, "should succeed with valid request"
        assert response.server_preferences["theme"] == '"dark"', "should return updated value"
        assert response.etag, "should include new ETag"
        assert response.server_updated_at > 0, "should include updated timestamp"

    async def test_handles_complex_json_values(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """SetPreferences handles complex nested JSON values."""
        mock_preferences_repo.get_all_with_metadata.return_value = []

        complex_value = {"nested": {"key": "value"}, "list": [1, 2, 3]}
        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"config": json.dumps(complex_value)},
            merge=True,
        )
        response = await servicer.SetPreferences(request, mock_grpc_context)

        assert response.success is True, "complex JSON values should be accepted"
        mock_preferences_repo.set_bulk.assert_called_once_with({"config": complex_value})

    async def test_rejects_invalid_json_values(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """SetPreferences aborts when preference value is invalid JSON."""
        mock_preferences_repo.get_all_with_metadata.return_value = []

        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"theme": "not-valid-json{"},
            merge=True,
        )
        # abort helpers raise AssertionError after mock context.abort()
        with pytest.raises(AssertionError, match="Unreachable"):
            await servicer.SetPreferences(request, mock_grpc_context)

        mock_grpc_context.abort.assert_called_once()
        mock_preferences_repo.set_bulk.assert_not_called()

    async def test_succeeds_with_empty_preferences(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_datetime: datetime,
    ) -> None:
        """SetPreferences with empty preferences is valid (clears in replace mode)."""
        mock_preferences_repo.get_all_with_metadata.return_value = [
            create_pref_with_metadata("existing", "value", sample_datetime),
        ]

        request = noteflow_pb2.SetPreferencesRequest(
            preferences={},
            merge=False,
        )
        response = await servicer.SetPreferences(request, mock_grpc_context)

        assert response.success is True, "empty replace should succeed"
        mock_preferences_repo.delete.assert_called_once_with("existing")

    async def test_force_update_without_etag(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_datetime: datetime,
    ) -> None:
        """SetPreferences with empty if_match bypasses ETag check."""
        mock_preferences_repo.get_all_with_metadata.return_value = [
            create_pref_with_metadata("theme", "dark", sample_datetime),
        ]

        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"theme": '"light"'},
            if_match="",  # Empty means force update
            merge=True,
        )
        response = await servicer.SetPreferences(request, mock_grpc_context)

        assert response.success is True, "empty etag should bypass check"
        assert response.conflict is False, "empty etag should not report conflict"
        mock_preferences_repo.set_bulk.assert_called_once()

    async def test_handles_null_preference_value(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """SetPreferences handles null values correctly."""
        mock_preferences_repo.get_all_with_metadata.return_value = []

        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"nullPref": "null"},
            merge=True,
        )
        response = await servicer.SetPreferences(request, mock_grpc_context)

        assert response.success is True, "null preference value should be accepted"
        mock_preferences_repo.set_bulk.assert_called_once_with({"nullPref": None})

    async def test_handles_unicode_keys_and_values(
        self,
        servicer: MockServicerHost,
        mock_preferences_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """SetPreferences handles unicode in keys and values."""
        mock_preferences_repo.get_all_with_metadata.return_value = []

        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"日本語キー": '"émoji 🎉 value"'},
            merge=True,
        )
        response = await servicer.SetPreferences(request, mock_grpc_context)

        assert response.success is True, "unicode keys and values should be accepted"
        mock_preferences_repo.set_bulk.assert_called_once_with({"日本語キー": "émoji 🎉 value"})


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

    Uses mock_grpc_context fixture from tests/conftest.py.
    """

    @pytest.fixture
    def servicer_no_db(self) -> MockServicerHost:
        """Create servicer with database not supported."""
        repo = AsyncMock()
        servicer = MockServicerHost(repo)
        # Override repository provider to not support preferences
        provider = MockRepositoryProvider(repo)
        provider.supports_preferences = False
        servicer.create_repository_provider = lambda: provider
        return servicer

    async def test_get_preferences_aborts_without_database(
        self,
        servicer_no_db: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetPreferences aborts when database not available."""
        request = noteflow_pb2.GetPreferencesRequest()

        # abort helpers raise AssertionError after mock context.abort()
        with pytest.raises(AssertionError, match="Unreachable"):
            await servicer_no_db.GetPreferences(request, mock_grpc_context)

        assert mock_grpc_context.abort.call_count == 1, "should abort once when DB unavailable"

    async def test_set_preferences_aborts_without_database(
        self,
        servicer_no_db: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """SetPreferences aborts when database not available."""
        request = noteflow_pb2.SetPreferencesRequest(
            preferences={"theme": '"dark"'},
            merge=True,
        )

        # abort helpers raise AssertionError after mock context.abort()
        with pytest.raises(AssertionError, match="Unreachable"):
            await servicer_no_db.SetPreferences(request, mock_grpc_context)

        assert mock_grpc_context.abort.call_count == 1, "should abort once when DB unavailable"


