import Foundation // MARK: - Protocols for Dependency Injection /// Abstraction over Keychain storage for the API key. /// Production: KeychainService conforms. Tests: InMemoryKeyStore. protocol KeyStoreProtocol { func save(_ key: String) throws func load() -> String? func delete() } /// Abstraction over UserDefaults for reading/removing the legacy API key. /// Production: UserDefaultsStore conforms. Tests: InMemoryDefaultsStore. protocol DefaultsStoreProtocol { func string(forKey key: String) -> String? func removeObject(forKey key: String) } // MARK: - Production Store Adapters /// Adapts KeychainService (static enum) to KeyStoreProtocol. struct KeychainKeyStore: KeyStoreProtocol { func save(_ key: String) throws { try KeychainService.saveAPIKey(key) } func load() -> String? { KeychainService.loadAPIKey() } func delete() { KeychainService.deleteAPIKey() } } /// Adapts UserDefaults to DefaultsStoreProtocol. struct UserDefaultsStore: DefaultsStoreProtocol { private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { self.defaults = defaults } func string(forKey key: String) -> String? { defaults.string(forKey: key) } func removeObject(forKey key: String) { defaults.removeObject(forKey: key) } } // MARK: - ChadMusicCredentials /// Single source of truth for the Chad Music API key. /// Reads from Keychain. On first access, migrates any existing /// UserDefaults value to Keychain and deletes the plaintext copy. @MainActor final class ChadMusicCredentials { /// Shared singleton using real Keychain + UserDefaults for production. static let shared = ChadMusicCredentials() private static let userDefaultsKey = "chadMusic.apiKey" private let keyStore: any KeyStoreProtocol private let defaultsStore: any DefaultsStoreProtocol private var hasMigrated = false /// DI initializer — tests inject in-memory mocks. init( keyStore: any KeyStoreProtocol = KeychainKeyStore(), defaultsStore: any DefaultsStoreProtocol = UserDefaultsStore() ) { self.keyStore = keyStore self.defaultsStore = defaultsStore } /// The current API key, or nil if not set. /// First call triggers one-time migration from UserDefaults → Keychain. var apiKey: String? { migrateIfNeeded() let key = keyStore.load() return (key?.isEmpty ?? true) ? nil : key } /// Whether an API key is currently stored. var hasKey: Bool { apiKey != nil } /// Save a new API key to Keychain. func save(_ key: String) throws { try keyStore.save(key) } /// Delete the API key from Keychain. func delete() { keyStore.delete() } // MARK: - Migration /// One-time migration: if UserDefaults has a key, move it to Keychain. private func migrateIfNeeded() { guard !hasMigrated else { return } hasMigrated = true guard let existingKey = defaultsStore.string(forKey: Self.userDefaultsKey), !existingKey.isEmpty else { return } // Only migrate if Keychain is empty (don't overwrite a Keychain value) if keyStore.load() == nil { do { try keyStore.save(existingKey) defaultsStore.removeObject(forKey: Self.userDefaultsKey) } catch { // Keychain denied — leave UserDefaults in place as fallback. print("[ChadMusicCredentials] Migration failed: \(error). Leaving UserDefaults in place.") } } else { // Keychain already has a value — just delete the plaintext copy defaultsStore.removeObject(forKey: Self.userDefaultsKey) } } }