"""
Admin Control Plane · Policy Engine · Phase 2a
A-owned · session_a

Pure-Python policy evaluator that consumes the five Phase 1 JSON models
(role_registry · access_policy · assist_model · approval_queue_model ·
audit_event_model) and produces deterministic decisions for:

  - access.check        → allow | mask | deny
  - mask.resolve        → unmasked | masked | masked-category-only | denied
  - assist.validate     → valid / invalid
  - viewas.validate     → valid / invalid · writes always rejected
  - sensitive.check     → allow / deny · lists required approvers

Principles preserved from Phase 1:
  - Masked by default
  - Deny on unknown role
  - Self-view exception for own data
  - Tenant sovereignty (own-tenant admin/DPO unmasked on own tenant)
  - Cross-tenant without assist/view-as → deny
  - Sensitive-surface requires dual approvers
  - View-as is read-only · no write path
  - Forbidden patterns from assist_model enforced
  - Expired approval refs treated as missing
  - GPS producer-side policy cannot be bypassed

This module is imported by app.py (FastAPI). It is side-effect free:
never writes audit events · never mutates state · never calls network.
"""
from __future__ import annotations

from datetime import datetime, timezone
from typing import Any

ENGINE_VERSION = "2a-py-policy-engine"

# Keys the engine will match when inspecting masking_policy_rows
FIELD_CATEGORIES = {
    "email":         "PII · email/phone/national_id",
    "phone":         "PII · email/phone/national_id",
    "national_id":   "PII · email/phone/national_id",
    "pii":           "PII · email/phone/national_id",
    "health":        "Sensitive PII · biometric/health/minors",
    "biometric":     "Sensitive PII · biometric/health/minors",
    "minor_identity":"Sensitive PII · biometric/health/minors",
    "sensitive_pii": "Sensitive PII · biometric/health/minors",
    "financial_pan": "Financial · PAN / full wallet number",
    "financial":     "Financial · PAN / full wallet number",
    "gps":           "GPS · exact coordinates",
    "gps_coords":    "GPS · exact coordinates",
    "kpi":           "Business KPIs (aggregate)",
    "business_kpi":  "Business KPIs (aggregate)",
    "audit_log":     "Audit log",
}

# Default mask forms when a rule says "masked" but doesn't specify a form
DEFAULT_MASK_FORMS = {
    "PII · email/phone/national_id":              "a***@***.com",
    "Sensitive PII · biometric/health/minors":    "[category only]",
    "Financial · PAN / full wallet number":       "**** **** **** 5678",
    "GPS · exact coordinates":                    "[H3 cell only · never raw]",
    "Business KPIs (aggregate)":                  "[aggregate only · k≥5]",
    "Audit log":                                  "[summary only]",
}


# --------------------------------------------------------------- helpers ---

def _parse_iso(s: str | None):
    if not s:
        return None
    try:
        if s.endswith("Z"):
            s = s[:-1] + "+00:00"
        return datetime.fromisoformat(s)
    except Exception:
        return None


def _now(now_iso: str | None) -> datetime:
    if now_iso:
        d = _parse_iso(now_iso)
        if d:
            return d
    return datetime.now(timezone.utc)


def _role(models: dict, key: str):
    reg = models.get("role_registry", {})
    for r in reg.get("roles", []):
        if r.get("key") == key:
            return r
    return None


def _mask_row_for_category(models: dict, field_category: str):
    resolved = FIELD_CATEGORIES.get(field_category, field_category)
    for row in models.get("access_policy", {}).get("masking_policy_rows", []):
        if row.get("field_category") == resolved or row.get("field_category") == field_category:
            return row
    return None


def _ttl_remaining(now: datetime, expires_at: str | None) -> int | None:
    if not expires_at:
        return None
    d = _parse_iso(expires_at)
    if not d:
        return None
    delta = (d - now).total_seconds()
    return max(0, int(delta))


def _reason(id_: str, text: str, rule_ref: str | None = None) -> dict:
    r = {"id": id_, "text": text}
    if rule_ref:
        r["rule_ref"] = rule_ref
    return r


def _tenant_relation(actor_role: dict | None, actor_tenant_id: str | None, target_tenant_id: str | None) -> str:
    """Determine tenant relation between actor and target.

    Platform-operator roles (superadmin, sales_ae, support_success, product_ops,
    platform_governance, dec_board, founder) carry null actor_tenant_id by design —
    any target_tenant they reach is "cross" to them. Approval-role category behaves
    the same way.
    """
    if target_tenant_id is None:
        return "platform-global"
    platform_like = {"platform_operator", "approval_role"}
    if actor_tenant_id is None:
        if actor_role and actor_role.get("category") in platform_like:
            return "cross"
        return "unknown"
    return "own" if actor_tenant_id == target_tenant_id else "cross"


def _active_approval(models: dict, approval_refs: list[str] | None, now: datetime) -> str | None:
    """Placeholder: without a real approval store we cannot verify specific
    row ids. Presence of any non-empty ref is treated as a valid approval
    token for engine logic; real TTL enforcement is Phase 2b (store-backed).
    """
    if not approval_refs:
        return None
    for ref in approval_refs:
        if isinstance(ref, str) and ref.strip():
            return ref.strip()
    return None


