//! `SecretsServiceImpl` — wires the `SecretsRepo` underneath the
//! `picloud_shared::SecretsService` trait that scripts see via the Rhai
//! bridge.
//!
//! Layers added here (vs the raw repo):
//!
//! 1. Name validation (non-empty, ≤255 bytes) at the SDK boundary.
//! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
//!    `authz::require(...)`; when it's `None` (public unauthenticated
//!    HTTP) we skip the check. Cross-app isolation is unaffected — every
//!    query is keyed by `cx.app_id`, never an argument.
//! 3. **JSON ⇄ ciphertext**: `set` serializes the value to JSON bytes,
//!    enforces the per-secret size cap, and AES-256-GCM-seals it; `get`
//!    decrypts and deserializes back to the same JSON shape (a String
//!    round-trips to a String, not a JSON-quoted `"\"…\""`).
//!
//! Deliberately **no `ServiceEvent` emission** — secret writes do not
//! fire triggers (footgun avoidance; see `docs/sdk-shape.md` + the
//! v1.1.7 brief §2).

use std::sync::Arc;

use async_trait::async_trait;
use picloud_shared::{
    crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
    SecretsService,
};

use crate::authz::{self, AuthzRepo, Capability};
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};

// `SecretOwner` is defined one layer down in `secrets_repo` (it keys both
// the storage CRUD and the AAD). Re-exported here so the historical
// `secrets_service::SecretOwner` path stays stable.
pub use crate::secrets_repo::SecretOwner;

/// Current AES-GCM envelope version for the per-app secret store.
/// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1.
pub const SECRET_ENVELOPE_V1: i16 = 1;

/// The env scope under which an app's OWN secrets are stored. App secrets
/// are env-agnostic — only group secrets carry a concrete environment.
const APP_SECRET_SCOPE: &str = "*";

/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a cross-row
/// swap (moving one owner's ciphertext under another's slot) fails
/// decryption. The **app** form is byte-identical to the pre-Phase-3
/// `secret:{app_id}:{name}`, so every existing v1 row keeps decrypting
/// unchanged; group secrets use a distinct `secret:group:{group_id}:{name}`
/// namespace (the `group:` infix keeps app and group AAD disjoint even if a
/// group UUID happened to equal an app UUID).
fn secret_aad(owner: SecretOwner, name: &str) -> Vec<u8> {
    match owner {
        SecretOwner::App(app_id) => format!("secret:{app_id}:{name}"),
        SecretOwner::Group(group_id) => format!("secret:group:{group_id}:{name}"),
    }
    .into_bytes()
}

/// Default per-secret plaintext cap (64 KB). Override with
/// `PICLOUD_SECRET_MAX_VALUE_BYTES`.
pub const DEFAULT_SECRET_MAX_VALUE_BYTES: usize = 64 * 1024;

/// Process config for the secrets service.
#[derive(Debug, Clone, Copy)]
pub struct SecretsConfig {
    /// Maximum size of the JSON-encoded plaintext, in bytes.
    pub max_value_bytes: usize,
}

impl SecretsConfig {
    #[must_use]
    pub const fn conservative() -> Self {
        Self {
            max_value_bytes: DEFAULT_SECRET_MAX_VALUE_BYTES,
        }
    }

    /// Read `PICLOUD_SECRET_MAX_VALUE_BYTES`; invalid values are ignored
    /// with a warning (keeps the conservative default).
    #[must_use]
    pub fn from_env() -> Self {
        let mut c = Self::conservative();
        if let Ok(v) = std::env::var("PICLOUD_SECRET_MAX_VALUE_BYTES") {
            match v.trim().parse::<usize>() {
                Ok(n) if n > 0 => c.max_value_bytes = n,
                _ => tracing::warn!(
                    value = %v,
                    "ignoring invalid PICLOUD_SECRET_MAX_VALUE_BYTES (want a positive integer)"
                ),
            }
        }
        c
    }
}

impl Default for SecretsConfig {
    fn default() -> Self {
        Self::conservative()
    }
}

