"""WhatsApp transport: signatures, webhooks, sending, media.

What can be proven here, and what cannot, are different things and worth naming.

**Proven:** signature verification against the documented algorithm, the
verification handshake, webhook parsing of Meta's real payload shape, and the
exact request each provider would put on the wire.

**Not proven:** that a live Evolution instance accepts those requests, or that
Meta's media URLs resolve with real credentials. Those need an account.

The signature tests carry the most weight. An endpoint that skips them accepts a
forged document from anyone, and puts it in a reviewer's queue with a real
client's name attached.
"""

from __future__ import annotations

import hashlib
import hmac
import json

import pytest

from app.runtime.server import Request
from app.support.http import HttpError, HttpResponse, UrllibTransport
from app.whatsapp.evolution import EvolutionProvider, MediaDownloadError
from app.whatsapp.inbox import InboxFilter, SelfChatPolicy
from app.whatsapp.messages import MediaReference, MessageType, OutboundMessage
from app.whatsapp.meta import (
    MetaBusinessProvider,
    MetaConfig,
    verification_challenge,
    verify_signature,
)
from app.whatsapp.provider import MessageSender
from app.whatsapp.receiver import WebhookReceiver
from app.whatsapp.webhook import InboundQueue, meta_webhook_routes

SECRET = "an-app-secret"
OWN_NUMBER = "923001234567"


class FakeTransport:
    """Records requests and returns queued responses."""

    def __init__(self, responses: list[HttpResponse] | None = None) -> None:
        self.responses = responses or []
        self.requests: list[dict] = []

    def request(self, method, url, *, headers=None, json=None, data=None, timeout=30.0):
        self.requests.append(
            {"method": method, "url": url, "headers": headers or {}, "json": json, "data": data}
        )

        return self.responses.pop(0) if self.responses else HttpResponse(200, b"{}")


def signed(body: bytes, secret: str = SECRET) -> str:
    return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()


class TestSignatureVerification:
    """The gate between a reviewer's queue and anyone on the internet."""

    def test_a_correctly_signed_body_is_accepted(self):
        body = b'{"entry":[]}'

        assert verify_signature(SECRET, body, signed(body))

    def test_a_tampered_body_is_refused(self):
        body = b'{"entry":[]}'
        header = signed(body)

        assert not verify_signature(SECRET, b'{"entry":[{"evil":true}]}', header)

    def test_the_wrong_secret_is_refused(self):
        body = b'{"entry":[]}'

        assert not verify_signature("a-different-secret", body, signed(body))

    def test_a_missing_header_is_refused(self):
        assert not verify_signature(SECRET, b"{}", None)

    def test_an_unconfigured_secret_refuses_everything(self):
        body = b"{}"

        # An endpoint that skipped the check because it had nothing to check
        # with would accept a forgery silently — worse than refusing.
        assert not verify_signature("", body, signed(body))

    def test_another_algorithm_is_refused(self):
        body = b"{}"
        sha1 = hmac.new(SECRET.encode(), body, hashlib.sha1).hexdigest()

        assert not verify_signature(SECRET, body, f"sha1={sha1}")

    def test_a_malformed_header_is_refused(self):
        for header in ("garbage", "sha256=", "=abc", ""):
            assert not verify_signature(SECRET, b"{}", header)


class TestVerificationHandshake:
    def test_the_challenge_is_returned_when_the_token_matches(self):
        params = {"hub.mode": "subscribe", "hub.verify_token": "t0ken", "hub.challenge": "12345"}

        assert verification_challenge(params, "t0ken") == "12345"

    def test_a_wrong_token_is_refused(self):
        params = {"hub.mode": "subscribe", "hub.verify_token": "guess", "hub.challenge": "12345"}

        # Echoing without checking would let anyone who guesses the URL register
        # it as their own webhook.
        assert verification_challenge(params, "t0ken") is None

    def test_an_unconfigured_token_refuses(self):
        params = {"hub.mode": "subscribe", "hub.verify_token": "", "hub.challenge": "12345"}

        assert verification_challenge(params, "") is None

    def test_another_mode_is_refused(self):
        params = {"hub.mode": "unsubscribe", "hub.verify_token": "t0ken", "hub.challenge": "1"}

        assert verification_challenge(params, "t0ken") is None


