"""Tests for Phase 17 — Webhook management and delivery."""

from __future__ import annotations

import hashlib
import hmac
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from backend.dashboard.middleware import CustomerContext, require_dashboard_auth
from backend.dashboard.routes import router
from fastapi import FastAPI
from fastapi.testclient import TestClient

_PATCH_FIRESTORE_DB = "backend.shared.firestore.get_db"
_PATCH_SECRET_MANAGER = "backend.dashboard.webhooks._get_secret_manager_client"
_PATCH_STORE_SECRET = "backend.dashboard.webhooks._store_secret"
_PATCH_READ_SECRET = "backend.dashboard.webhooks._read_secret"
_PATCH_DELETE_SECRET = "backend.dashboard.webhooks._delete_secret"


def _ctx(
    tier: str = "team",
    role: str = "owner",
    features: list[str] | None = None,
) -> CustomerContext:
    return CustomerContext(
        customer_id="cus_test",
        email="owner@example.com",
        tier=tier,
        role=role,
        member_email="owner@example.com",
        features=features
        or [
            "hosted_dashboard",
            "team_members",
            "audit_log",
            "webhooks",
            "shared_scan_links",
        ],
        project_limit=1,
        batch_limit=5,
        cancellation_pending=False,
        subscription_expires_at="2027-01-01T00:00:00+00:00",
        consent_accepted=True,
        tos_version="1.0",
        privacy_version="1.0",
    )


def _build_app(ctx: CustomerContext | None = None) -> FastAPI:
    app = FastAPI()
    app.include_router(router)
    if ctx is not None:
        app.dependency_overrides[require_dashboard_auth] = lambda: ctx
    return app


def _mock_webhook_db(
    webhooks: list[dict[str, Any]] | None = None,
    deliveries: list[dict[str, Any]] | None = None,
) -> MagicMock:
    """Build chainable mock Firestore DB for webhooks."""
    mock_db = MagicMock()

    webhook_docs: list[MagicMock] = []
    if webhooks:
        for i, w in enumerate(webhooks):
            doc = MagicMock()
            doc.id = w.get("_id", f"wh_{i}")
            doc.exists = True
            doc.to_dict = MagicMock(return_value=w)
            webhook_docs.append(doc)

    delivery_docs: list[MagicMock] = []
    if deliveries:
        for i, d in enumerate(deliveries):
            doc = MagicMock()
            doc.id = d.get("_id", f"del_{i}")
            doc.exists = True
            doc.to_dict = MagicMock(return_value=d)
            delivery_docs.append(doc)

    # webhooks subcollection
    webhooks_coll = MagicMock()
    new_wh_ref = MagicMock()
    new_wh_ref.id = "new_wh_1"
    webhooks_coll.add = AsyncMock(return_value=(None, new_wh_ref))

    async def _wh_stream() -> Any:
        for d in webhook_docs:
            yield d

    webhooks_coll.stream = _wh_stream

    # Query for status in filter
    wh_query = MagicMock()
    wh_query.stream = _wh_stream
    webhooks_coll.where = MagicMock(return_value=wh_query)

    def _wh_doc(doc_id: str) -> MagicMock:
        d = MagicMock()
        found = [w for w in webhook_docs if w.id == doc_id]
        if found:
            d.get = AsyncMock(return_value=found[0])
        else:
            not_found = MagicMock()
            not_found.exists = False
            d.get = AsyncMock(return_value=not_found)
        d.set = AsyncMock()
        d.update = AsyncMock()
        d.delete = AsyncMock()
        return d

    webhooks_coll.document = MagicMock(side_effect=_wh_doc)

    # webhook_deliveries subcollection
    deliveries_coll = MagicMock()
    new_del_ref = MagicMock()
    new_del_ref.id = "new_del_1"
    deliveries_coll.add = AsyncMock(return_value=(None, new_del_ref))

    del_query = MagicMock()

    async def _del_stream() -> Any:
        for d in delivery_docs:
            yield d

    del_query.stream = _del_stream
    del_query.where = MagicMock(return_value=del_query)
    del_query.order_by = MagicMock(return_value=del_query)
    del_query.limit = MagicMock(return_value=del_query)

    deliveries_coll.where = MagicMock(return_value=del_query)
    deliveries_coll.order_by = MagicMock(return_value=del_query)

    # audit_log subcollection (for _audit helper)
    audit_coll = MagicMock()
    audit_doc_ref = MagicMock()
    audit_doc_ref.id = "audit_1"
    audit_coll.add = AsyncMock(return_value=(None, audit_doc_ref))

    def _get_collection(name: str) -> MagicMock:
        cust_coll = MagicMock()
        cust_doc = MagicMock()

        def _subcoll(sub_name: str) -> MagicMock:
            if sub_name == "webhooks":
                return webhooks_coll
            if sub_name == "webhook_deliveries":
                return deliveries_coll
            if sub_name == "audit_log":
                return audit_coll
            return MagicMock()

        cust_doc.collection = MagicMock(side_effect=_subcoll)
        cust_coll.document = MagicMock(return_value=cust_doc)
        return cust_coll

    mock_db.collection = MagicMock(side_effect=_get_collection)
    return mock_db


