import Foundation
import Security

/// Shared persistence for Nucleic's **own** secrets so the app reads them back *silently* — without
/// the "Nucleic wants to use your confidential information stored in <item>" authorization panel —
/// while keeping every other process gated out.
///
/// **Why the old login-Keychain approach couldn't stay silent.** The login Keychain gates each item
/// on *two independent* things: its access-control list (ACL, which names trusted applications) and,
/// since macOS Sierra, its **ACL partition list** (`teamid:…` / `apple:` partitions). Even when
/// Nucleic is named in the trusted-application list, an item whose partition list doesn't include
/// Nucleic's partition still triggers the panel — and there is *no public API* to set the partition
/// list when creating an item (it's what the `security set-key-partition-list` CLI exists to patch).
/// So a login-Keychain item created programmatically can't be made reliably silent for the very app
/// that owns it: exactly the "the app is asking permission to read its own secret" symptom.
///
/// **The fix: the data-protection Keychain.** `kSecUseDataProtectionKeychain` selects the modern
/// Keychain, where access is governed by the app's **access group** (derived from its code signature
/// / the `keychain-access-groups` entitlement in `signing/nucleic.entitlements`), *not* by ACLs or
/// partition lists. The owning app reads its items silently and permanently — surviving Sparkle
/// updates and re-signs — while any other process still can't reach them. All channels share one
/// team-prefixed access group (`L7UDTQ6F5W.xyz.blakeslee.nucleic`), so a canary and a stable install
/// see the same items, exactly as the shared login Keychain did before.
///
/// **Ad-hoc / unsigned fallback.** A local dev build is ad-hoc signed (no Team ID), so it can't claim
/// a team-prefixed access group and the data-protection Keychain reports `errSecMissingEntitlement`.
/// `dataProtectionAvailable` probes for that once and, when it's missing, every operation falls back
/// to the login Keychain with the app-owned ACL — the prior behavior — so local builds keep working
/// (they just don't get the silent guarantee, which they never could across constant re-signs anyway).
///
/// **Migration + durable login mirror.** An install that predates this change kept its secret in the
/// login Keychain. `copyData` notices the data-protection Keychain is empty and reads the legacy item
/// across (this one read may show the panel a final time if the user never clicked "Always Allow"),
/// writing it into the data-protection Keychain. The login copy is deliberately *kept*, and every
/// ``set`` refreshes it too: reads prefer the data-protection copy, so the mirror stays unread — and
/// therefore silent — whenever the entitlement is present, but it survives a later build that can no
/// longer reach the data-protection Keychain (a dropped/absent `keychain-access-groups` entitlement).
/// Deleting that mirror is exactly what stranded the host Noise identity when the entitlement was
/// removed: the new build couldn't read the data-protection item, regenerated the identity, and every
/// paired device stopped recognizing this Mac.
///
/// Not for the `Claude Code-credentials` item: that one is deliberately *shared* with Claude Code and
/// other clients (see ``ClaudeLoginKeychain``) and must keep whatever ACL its creator gave it —
/// owning it would lock the other clients out.
enum KeychainOwnedAccess {
    /// Whether this build can use the data-protection Keychain. A signed build carrying the
    /// `keychain-access-groups` entitlement can; an ad-hoc/unsigned build can't (the platform
    /// answers `errSecMissingEntitlement`), and then callers fall back to the login Keychain.
    ///
    /// Probed once (thread-safe lazy `let`) with a harmless lookup for a nonexistent account: any
    /// answer other than `errSecMissingEntitlement` means the data-protection Keychain is reachable.
    private static let dataProtectionAvailable: Bool = {
        let status = SecItemCopyMatching([
            kSecClass as String: kSecClassGenericPassword,
            kSecUseDataProtectionKeychain as String: true,
            kSecAttrAccount as String: "com.nucleic.__dp_probe__",
            kSecMatchLimit as String: kSecMatchLimitOne,
            kSecReturnData as String: true,
        ] as CFDictionary, nil)
        return status != errSecMissingEntitlement
    }()

    // MARK: - Store API (used by every Nucleic-owned Keychain store)

    /// Read the secret selected by `identity` (a query naming exactly one item by class +
    /// account [+ service], with *no* return/limit/value keys). Prefers the data-protection
    /// Keychain; migrates a legacy login-Keychain copy across on first use. Returns `nil` only when
    /// the secret is genuinely absent (or an existing one couldn't be read).
    static func copyData(_ identity: [String: Any]) -> Data? {
        guard dataProtectionAvailable else { return rawRead(identity) }
        if let data = rawRead(dataProtection(identity)) { return data }
        // Not in the data-protection Keychain — an older install (or one signed without the
        // access-group entitlement) kept it in the login Keychain. Mirror it across so future reads
        // hit the data-protection Keychain silently (this legacy read may surface the panel once),
        // but KEEP the login copy as a durable fallback: if a later build loses the entitlement,
        // that copy is the only thing between it and a regenerated secret.
        guard let legacy = rawRead(identity) else { return nil }
        var add = dataProtection(identity)
        add[kSecValueData as String] = legacy
        add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
        _ = SecItemAdd(add as CFDictionary, nil)
        return legacy
    }