class TestMetaParsing:
    def _payload(self, message: dict) -> dict:
        return {"entry": [{"changes": [{"value": {"messages": [message]}}]}]}

    def test_a_text_message_is_read(self):
        parsed = MetaBusinessProvider(MetaConfig("1", "t")).parse_webhook(
            self._payload({"id": "wamid.1", "from": OWN_NUMBER, "type": "text",
                           "text": {"body": "my CNIC"}})
        )[0]

        assert parsed.sender == OWN_NUMBER
        assert parsed.chat == OWN_NUMBER
        assert parsed.text == "my CNIC"

    def test_a_document_carries_its_media_and_caption(self):
        parsed = MetaBusinessProvider(MetaConfig("1", "t")).parse_webhook(
            self._payload({
                "id": "wamid.2", "from": OWN_NUMBER, "type": "document",
                "document": {"id": "media-99", "mime_type": "application/pdf",
                             "filename": "statement.pdf", "caption": "bank statement"},
            })
        )[0]

        assert parsed.has_document
        assert parsed.media.handle == "media-99"
        assert parsed.media.is_pdf
        # The caption is the sender saying what the document is.
        assert parsed.text == "bank statement"

    def test_a_status_update_is_not_a_message(self):
        # Delivery receipts arrive on the same endpoint with no `messages` key.
        # Not an error; simply not a message.
        payload = {"entry": [{"changes": [{"value": {"statuses": [{"id": "x"}]}}]}]}

        assert MetaBusinessProvider(MetaConfig("1", "t")).parse_webhook(payload) == []

    def test_a_malformed_payload_yields_nothing_rather_than_raising(self):
        provider = MetaBusinessProvider(MetaConfig("1", "t"))

        # A webhook that raises is retried forever, and a payload that cannot be
        # parsed will not parse on the retry either.
        for payload in ({}, {"entry": "nonsense"}, {"entry": [{"changes": "no"}]}):
            assert provider.parse_webhook(payload) == []

    def test_a_message_without_an_id_is_skipped(self):
        parsed = MetaBusinessProvider(MetaConfig("1", "t")).parse_webhook(
            self._payload({"from": OWN_NUMBER, "type": "text", "text": {"body": "x"}})
        )

        assert parsed == []


class TestMetaSending:
    def test_a_text_message_is_addressed_and_authenticated(self):
        transport = FakeTransport([HttpResponse(200, b'{"messages":[{"id":"wamid.out"}]}')])
        provider = MetaBusinessProvider(MetaConfig("PN1", "tok3n"), transport)

        result = provider.send(OutboundMessage(recipient=OWN_NUMBER, text="Filed."))

        sent = transport.requests[0]
        assert sent["url"].endswith("/PN1/messages")
        assert sent["headers"]["Authorization"] == "Bearer tok3n"
        assert sent["json"]["text"]["body"] == "Filed."
        assert result.ok and result.provider_message_id == "wamid.out"

    def test_a_template_is_sent_as_a_template(self):
        transport = FakeTransport([HttpResponse(200, b'{"messages":[{"id":"x"}]}')])
        provider = MetaBusinessProvider(MetaConfig("PN1", "t"), transport)

        provider.send(OutboundMessage(recipient=OWN_NUMBER, template="reminder", text="hi"))

        body = transport.requests[0]["json"]
        assert body["type"] == "template"
        assert body["template"]["name"] == "reminder"
        # A template is registered per language; the wrong code is rejected as a
        # template that does not exist, which reads as an approval problem.
        assert body["template"]["language"]["code"] == "en"

    def test_a_refusal_is_reported_with_its_reason(self):
        transport = FakeTransport([
            HttpResponse(400, b'{"error":{"message":"Recipient not opted in"}}')
        ])
        provider = MetaBusinessProvider(MetaConfig("PN1", "t"), transport)

        result = provider.send(OutboundMessage(recipient=OWN_NUMBER, text="hi"))

        assert not result.ok
        assert "opted in" in result.error


