"""Which conversations this deployment is allowed to touch.

TaxPilot AI connects to a real person's WhatsApp account. That account also
carries their family, their friends, their other clients and everything else
they have ever discussed. **Almost every message it can see is one it must not
read.**

## The rule

One conversation: the connected account's own self-chat — the "Message
yourself" thread, where the owner is both ends. Nothing else, ever.

A message is processed only when all of these hold:

    the provider says the connected account wrote it   (from_me)
    the conversation is the owner's own                (chat    == owner)
    the sender is the owner                            (sender  == owner)
    the recipient is the owner                         (recipient == owner)

Everything else is ignored, and the list of what "everything else" covers is
the point: individual conversations with other people, client chats, family
chats, groups, broadcasts, channels, communities, and any message where either
end is not the connected number.

## Why it is not configurable

In Version 1 there is no setting that widens this, because a setting that can
widen it is a setting that can be got wrong once and read somebody's private
messages forever. The owner's number is discovered from the connected session
and *is* the policy — nothing is typed in, so nothing can be mistyped.

That also removes the failure this module used to warn about: a number written
in a format the matcher did not recognise silently matched nothing, the AI
ignored every message, and the deployment looked perfectly healthy. There is
now nothing to write in the wrong format.

## Why the owner comes from the session and never from a payload

The owner is resolved from the provider over its authenticated API. It is never
taken from the message being judged, and never from the webhook envelope —
both are supplied by whoever posted to the endpoint. A forged delivery claiming
to be from a different account is measured against the real owner and refused.

## Fails closed

Until the owner is known, nothing is processed. A deployment that cannot
confirm whose account it is attached to has no business reading any of it.
"""

from __future__ import annotations

import logging
import re
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta

from app.whatsapp.messages import InboundMessage

logger = logging.getLogger(__name__)

#: WhatsApp suffixes. `@s.whatsapp.net` and `@c.us` are individuals, `@g.us` a
#: group, `@lid` a privacy-preserving identifier newer clients may send.
_JID_SUFFIX = re.compile(r"@(s\.whatsapp\.net|c\.us|g\.us|lid|broadcast)$", re.IGNORECASE)

#: Pakistan. Used only to reconcile the local `03xx` form with the international
#: `923xx` one — the two are the same number and providers report both.
_DEFAULT_COUNTRY_CODE = "92"

#: Suffixes that are never a one-to-one conversation, whatever the digits say.
#:
#: Checked by name rather than left to the number comparison. A community or
#: channel id will not match a phone number anyway, so this is redundant today —
#: which is exactly why it is written down. The guarantee is that these are
#: excluded, and a guarantee resting on "their ids happen to look different"
#: is one a provider can retire without telling anybody.
_NEVER_A_SELF_CHAT = (
    "@g.us",           # group
    "@broadcast",      # broadcast list, and status@broadcast
    "@newsletter",     # channel
    "@lid",            # privacy-preserving id: real, but not a number we own
)


def normalise(identifier: str, country_code: str = _DEFAULT_COUNTRY_CODE) -> str:
    """Reduce a chat id or number to something comparable.

    Handles what people and providers actually produce:

        923001234567@s.whatsapp.net   from the provider
        +92 300 1234567               copied out of a contact
        0300-1234567                  written the local way
        120363001234567890@g.us       a group, which has no phone number

    A group id keeps its suffix, because a group's digits are not a phone number
    and reconciling them with one would be nonsense.
    """
    value = identifier.strip()

    if not value:
        return ""

    is_group = value.lower().endswith("@g.us")
    bare = _JID_SUFFIX.sub("", value)

    # Device suffix: WhatsApp appends ":12" for a linked device on some ids.
    bare = bare.split(":", 1)[0]

    digits = re.sub(r"\D", "", bare)

    if not digits:
        return value.lower()

    if is_group:
        return f"{digits}@g.us"

    # 03001234567 and 923001234567 are one number written two ways.
    if digits.startswith("0") and not digits.startswith("00"):
        digits = country_code + digits[1:]

    # 00 is the international prefix in much of the world; +92… and 0092… are
    # the same number.
    if digits.startswith("00"):
        digits = digits[2:]

    return digits


@dataclass(frozen=True, slots=True)
class SelfChatPolicy:
    """The only conversation this deployment may read.

    Immutable, and holds one fact: whose account this is. Everything the policy
    decides follows from that, so there is no combination of settings to reason
    about and no state in which it permits more than one conversation.
    """

    owner: str = ""
    """The connected number, normalised. Empty means not yet known."""

    country_code: str = _DEFAULT_COUNTRY_CODE

    @classmethod
    def for_owner(cls, number: str | None, country_code: str = _DEFAULT_COUNTRY_CODE) -> SelfChatPolicy:
        return cls(owner=normalise(number or "", country_code), country_code=country_code)

    @property
    def is_configured(self) -> bool:
        """Whether the owner is known — the only thing that can be missing."""
        return bool(self.owner)

    def allows(self, message: InboundMessage) -> bool:
        """Whether this message is in the owner's own self-chat.

        Every condition is checked, even where one implies another. The chat id
        being the owner's number already means both ends are the owner on both
        providers today; asserting the ends separately is what keeps that true
        when a provider changes how it labels a conversation.
        """
        if not self.is_configured:
            return False

        # The provider's own attestation of authorship. A message the connected
        # account did not write cannot be in its self-chat, whatever the ids say.
        if not message.from_me:
            return False

        for identifier in (message.chat, message.sender, message.recipient):
            if not self._is_owner(identifier):
                return False

        return True

    def _is_owner(self, identifier: str) -> bool:
        value = (identifier or "").strip().lower()

        if not value:
            return False

        # A group, broadcast, channel or community is not a self-chat no matter
        # what its digits reduce to.
        if any(value.endswith(suffix) for suffix in _NEVER_A_SELF_CHAT):
            return False

        return normalise(value, self.country_code) == self.owner

    def describe(self) -> str:
        """What the policy is, for an operator to read at boot.

        Masked. This is a real person's number and it goes to a log that is
        collected and kept — the last three digits are enough to recognise your
        own and not enough to be a contact list.
        """
        if not self.is_configured:
            return "nothing (the connected number is not known yet — every message is ignored)"

        return f"the self-chat of {_mask(self.owner)} only"


