"""Proving the deployment works before it starts pretending to.

A service that starts successfully and then fails every request is far harder to
diagnose than one that refuses to start and says why. These checks run once, at
boot, and each returns something a person can act on.

The distinction that matters is between fatal and degraded. Missing credentials
are fatal — nothing this process does is possible without them. Missing OCR is
not: the platform still receives documents and still puts them in front of a
human, unread. That is a bad day, not an outage, and refusing to start would turn
it into one.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from enum import StrEnum

logger = logging.getLogger(__name__)


class Outcome(StrEnum):
    OK = "ok"
    DEGRADED = "degraded"
    FATAL = "fatal"


@dataclass(frozen=True, slots=True)
class Check:
    name: str
    outcome: Outcome
    detail: str

    @property
    def is_fatal(self) -> bool:
        return self.outcome is Outcome.FATAL


def check_cms(cms) -> Check:
    """Can this deployment reach its CMS, and does the CMS know who it is?

    `whoami` needs no permission by design, precisely so it can answer this. A
    signature failure here is a misconfigured secret, and finding that at boot
    beats finding it when the first client's document arrives.
    """
    try:
        # The handshake, not a bare whoami: one call answers both "can we reach
        # it?" and "can we work with it?", and it leaves the verdict on the
        # client where the compatibility gate reads it.
        cms.handshake()
    except Exception as exc:  # noqa: BLE001 - the reason is the payload
        return Check("cms", Outcome.FATAL, f"Cannot reach the CMS or authenticate: {exc}")

    identity = cms.identity or {}
    name = identity.get("name", "unnamed")
    granted = set(identity.get("permissions") or [])

    # The grants this platform actually uses. Missing `proposals.submit` means
    # every document it reads has nowhere to go — the process would run, read,
    # and silently discard, which is the worst of the available failures.
    required = {"clients.read", "proposals.submit"}
    missing = required - granted

    if missing:
        return Check(
            "cms",
            Outcome.FATAL,
            f"Agent '{name}' is missing {', '.join(sorted(missing))}. "
            "Grant them in the CMS and restart.",
        )

    return Check("cms", Outcome.OK, f"Authenticated as '{name}'.")


def check_compatibility(cms, running_version: str | None = None) -> Check:
    """Will this release work with the CMS it just spoke to?

    FATAL, not degraded. An incompatible contract means requests this release
    knows how to make no longer mean what it thinks they mean — and the requests
    in question file documents against a tax firm's client records. Starting
    anyway and finding out one document at a time is the worse failure by a wide
    margin.

    Depends on check_cms having run first, which is what performs the handshake.
    Nothing enforces that ordering beyond the list they are called in; a verdict
    of None is reported as unchecked rather than assumed good.
    """
    from app.api.compatibility import Level, agent_is_new_enough
    from app.release.version import VERSION

    verdict = cms.compatibility

    if verdict is None:
        return Check("compatibility", Outcome.DEGRADED, "The CMS was not asked what it speaks.")

    if verdict.level is Level.INCOMPATIBLE:
        return Check("compatibility", Outcome.FATAL, verdict.detail)

    identity = cms.identity or {}
    described = identity.get("cms") if isinstance(identity.get("cms"), dict) else {}
    floor = described.get("minimum_agent_version")
    running = running_version or VERSION

    if not agent_is_new_enough(floor, running):
        return Check(
            "compatibility",
            Outcome.FATAL,
            f"The CMS requires TaxPilot AI {floor} or later; this is {running}.",
        )

    outcome = Outcome.DEGRADED if verdict.level is Level.DEGRADED else Outcome.OK

    return Check("compatibility", outcome, verdict.detail)


def check_database(connect) -> Check:
    """Is the database reachable?

    Without it, runs are held in memory and a restart loses every workflow
    mid-flight — including proposals already sitting in somebody's queue. Fatal
    rather than degraded: the silent version of that failure is a reviewer
    approving a document whose workflow no longer exists.
    """
    if connect is None:
        return Check(
            "database",
            Outcome.FATAL,
            "No database configured. Set TAXPILOT_DATABASE_URL.",
        )

    try:
        with connect() as connection, connection.cursor() as cursor:
            cursor.execute("SELECT 1")
            cursor.fetchone()
    except Exception as exc:  # noqa: BLE001
        return Check("database", Outcome.FATAL, f"Cannot reach PostgreSQL: {exc}")

    return Check("database", Outcome.OK, "PostgreSQL is reachable.")


def check_ocr(engine) -> Check:
    """Can this deployment read a document?

    Degraded, never fatal. Without OCR every document still reaches a reviewer —
    unread, and marked high risk because nothing could be read. Refusing to start
    would turn a degraded service into no service, and the documents would still
    arrive.
    """
    name = getattr(engine, "name", "unknown")

    if name == "null":
        return Check(
            "ocr",
            Outcome.DEGRADED,
            "No OCR engine. Every document will reach a reviewer unread.",
        )

    return Check("ocr", Outcome.OK, f"OCR engine '{name}' is loaded.")


def check_document_types(cms) -> Check:
    """Does this installation have every type this agent files under?

    The registry decides what a document *is*; the CMS decides what may be
    stored. The two are separately deployed, so they can disagree — and the
    disagreement is invisible until a real document classifies as the missing
    type, at which point the filing is refused and somebody is looking at a
    proposal nobody can approve.

    Degraded rather than fatal. A missing type breaks the documents that
    classify as it and nothing else; refusing to start would take intake down
    for every type that *is* present. The names are logged, because "which
    ones?" is the only question an operator will have.

    Directional on purpose: types the CMS offers and this agent never produces
    are fine and go unmentioned. A person can file anything by hand that this
    does not recognise, and that is not a fault.
    """
    from app.documents import registry

    try:
        offered = {entry.get("value") for entry in cms.document_types()}
    except Exception as exc:  # noqa: BLE001 - the reason is the payload
        # check_cms has already settled whether the CMS is reachable at all, so
        # a failure here is about this endpoint rather than the connection.
        return Check(
            "document_types",
            Outcome.DEGRADED,
            f"Could not read the CMS document types, so they are unverified: {exc}",
        )

    missing = sorted(set(registry.filing_slugs()) - offered)

    if missing:
        return Check(
            "document_types",
            Outcome.DEGRADED,
            "This CMS does not offer "
            + ", ".join(missing)
            + ". Documents classified as those cannot be filed until it is upgraded.",
        )

    return Check(
        "document_types",
        Outcome.OK,
        f"All {len(registry.filing_slugs())} filing types are available.",
    )


def check_model(container) -> Check:
    """Is a classification model configured, and is it somewhere it may be used?

    Three outcomes, and the middle one is why this check exists. Not configured
    is the ordinary state and is reported as OK — the stage is optional and most
    deployments will never have one.

    Configured but refused is the case worth catching at boot: somebody pointed
    `TAXPILOT_MODEL_URL` at a public endpoint, the model package declined it,
    and without this the deployment would run for weeks believing it had a model
    stage that has never once fired.

    Degraded, never fatal. Everything except the residue still classifies.
    """
    configured = bool(getattr(container, "_env", {}).get("TAXPILOT_MODEL_URL"))

    try:
        model = container.model
    except Exception as exc:  # noqa: BLE001 - the reason is the payload
        return Check("model", Outcome.DEGRADED, f"Could not build the model client: {exc}")

    if model is not None:
        return Check("model", Outcome.OK, f"Local model classification is on ({model.url}).")

    if configured:
        return Check(
            "model",
            Outcome.DEGRADED,
            "A model URL is configured but was refused — it is not a local endpoint, "
            "or no model name was set. Document text may not leave this installation "
            "(ADR-0009), so the model stage is off.",
        )

    return Check("model", Outcome.OK, "No classification model configured; the stage is off.")


def run(checks: list[Check]) -> bool:
    """Log every result and say whether the process may continue.

    All of them run, even after one fails. Reporting the first problem and
    stopping means an operator fixes it, restarts, and discovers the second —
    which for a deployment on somebody else's infrastructure is several round
    trips that one log could have saved.
    """
    for check in checks:
        message = f"{check.name}: {check.detail}"

        if check.outcome is Outcome.OK:
            logger.info(message)
        elif check.outcome is Outcome.DEGRADED:
            logger.warning(message)
        else:
            logger.error(message)

    return not any(check.is_fatal for check in checks)
