//! Agent capability operations on `TidalDb` (M10p2).
//!
//! `grant_capability` / `revoke_capability` manage scoped agent rights;
//! `agent_signal_for_community` is the capability-gated community write. The
//! revocation check is a single atomic load on the synchronous write path —
//! `< 1s` by construction, never the sweeper. Denials emit a tracing audit event
//! and return a typed error; the local profile is never touched by these paths.

use std::time::Duration;

use super::TidalDb;
use crate::{
    governance::{CapabilityToken, ScopeClass, ScopePermission, scope::CommunityId},
    schema::{EntityId, TidalError, Timestamp},
    storage::{Tag, encode_key},
};

impl TidalDb {
    /// Grant an agent a capability token with the given scope permissions and a
    /// TTL. Returns the new token id. Persisted under `Tag::Capability`.
    ///
    /// A `ttl` of `None` grants a non-expiring token. The expiry is stored as a
    /// nanosecond `u64` wall-clock timestamp, so the maximum representable TTL is
    /// `u64::MAX` ns (~584.9 years from now). A `ttl` whose nanoseconds exceed
    /// `u64::MAX`, or whose addition would overflow, saturates to `u64::MAX`
    /// (effectively non-expiring) rather than wrapping to a near-future instant
    /// that would expire the token immediately.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if the database is not writeable.
    /// - `TidalError::Storage` on persistence failure.
    pub fn grant_capability(
        &self,
        agent_id: &str,
        permissions: Vec<ScopePermission>,
        ttl: Option<Duration>,
    ) -> crate::Result<String> {
        self.require_writeable("grant_capability")?;
        let now_ns = Timestamp::now().as_nanos();
        // `Duration::as_nanos` is u128; clamp to u64 BEFORE adding so a TTL above
        // `u64::MAX` ns (~584.9 years) does not truncate-then-wrap into a
        // near-future expiry. `saturating_add` then guards the addition itself.
        let expires_at_ns = ttl.map_or(u64::MAX, |d| {
            let ttl_ns = u64::try_from(d.as_nanos()).unwrap_or(u64::MAX);
            now_ns.saturating_add(ttl_ns)
        });
        let token_id = self.capability_registry.next_token_id(agent_id);
        let token = CapabilityToken {
            token_id: token_id.clone(),
            agent_id: agent_id.to_owned(),
            permissions,
            created_at_ns: now_ns,
            expires_at_ns,
            revoked_at_ns: std::sync::atomic::AtomicU64::new(0),
        };
        self.persist_capability(&token)?;
        self.capability_registry.insert(token);
        Ok(token_id)
    }

    /// Revoke a capability token by id. Returns `true` if the token existed.
    ///
    /// The in-memory revocation is an atomic store visible to the very next
    /// capability check (`< 1s` p99, in practice immediate). The revocation is
    /// then persisted (the token row is rewritten with its revoked timestamp, and
    /// a `Tag::CapabilityRevocation` audit record is appended) so it survives
    /// restart and is inspectable by `tidalctl`.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if the database is not writeable.
    /// - `TidalError::Storage` on persistence failure.
    pub fn revoke_capability(&self, token_id: &str) -> crate::Result<bool> {
        self.require_writeable("revoke_capability")?;
        let now_ns = Timestamp::now().as_nanos();
        if !self.capability_registry.revoke(token_id, now_ns) {
            return Ok(false);
        }
        // Persist the now-revoked token + an audit record.
        if let Some(t) = self.capability_registry.token(token_id) {
            self.persist_capability(&t)?;
            self.persist_revocation(&t.token_id, &t.agent_id, now_ns)?;
        }
        Ok(true)
    }

    /// Whether `agent_id` currently holds a live capability for `(scope, action)`.
    #[must_use]
    pub fn check_capability(&self, agent_id: &str, scope: ScopeClass, write: bool) -> bool {
        self.capability_registry
            .check(agent_id, scope, write, Timestamp::now().as_nanos())
            .is_ok()
    }