/// Serialize + size-check + encrypt a value into `(ciphertext, nonce,
/// version)`. New writes are envelope version 1 with AAD bound to
/// `"secret:{app_id}:{name}"`.
///
/// Audit 2026-06-11 H-D1.
///
/// # Errors
///
/// [`SecretsError::TooLarge`] when the encoded plaintext exceeds
/// `max_value_bytes`; [`SecretsError::Backend`] on a serialization
/// failure (should not happen for a `serde_json::Value`).
pub fn seal(
    master_key: &MasterKey,
    owner: SecretOwner,
    name: &str,
    value: &serde_json::Value,
    max_value_bytes: usize,
) -> Result<(Vec<u8>, [u8; crypto::NONCE_LEN], i16), SecretsError> {
    let plaintext = serde_json::to_vec(value)
        .map_err(|e| SecretsError::Backend(format!("encode secret value: {e}")))?;
    if plaintext.len() > max_value_bytes {
        return Err(SecretsError::TooLarge {
            limit: max_value_bytes,
            actual: plaintext.len(),
        });
    }
    let aad = secret_aad(owner, name);
    let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes());
    Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1))
}

/// Decrypt + deserialize a stored secret back to its JSON value.
/// Dispatches on `stored.version`: v0 uses the legacy no-AAD layout
/// (pre-2026-06-11 rows that haven't been rewritten yet); v1 binds
/// AAD = `"secret:{app_id}:{name}"`, so a cross-row swap fails the
/// GCM auth tag.
///
/// # Errors
///
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
pub fn open(
    master_key: &MasterKey,
    owner: SecretOwner,
    name: &str,
    stored: &StoredSecret,
) -> Result<serde_json::Value, SecretsError> {
    let plaintext = match stored.version {
        0 => crypto::decrypt(
            &stored.encrypted_value,
            &stored.nonce,
            master_key.as_bytes(),
        ),
        SECRET_ENVELOPE_V1 => {
            let aad = secret_aad(owner, name);
            crypto::decrypt_with_aad(
                &stored.encrypted_value,
                &stored.nonce,
                &aad,
                master_key.as_bytes(),
            )
        }
        _ => return Err(SecretsError::Corrupted),
    }
    .map_err(|_| SecretsError::Corrupted)?;
    serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted)
}

/// Legacy v0 seal (no AAD, no version). The email-trigger path now seals v1 via
/// [`seal_email`] (Track A M3 / audit 2026-06-11 H-D1 closed — `email_trigger_details`
/// gained an `inbound_secret_version` column in 0069). This and [`open_legacy`]
/// remain to READ pre-M3 v0 rows that haven't been re-applied, preserving the
/// exact pre-audit wire format so existing email-trigger secrets keep decrypting.
///
/// # Errors
///
/// [`SecretsError::TooLarge`] / [`SecretsError::Backend`] as for [`seal`].
pub fn seal_legacy(
    master_key: &MasterKey,
    value: &serde_json::Value,
    max_value_bytes: usize,
) -> Result<(Vec<u8>, [u8; crypto::NONCE_LEN]), SecretsError> {
    let plaintext = serde_json::to_vec(value)
        .map_err(|e| SecretsError::Backend(format!("encode secret value: {e}")))?;
    if plaintext.len() > max_value_bytes {
        return Err(SecretsError::TooLarge {
            limit: max_value_bytes,
            actual: plaintext.len(),
        });
    }
    let enc = crypto::encrypt(&plaintext, master_key.as_bytes());
    Ok((enc.ciphertext, enc.nonce))
}

/// Legacy v0 open (no AAD) paired with [`seal_legacy`]. See that
/// function for why the email-trigger path is still v0.
///
/// # Errors
///
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
pub fn open_legacy(
    master_key: &MasterKey,
    ciphertext: &[u8],
    nonce: &[u8],
) -> Result<serde_json::Value, SecretsError> {
    let plaintext = crypto::decrypt(ciphertext, nonce, master_key.as_bytes())
        .map_err(|_| SecretsError::Corrupted)?;
    serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted)
}

