"""Tests for outbound webhook delivery (task 3.7.7).

Coverage
--------
- ``compute_hmac`` — pure HMAC helper
- ``do_send`` — HTTP dispatch (success / HTTP error / network error)
- ``deliver_webhook`` — service creates delivery + enqueues task
- ``send_webhook_task`` — Celery task: success, retrying, final failure,
  endpoint deactivation, idempotent skip, inactive endpoint
- Webhook CRUD API: list, create, detail GET/PATCH/DELETE, deliveries, test
"""

from __future__ import annotations

import hashlib
import hmac
import json
import uuid
from datetime import datetime, timezone as dt_timezone
from unittest.mock import MagicMock, patch

import pytest

from simorgh.apps.automation.webhook_service import (
    MAX_FAILURES,
    RETRY_DELAYS,
    compute_hmac,
    do_send,
)


# ===========================================================================
# Shared fixtures
# ===========================================================================

@pytest.fixture
def tenant_and_node(db):
    from simorgh.apps.organizations.services import create_node
    from simorgh.apps.tenants.models import Tenant

    tenant = Tenant.objects.create(slug=f"wh-{uuid.uuid4().hex[:8]}", name="WH Tenant")
    node = create_node(tenant_id=tenant.pk, name="Root")
    return tenant, node


@pytest.fixture
def make_endpoint(tenant_and_node):
    tenant, node = tenant_and_node

    def _factory(
        *,
        url: str = "https://example.com/hook",
        name: str = "Test Hook",
        secret: str = "s3cr3t",
        events: list[str] | None = None,
        is_active: bool = True,
        headers: dict | None = None,
        failure_count: int = 0,
        timeout_seconds: int = 5,
    ):
        from simorgh.apps.automation.models import WebhookEndpoint

        return WebhookEndpoint.objects.create(
            tenant=tenant,
            organization_node=node,
            url=url,
            name=name,
            secret=secret,
            events=events or ["crm.lead.created"],
            is_active=is_active,
            headers=headers or {},
            failure_count=failure_count,
            timeout_seconds=timeout_seconds,
        )

    return _factory


@pytest.fixture
def make_delivery(tenant_and_node, make_endpoint):
    tenant, node = tenant_and_node

    def _factory(*, endpoint=None, event_name: str = "crm.lead.created",
                 payload: dict | None = None, status: str = "pending", attempt: int = 0):
        from simorgh.apps.automation.models import DeliveryStatus, WebhookDelivery

        ep = endpoint or make_endpoint()
        return WebhookDelivery.objects.create(
            tenant=tenant,
            organization_node=node,
            endpoint=ep,
            event_name=event_name,
            payload=payload or {"id": 1},
            status=status,
            attempt=attempt,
        )

    return _factory


# ===========================================================================
# 1. compute_hmac — pure function
# ===========================================================================

class TestComputeHmac:
    def test_format(self):
        sig = compute_hmac("secret", b"hello")
        assert sig.startswith("sha256=")
        assert len(sig) == len("sha256=") + 64  # 32 bytes hex = 64 chars

    def test_correctness(self):
        secret = "my-secret"
        body = b'{"event": "test"}'
        expected = "sha256=" + hmac.new(
            secret.encode(), body, hashlib.sha256
        ).hexdigest()
        assert compute_hmac(secret, body) == expected

    def test_different_secrets_differ(self):
        body = b"payload"
        assert compute_hmac("a", body) != compute_hmac("b", body)

    def test_different_bodies_differ(self):
        assert compute_hmac("s", b"a") != compute_hmac("s", b"b")

    def test_empty_secret(self):
        sig = compute_hmac("", b"data")
        assert sig.startswith("sha256=")


# ===========================================================================
# 2. do_send — HTTP dispatch
# ===========================================================================

