//! Portable read-only facade for future tsafe mobile clients.
//!
//! The crate deliberately works from vault bytes and caller-supplied identity
//! material. Native iOS/Android shells remain responsible for GitHub transport,
//! secure storage, biometric prompts, clipboard handling, and app lifecycle.

use std::collections::HashMap;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tsafe_core::audit::{AuditClipboardContext, AuditContext, AuditEntry, AuditRevealContext};
use tsafe_core::crypto::{self, CipherKind, KeyPurpose, KeySchedule, VaultKey};
pub use tsafe_core::errors::{SafeError, SafeResult};
use tsafe_core::vault::{SecretEntry, VaultFile};
use tsafe_core::{team, totp};
use zeroize::{Zeroize, Zeroizing};

pub mod envelopes;

const VAULT_SCHEMA: &str = "tsafe/vault/v1";
const TEAM_SCHEMA: &str = "tsafe/vault/v2";
const VAULT_KDF_ALGORITHM: &str = "argon2id";
const VAULT_CHALLENGE_PLAINTEXT: &[u8] = b"tsafe-vault-challenge-v1";
const KDF_M_COST_MIN: u32 = 8_192;
const KDF_M_COST_MAX: u32 = 131_072;
const KDF_T_COST_MIN: u32 = 1;
const KDF_T_COST_MAX: u32 = 20;
const KDF_P_COST_MIN: u32 = 1;
const KDF_P_COST_MAX: u32 = 16;

/// Which backing vault format authenticated this mobile session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MobileVaultKind {
    Password,
    Team,
}

/// Redacted metadata for one vault item.
///
/// This never includes a secret value, TOTP seed, generated TOTP code, nonce, or
/// ciphertext. Tags may still carry operator-chosen metadata and should be
/// treated as metadata, not plaintext secret material.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MobileItemMetadata {
    pub key: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub tags: HashMap<String, String>,
    pub history_len: usize,
    pub item_kind: MobileItemKind,
}

/// Coarse item classification derived from redacted metadata only.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MobileItemKind {
    Secret,
    Totp,
}

/// A single decrypted item.
///
/// Values are returned one-at-a-time and wrapped in [`Zeroizing`] so Rust callers
/// drop and wipe them promptly. Swift/Kotlin FFI layers should preserve the same
/// short-lived reveal/copy discipline.
#[derive(Debug)]
pub struct MobileVaultItem {
    pub key: String,
    pub value: Zeroizing<String>,
    pub metadata: MobileItemMetadata,
}

/// Current TOTP code plus the remaining seconds in the stored TOTP profile.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MobileTotpCode {
    pub code: String,
    pub seconds_remaining: u64,
}

/// Authenticated read-only mobile session over a vault file held in memory.
pub struct MobileVault {
    kind: MobileVaultKind,
    file: VaultFile,
    key: VaultKey,
    cipher: CipherKind,
    key_schedule: KeySchedule,
}

impl MobileVault {
    /// Open a password-derived personal vault from JSON bytes.
    pub fn open_password_vault_from_bytes(bytes: &[u8], password: &[u8]) -> SafeResult<Self> {
        let file = parse_vault_file(bytes)?;
        if file.schema != VAULT_SCHEMA {
            return Err(SafeError::InvalidVault {
                reason: format!("unsupported password vault schema: {}", file.schema),
            });
        }
        if team::is_team_vault(&file) {
            return Err(SafeError::InvalidVault {
                reason: "team vault requires age identity open path".into(),
            });
        }
        validate_password_kdf(&file)?;
        let salt = crypto::decode_b64(&file.kdf.salt)?;
        let key = crypto::derive_key(
            password,
            &salt,
            file.kdf.m_cost,
            file.kdf.t_cost,
            file.kdf.p_cost,
        )?;
        Self::open_with_key(file, key, MobileVaultKind::Password)
    }

    /// Open an age/team vault from JSON bytes and an age identity file string.
    pub fn open_team_vault_from_bytes(bytes: &[u8], identity_file: &str) -> SafeResult<Self> {
        let file = parse_vault_file(bytes)?;
        if file.schema != TEAM_SCHEMA {
            return Err(SafeError::InvalidVault {
                reason: format!("unsupported team vault schema: {}", file.schema),
            });
        }
        if !team::is_team_vault(&file) {
            return Err(SafeError::InvalidVault {
                reason: "vault is missing team age recipients or wrapped DEK".into(),
            });
        }
        let identities = parse_age_identities(identity_file)?;
        let key = team::unwrap_dek(&file, &identities)?;
        Self::open_with_key(file, key, MobileVaultKind::Team)
    }

