"""
Sanna Constitution — policy authoring, signing, and binding.

Defines the constitution document format (YAML/JSON) and all operations:
load, parse, validate, sign (hash), convert to receipt reference, scaffold.

The constitution captures governance provenance: who defined the agent's
boundaries, what those boundaries are, and who approved them.

v0.6.3: Constitution signature covers the full document (not just the hash).
Signature metadata lives in provenance.signature.  ``document_hash`` is
renamed to ``policy_hash`` everywhere.
"""

from __future__ import annotations

import hashlib
import json
import re
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional


# =============================================================================
# EXCEPTIONS
# =============================================================================

class SannaConstitutionError(Exception):
    """Raised when a constitution fails integrity checks."""
    pass


# =============================================================================
# CONSTANTS
# =============================================================================

CONSTITUTION_SCHEMA_VERSION = "0.1.0"

VALID_CATEGORIES = {"scope", "authorization", "confidentiality", "safety", "compliance", "custom"}
VALID_SEVERITIES = {"critical", "high", "medium", "low", "info"}
VALID_ENFORCEMENT = {"halt", "warn", "log"}
VALID_APPROVAL_STATUSES = {"approved", "pending", "revoked"}

_BOUNDARY_ID_RE = re.compile(r"^B\d{3}$")
_HALT_ID_RE = re.compile(r"^H\d{3}$")
_ISO8601_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")


# =============================================================================
# DATA CLASSES
# =============================================================================

@dataclass
class Boundary:
    id: str
    description: str
    category: str
    severity: str


@dataclass
class HaltCondition:
    id: str
    trigger: str
    escalate_to: str
    severity: str
    enforcement: str


@dataclass
class TrustTiers:
    autonomous: list[str] = field(default_factory=list)
    requires_approval: list[str] = field(default_factory=list)
    prohibited: list[str] = field(default_factory=list)


@dataclass
class TrustedSources:
    """Source trust tiers for C1 source-aware context evaluation.

    Maps source names (e.g., MCP server names, database identifiers) to
    trust tiers that control how C1 weighs their content:

    - **tier_1**: Full trust — claims count as grounded evidence.
    - **tier_2**: Evidence with verification flag in receipt.
    - **tier_3**: Reference only — cannot be sole basis for conclusions.
    - **untrusted**: Excluded from C1 contradiction checking.
    """
    tier_1: list[str] = field(default_factory=list)
    tier_2: list[str] = field(default_factory=list)
    tier_3: list[str] = field(default_factory=list)
    untrusted: list[str] = field(default_factory=list)


@dataclass
class ConstitutionSignature:
    """Ed25519 signature block for a constitution document.

    Lives at ``provenance.signature`` in the constitution.  The ``value``
    field is excluded from the signed material (set to ``""`` during
    signing / verification).
    """
    value: Optional[str] = None        # Base64 Ed25519 signature
    key_id: Optional[str] = None       # SHA-256 fingerprint of signing public key (64 hex)
    signed_by: Optional[str] = None    # Identity of the signer
    signed_at: Optional[str] = None    # ISO 8601 timestamp
    scheme: str = "constitution_sig_v1"


@dataclass
class Provenance:
    authored_by: str
    approved_by: list[str]
    approval_date: str
    approval_method: str
    change_history: list[dict[str, str]] = field(default_factory=list)
    signature: Optional[ConstitutionSignature] = None


@dataclass
class IdentityClaim:
    """A verifiable identity assertion from an external provider.

    Claims follow a standard format: provider issues a signed credential
    that Sanna can verify using the provider's public key.
    """
    provider: str           # "trulioo", "vouched", "sumsub", "internal"
    claim_type: str         # "digital_agent_passport", "deployment_attestation"
    credential_id: str      # External credential reference
    issued_at: str          # ISO 8601 timestamp
    expires_at: str = ""    # Optional expiry
    signature: str = ""     # Base64-encoded Ed25519 signature from provider
    public_key_id: str = "" # Key ID for the provider's signing key


@dataclass
class IdentityVerificationResult:
    """Result of verifying a single identity claim."""
    claim: IdentityClaim
    status: str             # "verified", "unverified", "failed", "expired", "no_key"
    detail: str = ""


@dataclass
class IdentityVerificationSummary:
    """Aggregate result of verifying all identity claims on an agent."""
    results: list[IdentityVerificationResult] = field(default_factory=list)
    all_verified: bool = True
    total_claims: int = 0
    verified_count: int = 0
    failed_count: int = 0
    unverified_count: int = 0


@dataclass
class AgentIdentity:
    agent_name: str
    domain: str
    description: str = ""
    extensions: dict = field(default_factory=dict)
    identity_claims: list[IdentityClaim] = field(default_factory=list)


@dataclass
class Invariant:
    id: str           # INV_NO_FABRICATION, INV_CUSTOM_*, etc.
    rule: str         # Human-readable description
    enforcement: str  # "halt" | "warn" | "log"
    check: Optional[str] = None  # Optional check impl ID (e.g., "sanna.context_contradiction")


@dataclass
class EscalationTargetConfig:
    """Escalation target configuration from a constitution YAML.

    Defines where/how an escalation is routed when a must_escalate
    condition triggers.

    Attributes:
        type: Target type — ``"log"``, ``"webhook"``, or ``"callback"``.
        url: Webhook URL (only for ``type="webhook"``).
        handler: Registered callback name (only for ``type="callback"``).
    """
    type: str = "log"
    url: Optional[str] = None
    handler: Optional[str] = None


@dataclass
class EscalationRule:
    """A must_escalate rule from a constitution's authority_boundaries.

    Attributes:
        condition: Natural-language description of when to escalate
            (e.g., ``"decisions involving PII"``).
        target: Optional escalation target override. When absent, the
            constitution's default escalation type is used.
    """
    condition: str
    target: Optional[EscalationTargetConfig] = None


@dataclass
class AuthorityBoundaries:
    """Authority boundary definitions from a constitution.

    Defines three tiers of action control:

    - **cannot_execute**: Actions the agent must never perform.
    - **must_escalate**: Conditions under which human/system review is required.
    - **can_execute**: Actions explicitly permitted for the agent.

    Attributes:
        cannot_execute: List of forbidden action descriptions.
        must_escalate: List of escalation rules with conditions and targets.
        can_execute: List of explicitly allowed action descriptions.
        default_escalation: Fallback escalation target type when a
            must_escalate rule has no explicit target.
    """
    cannot_execute: list[str] = field(default_factory=list)
    must_escalate: list[EscalationRule] = field(default_factory=list)
    can_execute: list[str] = field(default_factory=list)
    default_escalation: str = "log"


# ---------------------------------------------------------------------------
# Reasoning configuration (v1.1+)
# ---------------------------------------------------------------------------

VALID_JUSTIFICATION_LEVELS = frozenset({"must_escalate", "cannot_execute", "can_execute"})
VALID_ON_MISSING_JUSTIFICATION = frozenset({"block", "escalate", "allow"})
VALID_ON_CHECK_ERROR = frozenset({"block", "escalate", "allow"})
VALID_ON_API_ERROR = frozenset({"block", "allow", "score_zero"})

# Mapping from unnumbered (legacy) check keys to canonical numbered keys.
# Both forms are accepted in constitutions; numbered is canonical internally.
_UNNUMBERED_TO_NUMBERED = {
    "glc_minimum_substance": "glc_002_minimum_substance",
    "glc_no_parroting": "glc_003_no_parroting",
    "glc_llm_coherence": "glc_005_llm_coherence",
}

# All known check keys (numbered canonical + unnumbered legacy)
_KNOWN_CHECK_KEYS = frozenset(_UNNUMBERED_TO_NUMBERED.keys()) | frozenset(_UNNUMBERED_TO_NUMBERED.values())


def _normalize_check_key(key: str) -> str:
    """Normalize a check key to its canonical numbered form."""
    return _UNNUMBERED_TO_NUMBERED.get(key, key)


@dataclass
class GLCCheckConfig:
    """Base configuration for a governance-level reasoning check."""
    enabled: bool = True


@dataclass
class GLCMinimumSubstanceConfig(GLCCheckConfig):
    """Config for minimum-substance reasoning check.

    Rejects justifications shorter than ``min_length`` characters.
    """
    min_length: int = 20


