"""Canonical runtime pin identity for the recipe fingerprint (DGR-025).

The recipe digest (:mod:`meshnet_node.runtime_recipe`) commits to a
``runtime_version`` axis, but a string the operator typed is a label, not a
pin: two workers could run different patch stacks under the same label and
still hash to the same recipe. The DGR-027 lock manifest
(``packages/node/native/llama``) already records the one exact upstream commit
and the ordered patch stack the native runtime is built from, so the axis
value is *derived* from that manifest, never asserted.

The derived value has three load-bearing parts, and each is separately fatal
to compatibility: the runtime name (from the upstream URL), the exact
40-character upstream commit, and a digest over the ordered patch-stack bytes.
A different upstream pin, a reordered stack, or a single changed patch byte
each produce a different axis value, which produces a different recipe digest,
which partitions the route — exactly the fail-closed behavior DGR-025 asks
for.

Every consistency check here fails closed. The manifest keeps three records of
the stack — ``UPSTREAM_LOCK.json``'s ``patch_series``, ``patches/series``, and
``patches/SHA256SUMS`` — plus the ``UPSTREAM_COMMIT`` convenience file, and a
disagreement between any two of them means the workspace's identity is
unknowable, not "probably fine". This module reads the committed manifest
only; fetching and patching the actual source tree stays with
``scripts/llama_cpp_dependency.py`` (DGR-027).
"""

from __future__ import annotations

import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path

# Domain separation, matching the runtime_recipe digest convention: a patch
# stack digest can never be confused with an artifact or recipe digest.
PATCH_STACK_DIGEST_DOMAIN = "meshnet.runtime-patch-stack.v1"

# The UPSTREAM_LOCK.json layout this reader understands (DGR-027 schema).
RUNTIME_PIN_SCHEMA_VERSION = 1

# The committed DGR-027 manifest for the llama.cpp runtime.
DEFAULT_LOCK_DIR = Path(__file__).resolve().parent.parent / "native" / "llama"

_HEX40 = re.compile(r"^[0-9a-f]{40}$")
_HEX64 = re.compile(r"^[0-9a-f]{64}$")


class RuntimePinError(ValueError):
    """The lock workspace is missing, malformed, or internally inconsistent."""