# ========================================================== access.check ===

def access_check(models: dict, ctx: dict) -> dict:
    """Decide allow | mask | deny for the given access request.

    Resolution order:
      1. Request validity (missing actor_role/user_id → invalid_request)
      2. Role known (unknown_role → deny)
      3. Producer-irreversible category (GPS → deny unmask path)
      4. Self-view exception (actor_user_id == target_user_id → allow unmasked)
      5. Sensitive-surface precheck (is_sensitive → require dual approvals)
      6. Tenant relation
         · own         → allow (mask per role · unmasked for tenant_admin/tenant_dpo)
         · platform    → mask-by-default unless approval_ref held
         · cross w/ assist_ctx or view_as_ctx → proceed to context validation
         · cross w/o  any context → deny
      7. Mask row lookup → mask_level + mask_form
      8. Default → mask
    """
    trace: list[str] = []
    reasons: list[dict] = []
    now = _now(ctx.get("now_iso"))

    actor_role_key = ctx.get("actor_role")
    actor_user_id = ctx.get("actor_user_id")
    actor_tenant_id = ctx.get("actor_tenant_id")
    target_tenant_id = ctx.get("target_tenant_id")
    target_user_id = ctx.get("target_user_id")
    field_category = ctx.get("field_category")
    requested_action = ctx.get("requested_action", "read") or "read"
    is_sensitive = bool(ctx.get("is_sensitive", False))
    approval_refs = ctx.get("approval_refs") or []

    if not actor_role_key or not actor_user_id:
        trace.append("[0] request invalid: missing actor_role or actor_user_id")
        return {
            "decision": "deny",
            "mask_level": "denied",
            "mask_form": None,
            "reasons": [_reason("invalid_request", "missing actor_role or actor_user_id")],
            "required_approvers": [],
            "approval_matrix_row": None,
            "self_view": False,
            "tenant_relation": "unknown",
            "trace": trace,
        }

    role = _role(models, actor_role_key)
    if role is None:
        trace.append(f"[1] unknown_role: '{actor_role_key}' not in role_registry")
        return {
            "decision": "deny",
            "mask_level": "denied",
            "mask_form": None,
            "reasons": [_reason("unknown_role", f"'{actor_role_key}' is not a registered role", "role_registry.roles[].key")],
            "required_approvers": [],
            "approval_matrix_row": None,
            "self_view": False,
            "tenant_relation": "unknown",
            "trace": trace,
        }
    trace.append(f"[1] actor_role found: {role['key']} (category={role.get('category')})")

    relation = _tenant_relation(role, actor_tenant_id, target_tenant_id)
    trace.append(f"[2] tenant_relation: {relation}")

    mask_row = _mask_row_for_category(models, field_category) if field_category else None
    if mask_row:
        trace.append(f"[3] mask_row: {mask_row['field_category']}")
    else:
        trace.append(f"[3] mask_row: none for field_category={field_category!r}")

    # Step 4: GPS producer-irreversible → no unmask path regardless of role
    if mask_row and mask_row.get("field_category") == "GPS · exact coordinates":
        trace.append("[4] GPS category: producer-side fuzz · unmask always denied (prec-2)")
        return {
            "decision": "mask",
            "mask_level": "masked",
            "mask_form": mask_row.get("default", "[H3 cell only]"),
            "reasons": [_reason("gps_irreversible", "GPS fuzz happens at producer · raw coordinates never exist downstream", "access_policy.precedence_rules.prec-2")],
            "required_approvers": [],
            "approval_matrix_row": None,
            "self_view": False,
            "tenant_relation": relation,
            "trace": trace,
        }

    # Step 5: Self-view shortcut
    if target_user_id and target_user_id == actor_user_id:
        trace.append("[5] self-view: actor == target_user · unmasked for own data (prec-3)")
        reasons.append(_reason("self_view", "actor is the subject · own data always unmasked", "access_policy.precedence_rules.prec-3"))
        return {
            "decision": "allow",
            "mask_level": "unmasked",
            "mask_form": None,
            "reasons": reasons,
            "required_approvers": [],
            "approval_matrix_row": None,
            "self_view": True,
            "tenant_relation": relation,
            "trace": trace,
        }

    # Step 6: Sensitive-surface + no active approval → deny early (dual required)
    if is_sensitive or (mask_row and mask_row.get("sensitive_escalation")):
        approval = _active_approval(models, approval_refs, now)
        if not approval:
            trace.append("[6] sensitive-surface without approval_ref → deny (prec-6)")
            return {
                "decision": "deny",
                "mask_level": "denied",
                "mask_form": None,
                "reasons": [_reason("sensitive_dual_required", "sensitive-surface field requires dual signers per approval_matrix.row-sensitive-override", "access_policy.precedence_rules.prec-6")],
                "required_approvers": ["tenant_dpo", "platform_governance"],
                "approval_matrix_row": "row-sensitive-override",
                "self_view": False,
                "tenant_relation": relation,
                "trace": trace,
            }
        trace.append(f"[6] sensitive-surface: approval_ref present ({approval})")

    # Step 7: Tenant relation gating
    if relation == "cross":
        has_assist = bool(ctx.get("assist_ctx"))
        has_viewas = bool(ctx.get("view_as_ctx"))
        if not (has_assist or has_viewas):
            trace.append("[7] cross-tenant without assist_ctx or view_as_ctx → deny (prec-5)")
            return {
                "decision": "deny",
                "mask_level": "denied",
                "mask_form": None,
                "reasons": [_reason("cross_tenant_no_context", "platform-operator cross-tenant access requires tenant-side approval or assist/view-as context", "access_policy.precedence_rules.prec-5")],
                "required_approvers": ["tenant_admin"],
                "approval_matrix_row": None,
                "self_view": False,
                "tenant_relation": relation,
                "trace": trace,
            }
        # Writes during view_as_ctx never allowed
        if has_viewas and requested_action != "read":
            trace.append("[7] view_as_ctx + non-read action → deny (read-only mode)")
            return {
                "decision": "deny",
                "mask_level": "denied",
                "mask_form": None,
                "reasons": [_reason("view_as_read_only", "view-as-tenant is always read-only · writes require assist_ctx or direct tenant sign-in", "assist_model.modes.view_as_tenant.capabilities_forbidden")],
                "required_approvers": [],
                "approval_matrix_row": None,
                "self_view": False,
                "tenant_relation": relation,
                "trace": trace,
            }

    # Step 8: Resolve mask level by role × relation
    mask_level, mask_form, precedence_rule = _resolve_mask(models, role, relation, mask_row, approval_refs, now, trace)
    trace.append(f"[8] mask_level resolved: {mask_level}")

    if mask_level == "unmasked":
        return {
            "decision": "allow",
            "mask_level": "unmasked",
            "mask_form": None,
            "reasons": [_reason("mask_unmasked", "policy resolved unmasked for this role/relation", precedence_rule)],
            "required_approvers": [],
            "approval_matrix_row": None,
            "self_view": False,
            "tenant_relation": relation,
            "trace": trace,
        }

    if mask_level == "denied":
        return {
            "decision": "deny",
            "mask_level": "denied",
            "mask_form": None,
            "reasons": [_reason("mask_denied", "policy denies access for this role/relation", precedence_rule)],
            "required_approvers": ["tenant_admin","tenant_dpo"],
            "approval_matrix_row": None,
            "self_view": False,
            "tenant_relation": relation,
            "trace": trace,
        }

    # Default: mask
    return {
        "decision": "mask",
        "mask_level": mask_level,
        "mask_form": mask_form,
        "reasons": [_reason("mask_applied", "default-masked policy applied · unmask requires explicit approval", precedence_rule or "access_policy.masking_defaults.principle")],
        "required_approvers": [],
        "approval_matrix_row": None,
        "self_view": False,
        "tenant_relation": relation,
        "trace": trace,
    }


