// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM),
// generated once and stored in the data-protection Keychain (with a legacy file-keychain
// fallback for unsigned builds — see `query(dataProtection:)`). The certificate's fingerprint is how
// hosts recognize this client after PIN pairing — losing the key un-pairs this Mac from
// every host, so the pair is presented on every connect but never regenerated once
// stored. That invariant drives the error handling below: a Keychain that *refuses
// access* (locked, ACL denied) is an error, not a first run — minting a replacement
// would silently shadow the durable identity and break every existing pairing.

import Foundation
import PunktfunkKit
import Security

final class ClientIdentityStore: @unchecked Sendable {
    static let shared = ClientIdentityStore()

    enum IdentityError: Error {
        /// The Keychain refused access (locked, ACL denied, …) — an identity may exist.
        case keychain(OSStatus)
        /// The identity lives only in memory (Keychain write failed); good enough to
        /// present on a connect, not good enough to pair against.
        case notPersisted
    }

    private let lock = NSLock()
    private var cached: (identity: ClientIdentity, persisted: Bool)?

    /// The identity to present when connecting, generating + persisting it on first run.
    /// `persisted == false` means the Keychain write failed and it lives only in memory —
    /// fine for a session, see `loadForPairing()` for the strict variant. Blocking
    /// (Keychain + key generation) — call off the main actor.
    func load() throws -> (identity: ClientIdentity, persisted: Bool) {
        lock.lock()
        defer { lock.unlock() }
        if let cached { return cached }

        switch copyStored() {
        case .found(let identity):
            let hit = (identity, true)
            cached = hit
            return hit
        case .absent:
            break // genuine first run — mint below
        case .corrupt:
            // Our own item, undecodable: the pairings it backed are unusable either
            // way, so deliberately self-heal by replacing it (both keychains, best-effort).
            SecItemDelete(Self.query(dataProtection: true) as CFDictionary)
            SecItemDelete(Self.query(dataProtection: false) as CFDictionary)
        case .denied(let status):
            throw IdentityError.keychain(status)
        }

        let fresh = try generateIdentity()
        let entry: (ClientIdentity, Bool)
        switch add(fresh) {
        case errSecSuccess:
            entry = (fresh, true)
        case errSecDuplicateItem:
            // Lost a first-run race with another instance — the stored identity is the
            // durable one, never overwrite it.
            if case .found(let identity) = copyStored() {
                entry = (identity, true)
            } else {
                entry = (fresh, false)
            }
        default:
            entry = (fresh, false)
        }
        cached = entry
        return entry
    }

    /// Pairing variant: the host is about to durably trust this identity, so it must be
    /// durable on our side too — a memory-only identity would evaporate on relaunch and
    /// strand the pairing.
    func loadForPairing() throws -> ClientIdentity {
        let (identity, persisted) = try load()
        guard persisted else { throw IdentityError.notPersisted }
        return identity
    }

    private struct Stored: Codable {
        var certPEM: String
        var keyPEM: String
    }

    private enum ReadResult {
        case found(ClientIdentity)
        case absent
        case corrupt
        case denied(OSStatus)
    }

    /// Item coordinates. We prefer the DATA-PROTECTION keychain: with the app's
    /// `keychain-access-groups` entitlement, items there are gated by the app's identity
    /// (team + bundle id) instead of a per-binary ACL — so a SIGNED build reads them across
    /// rebuilds with NO Keychain prompt (a per-binary ACL re-prompts on every resign, which
    /// is why an ad-hoc-signed app asked every launch). An ad-hoc / unsigned build (e.g.
    /// `swift run`) has no such entitlement — `SecItem*` returns `errSecMissingEntitlement`
    /// there, and we fall back to the legacy file keychain (still works, with the old prompt).
    private static func query(dataProtection: Bool) -> [String: Any] {
        var q: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: "io.unom.punktfunk",
            kSecAttrAccount as String: "client-identity",
        ]
        if dataProtection { q[kSecUseDataProtectionKeychain as String] = true }
        return q
    }

    private func copyStored() -> ReadResult {
        let result = read(dataProtection: true)
        // No entitlement (ad-hoc / unsigned build): the data-protection keychain is
        // unavailable — read the legacy file keychain instead.
        if case .denied(errSecMissingEntitlement) = result {
            return read(dataProtection: false)
        }
        return result
    }

    private func read(dataProtection: Bool) -> ReadResult {
        var query = Self.query(dataProtection: dataProtection)
        query[kSecReturnData as String] = true
        var out: CFTypeRef?
        switch SecItemCopyMatching(query as CFDictionary, &out) {
        case errSecSuccess:
            guard let data = out as? Data,
                  let stored = try? JSONDecoder().decode(Stored.self, from: data)
            else { return .corrupt }
            return .found(ClientIdentity(certPEM: stored.certPEM, keyPEM: stored.keyPEM))
        case errSecItemNotFound:
            return .absent
        case let status:
            return .denied(status)
        }
    }

    private func add(_ identity: ClientIdentity) -> OSStatus {
        guard let data = try? JSONEncoder().encode(
            Stored(certPEM: identity.certPEM, keyPEM: identity.keyPEM))
        else { return errSecParam }
        var add = Self.query(dataProtection: true)
        add[kSecValueData as String] = data
        // After-first-unlock so a background reconnect can still read it; the access-group
        // entitlement (not a per-binary ACL) gates it, so it survives rebuilds prompt-free.
        add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
        let status = SecItemAdd(add as CFDictionary, nil)
        guard status == errSecMissingEntitlement else { return status }
        // Ad-hoc / unsigned build: persist to the legacy file keychain instead.
        var legacy = Self.query(dataProtection: false)
        legacy[kSecValueData as String] = data
        return SecItemAdd(legacy as CFDictionary, nil)
    }
}
