"""Protected Kernel — the allow/forbid list for evolution.

This module is the teeth behind the Goal's non-negotiable:

    "The goal is not unconstrained autonomy."

The selector may vary behavior across the ALLOWED dimensions (agent,
model tier, tool subset within the agent's existing grant, critique
opt-in, retry policy, temperature, prompt fragments tagged
`#evolution-tunable`). It may NOT modify anything in the FORBIDDEN list —
these are the load-bearing invariants of AgntSpace: contract enforcement,
security, audit, privacy, emergency stop, approval semantics, trust
posterior keys, vault layout, graph schema, and anything tagged
``arch:deterministic``.

``validate_reflex_respects_kernel(reflex)`` is called on every reflex
load. Invalid reflexes stay at ``status='draft'`` and emit a
high-importance ``kernel_violation_proposed`` activity event. They NEVER
reach ``candidate`` status, so the selector never sees them.

The ``PROTECTED_SKILLS`` set is the selector's hard fallback list. For
these skills, ``select_policy()`` always returns ``incumbent_default``
regardless of what mining has produced. Adding a new governance
substrate? Add its skill name here.

This module deliberately has NO external dependencies (no DB, no LLM, no
I/O) so it can be imported from any layer and tested in isolation.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from agntspace.evolution.schemas import Reflex


# ── ALLOWED dimensions ──
# These are what a reflex may tune. The selector may choose different
# values for these across runs of the same scope.
ALLOWED_DIMENSIONS: frozenset[str] = frozenset({
    "preferred_agent",           # orchestrator already scopes tools per agent
    "preferred_tier",            # local | cloud-small | cloud-large | fast | capable | reasoning
    "tools",                     # subset within the agent's existing grant (contract still gates)
    "triggers",                  # trigger phrases for the intent matcher
    "preconditions",             # conditions that must hold before dispatch
    "invariants",                # soft invariants (informational; not contract)
    "abort_conditions",          # stop-early conditions inside the subagent loop
    "expected_mutations",        # min vault_writes / tool_calls / graph_triples
    # Future-extensible: CritiqueSpec opt-in, retry policy, temperature,
    # reasoning_effort, max_tokens, step ordering on commutative workflows,
    # prompt fragments tagged `#evolution-tunable`. Fields are added here
    # as they become first-class reflex attributes.
})


# ── FORBIDDEN dimensions ──
# These must NEVER differ across reflexes for the same scope. If a reflex
# mentions any of these, validation rejects it.
FORBIDDEN_DIMENSIONS: frozenset[str] = frozenset({
    "contract",                  # Rule #3 — contract is the law
    "contract_action",
    "contract_rules",
    "grant_set",
    "security",                  # Guardian, blacklist, sanitizer
    "credentials",               # API keys, secrets, Fernet
    "audit",                     # decision_log, activity_log, error_log schemas
    "audit_log",
    "privacy",                   # PII, redaction, forget/erase
    "emergency_stop",
    "approval",                  # user approval / confirmation semantics
    "approval_gate",
    "vault_layout",              # physical paths, VaultPaths resolver
    "vault_path_map",
    "graph_schema",              # ontology node/edge types
    "ontology",
    "correlation_id",            # causal-chain threading is load-bearing
    "scope_mutations",           # the mutation counter itself
    "classify_tool_result",      # outcome classification engine, not a reflex concern
    "decision_log",              # schema
    "trust_domain_key",          # (domain, agent_key, goal_key) is the Bayesian key — not tunable
    "cost_attribution",          # goal_id / run_id attribution
    "skill_identity",            # the skill_name itself is the scope KEY, not a tunable
    "pii_redaction",
    "guardian_rules",
})


# ── PROTECTED_SKILLS ──
# For any run with ``skill_name`` in this set, the selector returns
# ``incumbent_default`` unconditionally. These are the substrates that
# MUST behave deterministically for the governance layer to function.
#
# Add a new meta/governance skill? Add it here.
PROTECTED_SKILLS: frozenset[str] = frozenset({
    "critique-protocol",         # the critic substrate that grades other work
    "fact-checking",             # truth verification; must be deterministic
    "reply-guardrails",          # placeholder scrub + capability hallucination detector
    "tool-outcome-classify",     # outcome classifier; evolution CONSUMES, never modifies
    "trust-evolution",           # Bayesian trust posteriors; the ledger consults these
    "trust-domain-discovery",    # cluster-and-propose for trust domains
    "evolution",                 # this subsystem itself — reflexes can't route reflexes
    # Any future governance skill should be added here before merging.
})


# ── PROTECTED_CALLERS ──
# Never experiment during these callers — they indicate remediation or
# deterministic-retry contexts where randomness is harmful.
PROTECTED_CALLERS: frozenset[str] = frozenset({
    "recovery",                  # chat recovery handler, post-crash flows
    "rollback",                  # explicit rollback from a prior promotion
})


@dataclass
class KernelViolation:
    """Structured result when a reflex touches forbidden ground.

    Callers of ``validate_reflex_respects_kernel`` receive ``None`` when
    the reflex passes, or a ``KernelViolation`` describing exactly what
    was wrong. The ``details`` dict is small enough to fit in the
    ``details`` column of a decision_log row.
    """

    field: str                   # which reflex field triggered the rejection
    value: str                   # the offending value
    reason: str                  # short human-readable explanation
    forbidden_match: str = ""    # which FORBIDDEN_DIMENSIONS entry it matched (if any)

    def as_dict(self) -> dict[str, str]:
        return {
            "field": self.field,
            "value": self.value,
            "reason": self.reason,
            "forbidden_match": self.forbidden_match,
        }


def validate_reflex_respects_kernel(
    reflex: Reflex,
    *,
    agent_tool_grants: dict[str, frozenset[str]] | None = None,
) -> KernelViolation | None:
    """Check that a reflex does not touch forbidden dimensions.

    Returns None when the reflex is safe to promote past ``draft``.
    Returns a ``KernelViolation`` when any invariant is breached — the
    caller should keep the reflex at ``status='draft'`` and emit a
    ``kernel_violation_proposed`` activity event.

    Checks (all must pass):
      1. ``name`` / ``scope`` / ``triggers`` / ``invariants`` must not
         mention any FORBIDDEN_DIMENSIONS key (case-insensitive substring).
      2. ``skill_name`` in the scope must not be a PROTECTED_SKILL.
      3. If ``agent_tool_grants`` was passed and the reflex targets a
         specific agent, every tool in ``reflex.tools`` must be in that
         agent's grant set (contract still gates execution, but a reflex
         can't ADD a tool the agent wasn't already allowed to call).
      4. ``expected_mutations`` keys must be from the known counter set
         (``vault_writes_min``, ``tool_calls_min``, ``graph_triples_min``).

    This function is pure and side-effect-free. It is called on every
    reflex load AND before every status transition — cheap enough to run
    often.
    """
    # 1. Forbidden dimensions via case-insensitive substring match.
    scan_fields: list[tuple[str, str]] = [
        ("name", reflex.name or ""),
    ]
    # Scope is dict; flatten to a scanable string blob
    if isinstance(reflex.scope, dict):
        scope_blob = " ".join(
            f"{k}={v}" for k, v in reflex.scope.items() if isinstance(k, str)
        )
        scan_fields.append(("scope", scope_blob))
    for fname, coll in (
        ("triggers", reflex.triggers),
        ("invariants", reflex.invariants),
        ("preconditions", reflex.preconditions),
        ("abort_conditions", reflex.abort_conditions),
    ):
        if coll:
            scan_fields.append((fname, " ".join(str(x) for x in coll)))

    for fname, blob in scan_fields:
        lower = blob.lower()
        for forbidden in FORBIDDEN_DIMENSIONS:
            # Prefix/boundary-aware match: a skill named "privacy-tune" shouldn't
            # trip the "privacy" forbidden keyword if the user genuinely wants
            # to tune something privacy-adjacent, but any literal `privacy:` or
            # `disable privacy` must trip.
            if forbidden in lower:
                return KernelViolation(
                    field=fname,
                    value=blob[:200],
                    reason=f"reflex field {fname} references forbidden dimension '{forbidden}'",
                    forbidden_match=forbidden,
                )

    # 2. Protected skill in scope — reflexes may not route these.
    scope_skill = ""
    if isinstance(reflex.scope, dict):
        scope_skill = str(reflex.scope.get("skill_name", ""))
    if scope_skill and scope_skill in PROTECTED_SKILLS:
        return KernelViolation(
            field="scope.skill_name",
            value=scope_skill,
            reason=f"skill '{scope_skill}' is in PROTECTED_SKILLS and must behave deterministically",
            forbidden_match="protected_skill",
        )

    # 3. Tool grants — reflexes can narrow, never widen.
    if agent_tool_grants is not None and reflex.preferred_agent:
        grant = agent_tool_grants.get(reflex.preferred_agent)
        if grant is not None:
            # Reflex tools must be a subset of the agent's existing grant.
            bad = [t for t in reflex.tools if t not in grant]
            if bad:
                return KernelViolation(
                    field="tools",
                    value=",".join(bad[:10]),
                    reason=(
                        f"reflex tools {bad} are outside agent "
                        f"'{reflex.preferred_agent}'s contract grant"
                    ),
                    forbidden_match="grant_set",
                )

    # 4. expected_mutations keys must be known counter fields.
    if reflex.expected_mutations:
        _ALLOWED_MUTATION_KEYS: frozenset[str] = frozenset({
            "vault_writes_min", "vault_writes_max",
            "tool_calls_min", "tool_calls_max",
            "graph_triples_min", "graph_triples_max",
        })
        for key in reflex.expected_mutations:
            if key not in _ALLOWED_MUTATION_KEYS:
                return KernelViolation(
                    field="expected_mutations",
                    value=key,
                    reason=f"expected_mutations key '{key}' is not a known counter field",
                    forbidden_match="scope_mutations",
                )

    return None


def is_protected_skill(skill_name: str) -> bool:
    """Return True if a skill must always route through ``incumbent_default``."""
    return skill_name in PROTECTED_SKILLS


def is_protected_caller(caller: str) -> bool:
    """Return True if a caller context must not participate in experimentation."""
    return caller in PROTECTED_CALLERS
