"""WhatsApp transport.

Two things carry the risk here, and both are tested hard: the 24-hour session
window (which does not exist on the development provider but decides whether
production messages are delivered at all), and deduplication (because webhook
delivery is at-least-once and a duplicate files a document twice).
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta

import pytest

from app.whatsapp.evolution import EvolutionProvider
from app.whatsapp.inbox import InboxFilter, SelfChatPolicy
from app.whatsapp.messages import InboundMessage, MediaReference, MessageType, OutboundMessage
from app.whatsapp.provider import (
    SESSION_WINDOW,
    MessageSender,
    SessionWindow,
    SessionWindowError,
    TemplateNotApprovedError,
)
from app.whatsapp.receiver import SeenMessages, WebhookReceiver

#: The linked account for these tests. Only its own self-chat is processed.
OWNER = "923001234567"


def evolution_payload(
    message_id="msg-1",
    sender=None,
    text="hello",
    from_me=None,
    owner=OWNER,
):
    """A webhook delivery, shaped like the owner's self-chat by default.

    That default matters: the self-chat is now the only conversation that
    reaches a handler, so a test that means "a message arrives" has to build one
    rather than any old direct message. Pass ``sender`` to build some other
    conversation and watch it be refused.
    """
    chat = owner if sender is None else sender
    mine = from_me if from_me is not None else (chat == owner)

    return {
        # Evolution names the connected account on every delivery.
        "sender": f"{owner}@s.whatsapp.net",
        "data": [
            {
                "key": {"id": message_id, "remoteJid": f"{chat}@s.whatsapp.net", "fromMe": mine},
                "message": {"conversation": text},
            }
        ],
    }


class TestMessages:
    def test_an_inbound_message_needs_a_provider_id(self):
        # Without one there is no deduplication, and a retried webhook files the
        # same document twice.
        with pytest.raises(ValueError, match="provider message id"):
            InboundMessage(provider_message_id="", sender="92300", type=MessageType.TEXT)

    def test_an_outbound_message_needs_content(self):
        with pytest.raises(ValueError, match="text or a template"):
            OutboundMessage(recipient="92300")

    def test_media_recognises_documents_and_images(self):
        pdf = MediaReference(handle="h", mime_type="application/pdf")
        img = MediaReference(handle="h", mime_type="image/jpeg")

        assert pdf.is_pdf and not pdf.is_image
        assert img.is_image and not img.is_pdf


class TestAStatedFileSizeIsAlwaysANumber:
    """Whatever a provider says the size is, callers get an int or None.

    From a real failure on 31 July 2026: Baileys hands Evolution WhatsApp's
    `fileLength` as a protobuf Long object. It was declared `int | None` and
    passed straight through, and a dataclass checks nothing, so the dict
    reached `(size_bytes or 0) > cap` and raised TypeError — losing a document
    that had just been correctly identified as a bank statement.
    """

    def test_a_baileys_long_becomes_the_number_it_encodes(self):
        # The exact payload from that message; 69100 is the PDF's real size.
        media = MediaReference(handle="h", size_bytes={"low": 69100, "high": 0, "unsigned": True})

        assert media.size_bytes == 69100

    def test_a_long_spanning_both_halves_is_reassembled(self):
        media = MediaReference(handle="h", size_bytes={"low": 0, "high": 1, "unsigned": True})

        assert media.size_bytes == 4 * 1024**3

    def test_a_plain_number_is_left_alone(self):
        # Meta sends JSON numbers, and must keep working unchanged.
        assert MediaReference(handle="h", size_bytes=69100).size_bytes == 69100

    def test_a_decimal_string_is_read_as_a_number(self):
        assert MediaReference(handle="h", size_bytes="69100").size_bytes == 69100

    def test_a_shape_nobody_recognises_becomes_unknown_rather_than_wrong(self):
        """None, not 0.

        Zero is a claim that the file is empty, which would send callers down
        a branch about an empty attachment. None says the size is unknown,
        which is the truth.
        """
        assert MediaReference(handle="h", size_bytes={"bytes": "lots"}).size_bytes is None
        assert MediaReference(handle="h", size_bytes=-5).size_bytes is None
        assert MediaReference(handle="h", size_bytes="").size_bytes is None

    def test_the_comparison_that_actually_broke_now_works(self):
        media = MediaReference(handle="h", size_bytes={"low": 69100, "high": 0, "unsigned": True})

        assert (media.size_bytes or 0) > 1024
        assert not (media.size_bytes or 0) > 25 * 1024 * 1024


class TestSessionWindow:
    """Meta's rule, enforced for every provider (ADR-0006)."""

    def test_the_window_opens_when_a_client_writes(self):
        window = SessionWindow()

        assert not window.is_open("92300")
        window.record_inbound("92300")
        assert window.is_open("92300")

    def test_the_window_closes_after_twenty_four_hours(self):
        window = SessionWindow()
        window.record_inbound("92300", datetime.now(UTC) - SESSION_WINDOW - timedelta(minutes=1))

        assert not window.is_open("92300")

    def test_it_stays_open_just_inside_the_boundary(self):
        window = SessionWindow()
        window.record_inbound("92300", datetime.now(UTC) - SESSION_WINDOW + timedelta(minutes=1))

        assert window.is_open("92300")

    def test_a_client_who_never_wrote_has_no_window(self):
        # Cold outreach requires a template — this is the rule that catches it.
        assert not SessionWindow().is_open("92300")

    def test_it_reports_when_the_window_closes(self):
        window = SessionWindow()
        window.record_inbound("92300")

        # So a workflow can decide to send now rather than discover later.
        assert window.closes_at("92300") is not None


