"""Tests for WebhooksMixin gRPC endpoints.

Tests cover:
- RegisterWebhook: creation with validation
- ListWebhooks: filtering by enabled status
- UpdateWebhook: partial updates and not found errors
- DeleteWebhook: success and not found cases
- GetWebhookDeliveries: limit enforcement
"""


from __future__ import annotations

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

import pytest

from noteflow.domain.webhooks import (
    DeliveryResult,
    WebhookConfig,
    WebhookDelivery,
    WebhookEventType,
)
from noteflow.grpc._mixins._types import GrpcContext
from noteflow.grpc._mixins.webhooks import WebhooksMixin
from noteflow.grpc.proto import noteflow_pb2

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


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

    def __init__(self, webhook_repo: AsyncMock, *, supports: bool = True) -> None:
        """Initialize with mock webhook repository."""
        self.supports_webhooks = supports
        self.webhooks = webhook_repo
        self.commit = AsyncMock()

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

    def __init__(
        self, webhook_repo: AsyncMock, *, supports_webhooks: bool = True
    ) -> None:
        """Initialize with mock webhook repository."""
        self._webhook_repo = webhook_repo
        self._supports_webhooks = supports_webhooks

    def create_repository_provider(self) -> MockWebhookRepositoryProvider:
        """Create mock repository provider context manager."""
        return MockWebhookRepositoryProvider(
            self._webhook_repo, supports=self._supports_webhooks
        )

    # Explicit method stubs to fix type inference from mixin methods
    #
    # Note: these stubs tell the type checker what return types to expect.
    # The actual implementation is inherited from WebhooksMixin.
    if TYPE_CHECKING:
        async def RegisterWebhook(
            self,
            request: noteflow_pb2.RegisterWebhookRequest,
            context: GrpcContext,
        ) -> noteflow_pb2.WebhookConfigProto: ...

        async def ListWebhooks(
            self,
            request: noteflow_pb2.ListWebhooksRequest,
            context: GrpcContext,
        ) -> noteflow_pb2.ListWebhooksResponse: ...

        async def UpdateWebhook(
            self,
            request: noteflow_pb2.UpdateWebhookRequest,
            context: GrpcContext,
        ) -> noteflow_pb2.WebhookConfigProto: ...

        async def DeleteWebhook(
            self,
            request: noteflow_pb2.DeleteWebhookRequest,
            context: GrpcContext,
        ) -> noteflow_pb2.DeleteWebhookResponse: ...

        async def GetWebhookDeliveries(
            self,
            request: noteflow_pb2.GetWebhookDeliveriesRequest,
            context: GrpcContext,
        ) -> noteflow_pb2.GetWebhookDeliveriesResponse: ...


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


@pytest.fixture
def webhooks_servicer(mock_webhook_repo: AsyncMock) -> MockServicerHost:
    """Create servicer with mock repository for webhooks mixin tests."""
    return MockServicerHost(mock_webhook_repo)


@pytest.fixture
def webhooks_servicer_no_db(mock_webhook_repo: AsyncMock) -> MockServicerHost:
    """Create servicer without database support for webhooks mixin tests."""
    return MockServicerHost(mock_webhook_repo, supports_webhooks=False)


# ============================================================================
# TestRegisterWebhook
# ============================================================================