class TestCreateWebhook:
    def test_create_webhook_success(self) -> None:
        ctx = _ctx()
        app = _build_app(ctx)
        client = TestClient(app, raise_server_exceptions=False)
        client.cookies.set("__Host-session", "encrypted")

        db = _mock_webhook_db(webhooks=[])
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch(_PATCH_STORE_SECRET, new_callable=AsyncMock),
            patch("backend.dashboard.routes._validate_csrf", new_callable=AsyncMock),
        ):
            resp = client.post(
                "/dashboard/settings/webhooks/create",
                json={
                    "url": "https://example.com/hook",
                    "event_types": ["scan.completed"],
                },
            )

        assert resp.status_code == 200
        data = resp.json()
        assert "webhook_id" in data
        assert data["signing_secret"] is not None

    def test_create_webhook_over_limit(self) -> None:
        ctx = _ctx()
        app = _build_app(ctx)
        client = TestClient(app, raise_server_exceptions=False)
        client.cookies.set("__Host-session", "encrypted")

        existing = [
            {
                "url": f"https://ex{i}.com/hook",
                "status": "active",
                "event_types": ["scan.completed"],
                "signing_secret_hash": "abcd1234",
                "created_at": "2025-01-01",
                "last_delivery_at": None,
                "consecutive_failures": 0,
                "_id": f"wh_{i}",
            }
            for i in range(3)
        ]
        db = _mock_webhook_db(webhooks=existing)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch("backend.dashboard.routes._validate_csrf", new_callable=AsyncMock),
        ):
            resp = client.post(
                "/dashboard/settings/webhooks/create",
                json={
                    "url": "https://example.com/hook",
                    "event_types": ["scan.completed"],
                },
            )

        assert resp.status_code == 409

    def test_create_webhook_viewer_forbidden(self) -> None:
        ctx = _ctx(role="viewer")
        app = _build_app(ctx)
        client = TestClient(app, raise_server_exceptions=False)
        client.cookies.set("__Host-session", "encrypted")

        with patch("backend.dashboard.routes._validate_csrf", new_callable=AsyncMock):
            resp = client.post(
                "/dashboard/settings/webhooks/create",
                json={"url": "https://ex.com/hook", "event_types": ["scan.completed"]},
            )

        assert resp.status_code == 403

    def test_create_webhook_no_feature(self) -> None:
        ctx = _ctx(features=["hosted_dashboard"])
        app = _build_app(ctx)
        client = TestClient(app, raise_server_exceptions=False)
        client.cookies.set("__Host-session", "encrypted")

        with patch("backend.dashboard.routes._validate_csrf", new_callable=AsyncMock):
            resp = client.post(
                "/dashboard/settings/webhooks/create",
                json={"url": "https://ex.com/hook", "event_types": ["scan.completed"]},
            )

        assert resp.status_code == 403


class TestDeleteWebhook:
    def test_delete_webhook(self) -> None:
        ctx = _ctx()
        app = _build_app(ctx)
        client = TestClient(app, raise_server_exceptions=False)
        client.cookies.set("__Host-session", "encrypted")

        db = _mock_webhook_db()
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch(_PATCH_DELETE_SECRET, new_callable=AsyncMock),
            patch("backend.dashboard.routes._validate_csrf", new_callable=AsyncMock),
        ):
            resp = client.delete("/dashboard/settings/webhooks/wh_1")

        assert resp.status_code == 204


class TestUpdateWebhook:
    def test_update_webhook_url(self) -> None:
        ctx = _ctx()
        app = _build_app(ctx)
        client = TestClient(app, raise_server_exceptions=False)
        client.cookies.set("__Host-session", "encrypted")

        existing = [
            {
                "url": "https://old.com/hook",
                "status": "active",
                "event_types": ["scan.completed"],
                "signing_secret_hash": "abcd1234",
                "created_at": "2025-01-01",
                "last_delivery_at": None,
                "consecutive_failures": 0,
                "_id": "wh_0",
            }
        ]
        db = _mock_webhook_db(webhooks=existing)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch("backend.dashboard.routes._validate_csrf", new_callable=AsyncMock),
        ):
            resp = client.put(
                "/dashboard/settings/webhooks/wh_0",
                json={"url": "https://new.com/hook"},
            )

        assert resp.status_code == 200
        assert resp.json()["url"] == "https://new.com/hook"

    def test_update_webhook_events(self) -> None:
        ctx = _ctx()
        app = _build_app(ctx)
        client = TestClient(app, raise_server_exceptions=False)
        client.cookies.set("__Host-session", "encrypted")

        existing = [
            {
                "url": "https://old.com/hook",
                "status": "active",
                "event_types": ["scan.completed"],
                "signing_secret_hash": "abcd1234",
                "created_at": "2025-01-01",
                "last_delivery_at": None,
                "consecutive_failures": 0,
                "_id": "wh_0",
            }
        ]
        db = _mock_webhook_db(webhooks=existing)
        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch("backend.dashboard.routes._validate_csrf", new_callable=AsyncMock),
        ):
            resp = client.put(
                "/dashboard/settings/webhooks/wh_0",
                json={"event_types": ["scan.completed", "score.changed"]},
            )

        assert resp.status_code == 200


