"""Tracker-side validation of the capability report a Node presents at registration.

A Node proves locally that it can execute one exact combination — model artifact,
shard range, recipe, backend/device — and ships that proof with its registration
(ADR-0023, NCA-001/002/003). The tracker does not re-run the forward; it decides
whether the presented proof *covers what the node is advertising*, records the
verdict as a small sanitized enum, and routes only to nodes whose verdict is
`admitted`.

Two properties this module deliberately keeps:

* **No model knowledge.** Model ids, recipe ids, backend ids and device names are
  opaque labels. They are compared, never interpreted; no vendor string is a
  code path here.
* **Evidence, not assertion.** A report is treated as a claim about identity, and
  the tracker only ever *narrows* what a node may serve with it. Nothing in a
  report can widen a node's eligibility or its routing weight — throughput
  routing stays measurement-driven (ADR-0013/0021).

Older nodes that predate the capability protocol present no report at all. They
are handled by an explicit policy (`POLICY_COMPAT` vs `POLICY_ENFORCE`), never by
silently treating "no proof" as "proven" — see `docs/adr/0023-…` for the rollout.
"""

from __future__ import annotations

import os
import re
import time
from dataclasses import dataclass, replace
from typing import Any, Callable, Mapping

from .recipe import (
    CertificationLedger,
    FingerprintMismatch,
    RecipeIdentityError,
    parse_identity,
)

# The capability report layout this tracker reads (meshnet_node.capability).
SUPPORTED_SCHEMA_VERSION = 1

# The oldest recipe catalogue whose recipe semantics this tracker still trusts.
# A node carrying an older catalogue may be running a recipe whose id has since
# been redefined, so its proof cannot be matched to a name reliably.
MIN_CATALOGUE_VERSION = "2026.07.1"

# How old a proof may be *at the moment it is presented*. Freshness after that is
# carried by liveness: a registration is re-asserted on tracker restart and the
# node is purged once heartbeats stop.
DEFAULT_MAX_REPORT_AGE_SECONDS = 900.0

# A proof timestamped further ahead than this is not fresh, it is wrong.
MAX_CLOCK_SKEW_SECONDS = 60.0

STATUS_PASSED = "passed"

# --- Admission verdicts. `admitted` is the only routable one under `enforce`. ---
STATE_ADMITTED = "admitted"
STATE_ABSENT = "absent"
STATE_INVALID = "invalid"
STATE_FAILED = "failed"
STATE_STALE = "stale"
STATE_MODEL_MISMATCH = "model-mismatch"
STATE_SHARD_MISMATCH = "shard-mismatch"
STATE_RECIPE_MISMATCH = "recipe-mismatch"
STATE_CATALOGUE_INCOMPATIBLE = "catalogue-incompatible"
# The node presented a DGR-003 identity block whose declared fingerprint is not
# the digest of the axes it declared. Identity is derived, never asserted.
STATE_FINGERPRINT_MISMATCH = "fingerprint-mismatch"
# Registered-but-dark: a known recipe no real distributed forward has certified.
# Visible to an operator, never routable for user traffic.
STATE_UNCERTIFIED = "uncertified"

ALL_STATES = (
    STATE_ADMITTED,
    STATE_ABSENT,
    STATE_INVALID,
    STATE_FAILED,
    STATE_STALE,
    STATE_MODEL_MISMATCH,
    STATE_SHARD_MISMATCH,
    STATE_RECIPE_MISMATCH,
    STATE_CATALOGUE_INCOMPATIBLE,
    STATE_FINGERPRINT_MISMATCH,
    STATE_UNCERTIFIED,
)

# --- Compatibility policy for nodes that predate the capability protocol. ---
# `compat`  — a node presenting *no* proof still routes (legacy behaviour), but a
#             node presenting a *bad* proof never does. Presenting a broken or
#             mismatched proof is a stronger signal than presenting none.
# `enforce` — only `admitted` routes. Absent proof is not routable.
POLICY_COMPAT = "compat"
POLICY_ENFORCE = "enforce"
ALL_POLICIES = (POLICY_COMPAT, POLICY_ENFORCE)
DEFAULT_POLICY = POLICY_COMPAT