@dataclass
class GLCNoParrotingConfig(GLCCheckConfig):
    """Config for no-parroting reasoning check.

    Rejects justifications that contain any blocklisted phrase.
    """
    blocklist: list[str] = field(default_factory=lambda: [
        "because you asked",
        "you told me to",
        "you requested",
    ])


@dataclass
class GLCLLMCoherenceConfig(GLCCheckConfig):
    """Config for LLM coherence reasoning check.

    Uses an LLM judge to score justification quality.
    Only applied for actions matching ``enabled_for`` enforcement levels.
    """
    enabled_for: list[str] = field(default_factory=lambda: ["must_escalate"])
    timeout_ms: int = 2000
    score_threshold: float = 0.6
    judge_override: dict | None = None


@dataclass
class JudgeConfig:
    """Top-level judge configuration for reasoning evaluation.

    Attributes:
        default_provider: LLM provider to use (``"anthropic"``,
            ``"openai"``, ``"heuristic"``, or *None* for auto-detect).
        default_model: Model name for the judge.  *None* means the
            provider's default small/fast model.
        cross_provider: If *True*, the factory selects a different
            provider from the one the agent is using (if available).
    """
    default_provider: str | None = None
    default_model: str | None = None
    cross_provider: bool = False


@dataclass
class InvariantJudgeOverride:
    """Per-invariant judge overrides.

    Allows a specific invariant to use a different provider, model,
    or scrutiny level than the top-level judge config.

    Attributes:
        provider: Override provider for this invariant.
        model: Override model for this invariant.
        scrutiny: ``"standard"`` (default) or ``"thorough"``.
            Thorough uses a longer prompt that asks the model to
            enumerate logical steps before scoring.
    """
    provider: str | None = None
    model: str | None = None
    scrutiny: str = "standard"


VALID_SCRUTINY_LEVELS = frozenset({"standard", "thorough"})
VALID_JUDGE_PROVIDERS = frozenset({"anthropic", "openai", "heuristic"})


@dataclass
class ReasoningConfig:
    """Configuration for governance-level reasoning checks (v1.1+).

    Controls when and how agent justifications are evaluated before
    enforcement decisions (escalation, denial, etc.).

    Attributes:
        require_justification_for: Enforcement levels that require
            a justification string. Valid values: ``"must_escalate"``,
            ``"cannot_execute"``, ``"can_execute"``.
        on_missing_justification: Action when justification is missing
            for a required level. ``"block"`` (deny), ``"escalate"``
            (route to human), or ``"allow"`` (proceed anyway).
        on_check_error: Action when a reasoning check errors.
        on_api_error: Action when an LLM API call fails.
            ``"block"`` → score 0.0 (fail-closed), ``"allow"`` →
            score 1.0 (fail-open), ``"score_zero"`` → score 0.0
            (same as block, different receipt semantics).
        checks: Named check configs. Canonical (numbered) keys:
            ``"glc_002_minimum_substance"``, ``"glc_003_no_parroting"``,
            ``"glc_005_llm_coherence"``. Unnumbered legacy forms are
            also accepted and normalized on parse.
        judge: Optional judge configuration.  Controls which LLM
            provider, model, and cross-provider behavior to use.
        evaluate_before_escalation: If True, run reasoning checks
            before the escalation round-trip.
        auto_deny_on_reasoning_failure: If True, automatically deny
            tool calls when reasoning checks fail.
    """
    require_justification_for: list[str] = field(
        default_factory=lambda: ["must_escalate", "cannot_execute"],
    )
    on_missing_justification: str = "block"
    on_check_error: str = "block"
    on_api_error: str = "block"
    checks: dict[str, GLCCheckConfig] = field(default_factory=dict)
    judge: JudgeConfig | None = None
    evaluate_before_escalation: bool = True
    auto_deny_on_reasoning_failure: bool = False


@dataclass
class ApprovalRecord:
    """A single approval record binding an approver to a constitution version.

    The approval_signature is an Ed25519 signature over the canonical JSON
    of all fields EXCEPT approval_signature itself (same pattern as receipt
    signatures).  The content_hash binds the approval to the specific
    constitution content at the time of approval.
    """
    status: str               # "approved" | "pending" | "revoked"
    approver_id: str          # Email or identifier of approver
    approver_role: str        # Human-readable role: "VP Risk", "CISO", etc.
    approved_at: str          # ISO 8601 timestamp
    approval_signature: str   # Base64-encoded Ed25519 signature
    constitution_version: str # Human-readable version (e.g., "3", "1.2.0")
    content_hash: str         # SHA-256 hash of constitution content AT approval time
    previous_version_hash: Optional[str] = None  # Links to prior version


@dataclass
class ApprovalChain:
    """Chain of approval records for a constitution.

    v0.9.0 only writes/validates a single record.  The array structure
    is forward-compatible for multi-party chains in future versions.
    """
    records: list[ApprovalRecord] = field(default_factory=list)

    @property
    def current(self) -> Optional[ApprovalRecord]:
        """Most recent approval record."""
        return self.records[-1] if self.records else None

    @property
    def is_approved(self) -> bool:
        """True if most recent record has status 'approved'."""
        return self.current is not None and self.current.status == "approved"


@dataclass
class Constitution:
    schema_version: str
    identity: AgentIdentity
    provenance: Provenance
    boundaries: list[Boundary]
    trust_tiers: TrustTiers = field(default_factory=TrustTiers)
    halt_conditions: list[HaltCondition] = field(default_factory=list)
    invariants: list[Invariant] = field(default_factory=list)
    policy_hash: Optional[str] = None
    authority_boundaries: Optional[AuthorityBoundaries] = None
    trusted_sources: Optional[TrustedSources] = None
    approval: Optional[ApprovalChain] = None
    version: str = "1.0"
    reasoning: Optional[ReasoningConfig] = None


# =============================================================================
# VALIDATION
# =============================================================================

