"""Messages, independent of which provider carried them.

Everything above this line — agents, workflows, tools — works with these types
and never learns whether Evolution or the Meta Business Platform is configured
(ADR-0006).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum


class MessageType(StrEnum):
    TEXT = "text"
    IMAGE = "image"
    DOCUMENT = "document"
    AUDIO = "audio"
    VIDEO = "video"
    UNSUPPORTED = "unsupported"
    """Stickers, locations, contacts. Recognised so the AI can reply usefully
    rather than silently ignoring someone who sent something."""


def _byte_count(value: object) -> int | None:
    """A provider's stated file size, as an int or nothing at all.

    Three shapes arrive in practice. Meta sends a JSON number. Evolution sends
    whatever Baileys decoded the protobuf into: for a 64-bit field that is a
    `{low, high, unsigned}` pair of 32-bit halves, and on some messages a
    decimal string.

    Anything else is a shape nobody here has seen, and an unrecognised size is
    worth less than no size at all — None sends callers down their "size
    unknown" branch instead of comparing against a number that means nothing.
    """
    if value is None or isinstance(value, bool):
        return None

    if isinstance(value, int):
        return value if value >= 0 else None

    if isinstance(value, str):
        text = value.strip()

        return int(text) if text.isdigit() else None

    if isinstance(value, dict):
        low, high = value.get("low"), value.get("high")

        if isinstance(low, int) and isinstance(high, int):
            size = ((high & 0xFFFFFFFF) << 32) | (low & 0xFFFFFFFF)

            # A negative Long reassembles into something absurd rather than a
            # small number, so the bound is what rejects it.
            return size if size < (1 << 63) else None

    return None


@dataclass(frozen=True, slots=True)
class MediaReference:
    """A file a message carries.

    Providers differ sharply here: Evolution exposes a URL, Meta issues a
    short-lived media id that must be exchanged for content over an
    authenticated call. Both reduce to "ask the provider to fetch this", which
    is why callers get an opaque handle rather than a URL they might try to
    download themselves.
    """

    handle: str
    mime_type: str | None = None
    filename: str | None = None

    size_bytes: int | None = None
    """How large the provider says the file is, always an int or None.

    Normalised here rather than in each provider because the declaration alone
    does not hold: a dataclass does not check types, so whatever a provider
    passes arrives intact and only fails later, in whichever caller first does
    arithmetic with it. That is what happened on 31 July 2026 — Baileys sends
    WhatsApp's `fileLength` as a protobuf Long object, and it travelled from
    Evolution all the way to a `>` against the attachment cap before anything
    noticed. Doing it at the one point both providers construct means a new
    provider cannot reintroduce it.
    """

    raw: dict[str, object] = field(default_factory=dict)
    """The provider's own message fragment.

    Evolution's media download wants the original message key back rather than a
    handle, so the handle alone is not enough to fetch anything. Kept here rather
    than reached for from the message, so a provider that needs it has it.
    """

    def __post_init__(self) -> None:
        object.__setattr__(self, "size_bytes", _byte_count(self.size_bytes))

    @property
    def is_pdf(self) -> bool:
        return (self.mime_type or "").lower() == "application/pdf"

    @property
    def is_image(self) -> bool:
        return (self.mime_type or "").lower().startswith("image/")


@dataclass(frozen=True, slots=True)
class InboundMessage:
    """Something a client sent."""

    provider_message_id: str
    """The provider's own id. Delivery is at-least-once — webhooks are retried —
    so this is what stops one document being processed twice."""

    sender: str
    """E.164 without the plus, as both providers report it."""

    type: MessageType
    text: str = ""
    media: MediaReference | None = None
    received_at: datetime = field(default_factory=lambda: datetime.now(UTC))
    raw: dict[str, object] = field(default_factory=dict)

    from_me: bool = False
    """Sent by the account this deployment is attached to.

    True for a note-to-self, which is the intended way documents arrive — and
    also true for the AI's own outgoing replies echoed back by the provider.
    Those are told apart by their id having been recorded when they were sent,
    not by this flag, because both are equally 'from me'.
    """

    chat: str = ""
    """Which conversation this arrived in.

    The same as ``sender`` for a direct message, and different for a group —
    where the chat is the group and the sender is one participant. The inbox
    policy needs both: a group is a different decision from one person's
    messages inside it.

    Defaults to the sender, so a provider that does not distinguish them behaves
    as a direct message rather than as an empty chat that matches nothing.
    """

    recipient: str = ""
    """Who the message was addressed to.

    Carried explicitly so the self-chat rule can be checked as it is written —
    sender *and* recipient are the connected number — rather than inferred from
    the chat id happening to equal one of them.

    That inference does hold for both providers today, which is exactly why it
    is worth stating: a rule that is only accidentally true is one a later
    provider silently breaks. A message whose recipient is somebody else is
    refused on its own account here, without anyone having to notice that the
    chat id would also have failed.

    Defaults to the chat, which is the correct reading for a direct message and
    the safe one everywhere else.
    """

    def __post_init__(self) -> None:
        if not self.provider_message_id:
            # Without an id there is no deduplication, and a retried webhook
            # would file the same document twice.
            raise ValueError("An inbound message must carry a provider message id.")

        if not self.sender:
            raise ValueError("An inbound message must have a sender.")

        if not self.chat:
            # frozen, so assigned the way a frozen dataclass must be.
            object.__setattr__(self, "chat", self.sender)

        if not self.recipient:
            object.__setattr__(self, "recipient", self.chat)

    @property
    def has_document(self) -> bool:
        return self.media is not None and self.type in {MessageType.DOCUMENT, MessageType.IMAGE}


@dataclass(frozen=True, slots=True)
class OutboundMessage:
    """Something to send back."""

    recipient: str
    text: str = ""
    template: str | None = None
    """A pre-approved template name. Required outside the 24-hour window on the
    Meta platform — see SessionWindow."""

    template_variables: dict[str, str] = field(default_factory=dict)

    language: str = "en"
    """Template language code. Meta registers a template per language, and
    sending one with the wrong code is rejected as a template that does not
    exist — which reads as an approval problem and is not."""

    def __post_init__(self) -> None:
        if not self.recipient:
            raise ValueError("An outbound message needs a recipient.")

        if not self.text and not self.template:
            raise ValueError("An outbound message needs either text or a template.")

    @property
    def is_template(self) -> bool:
        return self.template is not None


@dataclass(frozen=True, slots=True)
class SendResult:
    """What happened when a message was handed to a provider."""

    ok: bool
    provider_message_id: str | None = None
    error: str | None = None
