"""Which CMS builds this release will work with.

WHAT IS NEGOTIATED, AND WHAT IS NOT

Not the CMS's application version. The two products ship on separate cadences to
separate infrastructure, so pinning against it would mean every CMS patch
release — a dashboard colour, a PDF margin — is a version this side has to be
told about. Somebody would maintain a table mapping one product's releases onto
the other's, and it would be wrong within a month.

What is negotiated is the *contract*: the shape of the Agent API, versioned
separately and changed only when the shape changes.

    MAJOR   a breaking change — an endpoint removed or renamed, a field whose
            meaning changed, a change to signing. Refuse.
    MINOR   an additive change — a new endpoint, a new optional field. Older
            agents keep working; a newer agent may require a floor.

So a release requiring 1.2 works against 1.2 and 1.3, and refuses 1.1 and 2.0.

WHY A MISSING CONTRACT MEANS 1.0 RATHER THAN A REFUSAL

A CMS built before the contract was declared reports nothing. Its API is exactly
contract 1.0 — nothing had changed yet — so treating silence as 1.0 is not a
guess, it is what silence means.

Refusing instead would mean this platform could not talk to any CMS currently
deployed, including the one in production, until that CMS was updated. A
compatibility check that causes the outage it exists to prevent is worse than no
check.

And the rule corrects itself later: when a future release requires 2.0, an
assumed 1.0 fails the ordinary comparison. Silence stops being acceptable at
exactly the point where it stops being true, with no special case to remember.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from enum import StrEnum

#: The oldest contract this release can work against.
#:
#: Raise this when the code starts depending on something a CMS only gained in a
#: later MINOR — never as a precaution. A floor above what is actually needed
#: locks out working installations for nothing.
#:
#: 1.1 SINCE ADR-0010
#:
#: This release registers every arriving document with the CMS before doing
#: anything else with it, and those endpoints arrived in contract 1.1. On a 1.0
#: CMS they are not there at all.
#:
#: The floor stayed at 1.0 through all of that work, which left the gate unable
#: to catch the one mismatch it exists for. The result was worse than a refusal:
#: the handshake passed, every registration answered 404, the outbox treated a
#: permanent error as a retryable one and kept the document, and once it reached
#: capacity the agent began turning new documents away. A firm's WhatsApp intake
#: would have gone quiet while the health page reported a compatible CMS.
#:
#: 1.2 SINCE ARRIVALS WERE ANNOUNCED UP FRONT
#:
#: This release pushes `queued` for every document waiting its turn, and that
#: status entered the CMS's vocabulary in contract 1.2. Against a 1.1 CMS the
#: push is refused with a validation error — best-effort, so no document is
#: harmed, but the behaviour this release exists to provide (every arrival
#: visible at once) would silently not happen. A floor that lets a release
#: start while its headline feature no-ops is not a floor.
REQUIRED_CONTRACT = "1.2"

#: Contracts with a different MAJOR are refused outright, in both directions.
#:
#: Newer is refused because a breaking change is breaking: this release does not
#: know what changed, and "probably fine" is not a basis on which to file
#: somebody's tax documents. Older is refused because the endpoints this depends
#: on may simply not be there.
SUPPORTED_MAJOR = 1

#: Reported to the CMS and recorded in the release manifest. Diagnostics only.
MINIMUM_CMS_VERSION = "1.1.0"

#: Assumed when the CMS says nothing. See the module docstring.
ASSUMED_CONTRACT = "1.0"

CONTRACT_PATTERN = re.compile(r"^(\d+)\.(\d+)$")


class Level(StrEnum):
    OK = "ok"
    DEGRADED = "degraded"
    """Usable, but something is worth an operator's attention."""

    INCOMPATIBLE = "incompatible"
    """Refuse to work. Not a warning."""


@dataclass(frozen=True, slots=True)
class Verdict:
    level: Level
    detail: str
    cms_version: str | None = None
    contract: str | None = None

    @property
    def is_usable(self) -> bool:
        return self.level is not Level.INCOMPATIBLE

    def __str__(self) -> str:  # pragma: no cover - diagnostics only
        return f"{self.level.value}: {self.detail}"