class TestMetaMedia:
    def test_media_is_fetched_in_two_authenticated_calls(self, tmp_path):
        transport = FakeTransport([
            HttpResponse(200, b'{"url":"https://lookaside.example/abc"}'),
            HttpResponse(200, b"\xff\xd8\xff-jpeg-bytes"),
        ])
        provider = MetaBusinessProvider(MetaConfig("PN1", "tok3n"), transport)

        path = provider.download_media(MediaReference(handle="media-99"), tmp_path / "doc.jpg")

        assert path.read_bytes() == b"\xff\xd8\xff-jpeg-bytes"
        # The second call needs the token too. Without it Meta answers 401,
        # which reads confusingly as "the media expired".
        assert transport.requests[1]["headers"]["Authorization"] == "Bearer tok3n"

    def test_an_unresolvable_id_is_an_error(self, tmp_path):
        transport = FakeTransport([HttpResponse(404, b'{"error":{"message":"not found"}}')])
        provider = MetaBusinessProvider(MetaConfig("PN1", "t"), transport)

        with pytest.raises(Exception, match="Could not resolve"):
            provider.download_media(MediaReference(handle="gone"), tmp_path / "x.jpg")


class TestEvolutionTransport:
    def test_it_still_records_when_no_base_url_is_set(self):
        provider = EvolutionProvider()

        # Which is what makes the whole path testable without a live instance.
        assert provider.send(OutboundMessage(recipient=OWN_NUMBER, text="hi")).ok
        assert len(provider.sent) == 1

    def test_a_configured_instance_actually_posts(self):
        transport = FakeTransport([HttpResponse(200, b'{"key":{"id":"evo-1"}}')])
        provider = EvolutionProvider("https://evo.local", "main", "k3y", transport)

        result = provider.send(OutboundMessage(recipient=OWN_NUMBER, text="Filed."))

        sent = transport.requests[0]
        assert sent["url"] == "https://evo.local/message/sendText/main"
        assert sent["headers"]["apikey"] == "k3y"
        assert sent["json"] == {"number": OWN_NUMBER, "text": "Filed."}
        assert result.provider_message_id == "evo-1"

    def test_media_is_decoded_from_base64(self, tmp_path):
        import base64

        content = b"\xff\xd8\xff-jpeg"
        transport = FakeTransport([
            HttpResponse(200, json.dumps({"base64": base64.b64encode(content).decode()}).encode())
        ])
        provider = EvolutionProvider("https://evo.local", "main", "k3y", transport)
        media = MediaReference(handle="h", raw={"key": {"id": "m1"}})

        path = provider.download_media(media, tmp_path / "doc.jpg")

        assert path.read_bytes() == content
        # Evolution wants the original message back, not the handle. With no
        # `message` fragment to send there is only the key — the fallback.
        assert transport.requests[0]["json"]["message"]["key"] == {"id": "m1"}

    def test_the_whole_message_is_sent_so_evolution_need_not_have_kept_it(self, tmp_path):
        """The fix for every real document failing with "Message not found".

        Evolution decides between two paths:

            const msg = m?.message ? m : await this.getMessage(m.key, true);

        Given the message it decrypts what it was handed. Given only a key it
        searches its own database — and this runs with
        DATABASE_SAVE_DATA_NEW_MESSAGE=false, because ADR-0002 says a client's
        documents do not get a second home in Evolution's Postgres.

        So sending only the key asked Evolution to find a message it had been
        told never to keep: HTTP 400, on every real document, with the entire
        path in front of it working.
        """
        import base64

        transport = FakeTransport([
            HttpResponse(200, json.dumps({"base64": base64.b64encode(b"x").decode()}).encode())
        ])
        provider = EvolutionProvider("https://evo.local", "main", "k3y", transport)
        original = {
            "key": {"id": "m1", "remoteJid": f"{OWN_NUMBER}@s.whatsapp.net", "fromMe": True},
            "message": {"imageMessage": {"mimetype": "image/jpeg", "mediaKey": "abc"}},
        }

        provider.download_media(MediaReference(handle="h", raw=original), tmp_path / "doc.jpg")

        sent = transport.requests[0]["json"]["message"]

        # The decryptable fragment, not just the key — otherwise Evolution has
        # to have stored the message, and it deliberately has not.
        assert sent["message"] == original["message"]
        assert sent["key"] == original["key"]

    def test_corrupted_media_is_refused_rather_than_written_truncated(self, tmp_path):
        transport = FakeTransport([HttpResponse(200, b'{"base64":"not valid base64!!"}')])
        provider = EvolutionProvider("https://evo.local", "main", "k", transport)

        # A truncated document would be written, then read by OCR as a bad scan.
        with pytest.raises(MediaDownloadError):
            provider.download_media(MediaReference(handle="h"), tmp_path / "x.jpg")

    def test_downloading_without_a_base_url_says_why(self, tmp_path):
        with pytest.raises(MediaDownloadError, match="nothing to download from"):
            EvolutionProvider().download_media(MediaReference(handle="h"), tmp_path / "x.jpg")