def _canonical_sha256(value: object) -> str:
    payload = json.dumps(
        value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


@dataclass(frozen=True)
class RuntimePin:
    """One exact runtime: a name, an upstream commit, and an ordered patch stack."""

    runtime_name: str
    upstream_commit: str
    patch_series: tuple[str, ...]
    patch_digests: tuple[str, ...]

    @property
    def patch_stack_digest(self) -> str:
        """A digest over the ordered (name, bytes-digest) stack.

        Order is digested deliberately: applying the same patches in a
        different order can produce a different tree, so a reordered stack is
        a different runtime.
        """
        return _canonical_sha256(
            {
                "domain": PATCH_STACK_DIGEST_DOMAIN,
                "body": {
                    "patches": [
                        [name, digest]
                        for name, digest in zip(self.patch_series, self.patch_digests)
                    ]
                },
            }
        )

    @property
    def runtime_version(self) -> str:
        """The exact ``runtime_version`` recipe axis value for this pin."""
        return (
            f"{self.runtime_name}@{self.upstream_commit}"
            f"+patchstack.{self.patch_stack_digest}"
        )


def _read_text(path: Path, what: str) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except FileNotFoundError:
        raise RuntimePinError(f"{what} not found at {path}") from None
    except OSError as exc:
        raise RuntimePinError(f"{what} at {path} is unreadable: {exc}") from exc


def _read_series_file(path: Path) -> list[str]:
    lines = _read_text(path, "patches/series").splitlines()
    return [line.strip() for line in lines if line.strip() and not line.startswith("#")]


def _read_sums_file(path: Path) -> list[tuple[str, str]]:
    entries: list[tuple[str, str]] = []
    for line in _read_text(path, "patches/SHA256SUMS").splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        parts = line.split(None, 1)
        if len(parts) != 2 or not _HEX64.match(parts[0]):
            raise RuntimePinError(
                "patches/SHA256SUMS contains a line that is not "
                "'<sha256> <patch name>'"
            )
        entries.append((parts[0], parts[1].strip()))
    return entries


def load_runtime_pin(lock_dir: Path = DEFAULT_LOCK_DIR) -> RuntimePin:
    """Derive the exact runtime pin from a DGR-027 lock workspace, or refuse.

    Refuses — rather than guessing — on a missing or malformed lock, a moving
    commit reference, a disagreement between the lock's ``patch_series``, the
    ``patches/series`` file, ``patches/SHA256SUMS``, or the actual patch
    bytes, and on an ``UPSTREAM_COMMIT`` file that names a different commit.
    """
    lock_path = lock_dir / "UPSTREAM_LOCK.json"
    raw = _read_text(lock_path, "UPSTREAM_LOCK.json")
    try:
        lock = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise RuntimePinError(f"UPSTREAM_LOCK.json is not valid JSON: {exc}") from exc
    if not isinstance(lock, dict):
        raise RuntimePinError("UPSTREAM_LOCK.json must be a JSON object")

    schema = lock.get("schema_version")
    if schema != RUNTIME_PIN_SCHEMA_VERSION:
        raise RuntimePinError(
            f"UPSTREAM_LOCK.json declares schema version {schema!r}; this reader "
            f"understands version {RUNTIME_PIN_SCHEMA_VERSION}"
        )

    upstream = lock.get("upstream")
    if not isinstance(upstream, str) or not upstream.strip():
        raise RuntimePinError("UPSTREAM_LOCK.json is missing the upstream URL")
    runtime_name = upstream.rstrip("/").rsplit("/", 1)[-1]
    if runtime_name.endswith(".git"):
        runtime_name = runtime_name[: -len(".git")]
    if not runtime_name:
        raise RuntimePinError("the upstream URL does not name a runtime")

    commit = lock.get("commit")
    if not isinstance(commit, str) or not _HEX40.match(commit):
        raise RuntimePinError(
            f"UPSTREAM_LOCK.json commit {commit!r} is not an exact 40-character "
            "hexadecimal object id; a moving reference is not a pin"
        )

    commit_file = _read_text(lock_dir / "UPSTREAM_COMMIT", "UPSTREAM_COMMIT")
    recorded = commit_file.strip().splitlines()[0].strip() if commit_file.strip() else ""
    if recorded != commit:
        raise RuntimePinError(
            "UPSTREAM_COMMIT and UPSTREAM_LOCK.json disagree on the pinned commit"
        )

    lock_series = lock.get("patch_series")
    if not isinstance(lock_series, list) or not all(
        isinstance(name, str) and name.strip() for name in lock_series
    ):
        raise RuntimePinError(
            "UPSTREAM_LOCK.json patch_series must be a list of patch file names"
        )
    if len(set(lock_series)) != len(lock_series):
        raise RuntimePinError("UPSTREAM_LOCK.json patch_series contains a duplicate")

    series = _read_series_file(lock_dir / "patches" / "series")
    if series != lock_series:
        raise RuntimePinError(
            "patches/series and UPSTREAM_LOCK.json patch_series disagree on the "
            "ordered patch stack"
        )

    sums = _read_sums_file(lock_dir / "patches" / "SHA256SUMS")
    if [name for _, name in sums] != lock_series:
        raise RuntimePinError(
            "patches/SHA256SUMS does not record exactly the ordered patch stack "
            "named by UPSTREAM_LOCK.json"
        )

    digests: list[str] = []
    for (expected_digest, name) in sums:
        patch_path = lock_dir / "patches" / name
        try:
            body = patch_path.read_bytes()
        except FileNotFoundError:
            raise RuntimePinError(f"patch file {name} is named but missing") from None
        except OSError as exc:
            raise RuntimePinError(f"patch file {name} is unreadable: {exc}") from exc
        actual = hashlib.sha256(body).hexdigest()
        if actual != expected_digest:
            raise RuntimePinError(
                f"patch file {name} does not match its patches/SHA256SUMS digest"
            )
        digests.append(actual)

    return RuntimePin(
        runtime_name=runtime_name,
        upstream_commit=commit,
        patch_series=tuple(lock_series),
        patch_digests=tuple(digests),
    )
