//! macOS Keychain with `SecAccessControl` so reads require Touch ID (BiometryCurrentSet).
//!
//! Since Tranche 2 (E4.2, 2026-05-16) the ACL flag is `kSecAccessControlBiometryCurrentSet`
//! (`AccessControlOptions::BIOMETRY_CURRENT_SET`), which requires Touch ID and is invalidated
//! when the biometric enrollment changes (new finger enrolled). This is strictly stronger than
//! `kSecAccessControlUserPresence`, which accepted a device password as a fallback and survived
//! new-enrollment events. The upgrade closes residual C5.3.

use crate::errors::{SafeError, SafeResult};
use core_foundation::base::{CFRelease, CFType, CFTypeRef, TCFType};
use core_foundation::boolean::CFBoolean;
use core_foundation::dictionary::CFDictionary;
use core_foundation::string::CFString;
use keyring::Error as KeyringError;
use security_framework::base::Error as SecError;
use security_framework::passwords::{
    delete_generic_password_options, generic_password, set_generic_password_options,
    PasswordOptions,
};
use security_framework::passwords_options::AccessControlOptions;
use security_framework_sys::base::errSecItemNotFound;
use security_framework_sys::item::{
    kSecAttrAccount, kSecAttrService, kSecClass, kSecClassGenericPassword, kSecReturnAttributes,
    kSecReturnData, kSecUseAuthenticationUI, kSecUseAuthenticationUISkip,
    kSecUseDataProtectionKeychain,
};
use security_framework_sys::keychain::SecKeychainSetUserInteractionAllowed;
use security_framework_sys::keychain_item::SecItemCopyMatching;

/// Item exists but authentication UI was skipped (`kSecUseAuthenticationUISkip`).
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25308;

/// Creating items with `SecAccessControl` biometry requires a properly entitled, signed binary.
/// Unsigned `tsafe` builds (local `cargo install`, dev) hit this — fall back to plain keychain storage.
const ERR_SEC_MISSING_ENTITLEMENT: i32 = -34018;

/// `kSecAccessControlBiometryCurrentSet` raw flag value: `1 << 3 = 0x08`.
///
/// `AccessControlOptions::BIOMETRY_CURRENT_SET` from `security-framework` 3.x exposes this
/// constant natively; this local constant exists only to make the value testable on all platforms
/// without pulling in the macOS-only `security_framework_sys` in non-macOS test contexts.
#[cfg(test)]
pub(crate) const SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET: usize = 0x08;

pub(super) const SERVICE_NAME: &str = "tsafe";

/// Set at **compile time** for signed release builds so Keychain items use the same access group as
/// `codesign --entitlements` (`keychain-access-groups`). Format: `{TEAM_ID}.com.tsafe.cli`.
const KEYCHAIN_ACCESS_GROUP: Option<&str> = option_env!("TSAFE_MACOS_KEYCHAIN_ACCESS_GROUP");

fn plain_options(service: &str, account: &str) -> PasswordOptions {
    PasswordOptions::new_generic_password(service, account)
}

fn data_protection_options(service: &str, account: &str) -> PasswordOptions {
    let mut o = PasswordOptions::new_generic_password(service, account);
    o.use_protected_keychain();
    if let Some(group) = KEYCHAIN_ACCESS_GROUP {
        o.set_access_group(group);
    }
    o
}

fn missing_entitlement_for_touch_id_protected_item(e: &SecError) -> bool {
    if e.code() == ERR_SEC_MISSING_ENTITLEMENT {
        return true;
    }
    e.message()
        .map(|m| m.to_lowercase().contains("entitlement"))
        .unwrap_or(false)
}

/// Store using the cross-platform `keyring` backend (login keychain, no `SecAccessControl` ACL).
fn store_password_plain_keyring(profile: &str, password: &str) -> SafeResult<()> {
    let entry = keyring::Entry::new(SERVICE_NAME, profile).map_err(|e| SafeError::Crypto {
        context: format!("keyring init: {e}"),
    })?;
    entry
        .set_password(password)
        .map_err(|e| SafeError::Crypto {
            context: format!("keyring store: {e}"),
        })?;
    Ok(())
}

