//! Biscuit-attenuated share links — plan 17 §S5.
//!
//! Mint short-lived capability tokens for an item, vault, or
//! collection. Caveats: time-bound, IP-bound, optional single-use.
//! Verifies offline (no DB hit on the verify path beyond the
//! revocation lookup).
//!
//! ## Why a separate root key from `assay-auth`
//!
//! Per plan §"Open questions" #3: keep blast radius small — a
//! compromise of the auth biscuit root mustn't grant share-token
//! forging power. The vault crate maintains its own root keypair in
//! `vault.biscuit_root_keys`, mirroring the auth crate's pattern.
//!
//! ## Revocation
//!
//! Biscuit's `revocation_identifiers()` returns one ID per block. We
//! store any ID an operator explicitly revokes in `vault.share_revoked`
//! and check every block's ID against that table on verify. Block-
//! level granularity matches biscuit's design — revoking a parent
//! cascades to attenuated children.

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::error::Result;

/// What the share token grants access to. Phase 4 ships these three;
/// future targets (e.g. transit key) just add a variant.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", content = "id")]
#[non_exhaustive]
pub enum ShareTarget {
    Item(String),
    Vault(String),
    Collection(String),
}

impl ShareTarget {
    pub fn kind(&self) -> &'static str {
        match self {
            Self::Item(_) => "item",
            Self::Vault(_) => "vault",
            Self::Collection(_) => "collection",
        }
    }

    pub fn id(&self) -> &str {
        match self {
            Self::Item(id) | Self::Vault(id) | Self::Collection(id) => id,
        }
    }
}

/// Caveats wired into the biscuit at mint time.
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct ShareCaveats {
    /// Token expires this many seconds after mint.
    pub ttl_secs: u64,
    /// If set, verifier must present a client IP that matches.
    /// Stored as a string (CIDR form, e.g. "10.0.0.0/8") so callers
    /// can use any matcher; the runtime parses on verify.
    pub max_ip_cidr: Option<String>,
    /// If set, additional per-token usage limit (Phase 4 ships the
    /// caveat shape; the actual single-use enforcement requires
    /// server-side counter — that lands in a follow-up commit).
    pub max_uses: Option<u32>,
}

/// Successful mint result. The `token` is the base64-URL-safe biscuit
/// the caller hands out. The `revocation_ids` are the IDs an operator
/// uses with [`ShareService::revoke`] to disable the token.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MintedShare {
    pub token: String,
    pub revocation_ids: Vec<String>,
    pub expires_at: f64,
}

/// Verified-token grant — what `verify` returns when caveats pass.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct ShareGrant {
    pub target: ShareTarget,
}

/// Pure-IO trait for the revocation table. Same shape as the rest of
/// the vault's storage traits.
#[async_trait]
pub trait RevocationStore: Send + Sync + 'static {
    /// Add one revocation id.
    async fn add(&self, key_id: &str, reason: &str) -> Result<()>;

    /// Whether ANY of the supplied biscuit-block revocation IDs is in
    /// the revocation list. Verify hot-path; takes a slice so the
    /// caller passes every block's ID at once.
    async fn any_revoked(&self, key_ids: &[String]) -> Result<bool>;

    /// List revoked IDs (admin / dashboard).
    async fn list(&self) -> Result<Vec<RevocationEntry>>;
}

/// One row in `vault.share_revoked`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RevocationEntry {
    pub key_id: String,
    pub revoked_at: f64,
    pub reason: String,
}

#[cfg(feature = "vault-share")]
mod biscuit_impl {
    use super::*;
    use biscuit_auth::macros::authorizer;
    use biscuit_auth::macros::biscuit;
    use biscuit_auth::{Biscuit, KeyPair, PublicKey};
    use std::sync::Arc;
    use std::time::{SystemTime, UNIX_EPOCH};

    use crate::error::VaultError;

    /// Cheap-to-clone share service. Wraps the active biscuit root
    /// keypair + the revocation store.
    #[derive(Clone)]
    pub struct ShareService {
        keypair: Arc<KeyPair>,
        public_key: PublicKey,
        revocations: Arc<dyn RevocationStore>,
    }

    impl ShareService {
        pub fn new(keypair: KeyPair, revocations: Arc<dyn RevocationStore>) -> Self {
            let public_key = keypair.public();
            Self {
                keypair: Arc::new(keypair),
                public_key,
                revocations,
            }
        }

        pub fn public_key(&self) -> PublicKey {
            self.public_key
        }