class TestRegisterWebhook:
    """Tests for RegisterWebhook RPC."""

    async def test_registers_webhook_with_minimal_fields(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RegisterWebhook creates webhook with minimal required fields."""
        workspace_id = uuid4()
        expected_config = WebhookConfig.create(
            workspace_id=workspace_id,
            url="https://example.com/hook",
            events=[WebhookEventType.MEETING_COMPLETED],
        )
        mock_webhook_repo.create.return_value = expected_config

        request = noteflow_pb2.RegisterWebhookRequest(
            workspace_id=str(workspace_id),
            url="https://example.com/hook",
            events=["meeting.completed"],
        )

        response = await webhooks_servicer.RegisterWebhook(request, mock_grpc_context)

        assert response.id != "", "Response should have ID"
        assert response.url == "https://example.com/hook", "URL should match"
        mock_webhook_repo.create.assert_called_once()

    async def test_registers_webhook_with_all_optional_fields(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RegisterWebhook preserves all optional fields."""
        workspace_id = uuid4()
        expected_config = WebhookConfig.create(
            workspace_id=workspace_id,
            url="https://example.com/hook",
            events=[WebhookEventType.MEETING_COMPLETED, WebhookEventType.SUMMARY_GENERATED],
            name="Custom Webhook",
            secret="my-secret",
            timeout_ms=30000,
            max_retries=10,
        )
        mock_webhook_repo.create.return_value = expected_config

        request = noteflow_pb2.RegisterWebhookRequest(
            workspace_id=str(workspace_id),
            url="https://example.com/hook",
            events=["meeting.completed", "summary.generated"],
            name="Custom Webhook",
            secret="my-secret",
            timeout_ms=30000,
            max_retries=10,
        )

        response = await webhooks_servicer.RegisterWebhook(request, mock_grpc_context)

        expected_timeout = request.timeout_ms
        expected_retries = request.max_retries
        assert response.name == "Custom Webhook", "Name should be preserved"
        assert response.timeout_ms == expected_timeout, "Timeout should be preserved"
        assert response.max_retries == expected_retries, "Max retries should be preserved"

    async def test_rejects_invalid_url(
        self,
        webhooks_servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RegisterWebhook rejects URL without http/https scheme."""
        request = noteflow_pb2.RegisterWebhookRequest(
            workspace_id=str(uuid4()),
            url="ftp://invalid-scheme.com",
            events=["meeting.completed"],
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_rejects_empty_url(
        self,
        webhooks_servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RegisterWebhook rejects empty URL."""
        request = noteflow_pb2.RegisterWebhookRequest(
            workspace_id=str(uuid4()),
            url="",
            events=["meeting.completed"],
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_rejects_empty_events(
        self,
        webhooks_servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RegisterWebhook rejects request with no events."""
        request = noteflow_pb2.RegisterWebhookRequest(
            workspace_id=str(uuid4()),
            url="https://example.com/hook",
            events=[],
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_rejects_invalid_event_type(
        self,
        webhooks_servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RegisterWebhook rejects invalid event type string."""
        request = noteflow_pb2.RegisterWebhookRequest(
            workspace_id=str(uuid4()),
            url="https://example.com/hook",
            events=["invalid.event.type"],
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_rejects_invalid_workspace_id(
        self,
        webhooks_servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RegisterWebhook rejects invalid workspace_id format."""
        request = noteflow_pb2.RegisterWebhookRequest(
            workspace_id="not-a-uuid",
            url="https://example.com/hook",
            events=["meeting.completed"],
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_register_aborts_when_database_unavailable(
        self,
        webhooks_servicer_no_db: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """RegisterWebhook aborts when database is unavailable."""
        request = noteflow_pb2.RegisterWebhookRequest(
            workspace_id=str(uuid4()),
            url="https://example.com/hook",
            events=["meeting.completed"],
        )

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestListWebhooks
# ============================================================================


class TestListWebhooks:
    """Tests for ListWebhooks RPC."""

    async def test_list_returns_all_webhooks(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_webhook_config: WebhookConfig,
    ) -> None:
        """ListWebhooks returns all webhooks when enabled_only is False."""
        mock_webhook_repo.get_all.return_value = [sample_webhook_config]

        request = noteflow_pb2.ListWebhooksRequest(enabled_only=False)
        response = await webhooks_servicer.ListWebhooks(request, mock_grpc_context)

        expected_count = len([sample_webhook_config])
        assert response.total_count == expected_count, "Total count should be 1"
        assert len(response.webhooks) == expected_count, "Should return 1 webhook"
        mock_webhook_repo.get_all.assert_called_once()

    async def test_list_filters_enabled_only(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_webhook_config: WebhookConfig,
    ) -> None:
        """ListWebhooks returns only enabled webhooks when enabled_only is True."""
        mock_webhook_repo.get_all_enabled.return_value = [sample_webhook_config]

        request = noteflow_pb2.ListWebhooksRequest(enabled_only=True)
        response = await webhooks_servicer.ListWebhooks(request, mock_grpc_context)

        expected_count = len([sample_webhook_config])
        assert response.total_count == expected_count, "Total count should be 1"
        mock_webhook_repo.get_all_enabled.assert_called_once()
        mock_webhook_repo.get_all.assert_not_called()

    async def test_returns_empty_list(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListWebhooks returns empty response when no webhooks exist."""
        mock_webhook_repo.get_all.return_value = []

        request = noteflow_pb2.ListWebhooksRequest()
        response = await webhooks_servicer.ListWebhooks(request, mock_grpc_context)

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

    async def test_list_aborts_when_database_unavailable(
        self,
        webhooks_servicer_no_db: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """ListWebhooks aborts when database is unavailable."""
        request = noteflow_pb2.ListWebhooksRequest()

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestUpdateWebhook
# ============================================================================


class TestUpdateWebhook:
    """Tests for UpdateWebhook RPC."""

    async def test_update_rpc_modifies_single_field(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_webhook_config: WebhookConfig,
    ) -> None:
        """UpdateWebhook updates a single field."""
        mock_webhook_repo.get_by_id.return_value = sample_webhook_config
        mock_webhook_repo.update.return_value = sample_webhook_config

        request = noteflow_pb2.UpdateWebhookRequest(
            webhook_id=str(sample_webhook_config.id),
            name="Updated Name",
        )

        response = await webhooks_servicer.UpdateWebhook(request, mock_grpc_context)

        assert response.id == str(sample_webhook_config.id), "ID should match"
        mock_webhook_repo.update.assert_called_once()

    async def test_updates_multiple_fields(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_webhook_config: WebhookConfig,
    ) -> None:
        """UpdateWebhook updates multiple fields at once."""
        mock_webhook_repo.get_by_id.return_value = sample_webhook_config
        mock_webhook_repo.update.return_value = sample_webhook_config

        request = noteflow_pb2.UpdateWebhookRequest(
            webhook_id=str(sample_webhook_config.id),
            url="https://new-url.com/hook",
            enabled=False,
            timeout_ms=20000,
        )

        response = await webhooks_servicer.UpdateWebhook(request, mock_grpc_context)

        assert response.id, "Should return response with ID"
        mock_webhook_repo.update.assert_called_once()

    async def test_replaces_events(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_webhook_config: WebhookConfig,
    ) -> None:
        """UpdateWebhook replaces entire events list."""
        mock_webhook_repo.get_by_id.return_value = sample_webhook_config
        mock_webhook_repo.update.return_value = sample_webhook_config

        request = noteflow_pb2.UpdateWebhookRequest(
            webhook_id=str(sample_webhook_config.id),
            events=["summary.generated", "recording.started"],
        )

        await webhooks_servicer.UpdateWebhook(request, mock_grpc_context)

        # Verify update was called with new events
        call_args = mock_webhook_repo.update.call_args[0][0]
        assert WebhookEventType.SUMMARY_GENERATED in call_args.events, "SUMMARY_GENERATED should be in events"
        assert WebhookEventType.RECORDING_STARTED in call_args.events, "RECORDING_STARTED should be in events"

    async def test_returns_not_found_for_nonexistent(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateWebhook returns NOT_FOUND for nonexistent webhook."""
        mock_webhook_repo.get_by_id.return_value = None
        webhook_id = uuid4()

        request = noteflow_pb2.UpdateWebhookRequest(
            webhook_id=str(webhook_id),
            name="New Name",
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_update_rejects_invalid_id(
        self,
        webhooks_servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateWebhook rejects invalid webhook_id format."""
        request = noteflow_pb2.UpdateWebhookRequest(
            webhook_id="not-a-uuid",
            name="New Name",
        )

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

        mock_grpc_context.abort.assert_called_once()

    async def test_update_aborts_when_database_unavailable(
        self,
        webhooks_servicer_no_db: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """UpdateWebhook aborts when database is unavailable."""
        request = noteflow_pb2.UpdateWebhookRequest(
            webhook_id=str(uuid4()),
            name="New Name",
        )

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestDeleteWebhook
# ============================================================================


class TestDeleteWebhook:
    """Tests for DeleteWebhook RPC."""

    async def test_deletes_existing_webhook(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteWebhook returns success=true for existing webhook."""
        mock_webhook_repo.delete.return_value = True
        webhook_id = uuid4()

        request = noteflow_pb2.DeleteWebhookRequest(webhook_id=str(webhook_id))
        response = await webhooks_servicer.DeleteWebhook(request, mock_grpc_context)

        assert response.success is True, "Should return success=true"
        mock_webhook_repo.delete.assert_called_once_with(webhook_id)

    async def test_delete_returns_false_for_nonexistent(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteWebhook returns success=false for nonexistent webhook."""
        mock_webhook_repo.delete.return_value = False
        webhook_id = uuid4()

        request = noteflow_pb2.DeleteWebhookRequest(webhook_id=str(webhook_id))
        response = await webhooks_servicer.DeleteWebhook(request, mock_grpc_context)

        assert response.success is False, "Should return success=false"

    async def test_delete_rejects_invalid_id(
        self,
        webhooks_servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteWebhook rejects invalid webhook_id format."""
        request = noteflow_pb2.DeleteWebhookRequest(webhook_id="not-a-uuid")

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

        mock_grpc_context.abort.assert_called_once()

    async def test_delete_aborts_when_database_unavailable(
        self,
        webhooks_servicer_no_db: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """DeleteWebhook aborts when database is unavailable."""
        request = noteflow_pb2.DeleteWebhookRequest(webhook_id=str(uuid4()))

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestGetWebhookDeliveries
# ============================================================================


class TestGetWebhookDeliveries:
    """Tests for GetWebhookDeliveries RPC."""

    async def test_returns_deliveries(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
        sample_webhook_delivery: WebhookDelivery,
    ) -> None:
        """GetWebhookDeliveries returns delivery history."""
        mock_webhook_repo.get_deliveries.return_value = [sample_webhook_delivery]
        webhook_id = sample_webhook_delivery.webhook_id

        request = noteflow_pb2.GetWebhookDeliveriesRequest(webhook_id=str(webhook_id))
        response = await webhooks_servicer.GetWebhookDeliveries(request, mock_grpc_context)

        expected_count = len([sample_webhook_delivery])
        assert response.total_count == expected_count, "Total count should be 1"
        assert len(response.deliveries) == expected_count, "Should return 1 delivery"
        assert response.deliveries[0].status_code == sample_webhook_delivery.status_code, "Status code should match"

    async def test_uses_default_limit(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetWebhookDeliveries uses default limit of 50."""
        mock_webhook_repo.get_deliveries.return_value = []
        webhook_id = uuid4()

        request = noteflow_pb2.GetWebhookDeliveriesRequest(webhook_id=str(webhook_id))
        await webhooks_servicer.GetWebhookDeliveries(request, mock_grpc_context)

        mock_webhook_repo.get_deliveries.assert_called_once_with(webhook_id, limit=50)

    async def test_get_deliveries_respects_limit(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetWebhookDeliveries respects custom limit parameter."""
        mock_webhook_repo.get_deliveries.return_value = []
        webhook_id = uuid4()

        request = noteflow_pb2.GetWebhookDeliveriesRequest(
            webhook_id=str(webhook_id),
            limit=25,
        )
        await webhooks_servicer.GetWebhookDeliveries(request, mock_grpc_context)

        mock_webhook_repo.get_deliveries.assert_called_once_with(webhook_id, limit=25)

    async def test_caps_limit_at_500(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetWebhookDeliveries caps limit at 500."""
        mock_webhook_repo.get_deliveries.return_value = []
        webhook_id = uuid4()

        request = noteflow_pb2.GetWebhookDeliveriesRequest(
            webhook_id=str(webhook_id),
            limit=1000,  # Exceeds max
        )
        await webhooks_servicer.GetWebhookDeliveries(request, mock_grpc_context)

        mock_webhook_repo.get_deliveries.assert_called_once_with(webhook_id, limit=500)

    async def test_returns_empty_when_no_deliveries(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetWebhookDeliveries returns empty response when no deliveries."""
        mock_webhook_repo.get_deliveries.return_value = []

        request = noteflow_pb2.GetWebhookDeliveriesRequest(webhook_id=str(uuid4()))
        response = await webhooks_servicer.GetWebhookDeliveries(request, mock_grpc_context)

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

    async def test_get_deliveries_rejects_invalid_id(
        self,
        webhooks_servicer: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetWebhookDeliveries rejects invalid webhook_id format."""
        request = noteflow_pb2.GetWebhookDeliveriesRequest(webhook_id="not-a-uuid")

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

        mock_grpc_context.abort.assert_called_once()

    async def test_get_deliveries_aborts_when_database_unavailable(
        self,
        webhooks_servicer_no_db: MockServicerHost,
        mock_grpc_context: MagicMock,
    ) -> None:
        """GetWebhookDeliveries aborts when database is unavailable."""
        request = noteflow_pb2.GetWebhookDeliveriesRequest(webhook_id=str(uuid4()))

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

        mock_grpc_context.abort.assert_called_once()


# ============================================================================
# TestProtoConversion
# ============================================================================


class TestProtoConversion:
    """Tests for proto conversion helpers."""

    async def test_config_proto_includes_all_fields(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """Webhook config proto includes all expected fields."""
        workspace_id = uuid4()
        config = WebhookConfig.create(
            workspace_id=workspace_id,
            url="https://example.com/hook",
            events=[WebhookEventType.MEETING_COMPLETED, WebhookEventType.SUMMARY_GENERATED],
            name="Full Config",
            secret="my-secret",
            timeout_ms=20000,
            max_retries=7,
        )
        mock_webhook_repo.get_all.return_value = [config]

        request = noteflow_pb2.ListWebhooksRequest()
        response = await webhooks_servicer.ListWebhooks(request, mock_grpc_context)

        proto = response.webhooks[0]
        assert proto.id == str(config.id), "ID should be string UUID"
        assert proto.workspace_id == str(workspace_id), "Workspace ID should match"
        assert proto.name == "Full Config", "Name should match"
        assert proto.url == "https://example.com/hook", "URL should match"
        assert set(proto.events) == {"meeting.completed", "summary.generated"}, "Events should match expected set"
        assert proto.enabled is True, "Enabled should match"
        assert proto.timeout_ms == config.timeout_ms, "Timeout should match"
        assert proto.max_retries == config.max_retries, "Max retries should match"
        assert proto.created_at > 0, "Created_at should be timestamp"
        assert proto.updated_at > 0, "Updated_at should be timestamp"

    async def test_delivery_proto_includes_all_fields(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """Webhook delivery proto includes all expected fields."""
        webhook_id = uuid4()
        result = DeliveryResult(
            status_code=201,
            response_body='{"received": true}',
            error_message=None,
            attempt_count=2,
            duration_ms=175,
        )
        delivery = WebhookDelivery.create(
            webhook_id=webhook_id,
            event_type=WebhookEventType.MEETING_COMPLETED,
            payload={"test": "payload"},
            result=result,
        )
        mock_webhook_repo.get_deliveries.return_value = [delivery]

        request = noteflow_pb2.GetWebhookDeliveriesRequest(webhook_id=str(webhook_id))
        response = await webhooks_servicer.GetWebhookDeliveries(request, mock_grpc_context)

        proto = response.deliveries[0]
        assert proto.id == str(delivery.id), "ID should be string UUID"
        assert proto.webhook_id == str(webhook_id), "Webhook ID should match"
        assert proto.event_type == "meeting.completed", "Event type should match"
        assert proto.status_code == delivery.status_code, "Status code should match"
        assert proto.error_message == "", "Error message should be empty"
        assert proto.attempt_count == delivery.attempt_count, "Attempt count should match"
        assert proto.duration_ms == delivery.duration_ms, "Duration should match"
        assert proto.delivered_at > 0, "Delivered_at should be timestamp"
        assert proto.succeeded is True, "Succeeded should be True for 2xx"

    async def test_failed_delivery_proto(
        self,
        webhooks_servicer: MockServicerHost,
        mock_webhook_repo: AsyncMock,
        mock_grpc_context: MagicMock,
    ) -> None:
        """Failed delivery proto includes error information."""
        webhook_id = uuid4()
        result = DeliveryResult(
            status_code=None,
            response_body=None,
            error_message="Connection timeout",
            attempt_count=3,
            duration_ms=None,
        )
        delivery = WebhookDelivery.create(
            webhook_id=webhook_id,
            event_type=WebhookEventType.MEETING_COMPLETED,
            payload={"test": "payload"},
            result=result,
        )
        mock_webhook_repo.get_deliveries.return_value = [delivery]

        request = noteflow_pb2.GetWebhookDeliveriesRequest(webhook_id=str(webhook_id))
        response = await webhooks_servicer.GetWebhookDeliveries(request, mock_grpc_context)

        proto = response.deliveries[0]
        expected_zero = 0
        assert proto.status_code == expected_zero, "Status code should be 0 for failed"
        assert proto.error_message == "Connection timeout", "Error message should match"
        assert proto.duration_ms == expected_zero, "Duration should be 0 for None"
        assert proto.succeeded is False, "Succeeded should be False"