POLICY_ENV_VAR = "MESHNET_TRACKER_CAPABILITY_POLICY"

# Operator-facing detail strings are short and never carry a raw exception.
_MAX_DETAIL_CHARS = 240
_MAX_DIAGNOSTICS = 3

_CREDENTIAL_PATTERNS = (
    re.compile(r"\b[A-Za-z0-9_]{2,6}_[A-Za-z0-9]{16,}\b"),  # hf_…, ghp_…, sk_live_…
    re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"),
    re.compile(r"(?i)\bbearer\s+\S+"),
    re.compile(r"(?i)\b(?:token|api[_-]?key|password|secret)\s*[=:]\s*\S+"),
)
_REDACTED = "[redacted]"


def normalize_policy(value: Any) -> str:
    """Return a known policy name, falling back to the default for anything else."""
    if isinstance(value, str) and value.strip().lower() in ALL_POLICIES:
        return value.strip().lower()
    return DEFAULT_POLICY


def policy_from_env(environ: Mapping[str, str] | None = None) -> str:
    env = os.environ if environ is None else environ
    return normalize_policy(env.get(POLICY_ENV_VAR))


def sanitize_detail(text: Any) -> str:
    """Collapse, redact and clip a string bound for an operator view."""
    cleaned = " ".join(str(text).split())
    for pattern in _CREDENTIAL_PATTERNS:
        cleaned = pattern.sub(_REDACTED, cleaned)
    if len(cleaned) > _MAX_DETAIL_CHARS:
        cleaned = cleaned[: _MAX_DETAIL_CHARS - 1].rstrip() + "…"
    return cleaned


def catalogue_is_compatible(version: Any) -> bool:
    """True when `version` is at least `MIN_CATALOGUE_VERSION`.

    Versions are dotted integer sequences (`2026.07.1`). Anything that does not
    parse is incompatible — an unparseable catalogue version cannot be shown to
    be new enough.
    """
    parsed = _parse_version(version)
    if parsed is None:
        return False
    return parsed >= _parse_version(MIN_CATALOGUE_VERSION)  # type: ignore[operator]


def _parse_version(value: Any) -> tuple[int, ...] | None:
    if not isinstance(value, str) or not value.strip():
        return None
    parts = value.strip().split(".")
    try:
        return tuple(int(part) for part in parts)
    except ValueError:
        return None


@dataclass(frozen=True)
class CapabilityState:
    """The tracker's sanitized verdict on one node's presented proof.

    This is what the network map exposes and what route selection consults. It
    holds identity labels and a verdict — never a raw exception, a file path, or
    a credential.
    """

    state: str
    detail: str = ""
    model_id: str | None = None
    shard_start: int | None = None
    shard_end: int | None = None
    recipe_id: str | None = None
    recipe_version: str | None = None
    catalogue_version: str | None = None
    backend_id: str | None = None
    device: str | None = None
    quantization: str | None = None
    validated_at: float | None = None
    recorded_at: float = 0.0
    schema_version: int | None = None
    diagnostics: tuple[str, ...] = ()
    # The DGR-003 compatibility fingerprint, *re-derived* by this tracker from
    # the axes the node declared — never copied from what the node claimed.
    # Absent for a node that predates DGR-003.
    model_artifact_digest: str | None = None
    runtime_recipe_digest: str | None = None
    shard_binding_digest: str | None = None
    # The tracker ledger's verdict on that fingerprint at evaluation time
    # ("dark"/"certified"), so the network map answers "why is this exact node
    # not routing" without a second query. None when no identity was presented.
    certification: str | None = None

    @property
    def proven(self) -> bool:
        """The presented proof covers exactly what the node advertised."""
        return self.state == STATE_ADMITTED

    @property
    def fingerprint(self) -> tuple[str, str] | None:
        """What route formation compares. `None` when the node declares no identity."""
        if self.model_artifact_digest is None or self.runtime_recipe_digest is None:
            return None
        return (self.model_artifact_digest, self.runtime_recipe_digest)

    def routable_under(self, policy: str) -> bool:
        if self.proven:
            return True
        return self.state == STATE_ABSENT and normalize_policy(policy) == POLICY_COMPAT

    def with_state(self, state: str, detail: str) -> CapabilityState:
        """Re-verdict a recorded proof against what the node advertises *now*."""
        return replace(self, state=state, detail=sanitize_detail(detail))

    def to_dict(self) -> dict:
        return {
            "state": self.state,
            "detail": self.detail,
            "model_id": self.model_id,
            "shard_start": self.shard_start,
            "shard_end": self.shard_end,
            "recipe_id": self.recipe_id,
            "recipe_version": self.recipe_version,
            "catalogue_version": self.catalogue_version,
            "backend_id": self.backend_id,
            "device": self.device,
            "quantization": self.quantization,
            "validated_at": self.validated_at,
            "recorded_at": self.recorded_at,
            "schema_version": self.schema_version,
            "diagnostics": list(self.diagnostics),
            "model_artifact_digest": self.model_artifact_digest,
            "runtime_recipe_digest": self.runtime_recipe_digest,
            "shard_binding_digest": self.shard_binding_digest,
            "certification": self.certification,
        }