def _resolve_mask(models, role, relation, mask_row, approval_refs, now, trace) -> tuple[str, str | None, str | None]:
    """Return (mask_level, mask_form, precedence_rule_id)."""
    role_key = role.get("key")
    has_approval = bool(_active_approval(models, approval_refs, now))

    # tenant_admin / tenant_dpo on own tenant → unmasked
    if relation == "own" and role_key in ("tenant_admin", "tenant_dpo"):
        trace.append("[8a] tenant sovereignty: own-tenant admin/dpo → unmasked (prec-4)")
        return "unmasked", None, "prec-4"

    # End-user on own tenant & category == mask row present → masked unless self-view (handled earlier)
    if role_key == "end_user" and relation == "own":
        trace.append("[8b] end_user on own-tenant (non-self) → mask default")
        if mask_row:
            return "masked", mask_row.get("default") or DEFAULT_MASK_FORMS.get(mask_row.get("field_category"), "[masked]"), "access_policy.masking_defaults.principle"
        return "masked", None, "access_policy.masking_defaults.principle"

    # Platform-role with approval_ref → unmasked (simulated · store not wired)
    platform_roles = {"superadmin", "sales_ae", "support_success", "product_ops", "platform_governance", "dec_board", "founder"}
    if role_key in platform_roles:
        if has_approval:
            trace.append("[8c] platform role + approval_ref held → unmasked (time-bounded · prec-7)")
            return "unmasked", None, "prec-7"
        if mask_row:
            # product_ops on KPI aggregate allowed
            if role_key == "product_ops" and mask_row.get("field_category") == "Business KPIs (aggregate)":
                trace.append("[8d] product_ops on KPI aggregate → unmasked k-anon-enforced")
                return "unmasked", None, "access_policy.masking_policy_rows.business_kpi"
            trace.append("[8e] platform role · no approval → masked")
            return "masked", mask_row.get("default") or DEFAULT_MASK_FORMS.get(mask_row.get("field_category"), "[masked]"), "access_policy.masking_defaults.principle"
        trace.append("[8f] platform role · no field_category → masked default")
        return "masked", None, "access_policy.masking_defaults.principle"

    # approval roles (dec_board etc.) without mask row → masked default
    trace.append("[8g] role default → masked")
    return "masked", (mask_row.get("default") if mask_row else None) or "[masked]", "access_policy.masking_defaults.principle"


# ========================================================== mask.resolve ===

