"""``EngineGate`` — D-C10-3 + D-C10-7 takeoff validator (AZ-301).

Every :class:`InferenceRuntime` strategy calls
:meth:`EngineGate.validate` before :meth:`InferenceRuntime.deserialize_engine`
so the five refusal paths happen in one place:

1. **Schema parse failure** — the ``.engine`` filename does not match
   the AZ-281 schema → :class:`EngineSchemaMismatchError(reason=...)`.
2. **Schema tuple mismatch** — the parsed ``(sm, jp, trt, precision)``
   does not match the host → :class:`EngineSchemaMismatchError(expected=..., got=...)`.
3. **Sidecar missing** — the ``.sha256`` sidecar is absent →
   :class:`EngineSidecarMissingError`.
4. **Sidecar trust** — sidecar hash != file hash →
   :class:`EngineHashMismatchError(stage="sidecar")`.
5. **Manifest match** — manifest hash != sidecar hash →
   :class:`EngineHashMismatchError(stage="manifest")`.

Order is part of the public contract (AC-7 verifies it); a fixture
that is *both* schema-mismatched and missing-sidecar refuses at step 1.

Pure validator — no GPU ops, no writes, no network. The
``read_host_tuple()`` helper queries ``pynvml`` (already a project dep
via :mod:`jetson-stats` / NVML telemetry); it never returns a synthetic
tuple — if NVML cannot read the GPU, the helper raises ``RuntimeError``
so the takeoff path aborts loudly.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

from gps_denied_onboard._types.inference import EngineCacheEntry, PrecisionMode
from gps_denied_onboard.components.c7_inference.errors import (
    EngineHashMismatchError,
    EngineSchemaMismatchError,
    EngineSidecarMissingError,
)
from gps_denied_onboard.components.c7_inference.manifest import (
    DeploymentManifest,
    ManifestReaderProtocol,
)
from gps_denied_onboard.helpers.engine_filename_schema import (
    EngineFilenameSchema,
    EngineFilenameSchemaError,
)
from gps_denied_onboard.helpers.sha256_sidecar import (
    SIDECAR_SUFFIX,
    Sha256Sidecar,
    Sha256SidecarError,
)
from gps_denied_onboard.logging import get_logger

if TYPE_CHECKING:
    pass

__all__ = ["EngineGate", "HostTuple", "read_host_tuple"]


@dataclass(frozen=True)
class HostTuple:
    """Host capability tuple consulted by D-C10-7 schema-match gate.

    Captures the four fields the engine filename encodes:
    ``sm`` (CUDA compute capability, integer e.g. 87 for Orin),
    ``jp`` (JetPack version, dotted "<major>.<minor>"),
    ``trt`` (TensorRT version, dotted), and ``precision``
    (:class:`PrecisionMode` enum value).
    """

    sm: int
    jp: str
    trt: str
    precision: PrecisionMode


class EngineGate:
    """Stateless validator. Constructor injects test-side overrides only.

    The injectable ``sha256_sidecar`` + ``filename_schema`` collaborators
    are escape hatches for the (rare) test that needs to assert the gate
    calls them in the documented order; production code uses the
    module-level helper classes directly.
    """

    def __init__(
        self,
        *,
        sha256_sidecar: type[Sha256Sidecar] = Sha256Sidecar,
        filename_schema: type[EngineFilenameSchema] = EngineFilenameSchema,
    ) -> None:
        self._sidecar = sha256_sidecar
        self._schema = filename_schema
        self._logger = get_logger("c7_inference.gate")

    def validate(
        self,
        entry: EngineCacheEntry,
        host_tuple: HostTuple,
        manifest: DeploymentManifest | ManifestReaderProtocol,
    ) -> None:
        """Run the five-step refusal pipeline. Returns None on full success."""
        engine_path = Path(entry.engine_path)
        deployment_manifest = _resolve_manifest(manifest)

        # Step 1+2: filename schema parse + tuple match.
        try:
            parsed = self._schema.parse(engine_path.name)
        except EngineFilenameSchemaError as exc:
            self._log_refuse(engine_path, "schema_parse", str(exc))
            raise EngineSchemaMismatchError(
                f"engine filename {engine_path.name!r} does not match the "
                f"AZ-281 schema: {exc}"
            ) from exc
        if (
            parsed.sm != host_tuple.sm
            or parsed.jetpack != host_tuple.jp
            or parsed.trt != host_tuple.trt
            or parsed.precision != host_tuple.precision.value
        ):
            reason = (
                f"engine {engine_path.name!r} parsed as "
                f"(sm={parsed.sm}, jp={parsed.jetpack!r}, trt={parsed.trt!r}, "
                f"precision={parsed.precision!r}); host expects "
                f"(sm={host_tuple.sm}, jp={host_tuple.jp!r}, "
                f"trt={host_tuple.trt!r}, "
                f"precision={host_tuple.precision.value!r})"
            )
            self._log_refuse(engine_path, "schema_tuple", reason)
            raise EngineSchemaMismatchError(reason)

        # Step 3: sidecar existence.
        sidecar_path = Path(str(engine_path) + SIDECAR_SUFFIX)
        if not sidecar_path.exists():
            reason = (
                f"sidecar missing for engine {engine_path!s} "
                f"(expected at {sidecar_path!s})"
            )
            self._log_refuse(engine_path, "sidecar_missing", reason)
            raise EngineSidecarMissingError(reason)

        # Step 4: sidecar trust (sidecar hash == file hash).
        try:
            sidecar_ok = self._sidecar.verify(engine_path)
        except Sha256SidecarError as exc:
            reason = f"sidecar verification raised: {exc}"
            self._log_refuse(engine_path, "sidecar_trust", reason)
            raise EngineHashMismatchError(reason) from exc
        if not sidecar_ok:
            actual = _sha256_of_file(engine_path)
            recorded = sidecar_path.read_text(encoding="utf-8").strip().lower()
            reason = (
                f"sidecar hash {recorded!r} does not match engine sha256 "
                f"{actual!r} for {engine_path!s}"
            )
            self._log_refuse(engine_path, "sidecar_trust", reason)
            raise EngineHashMismatchError(reason)

        # Step 5: manifest match.
        try:
            relative = engine_path.relative_to(deployment_manifest.root)
        except ValueError as exc:
            reason = (
                f"engine {engine_path!s} is not under manifest root "
                f"{deployment_manifest.root!s}"
            )
            self._log_refuse(engine_path, "manifest_path", reason)
            raise EngineHashMismatchError(reason) from exc
        sidecar_hash = sidecar_path.read_text(encoding="utf-8").strip().lower()
        manifest_hash = deployment_manifest[relative].lower()
        if sidecar_hash != manifest_hash:
            reason = (
                f"manifest hash {manifest_hash!r} != sidecar hash "
                f"{sidecar_hash!r} for engine path {relative.as_posix()!r}"
            )
            self._log_refuse(engine_path, "manifest_mismatch", reason)
            raise EngineHashMismatchError(reason)

        self._logger.info(
            "engine gate passed for %s",
            engine_path.name,
            extra={
                "kind": "c7.gate.pass",
                "kv": {
                    "engine_path": str(engine_path),
                    "host_sm": host_tuple.sm,
                    "host_jp": host_tuple.jp,
                    "host_trt": host_tuple.trt,
                    "precision": host_tuple.precision.value,
                    "sha256": sidecar_hash,
                },
            },
        )

    def _log_refuse(
        self, engine_path: Path, step: str, reason: str
    ) -> None:
        self._logger.error(
            "engine gate refused %s at step %s",
            engine_path.name,
            step,
            extra={
                "kind": "c7.gate.refuse",
                "kv": {
                    "engine_path": str(engine_path),
                    "step": step,
                    "reason": reason,
                },
            },
        )


def read_host_tuple(*, precision: PrecisionMode) -> HostTuple:
    """Query ``pynvml`` for ``(sm, jp, trt)``; combine with ``precision``.

    Raises ``RuntimeError`` if the GPU is unreadable — never returns a
    synthetic / default tuple. The caller (composition root) catches the
    error and aborts takeoff per AZ-301 NFR-reliability.

    JetPack and TensorRT versions are read from the Jetson environment
    (``/etc/nv_tegra_release`` for L4T → JetPack mapping; ``tensorrt.__version__``
    when the binding is importable). Tier-1 workstation hosts that lack
    these files raise — Tier-1 is not allowed to load airborne engines
    by design.
    """
    import pynvml  # local import — pynvml is Tier-2-only dep

    try:
        pynvml.nvmlInit()
    except pynvml.NVMLError as exc:  # type: ignore[attr-defined]
        raise RuntimeError(
            f"cannot read host tuple: pynvml init failed: {exc}"
        ) from exc
    try:
        handle = pynvml.nvmlDeviceGetHandleByIndex(0)
        major, minor = pynvml.nvmlDeviceGetCudaComputeCapability(handle)
        sm = int(major) * 10 + int(minor)
    except pynvml.NVMLError as exc:  # type: ignore[attr-defined]
        raise RuntimeError(
            f"cannot read host tuple: NVML device query failed: {exc}"
        ) from exc
    finally:
        try:
            pynvml.nvmlShutdown()
        except pynvml.NVMLError:  # type: ignore[attr-defined]
            pass

    jp = _read_jetpack_version()
    trt = _read_tensorrt_version()
    return HostTuple(sm=sm, jp=jp, trt=trt, precision=precision)


def _resolve_manifest(
    manifest: DeploymentManifest | ManifestReaderProtocol,
) -> DeploymentManifest:
    if isinstance(manifest, DeploymentManifest):
        return manifest
    return manifest.read()


def _read_jetpack_version() -> str:
    """Parse ``/etc/nv_tegra_release`` → JetPack dotted version."""
    release_path = Path("/etc/nv_tegra_release")
    if not release_path.exists():
        raise RuntimeError(
            "cannot read host tuple: /etc/nv_tegra_release not found "
            "(JetPack version requires a Jetson host)"
        )
    text = release_path.read_text(encoding="utf-8", errors="replace")
    # L4T R36.x → JetPack 6.x; the mapping is owned by the JetPack release notes.
    # The contract test fixture pins JetPack 6.2 ← L4T R36.4.
    if "R36" in text:
        if "REVISION: 4" in text:
            return "6.2"
        return "6.0"
    if "R35" in text:
        return "5.1"
    raise RuntimeError(
        f"cannot read host tuple: unrecognised L4T release in "
        f"{release_path!s}: {text!r}"
    )


def _read_tensorrt_version() -> str:
    """Return the dotted ``<major>.<minor>`` TRT version from the binding."""
    try:
        import tensorrt  # type: ignore[import-not-found]
    except ImportError as exc:
        raise RuntimeError(
            "cannot read host tuple: tensorrt python binding not installed "
            "(Tier-2 Jetson only)"
        ) from exc
    version: str = tensorrt.__version__
    parts = version.split(".")
    if len(parts) < 2:
        raise RuntimeError(
            f"cannot read host tuple: tensorrt.__version__={version!r} is "
            "not a dotted version"
        )
    return f"{parts[0]}.{parts[1]}"


def _sha256_of_file(path: Path) -> str:
    import hashlib

    hasher = hashlib.sha256()
    with path.open("rb") as fh:
        while True:
            chunk = fh.read(1 << 20)
            if not chunk:
                break
            hasher.update(chunk)
    return hasher.hexdigest()