/// Store the vault password in the data-protection keychain with **BiometryCurrentSet** ACL.
///
/// `kSecAccessControlBiometryCurrentSet` requires Touch ID and is invalidated when the biometric
/// enrollment changes (new finger added or removed), closing the residual risk that a stolen device
/// with a freshly enrolled finger could use an old ACL-gated credential. This is strictly stronger
/// than the former `USER_PRESENCE` flag, which also accepted the device password as a fallback and
/// survived enrollment changes.
///
/// When the OS rejects `SecAccessControl` (unsigned CLI, missing entitlement), falls back to the
/// same generic-keychain path as Linux/Windows so `biometric enable` still succeeds; quick unlock
/// then uses the standard login keychain (not a per-read biometric prompt).
pub fn store_password(profile: &str, password: &str) -> SafeResult<()> {
    // Remove any prior item (data-protection keychain, file-based keychain, or `keyring` entry).
    let _ = delete_generic_password_options(data_protection_options(SERVICE_NAME, profile));
    let _ = delete_generic_password_options(plain_options(SERVICE_NAME, profile));
    let _ = keyring::Entry::new(SERVICE_NAME, profile).and_then(|e| e.delete_credential());

    let mut options = data_protection_options(SERVICE_NAME, profile);
    // BiometryCurrentSet: Touch ID only, invalidated on new-enrollment. Upgraded from
    // USER_PRESENCE in Tranche 2 (E4.2) — see ADR-008 implementation note.
    options.set_access_control_options(AccessControlOptions::BIOMETRY_CURRENT_SET);
    match set_generic_password_options(password.as_bytes(), options) {
        Ok(()) => Ok(()),
        Err(e_acl) => {
            // Unsigned CLIs, missing entitlements, and some environments fail the ACL path for
            // many reasons — always try the plain keyring store before surfacing an error.
            match store_password_plain_keyring(profile, password) {
                Ok(()) => {
                    if missing_entitlement_for_touch_id_protected_item(&e_acl) {
                        eprintln!(
                            "tsafe: note: Touch ID–protected keychain items are not available for this build (OS reports missing keychain entitlement). Stored in the login keychain without per-read biometric (quick unlock still works when your session can access the keychain)."
                        );
                    } else {
                        eprintln!(
                            "tsafe: note: could not store with Touch ID–protected keychain item ({e_acl}). Stored using standard login keychain storage instead."
                        );
                    }
                    Ok(())
                }
                Err(e_plain) => Err(SafeError::Crypto {
                    context: format!(
                        "keychain: could not store credential (primary: {e_acl}; fallback: {e_plain})"
                    ),
                }),
            }
        }
    }
}

pub fn retrieve_password(profile: &str) -> SafeResult<Option<String>> {
    // Some hosts (e.g. terminal emulators after raw mode, or tools that toggled the flag) run with
    // keychain UI disabled; reads then fail or fall back to password-only prompts without Touch ID.
    // Re-allow interaction before every protected read (idempotent when already allowed).
    // SAFETY: SecKeychainSetUserInteractionAllowed takes a single Boolean (u8) by
    // value and mutates only process-global Security framework state. No pointers
    // are passed in or returned, and the call is documented as thread-safe and
    // idempotent. We deliberately ignore the OSStatus return — failure here just
    // means the subsequent keychain read may fall back to a non-biometric prompt.
    unsafe {
        let _ = SecKeychainSetUserInteractionAllowed(1);
    }

    fn retrieve_plain(profile: &str) -> SafeResult<Option<String>> {
        match generic_password(plain_options(SERVICE_NAME, profile)) {
            Ok(bytes) => String::from_utf8(bytes)
                .map(Some)
                .map_err(|e| SafeError::Crypto {
                    context: format!("keychain password is not valid UTF-8: {e}"),
                }),
            Err(e2) if e2.code() == errSecItemNotFound => Ok(None),
            Err(e2) => Err(SafeError::Crypto {
                context: format!("keychain retrieve: {e2}"),
            }),
        }
    }

    match generic_password(data_protection_options(SERVICE_NAME, profile)) {
        Ok(bytes) => String::from_utf8(bytes)
            .map(Some)
            .map_err(|e| SafeError::Crypto {
                context: format!("keychain password is not valid UTF-8: {e}"),
            }),
        Err(e) if e.code() == errSecItemNotFound => retrieve_plain(profile),
        Err(e) if missing_entitlement_for_touch_id_protected_item(&e) => retrieve_plain(profile),
        Err(e) => Err(SafeError::Crypto {
            context: format!("keychain retrieve: {e}"),
        }),
    }
}