class TestADocumentInsideAnEnvelope:
    """WhatsApp wraps a message rather than adding fields to it.

    A caption is not a field set on a document — WhatsApp replaces the whole
    message with a `documentWithCaptionMessage` holding the original one level
    down, and does the same for disappearing and view-once messages. An
    unwrapped parser sees a key it has no type for, decides the message carries
    no document, and skips it at DEBUG. The document vanishes with no error,
    and it is the captioned ones that vanish — the only ones that say which
    client the file belongs to.
    """

    DOC = {
        "url": "https://evo.local/m.enc",
        "mimetype": "application/pdf",
        "fileName": "Account Statement - 12-Jul-26.pdf",
        "fileLength": {"low": 69100, "high": 0, "unsigned": True},
        "caption": "File no 279",
    }

    def _parse(self, body: dict):
        item = {"key": {"id": "M1", "remoteJid": f"{OWN_NUMBER}@s.whatsapp.net", "fromMe": True},
                "message": body}

        return EvolutionProvider().parse_webhook({"event": "messages.upsert", "data": item})

    def test_a_captioned_document_is_still_a_document(self):
        parsed = self._parse({"documentWithCaptionMessage": {"message": {"documentMessage": self.DOC}}})[0]

        assert parsed.type is MessageType.DOCUMENT
        assert parsed.has_document
        assert parsed.text == "File no 279"
        assert parsed.media.size_bytes == 69100

    def test_a_disappearing_message_is_opened_too(self):
        parsed = self._parse({"ephemeralMessage": {"message": {"documentMessage": self.DOC}}})[0]

        assert parsed.has_document

    def test_envelopes_nest(self):
        # A disappearing message carrying a captioned document: two deep.
        parsed = self._parse({
            "ephemeralMessage": {
                "message": {"documentWithCaptionMessage": {"message": {"documentMessage": self.DOC}}}
            }
        })[0]

        assert parsed.has_document
        assert parsed.text == "File no 279"

    def test_an_uncaptioned_document_is_unaffected(self):
        parsed = self._parse({"documentMessage": dict(self.DOC, caption=None)})[0]

        assert parsed.has_document
        assert parsed.text == ""

    def test_an_empty_envelope_is_unsupported_rather_than_an_error(self):
        parsed = self._parse({"documentWithCaptionMessage": {}})[0]

        assert parsed.type is MessageType.UNSUPPORTED
        assert not parsed.has_document

    def test_a_pathological_nesting_terminates(self):
        # Built deeper than the cap on purpose; it must return, not recurse away.
        body: dict = {"documentMessage": self.DOC}
        for _ in range(40):
            body = {"ephemeralMessage": {"message": body}}

        parsed = self._parse(body)[0]

        assert parsed.type is MessageType.UNSUPPORTED