    pub fn kind(&self) -> MobileVaultKind {
        self.kind
    }

    /// Return sorted, redacted item metadata.
    pub fn list_items(&self) -> Vec<MobileItemMetadata> {
        let mut items: Vec<_> = self
            .file
            .secrets
            .iter()
            .map(|(key, entry)| metadata_for(key, entry))
            .collect();
        items.sort_by(|a, b| a.key.cmp(&b.key));
        items
    }

    /// Decrypt and return one item by key.
    pub fn get_item(&self, key: &str) -> SafeResult<MobileVaultItem> {
        let entry = self
            .file
            .secrets
            .get(key)
            .ok_or_else(|| SafeError::SecretNotFound {
                key: key.to_string(),
            })?;
        let nonce = crypto::decode_b64(&entry.nonce)?;
        let ciphertext = crypto::decode_b64(&entry.ciphertext)?;
        let plaintext = crypto::decrypt_with_key_schedule(
            &self.key,
            self.key_schedule,
            KeyPurpose::SecretData,
            self.cipher,
            &nonce,
            &ciphertext,
        )?;
        let value = zeroizing_string_from_utf8(plaintext)?;
        Ok(MobileVaultItem {
            key: key.to_string(),
            value,
            metadata: metadata_for(key, entry),
        })
    }

    /// Generate a TOTP code for an item using the stored otpauth profile.
    pub fn generate_totp_code(&self, key: &str) -> SafeResult<MobileTotpCode> {
        let item = self.get_item(key)?;
        let code = totp::generate_code(&item.value)?;
        let seconds_remaining = totp::seconds_remaining_for(&item.value)?;
        Ok(MobileTotpCode {
            code,
            seconds_remaining,
        })
    }

    fn open_with_key(file: VaultFile, key: VaultKey, kind: MobileVaultKind) -> SafeResult<Self> {
        let cipher = crypto::parse_cipher_kind(&file.cipher)?;
        let challenge_nonce = crypto::decode_b64(&file.vault_challenge.nonce)?;
        let challenge_ciphertext = crypto::decode_b64(&file.vault_challenge.ciphertext)?;
        let key_schedule = crypto::detect_key_schedule(
            &key,
            KeyPurpose::VaultChallenge,
            cipher,
            &challenge_nonce,
            &challenge_ciphertext,
            VAULT_CHALLENGE_PLAINTEXT,
        )?;
        Ok(Self {
            kind,
            file,
            key,
            cipher,
            key_schedule,
        })
    }
}

/// Mobile-local audit operation. Values are never accepted by this type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MobileAuditKind {
    VaultPull,
    Unlock,
    Reveal { ttl_secs: u64 },
    Copy { ttl_secs: u64 },
    TotpGet,
    Sync,
    RevocationSeen,
    StalePolicy,
}

/// Mobile-local audit outcome.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MobileAuditStatus {
    Success,
    Failure { summary: String },
}

/// Build a local audit event for mobile surfaces without accepting or recording
/// any secret value, TOTP seed, or TOTP code.
pub fn build_mobile_audit_event(
    profile: &str,
    kind: MobileAuditKind,
    key: Option<&str>,
    status: MobileAuditStatus,
) -> AuditEntry {
    let operation = mobile_operation_name(&kind);
    let mut entry = match status {
        MobileAuditStatus::Success => AuditEntry::success(profile, operation, key),
        MobileAuditStatus::Failure { summary } => {
            AuditEntry::failure(profile, operation, key, &summary)
        }
    };
    entry.context = match kind {
        MobileAuditKind::Reveal { ttl_secs } => {
            Some(AuditContext::from_reveal(AuditRevealContext { ttl_secs }))
        }
        MobileAuditKind::Copy { ttl_secs } => {
            Some(AuditContext::from_clipboard(AuditClipboardContext {
                ttl_secs,
                reason: None,
                excluded_from_history: None,
                cleared_verified: None,
            }))
        }
        _ => None,
    };
    entry
}

fn parse_vault_file(bytes: &[u8]) -> SafeResult<VaultFile> {
    serde_json::from_slice(bytes).map_err(|err| SafeError::VaultCorrupted {
        reason: format!("vault JSON parse error: {err}"),
    })
}