class TestDeliverWebhook:
    @pytest.mark.anyio
    async def test_deliver_webhook_success(self) -> None:
        webhooks = [
            {
                "url": "https://test.com/hook",
                "status": "active",
                "event_types": ["scan.completed"],
                "signing_secret_hash": "abcd1234",
                "created_at": "2025-01-01",
                "last_delivery_at": None,
                "consecutive_failures": 0,
                "_id": "wh_0",
            }
        ]
        db = _mock_webhook_db(webhooks=webhooks)

        mock_resp = MagicMock()
        mock_resp.status_code = 200

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=mock_resp)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch(
                _PATCH_READ_SECRET,
                new_callable=AsyncMock,
                return_value="test_secret_key",
            ),
            patch(
                "backend.dashboard.webhooks.httpx.AsyncClient", return_value=mock_client
            ),
        ):
            from backend.dashboard.webhooks import deliver_webhook_event

            await deliver_webhook_event(
                customer_id="cus_test",
                event_type="scan.completed",
                payload={"test": True},
            )

    @pytest.mark.anyio
    async def test_deliver_webhook_failure_retry(self) -> None:
        webhooks = [
            {
                "url": "https://fail.com/hook",
                "status": "active",
                "event_types": ["scan.completed"],
                "signing_secret_hash": "abcd1234",
                "created_at": "2025-01-01",
                "last_delivery_at": None,
                "consecutive_failures": 0,
                "_id": "wh_0",
            }
        ]
        db = _mock_webhook_db(webhooks=webhooks)

        mock_resp = MagicMock()
        mock_resp.status_code = 500

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=mock_resp)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch(
                _PATCH_READ_SECRET, new_callable=AsyncMock, return_value="test_secret"
            ),
            patch(
                "backend.dashboard.webhooks.httpx.AsyncClient", return_value=mock_client
            ),
            patch("backend.dashboard.webhooks.asyncio.sleep", new_callable=AsyncMock),
        ):
            from backend.dashboard.webhooks import _deliver_single

            delivery = await _deliver_single(
                customer_id="cus_test",
                webhook_id="wh_0",
                url="https://fail.com/hook",
                event_type="scan.completed",
                payload={"test": True},
            )

        assert delivery.status == "failed"
        # Should have tried 3 times
        assert mock_client.post.call_count == 3

    @pytest.mark.anyio
    async def test_deliver_webhook_consecutive_failures(self) -> None:
        webhooks = [
            {
                "url": "https://fail.com/hook",
                "status": "active",
                "event_types": ["scan.completed"],
                "signing_secret_hash": "abcd1234",
                "created_at": "2025-01-01",
                "last_delivery_at": None,
                "consecutive_failures": 2,
                "_id": "wh_0",
            }
        ]
        db = _mock_webhook_db(webhooks=webhooks)

        mock_resp = MagicMock()
        mock_resp.status_code = 500

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=mock_resp)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch(
                _PATCH_READ_SECRET, new_callable=AsyncMock, return_value="test_secret"
            ),
            patch(
                "backend.dashboard.webhooks.httpx.AsyncClient", return_value=mock_client
            ),
            patch("backend.dashboard.webhooks.asyncio.sleep", new_callable=AsyncMock),
        ):
            from backend.dashboard.webhooks import _deliver_single

            delivery = await _deliver_single(
                customer_id="cus_test",
                webhook_id="wh_0",
                url="https://fail.com/hook",
                event_type="scan.completed",
                payload={"test": True},
            )

        assert delivery.status == "failed"

    @pytest.mark.anyio
    async def test_deliver_webhook_recovery(self) -> None:
        webhooks = [
            {
                "url": "https://test.com/hook",
                "status": "unhealthy",
                "event_types": ["scan.completed"],
                "signing_secret_hash": "abcd1234",
                "created_at": "2025-01-01",
                "last_delivery_at": None,
                "consecutive_failures": 3,
                "_id": "wh_0",
            }
        ]
        db = _mock_webhook_db(webhooks=webhooks)

        mock_resp = MagicMock()
        mock_resp.status_code = 200

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=mock_resp)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch(
                _PATCH_READ_SECRET, new_callable=AsyncMock, return_value="test_secret"
            ),
            patch(
                "backend.dashboard.webhooks.httpx.AsyncClient", return_value=mock_client
            ),
        ):
            from backend.dashboard.webhooks import _deliver_single

            delivery = await _deliver_single(
                customer_id="cus_test",
                webhook_id="wh_0",
                url="https://test.com/hook",
                event_type="scan.completed",
                payload={"test": True},
            )

        assert delivery.status == "success"


