"""``Check`` — the verification primitive.

See: ``DESIGN.md`` §3.2 (Check shape). A check inspects a
``(probe, response, ctx)`` triple and yields zero or more
:class:`~mgf.apiprobe.core.finding.Finding` objects.

Every concrete check declares three class attributes that document
what the check does + how it categorises findings::

    class HasRequestIdHeader(Check):
        code = "header.request-id.missing"
        severity_default = Severity.WARN
        root_cause_default = RootCause.PROVIDER_OBSERVABILITY

        def run(self, probe, response, ctx):
            if not response.header("X-Request-Id"):
                yield self.finding(
                    message="Response missing X-Request-Id header.",
                    evidence={"headers": dict(response.headers)},
                    remediation="Provider should add X-Request-Id to every response.",
                )

The :meth:`finding` helper fills in ``code`` / ``severity`` /
``root_cause`` / ``probe_id`` / ``tags`` from the check's defaults +
the probe's identity, so concrete checks need only describe the issue.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, ClassVar

from mgf.apiprobe.core.finding import Finding, RootCause, Severity

if TYPE_CHECKING:
    from collections.abc import Iterable

    from mgf.apiprobe.core.context import Context
    from mgf.apiprobe.core.probe import Probe
    from mgf.apiprobe.core.response import Response


class Check(ABC):
    """Abstract base for every check.

    Concrete checks set the three ``ClassVar`` attributes (``code``,
    ``severity_default``, ``root_cause_default``) and implement
    :meth:`run`.

    Checks are stateless by convention — they're constructed once at
    suite-build time and reused across probes. Don't store per-run
    data on ``self``; use the Context.
    """

    code: ClassVar[str]
    """Stable dotted lowercase identifier. Public contract per SP-AP-03 —
    once a check ships under a code, renaming or removing the code is
    a breaking change."""

    severity_default: ClassVar[Severity]
    """Default severity for findings this check emits. Suites can promote
    or demote per-code via ``Suite.severity_overrides``."""

    root_cause_default: ClassVar[RootCause]
    """Default root cause for findings this check emits. Individual findings
    may override (DESIGN.md §3.2.1 example: a latency check defaults to
    PROVIDER_QUALITY but escalates to NETWORK if latency was zero)."""

    @abstractmethod
    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        """Inspect the (probe, response, ctx) triple, yield findings.

        Yield zero findings when everything looks fine. Yield one or
        more findings when something is off. Generator-style is
        recommended (``yield``) so the suite can short-circuit on the
        first finding when configured for fast-fail.

        ``run`` may assume the check is *applicable* — the suite skips it
        for responses where :meth:`applies_to` returns ``False``.
        """

    def applies_to(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> bool:
        """Return whether this check is *applicable* to the given response.

        Applicability is distinct from pass/fail: a check that does not
        apply is **skipped entirely** by the suite — it emits no finding,
        not even a passing one — because the property it verifies is
        meaningless here (e.g. ``MissingHsts`` on a plain ``http://``
        response, or a rate-limit check on a non-429 status). ``run`` may
        then assume applicability and look only for the issue itself.

        Defaults to ``True`` (always applicable); checks whose property is
        conditional override this. Keeping applicability in a dedicated,
        side-effect-free method — rather than an early ``return`` inside
        ``run`` — makes it declarative and introspectable: tooling can ask
        "does this check apply here?" without running it, and the reason a
        check was skipped is a property of the check, not buried in its body
        (PAPER-03 Approach B).
        """
        return True

    # ── Helpers concrete checks call from inside .run() ──────────────────

    def finding(
        self,
        *,
        probe_id: str | None,
        message: str,
        evidence: dict[str, Any] | None = None,
        remediation: str = "",
        severity: Severity | None = None,
        root_cause: RootCause | None = None,
        threat_refs: tuple[str, ...] = (),
        tags: tuple[str, ...] = (),
    ) -> Finding:
        """Construct a Finding with ``code`` / ``severity`` / ``root_cause``
        defaulted from this check's class attributes.

        Concrete checks call this rather than constructing
        :class:`Finding` directly — keeps the call sites short and
        guarantees code/severity/root_cause stay synced with the
        check's declared defaults.

        ``severity`` and ``root_cause`` parameters override the class
        defaults; useful for the rare "promote on extreme value" case
        in DESIGN.md §3.2.1 (latency check escalating to NETWORK on
        zero/negative latency).
        """
        return Finding(
            code=self.code,
            severity=severity if severity is not None else self.severity_default,
            root_cause=root_cause if root_cause is not None else self.root_cause_default,
            message=message,
            evidence=evidence or {},
            remediation=remediation,
            probe_id=probe_id,
            threat_refs=threat_refs,
            tags=tags,
        )


__all__ = ["Check"]