def mask_resolve(models: dict, ctx: dict) -> dict:
    trace: list[str] = []
    now = _now(ctx.get("now_iso"))
    actor_role_key = ctx.get("actor_role")
    field_category = ctx.get("field_category")
    if not actor_role_key:
        trace.append("[0] invalid: missing actor_role")
        return {"field_category": field_category, "mask_level": "masked", "mask_form": "[invalid]",
                "reasons": [_reason("invalid_request", "missing actor_role")],
                "ttl_remaining_seconds": None, "required_approvers": [],
                "precedence_rule_applied": None, "trace": trace}
    role = _role(models, actor_role_key)
    if role is None:
        trace.append(f"[1] unknown_role: {actor_role_key}")
        return {"field_category": field_category, "mask_level": "masked", "mask_form": "[masked]",
                "reasons": [_reason("unknown_role", actor_role_key, "role_registry")],
                "ttl_remaining_seconds": None, "required_approvers": [],
                "precedence_rule_applied": "fail-safe", "trace": trace}

    # Self-view
    if ctx.get("target_user_id") and ctx.get("target_user_id") == ctx.get("actor_user_id"):
        trace.append("[2] self-view → unmasked (prec-3)")
        return {"field_category": field_category, "mask_level": "unmasked", "mask_form": None,
                "reasons": [_reason("self_view", "own-data unmasked", "prec-3")],
                "ttl_remaining_seconds": None, "required_approvers": [],
                "precedence_rule_applied": "prec-3", "trace": trace}

    mask_row = _mask_row_for_category(models, field_category) if field_category else None
    relation = _tenant_relation(role, ctx.get("actor_tenant_id"), ctx.get("target_tenant_id"))
    trace.append(f"[3] tenant_relation={relation} · mask_row={(mask_row or {}).get('field_category')}")

    mask_level, mask_form, precedence = _resolve_mask(models, role, relation, mask_row, ctx.get("approval_refs") or [], now, trace)

    required: list[str] = []
    ttl: int | None = None
    if mask_level != "unmasked" and mask_row:
        if mask_row.get("sensitive_escalation"):
            required = ["tenant_dpo", "platform_governance"]
        elif actor_role_key in ("sales_ae", "support_success"):
            required = ["tenant_admin"]
        elif actor_role_key in ("superadmin",):
            required = ["founder"]
        # Compute TTL from approval if any
        approval = _active_approval(models, ctx.get("approval_refs") or [], now)
        if approval and ctx.get("approval_expires_at"):
            ttl = _ttl_remaining(now, ctx.get("approval_expires_at"))

    return {
        "field_category": field_category,
        "mask_level": mask_level,
        "mask_form": mask_form,
        "reasons": [_reason("mask_resolve", f"mask_level={mask_level}", precedence)],
        "ttl_remaining_seconds": ttl,
        "required_approvers": required,
        "precedence_rule_applied": precedence,
        "trace": trace,
    }


# ========================================================= assist.validate =

ASSIST_ELIGIBLE_ROLES = {"sales_ae", "support_success"}
ASSIST_FORBIDDEN_ACTIONS = {"sign_approval", "change_tenant_roles", "modify_pdpa",
                            "export_sensitive_pii", "cross_tenant_access",
                            "tenant_instantiate", "canonical_promote"}