class TestDoSend:
    def _mock_endpoint(self, url="https://example.com/hook", secret="", headers=None,
                       timeout_seconds=5):
        ep = MagicMock()
        ep.url = url
        ep.secret = secret
        ep.headers = headers or {}
        ep.timeout_seconds = timeout_seconds
        ep.public_id = uuid.uuid4()
        return ep

    def _mock_delivery(self, endpoint=None, event_name="test.event",
                       payload=None):
        ep = endpoint or self._mock_endpoint()
        d = MagicMock()
        d.endpoint = ep
        d.event_name = event_name
        d.payload = payload or {"key": "value"}
        d.public_id = uuid.uuid4()
        return d

    def test_success_2xx(self):
        delivery = self._mock_delivery()
        mock_resp = MagicMock()
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_resp.status = 200
        mock_resp.read.return_value = b'{"ok": true}'

        with patch("urllib.request.urlopen", return_value=mock_resp):
            success, status, body = do_send(delivery)

        assert success is True
        assert status == 200
        assert "ok" in body

    def test_includes_signature_when_secret_set(self):
        ep = self._mock_endpoint(secret="s3cr3t")
        delivery = self._mock_delivery(endpoint=ep)
        captured_headers = {}

        original_request = __import__("urllib.request", fromlist=["Request"]).Request

        def capture_request(url, data, headers, method):
            captured_headers.update(headers)
            return original_request(url=url, data=data, headers=headers, method=method)

        mock_resp = MagicMock()
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_resp.status = 200
        mock_resp.read.return_value = b"ok"

        with patch("urllib.request.Request", side_effect=capture_request):
            with patch("urllib.request.urlopen", return_value=mock_resp):
                do_send(delivery)

        assert "X-Simorgh-Signature" in captured_headers
        assert captured_headers["X-Simorgh-Signature"].startswith("sha256=")

    def test_no_signature_when_no_secret(self):
        ep = self._mock_endpoint(secret="")
        delivery = self._mock_delivery(endpoint=ep)
        captured_headers = {}

        original_request = __import__("urllib.request", fromlist=["Request"]).Request

        def capture_request(url, data, headers, method):
            captured_headers.update(headers)
            return original_request(url=url, data=data, headers=headers, method=method)

        mock_resp = MagicMock()
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_resp.status = 200
        mock_resp.read.return_value = b"ok"

        with patch("urllib.request.Request", side_effect=capture_request):
            with patch("urllib.request.urlopen", return_value=mock_resp):
                do_send(delivery)

        assert "X-Simorgh-Signature" not in captured_headers

    def test_http_error_returns_failure(self):
        import urllib.error

        delivery = self._mock_delivery()
        exc = urllib.error.HTTPError(
            url="https://example.com/hook",
            code=500,
            msg="Server Error",
            hdrs=None,  # type: ignore[arg-type]
            fp=None,
        )
        with patch("urllib.request.urlopen", side_effect=exc):
            success, status, body = do_send(delivery)

        assert success is False
        assert status == 500

    def test_network_error_returns_failure(self):
        import urllib.error

        delivery = self._mock_delivery()
        with patch("urllib.request.urlopen",
                   side_effect=urllib.error.URLError("connection refused")):
            success, status, body = do_send(delivery)

        assert success is False
        assert status is None
        assert "connection refused" in body

    def test_custom_headers_included(self):
        ep = self._mock_endpoint(headers={"X-Custom": "hello"})
        delivery = self._mock_delivery(endpoint=ep)
        captured = {}

        original_request = __import__("urllib.request", fromlist=["Request"]).Request

        def capture_request(url, data, headers, method):
            captured.update(headers)
            return original_request(url=url, data=data, headers=headers, method=method)

        mock_resp = MagicMock()
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_resp.status = 200
        mock_resp.read.return_value = b""

        with patch("urllib.request.Request", side_effect=capture_request):
            with patch("urllib.request.urlopen", return_value=mock_resp):
                do_send(delivery)

        assert captured.get("X-Custom") == "hello"


# ===========================================================================
# 3. deliver_webhook — service layer
# ===========================================================================