def validate_constitution_data(data: dict) -> list[str]:
    """Return list of error strings. Empty = valid."""
    errors: list[str] = []

    # Top-level required keys
    for key in ("identity", "provenance", "boundaries"):
        if key not in data:
            errors.append(f"Missing required field: {key}")
    if errors:
        return errors

    # Identity
    identity = data.get("identity", {})
    if not isinstance(identity, dict):
        errors.append("identity must be a dict")
    else:
        if not identity.get("agent_name"):
            errors.append("identity.agent_name is required")
        if not identity.get("domain"):
            errors.append("identity.domain is required")

    # Provenance
    prov = data.get("provenance", {})
    if not isinstance(prov, dict):
        errors.append("provenance must be a dict")
    else:
        if not prov.get("authored_by"):
            errors.append("provenance.authored_by is required")
        approved_by = prov.get("approved_by")
        if isinstance(approved_by, str):
            approved_by = [approved_by]
        if not approved_by or not isinstance(approved_by, list) or len(approved_by) == 0:
            errors.append("provenance.approved_by must have at least one entry")
        approval_date = prov.get("approval_date", "")
        if not approval_date:
            errors.append("provenance.approval_date is required")
        elif not _ISO8601_RE.match(str(approval_date)):
            errors.append(f"provenance.approval_date is not valid ISO 8601: {approval_date}")
        if not prov.get("approval_method"):
            errors.append("provenance.approval_method is required")

    # Boundaries
    boundaries = data.get("boundaries", [])
    if not isinstance(boundaries, list) or len(boundaries) == 0:
        errors.append("boundaries must contain at least one boundary")
    else:
        seen_bids: set[str] = set()
        for i, b in enumerate(boundaries):
            if not isinstance(b, dict):
                errors.append(f"boundaries[{i}] must be a dict")
                continue
            bid = b.get("id", "")
            if not _BOUNDARY_ID_RE.match(bid):
                errors.append(f"boundaries[{i}].id '{bid}' must match B### pattern")
            if bid in seen_bids:
                errors.append(f"Duplicate boundary ID: {bid}")
            seen_bids.add(bid)
            if not b.get("description"):
                errors.append(f"boundaries[{i}].description is required")
            cat = b.get("category", "")
            if cat not in VALID_CATEGORIES:
                errors.append(f"boundaries[{i}].category '{cat}' must be one of {sorted(VALID_CATEGORIES)}")
            sev = b.get("severity", "")
            if sev not in VALID_SEVERITIES:
                errors.append(f"boundaries[{i}].severity '{sev}' must be one of {sorted(VALID_SEVERITIES)}")

    # Halt conditions (optional)
    halt_conditions = data.get("halt_conditions", [])
    if isinstance(halt_conditions, list):
        seen_hids: set[str] = set()
        for i, h in enumerate(halt_conditions):
            if not isinstance(h, dict):
                errors.append(f"halt_conditions[{i}] must be a dict")
                continue
            hid = h.get("id", "")
            if not _HALT_ID_RE.match(hid):
                errors.append(f"halt_conditions[{i}].id '{hid}' must match H### pattern")
            if hid in seen_hids:
                errors.append(f"Duplicate halt condition ID: {hid}")
            seen_hids.add(hid)
            if not h.get("trigger"):
                errors.append(f"halt_conditions[{i}].trigger is required")
            if not h.get("escalate_to"):
                errors.append(f"halt_conditions[{i}].escalate_to is required")
            sev = h.get("severity", "")
            if sev not in VALID_SEVERITIES:
                errors.append(f"halt_conditions[{i}].severity '{sev}' must be one of {sorted(VALID_SEVERITIES)}")
            enf = h.get("enforcement", "")
            if enf not in VALID_ENFORCEMENT:
                errors.append(f"halt_conditions[{i}].enforcement '{enf}' must be one of {sorted(VALID_ENFORCEMENT)}")

    # Invariants (optional)
    invariants = data.get("invariants", [])
    if isinstance(invariants, list):
        seen_inv_ids: set[str] = set()
        for i, inv in enumerate(invariants):
            if not isinstance(inv, dict):
                errors.append(f"invariants[{i}] must be a dict")
                continue
            inv_id = inv.get("id", "")
            if not inv_id:
                errors.append(f"invariants[{i}].id is required")
            if inv_id in seen_inv_ids:
                errors.append(f"Duplicate invariant ID: {inv_id}")
            seen_inv_ids.add(inv_id)
            if not inv.get("rule"):
                errors.append(f"invariants[{i}].rule is required")
            enf = inv.get("enforcement", "")
            if enf not in VALID_ENFORCEMENT:
                errors.append(f"invariants[{i}].enforcement '{enf}' must be one of {sorted(VALID_ENFORCEMENT)}")

    # Authority boundaries (optional, v0.7.0+)
    ab = data.get("authority_boundaries")
    if ab is not None:
        if not isinstance(ab, dict):
            errors.append("authority_boundaries must be a dict")
        else:
            for key in ("cannot_execute", "can_execute"):
                val = ab.get(key, [])
                if not isinstance(val, list):
                    errors.append(f"authority_boundaries.{key} must be a list")
            must_esc = ab.get("must_escalate", [])
            if not isinstance(must_esc, list):
                errors.append("authority_boundaries.must_escalate must be a list")
            else:
                for i, rule in enumerate(must_esc):
                    if not isinstance(rule, dict):
                        errors.append(f"authority_boundaries.must_escalate[{i}] must be a dict")
                    elif not rule.get("condition"):
                        errors.append(f"authority_boundaries.must_escalate[{i}].condition is required")

    # Reasoning config (optional, v1.1+) — only validate when
    # sanna_constitution >= "1.1" (matches parse_constitution gating)
    reasoning = data.get("reasoning")
    schema_ver = str(data.get("sanna_constitution", data.get("schema_version", CONSTITUTION_SCHEMA_VERSION)))
    if reasoning is not None and schema_ver >= "1.1":
        if not isinstance(reasoning, dict):
            errors.append("reasoning must be a dict")
        else:
            errors.extend(_validate_reasoning_config(reasoning))

    return errors


def _validate_reasoning_config(reasoning: dict) -> list[str]:
    """Validate the reasoning configuration section. Returns error strings."""
    errors: list[str] = []

    # require_justification_for
    rjf = reasoning.get("require_justification_for")
    if rjf is not None:
        if not isinstance(rjf, list):
            errors.append("reasoning.require_justification_for must be a list")
        else:
            for i, level in enumerate(rjf):
                if level not in VALID_JUSTIFICATION_LEVELS:
                    errors.append(
                        f"reasoning.require_justification_for[{i}] "
                        f"'{level}' must be one of "
                        f"{sorted(VALID_JUSTIFICATION_LEVELS)}"
                    )

    # on_missing_justification
    omj = reasoning.get("on_missing_justification")
    if omj is not None and omj not in VALID_ON_MISSING_JUSTIFICATION:
        errors.append(
            f"reasoning.on_missing_justification '{omj}' must be one of "
            f"{sorted(VALID_ON_MISSING_JUSTIFICATION)}"
        )

    # on_check_error
    oce = reasoning.get("on_check_error")
    if oce is not None and oce not in VALID_ON_CHECK_ERROR:
        errors.append(
            f"reasoning.on_check_error '{oce}' must be one of "
            f"{sorted(VALID_ON_CHECK_ERROR)}"
        )

    # on_api_error
    oae = reasoning.get("on_api_error")
    if oae is not None and oae not in VALID_ON_API_ERROR:
        errors.append(
            f"reasoning.on_api_error '{oae}' must be one of "
            f"{sorted(VALID_ON_API_ERROR)}"
        )

    # judge config
    judge = reasoning.get("judge")
    if judge is not None:
        if not isinstance(judge, dict):
            errors.append("reasoning.judge must be a dict")
        else:
            dp = judge.get("default_provider")
            if dp is not None and dp not in VALID_JUDGE_PROVIDERS:
                errors.append(
                    f"reasoning.judge.default_provider '{dp}' must be one of "
                    f"{sorted(VALID_JUDGE_PROVIDERS)}"
                )
            cp = judge.get("cross_provider")
            if cp is not None and not isinstance(cp, bool):
                errors.append("reasoning.judge.cross_provider must be a boolean")

    # checks
    checks = reasoning.get("checks")
    if checks is not None:
        if not isinstance(checks, dict):
            errors.append("reasoning.checks must be a dict")
        else:
            for check_name, check_cfg in checks.items():
                if not isinstance(check_cfg, dict):
                    errors.append(f"reasoning.checks.{check_name} must be a dict")
                    continue
                errors.extend(
                    _validate_reasoning_check(check_name, check_cfg)
                )

    return errors


def _validate_reasoning_check(name: str, cfg: dict) -> list[str]:
    """Validate a single reasoning check config block.

    Accepts both numbered (``glc_002_minimum_substance``) and unnumbered
    (``glc_minimum_substance``) key forms.  Normalizes to numbered
    canonical form for matching.
    """
    errors: list[str] = []
    prefix = f"reasoning.checks.{name}"
    canonical = _normalize_check_key(name)

    # enabled must be bool if present
    if "enabled" in cfg and not isinstance(cfg["enabled"], bool):
        errors.append(f"{prefix}.enabled must be a boolean")

    if canonical == "glc_002_minimum_substance":
        ml = cfg.get("min_length")
        if ml is not None:
            if not isinstance(ml, int) or isinstance(ml, bool):
                errors.append(f"{prefix}.min_length must be an integer")
            elif ml <= 0:
                errors.append(f"{prefix}.min_length must be > 0, got {ml}")

    elif canonical == "glc_003_no_parroting":
        bl = cfg.get("blocklist")
        if bl is not None:
            if not isinstance(bl, list):
                errors.append(f"{prefix}.blocklist must be a list")
            else:
                for i, item in enumerate(bl):
                    if not isinstance(item, str) or not item.strip():
                        errors.append(
                            f"{prefix}.blocklist[{i}] must be a non-empty string"
                        )

    elif canonical == "glc_005_llm_coherence":
        ef = cfg.get("enabled_for")
        if ef is not None:
            if not isinstance(ef, list):
                errors.append(f"{prefix}.enabled_for must be a list")
            else:
                for i, level in enumerate(ef):
                    if level not in VALID_JUSTIFICATION_LEVELS:
                        errors.append(
                            f"{prefix}.enabled_for[{i}] '{level}' "
                            f"must be one of {sorted(VALID_JUSTIFICATION_LEVELS)}"
                        )

        st = cfg.get("score_threshold")
        if st is not None:
            if not isinstance(st, (int, float)):
                errors.append(f"{prefix}.score_threshold must be a number")
            elif not (0.0 <= float(st) <= 1.0):
                errors.append(
                    f"{prefix}.score_threshold must be 0.0-1.0, got {st}"
                )

        tm = cfg.get("timeout_ms")
        if tm is not None:
            if not isinstance(tm, int) or isinstance(tm, bool):
                errors.append(f"{prefix}.timeout_ms must be an integer")
            elif tm <= 0:
                errors.append(f"{prefix}.timeout_ms must be > 0, got {tm}")

        jo = cfg.get("judge_override")
        if jo is not None:
            if not isinstance(jo, dict):
                errors.append(f"{prefix}.judge_override must be a dict")
            else:
                jo_provider = jo.get("provider")
                if jo_provider is not None and jo_provider not in VALID_JUDGE_PROVIDERS:
                    errors.append(
                        f"{prefix}.judge_override.provider '{jo_provider}' "
                        f"must be one of {sorted(VALID_JUDGE_PROVIDERS)}"
                    )
                jo_scrutiny = jo.get("scrutiny")
                if jo_scrutiny is not None and jo_scrutiny not in VALID_SCRUTINY_LEVELS:
                    errors.append(
                        f"{prefix}.judge_override.scrutiny '{jo_scrutiny}' "
                        f"must be one of {sorted(VALID_SCRUTINY_LEVELS)}"
                    )

    return errors