class TestWebhookEndpoint:
    def _wire(self, secret: str = SECRET, owner: str = OWN_NUMBER):
        provider = MetaBusinessProvider(MetaConfig("PN1", "t", app_secret=secret, verify_token="t0ken"))
        queue = InboundQueue()
        inbox = InboxFilter(SelfChatPolicy.for_owner(owner))
        receiver = WebhookReceiver(provider, MessageSender(provider), lambda _m: None, None, inbox)

        routes: dict = {}

        class FakeServer:
            def route(self, method, path, handler):
                routes[(method, path)] = handler

        meta_webhook_routes(FakeServer(), provider, receiver, queue)

        return routes, queue

    def _post(self, routes, body: bytes, secret: str = SECRET):
        return routes[("POST", "/webhook/whatsapp")](
            Request(body=body, headers={"x-hub-signature-256": signed(body, secret)})
        )

    def _message(self, sender: str = OWN_NUMBER, message_id: str = "wamid.1") -> bytes:
        return json.dumps({
            "entry": [{"changes": [{"value": {"messages": [
                {"id": message_id, "from": sender, "type": "text", "text": {"body": "CNIC"}}
            ]}}]}]
        }).encode()

    def test_a_signed_meta_message_is_acknowledged_but_never_queued(self):
        """The self-chat rule applied to Meta, which has no self-chat.

        The Cloud API only ever delivers messages *to* the business number from
        somebody else — it does not hand the business its own messages back. So
        under the Version 1 policy a Meta deployment accepts nothing. That is
        the rule working, not an oversight, and it is why Version 1 is
        Evolution-only.

        Asserted here so that anyone who wires Meta up and finds an empty queue
        reads this rather than going hunting for a broken signature.
        """
        routes, queue = self._wire()

        status, payload = self._post(routes, self._message())

        # 200 because the signature was good: Meta must not be asked to retry.
        assert status == 200
        assert payload["accepted"] == 0
        assert len(queue) == 0

    def test_an_unsigned_request_is_refused_before_parsing(self):
        routes, queue = self._wire()

        status, payload = routes[("POST", "/webhook/whatsapp")](
            Request(body=self._message(), headers={})
        )

        # A forged webhook would put an attacker's document into a reviewer's
        # queue with a real client's name attached.
        assert status == 403
        assert payload["error"] == "bad_signature"
        assert len(queue) == 0

    def test_a_wrongly_signed_request_is_refused(self):
        routes, queue = self._wire()

        status, _ = self._post(routes, self._message(), secret="wrong-secret")

        assert status == 403
        assert len(queue) == 0

    def test_a_message_from_another_conversation_is_not_queued(self):
        routes, queue = self._wire()

        status, payload = self._post(routes, self._message(sender="923009999999"))

        # Correctly signed by Meta, and still none of this deployment's business.
        assert status == 200
        assert payload["accepted"] == 0
        assert len(queue) == 0

    def test_an_unparseable_body_is_acknowledged_not_rejected(self):
        routes, _ = self._wire()

        status, _ = self._post(routes, b"this is not json")

        # 200, because an error asks Meta to retry a payload that will never
        # parse, forever.
        assert status == 200

    def test_the_verification_handshake_echoes_the_challenge(self):
        routes, _ = self._wire()

        status, payload = routes[("GET", "/webhook/whatsapp")](
            Request(query={"hub.mode": "subscribe", "hub.verify_token": "t0ken",
                           "hub.challenge": "98765"})
        )

        assert status == 200
        assert payload == "98765"

    def test_a_wrong_verification_token_is_refused(self):
        routes, _ = self._wire()

        status, _ = routes[("GET", "/webhook/whatsapp")](
            Request(query={"hub.mode": "subscribe", "hub.verify_token": "guess",
                           "hub.challenge": "98765"})
        )

        assert status == 403

    def test_a_retried_delivery_is_acknowledged_every_time(self):
        """Retries must always get a 200, whether or not anything is queued.

        This used to assert deduplication here. It cannot any more: the
        self-chat rule refuses every Meta delivery before the deduplicator sees
        it, so a queue length of one would be unreachable and a queue length of
        zero proves nothing about dedup.

        Deduplication is proven where it can be — over Evolution, on the one
        conversation that is processed:
        tests/test_whatsapp.py::TestWebhookReceiver::test_a_retried_webhook_is_processed_once

        What is worth asserting here is that a refused message still returns
        200. Anything else asks Meta to retry it forever.
        """
        routes, queue = self._wire()

        first, _ = self._post(routes, self._message(message_id="wamid.same"))
        second, _ = self._post(routes, self._message(message_id="wamid.same"))

        assert (first, second) == (200, 200)
        assert len(queue) == 0


