"""Which conversations this deployment may touch.

TaxPilot AI attaches to a real person's WhatsApp account, which also carries
their family, their friends and everything else they have ever discussed. Almost
every message it can see is one it must not read.

Two failure modes, and they are not symmetric. Processing a conversation it
should not is a privacy breach. Processing none when it should is an outage —
annoying, visible, fixed in a minute. So this fails closed, and the tests that
matter most are the ones about *not* processing.

The rule under test is the Version 1 guarantee: **only the connected account's
own self-chat**. There is no setting that widens it, which is why most of what
follows is a list of conversations being refused rather than a list of options
being honoured.
"""

from __future__ import annotations

import logging

import pytest

from app.whatsapp.evolution import EvolutionProvider
from app.whatsapp.inbox import InboxFilter, SelfChatPolicy, normalise
from app.whatsapp.messages import InboundMessage, MessageType, OutboundMessage
from app.whatsapp.provider import MessageSender
from app.whatsapp.receiver import SeenMessages, WebhookReceiver

OWN_NUMBER = "923001234567"
SOMEONE_ELSE = "923009999999"


def _message(
    chat: str = OWN_NUMBER,
    sender: str | None = None,
    recipient: str | None = None,
    from_me: bool = True,
    message_id: str = "m1",
) -> InboundMessage:
    """A parsed message, built directly so each field can be varied on its own."""
    return InboundMessage(
        provider_message_id=message_id,
        sender=sender if sender is not None else chat,
        recipient=recipient if recipient is not None else chat,
        chat=chat,
        type=MessageType.TEXT,
        text="a document",
        from_me=from_me,
    )


def _payload(
    chat: str,
    message_id: str = "m1",
    text: str = "hello",
    from_me: bool = False,
    participant: str = "",
    owner: str = OWN_NUMBER,
    suffix: str = "@s.whatsapp.net",
) -> dict:
    key: dict = {"id": message_id, "remoteJid": f"{chat}{suffix}", "fromMe": from_me}

    if participant:
        key["participant"] = f"{participant}@s.whatsapp.net"

    return {
        # Evolution names the connected account on every delivery.
        "sender": f"{owner}@s.whatsapp.net",
        "data": [{"key": key, "message": {"conversation": text}}],
    }


class TestNormalisation:
    """A number that does not match silently ignores everything.

    Less dangerous than it was, because nobody types a number in any more. But
    the provider still reports one id in several shapes, and a comparison that
    tripped over that would refuse the owner their own chat.
    """

    @pytest.mark.parametrize(
        "written",
        [
            "923001234567",
            "923001234567@s.whatsapp.net",
            "923001234567@c.us",
            "+92 300 1234567",
            "+92-300-1234567",
            "923001234567:12@s.whatsapp.net",
            "0092 300 1234567",
        ],
    )
    def test_every_form_of_one_number_matches(self, written):
        assert normalise(written) == OWN_NUMBER

    def test_the_local_form_reconciles_with_the_international_one(self):
        assert normalise("03001234567") == normalise("923001234567")

    def test_a_group_keeps_its_identity(self):
        assert normalise("120363001234567890@g.us") == "120363001234567890@g.us"

    def test_a_group_is_never_confused_with_a_number(self):
        assert normalise("120363001234567890@g.us") != normalise("120363001234567890")

    def test_blank_is_blank(self):
        assert normalise("") == ""
        assert normalise("   ") == ""


class TestFailsClosed:
    """The property that protects everything else."""

    def test_a_policy_with_no_owner_allows_nothing(self):
        policy = SelfChatPolicy()

        assert policy.is_configured is False
        # Not even a message that is otherwise a perfect self-chat. Until this
        # deployment knows whose account it is on, it has no business reading
        # any of it.
        assert policy.allows(_message()) is False

    def test_an_unconfigured_receiver_processes_nothing(self):
        handled: list = []
        provider = EvolutionProvider()
        receiver = WebhookReceiver(provider, MessageSender(provider), handled.append)

        receiver.receive(_payload(chat=OWN_NUMBER, from_me=True, text="my CNIC"))

        # No inbox given must mean "touch nothing", never "touch everything".
        assert handled == []

    def test_describe_says_plainly_when_the_owner_is_unknown(self):
        assert "every message is ignored" in SelfChatPolicy().describe()


