"""Linking a WhatsApp account to this deployment.

WHAT A "SESSION" IS HERE

One deployment serves one CMS installation (ADR-0001), so it manages exactly one
WhatsApp account: the customer's. There is no tenant, no account list, no
routing. If this file ever grows a collection of sessions, multi-tenancy has
crept in.

WHY LINKING IS A SEPARATE PROTOCOL

Not every provider has an account to link. Meta's Cloud API has no QR code and
no Linked Devices flow at all — a Meta connection is a set of credentials and a
verified business, established long before the software runs. Evolution links by
scanning a QR, because it drives a personal WhatsApp client.

Those are different products, not two implementations of one idea. Forcing both
through one interface would mean MetaBusinessProvider raising "not supported"
from three methods it can never implement, and a UI that has to guess which
half of its own buttons work.

So: every provider reports a `SessionStatus`, because "is this thing connected?"
is a fair question to ask any of them. Only a `LinkableProvider` can produce a QR
code, and `supports_linking()` is how the rest of the system asks — a capability,
checked, rather than an exception, caught.

A QR CODE IS A CREDENTIAL

Scanning one links a device to a WhatsApp account: it grants read access to
every conversation in it. It is not a picture, and it must never be treated like
one. So `LinkRequest` masks its own payload in `repr()` for the reason the
Phase 9 audit found the hard way — a dataclass repr is how secrets reach a log
file — and it carries an expiry, because an unscanned QR left lying in a
database is a standing invitation.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from typing import Protocol, runtime_checkable

#: How long a QR code is offered before it is considered stale.
#:
#: 45 seconds, because that is what the provider actually allows. Evolution
#: passes `qrTimeout: 45_000` when it builds the Baileys socket, and Baileys
#: arms a timer for exactly that long before replacing the code (`genPairQR` in
#: Socket/socket.js). After five codes it runs out of pairing refs, ends the
#: socket, and every code issued by it dies at once.
#:
#: This was 90, justified as "an outer bound, not a promise that it still
#: works". That reasoning was wrong in the way that matters: nothing refreshed
#: the code, so the back half of every window displayed a QR WhatsApp had
#: already invalidated. Scanning it gave "Couldn't link device — try again
#: later" on the phone, with no fault reported anywhere on either side.
#:
#: Still an upper bound rather than a guarantee: the code the provider hands
#: back may already have spent part of its 45 seconds before we ever see it.
#: That is why the settings page re-fetches on a shorter interval instead of
#: trusting this number to tell it when to stop showing one.
QR_LIFETIME_SECONDS = 45


class SessionState(StrEnum):
    """What the link between this deployment and WhatsApp is doing."""

    DISCONNECTED = "disconnected"
    """No account is linked. The ordinary state before anyone has connected."""

    AWAITING_SCAN = "awaiting_scan"
    """A QR code has been issued and nobody has scanned it yet."""

    CONNECTED = "connected"
    """Linked and usable."""

    LINK_EXPIRED = "link_expired"
    """Nobody scanned, and the provider stopped offering codes.

    Evolution rotates a QR a fixed number of times (QRCODE_LIMIT, 30 by
    default) and then ends the attempt outright, reporting `refused`. Found by
    reading its source rather than its documentation, and it matters because
    both obvious mappings are wrong:

      UNAVAILABLE would say "the provider is unreachable" while Evolution is
      running perfectly and doing exactly what it was designed to do.

      LOGGED_OUT would say a working connection had dropped, when there was
      never a connection to drop.

    It is neither a fault nor an outage. It is a person who walked away, and the
    only remedy is to start again — so it gets its own state and its own
    message.
    """

    LOGGED_OUT = "logged_out"
    """Was linked, and the phone unlinked it — from WhatsApp's Linked Devices
    screen, or by the account being banned. Distinct from DISCONNECTED because
    somebody needs telling: this deployment was working and has stopped.

    Deliberately never inferred. No provider available today reports it
    truthfully, and guessing would raise an alarm about a connection that was
    never made. Reserved until one can."""

    UNAVAILABLE = "unavailable"
    """The provider itself could not be reached, so the real state is unknown.
    Never reported as disconnected — "we cannot tell" and "it is not connected"
    lead to different actions."""


@dataclass(frozen=True, slots=True)
class SessionStatus:
    """What a provider says about its own connection."""

    state: SessionState
    detail: str = ""

    number: str | None = None
    """The connected WhatsApp number, when the provider reports one. This is the
    customer's own number, shown back to them so they can confirm they linked
    the account they meant to."""

    since: datetime | None = None
    """When this state began, when the provider can say."""

    @property
    def is_usable(self) -> bool:
        return self.state is SessionState.CONNECTED

    @property
    def needs_attention(self) -> bool:
        """Worth telling somebody about.

        Two states are deliberately absent.

        DISCONNECTED: an installation that has never connected is not broken,
        it is new.

        LINK_EXPIRED: a customer was shown thirty codes and scanned none of
        them. Nothing is wrong with the software, the provider or the
        installation — and paging an administrator because somebody got
        distracted is exactly how alerts get ignored.
        """
        return self.state in {SessionState.LOGGED_OUT, SessionState.UNAVAILABLE}


@dataclass(frozen=True, slots=True)
class LinkRequest:
    """A QR code, and how long it is worth showing.

    Frozen and masked. The payload links a device to a WhatsApp account, which
    makes it a credential with a very short life and no business appearing in a
    log, an audit row, or a traceback.
    """

    payload: str = field(repr=False)
    """Base64 image data, or a pairing code, exactly as the provider gave it."""

    issued_at: datetime = field(default_factory=lambda: datetime.now(UTC))
    lifetime_seconds: int = QR_LIFETIME_SECONDS

    rotation: int | None = None
    """Which code this is in the current attempt.

    Evolution counts them and gives up at a limit, so a customer can be shown
    thirty codes and end up connected to nothing. Surfacing the number turns an
    invisible countdown into something they can see running out."""

    rotation_limit: int | None = None
    """How many the provider will offer before abandoning the attempt."""

    @property
    def rotations_left(self) -> int | None:
        """How many more codes there will be, when the provider says enough to tell."""
        if self.rotation is None or self.rotation_limit is None:
            return None

        return max(0, self.rotation_limit - self.rotation)

    @property
    def expires_at(self) -> datetime:
        return self.issued_at + timedelta(seconds=self.lifetime_seconds)

    def is_expired(self, now: datetime | None = None) -> bool:
        return (now or datetime.now(UTC)) >= self.expires_at

    def __repr__(self) -> str:
        # Length only. Enough to tell "a code was issued" from "the provider
        # returned nothing", which is the whole diagnostic need, and not enough
        # to link a device with.
        return f"LinkRequest(payload=<{len(self.payload)} chars>, expires_at={self.expires_at.isoformat()})"

    def __str__(self) -> str:
        return self.__repr__()


@runtime_checkable
class LinkableProvider(Protocol):
    """A provider whose account is linked by scanning a QR code.

    Evolution implements this. Meta cannot: its Cloud API has no QR flow, and a
    connection there is credentials plus business verification rather than a
    device link.
    """

    def start_link(self) -> LinkRequest | SessionStatus:
        """Begin linking, or say why it is unnecessary.

        Returns a `LinkRequest` when there is a code to scan, and a
        `SessionStatus` when there is not — an account already connected is the
        common case, and handing back a QR for it would unlink the working one.
        """
        ...

    def session_status(self) -> SessionStatus: ...

    def unlink(self) -> SessionStatus:
        """Log out, so the phone's Linked Devices no longer lists us."""
        ...


def supports_linking(provider: object) -> bool:
    """Can this provider's account be linked by QR?

    A capability, asked, rather than an exception, caught. The UI needs to know
    before it draws a Connect button, not after somebody presses one.
    """
    return isinstance(provider, LinkableProvider)


def status_of(provider: object) -> SessionStatus:
    """Ask any provider how it is doing.

    Providers that predate this — or that are stubs in a test — simply do not
    have the method, and "we cannot tell" is the honest answer for them rather
    than an AttributeError halfway through rendering a status page.
    """
    reporter = getattr(provider, "session_status", None)

    if not callable(reporter):
        return SessionStatus(
            SessionState.UNAVAILABLE,
            "this provider does not report a connection state",
        )

    try:
        return reporter()
    except Exception as exc:  # noqa: BLE001 - a status check must not raise
        return SessionStatus(SessionState.UNAVAILABLE, f"could not be reached: {exc}")