# =============================================================================
# PARSING
# =============================================================================

def parse_constitution(data: dict) -> Constitution:
    """Parse from dict. Validates all fields."""
    errors = validate_constitution_data(data)
    if errors:
        raise ValueError(f"Invalid constitution: {'; '.join(errors)}")

    # Map sanna_constitution → schema_version
    schema_version = data.get("sanna_constitution", data.get("schema_version", CONSTITUTION_SCHEMA_VERSION))

    identity_data = data["identity"]
    _identity_known_keys = {"agent_name", "domain", "description"}

    # Parse identity_claims into structured IdentityClaim list
    _identity_claims: list[IdentityClaim] = []
    raw_claims = identity_data.get("identity_claims")
    if isinstance(raw_claims, list):
        for claim_data in raw_claims:
            if not isinstance(claim_data, dict):
                continue
            try:
                _identity_claims.append(IdentityClaim(
                    provider=claim_data.get("provider", ""),
                    claim_type=claim_data.get("claim_type", ""),
                    credential_id=claim_data.get("credential_id", ""),
                    issued_at=claim_data.get("issued_at", ""),
                    expires_at=claim_data.get("expires_at", ""),
                    signature=claim_data.get("signature", ""),
                    public_key_id=claim_data.get("public_key_id", ""),
                ))
            except (TypeError, KeyError):
                continue  # Malformed claim, skip gracefully

    identity = AgentIdentity(
        agent_name=identity_data["agent_name"],
        domain=identity_data["domain"],
        description=identity_data.get("description", ""),
        extensions={k: v for k, v in identity_data.items()
                    if k not in _identity_known_keys},
        identity_claims=_identity_claims,
    )

    prov_data = data["provenance"]
    approved_by = prov_data["approved_by"]
    if isinstance(approved_by, str):
        approved_by = [approved_by]

    # Parse nested signature block if present
    sig_data = prov_data.get("signature")
    prov_signature = None
    if isinstance(sig_data, dict):
        prov_signature = ConstitutionSignature(
            value=sig_data.get("value"),
            key_id=sig_data.get("key_id"),
            signed_by=sig_data.get("signed_by"),
            signed_at=sig_data.get("signed_at"),
            scheme=sig_data.get("scheme", "constitution_sig_v1"),
        )

    provenance = Provenance(
        authored_by=prov_data["authored_by"],
        approved_by=approved_by,
        approval_date=str(prov_data["approval_date"]),
        approval_method=prov_data["approval_method"],
        change_history=prov_data.get("change_history", []),
        signature=prov_signature,
    )

    boundaries = [
        Boundary(
            id=b["id"],
            description=b["description"],
            category=b["category"],
            severity=b["severity"],
        )
        for b in data["boundaries"]
    ]

    trust_data = data.get("trust_tiers", {})
    if isinstance(trust_data, dict):
        trust_tiers = TrustTiers(
            autonomous=trust_data.get("autonomous", []),
            requires_approval=trust_data.get("requires_approval", []),
            prohibited=trust_data.get("prohibited", []),
        )
    else:
        trust_tiers = TrustTiers()

    halt_conditions = [
        HaltCondition(
            id=h["id"],
            trigger=h["trigger"],
            escalate_to=h["escalate_to"],
            severity=h["severity"],
            enforcement=h["enforcement"],
        )
        for h in data.get("halt_conditions", [])
    ]

    invariants = [
        Invariant(
            id=inv["id"],
            rule=inv["rule"],
            enforcement=inv["enforcement"],
            check=inv.get("check"),
        )
        for inv in data.get("invariants", [])
    ]

    # Authority boundaries (optional, v0.7.0+)
    authority_boundaries = None
    ab_data = data.get("authority_boundaries")
    if ab_data is not None and isinstance(ab_data, dict):
        et_data = data.get("escalation_targets", {})
        default_esc = et_data.get("default", "log") if isinstance(et_data, dict) else "log"

        cannot_execute = ab_data.get("cannot_execute", [])
        can_execute = ab_data.get("can_execute", [])

        must_escalate = []
        for rule_data in ab_data.get("must_escalate", []):
            if not isinstance(rule_data, dict):
                continue
            target_data = rule_data.get("target")
            target = None
            if isinstance(target_data, dict):
                target = EscalationTargetConfig(
                    type=target_data.get("type", "log"),
                    url=target_data.get("url"),
                    handler=target_data.get("handler"),
                )
            must_escalate.append(EscalationRule(
                condition=rule_data.get("condition", ""),
                target=target,
            ))

        authority_boundaries = AuthorityBoundaries(
            cannot_execute=cannot_execute,
            must_escalate=must_escalate,
            can_execute=can_execute,
            default_escalation=default_esc,
        )

    # Trusted sources (optional, v0.7.0+)
    trusted_sources = None
    ts_data = data.get("trusted_sources")
    if ts_data is not None and isinstance(ts_data, dict):
        trusted_sources = TrustedSources(
            tier_1=ts_data.get("tier_1", []),
            tier_2=ts_data.get("tier_2", []),
            tier_3=ts_data.get("tier_3", []),
            untrusted=ts_data.get("untrusted", []),
        )

    # Approval chain (optional, v0.9.0+)
    approval = None
    approval_data = data.get("approval")
    if approval_data is not None and isinstance(approval_data, dict):
        records_data = approval_data.get("records", [])
        if isinstance(records_data, list):
            records = []
            for rec in records_data:
                if not isinstance(rec, dict):
                    continue
                status = rec.get("status", "")
                if status not in VALID_APPROVAL_STATUSES:
                    raise ValueError(
                        f"Invalid approval status: {status!r}. "
                        f"Must be one of {sorted(VALID_APPROVAL_STATUSES)}"
                    )
                records.append(ApprovalRecord(
                    status=status,
                    approver_id=rec.get("approver_id", ""),
                    approver_role=rec.get("approver_role", ""),
                    approved_at=rec.get("approved_at", ""),
                    approval_signature=rec.get("approval_signature", ""),
                    constitution_version=rec.get("constitution_version", ""),
                    content_hash=rec.get("content_hash", ""),
                    previous_version_hash=rec.get("previous_version_hash"),
                ))
            approval = ApprovalChain(records=records)

    # Version (optional, v1.1+) — defaults to "1.0" for backward compat
    version = str(data.get("version", "1.0"))

    # Reasoning config (optional, v1.1+)
    # Gate on sanna_constitution (not version) — sanna_constitution is the
    # schema format identifier that users set in YAML; version is for
    # internal schema evolution.
    reasoning = None
    reasoning_data = data.get("reasoning")
    if reasoning_data is not None and isinstance(reasoning_data, dict) and schema_version >= "1.1":
        reasoning = _parse_reasoning_config(reasoning_data)

    return Constitution(
        schema_version=str(schema_version),
        identity=identity,
        provenance=provenance,
        boundaries=boundaries,
        trust_tiers=trust_tiers,
        halt_conditions=halt_conditions,
        invariants=invariants,
        policy_hash=data.get("policy_hash"),
        authority_boundaries=authority_boundaries,
        trusted_sources=trusted_sources,
        approval=approval,
        version=version,
        reasoning=reasoning,
    )