fn validate_password_kdf(file: &VaultFile) -> SafeResult<()> {
    if file.kdf.algorithm != VAULT_KDF_ALGORITHM {
        return Err(SafeError::InvalidVault {
            reason: format!(
                "unsupported KDF algorithm: '{}' (expected '{VAULT_KDF_ALGORITHM}')",
                file.kdf.algorithm
            ),
        });
    }
    if file.kdf.m_cost < KDF_M_COST_MIN || file.kdf.m_cost > KDF_M_COST_MAX {
        return Err(SafeError::InvalidVault {
            reason: format!(
                "KDF m_cost {} is outside allowed range [{KDF_M_COST_MIN}, {KDF_M_COST_MAX}]",
                file.kdf.m_cost
            ),
        });
    }
    if file.kdf.t_cost < KDF_T_COST_MIN || file.kdf.t_cost > KDF_T_COST_MAX {
        return Err(SafeError::InvalidVault {
            reason: format!(
                "KDF t_cost {} is outside allowed range [{KDF_T_COST_MIN}, {KDF_T_COST_MAX}]",
                file.kdf.t_cost
            ),
        });
    }
    if file.kdf.p_cost < KDF_P_COST_MIN || file.kdf.p_cost > KDF_P_COST_MAX {
        return Err(SafeError::InvalidVault {
            reason: format!(
                "KDF p_cost {} is outside allowed range [{KDF_P_COST_MIN}, {KDF_P_COST_MAX}]",
                file.kdf.p_cost
            ),
        });
    }
    Ok(())
}

fn parse_age_identities(identity_file: &str) -> SafeResult<Vec<Box<dyn age::Identity>>> {
    age::IdentityFile::from_buffer(identity_file.as_bytes())
        .map_err(|err| SafeError::Crypto {
            context: format!("parse age identity: {err}"),
        })?
        .into_identities()
        .map_err(|err| SafeError::Crypto {
            context: format!("extract age identities: {err}"),
        })
}

fn metadata_for(key: &str, entry: &SecretEntry) -> MobileItemMetadata {
    MobileItemMetadata {
        key: key.to_string(),
        created_at: entry.created_at,
        updated_at: entry.updated_at,
        tags: entry.tags.clone(),
        history_len: entry.history.len(),
        item_kind: if entry
            .tags
            .get("type")
            .is_some_and(|value| value.eq_ignore_ascii_case("totp"))
        {
            MobileItemKind::Totp
        } else {
            MobileItemKind::Secret
        },
    }
}

fn zeroizing_string_from_utf8(bytes: Vec<u8>) -> SafeResult<Zeroizing<String>> {
    match String::from_utf8(bytes) {
        Ok(value) => Ok(Zeroizing::new(value)),
        Err(err) => {
            let mut bytes = err.into_bytes();
            bytes.zeroize();
            Err(SafeError::InvalidVault {
                reason: "secret is not valid UTF-8".into(),
            })
        }
    }
}

