//! `AppSecretsRepo` — per-app secret material (v1.1.6, encrypted v1.1.7,
//! plaintext column dropped v1.1.8 F1).
//!
//! Holds the HMAC signing key for realtime subscriber tokens. The key is
//! generated lazily (32 random bytes) on the first
//! `pubsub::subscriber_token` call for an app and never changes
//! thereafter (no rotation API yet). The key is never exposed to
//! scripts: the SDK mints tokens, it never returns the key.
//!
//! **v1.1.7 at-rest encryption.** The key is sealed with the process
//! master key (AES-256-GCM). v1.1.8 (this commit) removes the
//! plaintext fallback column — the read path consults only the
//! encrypted columns now. The 0032 migration enforces this by
//! refusing to drop the plaintext column unless every existing row
//! has been encrypted by the v1.1.7 startup sweep first.

use async_trait::async_trait;
use picloud_shared::{crypto, AppId, MasterKey};
use rand::RngCore;
use sqlx::PgPool;

/// Length of a freshly-generated realtime signing key.
pub const SIGNING_KEY_LEN: usize = 32;

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

    /// A stored encrypted signing key could not be decrypted — corrupted
    /// row or a master-key mismatch (e.g. `PICLOUD_SECRET_KEY` changed).
    #[error("realtime signing key could not be decrypted (corrupted row or master-key mismatch)")]
    Crypto,
}

#[async_trait]
pub trait AppSecretsRepo: Send + Sync {
    /// Fetch the app's realtime signing key, generating + persisting one
    /// (32 random bytes, encrypted) if absent. Idempotent under
    /// concurrency: a racing creator's `ON CONFLICT DO NOTHING` insert is
    /// a no-op and the existing key is returned.
    async fn get_or_create_signing_key(
        &self,
        app_id: AppId,
    ) -> Result<Vec<u8>, AppSecretsRepoError>;

    /// Fetch the signing key if it exists, WITHOUT creating one. The SSE
    /// verify path uses this: a missing key means no token was ever
    /// minted for the app, so any presented token must be rejected.
    async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError>;
}

pub struct PostgresAppSecretsRepo {
    pool: PgPool,
    master_key: MasterKey,
}

impl PostgresAppSecretsRepo {
    #[must_use]
    pub fn new(pool: PgPool, master_key: MasterKey) -> Self {
        Self { pool, master_key }
    }

    fn decode(
        &self,
        app_id: AppId,
        encrypted: Option<Vec<u8>>,
        nonce: Option<Vec<u8>>,
        version: i16,
    ) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
        decode_signing_key(&self.master_key, app_id, encrypted, nonce, version)
    }
}

/// Audit 2026-06-11 H-D1 — AAD binding the realtime signing key to its
/// owning app, so a Postgres-write attacker can't swap one app's
/// ciphertext under another app's row.
fn signing_key_aad(app_id: AppId) -> Vec<u8> {
    format!("app_secret:{app_id}:realtime_signing_key").into_bytes()
}

/// Current AAD-bound envelope version for the realtime signing key.
const SIGNING_KEY_ENVELOPE_V1: i16 = 1;

/// Resolve the signing key from a row's encrypted columns. The
/// plaintext fallback that existed in v1.1.7 is gone — v1.1.8 F1
/// drops the column outright (migration 0032 refuses to apply if any
/// row still needs encryption, guaranteeing the read path can't see
/// a row that only has a plaintext value).
///
/// Audit 2026-06-11 H-D1: dispatches on `version`. v0 = legacy no-AAD
/// (pre-audit rows); v1 = AAD bound to `app_secret:{app_id}:realtime_signing_key`.
fn decode_signing_key(
    master_key: &MasterKey,
    app_id: AppId,
    encrypted: Option<Vec<u8>>,
    nonce: Option<Vec<u8>>,
    version: i16,
) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
    match (encrypted, nonce) {
        (Some(ct), Some(n)) => {
            let key = match version {
                0 => crypto::decrypt(&ct, &n, master_key.as_bytes()),
                SIGNING_KEY_ENVELOPE_V1 => {
                    let aad = signing_key_aad(app_id);
                    crypto::decrypt_with_aad(&ct, &n, &aad, master_key.as_bytes())
                }
                _ => return Err(AppSecretsRepoError::Crypto),
            }
            .map_err(|_| AppSecretsRepoError::Crypto)?;
            Ok(Some(key))
        }
        _ => Ok(None),
    }
}

