//! `SecretsService` — the v1.1.7 encrypted per-app secrets contract.
//!
//! Collection-less (per-app, like pubsub): the script API is the bare
//! `secrets::{get,set,delete,list}(name)` — there is no
//! `secrets::collection(...)`. Secrets are operational config (API keys,
//! OAuth tokens, webhook signing keys), encrypted at rest with the
//! process master key.
//!
//! Lives in `picloud-shared` (not `executor-core`) so the Rhai bridge,
//! the manager-core Postgres impl, and test fakes can all depend on the
//! same trait. Implementations MUST derive every storage `app_id` from
//! `cx.app_id` — never from a script-passed argument. That is the
//! cross-app isolation boundary; see `docs/sdk-shape.md`.
//!
//! Values are JSON internally: `set` accepts any `serde_json::Value`
//! (the bridge maps a Rhai String/Map/Array to JSON), encrypts the
//! encoded bytes, and `get` decrypts + decodes back to the same JSON
//! shape — so a String round-trips to a String, not a JSON-quoted
//! `"\"…\""`. There is deliberately **no `ServiceEvent` emission**:
//! firing triggers on secret writes is a footgun (every rotation would
//! fan out handler executions that might log the new value).

use async_trait::async_trait;
use thiserror::Error;

use crate::SdkCallCx;

/// Maximum secret name length in bytes (matches the brief: 255).
pub const SECRET_NAME_MAX_BYTES: usize = 255;

/// `SecretsService` is collection-less and per-app. Every method derives
/// the owning `app_id` from `cx.app_id`.
#[async_trait]
pub trait SecretsService: Send + Sync {
    /// Decrypt and return the secret, or `None` if no secret with this
    /// name exists for the app.
    async fn get(
        &self,
        cx: &SdkCallCx,
        name: &str,
    ) -> Result<Option<serde_json::Value>, SecretsError>;

    /// Encrypt and store the secret, overwriting any existing value for
    /// this name.
    async fn set(
        &self,
        cx: &SdkCallCx,
        name: &str,
        value: serde_json::Value,
    ) -> Result<(), SecretsError>;

    /// Delete the secret. Returns whether a secret was present.
    async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError>;

    /// List secret **names only** (never values), cursor-paginated like
    /// KV/files `list`. `cursor` is opaque; `None` starts from the
    /// beginning.
    async fn list(
        &self,
        cx: &SdkCallCx,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<SecretsListPage, SecretsError>;
}

/// One page of secret names from `SecretsService::list`. `next_cursor`
/// is `Some` when more pages exist.
#[derive(Debug, Clone)]
pub struct SecretsListPage {
    pub names: Vec<String>,
    pub next_cursor: Option<String>,
}

/// Failure modes surfaced to the Rhai bridge. The bridge converts each
/// to a Rhai runtime error string.
#[derive(Debug, Error)]
pub enum SecretsError {
    /// Empty name, or a name longer than [`SECRET_NAME_MAX_BYTES`].
    #[error("{0}")]
    InvalidName(String),

    /// The encoded plaintext exceeded the configured per-secret cap.
    #[error("secret value too large: {actual} bytes exceeds the {limit}-byte limit")]
    TooLarge { limit: usize, actual: usize },

    /// Caller principal lacked the required capability. Only raised when
    /// `cx.principal.is_some()` — public-HTTP scripts (`principal: None`)
    /// operate under script-as-gate semantics and skip the check.
    #[error("forbidden")]
    Forbidden,

    /// The stored ciphertext could not be decrypted (corrupted row,
    /// wrong master key, or tampering). The impl logs the affected
    /// `(app_id, name)` at error level before returning this.
    #[error("secret is corrupted or was encrypted with a different master key")]
    Corrupted,

    /// The process master key was unavailable. Startup should already
    /// have failed; this is defense in depth.
    #[error("master key is not configured")]
    MasterKeyMissing,

    /// Anything else — Postgres unavailable, serialization failure, etc.
    #[error("secrets backend error: {0}")]
    Backend(String),
}

/// Stub used by the executor-core test harness (which doesn't touch
/// secrets) so a `Services` bundle can be built without Postgres. Every
/// call returns `SecretsError::Backend(...)` so accidental use surfaces.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopSecretsService;

#[async_trait]
impl SecretsService for NoopSecretsService {
    async fn get(
        &self,
        _cx: &SdkCallCx,
        _name: &str,
    ) -> Result<Option<serde_json::Value>, SecretsError> {
        Err(SecretsError::Backend("secrets is not wired in".into()))
    }

    async fn set(
        &self,
        _cx: &SdkCallCx,
        _name: &str,
        _value: serde_json::Value,
    ) -> Result<(), SecretsError> {
        Err(SecretsError::Backend("secrets is not wired in".into()))
    }

    async fn delete(&self, _cx: &SdkCallCx, _name: &str) -> Result<bool, SecretsError> {
        Err(SecretsError::Backend("secrets is not wired in".into()))
    }

    async fn list(
        &self,
        _cx: &SdkCallCx,
        _cursor: Option<&str>,
        _limit: u32,
    ) -> Result<SecretsListPage, SecretsError> {
        Err(SecretsError::Backend("secrets is not wired in".into()))
    }
}

/// Validate a secret name at the SDK/admin boundary: non-empty and at
/// most [`SECRET_NAME_MAX_BYTES`] bytes.
///
/// # Errors
///
/// Returns [`SecretsError::InvalidName`] when empty or too long.
pub fn validate_secret_name(name: &str) -> Result<(), SecretsError> {
    if name.is_empty() {
        return Err(SecretsError::InvalidName(
            "secret name must not be empty".into(),
        ));
    }
    if name.len() > SECRET_NAME_MAX_BYTES {
        return Err(SecretsError::InvalidName(format!(
            "secret name must be at most {SECRET_NAME_MAX_BYTES} bytes, got {}",
            name.len()
        )));
    }
    Ok(())
}