def absent_state(detail: str = "", *, now: float | None = None) -> CapabilityState:
    """The verdict for a node that presented no proof at all (legacy node)."""
    return CapabilityState(
        state=STATE_ABSENT,
        detail=sanitize_detail(
            detail
            or "node registered without a capability report; it predates the "
            "capability protocol or ran with admission disabled"
        ),
        recorded_at=time.time() if now is None else now,
    )


def evaluate_report(
    report: Any,
    *,
    model_matches: Callable[[str], bool],
    advertised_model: str | None,
    shard_start: int | None,
    shard_end: int | None,
    declared_recipe_id: str | None = None,
    declared_recipe_version: str | None = None,
    now: float | None = None,
    max_age_seconds: float = DEFAULT_MAX_REPORT_AGE_SECONDS,
    ledger: CertificationLedger | None = None,
) -> CapabilityState:
    """Judge the proof a node presented against what that node is advertising.

    `model_matches` is the tracker's own alias-aware comparison against the
    node's registered model / hf_repo, so an opaque model id never has to be
    parsed here.

    Returns a verdict for *every* input, including malformed ones: a bad proof is
    recorded and shown to the operator rather than dropped, so "why is my node not
    routing" has an answer in the network map.
    """
    now = time.time() if now is None else now

    if report is None:
        return absent_state(now=now)

    if not isinstance(report, Mapping):
        return CapabilityState(
            state=STATE_INVALID,
            detail=sanitize_detail(
                f"capability_report must be a JSON object, got "
                f"{type(report).__name__}"
            ),
            recorded_at=now,
        )

    try:
        parsed = _parse_report(report)
    except _ReportError as exc:
        return CapabilityState(
            state=STATE_INVALID,
            detail=sanitize_detail(str(exc)),
            recorded_at=now,
            schema_version=_maybe_int(report.get("schema_version")),
        )

    status = parsed.pop("_status")
    base = CapabilityState(state=STATE_ADMITTED, recorded_at=now, **parsed)

    if base.schema_version != SUPPORTED_SCHEMA_VERSION:
        return base.with_state(
            STATE_INVALID,
            f"capability report declares schema version {base.schema_version}; "
            f"this tracker reads version {SUPPORTED_SCHEMA_VERSION}",
        )

    if not catalogue_is_compatible(base.catalogue_version):
        return base.with_state(
            STATE_CATALOGUE_INCOMPATIBLE,
            f"recipe catalogue {base.catalogue_version!r} is older than the "
            f"minimum this tracker trusts ({MIN_CATALOGUE_VERSION}); upgrade the node",
        )

    if not model_matches(base.model_id or ""):
        return base.with_state(
            STATE_MODEL_MISMATCH,
            f"proof is for model {base.model_id!r}, but the node registered "
            f"{advertised_model!r}",
        )

    if shard_start is not None and shard_end is not None:
        if (base.shard_start, base.shard_end) != (shard_start, shard_end):
            return base.with_state(
                STATE_SHARD_MISMATCH,
                f"proof is for layers {base.shard_start}–{base.shard_end}, but the "
                f"node registered layers {shard_start}–{shard_end}",
            )

    if declared_recipe_id is not None and base.recipe_id != declared_recipe_id:
        return base.with_state(
            STATE_RECIPE_MISMATCH,
            f"proof is for recipe {base.recipe_id!r}, but the node declared it "
            f"serves with {declared_recipe_id!r}",
        )
    if (
        declared_recipe_version is not None
        and base.recipe_version != declared_recipe_version
    ):
        return base.with_state(
            STATE_RECIPE_MISMATCH,
            f"proof is for recipe {base.recipe_id!r} v{base.recipe_version}, but "
            f"the node declared v{declared_recipe_version}",
        )

    identity = None
    if report.get("identity") is not None:
        try:
            identity = parse_identity(report["identity"])
        except FingerprintMismatch as exc:
            return base.with_state(STATE_FINGERPRINT_MISMATCH, str(exc))
        except RecipeIdentityError as exc:
            return base.with_state(
                STATE_INVALID, f"capability identity block is unusable: {exc}"
            )

        # The report's `shard` range is inclusive/inclusive (the CLI and backend
        # convention); the identity's is inclusive/exclusive (the protocol's, and
        # ADR-0012's). A node whose two halves disagree has not proven the range
        # it claims, whichever one is right.
        if (identity.shard_start, identity.shard_end) != (
            base.shard_start,
            (base.shard_end or 0) + 1,
        ):
            return base.with_state(
                STATE_SHARD_MISMATCH,
                f"identity covers layers {identity.shard_start}–{identity.shard_end} "
                f"(end-exclusive), but the proof is for layers {base.shard_start}–"
                f"{base.shard_end} (end-inclusive)",
            )

        if not model_matches(identity.artifact_id):
            return base.with_state(
                STATE_MODEL_MISMATCH,
                f"identity is for artifact {identity.artifact_id!r}, but the node "
                f"registered {advertised_model!r}",
            )
        model_claim = report["model"]
        if model_claim.get("revision") != identity.revision:
            return base.with_state(
                STATE_MODEL_MISMATCH,
                "identity revision does not match the capability proof",
            )
        config_fingerprint = model_claim.get("config_fingerprint")
        if isinstance(config_fingerprint, str) and config_fingerprint.startswith(
            "sha256:"
        ):
            config_fingerprint = config_fingerprint.removeprefix("sha256:")
        if config_fingerprint != identity.architecture_digest:
            return base.with_state(
                STATE_MODEL_MISMATCH,
                "identity architecture/config digest does not match the capability proof",
            )
        if (
            identity.recipe_id != base.recipe_id
            or identity.recipe_version != base.recipe_version
            or identity.catalogue_version != base.catalogue_version
        ):
            return base.with_state(
                STATE_RECIPE_MISMATCH,
                "identity recipe labels do not match the capability proof",
            )
        if identity.axes["backend_id"] != base.backend_id:
            return base.with_state(
                STATE_RECIPE_MISMATCH,
                "identity backend does not match the capability proof",
            )
        if (
            base.quantization is not None
            and identity.axes["weight_quantization"] != base.quantization
        ):
            return base.with_state(
                STATE_RECIPE_MISMATCH,
                "identity weight quantization does not match the capability proof",
            )

        base = replace(
            base,
            model_artifact_digest=identity.model_artifact_digest,
            runtime_recipe_digest=identity.runtime_recipe_digest,
            shard_binding_digest=identity.shard_binding_digest,
        )

    if status != STATUS_PASSED:
        return base.with_state(
            STATE_FAILED,
            f"capability validation {status} on the node"
            + (f" — {' '.join(base.diagnostics)}" if base.diagnostics else ""),
        )

    age = now - (base.validated_at or 0.0)
    if age > max_age_seconds:
        return base.with_state(
            STATE_STALE,
            f"proof is {age / 60:.0f} min old (limit {max_age_seconds / 60:.0f} min); "
            "the node must re-validate before it can be routed",
        )
    if age < -MAX_CLOCK_SKEW_SECONDS:
        return base.with_state(
            STATE_STALE,
            f"proof is timestamped {-age:.0f}s in the future; check the node's clock",
        )

    # The digest only establishes that this report is self-consistent. It does
    # not authenticate a node or prove a distributed forward. Exact recipes are
    # therefore registered-but-dark until tracker-owned certification records
    # that forward.
    #
    # `ledger is None` means the caller owns no certification authority. It is
    # not "certify anything" and it is not "make one up": a disposable ledger
    # would register the recipe into state that is discarded on return, which
    # reads like certification is wired when nothing is recording it. The only
    # safe reading is that nothing here has certified this recipe, so it stays
    # dark. `TrackerServer` owns the real ledger and passes it in.
    if identity is not None:
        if ledger is None:
            return base.with_state(
                STATE_UNCERTIFIED,
                "no certification ledger; an exact recipe is dark until a "
                "tracker-owned distributed forward certifies it",
            )
        recipe_status = ledger.register(identity)
        base = replace(base, certification=recipe_status.status)
        if not recipe_status.may_serve:
            return base.with_state(STATE_UNCERTIFIED, recipe_status.detail)

    return base.with_state(
        STATE_ADMITTED,
        f"{base.model_id} layers {base.shard_start}–{base.shard_end} proven on "
        f"{base.device} with recipe {base.recipe_id} (v{base.recipe_version})",
    )