        /// Mint a share token. The biscuit's authority block carries
        /// the target + the caveats; verifiers run the same logic.
        pub fn mint(
            &self,
            target: ShareTarget,
            caveats: ShareCaveats,
        ) -> Result<MintedShare> {
            let now = SystemTime::now();
            let now_secs = now
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs();
            let exp_secs = now_secs + caveats.ttl_secs.max(1);
            // biscuit-auth's `time()` predicate carries Date values
            // (its `Term::Date` type round-trips with `SystemTime`).
            // We compare Date-to-Date so the check actually fires on
            // expiry. SystemTime is what biscuit's macros accept via
            // its ToAnyParam impl.
            let exp_st = UNIX_EPOCH + std::time::Duration::from_secs(exp_secs);
            let kind = target.kind();
            let id = target.id().to_string();

            // CIDR + max_uses caveats deferred to a follow-up commit —
            // biscuit's Datalog needs operator-provided exact strings
            // (no built-in CIDR matcher) and max_uses requires a
            // server-side counter the revocation table doesn't model.
            // Phase 4 ships time-bound + targets + revocation; the rest
            // rides on top once the design is locked.
            let _ = &caveats.max_ip_cidr;
            let _ = caveats.max_uses;

            let builder = biscuit!(
                r#"
                target({kind}, {id});
                check if time($t), $t <= {exp};
                "#,
                kind = kind,
                id = id,
                exp = exp_st,
            );
            let biscuit = builder
                .build(&self.keypair)
                .map_err(|e| VaultError::Crypto(format!("biscuit build: {e:?}")))?;
            let token = biscuit
                .to_base64()
                .map_err(|e| VaultError::Crypto(format!("biscuit base64: {e:?}")))?;

            let revocation_ids = biscuit
                .revocation_identifiers()
                .iter()
                .map(|id| data_encoding::HEXLOWER.encode(id))
                .collect::<Vec<_>>();

            Ok(MintedShare {
                token,
                revocation_ids,
                expires_at: exp_secs as f64,
            })
        }

        /// Verify a share token. Returns the grant on success;
        /// surfaces:
        /// - `Forbidden`     — token revoked OR caveat (time / ip) failed
        /// - `Crypto`        — signature / parse fail
        /// - `Invalid`       — malformed wire format
        pub async fn verify(
            &self,
            token: &str,
            client_ip_cidr: Option<&str>,
        ) -> Result<ShareGrant> {
            let public_key = self.public_key;
            let biscuit = Biscuit::from_base64(token, public_key)
                .map_err(|e| VaultError::Crypto(format!("biscuit verify: {e:?}")))?;

            // Revocation check — any block's revocation id in the table → forbidden.
            let block_ids: Vec<String> = biscuit
                .revocation_identifiers()
                .iter()
                .map(|id| data_encoding::HEXLOWER.encode(id))
                .collect();
            if self.revocations.any_revoked(&block_ids).await? {
                return Err(VaultError::Forbidden);
            }

            let now = SystemTime::now();
            let _ = client_ip_cidr; // CIDR matching deferred — see mint() comment.
            let authorizer = authorizer!(
                r#"
                time({now});
                allow if true;
                "#,
                now = now,
            );
            let mut authorizer = authorizer
                .build(&biscuit)
                .map_err(|e| VaultError::Crypto(format!("biscuit authorizer: {e:?}")))?;
            authorizer
                .authorize()
                .map_err(|_| VaultError::Forbidden)?;

            // Pull the target back out of the authority facts.
            let facts: Vec<(String, String)> = authorizer
                .query("data($k, $i) <- target($k, $i)")
                .map_err(|e| VaultError::Crypto(format!("biscuit query: {e:?}")))?;
            let (kind, id) = facts
                .into_iter()
                .next()
                .ok_or_else(|| VaultError::Crypto("biscuit missing target fact".into()))?;
            let target = match kind.as_str() {
                "item" => ShareTarget::Item(id),
                "vault" => ShareTarget::Vault(id),
                "collection" => ShareTarget::Collection(id),
                other => {
                    return Err(VaultError::Crypto(format!(
                        "unknown target kind '{other}'"
                    )));
                }
            };
            Ok(ShareGrant { target })
        }

        /// Revoke a token by recording one of its block revocation IDs.
        pub async fn revoke(
            &self,
            revocation_id: &str,
            reason: &str,
        ) -> Result<()> {
            self.revocations.add(revocation_id, reason).await
        }