/// §M5.5 / audit 2026-06-11 H-D1 — AAD for an email-trigger inbound secret.
/// Binds to the SEALING OWNER only (no per-row trigger id / name), so a group
/// template's sealed bytes stay valid when the materializer copies them verbatim
/// to each descendant (all share the group AAD). The `email:` prefix keeps this
/// namespace disjoint from the per-app `secrets` table's `secret:` AAD, so an
/// email ciphertext can't be swapped in for a same-owner secrets-table entry.
/// A swap ACROSS owners (app↔app, group↔group) fails the GCM tag — the
/// cross-tenant relocation the audit flags. (A swap between two email triggers
/// of the SAME owner is not distinguished — an accepted, low-severity residual.)
fn email_secret_aad(owner: SecretOwner) -> Vec<u8> {
    match owner {
        SecretOwner::App(app_id) => format!("email:{app_id}"),
        SecretOwner::Group(group_id) => format!("email:group:{group_id}"),
    }
    .into_bytes()
}

/// Seal an email-trigger inbound secret at envelope v1 (AAD = the sealing
/// owner). Returns `(ciphertext, nonce, version)`. The value is a JSON string
/// (the HMAC key). Replaces the email path's former [`seal_legacy`] (v0).
///
/// # Errors
///
/// [`SecretsError::TooLarge`] / [`SecretsError::Backend`] as for [`seal`].
pub fn seal_email(
    master_key: &MasterKey,
    owner: SecretOwner,
    value: &serde_json::Value,
    max_value_bytes: usize,
) -> Result<(Vec<u8>, [u8; crypto::NONCE_LEN], i16), SecretsError> {
    let plaintext = serde_json::to_vec(value)
        .map_err(|e| SecretsError::Backend(format!("encode secret value: {e}")))?;
    if plaintext.len() > max_value_bytes {
        return Err(SecretsError::TooLarge {
            limit: max_value_bytes,
            actual: plaintext.len(),
        });
    }
    let aad = email_secret_aad(owner);
    let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes());
    Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1))
}

/// Open an email-trigger inbound secret, dispatching on `version`: v0 is the
/// legacy no-AAD layout ([`open_legacy`], owner ignored); v1 binds AAD to the
/// sealing `owner`. The caller recovers the sealing owner (a materialized copy's
/// source-template group, else the app) before calling.
///
/// # Errors
///
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
pub fn open_email(
    master_key: &MasterKey,
    owner: SecretOwner,
    ciphertext: &[u8],
    nonce: &[u8],
    version: i16,
) -> Result<serde_json::Value, SecretsError> {
    let plaintext = match version {
        SECRET_ENVELOPE_V1 => {
            let aad = email_secret_aad(owner);
            crypto::decrypt_with_aad(ciphertext, nonce, &aad, master_key.as_bytes())
        }
        _ => crypto::decrypt(ciphertext, nonce, master_key.as_bytes()),
    }
    .map_err(|_| SecretsError::Corrupted)?;
    serde_json::from_slice(&plaintext).map_err(|_| SecretsError::Corrupted)
}

pub struct SecretsServiceImpl {
    repo: Arc<dyn SecretsRepo>,
    authz: Arc<dyn AuthzRepo>,
    master_key: MasterKey,
    max_value_bytes: usize,
}

impl SecretsServiceImpl {
    #[must_use]
    pub fn new(
        repo: Arc<dyn SecretsRepo>,
        authz: Arc<dyn AuthzRepo>,
        master_key: MasterKey,
        config: SecretsConfig,
    ) -> Self {
        Self {
            repo,
            authz,
            master_key,
            max_value_bytes: config.max_value_bytes,
        }
    }

    async fn check_read(&self, cx: &SdkCallCx) -> Result<(), SecretsError> {
        if let Some(ref principal) = cx.principal {
            authz::require(
                &*self.authz,
                principal,
                Capability::AppSecretsRead(cx.app_id),
            )
            .await
            .map_err(|_| SecretsError::Forbidden)?;
        }
        Ok(())
    }

