"""Deployment manifest reader for the C7 ``EngineGate`` (AZ-301).

E-C10 produces a ``manifest.json`` during F1 cache provisioning. The
file maps every relative engine path to its canonical sha256_hex. This
module is the read-only consumer side: ``ManifestReader.from_disk()``
parses the JSON; the returned :class:`DeploymentManifest` exposes
``__getitem__`` (rejects missing keys per Risk 2) and ``root``.

The manifest's *write* shape is owned by E-C10. AZ-301 implements only
the reader sufficient for D-C10-3 takeoff gate. Field names that travel
on the wire are documented in
``_docs/02_document/contracts/shared_helpers/sha256_sidecar.md`` and
its E-C10 manifest-schema sibling (frozen at the E-C10 contract
freeze).
"""

from __future__ import annotations

import json
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol, runtime_checkable

from gps_denied_onboard.components.c7_inference.errors import (
    EngineHashMismatchError,
)

__all__ = [
    "DeploymentManifest",
    "ManifestReader",
    "ManifestReaderProtocol",
]


@dataclass(frozen=True)
class DeploymentManifest:
    """Read-only typed view over a deployed ``manifest.json``.

    ``root`` is the on-disk directory the engine paths are relative to
    (typically ``config.components['c7_inference'].engine_cache_dir``).
    ``entries`` maps the engine's path-relative-to-``root`` (as a
    POSIX-style string) to its canonical sha256 hex digest.

    Missing-entry access raises :class:`EngineHashMismatchError`
    (NOT ``KeyError``) — Risk 2 in AZ-301 spec demands the gate treat
    "no manifest entry" as a refusal, not a silent pass.
    """

    root: Path
    entries: Mapping[str, str]

    def __getitem__(self, relative_path: Path | str) -> str:
        key = _key_str(relative_path)
        try:
            return self.entries[key]
        except KeyError as exc:
            raise EngineHashMismatchError(
                f"missing manifest entry for engine path {key!r} "
                f"(manifest root={self.root!s})"
            ) from exc

    def __contains__(self, relative_path: object) -> bool:
        if not isinstance(relative_path, (Path, str)):
            return False
        return _key_str(relative_path) in self.entries


@runtime_checkable
class ManifestReaderProtocol(Protocol):
    """Structural shape the gate consumes. Live impl is :class:`ManifestReader`;
    tests inject a dict-backed fake."""

    def read(self) -> DeploymentManifest:  # pragma: no cover - structural
        ...


class ManifestReader:
    """On-disk JSON reader for the deployment manifest.

    Constructor takes the path of the ``manifest.json`` file. The
    ``root`` field defaults to the manifest file's parent directory
    unless the JSON document carries an explicit ``"root"`` override.
    """

    __slots__ = ("_path",)

    def __init__(self, manifest_path: Path) -> None:
        self._path = manifest_path

    def read(self) -> DeploymentManifest:
        try:
            text = self._path.read_text(encoding="utf-8")
        except OSError as exc:
            raise EngineHashMismatchError(
                f"cannot read manifest at {self._path!s}: "
                f"{type(exc).__name__}: {exc}"
            ) from exc
        try:
            payload = json.loads(text)
        except json.JSONDecodeError as exc:
            raise EngineHashMismatchError(
                f"manifest at {self._path!s} is not valid JSON: {exc.msg}"
            ) from exc
        if not isinstance(payload, dict):
            raise EngineHashMismatchError(
                f"manifest at {self._path!s} must be a JSON object; got "
                f"{type(payload).__name__}"
            )
        raw_entries = payload.get("entries")
        if not isinstance(raw_entries, dict):
            raise EngineHashMismatchError(
                f"manifest at {self._path!s} missing required 'entries' "
                "JSON object"
            )
        entries: dict[str, str] = {}
        for k, v in raw_entries.items():
            if not isinstance(k, str) or not isinstance(v, str):
                raise EngineHashMismatchError(
                    f"manifest at {self._path!s} entry has non-string "
                    f"key/value: {k!r}: {v!r}"
                )
            entries[k] = v.lower()
        root_value = payload.get("root")
        root = Path(root_value) if isinstance(root_value, str) else self._path.parent
        return DeploymentManifest(root=root, entries=entries)


def _key_str(relative_path: Path | str) -> str:
    """Normalise a key to a POSIX-style string for entry lookup."""
    if isinstance(relative_path, Path):
        return relative_path.as_posix()
    return relative_path.replace("\\", "/")
