//! [`FsSecretStore`] — encrypted-at-rest [`SecretStore`] adapter (ticket #92, lot B2).
//!
//! ```text
//! <app_data_dir>/
//! ├── secret.key     # 32 raw bytes, AES-256-GCM key material, chmod 0600 (Unix)
//! └── secrets.json   # { version, entries: { <secretRefId>: "<hex nonce+ciphertext>" } }
//! ```
//!
//! Unlike `profiles.json` (plain JSON), `secrets.json` never carries a plaintext
//! value: each entry is `nonce (12B) || AES-256-GCM(value)`, hex-encoded. The key
//! is generated once (random, `ring::rand::SystemRandom`) and persisted next to
//! it. No OS keyring integration in v1 (assumed debt, documented by Architect) —
//! a local attacker with read access to `secret.key` recovers every secret; the
//! win over `profiles.json` is that `secrets.json` alone (e.g. leaked in a backup
//! that excludes `secret.key`, or read by a process without access to the key
//! file's restrictive permissions) is inert ciphertext.

use std::sync::Arc;

use async_trait::async_trait;
use ring::aead::{self, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, UnboundKey};
use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize};

use domain::ports::{FileSystem, FsError, RemotePath, SecretRef, SecretStore, SecretStoreError};

const SECRETS_FILE: &str = "secrets.json";
const KEY_FILE: &str = "secret.key";
const SECRETS_VERSION: u32 = 1;
const KEY_LEN: usize = 32; // AES-256
const NONCE_LEN: usize = 12;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SecretsDoc {
    version: u32,
    /// `SecretRef` id -> hex(nonce || ciphertext || tag).
    entries: std::collections::BTreeMap<String, String>,
}

impl Default for SecretsDoc {
    fn default() -> Self {
        Self {
            version: SECRETS_VERSION,
            entries: std::collections::BTreeMap::new(),
        }
    }
}

/// Single-use nonce sequence wrapping one 12-byte value (ring's streaming AEAD
/// API insists on a `NonceSequence`, but every seal/open here uses exactly one
/// fresh/parsed nonce, never a stream).
struct OnceNonce(Option<[u8; NONCE_LEN]>);

impl NonceSequence for OnceNonce {
    fn advance(&mut self) -> Result<Nonce, ring::error::Unspecified> {
        let bytes = self.0.take().ok_or(ring::error::Unspecified)?;
        Ok(Nonce::assume_unique_for_key(bytes))
    }
}

/// Encrypted-at-rest [`SecretStore`] port implementation.
///
/// Cheap to clone (everything behind `Arc`); built once at the composition root.
#[derive(Clone)]
pub struct FsSecretStore {
    fs: Arc<dyn FileSystem>,
    app_data_dir: String,
}