class TestDeliverWebhook:
    @pytest.mark.django_db
    def test_creates_delivery_row(self, make_endpoint):
        from simorgh.apps.automation.models import DeliveryStatus, WebhookDelivery
        from simorgh.apps.automation.webhook_service import deliver_webhook

        ep = make_endpoint()
        with patch("simorgh.apps.automation.tasks.send_webhook_task.delay"):
            delivery = deliver_webhook(ep, "crm.lead.created", {"id": 99})

        assert WebhookDelivery.objects.filter(pk=delivery.pk).exists()
        assert delivery.status == DeliveryStatus.PENDING
        assert delivery.event_name == "crm.lead.created"
        assert delivery.payload == {"id": 99}
        assert delivery.attempt == 0

    @pytest.mark.django_db
    def test_enqueues_task(self, make_endpoint):
        from simorgh.apps.automation.webhook_service import deliver_webhook

        ep = make_endpoint()
        with patch("simorgh.apps.automation.tasks.send_webhook_task.delay") as mock_delay:
            delivery = deliver_webhook(ep, "crm.lead.created", {})

        mock_delay.assert_called_once_with(delivery.pk)

    @pytest.mark.django_db
    def test_continues_when_broker_unavailable(self, make_endpoint):
        from simorgh.apps.automation.models import WebhookDelivery
        from simorgh.apps.automation.webhook_service import deliver_webhook

        ep = make_endpoint()
        with patch("simorgh.apps.automation.tasks.send_webhook_task.delay",
                   side_effect=Exception("no broker")):
            delivery = deliver_webhook(ep, "test.event", {})

        # Delivery row created despite enqueue failure.
        assert WebhookDelivery.objects.filter(pk=delivery.pk).exists()


# ===========================================================================
# 4. send_webhook_task — Celery task
# ===========================================================================