def assist_validate(models: dict, ctx: dict) -> dict:
    trace: list[str] = []
    now = _now(ctx.get("now_iso"))
    actor_role_key = ctx.get("actor_role")
    role = _role(models, actor_role_key) if actor_role_key else None
    assist_ctx = ctx.get("assist_ctx") or {}
    proposed = ctx.get("proposed_action")

    if not actor_role_key or not role:
        trace.append("[0] invalid or unknown role")
        return {"valid": False, "reasons": [_reason("unknown_role", str(actor_role_key))],
                "ttl_remaining_seconds": None, "expires_at": None,
                "forbidden_pattern_hit": None, "proposed_action_allowed": False, "trace": trace}

    if not assist_ctx:
        trace.append("[0] missing assist_ctx")
        return {"valid": False, "reasons": [_reason("missing_assist_ctx", "no assist context provided")],
                "ttl_remaining_seconds": None, "expires_at": None,
                "forbidden_pattern_hit": None, "proposed_action_allowed": False, "trace": trace}

    if role["key"] not in ASSIST_ELIGIBLE_ROLES:
        trace.append(f"[1] role {role['key']} not eligible for assist")
        return {"valid": False, "reasons": [_reason("role_not_eligible", f"{role['key']} cannot initiate assist · see assist_model.modes.assist.eligibility")],
                "ttl_remaining_seconds": None, "expires_at": assist_ctx.get("expires_at"),
                "forbidden_pattern_hit": None, "proposed_action_allowed": False, "trace": trace}

    # TTL
    ttl = _ttl_remaining(now, assist_ctx.get("expires_at"))
    if ttl is None or ttl <= 0:
        trace.append("[2] TTL expired or missing")
        return {"valid": False, "reasons": [_reason("ttl_expired", "assist session expired or no TTL")],
                "ttl_remaining_seconds": 0, "expires_at": assist_ctx.get("expires_at"),
                "forbidden_pattern_hit": "PERMANENT_VIEW_AS" if not assist_ctx.get("expires_at") else None,
                "proposed_action_allowed": False, "trace": trace}

    # Max TTL check per assist_model (120 min)
    started = _parse_iso(assist_ctx.get("granted_at"))
    if started:
        elapsed = (now - started).total_seconds() / 60
        if elapsed > 120:
            trace.append(f"[3] session elapsed {elapsed:.0f}min > max 120min")
            return {"valid": False, "reasons": [_reason("ttl_max_exceeded", "assist max TTL is 120min")],
                    "ttl_remaining_seconds": 0, "expires_at": assist_ctx.get("expires_at"),
                    "forbidden_pattern_hit": "PERMANENT_VIEW_AS", "proposed_action_allowed": False, "trace": trace}

    # Consent record
    if not assist_ctx.get("consent_record_id"):
        trace.append("[4] missing consent_record_id")
        return {"valid": False, "reasons": [_reason("consent_missing", "assist requires explicit tenant consent · consent_record_id absent")],
                "ttl_remaining_seconds": ttl, "expires_at": assist_ctx.get("expires_at"),
                "forbidden_pattern_hit": None, "proposed_action_allowed": False, "trace": trace}

    # support_success needs case_ref
    if role["key"] == "support_success" and not assist_ctx.get("case_ref"):
        trace.append("[5] support_success missing case_ref")
        return {"valid": False, "reasons": [_reason("case_ref_required", "support_success assist requires case_ref")],
                "ttl_remaining_seconds": ttl, "expires_at": assist_ctx.get("expires_at"),
                "forbidden_pattern_hit": None, "proposed_action_allowed": False, "trace": trace}

    # target_tenant scope check
    if assist_ctx.get("target_tenant_id") == ctx.get("actor_tenant_id") and ctx.get("actor_tenant_id") is not None:
        trace.append("[6] assist into own tenant is meaningless · platform role expected")

    # proposed_action check
    proposed_allowed = True
    forbidden_hit = None
    if proposed:
        if proposed in ASSIST_FORBIDDEN_ACTIONS:
            proposed_allowed = False
            forbidden_hit = "DELEGATED_APPROVAL" if proposed == "sign_approval" else "ASSIST_FORBIDDEN_ACTION"
            trace.append(f"[7] proposed_action {proposed!r} is forbidden in assist mode")
        elif proposed not in (assist_ctx.get("scope") or []):
            proposed_allowed = False
            trace.append(f"[7] proposed_action {proposed!r} not in consented scope {assist_ctx.get('scope')}")
        else:
            trace.append(f"[7] proposed_action {proposed!r} within scope · allowed")

    reasons = [_reason("assist_valid", f"role={role['key']} · ttl={ttl}s · consent={assist_ctx.get('consent_record_id')}")]
    return {
        "valid": True if proposed_allowed else False,
        "reasons": reasons + ([_reason("proposed_blocked", f"{proposed!r} blocked", forbidden_hit)] if not proposed_allowed else []),
        "ttl_remaining_seconds": ttl,
        "expires_at": assist_ctx.get("expires_at"),
        "forbidden_pattern_hit": forbidden_hit,
        "proposed_action_allowed": proposed_allowed,
        "trace": trace,
    }


# ========================================================== viewas.validate

VIEWAS_ELIGIBLE_ROLES = {"sales_ae", "support_success", "superadmin"}

def viewas_validate(models: dict, ctx: dict) -> dict:
    trace: list[str] = []
    now = _now(ctx.get("now_iso"))
    actor_role_key = ctx.get("actor_role")
    role = _role(models, actor_role_key) if actor_role_key else None
    v_ctx = ctx.get("view_as_ctx") or {}
    proposed = (ctx.get("proposed_action") or "read").lower()

    if not actor_role_key or not role:
        return {"valid": False, "read_allowed": False, "write_allowed": False,
                "banner_required": True, "ttl_remaining_seconds": 0,
                "expires_at": v_ctx.get("expires_at"),
                "reasons": [_reason("unknown_role", str(actor_role_key))], "trace": trace}

    if not v_ctx:
        return {"valid": False, "read_allowed": False, "write_allowed": False,
                "banner_required": True, "ttl_remaining_seconds": 0, "expires_at": None,
                "reasons": [_reason("missing_view_as_ctx", "no view_as context")], "trace": trace}

    if role["key"] not in VIEWAS_ELIGIBLE_ROLES:
        trace.append(f"[1] role {role['key']} not eligible for view-as")
        return {"valid": False, "read_allowed": False, "write_allowed": False,
                "banner_required": True, "ttl_remaining_seconds": 0,
                "expires_at": v_ctx.get("expires_at"),
                "reasons": [_reason("role_not_eligible", f"{role['key']} cannot use view-as · see assist_model.modes.view_as_tenant.eligibility")], "trace": trace}

    ttl = _ttl_remaining(now, v_ctx.get("expires_at"))
    if ttl is None or ttl <= 0:
        trace.append("[2] TTL expired or missing")
        return {"valid": False, "read_allowed": False, "write_allowed": False,
                "banner_required": True, "ttl_remaining_seconds": 0,
                "expires_at": v_ctx.get("expires_at"),
                "reasons": [_reason("ttl_expired", "view-as session expired")], "trace": trace}

    started = _parse_iso(v_ctx.get("granted_at"))
    if started:
        elapsed_min = (now - started).total_seconds() / 60
        if elapsed_min > 60:
            trace.append(f"[3] view-as elapsed {elapsed_min:.0f}min > max 60min")
            return {"valid": False, "read_allowed": False, "write_allowed": False,
                    "banner_required": True, "ttl_remaining_seconds": 0,
                    "expires_at": v_ctx.get("expires_at"),
                    "reasons": [_reason("ttl_max_exceeded", "view-as max TTL is 60min")], "trace": trace}

    if proposed != "read":
        trace.append(f"[4] proposed_action {proposed!r} invalid · view-as is read-only")
        return {"valid": False, "read_allowed": True, "write_allowed": False,
                "banner_required": True, "ttl_remaining_seconds": ttl,
                "expires_at": v_ctx.get("expires_at"),
                "reasons": [_reason("write_in_view_as", "writes forbidden in view-as mode · use assist mode with consent", "assist_model.modes.view_as_tenant.capabilities_forbidden")], "trace": trace}

    trace.append(f"[5] view-as valid · ttl={ttl}s · read-only")
    return {"valid": True, "read_allowed": True, "write_allowed": False,
            "banner_required": True, "ttl_remaining_seconds": ttl,
            "expires_at": v_ctx.get("expires_at"),
            "reasons": [_reason("viewas_valid", f"role={role['key']} · ttl={ttl}s · read-only")], "trace": trace}