def _parse_reasoning_check(name: str, cfg: dict) -> GLCCheckConfig:
    """Parse a single reasoning check config dict into the appropriate dataclass.

    Accepts both numbered and unnumbered key forms via normalization.
    """
    enabled = cfg.get("enabled", True)
    canonical = _normalize_check_key(name)

    if canonical == "glc_002_minimum_substance":
        return GLCMinimumSubstanceConfig(
            enabled=enabled,
            min_length=cfg.get("min_length", 20),
        )
    elif canonical == "glc_003_no_parroting":
        return GLCNoParrotingConfig(
            enabled=enabled,
            blocklist=cfg.get("blocklist", GLCNoParrotingConfig().blocklist),
        )
    elif canonical == "glc_005_llm_coherence":
        judge_override_raw = cfg.get("judge_override")
        judge_override = None
        if isinstance(judge_override_raw, dict):
            judge_override = judge_override_raw  # stored as dict, parsed on use
        return GLCLLMCoherenceConfig(
            enabled=enabled,
            enabled_for=cfg.get("enabled_for", ["must_escalate"]),
            timeout_ms=cfg.get("timeout_ms", 2000),
            score_threshold=cfg.get("score_threshold", 0.6),
            judge_override=judge_override,
        )
    else:
        # Unknown check type — store as base config
        return GLCCheckConfig(enabled=enabled)


def _parse_reasoning_config(data: dict) -> ReasoningConfig:
    """Parse a reasoning config dict into a ReasoningConfig dataclass.

    Check keys are normalized to canonical numbered form (e.g.,
    ``glc_minimum_substance`` → ``glc_002_minimum_substance``).
    """
    checks: dict[str, GLCCheckConfig] = {}
    checks_data = data.get("checks", {})
    if isinstance(checks_data, dict):
        for name, cfg in checks_data.items():
            if isinstance(cfg, dict):
                canonical = _normalize_check_key(name)
                checks[canonical] = _parse_reasoning_check(name, cfg)

    # Parse judge config
    judge_config = None
    judge_data = data.get("judge")
    if isinstance(judge_data, dict):
        judge_config = JudgeConfig(
            default_provider=judge_data.get("default_provider"),
            default_model=judge_data.get("default_model"),
            cross_provider=judge_data.get("cross_provider", False),
        )

    return ReasoningConfig(
        require_justification_for=data.get(
            "require_justification_for",
            ["must_escalate", "cannot_execute"],
        ),
        on_missing_justification=data.get("on_missing_justification", "block"),
        on_check_error=data.get("on_check_error", "block"),
        on_api_error=data.get("on_api_error", "block"),
        checks=checks,
        judge=judge_config,
        evaluate_before_escalation=data.get("evaluate_before_escalation", True),
        auto_deny_on_reasoning_failure=data.get("auto_deny_on_reasoning_failure", False),
    )


# =============================================================================
# HASHING & SIGNING
# =============================================================================

def _identity_dict(identity: AgentIdentity) -> dict:
    """Convert AgentIdentity to dict, flattening extensions into top level.

    Extensions are stored as a nested dict in the dataclass but represented
    as flat keys in YAML/JSON.  This function flattens them back so that:

    - Empty extensions → same dict as before (backward compat).
    - Non-empty extensions → flat keys alongside agent_name/domain/description,
      matching the original YAML representation.
    """
    d = {
        "agent_name": identity.agent_name,
        "domain": identity.domain,
        "description": identity.description,
    }
    if identity.extensions:
        d.update(identity.extensions)

    # Sync: if identity_claims were set programmatically but not
    # mirrored into extensions, include them for signing
    if identity.identity_claims and "identity_claims" not in d:
        d["identity_claims"] = [asdict(c) for c in identity.identity_claims]

    return d


def _reasoning_check_to_dict(name: str, check: GLCCheckConfig, *, for_signing: bool = False) -> dict:
    """Serialize a single reasoning check config to a plain dict.

    When ``for_signing=True``, float values are converted to integer
    basis points (0-10000) for deterministic Sanna Canonical JSON.
    """
    d = asdict(check)
    canonical = _normalize_check_key(name)
    if for_signing and canonical == "glc_005_llm_coherence" and isinstance(check, GLCLLMCoherenceConfig):
        d["score_threshold"] = int(round(check.score_threshold * 10000))
    return d


def _reasoning_config_to_dict(reasoning: ReasoningConfig, *, for_signing: bool = False) -> dict:
    """Serialize ReasoningConfig to a plain dict.

    When ``for_signing=True``, float values are converted to integer
    basis points for Sanna Canonical JSON compatibility (used by hashing and Ed25519
    signing). When ``False`` (default), floats are preserved (for YAML output).
    """
    checks_dict: dict[str, dict] = {}
    for name, check in reasoning.checks.items():
        checks_dict[name] = _reasoning_check_to_dict(name, check, for_signing=for_signing)
    d = {
        "require_justification_for": reasoning.require_justification_for,
        "on_missing_justification": reasoning.on_missing_justification,
        "on_check_error": reasoning.on_check_error,
        "on_api_error": reasoning.on_api_error,
        "checks": checks_dict,
        "evaluate_before_escalation": reasoning.evaluate_before_escalation,
        "auto_deny_on_reasoning_failure": reasoning.auto_deny_on_reasoning_failure,
    }
    if reasoning.judge is not None:
        d["judge"] = asdict(reasoning.judge)
    return d


def compute_constitution_hash(constitution: Constitution) -> str:
    """SHA-256 of canonical content. Returns FULL 64-character hex digest.

    Hash covers identity, boundaries, trust_tiers, halt_conditions
    but NOT provenance. Same policy = same hash regardless of who approved.

    Uses Sanna Canonical JSON — see spec/sanna-specification-v1.0.md
    """
    # Sort boundaries by ID for determinism
    boundaries = sorted(
        [asdict(b) for b in constitution.boundaries],
        key=lambda b: b["id"],
    )
    # Sort halt conditions by ID
    halt_conditions = sorted(
        [asdict(h) for h in constitution.halt_conditions],
        key=lambda h: h["id"],
    )
    # Sort trust tier lists alphabetically
    trust_tiers = {
        "autonomous": sorted(constitution.trust_tiers.autonomous),
        "requires_approval": sorted(constitution.trust_tiers.requires_approval),
        "prohibited": sorted(constitution.trust_tiers.prohibited),
    }

    invariants = sorted(
        [asdict(inv) for inv in constitution.invariants],
        key=lambda inv: inv["id"],
    )

    hashable = {
        "identity": _identity_dict(constitution.identity),
        "boundaries": boundaries,
        "trust_tiers": trust_tiers,
        "halt_conditions": halt_conditions,
        "invariants": invariants,
    }

    # Include authority_boundaries only if present (backward compat:
    # constitutions without this field produce the same hash as before)
    if constitution.authority_boundaries is not None:
        hashable["authority_boundaries"] = asdict(constitution.authority_boundaries)

    # Include trusted_sources only if present (backward compat)
    if constitution.trusted_sources is not None:
        hashable["trusted_sources"] = asdict(constitution.trusted_sources)

    # Include reasoning only if present (backward compat:
    # v1.0 constitutions without reasoning produce the same hash as before)
    if constitution.reasoning is not None:
        hashable["reasoning"] = _reasoning_config_to_dict(constitution.reasoning, for_signing=True)

    from .hashing import canonical_json_bytes
    return hashlib.sha256(canonical_json_bytes(hashable)).hexdigest()


