//! Low-level Postgres CRUD over `secrets`. Storage-only: it moves
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
//! encoding, authorization, name validation, and the value-size cap all
//! live one layer up in `SecretsServiceImpl` / `secrets_api`.
//!
//! Phase 3 made secrets polymorphic-owner + env-scoped (migration
//! `0049_group_secrets.sql`): a secret is owned by exactly one app OR one
//! ancestor group, and a descendant app resolves the nearest one,
//! environment-filtered (mirroring `vars` / `config_resolver`). Writes are
//! owner-keyed via [`SecretOwner`]; the SDK read path goes through
//! [`SecretsRepo::resolve`], which walks the app→group→root chain.

use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, GroupId};
use sqlx::PgPool;
use uuid::Uuid;

use crate::config_resolver::CHAIN_LEVELS_CTE;

#[derive(Debug, thiserror::Error)]
pub enum SecretsRepoError {
    #[error("database error: {0}")]
    Db(#[from] sqlx::Error),

    #[error("invalid pagination cursor")]
    InvalidCursor,
}

/// Who owns a secret (Phase 3). A secret is owned by exactly one app OR
/// one group; the owner is bound into the AES-GCM AAD (see
/// `secrets_service::secret_aad`) so a cross-owner ciphertext swap fails
/// decryption, and it selects the partial-unique conflict target on write.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretOwner {
    App(AppId),
    Group(GroupId),
}

/// An encrypted secret as it lives on disk: ciphertext (auth tag
/// appended) plus the nonce it was sealed with.
///
/// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout.
/// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` =
/// AES-GCM with AAD bound to the owner+name (see `secrets_service`).
#[derive(Debug, Clone)]
pub struct StoredSecret {
    pub encrypted_value: Vec<u8>,
    pub nonce: Vec<u8>,
    pub version: i16,
}

/// The winner of an inherited-secret resolution: the stored ciphertext
/// plus the owner it actually came from (app-own or an ancestor group),
/// which the caller needs to pick the right AAD when decrypting.
#[derive(Debug, Clone)]
pub struct ResolvedSecret {
    pub owner: SecretOwner,
    pub stored: StoredSecret,
}

/// Admin-surface metadata for one secret. Values are never returned —
/// only the name, its environment scope, and the last-modified timestamp.
#[derive(Debug, Clone)]
pub struct SecretMeta {
    pub name: String,
    pub environment_scope: String,
    pub updated_at: DateTime<Utc>,
}

/// One page of names (SDK `list`).
#[derive(Debug, Clone)]
pub struct SecretsNamePage {
    pub names: Vec<String>,
    pub next_cursor: Option<String>,
}

/// One page of name + updated_at (admin `GET`).
#[derive(Debug, Clone)]
pub struct SecretsMetaPage {
    pub items: Vec<SecretMeta>,
    pub next_cursor: Option<String>,
}

/// Repo surface. Exposed as a trait so the service unit tests can
/// substitute an in-memory backing without Postgres.
#[async_trait]
pub trait SecretsRepo: Send + Sync {
    /// Resolve the effective secret for `app_id` by name: walk the
    /// app→ancestor-group chain (depth 0 = the app), env-filter to the
    /// app's environment or `*`, and return the nearest winner (with
    /// `@E` beating `*` within a level). `None` if no level defines it.
    /// This is the runtime injection path — isolation is anchored to
    /// `app_id`, so an app only ever sees its own + its ancestors' secrets.
    async fn resolve(
        &self,
        app_id: AppId,
        name: &str,
    ) -> Result<Option<ResolvedSecret>, SecretsRepoError>;

    /// Read one owner's OWN secret at a specific env scope (no inheritance).
    /// Backs the group-gated human value-read and the apply email path.
    async fn get(
        &self,
        owner: SecretOwner,
        env_scope: &str,
        name: &str,
    ) -> Result<Option<StoredSecret>, SecretsRepoError>;

    /// Upsert (overwrite if present) one `(owner, env_scope, name)` row.
    /// `version` is the AES-GCM envelope discriminator.
    async fn set(
        &self,
        owner: SecretOwner,
        env_scope: &str,
        name: &str,
        encrypted_value: &[u8],
        nonce: &[u8],
        version: i16,
    ) -> Result<(), SecretsRepoError>;

    /// Delete one `(owner, env_scope, name)` row; returns whether a row
    /// was present.
    async fn delete(
        &self,
        owner: SecretOwner,
        env_scope: &str,
        name: &str,
    ) -> Result<bool, SecretsRepoError>;