class TestSendWebhookTask:
    @pytest.mark.django_db
    def test_success_path(self, make_delivery):
        from simorgh.apps.automation.models import DeliveryStatus, WebhookDelivery, WebhookEndpoint
        from simorgh.apps.automation.tasks import send_webhook_task

        delivery = make_delivery()
        ep_pk = delivery.endpoint_id

        with patch("simorgh.apps.automation.webhook_service.do_send",
                   return_value=(True, 200, "ok")):
            send_webhook_task(delivery.pk)

        delivery.refresh_from_db()
        assert delivery.status == DeliveryStatus.SUCCESS
        assert delivery.attempt == 1
        assert delivery.response_status == 200
        assert delivery.delivered_at is not None
        assert delivery.next_retry_at is None

        ep = WebhookEndpoint.objects.get(pk=ep_pk)
        assert ep.failure_count == 0
        assert ep.last_success_at is not None

    @pytest.mark.django_db
    def test_failure_schedules_retry(self, make_delivery):
        from simorgh.apps.automation.models import DeliveryStatus, WebhookDelivery
        from simorgh.apps.automation.tasks import send_webhook_task

        delivery = make_delivery()

        with patch("simorgh.apps.automation.webhook_service.do_send",
                   return_value=(False, 500, "error")), \
             patch("simorgh.apps.automation.tasks.send_webhook_task.apply_async") as mock_async:
            send_webhook_task(delivery.pk)

        delivery.refresh_from_db()
        assert delivery.status == DeliveryStatus.RETRYING
        assert delivery.attempt == 1
        assert delivery.next_retry_at is not None
        # First retry delay = 60 s
        mock_async.assert_called_once_with(args=[delivery.pk], countdown=RETRY_DELAYS[0])

    @pytest.mark.django_db
    def test_failure_increments_endpoint_failure_count(self, make_delivery):
        from simorgh.apps.automation.models import WebhookEndpoint
        from simorgh.apps.automation.tasks import send_webhook_task

        delivery = make_delivery()
        ep_pk = delivery.endpoint_id

        with patch("simorgh.apps.automation.webhook_service.do_send",
                   return_value=(False, 503, "unavailable")), \
             patch("simorgh.apps.automation.tasks.send_webhook_task.apply_async"):
            send_webhook_task(delivery.pk)

        ep = WebhookEndpoint.objects.get(pk=ep_pk)
        assert ep.failure_count == 1
        assert ep.last_failure_at is not None

    @pytest.mark.django_db
    def test_final_failure_after_all_retries(self, make_delivery):
        """After MAX_FAILURES attempts, delivery is marked FAILED."""
        from simorgh.apps.automation.models import DeliveryStatus, WebhookDelivery
        from simorgh.apps.automation.tasks import send_webhook_task

        # Simulate delivery that has already tried len(RETRY_DELAYS) times.
        delivery = make_delivery(attempt=len(RETRY_DELAYS), status="retrying")

        with patch("simorgh.apps.automation.webhook_service.do_send",
                   return_value=(False, 500, "error")), \
             patch("simorgh.apps.automation.tasks.send_webhook_task.apply_async") as mock_async:
            send_webhook_task(delivery.pk)

        delivery.refresh_from_db()
        assert delivery.status == DeliveryStatus.FAILED
        assert delivery.next_retry_at is None
        mock_async.assert_not_called()

    @pytest.mark.django_db
    def test_endpoint_deactivated_after_max_failures(self, make_delivery, make_endpoint):
        from simorgh.apps.automation.models import WebhookEndpoint
        from simorgh.apps.automation.tasks import send_webhook_task

        # One below threshold — next failure should cross it.
        ep = make_endpoint(failure_count=MAX_FAILURES - 1)
        delivery = make_delivery(endpoint=ep)

        with patch("simorgh.apps.automation.webhook_service.do_send",
                   return_value=(False, 500, "err")), \
             patch("simorgh.apps.automation.tasks.send_webhook_task.apply_async"):
            send_webhook_task(delivery.pk)

        ep.refresh_from_db()
        assert ep.is_active is False
        assert ep.failure_count == MAX_FAILURES

    @pytest.mark.django_db
    def test_already_success_is_skipped(self, make_delivery):
        from simorgh.apps.automation.tasks import send_webhook_task

        delivery = make_delivery(status="success", attempt=1)

        with patch("simorgh.apps.automation.webhook_service.do_send") as mock_send:
            send_webhook_task(delivery.pk)

        mock_send.assert_not_called()

    @pytest.mark.django_db
    def test_already_failed_is_skipped(self, make_delivery):
        from simorgh.apps.automation.tasks import send_webhook_task

        delivery = make_delivery(status="failed")

        with patch("simorgh.apps.automation.webhook_service.do_send") as mock_send:
            send_webhook_task(delivery.pk)

        mock_send.assert_not_called()

    @pytest.mark.django_db
    def test_inactive_endpoint_marks_delivery_failed(self, make_endpoint, make_delivery):
        from simorgh.apps.automation.models import DeliveryStatus
        from simorgh.apps.automation.tasks import send_webhook_task

        ep = make_endpoint(is_active=False)
        delivery = make_delivery(endpoint=ep)

        with patch("simorgh.apps.automation.webhook_service.do_send") as mock_send:
            send_webhook_task(delivery.pk)

        mock_send.assert_not_called()
        delivery.refresh_from_db()
        assert delivery.status == DeliveryStatus.FAILED

    @pytest.mark.django_db
    def test_nonexistent_delivery_is_skipped(self):
        from simorgh.apps.automation.tasks import send_webhook_task

        # Should not raise.
        send_webhook_task(999999999)

    @pytest.mark.django_db
    def test_network_error_retries(self, make_delivery):
        from simorgh.apps.automation.models import DeliveryStatus
        from simorgh.apps.automation.tasks import send_webhook_task

        delivery = make_delivery()

        with patch("simorgh.apps.automation.webhook_service.do_send",
                   return_value=(False, None, "Connection refused")), \
             patch("simorgh.apps.automation.tasks.send_webhook_task.apply_async"):
            send_webhook_task(delivery.pk)

        delivery.refresh_from_db()
        assert delivery.status == DeliveryStatus.RETRYING
        assert delivery.response_status is None


