//! Policy evaluation for session signal writes.

use std::{sync::atomic::Ordering, time::Duration};

use super::state::SessionState;
use crate::schema::AgentPolicy;

// ── PolicyViolationKind ───────────────────────────────────────────────────────

/// Typed reason category for a policy check failure.
///
/// Allows callers to dispatch on the specific cause without parsing strings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyViolationKind {
    /// Session duration limit exceeded.
    Expired,
    /// Per-session signal count cap reached.
    CountCap,
    /// Signal type is in the `denied_signals` list.
    Denied,
    /// Signal type is not in the non-empty `allowed_signals` list.
    NotAllowed,
}

// ── PolicyViolation ───────────────────────────────────────────────────────────

/// Describes why a policy check rejected a signal write.
#[derive(Debug, Clone)]
pub struct PolicyViolation {
    /// Typed reason for the rejection — used for error-type dispatch.
    pub kind: PolicyViolationKind,
    pub signal_type: String,
    pub policy_name: String,
    pub reason: String,
}

// ── PolicyEvaluator ───────────────────────────────────────────────────────────

/// Evaluates policy rules against incoming session signals.
pub struct PolicyEvaluator<'a> {
    policy: &'a AgentPolicy,
    policy_name: &'a str,
}

impl<'a> PolicyEvaluator<'a> {
    #[must_use]
    pub const fn new(policy: &'a AgentPolicy, policy_name: &'a str) -> Self {
        Self {
            policy,
            policy_name,
        }
    }

    /// Check whether a signal can be written under this policy.
    ///
    /// `now_ns` is the current wall clock in nanoseconds since the Unix epoch
    /// (`Timestamp::now().as_nanos()` at the call site). The duration check
    /// measures age from the **durable** `started_at_ns`, never from the
    /// monotonic `started_at` `Instant`: a session restored on open re-seeds
    /// `started_at`, so reading the monotonic clock here would reset a restored
    /// session's age to ~zero and let an agent evade `max_session_duration`
    /// indefinitely by surviving across restarts. `started_at_ns` is the durable
    /// anchor, matching `close_session_internal`'s duration accounting.
    ///
    /// Returns `Ok(())` if all policy checks pass.
    ///
    /// # Errors
    ///
    /// Returns `PolicyViolation` describing the first rule violated.
    pub fn check(
        &self,
        signal_type: &str,
        state: &SessionState,
        now_ns: u64,
    ) -> Result<(), PolicyViolation> {
        let make_violation = |kind: PolicyViolationKind, reason: String| PolicyViolation {
            kind,
            signal_type: signal_type.to_owned(),
            policy_name: self.policy_name.to_owned(),
            reason,
        };

        // 1. Duration check — measured off the durable wall clock, not the
        //    monotonic Instant (which restore reseeds). Saturating so a backwards
        //    wall-clock step reports 0 elapsed rather than underflowing.
        let elapsed = Duration::from_nanos(now_ns.saturating_sub(state.started_at_ns));
        if elapsed > self.policy.max_session_duration {
            return Err(make_violation(
                PolicyViolationKind::Expired,
                format!(
                    "session expired after {:.1}s (max {:.1}s)",
                    elapsed.as_secs_f64(),
                    self.policy.max_session_duration.as_secs_f64()
                ),
            ));
        }

        // 2. Count cap (0 = unlimited).
        if self.policy.max_signals_per_session > 0
            && state.signals_written.load(Ordering::Relaxed)
                >= u64::from(self.policy.max_signals_per_session)
        {
            return Err(make_violation(
                PolicyViolationKind::CountCap,
                format!(
                    "signal count cap reached (max {})",
                    self.policy.max_signals_per_session
                ),
            ));
        }

        // 3. Deny list.
        if self
            .policy
            .denied_signals
            .iter()
            .any(|s| s.as_str() == signal_type)
        {
            return Err(make_violation(
                PolicyViolationKind::Denied,
                format!(
                    "signal '{signal_type}' is explicitly denied by policy '{}'",
                    self.policy_name
                ),
            ));
        }

        // 4. Allow list (empty allow list = all allowed).
        if !self.policy.allowed_signals.is_empty()
            && !self
                .policy
                .allowed_signals
                .iter()
                .any(|s| s.as_str() == signal_type)
        {
            return Err(make_violation(
                PolicyViolationKind::NotAllowed,
                format!(
                    "signal '{signal_type}' not in allowed_signals for policy '{}'",
                    self.policy_name
                ),
            ));
        }

        Ok(())
    }
}