class TestTheSelfChat:
    """The one conversation that is processed."""

    def _receiver(self, handled: list, owner: str = OWN_NUMBER, seen=None):
        provider = EvolutionProvider()
        seen = seen if seen is not None else SeenMessages()
        sender = MessageSender(provider, sent=seen)

        return WebhookReceiver(
            provider, sender, handled.append, seen, InboxFilter(SelfChatPolicy.for_owner(owner))
        )

    def test_a_document_sent_to_yourself_is_processed(self):
        handled: list = []
        receiver = self._receiver(handled)

        receiver.receive(_payload(chat=OWN_NUMBER, from_me=True, text="client CNIC"))

        # `fromMe` is true for a note-to-self. Filtering those out would ignore
        # exactly the messages this feature exists to handle.
        assert len(handled) == 1
        assert handled[0].text == "client CNIC"

    def test_our_own_reply_coming_back_is_not_reprocessed(self):
        handled: list = []
        seen = SeenMessages()
        provider = EvolutionProvider()
        sender = MessageSender(provider, sent=seen)
        sender.window.record_inbound(OWN_NUMBER)
        receiver = WebhookReceiver(
            provider,
            sender,
            handled.append,
            seen,
            InboxFilter(SelfChatPolicy.for_owner(OWN_NUMBER)),
        )

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

        # The provider echoes it back, fromMe, in the one allowed chat —
        # otherwise indistinguishable from a note the user typed. Told apart by
        # its recorded id, which is why the self-chat rule does not have to.
        receiver.receive(_payload(
            chat=OWN_NUMBER, from_me=True, message_id=result.provider_message_id, text="Filed."
        ))

        assert handled == []

    def test_an_ignored_message_leaves_no_trace(self):
        handled: list = []
        seen = SeenMessages()
        receiver = self._receiver(handled, seen=seen)

        receiver.receive(_payload(chat=SOMEONE_ELSE, message_id="private-1"))

        # Not remembered, and no session window opened. A conversation this
        # deployment may not touch should not appear anywhere in its state.
        assert "private-1" not in seen
        assert len(seen) == 0