# ===========================================================================
# 5. Webhook API
# ===========================================================================

@pytest.fixture
def api_client_with_tenant(tenant_and_node):
    from rest_framework.test import APIClient
    from tests.factories import UserFactory

    tenant, node = tenant_and_node
    user = UserFactory()
    client = APIClient()
    client.force_authenticate(user=user)

    # Inject tenant into every request.
    original_get = client.get
    original_post = client.post
    original_patch = client.patch
    original_delete = client.delete

    def _inject(method):
        def _wrapped(path, *args, **kwargs):
            kwargs.setdefault("SERVER_NAME", "testserver")
            return method(path, *args, **kwargs)
        return _wrapped

    class TenantClient(APIClient):
        def __init__(self, base_client, user, tenant, node):
            super().__init__()
            self.force_authenticate(user=user)
            self._tenant = tenant
            self._node = node

        def _patch_request(self, request):
            request.tenant = self._tenant
            return request

    tc = TenantClient(client, user, tenant, node)
    # Grant all webhook permissions.
    from django.contrib.auth.models import Permission
    from django.contrib.contenttypes.models import ContentType
    from simorgh.apps.automation.models import WebhookEndpoint
    ct = ContentType.objects.get_for_model(WebhookEndpoint)
    for codename in ("view_webhookendpoint", "add_webhookendpoint",
                     "change_webhookendpoint", "delete_webhookendpoint"):
        perm, _ = Permission.objects.get_or_create(
            codename=codename, content_type=ct,
            defaults={"name": codename},
        )
        user.user_permissions.add(perm)

    return tc, tenant, node, user


def _make_user():
    """Create a valid user for this project's custom User model (mobile-first)."""
    from tests.factories import UserFactory
    return UserFactory()


