"""Typed primitives for the NutriCoach v1.4.0 candidate seal."""

from __future__ import annotations

import hashlib
import json
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Final, TypeAlias, override

from pydantic import JsonValue as PydanticJsonValue, TypeAdapter

JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
_DIGEST_LENGTH: Final = 64


@dataclass(frozen=True, slots=True)
class CandidateContractError(RuntimeError):
    """A candidate input does not satisfy the immutable release contract."""

    reason: str

    @override
    def __str__(self) -> str:
        return self.reason


def canonical(value: JsonValue) -> bytes:
    """Encode one JSON value using the release canonicalization."""
    return json.dumps(
        value,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
        allow_nan=False,
    ).encode()


def sha256_bytes(payload: bytes) -> str:
    return hashlib.sha256(payload).hexdigest()


def sha256_file(path: Path) -> str:
    return sha256_bytes(path.read_bytes())


def load_json(path: Path) -> JsonValue:
    adapter: TypeAdapter[PydanticJsonValue] = TypeAdapter(PydanticJsonValue)
    return adapter.validate_json(path.read_text(encoding="utf-8"))


def require_object(value: JsonValue, label: str) -> dict[str, JsonValue]:
    if not isinstance(value, dict):
        raise CandidateContractError(f"{label}: expected object")
    return value


def require_list(value: JsonValue | None, label: str) -> list[JsonValue]:
    if not isinstance(value, list):
        raise CandidateContractError(f"{label}: expected list")
    return value


def require_string(value: JsonValue | None, label: str) -> str:
    if not isinstance(value, str):
        raise CandidateContractError(f"{label}: expected string")
    return value


def require_int(value: JsonValue | None, label: str) -> int:
    if not isinstance(value, int) or isinstance(value, bool):
        raise CandidateContractError(f"{label}: expected integer")
    return value


def require_digest(value: JsonValue | None, label: str) -> str:
    digest = require_string(value, label)
    if len(digest) != _DIGEST_LENGTH:
        raise CandidateContractError(f"{label}: invalid digest length")
    try:
        _ = bytes.fromhex(digest)
    except ValueError as error:
        raise CandidateContractError(f"{label}: invalid digest encoding") from error
    return digest


def path_beneath(root: Path, raw: JsonValue | None, label: str) -> Path:
    relative = Path(require_string(raw, label))
    if relative.is_absolute() or ".." in relative.parts:
        raise CandidateContractError(f"{label}: path escapes successor")
    result = root / relative
    if not result.is_file() or result.is_symlink():
        raise CandidateContractError(f"{label}: missing regular file")
    return result


def verify_hash_entries(
    root: Path,
    raw_entries: JsonValue | None,
    label: str,
) -> list[dict[str, JsonValue]]:
    entries = require_list(raw_entries, label)
    verified: list[dict[str, JsonValue]] = []
    seen: set[str] = set()
    for index, raw in enumerate(entries):
        entry = require_object(raw, f"{label}[{index}]")
        relative = require_string(entry.get("path"), f"{label}[{index}].path")
        if relative in seen:
            raise CandidateContractError(f"{label}: duplicate path {relative}")
        path = path_beneath(root, relative, f"{label}[{index}].path")
        expected = require_digest(entry.get("sha256"), f"{label}[{index}].sha256")
        if sha256_file(path) != expected:
            raise CandidateContractError(f"{label}: hash mismatch {relative}")
        seen.add(relative)
        verified.append(entry)
    if [require_string(item.get("path"), label) for item in verified] != sorted(seen):
        raise CandidateContractError(f"{label}: inventory is not sorted")
    return verified


def inventory_digest(entries: list[dict[str, JsonValue]]) -> str:
    normalized: list[JsonValue] = [
        {
            "path": require_string(entry.get("path"), "inventory.path"),
            "sha256": require_digest(entry.get("sha256"), "inventory.sha256"),
        }
        for entry in entries
    ]
    return sha256_bytes(canonical(normalized))


def derive_candidate_digest(
    inputs: Mapping[str, JsonValue],
    *,
    claimed: str | None = None,
) -> str:
    """Derive the full product identity and reject a wheel-shaped claim."""
    required = {
        "config_digest",
        "evidence_digest",
        "hermes_wheel_sha256",
        "interpreter_sha256",
        "policy_digest",
        "profile_wheel_sha256",
        "source_tree_digest",
    }
    if set(inputs) != required:
        raise CandidateContractError("product derivation input fields are invalid")
    for name in sorted(required):
        _ = require_digest(inputs[name], f"derivation_inputs.{name}")
    candidate = sha256_bytes(canonical(dict(inputs)))
    wheel_digests = {
        require_digest(inputs["hermes_wheel_sha256"], "hermes wheel"),
        require_digest(inputs["profile_wheel_sha256"], "profile wheel"),
    }
    if candidate in wheel_digests or claimed in wheel_digests:
        raise CandidateContractError("candidate digest equals a single wheel digest")
    if claimed is not None and candidate != claimed:
        raise CandidateContractError("candidate derivation mismatch")
    return candidate
