import Foundation
import Security

/// Testability seam over the four `SecItem*` C calls (plan §3.3 / T-iOS-7).
///
/// Why a seam: unsigned `swift test` binaries get `errSecMissingEntitlement`
/// (-34018) from the data-protection keychain, so unit tests inject a fake
/// conforming to this protocol and assert the store's dictionaries verbatim;
/// the live shim only runs inside the signed simulator/app pass (T-iOS-15/16).
public protocol SecItemShim: Sendable {
    func add(_ attributes: [String: any Sendable]) -> OSStatus
    func update(query: [String: any Sendable],
                attributesToUpdate: [String: any Sendable]) -> OSStatus
    func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?)
    func delete(_ query: [String: any Sendable]) -> OSStatus
}

/// Live passthrough to Security.framework. Deliberately logic-free: every
/// attribute — including the §5.3-mandated protection keys — is built by
/// `KeychainItemSpec` below, so there is exactly ONE place to audit and to
/// unit-test. Adding attribute logic here would split that single source.
public struct LiveSecItemShim: SecItemShim {
    public init() {}

    public func add(_ attributes: [String: any Sendable]) -> OSStatus {
        SecItemAdd(attributes as [String: Any] as CFDictionary, nil)
    }

    public func update(query: [String: any Sendable],
                       attributesToUpdate: [String: any Sendable]) -> OSStatus {
        SecItemUpdate(query as [String: Any] as CFDictionary,
                      attributesToUpdate as [String: Any] as CFDictionary)
    }

    public func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?) {
        var result: CFTypeRef?
        let status = SecItemCopyMatching(query as [String: Any] as CFDictionary, &result)
        return (status, result as? Data)
    }

    public func delete(_ query: [String: any Sendable]) -> OSStatus {
        SecItemDelete(query as [String: Any] as CFDictionary)
    }
}

/// §5.3 single source of truth for every keychain dictionary the store sends
/// through the shim. One wrong attribute here = credentials leave the device
/// with backups — KeychainHostStoreTests asserts these dictionaries verbatim
/// (exact key sets), and the signed simulator pass (T-iOS-15/16) verifies the
/// real item's attributes end-to-end.
struct KeychainItemSpec: Sendable {
    let service: String
    let account: String

    /// Identifies the item in every call. `kSecUseDataProtectionKeychain`
    /// pins the iOS-style data-protection keychain on macOS too (on iOS it
    /// is implicit); omitting it would silently fall back to the legacy
    /// file keychain on Mac and void the §5.3 semantics.
    func baseQuery() -> [String: any Sendable] {
        [
            kSecClass as String: kSecClassGenericPassword as String,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecUseDataProtectionKeychain as String: true,
        ]
    }

    func copyQuery() -> [String: any Sendable] {
        merged(baseQuery(), [
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne as String,
        ])
    }

    /// `AfterFirstUnlockThisDeviceOnly`: readable in background after first
    /// unlock (reconnect/UI needs), never migrates to another device or into
    /// backups; no `kSecAttrSynchronizable` → never iCloud-synced (§5.3).
    func addAttributes(data: Data) -> [String: any Sendable] {
        merged(baseQuery(), updateAttributes(data: data))
    }

    /// Update re-asserts `kSecAttrAccessible` so a pre-existing item can
    /// never retain a weaker protection class than the current policy.
    func updateAttributes(data: Data) -> [String: any Sendable] {
        [
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
            kSecValueData as String: data,
        ]
    }

    private func merged(
        _ base: [String: any Sendable],
        _ extra: [String: any Sendable]
    ) -> [String: any Sendable] {
        base.merging(extra) { _, new in new }
    }
}
