#!/usr/bin/env python3
"""Visible D3D-002 lab consistency checker.

CHECK_RULES.json is the visible rule source. This checker validates only
consistency/evidence structure. It does not infer provenance, choose a learner
decision, or contain a hidden baseline/transfer answer.
"""
from __future__ import annotations
from typing import Any, Dict, List

DEFAULT_ALLOWED = {"ACCEPT", "TARGET_SPECIFIC", "REWORK_REQUIRED", "HOLD", "EVIDENCE_INCOMPLETE", "PROVENANCE_UNKNOWN", "METHOD_UNSUITABLE"}
DEFAULT_POSITIVE = {"ACCEPT", "TARGET_SPECIFIC"}
REQUIRED_CHANNELS = {"base_color", "metallic", "roughness", "normal", "occlusion"}
TRANSFER = {"base_color":"SRGB","metallic":"LINEAR","roughness":"LINEAR","normal":"LINEAR","occlusion":"LINEAR","emissive":"SRGB"}
ALLOWED_TRIGGER_TYPES = {"review", "migration", "escalation", "stop", "pivot"}
REQUIRED_RULE_IDS = {f"R{i}" for i in range(1,12)}


def _err(code: str, rule: str, message: str) -> Dict[str, str]:
    return {"code": code, "rule": rule, "message": message}


def _text(v: Any) -> bool:
    return isinstance(v, str) and bool(v.strip())


def _explicit_measurement(v: Any) -> bool:
    return isinstance(v, dict) and _text(v.get("metric")) and "observed" in v and isinstance(v.get("threshold"), dict) and v["threshold"].get("value") is not None


def _explicit_density(v: Any) -> bool:
    return isinstance(v, dict) and _text(v.get("metric")) and isinstance(v.get("target_band"), dict) and isinstance(v.get("observed_range"), dict) and v["target_band"].get("min") is not None and v["target_band"].get("max") is not None and v["observed_range"].get("min") is not None and v["observed_range"].get("max") is not None


