"""The WhatsApp Business Platform — the production provider (ADR-0006).

Official, supported, and constrained: a 24-hour session window and templates that
must be approved before they can be sent. Those rules are enforced for *every*
provider by MessageSender, so nothing built against Evolution can work in
development and fail here.

Two security properties matter and both are implemented in full:

**Webhook signatures.** Meta signs each delivery with the app secret. Without
checking it, the endpoint accepts any POST from anyone — and a forged webhook
would put an attacker's document into a reviewer's queue with a client's name
attached.

**The verification handshake.** Meta proves the endpoint belongs to whoever
configured it by echoing a challenge, guarded by a token only they should know.
"""

from __future__ import annotations

import hashlib
import hmac
import logging
from dataclasses import dataclass
from pathlib import Path

from app.support.http import HttpTransport, UrllibTransport
from app.whatsapp.messages import (
    InboundMessage,
    MediaReference,
    MessageType,
    OutboundMessage,
    SendResult,
)

from app.whatsapp.session import SessionState, SessionStatus

logger = logging.getLogger(__name__)

GRAPH = "https://graph.facebook.com"
DEFAULT_VERSION = "v21.0"

_TYPE_MAP = {
    "text": MessageType.TEXT,
    "image": MessageType.IMAGE,
    "document": MessageType.DOCUMENT,
    "audio": MessageType.AUDIO,
    "video": MessageType.VIDEO,
}


@dataclass(frozen=True, slots=True)
class MetaConfig:
    """One WhatsApp Business number."""

    phone_number_id: str
    access_token: str

    app_secret: str = ""
    """Signs inbound webhooks. Without it signature verification cannot happen,
    and an unsigned endpoint accepts a forged document from anyone."""

    verify_token: str = ""
    """Chosen by us, echoed during Meta's endpoint verification handshake."""

    api_version: str = DEFAULT_VERSION

    def __repr__(self) -> str:
        """Masked — see Settings.__repr__. An access token in a log is a
        working credential for anyone who reads it."""
        return (
            f"MetaConfig(phone_number_id={self.phone_number_id!r}, "
            f"access_token={_mask(self.access_token)!r}, "
            f"app_secret={_mask(self.app_secret)!r}, "
            f"api_version={self.api_version!r})"
        )

    @classmethod
    def from_env(cls, env: dict[str, str]) -> MetaConfig | None:
        """Configured, or None. Absent is normal — a deployment on Evolution has
        no Meta credentials and must not be forced to invent them."""
        phone_number_id = env.get("TAXPILOT_META_PHONE_NUMBER_ID", "")
        token = env.get("TAXPILOT_META_ACCESS_TOKEN", "")

        if not phone_number_id or not token:
            return None

        return cls(
            phone_number_id=phone_number_id,
            access_token=token,
            app_secret=env.get("TAXPILOT_META_APP_SECRET", ""),
            verify_token=env.get("TAXPILOT_META_VERIFY_TOKEN", ""),
            api_version=env.get("TAXPILOT_META_API_VERSION", DEFAULT_VERSION),
        )


def _mask(secret: str) -> str:
    """Enough to tell two credentials apart, not enough to use one."""
    if not secret:
        return ""

    return f"…{secret[-4:]}" if len(secret) > 8 else "…"