def evaluate(identity: dict | None) -> Verdict:
    """Decide whether this release can work with the CMS that answered whoami.

    Takes the whole whoami payload rather than a version string, because what
    the CMS reports is what has to be interpreted — including reporting nothing,
    which is a case with its own meaning.
    """
    if not isinstance(identity, dict):
        return Verdict(
            Level.INCOMPATIBLE,
            "The CMS did not describe itself. Its response was not usable.",
        )

    described = identity.get("cms")
    described = described if isinstance(described, dict) else {}

    cms_version = _text(described.get("version"))
    raw_contract = _text(described.get("api_contract"))
    minimum_agent = _text(described.get("minimum_agent_version"))

    if raw_contract is None:
        contract = ASSUMED_CONTRACT
        assumed = True
    else:
        contract = raw_contract
        assumed = False

    parsed = _parse(contract)

    if parsed is None:
        return Verdict(
            Level.INCOMPATIBLE,
            f"The CMS reported an unreadable API contract: {contract!r}.",
            cms_version=cms_version,
            contract=contract,
        )

    major, minor = parsed
    required_major, required_minor = _parse(REQUIRED_CONTRACT)  # type: ignore[misc]

    if major != SUPPORTED_MAJOR:
        direction = "newer than" if major > SUPPORTED_MAJOR else "older than"

        return Verdict(
            Level.INCOMPATIBLE,
            f"The CMS speaks Agent API contract {contract}, which is {direction} "
            f"the {SUPPORTED_MAJOR}.x this release understands. "
            + (
                "Update TaxPilot AI."
                if major > SUPPORTED_MAJOR
                else "Update the CMS, or install an older TaxPilot AI."
            ),
            cms_version=cms_version,
            contract=contract,
        )

    if minor < required_minor:
        return Verdict(
            Level.INCOMPATIBLE,
            f"This release needs Agent API contract {REQUIRED_CONTRACT} or later; "
            f"the CMS speaks {contract}. Update the CMS.",
            cms_version=cms_version,
            contract=contract,
        )

    if assumed:
        # Compatible, and worth saying out loud: an operator seeing this knows
        # the CMS predates the handshake rather than that everything was
        # positively confirmed.
        #
        # UNREACHABLE WHILE THE FLOOR IS ABOVE THE ASSUMPTION
        #
        # Silence is assumed to be 1.0 and the floor is 1.1, so the minor check
        # above refuses before this can run. Kept rather than deleted: the two
        # constants are independent, this becomes live again the moment a
        # release requires only what silence implies, and the branch is the
        # right answer when it does. Deleting it would mean rediscovering that
        # a CMS which says nothing is a different case from one that says
        # something wrong.
        return Verdict(
            Level.DEGRADED,
            f"The CMS did not state an API contract; assumed {ASSUMED_CONTRACT}. "
            "It predates the version handshake.",
            cms_version=cms_version,
            contract=contract,
        )

    return Verdict(
        Level.OK,
        f"CMS {cms_version or 'of unstated version'} speaks contract {contract}.",
        cms_version=cms_version,
        contract=contract,
    )


def agent_is_new_enough(minimum: str | None, running: str) -> bool:
    """Is this release at or above the floor the CMS declares?

    The CMS declares this and does not enforce it — enforcement would put
    another moving part on the authentication path, which every request goes
    through and which is the last place for logic that can wrongly lock out a
    working installation. So the check happens here, on the honest assumption
    that an agent holding a key somebody issued it is a deployment, not an
    attacker.
    """
    if not minimum:
        return True

    from app.release.version import at_least, is_valid

    if not is_valid(minimum) or not is_valid(running):
        # An unreadable floor is not a reason to refuse to start. It is a reason
        # to carry on and let the real checks — permissions, endpoints — speak.
        return True

    return at_least(running, minimum)


# ── Internals ─────────────────────────────────────────────────────────────


def _parse(contract: str) -> tuple[int, int] | None:
    match = CONTRACT_PATTERN.match(contract.strip())

    return (int(match.group(1)), int(match.group(2))) if match else None


def _text(value: object) -> str | None:
    if value is None:
        return None

    text = str(value).strip()

    return text or None