impl FsSecretStore {
    /// Builds the store from an injected [`FileSystem`] and the app-data dir
    /// (same machine-local directory as `profiles.json`).
    #[must_use]
    pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
        Self {
            fs,
            app_data_dir: app_data_dir.into(),
        }
    }

    fn path(&self, file: &str) -> RemotePath {
        let base = self.app_data_dir.trim_end_matches(['/', '\\']);
        RemotePath::new(format!("{base}/{file}"))
    }

    /// Loads the key material, generating and persisting a fresh random key on
    /// first use. The key file is written with `0600` permissions on Unix
    /// (best-effort no-op elsewhere).
    async fn load_or_init_key(&self) -> Result<[u8; KEY_LEN], SecretStoreError> {
        let key_path = self.path(KEY_FILE);
        match self.fs.read(&key_path).await {
            Ok(bytes) => {
                let key: [u8; KEY_LEN] = bytes
                    .as_slice()
                    .try_into()
                    .map_err(|_| SecretStoreError::Crypto("secret.key has wrong length".into()))?;
                Ok(key)
            }
            Err(FsError::NotFound(_)) => {
                let mut key = [0_u8; KEY_LEN];
                SystemRandom::new().fill(&mut key).map_err(|_| {
                    SecretStoreError::Crypto("failed to generate secret key".into())
                })?;
                let dir =
                    RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
                self.fs
                    .create_dir_all(&dir)
                    .await
                    .map_err(|e| SecretStoreError::Io(e.to_string()))?;
                self.fs
                    .write(&key_path, &key)
                    .await
                    .map_err(|e| SecretStoreError::Io(e.to_string()))?;
                restrict_permissions(key_path.as_str());
                Ok(key)
            }
            Err(e) => Err(SecretStoreError::Io(e.to_string())),
        }
    }

    async fn read_doc(&self) -> Result<SecretsDoc, SecretStoreError> {
        match self.fs.read(&self.path(SECRETS_FILE)).await {
            Ok(bytes) => serde_json::from_slice(&bytes)
                .map_err(|e| SecretStoreError::Io(format!("secrets.json parse error: {e}"))),
            Err(FsError::NotFound(_)) => Ok(SecretsDoc::default()),
            Err(e) => Err(SecretStoreError::Io(e.to_string())),
        }
    }

    async fn write_doc(&self, doc: &SecretsDoc) -> Result<(), SecretStoreError> {
        let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
        self.fs
            .create_dir_all(&dir)
            .await
            .map_err(|e| SecretStoreError::Io(e.to_string()))?;
        let bytes = serde_json::to_vec_pretty(doc)
            .map_err(|e| SecretStoreError::Io(format!("secrets.json serialise error: {e}")))?;
        self.fs
            .write(&self.path(SECRETS_FILE), &bytes)
            .await
            .map_err(|e| SecretStoreError::Io(e.to_string()))
    }

    fn seal(key: &[u8; KEY_LEN], plaintext: &str) -> Result<String, SecretStoreError> {
        let mut nonce_bytes = [0_u8; NONCE_LEN];
        SystemRandom::new()
            .fill(&mut nonce_bytes)
            .map_err(|_| SecretStoreError::Crypto("failed to generate nonce".into()))?;
        let unbound = UnboundKey::new(&aead::AES_256_GCM, key)
            .map_err(|_| SecretStoreError::Crypto("invalid key material".into()))?;
        let mut sealing = SealingKey::new(unbound, OnceNonce(Some(nonce_bytes)));
        let mut in_out = plaintext.as_bytes().to_vec();
        sealing
            .seal_in_place_append_tag(aead::Aad::empty(), &mut in_out)
            .map_err(|_| SecretStoreError::Crypto("seal failed".into()))?;
        let mut out = Vec::with_capacity(NONCE_LEN + in_out.len());
        out.extend_from_slice(&nonce_bytes);
        out.extend_from_slice(&in_out);
        Ok(hex::encode(out))
    }

    fn open(key: &[u8; KEY_LEN], encoded: &str) -> Result<String, SecretStoreError> {
        let bytes =
            hex::decode(encoded).map_err(|e| SecretStoreError::Crypto(format!("bad hex: {e}")))?;
        if bytes.len() < NONCE_LEN {
            return Err(SecretStoreError::Crypto("ciphertext too short".into()));
        }
        let (nonce_bytes, ciphertext) = bytes.split_at(NONCE_LEN);
        let mut nonce_arr = [0_u8; NONCE_LEN];
        nonce_arr.copy_from_slice(nonce_bytes);
        let unbound = UnboundKey::new(&aead::AES_256_GCM, key)
            .map_err(|_| SecretStoreError::Crypto("invalid key material".into()))?;
        let mut opening = OpeningKey::new(unbound, OnceNonce(Some(nonce_arr)));
        let mut in_out = ciphertext.to_vec();
        let plaintext = opening
            .open_in_place(aead::Aad::empty(), &mut in_out)
            .map_err(|_| SecretStoreError::Crypto("decryption failed".into()))?;
        String::from_utf8(plaintext.to_vec())
            .map_err(|e| SecretStoreError::Crypto(format!("decrypted value is not utf8: {e}")))
    }
}

#[async_trait]
impl SecretStore for FsSecretStore {
    async fn put(&self, key: &SecretRef, value: &str) -> Result<(), SecretStoreError> {
        let enc_key = self.load_or_init_key().await?;
        let mut doc = self.read_doc().await?;
        let sealed = Self::seal(&enc_key, value)?;
        doc.entries.insert(key.as_str().to_owned(), sealed);
        self.write_doc(&doc).await
    }