def verify_signature(app_secret: str, raw_body: bytes, header: str | None) -> bool:
    """Was this webhook really sent by Meta?

    HMAC-SHA256 over the **raw** body with the app secret, sent as
    `X-Hub-Signature-256: sha256=<hex>`. Raw matters: re-serialising the JSON
    first produces different bytes and a signature that never matches.

    Refuses when no secret is configured. An endpoint that skipped the check
    because it had nothing to check with would accept a forged document from
    anyone — and would do so silently, which is worse than refusing.
    """
    if not app_secret or not header:
        return False

    algorithm, _, provided = header.partition("=")

    if algorithm != "sha256" or not provided:
        return False

    expected = hmac.new(app_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()

    # Constant time: a fast comparison leaks how much of a guess was right, one
    # byte at a time.
    return hmac.compare_digest(expected, provided)


def verification_challenge(params: dict[str, str], verify_token: str) -> str | None:
    """Answer Meta's endpoint verification, or refuse.

    Meta GETs the webhook once with a challenge and the token that was
    configured alongside it. Echoing the challenge without checking the token
    would let anybody who guesses the URL register it as their own.
    """
    if params.get("hub.mode") != "subscribe":
        return None

    if not verify_token or not hmac.compare_digest(params.get("hub.verify_token", ""), verify_token):
        return None

    return params.get("hub.challenge") or None


class MetaBusinessProvider:
    """Speaks the WhatsApp Business Cloud API."""

    name = "meta"

    def __init__(self, config: MetaConfig, transport: HttpTransport | None = None) -> None:
        self._config = config
        self._http = transport or UrllibTransport()

    @property
    def config(self) -> MetaConfig:
        return self._config

    # ── Connection state ──────────────────────────────────────────────────
    #
    # Deliberately NOT a LinkableProvider. Meta's Cloud API has no QR code and
    # no Linked Devices flow: a connection here is a verified business, a
    # registered number and a set of credentials, all established long before
    # this process runs. There is nothing for a customer to scan, and offering
    # them a Connect button would be offering something that cannot exist.
    #
    # It can still answer "are you connected?", because a settings screen has to
    # say something true about a Meta deployment too.

    def session_status(self) -> SessionStatus:
        """Configured means connected.

        There is no session to lose. The API is stateless per request, so the
        only honest question is whether this deployment holds credentials for a
        number — and it does, or it would not have been constructed.
        """
        return SessionStatus(
            state=SessionState.CONNECTED,
            detail="connected through the WhatsApp Business API",
            # The phone number ID, not the number itself: Meta identifies the
            # sender by an opaque id, and inventing a phone number from it would
            # be a guess shown to a customer as a fact.
            number=self._config.phone_number_id or None,
        )

    # ── Sending ───────────────────────────────────────────────────────────

    def send(self, message: OutboundMessage) -> SendResult:
        """Send text or a template.

        The window and template rules are checked by MessageSender before this
        is reached, so nothing here re-implements them — one place enforcing a
        rule is the only way it stays enforced.
        """
        payload: dict = {"messaging_product": "whatsapp", "to": message.recipient}

        if message.is_template:
            payload["type"] = "template"
            payload["template"] = {
                "name": message.template,
                "language": {"code": message.language or "en"},
            }
        else:
            payload["type"] = "text"
            payload["text"] = {"body": message.text}

        response = self._http.request(
            "POST",
            f"{GRAPH}/{self._config.api_version}/{self._config.phone_number_id}/messages",
            headers=self._auth(),
            json=payload,
        )

        if not response.ok:
            reason = _error_of(response)
            logger.error("WhatsApp send failed (%s): %s", response.status, reason)

            return SendResult(ok=False, error=reason)

        body = response.json()
        sent = (body.get("messages") or [{}])[0]

        return SendResult(ok=True, provider_message_id=sent.get("id"))

    # ── Receiving ─────────────────────────────────────────────────────────

    def parse_webhook(self, payload: dict) -> list[InboundMessage]:
        """Read Meta's webhook shape.

        Deeply nested — entry[] → changes[] → value.messages[] — and tolerant at
        every level, because a webhook that raises gets retried forever and a
        payload that cannot be parsed will not parse on the retry either.

        Status updates (delivered, read) arrive through the same endpoint with no
        `messages` key at all. They are not errors; they are simply not messages.
        """
        parsed: list[InboundMessage] = []

        for entry in payload.get("entry") or []:
            if not isinstance(entry, dict):
                continue

            for change in entry.get("changes") or []:
                if not isinstance(change, dict):
                    continue

                value = change.get("value") or {}

                # The business number the message arrived at. Meta puts it on the
                # change's metadata rather than on each message.
                metadata = value.get("metadata") or {}
                business_number = str(metadata.get("display_phone_number") or "")

                for raw in value.get("messages") or []:
                    if not isinstance(raw, dict):
                        continue

                    message = self._message_from(raw, business_number)

                    if message is not None:
                        parsed.append(message)

        return parsed

    def _message_from(self, raw: dict, business_number: str = "") -> InboundMessage | None:
        message_id = raw.get("id")
        sender = raw.get("from")

        if not message_id or not sender:
            return None

        kind = str(raw.get("type", ""))
        message_type = _TYPE_MAP.get(kind, MessageType.UNSUPPORTED)
        part = raw.get(kind) if isinstance(raw.get(kind), dict) else {}

        return InboundMessage(
            provider_message_id=str(message_id),
            sender=str(sender),
            # Meta reports a direct message only; the conversation is the sender.
            chat=str(sender),
            # Always somebody writing *to* the business number. The Cloud API has
            # no self-chat — it never delivers the business's own messages back
            # — so under the self-chat policy a Meta deployment accepts nothing,
            # by design rather than by accident. See docs/whatsapp.md.
            recipient=business_number or str(sender),
            type=message_type,
            text=self._text_of(raw, kind, part),
            media=self._media_of(kind, part),
            raw=raw,
        )

    @staticmethod
    def _text_of(raw: dict, kind: str, part: dict) -> str:
        if kind == "text":
            return str((raw.get("text") or {}).get("body", ""))

        # A caption on an image or document is the sender saying what it is.
        return str(part.get("caption", ""))

    @staticmethod
    def _media_of(kind: str, part: dict) -> MediaReference | None:
        if kind not in {"image", "document", "audio", "video"} or not part.get("id"):
            return None

        return MediaReference(
            # Meta gives a media id, not a URL. Resolving it to a link is a
            # second, authenticated call — see download_media.
            handle=str(part["id"]),
            mime_type=part.get("mime_type"),
            filename=part.get("filename"),
            size_bytes=part.get("file_size"),
        )

    # ── Media ─────────────────────────────────────────────────────────────

    def download_media(self, media: MediaReference, destination: Path) -> Path:
        """Fetch a document, in the two steps Meta requires.

        The id resolves to a short-lived URL, and that URL still needs the access
        token — it is not public. Both calls are authenticated, and the file is
        written where the caller says, which for Level 3 content is a private
        directory rather than a shared temp.
        """
        lookup = self._http.request(
            "GET", f"{GRAPH}/{self._config.api_version}/{media.handle}", headers=self._auth()
        )

        if not lookup.ok:
            raise MediaDownloadError(f"Could not resolve media {media.handle}: {_error_of(lookup)}")

        url = lookup.json().get("url")

        if not url:
            raise MediaDownloadError(f"Meta returned no URL for media {media.handle}.")

        # The Authorization header is required here too. Without it this is a
        # 401, which reads confusingly as "the media expired".
        download = self._http.request("GET", url, headers=self._auth())

        if not download.ok:
            raise MediaDownloadError(f"Could not download media {media.handle}.")

        destination.parent.mkdir(parents=True, exist_ok=True)
        destination.write_bytes(download.body)

        return destination

    # ── Internals ─────────────────────────────────────────────────────────

    def _auth(self) -> dict[str, str]:
        return {"Authorization": f"Bearer {self._config.access_token}"}


class MediaDownloadError(RuntimeError):
    """A document could not be fetched."""


def _error_of(response) -> str:
    """Meta's error message, without letting a parse failure hide the status."""
    try:
        return str((response.json().get("error") or {}).get("message", "unknown error"))
    except Exception:  # noqa: BLE001
        return f"HTTP {response.status}"
