"""Canonical encoding for SelfModel records and capability claims.

Determinism rules (per ticket #000014):

- All hashes use SHA-256 over the canonical-JSON serialization
  (``json.dumps(obj, sort_keys=True, separators=(',', ':'),
  ensure_ascii=False)`` — same convention as
  ``arborist.store._canonical_json``).
- Field order in canonical bodies is fixed: any addition is a new
  ``schema_version`` (``selfmodel-v1`` → ``selfmodel-v2``).
- Optional fields hash as their explicit ``None`` when absent (NOT
  omitted), so two SelfModels that differ only in "omitted" vs
  "explicitly null" hash to the same value.
"""

from __future__ import annotations

import hashlib
import json
from dataclasses import asdict, dataclass, field, replace
from typing import Optional


SCHEMA_VERSION = "selfmodel-v1"


@dataclass(frozen=True)
class CapabilityClaim:
    """A bench-derived assertion the SelfModel ships with.

    Fields
    ------
    metric:
        Stable metric name (e.g., ``"strict_rate"``,
        ``"directive_coverage"``). Free-form but should match a
        bench-harness identifier.
    threshold:
        The value the claim asserts the agent meets or exceeds.
    eval_digest:
        Canonical digest of the bench fixture set the claim was
        measured against. Re-running this fixture is what falsifies
        the claim if measured drops below threshold.
    measured_value:
        The actual measurement at claim-creation time. Optional —
        a forward-looking claim may have ``None`` here.
    measured_at:
        Unix timestamp (int) of measurement, or ``None``.
    validity_horizon:
        Free-form string indicating when the claim should be
        re-evaluated (e.g., ``"next-checkpoint"`` or ISO date).
    claim_text:
        Operator-readable description.
    """

    metric: str
    threshold: float
    eval_digest: str
    measured_value: Optional[float] = None
    measured_at: Optional[int] = None
    validity_horizon: str = "next-checkpoint"
    claim_text: str = ""

    def canonical(self) -> dict:
        return {
            "metric": self.metric,
            "threshold": float(self.threshold),
            "eval_digest": self.eval_digest,
            "measured_value": (
                None
                if self.measured_value is None
                else float(self.measured_value)
            ),
            "measured_at": self.measured_at,
            "validity_horizon": self.validity_horizon,
            "claim_text": self.claim_text,
        }


def canonical_claim(claim: CapabilityClaim) -> str:
    return json.dumps(
        claim.canonical(),
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    )


def claim_hash(claim: CapabilityClaim) -> str:
    return hashlib.sha256(
        canonical_claim(claim).encode("utf-8", errors="surrogatepass")
    ).hexdigest()


@dataclass(frozen=True)
class SelfModel:
    """Frozen-at-checkpoint identity record.

    The ``selfmodel_root`` is derived from the canonical body bytes
    via :func:`selfmodel_root`. Treat this dataclass as immutable;
    state transitions (live/stale/falsified) live on the row, not
    on the canonical body — flipping state must NOT change the root.

    Capability claims are stored alongside (sibling table) and
    referenced via their content-addressed ``claim_hash`` values.
    """

    schema_version: str
    parent_selfmodel_root: Optional[str]
    model_profile_hash: str
    verifier_method_root: str
    governance_policy_hash: str
    canonicalization_version: str
    chunking_version: str
    accepted_patch_root: Optional[str]
    rejected_patch_root: Optional[str]
    memory_root: Optional[str]
    capability_claim_hashes: tuple[str, ...] = field(default_factory=tuple)

    def canonical(self) -> dict:
        return {
            "schema_version": self.schema_version,
            "parent_selfmodel_root": self.parent_selfmodel_root,
            "model_profile_hash": self.model_profile_hash,
            "verifier_method_root": self.verifier_method_root,
            "governance_policy_hash": self.governance_policy_hash,
            "canonicalization_version": self.canonicalization_version,
            "chunking_version": self.chunking_version,
            "accepted_patch_root": self.accepted_patch_root,
            "rejected_patch_root": self.rejected_patch_root,
            "memory_root": self.memory_root,
            # capability claim hashes are sorted to make the root
            # invariant under the order in which claims are added.
            "capability_claim_hashes": sorted(self.capability_claim_hashes),
        }


def canonical_body(model: SelfModel) -> str:
    return json.dumps(
        model.canonical(),
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    )


def selfmodel_root(model: SelfModel) -> str:
    """SHA-256 over the canonical body bytes."""
    return hashlib.sha256(
        canonical_body(model).encode("utf-8", errors="surrogatepass")
    ).hexdigest()


def with_claims(
    base: SelfModel, claims: list[CapabilityClaim]
) -> SelfModel:
    """Return a SelfModel with the given claims attached.

    The resulting SelfModel's ``capability_claim_hashes`` is the
    sorted list of ``claim_hash(c)`` for each claim. Ordering of
    the input list does not affect the resulting selfmodel_root.
    """
    hashes = tuple(sorted({claim_hash(c) for c in claims}))
    return replace(base, capability_claim_hashes=hashes)