    /// Distinct names of an owner's OWN secrets (NOT inherited) — the SDK
    /// `list` surface. Names are de-duplicated across env scopes.
    async fn list_names(
        &self,
        owner: SecretOwner,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<SecretsNamePage, SecretsRepoError>;

    /// Name + scope + updated_at of an owner's OWN secrets — the admin
    /// `GET` surface.
    async fn list_meta(
        &self,
        owner: SecretOwner,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<SecretsMetaPage, SecretsRepoError>;
}

pub struct PostgresSecretsRepo {
    pool: PgPool,
}

impl PostgresSecretsRepo {
    #[must_use]
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

const SECRETS_LIST_MAX_LIMIT: u32 = 1_000;
const SECRETS_LIST_DEFAULT_LIMIT: u32 = 100;

fn clamp_limit(limit: u32) -> u32 {
    if limit == 0 {
        SECRETS_LIST_DEFAULT_LIMIT
    } else {
        limit.min(SECRETS_LIST_MAX_LIMIT)
    }
}

/// Opaque keyset cursor: base64url of the last `name` returned.
pub(crate) fn encode_cursor(last_name: &str) -> String {
    URL_SAFE_NO_PAD.encode(last_name.as_bytes())
}

pub(crate) fn decode_cursor(cursor: &str) -> Result<String, SecretsRepoError> {
    let bytes = URL_SAFE_NO_PAD
        .decode(cursor)
        .map_err(|_| SecretsRepoError::InvalidCursor)?;
    String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
}

/// `list_meta` orders by `(name, environment_scope)` — a name can now have
/// several env-scoped rows (group secrets) — so its keyset cursor must carry
/// BOTH columns, else a name whose scopes straddle a page boundary loses its
/// tail. Encoded as base64url of `name \x1f scope` (US is not a valid env or
/// secret-name char, so it's an unambiguous delimiter).
const CURSOR_SEP: char = '\u{1f}';

fn encode_meta_cursor(name: &str, scope: &str) -> String {
    URL_SAFE_NO_PAD.encode(format!("{name}{CURSOR_SEP}{scope}").as_bytes())
}

fn decode_meta_cursor(cursor: &str) -> Result<(String, String), SecretsRepoError> {
    let bytes = URL_SAFE_NO_PAD
        .decode(cursor)
        .map_err(|_| SecretsRepoError::InvalidCursor)?;
    let s = String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)?;
    s.split_once(CURSOR_SEP)
        .map(|(n, sc)| (n.to_string(), sc.to_string()))
        .ok_or(SecretsRepoError::InvalidCursor)
}

/// `(owner_column, owner_uuid)` for binding an owner into a query.
fn owner_bind(owner: SecretOwner) -> (&'static str, Uuid) {
    match owner {
        SecretOwner::App(a) => ("app_id", a.into_inner()),
        SecretOwner::Group(g) => ("group_id", g.into_inner()),
    }
}

#[async_trait]
impl SecretsRepo for PostgresSecretsRepo {
    async fn resolve(
        &self,
        app_id: AppId,
        name: &str,
    ) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
        // Reuse the shared chain-walk ($1 = app_id), join secrets by name,
        // env-filter, and take the nearest level — `@E` beating `*` within a
        // level via the secondary sort key. One row out, or none.
        let sql = format!(
            "{CHAIN_LEVELS_CTE} \
             SELECT CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
                    COALESCE(s.app_id, s.group_id) AS owner_id, \
                    s.encrypted_value, s.nonce, s.version \
             FROM chain c \
             JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
             WHERE s.name = $2 \
               AND (s.environment_scope = '*' OR s.environment_scope = c.app_env) \
             ORDER BY c.depth ASC, (s.environment_scope <> '*') DESC \
             LIMIT 1"
        );
        let row: Option<(String, Uuid, Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(&sql)
            .bind(app_id.into_inner())
            .bind(name)
            .fetch_optional(&self.pool)
            .await?;
        Ok(
            row.map(|(owner_kind, owner_id, encrypted_value, nonce, version)| {
                let owner = if owner_kind == "app" {
                    SecretOwner::App(AppId::from(owner_id))
                } else {
                    SecretOwner::Group(GroupId::from(owner_id))
                };
                ResolvedSecret {
                    owner,
                    stored: StoredSecret {
                        encrypted_value,
                        nonce,
                        version,
                    },
                }
            }),
        )
    }

    async fn get(
        &self,
        owner: SecretOwner,
        env_scope: &str,
        name: &str,
    ) -> Result<Option<StoredSecret>, SecretsRepoError> {
        let (col, id) = owner_bind(owner);
        let sql = format!(
            "SELECT encrypted_value, nonce, version FROM secrets \
             WHERE {col} = $1 AND environment_scope = $2 AND name = $3"
        );
        let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(&sql)
            .bind(id)
            .bind(env_scope)
            .bind(name)
            .fetch_optional(&self.pool)
            .await?;
        Ok(row.map(|(encrypted_value, nonce, version)| StoredSecret {
            encrypted_value,
            nonce,
            version,
        }))
    }

    async fn set(
        &self,
        owner: SecretOwner,
        env_scope: &str,
        name: &str,
        encrypted_value: &[u8],
        nonce: &[u8],
        version: i16,
    ) -> Result<(), SecretsRepoError> {
        // Owner-kind-specific SQL: write only the owner's nullable column and
        // restate the partial-unique predicate as the ON CONFLICT arbiter.
        let (col, id) = owner_bind(owner);
        let predicate = match owner {
            SecretOwner::App(_) => "app_id IS NOT NULL",
            SecretOwner::Group(_) => "group_id IS NOT NULL",
        };
        let sql = format!(
            "INSERT INTO secrets ({col}, environment_scope, name, encrypted_value, nonce, version) \
             VALUES ($1, $2, $3, $4, $5, $6) \
             ON CONFLICT ({col}, environment_scope, name) WHERE {predicate} DO UPDATE \
               SET encrypted_value = EXCLUDED.encrypted_value, \
                   nonce = EXCLUDED.nonce, \
                   version = EXCLUDED.version, \
                   updated_at = NOW()"
        );
        sqlx::query(&sql)
            .bind(id)
            .bind(env_scope)
            .bind(name)
            .bind(encrypted_value)
            .bind(nonce)
            .bind(version)
            .execute(&self.pool)
            .await?;
        Ok(())
    }

    async fn delete(
        &self,
        owner: SecretOwner,
        env_scope: &str,
        name: &str,
    ) -> Result<bool, SecretsRepoError> {
        let (col, id) = owner_bind(owner);
        let sql = format!(
            "DELETE FROM secrets WHERE {col} = $1 AND environment_scope = $2 AND name = $3"
        );
        let res = sqlx::query(&sql)
            .bind(id)
            .bind(env_scope)
            .bind(name)
            .execute(&self.pool)
            .await?;
        Ok(res.rows_affected() > 0)
    }

    async fn list_names(
        &self,
        owner: SecretOwner,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<SecretsNamePage, SecretsRepoError> {
        let limit = clamp_limit(limit);
        let last_name = match cursor {
            Some(c) => Some(decode_cursor(c)?),
            None => None,
        };
        let take = i64::from(limit) + 1;
        let (col, id) = owner_bind(owner);
        // DISTINCT collapses a name that exists at several env scopes into
        // one entry — the SDK `list` is a name catalogue, not per-scope.
        let sql = format!(
            "SELECT DISTINCT name FROM secrets \
             WHERE {col} = $1 AND ($2::text IS NULL OR name > $2) \
             ORDER BY name ASC LIMIT $3"
        );
        let rows: Vec<(String,)> = sqlx::query_as(&sql)
            .bind(id)
            .bind(last_name.as_deref())
            .bind(take)
            .fetch_all(&self.pool)
            .await?;

        let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
        let next_cursor = if names.len() > limit as usize {
            names.truncate(limit as usize);
            names.last().map(|n| encode_cursor(n))
        } else {
            None
        };
        Ok(SecretsNamePage { names, next_cursor })
    }

    async fn list_meta(
        &self,
        owner: SecretOwner,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<SecretsMetaPage, SecretsRepoError> {
        let limit = clamp_limit(limit);
        // Composite keyset on (name, environment_scope) — see encode_meta_cursor.
        let last = match cursor {
            Some(c) => Some(decode_meta_cursor(c)?),
            None => None,
        };
        let (last_name, last_scope) = match &last {
            Some((n, sc)) => (Some(n.as_str()), Some(sc.as_str())),
            None => (None, None),
        };
        let take = i64::from(limit) + 1;
        let (col, id) = owner_bind(owner);
        let sql = format!(
            "SELECT name, environment_scope, updated_at FROM secrets \
             WHERE {col} = $1 \
               AND ($2::text IS NULL OR (name, environment_scope) > ($2, $3)) \
             ORDER BY name ASC, environment_scope ASC LIMIT $4"
        );
        let rows: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(&sql)
            .bind(id)
            .bind(last_name)
            .bind(last_scope)
            .bind(take)
            .fetch_all(&self.pool)
            .await?;

        let mut items: Vec<SecretMeta> = rows
            .into_iter()
            .map(|(name, environment_scope, updated_at)| SecretMeta {
                name,
                environment_scope,
                updated_at,
            })
            .collect();
        let next_cursor = if items.len() > limit as usize {
            items.truncate(limit as usize);
            items
                .last()
                .map(|m| encode_meta_cursor(&m.name, &m.environment_scope))
        } else {
            None
        };
        Ok(SecretsMetaPage { items, next_cursor })
    }
}