class _ReportError(ValueError):
    """Malformed report input. Messages name the field, never echo a payload."""


def _parse_report(doc: Mapping[str, Any]) -> dict:
    model = _object(doc.get("model"), "model")
    shard = _object(doc.get("shard"), "shard")
    recipe = _object(doc.get("recipe"), "recipe")
    backend = _object(doc.get("backend"), "backend")

    validated_at = doc.get("validated_at")
    if isinstance(validated_at, bool) or not isinstance(validated_at, (int, float)):
        raise _ReportError("'validated_at' must be a Unix timestamp")

    schema_version = doc.get("schema_version")
    if isinstance(schema_version, bool) or not isinstance(schema_version, int):
        raise _ReportError("'schema_version' must be an integer")

    return {
        "model_id": _text(model.get("model_id"), "model.model_id"),
        "shard_start": _index(shard.get("start"), "shard.start"),
        "shard_end": _index(shard.get("end"), "shard.end"),
        "recipe_id": _text(recipe.get("recipe_id"), "recipe.recipe_id"),
        "recipe_version": _text(recipe.get("recipe_version"), "recipe.recipe_version"),
        "catalogue_version": _text(
            recipe.get("catalogue_version"), "recipe.catalogue_version"
        ),
        "backend_id": _text(backend.get("backend_id"), "backend.backend_id"),
        "device": _text(backend.get("device"), "backend.device"),
        "quantization": _optional_text(
            backend.get("quantization"), "backend.quantization"
        ),
        "validated_at": float(validated_at),
        "schema_version": schema_version,
        "diagnostics": _diagnostics(doc.get("diagnostics")),
        "_status": _text(doc.get("status"), "status"),
    }


def _object(value: Any, field_name: str) -> Mapping[str, Any]:
    if not isinstance(value, Mapping):
        raise _ReportError(f"{field_name!r} must be a JSON object")
    return value


def _text(value: Any, field_name: str) -> str:
    if not isinstance(value, str) or not value.strip():
        raise _ReportError(f"{field_name!r} must be a non-empty string")
    return value


def _optional_text(value: Any, field_name: str) -> str | None:
    if value is None:
        return None
    return _text(value, field_name)


def _index(value: Any, field_name: str) -> int:
    if isinstance(value, bool) or not isinstance(value, int) or value < 0:
        raise _ReportError(f"{field_name!r} must be a non-negative integer")
    return value


def _maybe_int(value: Any) -> int | None:
    if isinstance(value, bool) or not isinstance(value, int):
        return None
    return value


def _diagnostics(value: Any) -> tuple[str, ...]:
    if not isinstance(value, list):
        return ()
    out = [
        sanitize_detail(item)
        for item in value[:_MAX_DIAGNOSTICS]
        if isinstance(item, str) and item.strip()
    ]
    return tuple(out)