def compute_content_hash(constitution: Constitution) -> str:
    """Compute SHA-256 hash of constitution content EXCLUDING the approval block.

    This is the machine-verifiable identifier used in approval records.
    It covers the same fields as ``compute_constitution_hash`` (identity,
    boundaries, trust_tiers, halt_conditions, invariants, authority_boundaries,
    trusted_sources) — content that defines governance rules.

    The content_hash is distinct from policy_hash only conceptually:
    policy_hash is set by ``sign_constitution()`` and is the same computation.
    ``compute_content_hash()`` is a semantic alias that makes the approval
    workflow's intent explicit.
    """
    return compute_constitution_hash(constitution)


def constitution_to_signable_dict(constitution: Constitution) -> dict:
    """Build the canonical dict that gets Ed25519-signed.

    Includes the full document (identity, provenance with signature block,
    boundaries, trust_tiers, halt_conditions, invariants, policy_hash).
    The only exclusion is ``provenance.signature.value`` which is set to
    ``""`` in the signable representation.
    """
    prov_dict = {
        "authored_by": constitution.provenance.authored_by,
        "approved_by": constitution.provenance.approved_by,
        "approval_date": constitution.provenance.approval_date,
        "approval_method": constitution.provenance.approval_method,
        "change_history": constitution.provenance.change_history,
    }

    # Include signature block with value blanked out
    sig = constitution.provenance.signature
    if sig is not None:
        prov_dict["signature"] = {
            "value": "",  # excluded from signing
            "key_id": sig.key_id,
            "signed_by": sig.signed_by,
            "signed_at": sig.signed_at,
            "scheme": sig.scheme,
        }
    else:
        prov_dict["signature"] = None

    result = {
        "schema_version": constitution.schema_version,
        "identity": _identity_dict(constitution.identity),
        "provenance": prov_dict,
        "boundaries": [asdict(b) for b in constitution.boundaries],
        "trust_tiers": asdict(constitution.trust_tiers),
        "halt_conditions": [asdict(h) for h in constitution.halt_conditions],
        "invariants": [asdict(inv) for inv in constitution.invariants],
        "policy_hash": constitution.policy_hash,
    }

    if constitution.authority_boundaries is not None:
        result["authority_boundaries"] = asdict(constitution.authority_boundaries)
        result["escalation_targets"] = {
            "default": constitution.authority_boundaries.default_escalation,
        }

    if constitution.trusted_sources is not None:
        result["trusted_sources"] = asdict(constitution.trusted_sources)

    # v1.1+: Include version and reasoning in signing material
    if constitution.version != "1.0":
        result["version"] = constitution.version
    if constitution.reasoning is not None:
        result["reasoning"] = _reasoning_config_to_dict(constitution.reasoning, for_signing=True)

    return result


def sign_constitution(
    constitution: Constitution,
    private_key_path: Optional[str] = None,
    signed_by: Optional[str] = None,
) -> Constitution:
    """Return new Constitution with policy_hash (and optional Ed25519 signature).

    Args:
        constitution: The constitution to sign.
        private_key_path: Optional path to Ed25519 private key for cryptographic signing.
        signed_by: Optional identity of the signer.
    """
    policy_hash = compute_constitution_hash(constitution)

    prov_signature = None

    if private_key_path is not None:
        from .crypto import sign_constitution_full
        # Build a constitution with policy_hash set but no signature yet,
        # so sign_constitution_full can build the signable dict.
        pre_signed = Constitution(
            schema_version=constitution.schema_version,
            identity=constitution.identity,
            provenance=Provenance(
                authored_by=constitution.provenance.authored_by,
                approved_by=constitution.provenance.approved_by,
                approval_date=constitution.provenance.approval_date,
                approval_method=constitution.provenance.approval_method,
                change_history=constitution.provenance.change_history,
                signature=None,  # will be filled in by sign_constitution_full
            ),
            boundaries=constitution.boundaries,
            trust_tiers=constitution.trust_tiers,
            halt_conditions=constitution.halt_conditions,
            invariants=constitution.invariants,
            policy_hash=policy_hash,
            authority_boundaries=constitution.authority_boundaries,
            trusted_sources=constitution.trusted_sources,
            approval=constitution.approval,
            version=constitution.version,
            reasoning=constitution.reasoning,
        )
        prov_signature = sign_constitution_full(pre_signed, private_key_path, signed_by=signed_by)

    return Constitution(
        schema_version=constitution.schema_version,
        identity=constitution.identity,
        provenance=Provenance(
            authored_by=constitution.provenance.authored_by,
            approved_by=constitution.provenance.approved_by,
            approval_date=constitution.provenance.approval_date,
            approval_method=constitution.provenance.approval_method,
            change_history=constitution.provenance.change_history,
            signature=prov_signature,
        ),
        boundaries=constitution.boundaries,
        trust_tiers=constitution.trust_tiers,
        halt_conditions=constitution.halt_conditions,
        invariants=constitution.invariants,
        policy_hash=policy_hash,
        authority_boundaries=constitution.authority_boundaries,
        trusted_sources=constitution.trusted_sources,
        approval=constitution.approval,
        version=constitution.version,
        reasoning=constitution.reasoning,
    )


# =============================================================================
# APPROVAL
# =============================================================================

def _approval_record_to_signable_dict(record: ApprovalRecord) -> dict:
    """Build the canonical dict that gets Ed25519-signed for an approval.

    Includes all ApprovalRecord fields EXCEPT approval_signature (set to "").
    """
    d = {
        "status": record.status,
        "approver_id": record.approver_id,
        "approver_role": record.approver_role,
        "approved_at": record.approved_at,
        "approval_signature": "",  # excluded from signing
        "constitution_version": record.constitution_version,
        "content_hash": record.content_hash,
    }
    if record.previous_version_hash is not None:
        d["previous_version_hash"] = record.previous_version_hash
    return d


def _claim_to_signable_dict(claim: IdentityClaim) -> dict:
    """Build canonical dict for identity claim signature verification.

    Canonical signing rule for identity claims:
    - Always includes: provider, claim_type, credential_id, issued_at,
      public_key_id
    - Includes expires_at ONLY if present (non-empty string)
    - Sets signature to "" (excluded from signing, same pattern as
      constitution and approval signatures)
    - Keys are sorted for canonical JSON serialization

    External verifiers MUST follow this exact rule: omit expires_at
    when the original claim has no expiry, include it when it does.
    """
    d: dict = {
        "provider": claim.provider,
        "claim_type": claim.claim_type,
        "credential_id": claim.credential_id,
        "issued_at": claim.issued_at,
        "signature": "",  # excluded from signing
        "public_key_id": claim.public_key_id,
    }
    if claim.expires_at:
        d["expires_at"] = claim.expires_at
    return d


def verify_identity_claims(
    identity: AgentIdentity,
    provider_keys: dict[str, str] | None = None,
) -> IdentityVerificationSummary:
    """Verify identity claims on an AgentIdentity.

    Args:
        identity: The AgentIdentity with claims to verify.
        provider_keys: Mapping of public_key_id to path to public key file.
            If None or empty, all claims with signatures get "no_key" status.

    Returns:
        IdentityVerificationSummary with per-claim results.
    """
    from .crypto import verify_signature, load_public_key
    from .hashing import canonical_json_bytes

    if not identity.identity_claims:
        return IdentityVerificationSummary(
            results=[],
            all_verified=True,
            total_claims=0,
            verified_count=0,
            failed_count=0,
            unverified_count=0,
        )

    provider_keys = provider_keys or {}
    results: list[IdentityVerificationResult] = []

    for claim in identity.identity_claims:
        # 1. No signature → unverified
        if not claim.signature:
            results.append(IdentityVerificationResult(
                claim=claim,
                status="unverified",
                detail="No signature on claim",
            ))
            continue

        # 2. No matching key → no_key
        if claim.public_key_id not in provider_keys:
            results.append(IdentityVerificationResult(
                claim=claim,
                status="no_key",
                detail=f"No public key available for provider key '{claim.public_key_id}'",
            ))
            continue

        # 3. Check expiry (before signature verification)
        if claim.expires_at:
            try:
                exp_str = claim.expires_at.replace("Z", "+00:00")
                expires = datetime.fromisoformat(exp_str)
                if expires.tzinfo is None:
                    expires = expires.replace(tzinfo=timezone.utc)
                now = datetime.now(timezone.utc)
                if now > expires:
                    results.append(IdentityVerificationResult(
                        claim=claim,
                        status="expired",
                        detail=f"Claim expired at {claim.expires_at}",
                    ))
                    continue
            except (ValueError, TypeError):
                results.append(IdentityVerificationResult(
                    claim=claim,
                    status="failed",
                    detail=f"Invalid expires_at format: {claim.expires_at!r}",
                ))
                continue

        # 4. Verify signature
        try:
            pub_key = load_public_key(provider_keys[claim.public_key_id])
            signable = _claim_to_signable_dict(claim)
            data = canonical_json_bytes(signable)
            valid = verify_signature(data, claim.signature, pub_key)
            if valid:
                results.append(IdentityVerificationResult(
                    claim=claim,
                    status="verified",
                    detail="Signature verified",
                ))
            else:
                results.append(IdentityVerificationResult(
                    claim=claim,
                    status="failed",
                    detail="Signature verification failed",
                ))
        except Exception as exc:
            results.append(IdentityVerificationResult(
                claim=claim,
                status="failed",
                detail=f"Signature verification error: {exc}",
            ))

    verified = sum(1 for r in results if r.status == "verified")
    failed = sum(1 for r in results if r.status == "failed")
    unverified = sum(1 for r in results if r.status in ("unverified", "no_key", "expired"))

    return IdentityVerificationSummary(
        results=results,
        all_verified=len(results) > 0 and verified == len(results),
        total_claims=len(results),
        verified_count=verified,
        failed_count=failed,
        unverified_count=unverified,
    )