    async fn check_write(&self, cx: &SdkCallCx) -> Result<(), SecretsError> {
        if let Some(ref principal) = cx.principal {
            authz::require(
                &*self.authz,
                principal,
                Capability::AppSecretsWrite(cx.app_id),
            )
            .await
            .map_err(|_| SecretsError::Forbidden)?;
        }
        Ok(())
    }
}

impl From<SecretsRepoError> for SecretsError {
    fn from(e: SecretsRepoError) -> Self {
        Self::Backend(e.to_string())
    }
}

#[async_trait]
impl SecretsService for SecretsServiceImpl {
    async fn get(
        &self,
        cx: &SdkCallCx,
        name: &str,
    ) -> Result<Option<serde_json::Value>, SecretsError> {
        validate_secret_name(name)?;
        self.check_read(cx).await?;
        // Inherited resolution: the app's own secret, else the nearest
        // ancestor group's, env-filtered. `resolve` anchors the walk to
        // `cx.app_id`, so an app can only ever read its own + its ancestors'
        // secrets — the cross-app isolation boundary. The winning owner comes
        // back with the row so we decrypt under the AAD it was sealed with.
        let Some(resolved) = self.repo.resolve(cx.app_id, name).await? else {
            return Ok(None);
        };
        match open(&self.master_key, resolved.owner, name, &resolved.stored) {
            Ok(value) => Ok(Some(value)),
            Err(e) => {
                // A decrypt failure is operationally significant — surface
                // the affected (app_id, name) so an operator can find the
                // bad row, but never log the ciphertext or key material.
                tracing::error!(
                    app_id = %cx.app_id,
                    secret = %name,
                    "secret could not be decrypted (corrupted row or master-key mismatch)"
                );
                Err(e)
            }
        }
    }

    async fn set(
        &self,
        cx: &SdkCallCx,
        name: &str,
        value: serde_json::Value,
    ) -> Result<(), SecretsError> {
        validate_secret_name(name)?;
        self.check_write(cx).await?;
        let owner = SecretOwner::App(cx.app_id);
        let (ciphertext, nonce, version) =
            seal(&self.master_key, owner, name, &value, self.max_value_bytes)?;
        self.repo
            .set(owner, APP_SECRET_SCOPE, name, &ciphertext, &nonce, version)
            .await?;
        Ok(())
    }

    async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
        validate_secret_name(name)?;
        self.check_write(cx).await?;
        Ok(self
            .repo
            .delete(SecretOwner::App(cx.app_id), APP_SECRET_SCOPE, name)
            .await?)
    }

    async fn list(
        &self,
        cx: &SdkCallCx,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<SecretsListPage, SecretsError> {
        self.check_read(cx).await?;
        let page = self
            .repo
            .list_names(SecretOwner::App(cx.app_id), cursor, limit)
            .await?;
        Ok(SecretsListPage {
            names: page.names,
            next_cursor: page.next_cursor,
        })
    }
}