class TestWebhookAPI:
    """API integration tests using force_login + HTTP_X_TENANT (project pattern)."""

    @pytest.mark.django_db
    def test_list_empty(self, api_client, tenant_and_node):
        tenant, node = tenant_and_node
        user = _make_user()
        api_client.force_login(user)
        with patch("simorgh.apps.automation.api.views._require_perm"):
            resp = api_client.get("/api/v1/automation/webhooks/",
                                  HTTP_X_TENANT=tenant.slug)
        assert resp.status_code == 200
        assert resp.json()["count"] == 0

    @pytest.mark.django_db
    def test_create_endpoint(self, api_client, tenant_and_node):
        from simorgh.apps.automation.models import WebhookEndpoint

        tenant, node = tenant_and_node
        user = _make_user()
        api_client.force_login(user)
        with patch("simorgh.apps.automation.api.views._require_perm"):
            resp = api_client.post(
                "/api/v1/automation/webhooks/",
                data=json.dumps({
                    "name": "My Hook",
                    "url": "https://example.com/hook",
                    "events": ["crm.lead.created"],
                    "secret": "topsecret",
                }),
                content_type="application/json",
                HTTP_X_TENANT=tenant.slug,
            )
        assert resp.status_code == 201, resp.content
        body = resp.json()
        assert body["name"] == "My Hook"
        assert "secret" not in body  # write-only
        assert WebhookEndpoint.objects.filter(name="My Hook", tenant=tenant).exists()

    @pytest.mark.django_db
    def test_create_missing_url(self, api_client, tenant_and_node):
        tenant, node = tenant_and_node
        user = _make_user()
        api_client.force_login(user)
        with patch("simorgh.apps.automation.api.views._require_perm"):
            resp = api_client.post(
                "/api/v1/automation/webhooks/",
                data=json.dumps({"name": "No URL"}),
                content_type="application/json",
                HTTP_X_TENANT=tenant.slug,
            )
        assert resp.status_code == 400
        assert "url" in resp.json()["error"]

    @pytest.mark.django_db
    def test_patch_endpoint(self, api_client, tenant_and_node, make_endpoint):
        tenant, node = tenant_and_node
        user = _make_user()
        api_client.force_login(user)
        ep = make_endpoint()
        with patch("simorgh.apps.automation.api.views._require_perm"):
            resp = api_client.patch(
                f"/api/v1/automation/webhooks/{ep.public_id}/",
                data=json.dumps({"name": "Updated Name", "is_active": False}),
                content_type="application/json",
                HTTP_X_TENANT=tenant.slug,
            )
        assert resp.status_code == 200, resp.content
        body = resp.json()
        assert body["name"] == "Updated Name"
        assert body["is_active"] is False

    @pytest.mark.django_db
    def test_delete_endpoint(self, api_client, tenant_and_node, make_endpoint):
        from simorgh.apps.automation.models import WebhookEndpoint

        tenant, node = tenant_and_node
        user = _make_user()
        api_client.force_login(user)
        ep = make_endpoint()
        ep_pk = ep.pk
        with patch("simorgh.apps.automation.api.views._require_perm"):
            resp = api_client.delete(
                f"/api/v1/automation/webhooks/{ep.public_id}/",
                HTTP_X_TENANT=tenant.slug,
            )
        assert resp.status_code == 204
        assert not WebhookEndpoint.objects.filter(pk=ep_pk).exists()

    @pytest.mark.django_db
    def test_list_deliveries(self, api_client, tenant_and_node, make_endpoint, make_delivery):
        tenant, node = tenant_and_node
        user = _make_user()
        api_client.force_login(user)
        ep = make_endpoint()
        make_delivery(endpoint=ep)
        make_delivery(endpoint=ep, event_name="other.event")
        with patch("simorgh.apps.automation.api.views._require_perm"):
            resp = api_client.get(
                f"/api/v1/automation/webhooks/{ep.public_id}/deliveries/",
                HTTP_X_TENANT=tenant.slug,
            )
        assert resp.status_code == 200, resp.content
        assert resp.json()["count"] == 2

    @pytest.mark.django_db
    def test_test_endpoint(self, api_client, tenant_and_node, make_endpoint):
        tenant, node = tenant_and_node
        user = _make_user()
        api_client.force_login(user)
        ep = make_endpoint()
        with patch("simorgh.apps.automation.api.views._require_perm"), \
             patch("simorgh.apps.automation.tasks.send_webhook_task.delay"):
            resp = api_client.post(
                f"/api/v1/automation/webhooks/{ep.public_id}/test/",
                HTTP_X_TENANT=tenant.slug,
            )
        assert resp.status_code == 202, resp.content
        assert resp.json()["event_name"] == "webhook.test"

    @pytest.mark.django_db
    def test_detail_not_found(self, api_client, tenant_and_node):
        tenant, node = tenant_and_node
        user = _make_user()
        api_client.force_login(user)
        fake_id = uuid.uuid4()
        with patch("simorgh.apps.automation.api.views._require_perm"):
            resp = api_client.get(
                f"/api/v1/automation/webhooks/{fake_id}/",
                HTTP_X_TENANT=tenant.slug,
            )
        assert resp.status_code == 404

    @pytest.mark.django_db
    def test_secret_not_in_response(self, api_client, tenant_and_node, make_endpoint):
        tenant, node = tenant_and_node
        user = _make_user()
        api_client.force_login(user)
        ep = make_endpoint(secret="very-secret")
        with patch("simorgh.apps.automation.api.views._require_perm"):
            resp = api_client.get(
                f"/api/v1/automation/webhooks/{ep.public_id}/",
                HTTP_X_TENANT=tenant.slug,
            )
        assert resp.status_code == 200, resp.content
        assert "secret" not in resp.json()
        assert "very-secret" not in str(resp.content)