def _mask(identifier: str) -> str:
    if identifier.endswith("@g.us"):
        return f"group …{identifier[-11:]}"

    return f"…{identifier[-3:]}" if len(identifier) > 3 else "…"


class InboxFilter:
    """Applies the policy, keeps the owner current, and counts what it refused.

    Counting rather than logging each rejection is deliberate. The entire point
    of this class is that other conversations are none of the AI's business, and
    a log line naming the sender of every ignored message would rebuild the
    contact list this exists to protect — in a file that is collected and kept.
    """

    #: How long a resolved owner is trusted before asking the provider again.
    #:
    #: The owner changes only when somebody links a different account, which is
    #: a deliberate act on the settings page. A minute is short enough to follow
    #: that and long enough that a busy inbox is not one HTTP call per message.
    REFRESH_SECONDS = 60

    def __init__(
        self,
        policy: SelfChatPolicy | None = None,
        owner_resolver: Callable[[], str | None] | None = None,
    ) -> None:
        self._policy = policy if policy is not None else SelfChatPolicy()
        self._resolver = owner_resolver
        self._resolved_at: datetime | None = None
        self.allowed = 0
        self.ignored = 0

        # When the pipe last proved itself. Any delivery counts, allowed or
        # ignored, because the question this answers is "can messages reach us
        # at all?" — Evolution reported its session as open for 45 minutes on
        # 2026-08-03 while its stream was dead, and the absence of events was
        # the only signal that anything was wrong. Silence is ambiguous (a
        # quiet evening looks identical), so this is surfaced for a person to
        # judge rather than alerted on.
        self.last_event_at: datetime | None = None

        # Forwarded attachments taken and refused, counted once a message has
        # already been allowed through. Kept here rather than on the queue
        # because this is the object the status report already reads, and
        # because they belong beside the other two: "allowed, then refused" is a
        # different outcome from "ignored", and a deployment that filed nothing
        # needs those told apart to know which thing is wrong.
        #
        # Counts only — never a filename, a type or a number. This is telemetry
        # that reaches a browser (ADR-0002).
        self.forwards_accepted = 0
        self.forwards_rejected = 0

        # Refused and failed are kept apart because they send whoever is
        # diagnosing this to different places. Rejected means bytes arrived and
        # were wrong — the wrong type, too large, not what the file claimed to
        # be — so the thing to do is ask whoever sent it. Failed means no bytes
        # arrived at all, and the thing to look at is the provider. One number
        # covering both is the same mistake as reporting only `allowed`.
        self.forwards_failed = 0

    @property
    def policy(self) -> SelfChatPolicy:
        """The policy as it stands, without going and asking anybody."""
        return self._policy

    def current_policy(self) -> SelfChatPolicy:
        """The policy, resolving the owner first if that is due.

        For callers whose whole question is *whether the owner is known* —
        health, and the status report the settings page renders. Reading
        `.policy` answers that from state only an arriving message would have
        populated, so a linked account with a quiet inbox reported itself as
        having no account linked at all. It said so on the customer's settings
        page, under the number it had just connected.
        """
        self._refresh_owner_if_due()

        return self._policy

    def permits(self, message: InboundMessage) -> bool:
        self._refresh_owner_if_due()

        # Stamped before the verdict: an ignored message still proves delivery.
        self.last_event_at = datetime.now(UTC)

        if self._policy.allows(message):
            self.allowed += 1

            return True

        self.ignored += 1
        # Masked, and only at DEBUG, so diagnosing a deployment that ignores
        # everything is possible without writing somebody's private contacts to
        # disk.
        logger.debug(
            "Ignored a message from %s: only the owner's self-chat is processed.",
            _mask(normalise(message.chat or message.sender)),
        )

        return False

    def _refresh_owner_if_due(self, now: datetime | None = None) -> None:
        """Ask the provider who it is attached to, occasionally.

        A failure keeps the last known owner rather than dropping to closed. It
        cannot widen anything: a session relinked to a different number produces
        a self-chat under *that* number, which the stale owner refuses. So the
        conservative-looking option — forgetting on error — only loses documents
        during a provider blip, and gains no safety at all.
        """
        if self._resolver is None:
            return

        moment = now or datetime.now(UTC)

        if self._resolved_at is not None:
            if moment - self._resolved_at < timedelta(seconds=self.REFRESH_SECONDS):
                return

        try:
            number = self._resolver()
        except Exception:  # noqa: BLE001 - resolving an owner must not raise at a caller
            logger.debug("Could not resolve the connected number; keeping the last known owner.")

            return

        self._resolved_at = moment

        if not number:
            # Disconnected. The owner is kept: the account has not become
            # somebody else's, and re-linking the same number should not require
            # a restart to start working again.
            return

        resolved = SelfChatPolicy.for_owner(number, self._policy.country_code)

        if resolved.owner and resolved.owner != self._policy.owner:
            logger.info("WhatsApp inbox is now %s", resolved.describe())
            self._policy = resolved