class TestMessageSender:
    def test_a_free_form_reply_inside_the_window_is_sent(self):
        provider = EvolutionProvider()
        sender = MessageSender(provider)
        sender.window.record_inbound("92300")

        result = sender.send(OutboundMessage(recipient="92300", text="Got your document."))

        assert result.ok
        assert len(provider.sent) == 1

    def test_a_free_form_reply_outside_the_window_is_refused(self):
        sender = MessageSender(EvolutionProvider())
        sender.window.record_inbound("92300", datetime.now(UTC) - timedelta(days=2))

        # Evolution would happily send this; Meta would not. The stricter rule
        # is enforced everywhere so development cannot build something that only
        # works in development.
        with pytest.raises(SessionWindowError, match="24-hour window"):
            sender.send(OutboundMessage(recipient="92300", text="Still there?"))

    def test_the_refusal_says_what_to_do_instead(self):
        sender = MessageSender(EvolutionProvider())

        with pytest.raises(SessionWindowError, match="template"):
            sender.send(OutboundMessage(recipient="92300", text="hello"))

    def test_an_approved_template_may_be_sent_at_any_time(self):
        provider = EvolutionProvider()
        sender = MessageSender(provider, approved_templates={"document_reminder"})

        # No window at all — templates are precisely what re-opens a conversation.
        result = sender.send(
            OutboundMessage(recipient="92300", template="document_reminder", text="reminder")
        )

        assert result.ok

    def test_an_unapproved_template_is_refused_by_name(self):
        sender = MessageSender(EvolutionProvider())

        # The platform returns a generic error; this says which template.
        with pytest.raises(TemplateNotApprovedError, match="invented_template"):
            sender.send(OutboundMessage(recipient="92300", template="invented_template", text="x"))

    def test_no_templates_are_approved_by_default(self):
        # The correct state for a deployment that has not been through review.
        sender = MessageSender(EvolutionProvider())

        with pytest.raises(TemplateNotApprovedError):
            sender.send(OutboundMessage(recipient="92300", template="anything", text="x"))

    def test_an_inbound_message_opens_the_window(self):
        provider = EvolutionProvider()
        sender = MessageSender(provider)

        sender.record_inbound(
            InboundMessage(provider_message_id="m1", sender="92300", type=MessageType.TEXT)
        )

        assert sender.send(OutboundMessage(recipient="92300", text="reply")).ok