        pub fn revocations(&self) -> &Arc<dyn RevocationStore> {
            &self.revocations
        }
    }

    #[cfg(test)]
    mod tests {
        use super::*;
        use std::collections::HashSet;
        use tokio::sync::RwLock;

        struct InMemRevocations {
            ids: RwLock<HashSet<String>>,
        }

        #[async_trait]
        impl RevocationStore for InMemRevocations {
            async fn add(&self, key_id: &str, _reason: &str) -> Result<()> {
                self.ids.write().await.insert(key_id.to_string());
                Ok(())
            }
            async fn any_revoked(&self, key_ids: &[String]) -> Result<bool> {
                let g = self.ids.read().await;
                Ok(key_ids.iter().any(|k| g.contains(k)))
            }
            async fn list(&self) -> Result<Vec<RevocationEntry>> {
                Ok(Vec::new())
            }
        }

        fn fresh_service() -> ShareService {
            ShareService::new(
                KeyPair::new(),
                Arc::new(InMemRevocations {
                    ids: RwLock::new(HashSet::new()),
                }),
            )
        }

        #[tokio::test]
        async fn mint_and_verify_round_trip() {
            let svc = fresh_service();
            let m = svc
                .mint(
                    ShareTarget::Item("item-1".into()),
                    ShareCaveats {
                        ttl_secs: 60,
                        ..Default::default()
                    },
                )
                .unwrap();
            let grant = svc
                .verify(&m.token, None)
                .await
                .unwrap_or_else(|e| panic!("verify of just-minted ttl=60 token must succeed: {e:?}"));
            assert_eq!(grant.target, ShareTarget::Item("item-1".into()));
        }

        #[tokio::test]
        async fn expired_token_rejected() {
            let svc = fresh_service();
            // ttl = 0 forces immediate expiry — the floor of 1s in mint
            // means we have to wait, so use a token with a past expiry
            // by minting then sleeping past it. Use ttl=1 + sleep.
            let m = svc
                .mint(
                    ShareTarget::Item("ephemeral".into()),
                    ShareCaveats {
                        ttl_secs: 1,
                        ..Default::default()
                    },
                )
                .unwrap();
            // biscuit's Date type rounds to seconds; sleep clearly past
            // the 1-second TTL so the verifier's "time" fact is on the
            // wrong side of the inequality regardless of nanos.
            tokio::time::sleep(std::time::Duration::from_millis(2200)).await;
            let res = svc.verify(&m.token, None).await;
            assert!(matches!(res, Err(VaultError::Forbidden)));
        }

        #[tokio::test]
        async fn revoked_token_rejected() {
            let svc = fresh_service();
            let m = svc
                .mint(
                    ShareTarget::Vault("v1".into()),
                    ShareCaveats {
                        ttl_secs: 60,
                        ..Default::default()
                    },
                )
                .unwrap();
            svc.verify(&m.token, None)
                .await
                .unwrap_or_else(|e| panic!("first verify before revoke must succeed: {e:?}"));
            // Revoke and re-verify.
            svc.revoke(&m.revocation_ids[0], "compromised").await.unwrap();
            let res = svc.verify(&m.token, None).await;
            assert!(matches!(res, Err(VaultError::Forbidden)));
        }

        #[tokio::test]
        async fn wrong_root_key_fails_verify() {
            let svc = fresh_service();
            let m = svc
                .mint(
                    ShareTarget::Vault("v1".into()),
                    ShareCaveats {
                        ttl_secs: 60,
                        ..Default::default()
                    },
                )
                .unwrap();
            // Different service = different root keypair.
            let other = fresh_service();
            let res = other.verify(&m.token, None).await;
            assert!(matches!(res, Err(VaultError::Crypto(_))));
        }

        #[tokio::test]
        async fn target_kinds_round_trip() {
            let svc = fresh_service();
            for t in [
                ShareTarget::Item("i".into()),
                ShareTarget::Vault("v".into()),
                ShareTarget::Collection("c".into()),
            ] {
                let m = svc
                    .mint(
                        t.clone(),
                        ShareCaveats {
                            ttl_secs: 60,
                            ..Default::default()
                        },
                    )
                    .unwrap();
                let grant = svc.verify(&m.token, None).await.unwrap();
                assert_eq!(grant.target, t);
            }
        }
    }
}

#[cfg(feature = "vault-share")]
pub use biscuit_impl::ShareService;