class TestEverythingElseIsRefused:
    """The guarantee, one excluded conversation at a time.

    Each of these is a real thing that arrives on a real account. Written out
    rather than collapsed into one parametrised case because the guarantee is
    stated as a list, and somebody checking whether communities are covered
    should be able to find the word.
    """

    def _policy(self) -> SelfChatPolicy:
        return SelfChatPolicy.for_owner(OWN_NUMBER)

    def test_an_individual_conversation_with_somebody_else(self):
        assert self._policy().allows(_message(chat=SOMEONE_ELSE, from_me=False)) is False

    def test_a_client_writing_in(self):
        # The one a customer is most likely to expect to work. It does not: a
        # client's own chat is not the owner's self-chat, even though the
        # recipient really is the owner.
        assert self._policy().allows(
            _message(chat=SOMEONE_ELSE, recipient=OWN_NUMBER, from_me=False)
        ) is False

    def test_a_message_the_owner_sent_to_somebody_else(self):
        # fromMe is true and the account really did write it — and it is still a
        # private conversation with another person.
        assert self._policy().allows(_message(chat=SOMEONE_ELSE, from_me=True)) is False

    def test_a_family_chat(self):
        assert self._policy().allows(_message(chat="923007777777", from_me=False)) is False

    def test_a_group(self):
        assert self._policy().allows(
            _message(chat="120363001234567890@g.us", sender=OWN_NUMBER, recipient=OWN_NUMBER)
        ) is False

    def test_a_group_whose_digits_are_the_owners_number(self):
        # Contrived, and exactly why the suffix is checked by name rather than
        # left to the digits to disagree.
        assert self._policy().allows(
            _message(chat=f"{OWN_NUMBER}@g.us", sender=OWN_NUMBER, recipient=OWN_NUMBER)
        ) is False

    def test_a_broadcast(self):
        assert self._policy().allows(
            _message(chat="status@broadcast", sender=OWN_NUMBER, recipient=OWN_NUMBER)
        ) is False

    def test_a_channel(self):
        assert self._policy().allows(
            _message(chat="120363999@newsletter", sender=OWN_NUMBER, recipient=OWN_NUMBER)
        ) is False

    def test_a_community(self):
        # Communities are carried as groups, announcement chat included.
        assert self._policy().allows(
            _message(chat="120363888000@g.us", sender=OWN_NUMBER, recipient=OWN_NUMBER)
        ) is False

    def test_a_message_whose_sender_is_not_the_owner(self):
        # Right conversation, wrong author. Refused on the sender alone.
        assert self._policy().allows(_message(chat=OWN_NUMBER, sender=SOMEONE_ELSE)) is False

    def test_a_message_whose_recipient_is_not_the_owner(self):
        # Right conversation and author, addressed elsewhere. Refused on the
        # recipient alone — the check the message shape had no field for until
        # this rule needed one.
        assert self._policy().allows(_message(chat=OWN_NUMBER, recipient=SOMEONE_ELSE)) is False

    def test_a_message_the_account_did_not_write(self):
        # Every id says self-chat and the provider says somebody else wrote it.
        # That should not occur; if it does, it is not trusted.
        assert self._policy().allows(_message(chat=OWN_NUMBER, from_me=False)) is False

    def test_a_group_message_arriving_through_the_receiver(self):
        # End to end rather than policy-only: a group message with the owner
        # speaking must not reach a handler.
        handled: list = []
        provider = EvolutionProvider()
        receiver = WebhookReceiver(
            provider,
            MessageSender(provider),
            handled.append,
            None,
            InboxFilter(SelfChatPolicy.for_owner(OWN_NUMBER)),
        )

        receiver.receive(_payload(
            chat="120363001234567890", suffix="@g.us", participant=OWN_NUMBER, from_me=True
        ))

        assert handled == []


class TestOwnerDetection:
    """The owner is discovered, never configured."""

    def test_the_owner_is_resolved_from_the_provider(self):
        inbox = InboxFilter(owner_resolver=lambda: f"{OWN_NUMBER}@s.whatsapp.net")

        assert inbox.policy.is_configured is False
        assert inbox.permits(_message()) is True
        assert inbox.policy.owner == OWN_NUMBER

    def test_nothing_is_processed_until_the_owner_is_known(self):
        inbox = InboxFilter(owner_resolver=lambda: None)

        assert inbox.permits(_message()) is False

    def test_asking_whether_an_account_is_linked_goes_and_finds_out(self):
        """Health and the status report ask this before any message arrives.

        Found live: a real account was linked and `/health/ready` reported "no
        WhatsApp account linked", because the owner was only ever resolved
        inside permits() — which nothing had called. The settings page would
        have told the customer the same thing, under the number they had just
        connected.
        """
        inbox = InboxFilter(owner_resolver=lambda: OWN_NUMBER)

        assert inbox.policy.is_configured is False
        assert inbox.current_policy().is_configured is True

    def test_relinking_to_another_account_moves_the_inbox(self):
        numbers = [OWN_NUMBER, SOMEONE_ELSE, SOMEONE_ELSE]
        inbox = InboxFilter(owner_resolver=lambda: numbers.pop(0))
        inbox.REFRESH_SECONDS = 0

        assert inbox.permits(_message(chat=OWN_NUMBER)) is True
        # The old owner's chat is now somebody else's conversation.
        assert inbox.permits(_message(chat=OWN_NUMBER)) is False
        assert inbox.permits(_message(chat=SOMEONE_ELSE)) is True

    def test_a_provider_that_cannot_be_reached_keeps_the_last_known_owner(self):
        """Availability, and it costs no safety.

        Forgetting the owner on a blip would drop documents. It would prevent
        nothing: an account relinked to a different number produces a self-chat
        under *that* number, which the stale owner refuses anyway.
        """
        calls = {"n": 0}

        def resolver():
            calls["n"] += 1

            if calls["n"] == 1:
                return OWN_NUMBER

            raise ConnectionError("evolution is down")

        inbox = InboxFilter(owner_resolver=resolver)
        inbox.REFRESH_SECONDS = 0

        assert inbox.permits(_message()) is True
        assert inbox.permits(_message()) is True
        assert inbox.policy.owner == OWN_NUMBER

    def test_a_disconnected_session_does_not_clear_the_owner(self):
        # Re-linking the same number should not need a restart to work again.
        answers = [OWN_NUMBER, None]
        inbox = InboxFilter(owner_resolver=lambda: answers.pop(0))
        inbox.REFRESH_SECONDS = 0

        assert inbox.permits(_message()) is True
        assert inbox.permits(_message()) is True

    def test_the_owner_is_not_taken_from_the_message(self):
        """A forged delivery cannot nominate itself as the owner.

        Evolution names the connected account in the webhook envelope, which is
        convenient and completely untrustworthy — it is whatever the caller
        wrote. The policy measures against the number it resolved itself.
        """
        handled: list = []
        provider = EvolutionProvider()
        receiver = WebhookReceiver(
            provider,
            MessageSender(provider),
            handled.append,
            None,
            InboxFilter(SelfChatPolicy.for_owner(OWN_NUMBER)),
        )

        receiver.receive(_payload(chat=SOMEONE_ELSE, from_me=True, owner=SOMEONE_ELSE))

        assert handled == []