// ----------------------------------------------------------------------------
// Tests — in-memory SecretsRepo so unit tests don't need Postgres.
// ----------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::authz::{AuthzError, AuthzRepo};
    use crate::secrets_repo::{ResolvedSecret, SecretsMetaPage, SecretsNamePage};
    use async_trait::async_trait;
    use picloud_shared::{
        AdminUserId, AppId, AppRole, ExecutionId, GroupId, InstanceRole, Principal, RequestId,
        ScriptId, UserId,
    };
    use std::collections::BTreeMap;
    use tokio::sync::Mutex;

    /// In-memory backing keyed by `(owner_key, env_scope, name)`. The owner
    /// key is `"app:{uuid}"` / `"group:{uuid}"` so app and group rows never
    /// collide. These unit tests exercise the app surface (scope `*`); the
    /// chain-walk `resolve` is journey-tested against real Postgres, so here
    /// it degrades to a plain app-own `*` lookup.
    #[derive(Default)]
    struct InMemorySecretsRepo {
        data: Mutex<BTreeMap<(String, String, String), StoredSecret>>,
    }

    fn owner_key(owner: SecretOwner) -> String {
        match owner {
            SecretOwner::App(a) => format!("app:{a}"),
            SecretOwner::Group(g) => format!("group:{g}"),
        }
    }

    #[async_trait]
    impl SecretsRepo for InMemorySecretsRepo {
        async fn resolve(
            &self,
            app_id: AppId,
            name: &str,
        ) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
            let owner = SecretOwner::App(app_id);
            Ok(self
                .data
                .lock()
                .await
                .get(&(
                    owner_key(owner),
                    APP_SECRET_SCOPE.to_string(),
                    name.to_string(),
                ))
                .cloned()
                .map(|stored| ResolvedSecret { owner, stored }))
        }
        async fn get(
            &self,
            owner: SecretOwner,
            env_scope: &str,
            name: &str,
        ) -> Result<Option<StoredSecret>, SecretsRepoError> {
            Ok(self
                .data
                .lock()
                .await
                .get(&(owner_key(owner), env_scope.to_string(), name.to_string()))
                .cloned())
        }
        async fn set(
            &self,
            owner: SecretOwner,
            env_scope: &str,
            name: &str,
            encrypted_value: &[u8],
            nonce: &[u8],
            version: i16,
        ) -> Result<(), SecretsRepoError> {
            self.data.lock().await.insert(
                (owner_key(owner), env_scope.to_string(), name.to_string()),
                StoredSecret {
                    encrypted_value: encrypted_value.to_vec(),
                    nonce: nonce.to_vec(),
                    version,
                },
            );
            Ok(())
        }
        async fn delete(
            &self,
            owner: SecretOwner,
            env_scope: &str,
            name: &str,
        ) -> Result<bool, SecretsRepoError> {
            Ok(self
                .data
                .lock()
                .await
                .remove(&(owner_key(owner), env_scope.to_string(), name.to_string()))
                .is_some())
        }
        async fn list_names(
            &self,
            owner: SecretOwner,
            cursor: Option<&str>,
            limit: u32,
        ) -> Result<SecretsNamePage, SecretsRepoError> {
            let data = self.data.lock().await;
            let ok = owner_key(owner);
            let last = cursor.map(std::string::ToString::to_string);
            let mut names: Vec<String> = data
                .iter()
                .filter(|((o, _, _), _)| *o == ok)
                .map(|((_, _, n), _)| n.clone())
                .filter(|n| last.as_ref().is_none_or(|l| n > l))
                .collect();
            names.sort();
            names.dedup();
            let take = (limit as usize).max(1);
            let next_cursor = if names.len() > take {
                names.truncate(take);
                names.last().cloned()
            } else {
                None
            };
            Ok(SecretsNamePage { names, next_cursor })
        }
        async fn list_meta(
            &self,
            _owner: SecretOwner,
            _cursor: Option<&str>,
            _limit: u32,
        ) -> Result<SecretsMetaPage, SecretsRepoError> {
            unimplemented!("admin-only; not exercised in service tests")
        }
    }

    #[derive(Default)]
    struct DenyingAuthzRepo;
    #[async_trait]
    impl AuthzRepo for DenyingAuthzRepo {
        async fn membership(
            &self,
            _user_id: UserId,
            _app_id: AppId,
        ) -> Result<Option<AppRole>, AuthzError> {
            Ok(None)
        }
    }

    fn key() -> MasterKey {
        MasterKey::from_bytes([0x5au8; 32])
    }

    fn svc() -> SecretsServiceImpl {
        SecretsServiceImpl::new(
            Arc::new(InMemorySecretsRepo::default()),
            Arc::new(DenyingAuthzRepo),
            key(),
            SecretsConfig::conservative(),
        )
    }

    fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
        SdkCallCx {
            app_id,
            script_id: ScriptId::new(),
            principal,
            execution_id: ExecutionId::new(),
            request_id: RequestId::new(),
            trigger_depth: 0,
            root_execution_id: ExecutionId::new(),
            is_dead_letter_handler: false,
            event: None,
        }
    }

    fn anon_cx(app_id: AppId) -> SdkCallCx {
        cx_with(app_id, None)
    }

    fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
        cx_with(
            app_id,
            Some(Principal {
                user_id: AdminUserId::new(),
                instance_role: InstanceRole::Member,
                scopes: None,
                app_binding: None,
            }),
        )
    }

    fn owner_cx(app_id: AppId) -> SdkCallCx {
        cx_with(
            app_id,
            Some(Principal {
                user_id: AdminUserId::new(),
                instance_role: InstanceRole::Owner,
                scopes: None,
                app_binding: None,
            }),
        )
    }

    #[tokio::test]
    async fn set_get_delete_round_trip() {
        let s = svc();
        let cx = anon_cx(AppId::new());
        s.set(&cx, "stripe_key", serde_json::json!("sk_live_xxx"))
            .await
            .unwrap();
        assert_eq!(
            s.get(&cx, "stripe_key").await.unwrap(),
            Some(serde_json::json!("sk_live_xxx"))
        );
        assert!(s.delete(&cx, "stripe_key").await.unwrap());
        assert_eq!(s.get(&cx, "stripe_key").await.unwrap(), None);
        // Idempotent delete.
        assert!(!s.delete(&cx, "stripe_key").await.unwrap());
    }

    #[tokio::test]
    async fn get_missing_returns_none() {
        let s = svc();
        let cx = anon_cx(AppId::new());
        assert_eq!(s.get(&cx, "nope").await.unwrap(), None);
    }

    #[tokio::test]
    async fn empty_name_rejected() {
        let s = svc();
        let cx = anon_cx(AppId::new());
        let err = s.set(&cx, "", serde_json::json!("x")).await.unwrap_err();
        assert!(matches!(err, SecretsError::InvalidName(_)));
        let err = s.get(&cx, "").await.unwrap_err();
        assert!(matches!(err, SecretsError::InvalidName(_)));
    }

    #[tokio::test]
    async fn name_length_capped() {
        let s = svc();
        let cx = anon_cx(AppId::new());
        let long = "a".repeat(256);
        let err = s.set(&cx, &long, serde_json::json!(1)).await.unwrap_err();
        assert!(matches!(err, SecretsError::InvalidName(_)));
        // Exactly 255 is allowed.
        let ok = "b".repeat(255);
        s.set(&cx, &ok, serde_json::json!(1)).await.unwrap();
    }

    #[tokio::test]
    async fn value_over_cap_rejected() {
        let s = SecretsServiceImpl::new(
            Arc::new(InMemorySecretsRepo::default()),
            Arc::new(DenyingAuthzRepo),
            key(),
            SecretsConfig {
                max_value_bytes: 16,
            },
        );
        let cx = anon_cx(AppId::new());
        let big = serde_json::json!("x".repeat(64));
        let err = s.set(&cx, "k", big).await.unwrap_err();
        assert!(matches!(err, SecretsError::TooLarge { limit: 16, .. }));
    }

    #[tokio::test]
    async fn cross_app_isolation() {
        let s = svc();
        let a = AppId::new();
        let b = AppId::new();
        s.set(&anon_cx(a), "shared", serde_json::json!("from-a"))
            .await
            .unwrap();
        s.set(&anon_cx(b), "shared", serde_json::json!("from-b"))
            .await
            .unwrap();
        assert_eq!(
            s.get(&anon_cx(a), "shared").await.unwrap(),
            Some(serde_json::json!("from-a"))
        );
        assert_eq!(
            s.get(&anon_cx(b), "shared").await.unwrap(),
            Some(serde_json::json!("from-b"))
        );
    }

    #[tokio::test]
    async fn anonymous_skips_authz() {
        let s = svc();
        // DenyingAuthzRepo would deny an authed principal; anon skips it.
        s.set(&anon_cx(AppId::new()), "k", serde_json::json!(1))
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn authed_member_without_role_forbidden() {
        let s = svc();
        let err = s
            .set(&member_no_role_cx(AppId::new()), "k", serde_json::json!(1))
            .await
            .unwrap_err();
        assert!(matches!(err, SecretsError::Forbidden));
    }

    #[tokio::test]
    async fn owner_can_write() {
        let s = svc();
        s.set(&owner_cx(AppId::new()), "k", serde_json::json!(1))
            .await
            .unwrap();
    }

    /// Type round-trip: a String comes back a String, a Map a Map, an
    /// Array an Array — the JSON encoding is transparent.
    #[tokio::test]
    async fn type_round_trip_preserves_shape() {
        let s = svc();
        let cx = anon_cx(AppId::new());

        s.set(&cx, "str", serde_json::json!("sk_live_xxx"))
            .await
            .unwrap();
        assert_eq!(
            s.get(&cx, "str").await.unwrap(),
            Some(serde_json::json!("sk_live_xxx"))
        );

        let map = serde_json::json!({ "client_id": "abc", "client_secret": "xyz" });
        s.set(&cx, "oauth", map.clone()).await.unwrap();
        assert_eq!(s.get(&cx, "oauth").await.unwrap(), Some(map));

        let arr = serde_json::json!([1, 2, 3]);
        s.set(&cx, "arr", arr.clone()).await.unwrap();
        assert_eq!(s.get(&cx, "arr").await.unwrap(), Some(arr));
    }

    #[tokio::test]
    async fn corrupted_ciphertext_surfaces_error() {
        let repo = Arc::new(InMemorySecretsRepo::default());
        let s = SecretsServiceImpl::new(
            repo.clone(),
            Arc::new(DenyingAuthzRepo),
            key(),
            SecretsConfig::conservative(),
        );
        let app = AppId::new();
        s.set(&anon_cx(app), "k", serde_json::json!("v"))
            .await
            .unwrap();
        // Corrupt the stored ciphertext directly.
        repo.data
            .lock()
            .await
            .get_mut(&(
                owner_key(SecretOwner::App(app)),
                "*".to_string(),
                "k".to_string(),
            ))
            .unwrap()
            .encrypted_value[0] ^= 0xff;
        let err = s.get(&anon_cx(app), "k").await.unwrap_err();
        assert!(matches!(err, SecretsError::Corrupted));
    }

    #[tokio::test]
    async fn aad_blocks_cross_app_ciphertext_swap() {
        // Audit 2026-06-11 H-D1 closure. Seal a secret under app A,
        // then move its stored row verbatim into app B's slot (the
        // exact attack a Postgres-write adversary would run). The v1
        // AAD = "secret:{app_id}:{name}" no longer matches, so B's read
        // surfaces Corrupted instead of A's plaintext.
        let repo = Arc::new(InMemorySecretsRepo::default());
        let s = SecretsServiceImpl::new(
            repo.clone(),
            Arc::new(DenyingAuthzRepo),
            key(),
            SecretsConfig::conservative(),
        );
        let a = AppId::new();
        let b = AppId::new();
        s.set(&anon_cx(a), "k", serde_json::json!("a-plaintext"))
            .await
            .unwrap();
        // Copy A's row into B's slot, same name.
        let stolen = repo
            .data
            .lock()
            .await
            .get(&(
                owner_key(SecretOwner::App(a)),
                "*".to_string(),
                "k".to_string(),
            ))
            .cloned()
            .unwrap();
        repo.data.lock().await.insert(
            (
                owner_key(SecretOwner::App(b)),
                "*".to_string(),
                "k".to_string(),
            ),
            stolen,
        );
        let err = s.get(&anon_cx(b), "k").await.unwrap_err();
        assert!(
            matches!(err, SecretsError::Corrupted),
            "cross-app swap must fail AAD, got {err:?}"
        );
    }

    #[tokio::test]
    async fn aad_distinguishes_app_and_group_owner() {
        // Phase 3: app and group secrets live in disjoint AAD namespaces
        // (`secret:{app}` vs `secret:group:{group}`). A ciphertext sealed
        // for a group must not open as an app secret (and vice-versa),
        // even if the raw UUIDs were equal — the `group:` infix separates
        // them. Exercise the free seal/open functions directly.
        let k = key();
        let app = AppId::new();
        let group = GroupId::new();
        let value = serde_json::json!("shared-config");

        // Seal under the GROUP owner.
        let (ct, nonce, version) =
            seal(&k, SecretOwner::Group(group), "db_url", &value, 4096).unwrap();
        let stored = StoredSecret {
            encrypted_value: ct,
            nonce: nonce.to_vec(),
            version,
        };
        // Opens fine as the same group owner.
        assert_eq!(
            open(&k, SecretOwner::Group(group), "db_url", &stored).unwrap(),
            value
        );
        // Fails as an app owner (AAD mismatch) — even reusing the UUID.
        let app_from_group = AppId::from(group.into_inner());
        assert!(matches!(
            open(&k, SecretOwner::App(app_from_group), "db_url", &stored),
            Err(SecretsError::Corrupted)
        ));
        // And the symmetric direction: an app-sealed row won't open as a group.
        let (ct2, nonce2, v2) = seal(&k, SecretOwner::App(app), "db_url", &value, 4096).unwrap();
        let stored2 = StoredSecret {
            encrypted_value: ct2,
            nonce: nonce2.to_vec(),
            version: v2,
        };
        assert!(matches!(
            open(
                &k,
                SecretOwner::Group(GroupId::from(app.into_inner())),
                "db_url",
                &stored2
            ),
            Err(SecretsError::Corrupted)
        ));
    }

    #[tokio::test]
    async fn aad_blocks_cross_name_ciphertext_swap() {
        // Same app, different name → AAD mismatch.
        let repo = Arc::new(InMemorySecretsRepo::default());
        let s = SecretsServiceImpl::new(
            repo.clone(),
            Arc::new(DenyingAuthzRepo),
            key(),
            SecretsConfig::conservative(),
        );
        let a = AppId::new();
        s.set(&anon_cx(a), "real", serde_json::json!("v"))
            .await
            .unwrap();
        let stolen = repo
            .data
            .lock()
            .await
            .get(&(
                owner_key(SecretOwner::App(a)),
                "*".to_string(),
                "real".to_string(),
            ))
            .cloned()
            .unwrap();
        repo.data.lock().await.insert(
            (
                owner_key(SecretOwner::App(a)),
                "*".to_string(),
                "renamed".to_string(),
            ),
            stolen,
        );
        let err = s.get(&anon_cx(a), "renamed").await.unwrap_err();
        assert!(matches!(err, SecretsError::Corrupted));
    }

    #[tokio::test]
    async fn legacy_v0_row_still_decrypts() {
        // A pre-audit row (version 0, no AAD) must keep decrypting after
        // the migration: only new writes are v1.
        let repo = Arc::new(InMemorySecretsRepo::default());
        let s = SecretsServiceImpl::new(
            repo.clone(),
            Arc::new(DenyingAuthzRepo),
            key(),
            SecretsConfig::conservative(),
        );
        let app = AppId::new();
        // Hand-seal a v0 row exactly as the pre-audit code would have.
        let (ct, nonce) = seal_legacy(
            &key(),
            &serde_json::json!("legacy"),
            DEFAULT_SECRET_MAX_VALUE_BYTES,
        )
        .unwrap();
        repo.data.lock().await.insert(
            (
                owner_key(SecretOwner::App(app)),
                "*".to_string(),
                "old".to_string(),
            ),
            StoredSecret {
                encrypted_value: ct,
                nonce: nonce.to_vec(),
                version: 0,
            },
        );
        assert_eq!(
            s.get(&anon_cx(app), "old").await.unwrap(),
            Some(serde_json::json!("legacy"))
        );
    }

    #[tokio::test]
    async fn list_returns_names_paginated() {
        let s = svc();
        let cx = anon_cx(AppId::new());
        for i in 0..5 {
            s.set(&cx, &format!("k{i:02}"), serde_json::json!(i))
                .await
                .unwrap();
        }
        let p1 = s.list(&cx, None, 2).await.unwrap();
        assert_eq!(p1.names, vec!["k00".to_string(), "k01".to_string()]);
        assert!(p1.next_cursor.is_some());
        let p2 = s.list(&cx, p1.next_cursor.as_deref(), 10).await.unwrap();
        assert_eq!(
            p2.names,
            vec!["k02".to_string(), "k03".to_string(), "k04".to_string()]
        );
        assert!(p2.next_cursor.is_none());
    }
}