fn mobile_operation_name(kind: &MobileAuditKind) -> &'static str {
    match kind {
        MobileAuditKind::VaultPull => "mobile.vault_pull",
        MobileAuditKind::Unlock => "mobile.unlock",
        MobileAuditKind::Reveal { .. } => "mobile.reveal",
        MobileAuditKind::Copy { .. } => "mobile.copy",
        MobileAuditKind::TotpGet => "mobile.totp_get",
        MobileAuditKind::Sync => "mobile.sync",
        MobileAuditKind::RevocationSeen => "mobile.revocation_seen",
        MobileAuditKind::StalePolicy => "mobile.stale_policy",
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use chrono::Utc;
    use tempfile::tempdir;
    use tsafe_core::age_crypto;
    use tsafe_core::crypto::{self, KeyPurpose, KeySchedule};
    use tsafe_core::team;
    use tsafe_core::vault::{SecretEntry, Vault};

    use crate::{
        build_mobile_audit_event, MobileAuditKind, MobileAuditStatus, MobileVault, MobileVaultKind,
    };

    #[test]
    fn opens_password_vault_bytes_and_lists_metadata_without_values() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("default.vault");
        let mut vault = Vault::create(&path, b"mobile-passphrase").unwrap();
        vault
            .set(
                "API_TOKEN",
                "super-secret-value",
                HashMap::from([("env".to_string(), "prod".to_string())]),
            )
            .unwrap();
        vault
            .set("TOTP_GITHUB", "JBSWY3DPEHPK3PXP", HashMap::new())
            .unwrap();
        let vault_bytes = serde_json::to_vec(vault.file()).unwrap();

        let mobile =
            MobileVault::open_password_vault_from_bytes(&vault_bytes, b"mobile-passphrase")
                .unwrap();

        assert_eq!(mobile.kind(), MobileVaultKind::Password);
        let items = mobile.list_items();
        assert_eq!(
            items
                .iter()
                .map(|item| item.key.as_str())
                .collect::<Vec<_>>(),
            vec!["API_TOKEN", "TOTP_GITHUB"]
        );
        assert_eq!(items[0].tags.get("env").map(String::as_str), Some("prod"));
        assert_eq!(items[0].history_len, 0);
        let serialized = serde_json::to_string(&items).unwrap();
        assert!(!serialized.contains("super-secret-value"));

        let item = mobile.get_item("API_TOKEN").unwrap();
        assert_eq!(item.key, "API_TOKEN");
        assert_eq!(&*item.value, "super-secret-value");
    }

    #[test]
    fn wrong_password_fails_closed() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("default.vault");
        let vault = Vault::create(&path, b"right-passphrase").unwrap();
        let vault_bytes = serde_json::to_vec(vault.file()).unwrap();

        let result = MobileVault::open_password_vault_from_bytes(&vault_bytes, b"wrong");

        assert!(result.is_err());
    }

    #[test]
    fn opens_team_vault_bytes_with_age_identity() {
        let (identity, recipient) = age_crypto::generate_identity();
        let (mut file, dek) = team::create_team_vault(&[recipient]).unwrap();
        let now = Utc::now();
        let (nonce, ciphertext) = crypto::encrypt_with_key_schedule(
            &dek,
            KeySchedule::HkdfSha256V1,
            KeyPurpose::SecretData,
            crypto::default_vault_cipher(),
            b"team-secret-value",
        )
        .unwrap();
        file.secrets.insert(
            "TEAM_TOKEN".to_string(),
            SecretEntry {
                nonce: crypto::encode_b64(&nonce),
                ciphertext: crypto::encode_b64(&ciphertext),
                created_at: now,
                updated_at: now,
                tags: HashMap::from([("scope".to_string(), "team".to_string())]),
                history: Vec::new(),
            },
        );
        let vault_bytes = serde_json::to_vec(&file).unwrap();

        let mobile = MobileVault::open_team_vault_from_bytes(&vault_bytes, &identity).unwrap();

        assert_eq!(mobile.kind(), MobileVaultKind::Team);
        assert_eq!(mobile.list_items()[0].key, "TEAM_TOKEN");
        assert_eq!(
            &*mobile.get_item("TEAM_TOKEN").unwrap().value,
            "team-secret-value"
        );
    }

    #[test]
    fn totp_generation_honors_stored_otpauth_profile() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("default.vault");
        let mut vault = Vault::create(&path, b"mobile-passphrase").unwrap();
        vault
            .set(
                "GITHUB_2FA",
                "otpauth://totp/GitHub?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&digits=8&period=60",
                HashMap::from([("type".to_string(), "totp".to_string())]),
            )
            .unwrap();
        let vault_bytes = serde_json::to_vec(vault.file()).unwrap();
        let mobile =
            MobileVault::open_password_vault_from_bytes(&vault_bytes, b"mobile-passphrase")
                .unwrap();

        let code = mobile.generate_totp_code("GITHUB_2FA").unwrap();

        assert_eq!(code.code.len(), 8);
        assert!(code.code.chars().all(|ch| ch.is_ascii_digit()));
        assert!((1..=60).contains(&code.seconds_remaining));
    }

    #[test]
    fn mobile_audit_event_is_redacted() {
        let event = build_mobile_audit_event(
            "mobile-default",
            MobileAuditKind::Copy { ttl_secs: 30 },
            Some("API_TOKEN"),
            MobileAuditStatus::Success,
        );

        let serialized = serde_json::to_string(&event).unwrap();
        assert!(serialized.contains("mobile.copy"));
        assert!(serialized.contains("API_TOKEN"));
        assert!(!serialized.contains("super-secret-value"));
    }
}