class TestEvolutionWebhookEndpoint:
    """Evolution's delivery path, which nothing signs.

    Meta HMACs the body with the app secret. Evolution posts plain JSON and
    offers only custom headers, so a shared token is the strongest thing
    available — and the route refuses to exist without one, because an inbound
    endpoint that accepts anything puts a forged document in a reviewer's queue
    with a real client's name on it.
    """

    TOKEN = "a-shared-token"

    def _wire(self, token: str = TOKEN, owner: str = OWN_NUMBER):
        from app.whatsapp.webhook import EVOLUTION_SECRET_HEADER, evolution_webhook_routes

        provider = EvolutionProvider()
        queue = InboundQueue()
        inbox = InboxFilter(SelfChatPolicy.for_owner(owner))
        receiver = WebhookReceiver(provider, MessageSender(provider), lambda _m: None, None, inbox)

        routes: dict = {}

        class FakeServer:
            def route(self, method, path, handler):
                routes[(method, path)] = handler

        evolution_webhook_routes(FakeServer(), receiver, queue, token)

        return routes, queue, EVOLUTION_SECRET_HEADER

    def _delivery(self, chat: str = OWN_NUMBER, event: str = "messages.upsert",
                  message_id: str = "evo-1", from_me: bool = True) -> bytes:
        return json.dumps({
            "event": event,
            "instance": "taxpilot",
            "sender": f"{OWN_NUMBER}@s.whatsapp.net",
            "data": [{
                "key": {"id": message_id, "remoteJid": f"{chat}@s.whatsapp.net", "fromMe": from_me},
                "message": {"conversation": "here is a document"},
            }],
        }).encode()

    def _post(self, routes, header, body: bytes, token: str | None = TOKEN):
        headers = {header.lower(): token} if token is not None else {}

        return routes[("POST", "/webhook/evolution")](Request(body=body, headers=headers))

    def test_a_self_chat_message_with_the_right_token_is_queued(self):
        routes, queue, header = self._wire()

        status, payload = self._post(routes, header, self._delivery())

        assert status == 200
        assert payload["accepted"] == 1
        assert len(queue) == 1

    def test_a_delivery_without_the_token_is_refused(self):
        routes, queue, header = self._wire()

        status, payload = self._post(routes, header, self._delivery(), token=None)

        assert status == 403
        assert payload["error"] == "unauthorised"
        assert len(queue) == 0

    def test_a_delivery_with_the_wrong_token_is_refused(self):
        routes, queue, header = self._wire()

        status, _ = self._post(routes, header, self._delivery(), token="not-the-token")

        assert status == 403
        assert len(queue) == 0

    def test_no_route_exists_at_all_without_a_configured_secret(self):
        """Not an open route — no route.

        A route that accepted deliveries because it had nothing to check them
        against would be worse than an absent one: the absent route 404s and
        somebody notices, while the open one quietly admits whatever finds the
        port.
        """
        routes, _, _ = self._wire(token="")

        assert ("POST", "/webhook/evolution") not in routes

    def test_another_persons_chat_is_authenticated_and_still_refused(self):
        # The token says the caller is Evolution. It says nothing about whose
        # conversation this is, and that is a separate gate.
        routes, queue, header = self._wire()

        status, payload = self._post(routes, header, self._delivery(chat="923009999999",
                                                                   from_me=False))

        assert status == 200
        assert payload["accepted"] == 0
        assert len(queue) == 0

    def test_events_that_are_not_messages_are_acknowledged_and_ignored(self):
        # Evolution sends connection updates, presence and typing indicators to
        # the same URL. Parsing one as a message is how a presence update
        # becomes a document.
        routes, queue, header = self._wire()

        for event in ("connection.update", "presence.update", "contacts.upsert"):
            status, payload = self._post(routes, header, self._delivery(event=event))

            assert status == 200
            assert payload["accepted"] == 0

        assert len(queue) == 0

    def test_the_underscored_event_spelling_is_understood(self):
        # Evolution reports MESSAGES_UPSERT in some versions and
        # messages.upsert in others, on the same endpoint.
        routes, queue, header = self._wire()

        self._post(routes, header, self._delivery(event="MESSAGES_UPSERT"))

        assert len(queue) == 1

    def test_an_unparseable_body_is_acknowledged_not_rejected(self):
        routes, _, header = self._wire()

        status, _ = self._post(routes, header, b"not json at all")

        # An error response asks for a retry of a body that will never parse.
        assert status == 200


class TestInboundQueue:
    def test_messages_come_back_in_order(self):
        from app.whatsapp.messages import InboundMessage, MessageType

        queue = InboundQueue()

        for i in range(3):
            queue.put(InboundMessage(provider_message_id=f"m{i}", sender=OWN_NUMBER,
                                     type=MessageType.TEXT))

        assert [m.provider_message_id for m in queue.drain()] == ["m0", "m1", "m2"]

    def test_draining_empties_it(self):
        from app.whatsapp.messages import InboundMessage, MessageType

        queue = InboundQueue()
        queue.put(InboundMessage(provider_message_id="m", sender=OWN_NUMBER, type=MessageType.TEXT))
        queue.drain()

        assert len(queue) == 0

    def test_it_is_bounded_and_drops_the_oldest(self):
        from app.whatsapp.messages import InboundMessage, MessageType

        queue = InboundQueue(capacity=2)

        for i in range(4):
            queue.put(InboundMessage(provider_message_id=f"m{i}", sender=OWN_NUMBER,
                                     type=MessageType.TEXT))

        # A retry storm must not grow until the process dies, and the newest
        # message is the one somebody is waiting on.
        assert [m.provider_message_id for m in queue.drain()] == ["m2", "m3"]
        assert queue.dropped == 2