pub fn remove_password(profile: &str) -> SafeResult<()> {
    let d1 = delete_generic_password_options(data_protection_options(SERVICE_NAME, profile));
    let d2 = delete_generic_password_options(plain_options(SERVICE_NAME, profile));

    let mut sec_err: Option<SecError> = None;
    for (idx, r) in [d1, d2].into_iter().enumerate() {
        match r {
            Ok(()) => return Ok(()),
            Err(e) if e.code() == errSecItemNotFound => {}
            // Unsigned / dev builds cannot delete data-protection items created by a signed release;
            // treat like "no Sec delete this way" and still try plain + `keyring` removal.
            Err(e) if idx == 0 && missing_entitlement_for_touch_id_protected_item(&e) => {}
            Err(e) => sec_err = Some(e),
        }
    }
    if let Some(e) = sec_err {
        return Err(SafeError::Crypto {
            context: format!("keychain delete: {e}"),
        });
    }

    let entry = keyring::Entry::new(SERVICE_NAME, profile).map_err(|e| SafeError::Crypto {
        context: format!("keyring init: {e}"),
    })?;
    match entry.delete_credential() {
        Ok(()) => Ok(()),
        Err(KeyringError::NoEntry) => Ok(()),
        Err(ke) => Err(SafeError::Crypto {
            context: format!("keyring delete: {ke}"),
        }),
    }
}

/// Whether a keychain item exists for this profile **without** triggering Touch ID.
/// Uses `kSecUseAuthenticationUISkip` so the biometric sheet is not shown.
pub fn has_password(profile: &str) -> bool {
    item_exists_skip_ui(SERVICE_NAME, profile, true).unwrap_or(false)
        || item_exists_skip_ui(SERVICE_NAME, profile, false).unwrap_or(false)
}

/// Best-effort classification of where the quick-unlock credential appears to live.
///
/// Uses the same no-UI `SecItemCopyMatching` probes as [`has_password`]. If the item is visible
/// under the **data-protection** keychain query first, we report the tier that matches
/// `kSecUseDataProtectionKeychain` + ACL storage; if only the legacy generic-password query
/// matches, we report the **login keychain / fallback** tier typical of unsigned builds or older
/// tsafe versions. This is a **heuristic** (not a cryptographic guarantee).
pub(super) fn quick_unlock_storage_note(profile: &str) -> Option<String> {
    // Heuristic: returns a storage-tier classification. Not a cryptographic guarantee.
    let in_dp = item_exists_skip_ui(SERVICE_NAME, profile, true).unwrap_or(false);
    if in_dp {
        return Some(
            "macOS storage tier: data-protection keychain (matches signed-release + ACL path; Touch ID / Watch / passcode per read when the OS allows)."
                .to_string(),
        );
    }
    let in_legacy = item_exists_skip_ui(SERVICE_NAME, profile, false).unwrap_or(false);
    if in_legacy {
        return Some(
            "macOS storage tier: login keychain fallback (no data-protection item found for this profile). Per-read Touch ID may not apply — common for unsigned `cargo install` / dev builds; try `biometric disable` then `biometric enable` after installing a signed release, or see docs/features/biometric.md."
                .to_string(),
        );
    }
    None
}