def approve_constitution(
    constitution_path: Path | str,
    approver_private_key: bytes | str | Path,
    approver_id: str,
    approver_role: str,
    constitution_version: str,
    author_public_key_path: str | Path | None = None,
    verify_author_sig: bool = True,
) -> ApprovalRecord:
    """Approve a constitution by signing it with the approver's key.

    Args:
        constitution_path: Path to the constitution YAML/JSON file.
        approver_private_key: Path to Ed25519 private key PEM file.
        approver_id: Email or identifier of the approver.
        approver_role: Human-readable role (e.g., "VP Risk").
        constitution_version: Human-readable version string.
        author_public_key_path: Path to author's Ed25519 public key for
            signature verification. Required when verify_author_sig is True.
        verify_author_sig: If True (default), cryptographically verify the
            author's signature before writing the approval record.

    Returns:
        The ApprovalRecord that was created and embedded in the constitution.

    Raises:
        SannaConstitutionError: If constitution is not signed by author,
            or if signature verification fails.
        FileNotFoundError: If constitution or key file not found.
    """
    import warnings
    from .crypto import (
        load_private_key, compute_key_id, sign_bytes,
    )
    from .hashing import canonical_json_bytes

    constitution_path = Path(constitution_path)
    approver_private_key = Path(approver_private_key)

    # 1. Load constitution
    constitution = load_constitution(str(constitution_path))

    # 2. Verify author signature exists
    sig = constitution.provenance.signature
    if sig is None or not sig.value:
        raise SannaConstitutionError(
            "Constitution must be signed before approval. "
            "Run `sanna-sign-constitution` first."
        )

    # 2b. Cryptographic verification of author signature (HIGH-06)
    if verify_author_sig:
        if not author_public_key_path:
            raise SannaConstitutionError(
                "Author public key required for signature verification. "
                "Provide author_public_key_path or pass verify_author_sig=False."
            )
        from .crypto import verify_constitution_full
        if not verify_constitution_full(constitution, str(author_public_key_path)):
            raise SannaConstitutionError(
                "Author signature verification failed. The constitution "
                "may have been tampered with since signing."
            )
    else:
        warnings.warn(
            "Approving constitution without verifying author signature "
            "(verify_author_sig=False). For production governance, always "
            "verify before approving.",
            stacklevel=2,
        )

    # 3. Load approver key
    private_key = load_private_key(str(approver_private_key))
    public_key = private_key.public_key()
    approver_key_id = compute_key_id(public_key)

    # 4. Warn if same key for author and approver
    if sig.key_id and sig.key_id == approver_key_id:
        warnings.warn(
            "Author and approver keys are identical. For production governance, "
            "use separate keys for author and approver roles.",
            stacklevel=2,
        )

    # 5. Compute content hash (excludes approval block)
    content_hash = compute_content_hash(constitution)

    # 6. Determine previous_version_hash
    previous_version_hash = None
    if constitution.approval is not None and constitution.approval.current is not None:
        previous_version_hash = constitution.approval.current.content_hash
        warnings.warn(
            "Overwriting existing approval record.",
            stacklevel=2,
        )

    # 7. Build approval record (without signature yet)
    approved_at = datetime.now(timezone.utc).isoformat()
    record = ApprovalRecord(
        status="approved",
        approver_id=approver_id,
        approver_role=approver_role,
        approved_at=approved_at,
        approval_signature="",  # placeholder
        constitution_version=constitution_version,
        content_hash=content_hash,
        previous_version_hash=previous_version_hash,
    )

    # 8. Sign the approval record
    signable_dict = _approval_record_to_signable_dict(record)
    data = canonical_json_bytes(signable_dict)
    signature_b64 = sign_bytes(data, private_key)
    record.approval_signature = signature_b64

    # 9. Create/replace approval chain
    approval_chain = ApprovalChain(records=[record])

    # 10. Build updated constitution with approval
    updated = Constitution(
        schema_version=constitution.schema_version,
        identity=constitution.identity,
        provenance=constitution.provenance,
        boundaries=constitution.boundaries,
        trust_tiers=constitution.trust_tiers,
        halt_conditions=constitution.halt_conditions,
        invariants=constitution.invariants,
        policy_hash=constitution.policy_hash,
        authority_boundaries=constitution.authority_boundaries,
        trusted_sources=constitution.trusted_sources,
        approval=approval_chain,
        version=constitution.version,
        reasoning=constitution.reasoning,
    )

    # 11. Write back
    save_constitution(updated, constitution_path)

    return record


# =============================================================================
# RECEIPT BINDING
# =============================================================================

def constitution_to_receipt_ref(constitution: Constitution) -> dict:
    """Convert signed constitution to receipt's constitution_ref format.

    Raises ValueError if not signed (no policy_hash).
    """
    if not constitution.policy_hash:
        raise ValueError("Constitution must be signed before binding to a receipt. Call sign_constitution() first.")

    ref = {
        "document_id": f"{constitution.identity.agent_name}/{constitution.schema_version}",
        "policy_hash": constitution.policy_hash,
        "version": constitution.schema_version,
        "approved_by": constitution.provenance.approved_by,
        "approval_date": constitution.provenance.approval_date,
        "approval_method": constitution.provenance.approval_method,
    }
    sig = constitution.provenance.signature
    if sig is not None and sig.value is not None:
        ref["signature"] = sig.value
        ref["key_id"] = sig.key_id
        ref["signed_by"] = sig.signed_by
        ref["signed_at"] = sig.signed_at
        ref["scheme"] = sig.scheme

    # v0.9.0: Always include constitution_approval in receipt binding.
    # When approved, include full record. When not, mark as unapproved.
    if constitution.approval is not None and constitution.approval.current is not None:
        rec = constitution.approval.current
        ref["constitution_approval"] = {
            "status": rec.status,
            "approver_id": rec.approver_id,
            "approver_role": rec.approver_role,
            "approved_at": rec.approved_at,
            "constitution_version": rec.constitution_version,
            "content_hash": rec.content_hash,
        }
    else:
        ref["constitution_approval"] = {"status": "unapproved"}

    return ref


# =============================================================================
# FILE I/O
# =============================================================================