class TestDeduplication:
    def test_the_same_id_is_only_accepted_once(self):
        seen = SeenMessages()

        assert seen.add("msg-1")
        assert not seen.add("msg-1")

    def test_it_is_bounded(self):
        seen = SeenMessages(capacity=3)

        for i in range(5):
            seen.add(f"msg-{i}")

        # A deployment runs for months; an unbounded set is a slow leak.
        assert len(seen) == 3
        assert "msg-0" not in seen
        assert "msg-4" in seen

    def test_a_repeated_id_is_not_evicted_while_retries_arrive(self):
        seen = SeenMessages(capacity=2)
        seen.add("busy")
        seen.add("other")

        seen.add("busy")  # a retry refreshes it
        seen.add("newest")

        assert "busy" in seen


class TestWebhookReceiver:
    def _receiver(self, handled: list, seen=None):
        provider = EvolutionProvider()
        # An inbox is required: a receiver without one processes nothing,
        # because this attaches to an account carrying every conversation its
        # owner has ever had.
        inbox = InboxFilter(SelfChatPolicy.for_owner(OWNER))

        return WebhookReceiver(provider, MessageSender(provider), handled.append, seen, inbox)

    def test_it_hands_a_new_message_to_the_handler(self):
        handled: list = []
        receiver = self._receiver(handled)

        processed = receiver.receive(evolution_payload(text="my CNIC"))

        assert len(processed) == 1
        assert handled[0].text == "my CNIC"

    def test_a_retried_webhook_is_processed_once(self):
        handled: list = []
        receiver = self._receiver(handled)
        payload = evolution_payload(message_id="dup-1")

        receiver.receive(payload)
        receiver.receive(payload)

        # Delivery is at-least-once; without this the client's document is filed
        # twice and two workflows start over it.
        assert len(handled) == 1

    def test_a_note_the_owner_sent_themselves_is_processed(self):
        handled: list = []
        receiver = self._receiver(handled)

        receiver.receive(evolution_payload(from_me=True, text="client CNIC"))

        # `fromMe` is true for a note-to-self, which is how documents are meant
        # to arrive. Dropping those — as this once did — would ignore exactly
        # the messages the feature exists to handle. Our own replies are told
        # apart by their recorded id instead; see tests/test_inbox.py.
        assert len(handled) == 1

    def test_a_message_from_anybody_else_never_reaches_the_handler(self):
        # The seam the whole guarantee rests on. A client writing in is an
        # ordinary event and it must stop here — before deduplication, before
        # the session window, before anything is remembered about it.
        handled: list = []
        receiver = self._receiver(handled)

        processed = receiver.receive(evolution_payload(sender="923009999999", text="my CNIC"))

        assert processed == []
        assert handled == []

    def test_a_malformed_payload_is_dropped_rather_than_raised(self):
        handled: list = []
        receiver = self._receiver(handled)

        # Returning an error triggers a provider retry, and a payload that
        # cannot be parsed will not parse on the retry either.
        assert receiver.receive({"garbage": True}) == []
        assert receiver.receive({"data": "not a list"}) == []

    def test_one_failing_message_does_not_stop_the_batch(self):
        seen_ids: list[str] = []

        def handler(message):
            seen_ids.append(message.provider_message_id)
            if message.provider_message_id == "bad":
                raise RuntimeError("handler blew up")

        provider = EvolutionProvider()
        inbox = InboxFilter(SelfChatPolicy.for_owner(OWNER))
        receiver = WebhookReceiver(provider, MessageSender(provider), handler, None, inbox)

        payload = {
            "data": [
                evolution_payload(message_id="bad")["data"][0],
                evolution_payload(message_id="good")["data"][0],
            ]
        }
        processed = receiver.receive(payload)

        assert seen_ids == ["bad", "good"]
        assert [m.provider_message_id for m in processed] == ["good"]

    def test_receiving_opens_the_reply_window(self):
        handled: list = []
        provider = EvolutionProvider()
        sender = MessageSender(provider)
        inbox = InboxFilter(SelfChatPolicy.for_owner(OWNER))
        receiver = WebhookReceiver(provider, sender, handled.append, None, inbox)

        receiver.receive(evolution_payload())

        # The owner's self-chat is the only conversation that gets this far, so
        # it is the only one a reply window can open for.
        assert sender.send(OutboundMessage(recipient=OWNER, text="reply")).ok