    /// The capability tokens granted to an agent (for inspection).
    #[must_use]
    pub fn agent_capabilities(&self, agent_id: &str) -> Vec<CapabilityToken> {
        self.capability_registry.tokens_for(agent_id)
    }

    /// The persisted revocation history: `(token_id, agent_id, revoked_at_ns)`
    /// records from `Tag::CapabilityRevocation`, in storage order. Empty in
    /// ephemeral mode.
    #[must_use]
    pub fn revocation_history(&self) -> Vec<(String, String, u64)> {
        let Some(ref storage) = self.storage else {
            return Vec::new();
        };
        let prefix = crate::storage::entity_tag_prefix(EntityId::new(0), Tag::CapabilityRevocation);
        storage
            .items_engine()
            .scan_prefix(&prefix)
            .flatten()
            .filter_map(|(_, v)| parse_revocation_record(&v))
            .collect()
    }

    /// Record a community-scoped contribution **as an agent**, enforcing the
    /// agent's capability before the community write.
    ///
    /// Requires the agent to hold a live (non-revoked, non-expired)
    /// community-write capability. On denial the call returns
    /// `TidalError::invalid_input` (a policy rejection) and emits a tracing audit
    /// event; nothing is written and the local profile is untouched. When
    /// permitted, delegates to [`signal_for_community`](Self::signal_for_community)
    /// (which still applies the membership + governance gates).
    ///
    /// # Errors
    ///
    /// - `TidalError::invalid_input` if the agent's capability is insufficient,
    ///   revoked, or expired.
    /// - Errors from [`signal_for_community`](Self::signal_for_community).
    // Mirrors `signal_for_community`'s signal coordinates plus the acting agent.
    #[allow(clippy::too_many_arguments)]
    pub fn agent_signal_for_community(
        &self,
        agent_id: &str,
        signal_type: &str,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
        user: crate::governance::UserId,
        community: CommunityId,
    ) -> crate::Result<bool> {
        // <1s capability gate: atomic revocation/expiry check.
        if let Err(denial) = self.capability_registry.check(
            agent_id,
            ScopeClass::Community,
            true,
            Timestamp::now().as_nanos(),
        ) {
            // Audit the denial (operator-visible) and reject.
            tracing::warn!(
                agent = agent_id,
                community = %community,
                reason = %denial,
                "agent community write denied by capability policy"
            );
            return Err(TidalError::invalid_input(format!(
                "agent '{agent_id}' denied community write: {denial}"
            )));
        }
        // Attribute the contribution to the writing agent so it can be removed by
        // agent scope (M10p3 remove-by-scope).
        self.community_contribute(
            signal_type,
            entity_id,
            weight,
            timestamp,
            user,
            community,
            agent_id,
        )
    }

    /// Persist a capability token under `Tag::Capability` (key = token id).
    fn persist_capability(&self, token: &CapabilityToken) -> crate::Result<()> {
        let Some(ref storage) = self.storage else {
            return Ok(());
        };
        let key = encode_key(EntityId::new(0), Tag::Capability, token.token_id.as_bytes());
        storage
            .items_engine()
            .put(&key, &token.to_bytes()?)
            .map_err(TidalError::from)
    }

    /// Append a revocation audit record under `Tag::CapabilityRevocation`.
    #[allow(clippy::unused_self)]
    fn persist_revocation(
        &self,
        token_id: &str,
        agent_id: &str,
        revoked_at_ns: u64,
    ) -> crate::Result<()> {
        let Some(ref storage) = self.storage else {
            return Ok(());
        };
        let key = encode_key(
            EntityId::new(0),
            Tag::CapabilityRevocation,
            token_id.as_bytes(),
        );
        // Serialize via serde rather than string-interpolating caller-controlled
        // `agent_id`/`token_id` into hand-rolled JSON: an id containing a quote
        // or backslash would otherwise produce malformed JSON that
        // `parse_revocation_record` silently drops, losing a revocation audit
        // record. serde escapes correctly for any input.
        let record = RevocationRecord {
            token_id,
            agent_id,
            revoked_at_ns,
        };
        let bytes = serde_json::to_vec(&record).map_err(|e| {
            TidalError::internal("persist_revocation", format!("serialization failed: {e}"))
        })?;
        storage
            .items_engine()
            .put(&key, &bytes)
            .map_err(TidalError::from)
    }
}