def load_constitution(path: str | Path, validate: bool = False) -> Constitution:
    """Load from .yaml/.yml/.json file. Validates on load.

    Args:
        path: Path to constitution file.
        validate: If True, validate against the JSON schema before parsing.
            Raises SannaConstitutionError on schema violation.
    """
    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(f"Constitution file not found: {path}")

    with open(path) as f:
        if path.suffix in (".yaml", ".yml"):
            from .utils.safe_yaml import safe_yaml_load
            data = safe_yaml_load(f)
        elif path.suffix == ".json":
            from .utils.safe_json import safe_json_load
            data = safe_json_load(f)
        else:
            raise ValueError(f"Unsupported file format: {path.suffix} (use .yaml, .yml, or .json)")

    if not isinstance(data, dict):
        raise ValueError(f"Constitution file must contain a YAML/JSON object, got {type(data).__name__}")

    if validate:
        schema_errors = validate_against_schema(data)
        if schema_errors:
            raise SannaConstitutionError(
                f"Constitution schema validation failed: {'; '.join(schema_errors)}"
            )

    constitution = parse_constitution(data)

    # Verify hash integrity if constitution is signed
    if constitution.policy_hash:
        computed = compute_constitution_hash(constitution)
        if computed != constitution.policy_hash:
            raise SannaConstitutionError(
                f"Constitution hash mismatch: file has been modified since signing. "
                f"Expected {constitution.policy_hash[:16]}..., got {computed[:16]}... "
                f"Re-sign with: sanna-sign-constitution {path}"
            )

    return constitution


def validate_against_schema(data: dict) -> list[str]:
    """Validate a constitution data dict against the JSON schema.

    Returns list of error strings. Empty list = valid.
    """
    from jsonschema import validate as jschema_validate, ValidationError

    schema_path = Path(__file__).parent / "spec" / "constitution.schema.json"
    with open(schema_path) as f:
        schema = json.load(f)

    errors = []
    try:
        jschema_validate(data, schema)
    except ValidationError as e:
        msg = f"Schema validation failed: {e.message}"
        if e.path:
            msg += f" (at {'.'.join(str(p) for p in e.path)})"
        errors.append(msg)
    return errors


def constitution_to_dict(constitution: Constitution) -> dict:
    """Serialize for YAML/JSON output.

    Reverses the mapping: Constitution.schema_version -> 'sanna_constitution' key.
    """
    result = {"sanna_constitution": constitution.schema_version}
    result["identity"] = _identity_dict(constitution.identity)

    # Build provenance dict with nested signature block
    prov_dict = {
        "authored_by": constitution.provenance.authored_by,
        "approved_by": constitution.provenance.approved_by,
        "approval_date": constitution.provenance.approval_date,
        "approval_method": constitution.provenance.approval_method,
        "change_history": constitution.provenance.change_history,
    }
    sig = constitution.provenance.signature
    if sig is not None:
        prov_dict["signature"] = {
            "value": sig.value,
            "key_id": sig.key_id,
            "signed_by": sig.signed_by,
            "signed_at": sig.signed_at,
            "scheme": sig.scheme,
        }
    result["provenance"] = prov_dict

    result["boundaries"] = [asdict(b) for b in constitution.boundaries]
    result["trust_tiers"] = asdict(constitution.trust_tiers)
    result["halt_conditions"] = [asdict(h) for h in constitution.halt_conditions]
    if constitution.invariants:
        result["invariants"] = [asdict(inv) for inv in constitution.invariants]

    if constitution.authority_boundaries is not None:
        ab = constitution.authority_boundaries
        must_escalate_list = []
        for rule in ab.must_escalate:
            rd: dict = {"condition": rule.condition}
            if rule.target is not None:
                rd["target"] = asdict(rule.target)
            must_escalate_list.append(rd)
        result["authority_boundaries"] = {
            "cannot_execute": ab.cannot_execute,
            "must_escalate": must_escalate_list,
            "can_execute": ab.can_execute,
        }
        result["escalation_targets"] = {"default": ab.default_escalation}

    if constitution.trusted_sources is not None:
        result["trusted_sources"] = asdict(constitution.trusted_sources)

    result["policy_hash"] = constitution.policy_hash

    # v1.1+: version and reasoning
    if constitution.version != "1.0":
        result["version"] = constitution.version
    if constitution.reasoning is not None:
        result["reasoning"] = _reasoning_config_to_dict(constitution.reasoning)

    # Approval chain (v0.9.0+) — included in serialization but NOT in
    # policy_hash or content_hash computation.
    if constitution.approval is not None:
        records = []
        for rec in constitution.approval.records:
            rec_dict = {
                "status": rec.status,
                "approver_id": rec.approver_id,
                "approver_role": rec.approver_role,
                "approved_at": rec.approved_at,
                "approval_signature": rec.approval_signature,
                "constitution_version": rec.constitution_version,
                "content_hash": rec.content_hash,
            }
            if rec.previous_version_hash is not None:
                rec_dict["previous_version_hash"] = rec.previous_version_hash
            records.append(rec_dict)
        result["approval"] = {"records": records}

    return result


def save_constitution(constitution: Constitution, path: str | Path) -> Path:
    """Save to .yaml/.yml/.json file (atomic write)."""
    from .utils.safe_io import atomic_write_text_sync

    path = Path(path)
    data = constitution_to_dict(constitution)

    if path.suffix in (".yaml", ".yml"):
        import yaml
        text = yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)
    elif path.suffix == ".json":
        text = json.dumps(data, indent=2)
    else:
        raise ValueError(f"Unsupported file format: {path.suffix}")

    atomic_write_text_sync(path, text)
    return path


# =============================================================================
# SCAFFOLDING
# =============================================================================

_SCAFFOLD_TEMPLATE = """\
# Sanna Constitution — Agent Governance Document
#
# This file defines the boundaries, trust tiers, and halt conditions
# for your AI agent. It answers: "Who defined these boundaries, and what are they?"
#
# Workflow:
#   1. Edit this file with your agent's specific constraints
#   2. Get approval from your compliance/risk team
#   3. Sign it:  sanna-sign-constitution constitution.yaml --private-key <your-key-id>.key
#   4. Wire it into your agent:
#        @sanna_observe(constitution_path="constitution.yaml")

sanna_constitution: "0.1.0"

identity:
  agent_name: "my-agent"            # Unique name for your agent
  domain: "my-domain"               # Business domain (e.g., "customer-service", "finance")
  description: ""                   # Optional description of what this agent does

provenance:
  authored_by: "you@company.com"    # Who wrote this constitution
  approved_by:                      # Who approved it (at least one)
    - "approver@company.com"
  approval_date: "{today}"          # When it was approved (ISO 8601)
  approval_method: "manual-sign-off"  # How it was approved
  change_history: []                # Version history (populated over time)

boundaries:
  # Each boundary defines a constraint the agent must operate within.
  # Categories: scope, authorization, confidentiality, safety, compliance, custom
  # Severities: critical, high, medium, low, info
  - id: "B001"
    description: "Only answer questions within the defined domain"
    category: "scope"
    severity: "high"

trust_tiers:
  autonomous:                       # Actions the agent can take without approval
    - "Answer domain questions"
  requires_approval:                # Actions needing human approval
    - "Escalate to specialist"
  prohibited:                       # Actions the agent must never take
    - "Make binding commitments"

halt_conditions:
  # Each halt condition defines when the agent should stop.
  # Enforcement: halt (stop execution), warn (continue with warning), log (record only)
  - id: "H001"
    trigger: "Agent contradicts verified information"
    escalate_to: "team-lead@company.com"
    severity: "critical"
    enforcement: "halt"

invariants:
  # Each invariant maps to a coherence check.
  # Standard IDs: INV_NO_FABRICATION, INV_MARK_INFERENCE, INV_NO_FALSE_CERTAINTY,
  #               INV_PRESERVE_TENSION, INV_NO_PREMATURE_COMPRESSION
  # Custom IDs:   INV_CUSTOM_* (appear in receipt as NOT_CHECKED)
  # Enforcement:  halt (stop execution), warn (continue with warning), log (record only)
  - id: "INV_NO_FABRICATION"
    rule: "Do not claim facts absent from provided sources."
    enforcement: "halt"
  - id: "INV_MARK_INFERENCE"
    rule: "Clearly mark inferences and speculation as such."
    enforcement: "warn"

# This field is set by `sanna-sign-constitution` — do not edit manually:
policy_hash: null
"""


def scaffold_constitution(output_path: str | Path | None = None) -> str:
    """Generate a scaffold constitution YAML with inline documentation.

    If output_path provided, write to file. Returns content string.
    """
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    content = _SCAFFOLD_TEMPLATE.replace("{today}", today)

    if output_path is not None:
        from .utils.safe_io import atomic_write_text_sync
        atomic_write_text_sync(Path(output_path), content)

    return content