class TestEvolutionParsing:
    def test_it_reads_a_plain_text_message(self):
        parsed = EvolutionProvider().parse_webhook(evolution_payload(text="hello"))

        assert parsed[0].type is MessageType.TEXT
        assert parsed[0].sender == "923001234567"

    def test_it_reads_a_document_with_its_caption(self):
        payload = {
            "data": [
                {
                    "key": {"id": "d1", "remoteJid": "923001234567@s.whatsapp.net"},
                    "message": {
                        "documentMessage": {
                            "url": "https://example.test/doc",
                            "mimetype": "application/pdf",
                            "fileName": "statement.pdf",
                            "caption": "my bank statement",
                        }
                    },
                }
            ]
        }

        message = EvolutionProvider().parse_webhook(payload)[0]

        assert message.type is MessageType.DOCUMENT
        assert message.has_document
        assert message.media.is_pdf
        # The caption is the client telling us what the document is.
        assert message.text == "my bank statement"

    def test_it_reads_an_image(self):
        payload = {
            "data": [
                {
                    "key": {"id": "i1", "remoteJid": "923001234567@s.whatsapp.net"},
                    "message": {"imageMessage": {"url": "u", "mimetype": "image/jpeg"}},
                }
            ]
        }

        message = EvolutionProvider().parse_webhook(payload)[0]

        assert message.type is MessageType.IMAGE
        assert message.has_document

    def test_an_unsupported_type_is_recognised_rather_than_dropped(self):
        payload = {
            "data": [
                {
                    "key": {"id": "s1", "remoteJid": "923001234567@s.whatsapp.net"},
                    "message": {"stickerMessage": {"url": "u"}},
                }
            ]
        }

        message = EvolutionProvider().parse_webhook(payload)[0]

        # Recognised so the AI can reply usefully instead of silently ignoring
        # someone who sent something.
        assert message.type is MessageType.UNSUPPORTED

    def test_a_single_object_payload_is_accepted(self):
        # Some Evolution versions send one object rather than a list.
        payload = {"data": evolution_payload()["data"][0]}

        assert len(EvolutionProvider().parse_webhook(payload)) == 1

    def test_a_message_without_an_id_is_skipped(self):
        payload = {"data": [{"key": {"remoteJid": "92300@s.whatsapp.net"}, "message": {}}]}

        assert EvolutionProvider().parse_webhook(payload) == []


class TestDevelopmentSafety:
    def test_the_development_provider_records_rather_than_transmits(self):
        # No base URL configured: nothing leaves the machine, so the whole path
        # is exercisable without a live instance or a real number.
        provider = EvolutionProvider()
        provider.send(OutboundMessage(recipient="92300", text="hi"))

        assert len(provider.sent) == 1

    def test_a_configured_base_url_transmits_instead_of_recording(self):
        """The recording behaviour is a *development* affordance, not a fallback.

        A provider given a real instance must actually call it. Recording when
        configured would look identical in tests and send nothing in production
        — the worst possible failure for a messaging integration.
        """
        from app.support.http import HttpResponse

        class Transport:
            def __init__(self):
                self.calls = []

            def request(self, method, url, **kwargs):
                self.calls.append(url)

                return HttpResponse(200, b'{"key":{"id":"evo-1"}}')

        transport = Transport()
        provider = EvolutionProvider("https://evolution.local", "dev", transport=transport)

        result = provider.send(OutboundMessage(recipient="92300", text="hi"))

        assert result.ok
        assert provider.sent == []          # not recorded
        assert transport.calls               # transmitted