#[async_trait]
impl AppSecretsRepo for PostgresAppSecretsRepo {
    async fn get_or_create_signing_key(
        &self,
        app_id: AppId,
    ) -> Result<Vec<u8>, AppSecretsRepoError> {
        let mut fresh = vec![0u8; SIGNING_KEY_LEN];
        rand::thread_rng().fill_bytes(&mut fresh);
        // Audit 2026-06-11 H-D1 — new keys are AAD-bound (v1).
        let aad = signing_key_aad(app_id);
        let enc = crypto::encrypt_with_aad(&fresh, &aad, self.master_key.as_bytes());

        // Insert-if-absent (encrypted-only). The racing-creator's insert
        // is a no-op; the SELECT always returns the winning row.
        sqlx::query(
            "INSERT INTO app_secrets \
                (app_id, realtime_signing_key_encrypted, realtime_signing_key_nonce, \
                 realtime_signing_key_version) \
             VALUES ($1, $2, $3, $4) ON CONFLICT (app_id) DO NOTHING",
        )
        .bind(app_id.into_inner())
        .bind(&enc.ciphertext)
        .bind(&enc.nonce[..])
        .bind(SIGNING_KEY_ENVELOPE_V1)
        .execute(&self.pool)
        .await?;

        let row: (Option<Vec<u8>>, Option<Vec<u8>>, i16) = sqlx::query_as(
            "SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
                    realtime_signing_key_version \
             FROM app_secrets WHERE app_id = $1",
        )
        .bind(app_id.into_inner())
        .fetch_one(&self.pool)
        .await?;
        // A row exists by construction, so a key must decode.
        self.decode(app_id, row.0, row.1, row.2)?
            .ok_or(AppSecretsRepoError::Crypto)
    }

    async fn signing_key(&self, app_id: AppId) -> Result<Option<Vec<u8>>, AppSecretsRepoError> {
        let row: Option<(Option<Vec<u8>>, Option<Vec<u8>>, i16)> = sqlx::query_as(
            "SELECT realtime_signing_key_encrypted, realtime_signing_key_nonce, \
                    realtime_signing_key_version \
             FROM app_secrets WHERE app_id = $1",
        )
        .bind(app_id.into_inner())
        .fetch_optional(&self.pool)
        .await?;
        match row {
            Some((e, n, v)) => self.decode(app_id, e, n, v),
            None => Ok(None),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn key() -> MasterKey {
        MasterKey::from_bytes([9u8; 32])
    }

    fn app() -> AppId {
        AppId::new()
    }

    #[test]
    fn legacy_v0_decodes() {
        let mk = key();
        let secret = vec![1u8, 2, 3, 4];
        let enc = crypto::encrypt(&secret, mk.as_bytes());
        let got = decode_signing_key(
            &mk,
            app(),
            Some(enc.ciphertext),
            Some(enc.nonce.to_vec()),
            0,
        )
        .unwrap();
        assert_eq!(got, Some(secret));
    }

    #[test]
    fn v1_aad_round_trips() {
        let mk = key();
        let a = app();
        let secret = vec![7u8; 32];
        let aad = signing_key_aad(a);
        let enc = crypto::encrypt_with_aad(&secret, &aad, mk.as_bytes());
        let got = decode_signing_key(
            &mk,
            a,
            Some(enc.ciphertext),
            Some(enc.nonce.to_vec()),
            SIGNING_KEY_ENVELOPE_V1,
        )
        .unwrap();
        assert_eq!(got, Some(secret));
    }

    #[test]
    fn v1_decode_under_wrong_app_fails() {
        // Audit 2026-06-11 H-D1 — a row swapped to a different app_id
        // can't be decrypted: the AAD no longer matches.
        let mk = key();
        let a = app();
        let b = app();
        let secret = vec![7u8; 32];
        let aad = signing_key_aad(a);
        let enc = crypto::encrypt_with_aad(&secret, &aad, mk.as_bytes());
        let err = decode_signing_key(
            &mk,
            b,
            Some(enc.ciphertext),
            Some(enc.nonce.to_vec()),
            SIGNING_KEY_ENVELOPE_V1,
        )
        .unwrap_err();
        assert!(matches!(err, AppSecretsRepoError::Crypto));
    }

    #[test]
    fn missing_columns_is_none() {
        let got = decode_signing_key(&key(), app(), None, None, 0).unwrap();
        assert_eq!(got, None);
    }

    #[test]
    fn wrong_master_key_is_crypto_error() {
        let secret = vec![3u8; 32];
        let enc = crypto::encrypt(&secret, key().as_bytes());
        let other = MasterKey::from_bytes([1u8; 32]);
        let err = decode_signing_key(
            &other,
            app(),
            Some(enc.ciphertext),
            Some(enc.nonce.to_vec()),
            0,
        )
        .unwrap_err();
        assert!(matches!(err, AppSecretsRepoError::Crypto));
    }
}