class TestSendTestEvent:
    def test_send_test_event(self) -> None:
        ctx = _ctx()
        app = _build_app(ctx)
        client = TestClient(app, raise_server_exceptions=False)
        client.cookies.set("__Host-session", "encrypted")

        existing = [
            {
                "url": "https://test.com/hook",
                "status": "active",
                "event_types": ["webhook.test"],
                "signing_secret_hash": "abcd1234",
                "created_at": "2025-01-01",
                "last_delivery_at": None,
                "consecutive_failures": 0,
                "_id": "wh_0",
            }
        ]
        db = _mock_webhook_db(webhooks=existing)

        mock_resp = MagicMock()
        mock_resp.status_code = 200

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=mock_resp)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch(
                _PATCH_READ_SECRET, new_callable=AsyncMock, return_value="test_secret"
            ),
            patch(
                "backend.dashboard.webhooks.httpx.AsyncClient", return_value=mock_client
            ),
            patch("backend.dashboard.routes._validate_csrf", new_callable=AsyncMock),
        ):
            resp = client.post("/dashboard/settings/webhooks/wh_0/test")

        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "success"


class TestDeliveryLog:
    def test_delivery_log(self) -> None:
        ctx = _ctx()
        app = _build_app(ctx)
        client = TestClient(app, raise_server_exceptions=False)
        client.cookies.set("__Host-session", "encrypted")

        deliveries_data = [
            {
                "webhook_id": "wh_0",
                "event_type": "scan.completed",
                "status": "success",
                "response_code": 200,
                "attempted_at": "2025-01-01T12:00:00+00:00",
            }
        ]
        db = _mock_webhook_db(deliveries=deliveries_data)
        with patch(_PATCH_FIRESTORE_DB, return_value=db):
            resp = client.get("/dashboard/settings/webhooks/wh_0/deliveries")

        assert resp.status_code == 200
        assert "success" in resp.text


class TestHMACSignature:
    def test_hmac_signature_verification(self) -> None:
        """Verify HMAC signature matches expected format."""
        secret = "test_secret_key"
        timestamp = "1234567890"
        payload = {"event": "scan.completed", "data": {}}
        body_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)

        signed_message = f"{timestamp}.{body_json}".encode()
        expected_sig = hmac.new(
            secret.encode(), signed_message, hashlib.sha256
        ).hexdigest()

        # Verify the format matches v1={hmac}
        header_value = f"v1={expected_sig}"
        assert header_value.startswith("v1=")
        assert len(expected_sig) == 64  # SHA-256 hex digest length


class TestWebhookEventFanout:
    @pytest.mark.anyio
    async def test_webhook_event_fanout(self) -> None:
        """Multiple webhooks subscribed to same event type."""
        webhooks = [
            {
                "url": "https://a.com/hook",
                "status": "active",
                "event_types": ["scan.completed"],
                "signing_secret_hash": "abcd1234",
                "created_at": "2025-01-01",
                "last_delivery_at": None,
                "consecutive_failures": 0,
                "_id": "wh_0",
            },
            {
                "url": "https://b.com/hook",
                "status": "active",
                "event_types": ["scan.completed"],
                "signing_secret_hash": "efgh5678",
                "created_at": "2025-01-01",
                "last_delivery_at": None,
                "consecutive_failures": 0,
                "_id": "wh_1",
            },
        ]
        db = _mock_webhook_db(webhooks=webhooks)

        mock_resp = MagicMock()
        mock_resp.status_code = 200

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.post = AsyncMock(return_value=mock_resp)

        with (
            patch(_PATCH_FIRESTORE_DB, return_value=db),
            patch(
                _PATCH_READ_SECRET, new_callable=AsyncMock, return_value="test_secret"
            ),
            patch(
                "backend.dashboard.webhooks.httpx.AsyncClient", return_value=mock_client
            ),
        ):
            from backend.dashboard.webhooks import deliver_webhook_event

            await deliver_webhook_event(
                customer_id="cus_test",
                event_type="scan.completed",
                payload={"test": True},
            )

        # Both webhooks should have been called
        assert mock_client.post.call_count == 2