# ======================================================== sensitive.check ==

def sensitive_check(models: dict, ctx: dict) -> dict:
    trace: list[str] = []
    now = _now(ctx.get("now_iso"))
    actor_role_key = ctx.get("actor_role")
    role = _role(models, actor_role_key) if actor_role_key else None
    field_category = ctx.get("field_category")
    action = (ctx.get("requested_action") or "read").lower()

    if role is None:
        return {"decision": "deny", "required_approvers": ["tenant_dpo","platform_governance"],
                "approval_matrix_row": "row-sensitive-override",
                "current_approvals_satisfy": False,
                "reasons": [_reason("unknown_role", str(actor_role_key))], "trace": trace}

    mask_row = _mask_row_for_category(models, field_category) if field_category else None
    if not mask_row:
        trace.append(f"[1] unknown field_category {field_category!r}")
        return {"decision": "deny", "required_approvers": ["tenant_dpo"],
                "approval_matrix_row": None, "current_approvals_satisfy": False,
                "reasons": [_reason("unknown_field_category", str(field_category))], "trace": trace}

    # Determine required approvers per mask row
    required: list[str] = []
    matrix_row = None
    if mask_row.get("sensitive_escalation"):
        if mask_row["field_category"].startswith("Sensitive PII"):
            required = ["tenant_dpo", "platform_governance", "domain_expert"]
            matrix_row = "row-sensitive-override"
        elif mask_row["field_category"].startswith("Financial"):
            required = ["tenant_dpo", "tenant_legal"]
            matrix_row = "row-sensitive-override"
    else:
        required = ["tenant_admin"]
        matrix_row = None

    approval = _active_approval(models, ctx.get("approval_refs") or [], now)
    satisfies = bool(approval)
    trace.append(f"[2] required={required} · approval_present={satisfies} · matrix_row={matrix_row}")

    decision = "allow" if satisfies else "deny"
    reasons = [_reason(
        "sensitive_decision",
        f"{decision} · required={','.join(required)} · matrix_row={matrix_row}",
        matrix_row,
    )]
    if action == "write" and role["key"] in {"sales_ae", "support_success"}:
        trace.append("[3] write on sensitive by platform role → deny regardless of ref")
        decision = "deny"
        reasons.append(_reason("write_blocked", f"{role['key']} cannot write sensitive fields · use assist + approval", "assist_model.modes.assist.capabilities_forbidden"))
        satisfies = False

    return {
        "decision": decision,
        "required_approvers": required,
        "approval_matrix_row": matrix_row,
        "current_approvals_satisfy": satisfies,
        "reasons": reasons,
        "trace": trace,
    }


# ================================================================ health ===

def health(models: dict, started_at: datetime) -> dict:
    now = _now(None)
    loaded = {k: bool(v) for k, v in models.items()}
    reg = models.get("role_registry") or {}
    pol = models.get("access_policy") or {}
    asm = models.get("assist_model") or {}
    counts = {
        "roles": len(reg.get("roles", [])),
        "mask_rows": len(pol.get("masking_policy_rows", [])),
        "precedence_rules": len(pol.get("precedence_rules", [])),
        "forbidden_patterns": len(asm.get("forbidden_patterns", [])),
    }
    return {
        "status": "ready" if all(loaded.values()) else "degraded",
        "models_loaded": loaded,
        "counts": counts,
        "uptime_seconds": int((now - started_at).total_seconds()),
        "engine_version": ENGINE_VERSION,
    }