    /// Store (replacing any prior copy) the secret. `base` carries the class, the identifying
    /// attributes, and `kSecValueData`. Writes to the data-protection Keychain when available, else
    /// the login Keychain with the app-owned ACL. Returns the `OSStatus` of whichever add ran.
    @discardableResult
    static func set(_ base: [String: Any], label: String = "Nucleic") -> OSStatus {
        var identity = base
        identity.removeValue(forKey: kSecValueData as String)

        // Always write a login-Keychain copy as a durable mirror. Reads prefer the data-protection
        // copy (see `copyData`), so this mirror stays unread — and therefore silent — whenever the
        // entitlement is present; it exists purely so the secret survives a build that can no longer
        // reach the data-protection Keychain. Dropping this mirror is what stranded the host identity
        // when the keychain-access-groups entitlement was removed.
        SecItemDelete(identity as CFDictionary)
        let loginStatus = addLoginKeychain(base, label: label)

        if dataProtectionAvailable {
            SecItemDelete(dataProtection(identity) as CFDictionary)
            var add = dataProtection(base)
            add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
            let status = SecItemAdd(add as CFDictionary, nil)
            if status == errSecSuccess { return status }
            // Data-protection add rejected — the login mirror above still holds the secret.
        }
        return loginStatus
    }

    /// Remove the secret from both Keychains. Succeeds whether or not one was present.
    static func remove(_ identity: [String: Any]) {
        SecItemDelete(dataProtection(identity) as CFDictionary)
        SecItemDelete(identity as CFDictionary)
    }

    // MARK: - Data-protection Keychain helpers

    /// `query` with the data-protection Keychain selected. The access group is left implicit: the
    /// single `keychain-access-groups` entitlement is the app's default group, so adds land there
    /// and reads search it — no need to name it (or duplicate the string) here.
    private static func dataProtection(_ query: [String: Any]) -> [String: Any] {
        var q = query
        q[kSecUseDataProtectionKeychain as String] = true
        return q
    }

    /// Fetch one item's data for `query` (any Keychain), adding the return/limit keys.
    private static func rawRead(_ query: [String: Any]) -> Data? {
        var q = query
        q[kSecReturnData as String] = true
        q[kSecMatchLimit as String] = kSecMatchLimitOne
        var item: CFTypeRef?
        guard SecItemCopyMatching(q as CFDictionary, &item) == errSecSuccess else { return nil }
        return item as? Data
    }

    // MARK: - Login-Keychain fallback (ad-hoc / unsigned builds)

    /// `SecItemAdd` `base` into the login Keychain with an ACL naming *this* app as the sole trusted
    /// application, so it reads back with no prompt where the platform honors it. Falls back to a
    /// plain `kSecAttrAccessibleAfterFirstUnlock` add if the ACL can't be built or is rejected.
    @discardableResult
    private static func addLoginKeychain(_ base: [String: Any], label: String) -> OSStatus {
        if let access = appOwned(label: label) {
            var owned = base
            owned[kSecAttrAccess as String] = access
            let status = SecItemAdd(owned as CFDictionary, nil)
            if status == errSecSuccess { return status }
            // ACL-bearing add rejected — fall through rather than lose the secret. (kSecAttrAccess
            // and kSecAttrAccessible are mutually exclusive, so swap them.)
        }
        var plain = base
        plain[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
        return SecItemAdd(plain as CFDictionary, nil)
    }

    /// An access object naming the running app as the sole trusted application, or `nil` if the
    /// platform can't vend one. Uses the legacy `SecTrustedApplication`/`SecAccess` API — deprecated,
    /// but still the only way to set a login-Keychain ACL, and only reached on the ad-hoc fallback.
    private static func appOwned(label: String) -> SecAccess? {
        var trustedApp: SecTrustedApplication?
        // A nil path means "the application calling this function" — i.e. Nucleic itself.
        guard SecTrustedApplicationCreateFromPath(nil, &trustedApp) == errSecSuccess,
              let trustedApp
        else { return nil }
        var access: SecAccess?
        guard SecAccessCreate(label as CFString, [trustedApp] as CFArray, &access) == errSecSuccess
        else { return nil }
        return access
    }
}