class TestHttpTransport:
    def test_a_non_http_url_is_refused(self):
        # A provider base URL is configuration, and configuration can be wrong.
        # file:// here would read local files instead of calling out.
        with pytest.raises(HttpError, match="Refusing"):
            UrllibTransport().request("GET", "file:///etc/passwd")

    def test_a_non_json_body_says_so_rather_than_crashing(self):
        response = HttpResponse(200, b"<html>a proxy error page</html>")

        with pytest.raises(HttpError, match="not JSON"):
            response.json()

    def test_ok_covers_the_two_hundreds(self):
        assert HttpResponse(200, b"").ok
        assert HttpResponse(204, b"").ok
        assert not HttpResponse(404, b"").ok
        assert not HttpResponse(500, b"").ok


class TestTheUserAgent:
    """What shared hosting sees, and why the default was unusable.

    On a real customer's cPanel host the firewall answered urllib's default
    `Python-urllib/3.13` by resetting the connection before the application saw
    the request. Nothing about the credential, the URL or the certificate was
    wrong; the header alone decided it. The daemon reported "cannot reach the
    CMS or authenticate" and every obvious explanation was a dead end.

    So the header is asserted here rather than left to whatever urllib defaults
    to next, and asserted by shape — `Mozilla/5.0 (compatible; …)` is the form
    those filters accept, and a rewrite that drops the prefix would pass a
    substring check on "TaxPilot" while breaking every install behind one.
    """

    def _sent(self, monkeypatch, **kwargs) -> dict[str, str]:
        import urllib.request

        captured: dict[str, str] = {}

        class FakeResponse:
            status = 200
            headers: dict[str, str] = {}

            def read(self):
                return b"{}"

            def __enter__(self):
                return self

            def __exit__(self, *_):
                return False

        def fake_urlopen(request, **_):
            captured.update(request.headers)

            return FakeResponse()

        monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
        UrllibTransport().request("GET", "https://cms.test/api", **kwargs)

        # urllib title-cases header names on the way in.
        return {k.lower(): v for k, v in captured.items()}

    def test_it_identifies_itself_in_a_form_shared_hosting_accepts(self, monkeypatch):
        from app.release.version import VERSION

        agent = self._sent(monkeypatch)["user-agent"]

        assert agent.startswith("Mozilla/5.0 (compatible;")
        assert f"TaxPilot-AI/{VERSION}" in agent

    def test_it_never_leaves_urllibs_default(self, monkeypatch):
        # The whole point: `Python-urllib/…` is the string that gets dropped.
        assert "urllib" not in self._sent(monkeypatch)["user-agent"].lower()

    def test_a_caller_that_sets_its_own_keeps_it(self, monkeypatch):
        sent = self._sent(monkeypatch, headers={"User-Agent": "something-deliberate"})

        assert sent["user-agent"] == "something-deliberate"


class TestAnEmptyQueueIsNotAnError:
    """204 is how the command queue says "nothing waiting".

    It is almost every poll. Treating it as a refusal filled the log with a
    warning every five seconds and made a genuinely broken queue look exactly
    like an idle one.
    """

    def test_204_decodes_as_success(self):
        from app.api.client import CmsClient
        from app.config.settings import Settings

        client = CmsClient(Settings("https://cms.test", "k", "s"))

        assert client._decode(b"", 204) == {"ok": True}  # noqa: SLF001

    def test_an_empty_body_on_200_is_also_survivable(self):
        from app.api.client import CmsClient
        from app.config.settings import Settings

        client = CmsClient(Settings("https://cms.test", "k", "s"))

        assert client._decode(b"   ", 200)["ok"] is True  # noqa: SLF001

    def test_a_real_error_body_still_raises(self):
        import pytest

        from app.api.client import AgentApiError, CmsClient
        from app.config.settings import Settings

        client = CmsClient(Settings("https://cms.test", "k", "s"))

        with pytest.raises(AgentApiError):
            client._decode(b'{"ok":false,"error":"permission_denied"}', 403)  # noqa: SLF001