// ── Unit tests ────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::{
        collections::HashMap,
        sync::{
            Arc, Mutex,
            atomic::{AtomicBool, AtomicU64},
        },
        time::{Duration, Instant},
    };

    use dashmap::DashMap;

    use super::{
        super::{
            audit::AuditLog,
            types::{AgentId, SessionId},
        },
        *,
    };
    use crate::schema::Timestamp;

    fn make_state(policy_name: &str) -> SessionState {
        // `started_at_ns` is "now" so the duration check sees a brand-new session.
        SessionState {
            id: SessionId(1),
            user_id: 100,
            agent_id: AgentId::new("test-agent").unwrap(),
            policy_name: policy_name.to_owned(),
            started_at: Instant::now(),
            started_at_ns: Timestamp::now().as_nanos(),
            metadata: HashMap::new(),
            signals: DashMap::new(),
            signaled_entities: DashMap::new(),
            annotations: Mutex::new(Vec::new()),
            signals_written: AtomicU64::new(0),
            signals_rejected: AtomicU64::new(0),
            audit_log: Mutex::new(AuditLog::new()),
            closed: Arc::new(AtomicBool::new(false)),
        }
    }

    #[test]
    fn policy_allow_list_accepts_allowed_signal() {
        let policy = AgentPolicy {
            allowed_signals: vec!["view".to_string()],
            denied_signals: vec![],
            max_session_duration: Duration::from_secs(3600),
            max_signals_per_session: 0,
        };
        let evaluator = PolicyEvaluator::new(&policy, "test_policy");
        let state = make_state("test_policy");
        assert!(
            evaluator
                .check("view", &state, Timestamp::now().as_nanos())
                .is_ok()
        );
    }

    #[test]
    fn policy_allow_list_rejects_unknown_signal() {
        let policy = AgentPolicy {
            allowed_signals: vec!["view".to_string()],
            denied_signals: vec![],
            max_session_duration: Duration::from_secs(3600),
            max_signals_per_session: 0,
        };
        let evaluator = PolicyEvaluator::new(&policy, "test_policy");
        let state = make_state("test_policy");
        assert!(
            evaluator
                .check("like", &state, Timestamp::now().as_nanos())
                .is_err()
        );
    }

    #[test]
    fn policy_deny_list_rejects_denied_signal() {
        let policy = AgentPolicy {
            allowed_signals: vec![],
            denied_signals: vec!["block".to_string()],
            max_session_duration: Duration::from_secs(3600),
            max_signals_per_session: 0,
        };
        let evaluator = PolicyEvaluator::new(&policy, "test_policy");
        let state = make_state("test_policy");
        assert!(
            evaluator
                .check("block", &state, Timestamp::now().as_nanos())
                .is_err()
        );
    }

    #[test]
    fn policy_count_cap_rejects_when_exceeded() {
        let policy = AgentPolicy {
            allowed_signals: vec![],
            denied_signals: vec![],
            max_session_duration: Duration::from_secs(3600),
            max_signals_per_session: 2,
        };
        let evaluator = PolicyEvaluator::new(&policy, "test_policy");
        let state = make_state("test_policy");
        state.signals_written.store(2, Ordering::Relaxed);
        assert!(
            evaluator
                .check("view", &state, Timestamp::now().as_nanos())
                .is_err()
        );
    }

    #[test]
    fn policy_expired_session_rejected() {
        let policy = AgentPolicy {
            allowed_signals: vec![],
            denied_signals: vec![],
            max_session_duration: Duration::from_millis(1),
            max_signals_per_session: 0,
        };
        let evaluator = PolicyEvaluator::new(&policy, "test_policy");
        let now_ns = Timestamp::now().as_nanos();
        let state = SessionState {
            id: SessionId(1),
            user_id: 100,
            agent_id: AgentId::new("test-agent").unwrap(),
            policy_name: "test_policy".to_owned(),
            started_at: Instant::now(),
            // durable start far in the past:
            started_at_ns: now_ns.saturating_sub(10 * 1_000_000_000),
            metadata: HashMap::new(),
            signals: DashMap::new(),
            signaled_entities: DashMap::new(),
            annotations: Mutex::new(Vec::new()),
            signals_written: AtomicU64::new(0),
            signals_rejected: AtomicU64::new(0),
            audit_log: Mutex::new(AuditLog::new()),
            closed: Arc::new(AtomicBool::new(false)),
        };
        assert!(evaluator.check("view", &state, now_ns).is_err());
    }

    /// Regression (BLOCKER): TTL / `max_session_duration` must survive a
    /// simulated restore. A restored session re-seeds the monotonic `started_at`
    /// with a fresh `Instant::now()` while keeping the durable `started_at_ns`
    /// far in the past. The duration check must reject off `started_at_ns`, not
    /// the near-zero monotonic age — otherwise an agent evades the bound by
    /// surviving across restarts.
    #[test]
    fn policy_duration_survives_simulated_restore() {
        let policy = AgentPolicy {
            allowed_signals: vec![],
            denied_signals: vec![],
            max_session_duration: Duration::from_secs(3600),
            max_signals_per_session: 0,
        };
        let evaluator = PolicyEvaluator::new(&policy, "test_policy");
        let now_ns = Timestamp::now().as_nanos();
        let two_hours_ns = 2 * 3600 * 1_000_000_000u64;
        let state = SessionState {
            id: SessionId(1),
            user_id: 100,
            agent_id: AgentId::new("test-agent").unwrap(),
            policy_name: "test_policy".to_owned(),
            // Restore shape: monotonic clock fresh, durable start 2h ago.
            started_at: Instant::now(),
            started_at_ns: now_ns.saturating_sub(two_hours_ns),
            metadata: HashMap::new(),
            signals: DashMap::new(),
            signaled_entities: DashMap::new(),
            annotations: Mutex::new(Vec::new()),
            signals_written: AtomicU64::new(0),
            signals_rejected: AtomicU64::new(0),
            audit_log: Mutex::new(AuditLog::new()),
            closed: Arc::new(AtomicBool::new(false)),
        };
        let err = evaluator
            .check("view", &state, now_ns)
            .expect_err("a 2h-old session under a 1h cap must be rejected after restore");
        assert_eq!(err.kind, PolicyViolationKind::Expired);
    }
}