__all__ = [
    "ENGINE_VERSION",
    "access_check",
    "mask_resolve",
    "assist_validate",
    "viewas_validate",
    "sensitive_check",
    "health",
    # Phase 2b additive · store + TTL-aware helpers (existing functions UNCHANGED · 24/24 parity preserved)
    "validate_approval_row",
    "sensitive_check_2b",
]


# ============================================================================
# PHASE 2B · additive helpers (do NOT alter Phase 2a functions above)
# ----------------------------------------------------------------------------
# These helpers operate on a file-backed approval store (row shape mirrors
# approval_queue_model.queue_shape.item_fields). They are consumed by the
# new Phase 2b endpoints in app.py:
#   GET  /api/policy/approvals/{approval_id}
#   POST /api/policy/approvals/validate
#   POST /api/policy/sensitive.check.2b
# The Phase 2a sensitive_check() and _active_approval() are NOT replaced,
# so verify_examples.py (24/24) continues to pass.
# ============================================================================

ENGINE_2B_VERSION = "2b-py-additive-0.1"


def validate_approval_row(row: dict, now: datetime | None = None) -> dict:
    """Compute TTL-aware validity for a single approval row.

    Input: a row matching approval_store_schema.json (and Phase 1
    queue_shape.item_fields). Returns a decision dict:
      { valid, reasons[], state, expired, ttl_remaining_seconds,
        current_signer_count, required_signer_count, matrix_row,
        sensitive_flag, trace[] }

    Honest rules (mirroring approval_queue_model + access_policy.prec-8):
      - state in ('withdrawn','rejected','sent_back') → valid=false
      - sla_due_at in the past → expired=true · valid=false
      - state=='signed_full'   → valid=true if not expired
      - state=='signed_partial' AND required_signer_count==1 → valid=true (single path)
      - state=='signed_partial' AND required_signer_count==2 → valid=false (dual required)
      - state in ('pending','in_review') → valid=false (not yet sign-off)
    """
    if now is None:
        now = datetime.now(timezone.utc)
    trace: list[str] = []
    reasons: list[dict] = []
    state = row.get("state")
    sla_due = row.get("sla_due_at")
    required = int(row.get("required_signer_count") or 1)
    signers = row.get("current_signers") or []
    current_count = sum(1 for s in signers if s.get("decision") == "approve")
    expires_at = sla_due
    ttl = _ttl_remaining(now, sla_due) or 0

    expired_by_sla = False
    if sla_due:
        d = _parse_iso(sla_due)
        if d is not None and d < now:
            expired_by_sla = True
    expired = expired_by_sla or state == "expired"

    trace.append(
        f"[0] row={row.get('approval_id')} state={state} "
        f"required={required} current={current_count} "
        f"sla_due_at={sla_due} ttl={ttl}s expired={expired}"
    )

    if state in ("withdrawn", "rejected", "sent_back"):
        reasons.append(_reason("approval_state_invalid", f"state={state}", "approval_queue_model.queue_states"))
        valid = False
        trace.append(f"[1] approval_state_invalid: {state}")
    elif expired:
        reasons.append(_reason("approval_expired", f"sla_due_at={sla_due} now past", "access_policy.precedence_rules.prec-8"))
        valid = False
        trace.append("[1] approval_expired · prec-8 fail-safe")
    elif state == "signed_full":
        valid = True
        reasons.append(_reason("signed_full", "approval fully signed within TTL"))
        trace.append("[1] signed_full within TTL → valid")
    elif state == "signed_partial":
        if required >= 2:
            valid = False
            reasons.append(_reason("dual_signers_required",
                f"{current_count}/{required} signers · approval_queue_model.dual_approval_enforcement",
                "approval_queue_model.queue_shape.required_signer_count"))
            trace.append(f"[1] signed_partial · required={required} → invalid (dual signers required)")
        else:
            valid = True
            reasons.append(_reason("single_signer_complete",
                f"{current_count}/{required} signers · single-signer path"))
            trace.append(f"[1] signed_partial · required={required} → valid (single path)")
    elif state in ("pending", "in_review"):
        valid = False
        reasons.append(_reason("approval_not_yet_signed", f"state={state}", "approval_queue_model.queue_states"))
        trace.append(f"[1] {state} · no sign-off yet → invalid")
    else:
        valid = False
        reasons.append(_reason("approval_state_unknown", f"state={state}"))
        trace.append(f"[1] unknown state={state} → invalid (fail-safe)")

    return {
        "approval_id": row.get("approval_id"),
        "matrix_row":  row.get("matrix_row"),
        "state":       state,
        "expired":     expired,
        "valid":       valid,
        "ttl_remaining_seconds": ttl,
        "expires_at":  expires_at,
        "current_signer_count":  current_count,
        "required_signer_count": required,
        "sensitive_flag": bool(row.get("sensitive_flag")),
        "target_type": row.get("target_type"),
        "target_id":   row.get("target_id"),
        "reasons":     reasons,
        "trace":       trace,
    }