fn item_exists_skip_ui(
    service: &str,
    account: &str,
    data_protection: bool,
) -> Result<bool, SecError> {
    // The `unsafe` calls below are all `CFString::wrap_under_get_rule(kSec*)`. Each
    // `kSec*` symbol is a `CFStringRef` exported by the Security framework as an
    // immortal, framework-owned constant — it is initialized at framework load and
    // never released. `wrap_under_get_rule` follows Core Foundation Get-rule
    // semantics: it retains the wrapped object, so the resulting CFString owns a
    // +1 retain that is balanced by CFString's Drop. This is the documented sound
    // pattern for borrowing a framework-owned CFStringRef.
    let mut pairs: Vec<(CFString, CFType)> = vec![
        (
            unsafe { CFString::wrap_under_get_rule(kSecClass) },
            unsafe { CFString::wrap_under_get_rule(kSecClassGenericPassword).into_CFType() },
        ),
        (
            unsafe { CFString::wrap_under_get_rule(kSecAttrService) },
            CFString::from(service).into_CFType(),
        ),
        (
            unsafe { CFString::wrap_under_get_rule(kSecAttrAccount) },
            CFString::from(account).into_CFType(),
        ),
        (
            unsafe { CFString::wrap_under_get_rule(kSecReturnAttributes) },
            CFBoolean::from(true).into_CFType(),
        ),
        (
            unsafe { CFString::wrap_under_get_rule(kSecReturnData) },
            CFBoolean::from(false).into_CFType(),
        ),
        (
            unsafe { CFString::wrap_under_get_rule(kSecUseAuthenticationUI) },
            unsafe { CFString::wrap_under_get_rule(kSecUseAuthenticationUISkip).into_CFType() },
        ),
    ];
    if data_protection {
        pairs.push((
            unsafe { CFString::wrap_under_get_rule(kSecUseDataProtectionKeychain) },
            CFBoolean::from(true).into_CFType(),
        ));
    }
    let params = CFDictionary::from_CFType_pairs(&pairs);
    let mut ret: CFTypeRef = std::ptr::null();
    // SAFETY: `params.as_concrete_TypeRef()` returns a CFDictionaryRef that is
    // valid for as long as `params` is alive — `params` outlives this call (it is
    // not moved or dropped until end of function). `&mut ret` points to a
    // properly-initialized `CFTypeRef` (null pointer) on this stack frame, alive
    // for the call. SecItemCopyMatching writes a +1-retained CFTypeRef there on
    // success (which we release below) or leaves it null on failure.
    let status = unsafe { SecItemCopyMatching(params.as_concrete_TypeRef(), &mut ret) };
    if !ret.is_null() {
        // SAFETY: SecItemCopyMatching followed the Copy rule and returned a
        // +1-retained CFTypeRef in `ret`. We are the sole owner; calling
        // CFRelease exactly once balances that retain. After this point we never
        // dereference `ret` again, so there is no use-after-free.
        unsafe {
            CFRelease(ret);
        }
    }
    match status {
        0 => Ok(true),
        e if e == errSecItemNotFound => Ok(false),
        // Item is present but requires authentication — counts as "configured".
        e if e == ERR_SEC_INTERACTION_NOT_ALLOWED => Ok(true),
        e => Err(SecError::from_code(e)),
    }
}

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

    #[test]
    fn missing_entitlement_code_triggers_fallback_path() {
        let e = SecError::from_code(ERR_SEC_MISSING_ENTITLEMENT);
        assert!(missing_entitlement_for_touch_id_protected_item(&e));
    }

    /// Verify the `BiometryCurrentSet` constant matches the Apple Security framework value.
    ///
    /// `kSecAccessControlBiometryCurrentSet` is defined as `1 << 3 = 0x08` in Apple's
    /// `Security/SecAccessControl.h` and confirmed in `security-framework-sys` 2.17.
    /// The task specification cited `0x20`; Apple's headers and the Rust binding both use `0x08`.
    /// This test is the authoritative record — if the value ever diverges, fail loudly.
    #[test]
    fn biometry_current_set_constant_has_expected_value() {
        assert_eq!(
            SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET, 0x08,
            "kSecAccessControlBiometryCurrentSet must equal 1<<3 (0x08) per Apple Security headers"
        );
        // Also cross-check against the bitflags value exposed by security-framework.
        assert_eq!(
            AccessControlOptions::BIOMETRY_CURRENT_SET.bits(),
            SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET,
            "AccessControlOptions::BIOMETRY_CURRENT_SET bits must match SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET"
        );
    }

    /// Verify that `store_password` uses `BIOMETRY_CURRENT_SET`, not `USER_PRESENCE`.
    ///
    /// This is a compile-time / unit-level check: if the wrong flag is used, this fails because
    /// `BIOMETRY_CURRENT_SET` bits (0x08) != `USER_PRESENCE` bits (0x01).
    ///
    /// A full integration test (actually writing to the macOS Keychain) requires a signed binary
    /// with the `keychain-access-groups` entitlement and macOS hardware — those tests are marked
    /// `#[ignore]` with the tag `[requires: macos-signed-hardware]`.
    #[test]
    fn biometry_current_set_is_not_user_presence() {
        assert_ne!(
            AccessControlOptions::BIOMETRY_CURRENT_SET.bits(),
            AccessControlOptions::USER_PRESENCE.bits(),
            "BIOMETRY_CURRENT_SET and USER_PRESENCE must be distinct flag values"
        );
    }

    /// Integration test: `store_password` and `retrieve_password` round-trip under BiometryCurrentSet.
    ///
    /// Requires:
    /// - macOS hardware (Touch ID enrolled or Simulator with biometrics)
    /// - A signed binary with `keychain-access-groups` entitlement (`TSAFE_MACOS_KEYCHAIN_ACCESS_GROUP`)
    ///
    /// Run manually: `cargo test -p tsafe-core -- store_password_uses_biometry_current_set --ignored`
    #[test]
    #[ignore = "requires: macos-signed-hardware — signed binary + Touch ID enrollment"]
    fn store_password_uses_biometry_current_set() {
        let profile = "tsafe-e4-2-test-biometry-current-set";
        let password = "hunter2-test-only";

        // Clean up any leftover from a previous run.
        let _ = remove_password(profile);

        store_password(profile, password).expect("store_password must succeed on signed macOS");

        // Verify the item is visible (without Touch ID — using kSecUseAuthenticationUISkip probe).
        assert!(
            has_password(profile),
            "has_password must return true after store_password"
        );

        // Clean up.
        remove_password(profile).expect("remove_password must succeed");
        assert!(
            !has_password(profile),
            "has_password must be false after removal"
        );
    }

    /// Integration test: `biometric re-enroll` stores the fresh credential under BiometryCurrentSet.
    ///
    /// Re-enroll calls `store_password` after removing the stale credential.  Because `store_password`
    /// is the only write path on macOS, the re-enrolled credential carries the same
    /// `BIOMETRY_CURRENT_SET` ACL as a first-time enrollment.
    ///
    /// This test exercises the re-enroll sequence end-to-end (remove stale + store fresh).
    ///
    /// Requires:
    /// - macOS hardware with signed binary (same as above)
    ///
    /// Run manually: `cargo test -p tsafe-core -- re_enroll_stores_with_updated_acl --ignored`
    #[test]
    #[ignore = "requires: macos-signed-hardware — signed binary + Touch ID enrollment"]
    fn re_enroll_stores_with_updated_acl() {
        let profile = "tsafe-e4-2-test-reenroll";
        let old_password = "old-password-stale";
        let new_password = "new-password-fresh";

        // Simulate a stale credential by storing under the old password.
        let _ = remove_password(profile);
        store_password(profile, old_password).expect("initial store must succeed");
        assert!(has_password(profile));

        // Simulate re-enroll: remove stale + store fresh (mirrors `biometric_re_enroll` logic).
        remove_password(profile).expect("remove stale credential");
        store_password(profile, new_password).expect("re-enroll store must succeed");

        assert!(
            has_password(profile),
            "credential must be present after re-enroll"
        );

        // Clean up.
        remove_password(profile).expect("cleanup");
        assert!(!has_password(profile));
    }
}