    async fn get(&self, key: &SecretRef) -> Result<Option<String>, SecretStoreError> {
        let doc = self.read_doc().await?;
        let Some(sealed) = doc.entries.get(key.as_str()) else {
            return Ok(None);
        };
        let enc_key = self.load_or_init_key().await?;
        Self::open(&enc_key, sealed).map(Some)
    }

    async fn delete(&self, key: &SecretRef) -> Result<(), SecretStoreError> {
        let mut doc = self.read_doc().await?;
        doc.entries.remove(key.as_str());
        self.write_doc(&doc).await
    }
}

#[cfg(unix)]
fn restrict_permissions(path: &str) {
    use std::os::unix::fs::PermissionsExt;
    if let Ok(metadata) = std::fs::metadata(path) {
        let mut perms = metadata.permissions();
        perms.set_mode(0o600);
        let _ = std::fs::set_permissions(path, perms);
    }
}

#[cfg(not(unix))]
fn restrict_permissions(_path: &str) {}

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

    struct TempDir(PathBuf);

    impl TempDir {
        fn new(label: &str) -> Self {
            let root = std::env::temp_dir().join(format!(
                "idea-secrets-store-{label}-{}",
                uuid::Uuid::new_v4()
            ));
            std::fs::create_dir_all(&root).unwrap();
            Self(root)
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    fn store(dir: &TempDir) -> FsSecretStore {
        FsSecretStore::new(
            Arc::new(crate::fs::LocalFileSystem::new()),
            dir.0.to_string_lossy().into_owned(),
        )
    }

    #[tokio::test]
    async fn put_then_get_round_trips_plaintext() {
        let dir = TempDir::new("roundtrip");
        let store = store(&dir);
        let key = SecretRef::new("secret-a");
        store.put(&key, "sk-live-abc123").await.unwrap();
        let got = store.get(&key).await.unwrap();
        assert_eq!(got, Some("sk-live-abc123".to_owned()));
    }

    #[tokio::test]
    async fn get_missing_key_returns_none() {
        let dir = TempDir::new("missing");
        let store = store(&dir);
        let got = store.get(&SecretRef::new("nope")).await.unwrap();
        assert_eq!(got, None);
    }

    #[tokio::test]
    async fn delete_removes_the_entry() {
        let dir = TempDir::new("delete");
        let store = store(&dir);
        let key = SecretRef::new("secret-b");
        store.put(&key, "value").await.unwrap();
        store.delete(&key).await.unwrap();
        assert_eq!(store.get(&key).await.unwrap(), None);
    }

    #[tokio::test]
    async fn secrets_file_never_contains_the_plaintext_value() {
        let dir = TempDir::new("plaintext-leak");
        let store = store(&dir);
        store
            .put(&SecretRef::new("secret-c"), "super-secret-literal")
            .await
            .unwrap();
        let raw = tokio::fs::read_to_string(dir.0.join(SECRETS_FILE))
            .await
            .unwrap();
        assert!(!raw.contains("super-secret-literal"));
    }

    #[tokio::test]
    async fn key_file_has_owner_only_permissions_on_unix() {
        let dir = TempDir::new("perms");
        let store = store(&dir);
        store
            .put(&SecretRef::new("secret-d"), "value")
            .await
            .unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let metadata = std::fs::metadata(dir.0.join(KEY_FILE)).unwrap();
            assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
        }
    }

    #[tokio::test]
    async fn put_reuses_the_same_key_across_calls() {
        let dir = TempDir::new("reuse-key");
        let store = store(&dir);
        store.put(&SecretRef::new("a"), "value-a").await.unwrap();
        store.put(&SecretRef::new("b"), "value-b").await.unwrap();
        assert_eq!(
            store.get(&SecretRef::new("a")).await.unwrap(),
            Some("value-a".to_owned())
        );
        assert_eq!(
            store.get(&SecretRef::new("b")).await.unwrap(),
            Some("value-b".to_owned())
        );
    }
}