/// On-disk shape of a `Tag::CapabilityRevocation` audit record.
///
/// Borrowed `&str` fields so the write path serializes without cloning the ids.
#[derive(serde::Serialize)]
struct RevocationRecord<'a> {
    token_id: &'a str,
    agent_id: &'a str,
    revoked_at_ns: u64,
}

/// Parse a `Tag::CapabilityRevocation` JSON record into
/// `(token_id, agent_id, revoked_at_ns)`.
fn parse_revocation_record(bytes: &[u8]) -> Option<(String, String, u64)> {
    let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
    let token_id = v.get("token_id")?.as_str()?.to_owned();
    let agent_id = v.get("agent_id")?.as_str()?.to_owned();
    let revoked_at_ns = v.get("revoked_at_ns")?.as_u64()?;
    Some((token_id, agent_id, revoked_at_ns))
}

#[cfg(test)]
mod tests {
    use crate::{
        db::TidalDb,
        governance::{ScopeClass, ScopePermission},
    };

    /// A TTL whose nanoseconds exceed `u64::MAX` must saturate the stored expiry
    /// to `u64::MAX` (effectively non-expiring) instead of truncating the u128
    /// nanos to u64 and wrapping into a near-future instant that would expire the
    /// token almost immediately.
    #[test]
    fn very_long_ttl_saturates_instead_of_expiring_early() {
        let db = TidalDb::builder()
            .ephemeral()
            .open()
            .expect("ephemeral open should succeed");

        // `Duration::MAX` is ~5.85e11 years; its nanos vastly exceed `u64::MAX`.
        // Before the fix `d.as_nanos() as u64` truncated this to a tiny value,
        // producing an expiry only a few seconds out.
        let ttl = std::time::Duration::MAX;
        let token_id = db
            .grant_capability(
                "agent-long-lived",
                vec![ScopePermission::read_write(ScopeClass::Community)],
                Some(ttl),
            )
            .expect("grant should succeed");

        let tokens = db.agent_capabilities("agent-long-lived");
        let token = tokens
            .iter()
            .find(|t| t.token_id == token_id)
            .expect("granted token must be retrievable");

        assert_eq!(
            token.expires_at_ns,
            u64::MAX,
            "an over-u64 TTL must saturate the expiry to u64::MAX, not wrap"
        );
        // The capability must still be live right now (and far into the future),
        // proving it did not expire early due to a truncated/wrapped expiry.
        assert!(
            db.check_capability("agent-long-lived", ScopeClass::Community, true),
            "saturated-expiry token must remain a live community-write capability"
        );
        assert!(
            !token.is_expired(u64::MAX - 1),
            "token must not be expired even near the far end of the u64 ns range"
        );
    }

    /// A `None` TTL grants a non-expiring token (`expires_at_ns == u64::MAX`).
    #[test]
    fn none_ttl_is_non_expiring() {
        let db = TidalDb::builder()
            .ephemeral()
            .open()
            .expect("ephemeral open should succeed");

        let token_id = db
            .grant_capability(
                "agent-forever",
                vec![ScopePermission::read_write(ScopeClass::Community)],
                None,
            )
            .expect("grant should succeed");

        let tokens = db.agent_capabilities("agent-forever");
        let token = tokens
            .iter()
            .find(|t| t.token_id == token_id)
            .expect("granted token must be retrievable");
        assert_eq!(token.expires_at_ns, u64::MAX, "None TTL must never expire");
    }
}