def validate_pack(pack: Dict[str, Any], visible_rules: Dict[str, Any] | None = None) -> List[Dict[str, str]]:
    """Return consistency/evidence errors. Empty list means internally consistent, not approved."""
    errors: List[Dict[str, str]] = []
    visible_rules = visible_rules if isinstance(visible_rules, dict) else {}
    rule_ids = {r.get("id") for r in visible_rules.get("rules", []) if isinstance(r, dict)}
    missing_rule_ids = sorted(REQUIRED_RULE_IDS - rule_ids)
    if missing_rule_ids:
        errors.append(_err("E_VISIBLE_RULESET_INCOMPLETE", "RULE_SOURCE", "Visible CHECK_RULES.json is missing required rule IDs: " + ", ".join(missing_rule_ids)))
    allowed = set(visible_rules.get("allowed_final_states") or DEFAULT_ALLOWED)
    positive_states = set(visible_rules.get("positive_states") or DEFAULT_POSITIVE)

    decision = pack.get("decision") if isinstance(pack.get("decision"), dict) else {}
    state = decision.get("final_state")
    if state not in allowed:
        errors.append(_err("E_DECISION_REQUIRED", "R11", "Choose one allowed final_state; the checker will not choose it for you."))
        positive = False
    else:
        positive = state in positive_states

    target = pack.get("target") if isinstance(pack.get("target"), dict) else {}
    uv = pack.get("uv") if isinstance(pack.get("uv"), dict) else {}
    channels = pack.get("channels") if isinstance(pack.get("channels"), list) else []
    by_role = {c.get("role"): c for c in channels if isinstance(c, dict) and _text(c.get("role"))}

    if positive:
        missing=[]
        for key in ("material_model","target_constraint_scope","uv_overlap_policy","channel_packing"):
            if not _text(target.get(key)): missing.append(f"target.{key}")
        if not _text(uv.get("uv_set")): missing.append("uv.uv_set")
        if not _text(uv.get("overlap_observation")): missing.append("uv.overlap_observation")
        if not _explicit_measurement(uv.get("stretch_check")): missing.append("uv.stretch_check(explicit threshold+observed)")
        if not _explicit_density(uv.get("texel_density_policy")): missing.append("uv.texel_density_policy(explicit band+observed range)")
        missing_roles=sorted(REQUIRED_CHANNELS-set(by_role))
        if missing_roles: missing.append("channels:"+",".join(missing_roles))
        if missing: errors.append(_err("E_MISSING_CRITICAL_POSITIVE","R1","Positive state is missing decisive visible evidence: "+"; ".join(missing)))

    if target.get("uv_overlap_policy")=="UNIQUE_REQUIRED" and uv.get("overlap_observation") in {"INTENTIONAL_MIRRORED","ACCIDENTAL_OVERLAP"} and positive:
        errors.append(_err("E_OVERLAP_POLICY","R2","The visible overlap observation conflicts with UNIQUE_REQUIRED for a positive state."))

    for role,channel in by_role.items():
        expected=TRANSFER.get(role)
        if expected and channel.get("transfer")!=expected:
            code="E_ROUGHNESS_TRANSFER" if role=="roughness" else "E_CHANNEL_TRANSFER"
            errors.append(_err(code,"R3",f"{role} transfer must be {expected} for this declared lab target; found {channel.get('transfer')!r}."))
        if positive and channel.get("uv_set")!=uv.get("uv_set"):
            errors.append(_err("E_UV_BINDING","R5",f"{role} is not bound to the declared uv.uv_set."))

    if target.get("channel_packing")=="PACKED_ORM":
        expected_conv={"R":"occlusion","G":"roughness","B":"metallic"}
        if target.get("packing_convention")!=expected_conv:
            errors.append(_err("E_ORM_CONVENTION","R4","PACKED_ORM requires the visible R=occlusion, G=roughness, B=metallic target convention in this lab."))
        orm={r:by_role.get(r) for r in ("occlusion","roughness","metallic")}
        if all(isinstance(v,dict) for v in orm.values()):
            refs={v.get("map_ref") for v in orm.values()}; slots={r:orm[r].get("packed_channel") for r in orm}
            if len(refs)!=1 or slots!={"occlusion":"R","roughness":"G","metallic":"B"}:
                errors.append(_err("E_ORM_BINDING","R4","Packed ORM evidence must show one shared map with R/Occlusion, G/Roughness, B/Metallic."))
        elif positive:
            errors.append(_err("E_ORM_BINDING","R4","Packed ORM positive evidence is missing one or more ORM roles."))

    if positive and not _text(pack.get("normal_convention")):
        errors.append(_err("E_NORMAL_CONVENTION","R5","A positive state requires a declared normal convention."))

    prov=pack.get("ai_provenance") if isinstance(pack.get("ai_provenance"),dict) else {}
    if positive:
        required_prov=all(_text(prov.get(k)) for k in ("ai_assist_method","source_asset_ref","rights_provenance_ref","rights_status"))
        edits=prov.get("manual_edit_log")
        if (not required_prov) or prov.get("rights_status") in {"UNKNOWN","UNRESOLVED","PROVENANCE_UNKNOWN"} or not isinstance(edits,list) or not any(_text(x) for x in edits):
            errors.append(_err("E_PROVENANCE_POSITIVE","R6","Positive state requires visible source/provenance, resolved rights status, and a non-empty manual edit log."))

    lighting=pack.get("lighting_validation") if isinstance(pack.get("lighting_validation"),list) else []
    visible_lighting=[x for x in lighting if isinstance(x,dict) and _text(x.get("lighting_id")) and _text(x.get("observation")) and not x["observation"].startswith("Learner")]
    if positive and len({x["lighting_id"] for x in visible_lighting})<2:
        errors.append(_err("E_LIGHTING_EVIDENCE","R7","Positive state requires at least two distinct visible lighting observations."))

    interop=pack.get("interoperability_evidence") if isinstance(pack.get("interoperability_evidence"),dict) else {}
    checks=interop.get("binding_checks") if isinstance(interop.get("binding_checks"),list) else []
    visible_checks=[x for x in checks if isinstance(x,dict) and _text(x.get("check")) and _text(x.get("expected")) and _text(x.get("observed"))]
    if positive and (not _text(interop.get("target_ref")) or len(visible_checks)<2 or interop.get("export_only_proof") is not False):
        errors.append(_err("E_INTEROP_EVIDENCE","R8","Positive state needs explicit target binding checks with visible expected/observed evidence and must not rely on export-only proof."))

    scope=target.get("target_constraint_scope")
    if state=="ACCEPT" and scope!="STANDARD_DECLARED_TARGET": errors.append(_err("E_TARGET_SCOPE","R9","ACCEPT requires STANDARD_DECLARED_TARGET."))
    if state=="TARGET_SPECIFIC" and scope!="CONSTRAINED_NAMED_TARGET_ONLY": errors.append(_err("E_TARGET_SCOPE","R9","TARGET_SPECIFIC requires CONSTRAINED_NAMED_TARGET_ONLY."))

    triggers=decision.get("triggers") if isinstance(decision.get("triggers"),list) else []
    valid_triggers=[]
    for item in triggers:
        if not isinstance(item,dict): continue
        t=item.get("type"); condition=item.get("condition")
        if t not in ALLOWED_TRIGGER_TYPES:
            errors.append(_err("E_DECISION_TRIGGER_TYPE","R10",f"Unknown trigger type {t!r}; use one of the visible named categories."))
        elif not _text(condition):
            errors.append(_err("E_DECISION_TRIGGER_CONDITION","R10",f"Trigger {t!r} needs a visible condition."))
        else: valid_triggers.append(item)
    if state in allowed and not valid_triggers:
        errors.append(_err("E_DECISION_TRIGGERS","R10","Decision must include at least one applicable named future trigger condition; all five categories are not required."))
    return errors
