#!/usr/bin/env python3
"""
Crovia Sentinel v0.2 – Transparency Drift & Topology Alert (OPEN EDITION)

Versione completamente OPEN-CORE del sistema Sentinel:

✓ misura deriva temporale
✓ calcola trasparenza (T_t)
✓ misura scostamento dal gruppo pari (opzionale)
✓ produce un livello: OK / WARN / ALERT

Caratteristiche della versione FREE:
- nessuna dipendenza da DSSE PRO
- nessuna firma, nessun billing
- nessuna attestation
- tutto locale e offline
"""

from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional, List
import math
import numpy as np
import json
import argparse


# ======================
# CONFIGURAZIONE
# ======================

@dataclass
class SentinelConfig:
    theta_max_deg: float = 45.0
    memory: float = 0.7
    delta_T_base: float = 0.25
    delta_T_scale: float = 0.4
    z_thresh: float = 1.5
    z_max: float = 3.0
    alpha: float = 0.25
    beta: float = 0.45
    gamma: float = 0.30
    eta: float = 0.2
    opacity_veto_threshold: float = 0.8
    ok_threshold: float = 0.3
    alert_threshold: float = 0.6
    transparency_neg_weight: float = 1.5


# ======================
# HELPERS
# ======================

def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
    return max(lo, min(hi, x))

def _safe_acos(x: float) -> float:
    return math.acos(max(-1.0, min(1.0, x)))


# ======================
# TRASPARENZA (T_t)
# ======================

def transparency_index(snapshot: Dict[str, Any], config: SentinelConfig) -> float:
    card_len = float(snapshot.get("card_length", 0))
    f_card = _clamp(card_len / 2000.0)

    safety_words = float(snapshot.get("safety_words", 0))
    f_safety = 0.0 if safety_words <= 0 else (0.5 if safety_words < 100 else 1.0)

    num_declared = float(snapshot.get("num_declared_datasets", 0))
    expected = float(snapshot.get("expected_datasets", 5)) or 5.0
    f_datasets = _clamp(num_declared / expected)

    receipts_fraction = float(snapshot.get("receipts_fraction", 0.0))
    f_receipts = _clamp(receipts_fraction)

    P_t = (f_card + f_safety + f_datasets + f_receipts) / 4.0

    missing_fields_fraction = float(snapshot.get("missing_fields_fraction", 0.0))
    f_gap = _clamp(missing_fields_fraction)

    legal_ambiguity_level = float(snapshot.get("legal_ambiguity_level", 0.0))
    f_legal = _clamp(legal_ambiguity_level)

    N_t = (f_gap + f_legal) / 2.0

    w = config.transparency_neg_weight
    raw = P_t - w * N_t

    return _clamp((raw + 1.5) / 2.5)


# ======================
# EMBEDDING FREE (stub semplice)
# ======================

def embed_metadata(snapshot: Dict[str, Any]) -> np.ndarray:
    v = np.array([
        float(snapshot.get("card_length", 0)) / 2000.0,
        float(snapshot.get("safety_words", 0)) / 200.0,
        float(snapshot.get("num_declared_datasets", 0)) / 10.0,
        float(snapshot.get("receipts_fraction", 0.0)),
        float(snapshot.get("missing_fields_fraction", 0.0)),
        float(snapshot.get("legal_ambiguity_level", 0.0)),
    ], dtype=float)

    norm = np.linalg.norm(v)
    if norm == 0:
        v = np.array([0,0,0,0,0,1], dtype=float)
        norm = 1.0

    return v / norm


# ======================
# CORE SENTINEL v0.2
# ======================

def run_sentinel_v0_2(
    snapshot_t: Dict[str, Any],
    snapshot_prev: Dict[str, Any],
    group_stats: Optional[Dict[str, Any]] = None,
    state_prev: Optional[Dict[str, Any]] = None,
    config: Optional[SentinelConfig] = None,
) -> Dict[str, Any]:

    if config is None:
        config = SentinelConfig()

    if state_prev is None:
        state_prev = {}

    z_t = embed_metadata(snapshot_t)
    z_prev = embed_metadata(snapshot_prev)

    cos_tp = float(np.dot(z_t, z_prev))
    theta = _safe_acos(cos_tp)

    lam = config.memory
    theta_cum = lam * float(state_prev.get("theta_cum", 0.0)) + (1 - lam) * theta

    theta_max = math.radians(config.theta_max_deg)
    S_drift = _clamp(theta_cum / theta_max)

    T_t = transparency_index(snapshot_t, config)
    T_prev = transparency_index(snapshot_prev, config)

    delta_T = T_t - T_prev
    if delta_T >= 0:
        S_opacity = 0.0
    else:
        DELTA_T_MAX = config.delta_T_base + config.delta_T_scale * max(T_prev, 0.5)
        S_opacity = _clamp(abs(delta_T) / DELTA_T_MAX)

    if group_stats is None or int(group_stats.get("size", 0)) < 5:
        S_peer = 0.0
    else:
        mu = group_stats["mu"]
        mu = mu / (np.linalg.norm(mu) + 1e-6)
        cos_g = float(np.dot(z_t, mu))
        phi = _safe_acos(cos_g)
        z_raw = (phi - group_stats["phi_mean"]) / (group_stats["phi_std"] + 1e-6)
        if z_raw <= config.z_thresh:
            S_peer = 0.0
        else:
            S_peer = _clamp((z_raw - config.z_thresh) /
                            (config.z_max - config.z_thresh))
    
    S_base = (config.alpha * S_drift +
              config.beta * S_opacity +
              config.gamma * S_peer)

    S_boost = 1 + config.eta * max(S_drift, S_opacity, S_peer)
    S_alert = _clamp(S_base * S_boost)

    if S_opacity > config.opacity_veto_threshold and S_alert < config.alert_threshold:
        S_alert = config.alert_threshold

    if S_alert < config.ok_threshold:
        level = "OK"
    elif S_alert < config.alert_threshold:
        level = "WARN"
    else:
        level = "ALERT"

    reasons = []
    if S_opacity > 0: reasons.append("transparency_drift")
    if S_drift > 0.2: reasons.append("temporal_drift")
    if S_peer > 0.3: reasons.append("peer_deviation")
    if not reasons: reasons.append("stable")

    return {
        "level": level,
        "S_alert": S_alert,
        "S_drift": S_drift,
        "S_opacity": S_opacity,
        "S_peer": S_peer,
        "T_t": T_t,
        "T_prev": T_prev,
        "reasons": reasons,
        "state": {"theta_cum": theta_cum, "last_level": level},
    }


# ======================
# CLI
# ======================

def cli():
    ap = argparse.ArgumentParser(description="Crovia Sentinel v0.2 (Open Edition)")
    ap.add_argument("--current", required=True, help="JSON file snapshot_t")
    ap.add_argument("--previous", required=True, help="JSON file snapshot_prev")
    args = ap.parse_args()

    with open(args.current, "r") as f:
        cur = json.load(f)
    with open(args.previous, "r") as f:
        prev = json.load(f)

    out = run_sentinel_v0_2(cur, prev)
    print(json.dumps(out, indent=2))

if __name__ == "__main__":
    cli()