def sensitive_check_2b(models: dict, ctx: dict, store_rows: list[dict] | None = None) -> dict:
    """Store-aware sensitive.check. Mirrors the Phase 2a sensitive_check() shape
    but consults the approval store + TTL for every approval_ref instead of
    trusting non-empty strings.

    Additional response fields (beyond Phase 2a shape):
      - per_ref_validations: [{approval_id, valid, expired, state, ttl_remaining_seconds, reasons[]}]
      - any_valid_ref: bool
      - any_expired_ref: bool

    This does NOT replace the Phase 2a sensitive_check — that function still
    runs unchanged (24/24 parity). Phase 2b endpoints call this one.
    """
    store_rows = store_rows or []
    trace: list[str] = []
    now = _now(ctx.get("now_iso"))
    actor_role_key = ctx.get("actor_role")
    role = _role(models, actor_role_key) if actor_role_key else None
    field_category = ctx.get("field_category")
    action = (ctx.get("requested_action") or "read").lower()

    if role is None:
        return {
            "decision": "deny",
            "required_approvers": ["tenant_dpo", "platform_governance"],
            "approval_matrix_row": "row-sensitive-override",
            "current_approvals_satisfy": False,
            "per_ref_validations": [],
            "any_valid_ref": False,
            "any_expired_ref": False,
            "reasons": [_reason("unknown_role", str(actor_role_key))],
            "trace": trace,
        }

    mask_row = _mask_row_for_category(models, field_category) if field_category else None
    if not mask_row:
        trace.append(f"[1] unknown field_category {field_category!r}")
        return {
            "decision": "deny",
            "required_approvers": ["tenant_dpo"],
            "approval_matrix_row": None,
            "current_approvals_satisfy": False,
            "per_ref_validations": [],
            "any_valid_ref": False,
            "any_expired_ref": False,
            "reasons": [_reason("unknown_field_category", str(field_category))],
            "trace": trace,
        }

    required: list[str] = []
    matrix_row_id: str | None = None
    if mask_row.get("sensitive_escalation"):
        if mask_row["field_category"].startswith("Sensitive PII"):
            required = ["tenant_dpo", "platform_governance", "domain_expert"]
            matrix_row_id = "row-sensitive-override"
        elif mask_row["field_category"].startswith("Financial"):
            required = ["tenant_dpo", "tenant_legal"]
            matrix_row_id = "row-sensitive-override"
    else:
        required = ["tenant_admin"]

    incoming_refs = ctx.get("approval_refs") or []
    # Store lookup by approval_id
    by_id = {r.get("approval_id"): r for r in store_rows if r.get("approval_id")}
    per_ref: list[dict] = []
    any_valid = False
    any_expired = False
    for ref in incoming_refs:
        row = by_id.get(ref)
        if row is None:
            per_ref.append({
                "approval_id": ref,
                "valid": False,
                "expired": False,
                "state": None,
                "ttl_remaining_seconds": 0,
                "reasons": [_reason("approval_not_found", f"approval_id={ref} not in store")],
            })
            continue
        v = validate_approval_row(row, now)
        per_ref.append({
            "approval_id": v["approval_id"],
            "valid": v["valid"],
            "expired": v["expired"],
            "state": v["state"],
            "ttl_remaining_seconds": v["ttl_remaining_seconds"],
            "reasons": v["reasons"],
            "matrix_row_on_row": v["matrix_row"],
        })
        if v["valid"]:
            any_valid = True
        if v["expired"]:
            any_expired = True

    trace.append(f"[2] refs_evaluated={len(incoming_refs)} any_valid={any_valid} any_expired={any_expired}")

    # Cross-check: when matrix_row_id is known, at least one valid ref MUST target it
    satisfies = any_valid
    if matrix_row_id and any_valid:
        matching = [
            p for p in per_ref
            if p.get("valid") and (p.get("matrix_row_on_row") == matrix_row_id)
        ]
        if not matching:
            satisfies = False
            trace.append(f"[3] valid refs present but none target matrix_row={matrix_row_id}")

    # Write-by-platform-role still blocked (same rule as Phase 2a sensitive_check)
    if action == "write" and role["key"] in {"sales_ae", "support_success"}:
        trace.append("[4] write on sensitive by platform role → deny regardless of refs")
        return {
            "decision": "deny",
            "required_approvers": required,
            "approval_matrix_row": matrix_row_id,
            "current_approvals_satisfy": False,
            "per_ref_validations": per_ref,
            "any_valid_ref": any_valid,
            "any_expired_ref": any_expired,
            "reasons": [_reason(
                "write_blocked",
                f"{role['key']} cannot write sensitive fields · use assist + approval",
                "assist_model.modes.assist.capabilities_forbidden",
            )],
            "trace": trace,
        }

    decision = "allow" if satisfies else "deny"
    reasons = [_reason(
        "sensitive_decision_2b",
        f"{decision} · required={','.join(required)} · matrix_row={matrix_row_id} · valid_refs={sum(1 for p in per_ref if p['valid'])}",
        matrix_row_id,
    )]
    if not satisfies and any_expired:
        reasons.append(_reason("approval_expired", "at least one ref is expired (prec-8)", "access_policy.precedence_rules.prec-8"))

    return {
        "decision": decision,
        "required_approvers": required,
        "approval_matrix_row": matrix_row_id,
        "current_approvals_satisfy": satisfies,
        "per_ref_validations": per_ref,
        "any_valid_ref": any_valid,
        "any_expired_ref": any_expired,
        "reasons": reasons,
        "trace": trace,
    }