class TestPrivacyOfTheFilterItself:
    def test_rejections_are_counted_not_logged_by_identity(self, caplog):
        inbox = InboxFilter(SelfChatPolicy.for_owner(OWN_NUMBER))

        with caplog.at_level(logging.DEBUG):
            inbox.permits(_message(chat=SOMEONE_ELSE, from_me=False))

        # A log line naming the sender of every ignored message rebuilds the
        # contact list this class exists to protect, in a file that is collected
        # and kept.
        assert SOMEONE_ELSE not in caplog.text
        assert inbox.ignored == 1

    def test_describe_masks_the_number(self):
        described = SelfChatPolicy.for_owner(OWN_NUMBER).describe()

        # Enough to recognise your own number, not enough to be a contact list.
        assert OWN_NUMBER not in described
        assert "567" in described

    def test_counts_are_kept_for_monitoring(self):
        inbox = InboxFilter(SelfChatPolicy.for_owner(OWN_NUMBER))

        inbox.permits(_message())
        inbox.permits(_message(chat=SOMEONE_ELSE, from_me=False))

        # "The AI is not seeing my documents" and "the AI is reading things it
        # should not" look identical without these.
        assert (inbox.allowed, inbox.ignored) == (1, 1)


class TestThereIsNothingToConfigure:
    """Version 1 has no setting that widens the inbox."""

    def test_the_retired_allow_list_variables_are_ignored(self):
        from app.runtime.container import Container

        container = Container({
            "TAXPILOT_WHATSAPP_ALLOWED_NUMBERS": SOMEONE_ELSE,
            "TAXPILOT_WHATSAPP_ALLOWED_CHATS": "120363001234567890@g.us",
        })

        # Whatever was set, the inbox is still one owner's self-chat — and until
        # a session reports one, nothing at all.
        assert container.inbox.policy.is_configured is False
        assert container.inbox.permits(_message(chat=SOMEONE_ELSE, from_me=False)) is False

    def test_setting_them_is_warned_about_rather_than_ignored_silently(self, caplog):
        from app.runtime.container import Container

        container = Container({"TAXPILOT_WHATSAPP_ALLOWED_NUMBERS": SOMEONE_ELSE})

        with caplog.at_level(logging.WARNING):
            _ = container.inbox

        # An operator who set it believes a conversation is being watched, and
        # would go looking at the provider when nothing arrived from it.
        assert "TAXPILOT_WHATSAPP_ALLOWED_NUMBERS" in caplog.text
