import CryptoKit
import Foundation
import NucleicProtocol
import Security

/// The phone-side credential vault (docs/REMOTE_AGENT_LOGIN.md follow-up: phone as credential
/// HOLDER). Holds the mirrorable rotating logins — Claude OAuth and Codex auth — so an
/// iPhone-primary mesh can credential a fresh runner with every Mac asleep: the phone gossips
/// a `CredentialManifest`, answers `credentialNeeded` for kinds it holds, and lands
/// `credentialUpdate` rotations with the exact same newest-wins comparators the Mac uses
/// (`ClaudeCredentialFormat` / `CodexCredentialFormat` in NucleicProtocol — shared, not
/// reimplemented). API keys stay deliberately out: the one-shot `submitAPIKey` push seals them
/// straight to a host and the phone never stores them.
///
/// At rest: one file, ChaChaPoly-sealed with a device-local 32-byte vault key. Where a Secure
/// Enclave exists, that vault key is wrapped by an SE-resident P-256 key
/// (`kSecAttrTokenIDSecureEnclave`) and only the wrapped blob touches the Keychain; without
/// one (simulator), the raw key lives in the Keychain (this-device-only, after-first-unlock).
///
/// HONESTY RULE (CLOUD_RUNTIME §5): the credential-sealing keypair is Curve25519, which CANNOT
/// live in the Secure Enclave (it holds P-256 only) — it is an ordinary Keychain item. What
/// the SE protects here is the at-rest wrap of the vault key. Never claim more.
///
/// Refresh leases: the phone may RECORD leases it learns but never ACQUIRES one — it
/// backgrounds unpredictably, and `CredentialRefreshLease.merged` deliberately prefers stable
/// holders (Macs/runners stay the refreshers).
///
/// An actor (not `@MainActor`): every Keychain and file touch runs off the main actor — the
/// Settings beach-ball lesson from AppStore applies to the phone too.
actor PhoneCredentialVault {
    static let shared = PhoneCredentialVault()

    /// The kinds the phone holds — the two rotating OAuth logins, matching
    /// `RunnerCredentialVault.mirrorableKinds`. Static keys are never stored on the phone.
    static let mirrorableKinds: [CredentialKind] = [.claudeOAuth, .codexAuth]

    // MARK: - Contents

    private struct StoredRecord: Codable {
        var payload: Data
        /// The credential's own freshness stamp (Claude `expiresAt`, Codex `last_refresh`) —
        /// the same clock every other mesh member merges on.
        var updatedAt: Date
    }

    private struct VaultContents: Codable {
        /// Keyed by `CredentialKind.rawValue`.
        var records: [String: StoredRecord] = [:]
        /// Recorded (never acquired) refresh leases — see the type doc. Empty today; kept in
        /// the file shape so recording them later needs no migration.
        var leases: [CredentialRefreshLease] = []
        /// Mesh-wide deletions this phone knows (`deletedAt`-monotonic, one per kind).
        var tombstones: [CredentialTombstone] = []
    }

    private var cachedContents: VaultContents?
    private var cachedVaultKey: SymmetricKey?

    // MARK: - Sealing keypair (Curve25519 — Keychain, NOT the Secure Enclave)

    private static let sealingKeyAccount = "xyz.blakeslee.nucleic.remote.credential-sealing"

    /// The public half other mesh members seal credentials to (rides in this phone's
    /// manifest). Empty only if the Keychain refuses us entirely.
    func sealingPublicKey() -> Data {
        guard let key = sealingPrivateKey() else { return Data() }
        return key.publicKey.rawRepresentation
    }

    private func sealingPrivateKey() -> Curve25519.KeyAgreement.PrivateKey? {
        if let data = Self.keychainRead(account: Self.sealingKeyAccount),
           let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data) {
            return key
        }
        let fresh = Curve25519.KeyAgreement.PrivateKey()
        guard Self.keychainWrite(fresh.rawRepresentation, account: Self.sealingKeyAccount)
        else { return nil }
        return fresh
    }

    // MARK: - Holder surface (manifest / provision / land / revoke)

    /// What this phone gossips after `welcome` on a host that ingests sealed credentials:
    /// descriptors for the kinds it holds (never the bytes), the tombstones it knows, and its
    /// sealing key so rotations can be mirrored here. Leases ride through unchanged — recorded
    /// only, never acquired (see the type doc).
    func manifest(deviceID: String) -> CredentialManifest {
        let contents = loadContents()
        var records: [CredentialRecordDescriptor] = []
        for (raw, record) in contents.records {
            let kind = CredentialKind(rawValue: raw)
            guard !suppressed(kind, updatedAt: record.updatedAt, in: contents) else { continue }
            records.append(CredentialRecordDescriptor(
                kind: kind, updatedAt: record.updatedAt, provenanceDeviceID: deviceID))
        }
        let key = sealingPublicKey()
        return CredentialManifest(
            records: records.sorted { $0.kind.rawValue < $1.kind.rawValue },
            leases: contents.leases,
            sealingPublicKey: key.isEmpty ? nil : key,
            tombstones: contents.tombstones)
    }

    /// Seal every *requested* kind this phone holds to the asker's key — the answer to
    /// `HostMsg.credentialNeeded`. Empty `kinds` solicits nothing (that contract is
    /// load-bearing: a cockpit Mac pushes `kinds: []` purely to advertise its sealing key for
    /// the one-shot API-key path, and no holder may volunteer anything for it).
    func sealedEnvelope(for need: WireCredentialNeed) -> SealedCredentialEnvelope? {
        guard !need.kinds.isEmpty else { return nil }
        let contents = loadContents()
        var records: [SealedCredentialRecord] = []
        for kind in need.kinds {
            guard let stored = contents.records[kind.rawValue],
                  !suppressed(kind, updatedAt: stored.updatedAt, in: contents) else { continue }
            let stub = SealedCredentialRecord(
                kind: kind, updatedAt: stored.updatedAt,
                box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data()))
            guard let box = try? SealedCredentialBox.seal(
                stored.payload, to: need.sealingPublicKey, additionalData: stub.additionalData)
            else { continue }
            records.append(SealedCredentialRecord(kind: kind, updatedAt: stored.updatedAt, box: box))
        }
        return records.isEmpty ? nil : SealedCredentialEnvelope(records: records)
    }

    /// Land a `credentialUpdate` (a host mirrored a rotation, sealed to this phone's key) —
    /// newest-wins by the credential's own clock via the SAME comparators the Mac hubs use.
    /// Returns the kinds actually written.
    func land(_ envelope: SealedCredentialEnvelope) -> [CredentialKind] {
        guard let key = sealingPrivateKey() else { return [] }
        var contents = loadContents()
        var landed: [CredentialKind] = []
        for record in envelope.records where Self.mirrorableKinds.contains(record.kind) {
            guard let plaintext = try? record.box.open(
                with: key, additionalData: record.additionalData),
                let json = String(data: plaintext, encoding: .utf8)
            else { continue }
            let current = contents.records[record.kind.rawValue]
                .flatMap { String(data: $0.payload, encoding: .utf8) }
            let stamp: Date
            switch record.kind {
            case .claudeOAuth:
                guard ClaudeCredentialFormat.shouldReplace(candidate: json, current: current)
                else { continue }
                stamp = ClaudeCredentialFormat.expiresAt(json)
                    .map { Date(timeIntervalSince1970: $0 / 1000) } ?? record.updatedAt
            case .codexAuth:
                guard CodexCredentialFormat.shouldReplace(candidate: json, current: current)
                else { continue }
                stamp = CodexCredentialFormat.lastRefresh(json)
                    .map(Date.init(timeIntervalSince1970:)) ?? record.updatedAt
            default:
                continue
            }
            // A revision at or before a recorded deletion stays dead — only a strictly newer
            // login resurrects the kind. Judged on the credential's OWN clock (`stamp`), never
            // the wire stamp: a mirror-back is stamped "now", which a Claude tombstone (bumped
            // past the deleted token's future expiry) would wrongly suppress.
            guard !suppressed(record.kind, updatedAt: stamp, in: contents) else { continue }
            contents.records[record.kind.rawValue] = StoredRecord(payload: plaintext, updatedAt: stamp)
            landed.append(record.kind)
        }
        if !landed.isEmpty { saveContents(contents) }
        return landed
    }

    /// Land mesh-wide deletions (`HostMsg.credentialRevoked` or the post-hello table push):
    /// merge each stone `deletedAt`-monotonic, drop the matching record, and absorb its
    /// freshness stamp so no stale holder can resurrect it. Returns the stones that carried
    /// new information (a replay returns empty — that's what terminates gossip loops).
    @discardableResult
    func applyTombstones(_ incoming: [CredentialTombstone]) -> [CredentialTombstone] {
        var contents = loadContents()
        var table = Dictionary(uniqueKeysWithValues: contents.tombstones.map { ($0.kind, $0) })
        var applied: [CredentialTombstone] = []
        for stone in incoming {
            let winner = CredentialTombstone.merged(table[stone.kind], stone)
            guard let winner, winner != table[stone.kind] else { continue }
            var effective = winner
            if let record = contents.records[stone.kind.rawValue] {
                effective = winner.absorbing(freshness: record.updatedAt)
                contents.records[stone.kind.rawValue] = nil
            }
            table[stone.kind] = effective
            applied.append(effective)
        }
        guard !applied.isEmpty else { return [] }
        contents.tombstones = table.values.sorted { $0.kind.rawValue < $1.kind.rawValue }
        saveContents(contents)
        return applied
    }

    /// The user deleted a credential from this phone (Agent Accounts): drop the local copy (if
    /// any), mint the tombstone — absorbing the copy's freshness stamp — and return it for the
    /// caller to send (`ClientMsg.credentialRevoke`) at every host that accepts the verb.
    func deleteCredential(_ kind: CredentialKind, deviceID: String) -> CredentialTombstone {
        var contents = loadContents()
        var stone = CredentialTombstone(kind: kind, deletedAt: Date(), originDeviceID: deviceID)
        if let record = contents.records[kind.rawValue] {
            stone = stone.absorbing(freshness: record.updatedAt)
            contents.records[kind.rawValue] = nil
        }
        var table = Dictionary(uniqueKeysWithValues: contents.tombstones.map { ($0.kind, $0) })
        table[kind] = CredentialTombstone.merged(table[kind], stone) ?? stone
        contents.tombstones = table.values.sorted { $0.kind.rawValue < $1.kind.rawValue }
        saveContents(contents)
        return table[kind] ?? stone
    }

    /// The kinds this phone currently holds (for the Settings surface).
    func heldKinds() -> [CredentialKind] {
        loadContents().records.keys.map(CredentialKind.init(rawValue:))
            .sorted { $0.rawValue < $1.rawValue }
    }

    private func suppressed(
        _ kind: CredentialKind, updatedAt: Date, in contents: VaultContents
    ) -> Bool {
        contents.tombstones.first { $0.kind == kind }?
            .suppresses(recordUpdatedAt: updatedAt) ?? false
    }

    // MARK: - At-rest encryption

    private static var vaultFileURL: URL {
        FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("Nucleic", isDirectory: true)
            .appendingPathComponent("credential-vault.sealed")
    }

    private func loadContents() -> VaultContents {
        if let cachedContents { return cachedContents }
        guard let key = vaultKey(),
              let sealed = try? Data(contentsOf: Self.vaultFileURL),
              let box = try? ChaChaPoly.SealedBox(combined: sealed),
              let plaintext = try? ChaChaPoly.open(box, using: key),
              let contents = try? JSONDecoder().decode(VaultContents.self, from: plaintext)
        else {
            let empty = VaultContents()
            cachedContents = empty
            return empty
        }
        cachedContents = contents
        return contents
    }

    private func saveContents(_ contents: VaultContents) {
        cachedContents = contents
        guard let key = vaultKey(),
              let plaintext = try? JSONEncoder().encode(contents),
              let sealed = try? ChaChaPoly.seal(plaintext, using: key)
        else { return }
        let url = Self.vaultFileURL
        try? FileManager.default.createDirectory(
            at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try? sealed.combined.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
    }

    // MARK: - Vault key (SE-wrapped where available)

    private static let wrappedKeyAccount = "xyz.blakeslee.nucleic.remote.vault-key.wrapped"
    private static let rawKeyAccount = "xyz.blakeslee.nucleic.remote.vault-key"
    private static let seKeyTag = Data("xyz.blakeslee.nucleic.remote.vault-wrap".utf8)

    private func vaultKey() -> SymmetricKey? {
        if let cachedVaultKey { return cachedVaultKey }
        let key = loadOrCreateVaultKey()
        cachedVaultKey = key
        return key
    }

    private func loadOrCreateVaultKey() -> SymmetricKey? {
        // Secure Enclave path: the vault key only ever exists in the clear in process memory;
        // the Keychain holds the SE-wrapped blob, and the wrap key never leaves the enclave.
        if SecureEnclave.isAvailable {
            if let wrapped = Self.keychainRead(account: Self.wrappedKeyAccount),
               let seKey = Self.loadSEKey(),
               let raw = Self.seDecrypt(wrapped, with: seKey) {
                return SymmetricKey(data: raw)
            }
            let fresh = SymmetricKey(size: .bits256)
            let rawFresh = fresh.withUnsafeBytes { Data($0) }
            if let seKey = Self.loadOrCreateSEKey(),
               let wrapped = Self.seEncrypt(rawFresh, with: seKey),
               Self.keychainWrite(wrapped, account: Self.wrappedKeyAccount) {
                return fresh
            }
            // SE claimed available but refused (rare) — fall through to the plain-Keychain key.
        }
        if let raw = Self.keychainRead(account: Self.rawKeyAccount) {
            return SymmetricKey(data: raw)
        }
        let fresh = SymmetricKey(size: .bits256)
        let raw = fresh.withUnsafeBytes { Data($0) }
        guard Self.keychainWrite(raw, account: Self.rawKeyAccount) else { return nil }
        return fresh
    }

    // MARK: SE wrap primitives (SecKey — P-256 in the enclave, ECIES for the wrap)

    private static func loadSEKey() -> SecKey? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassKey,
            kSecAttrApplicationTag as String: seKeyTag,
            kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
            kSecReturnRef as String: true,
        ]
        var item: CFTypeRef?
        guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
        return (item as! SecKey)
    }

    private static func loadOrCreateSEKey() -> SecKey? {
        if let existing = loadSEKey() { return existing }
        guard let access = SecAccessControlCreateWithFlags(
            nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, .privateKeyUsage, nil)
        else { return nil }
        let attributes: [String: Any] = [
            kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
            kSecAttrKeySizeInBits as String: 256,
            kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
            kSecPrivateKeyAttrs as String: [
                kSecAttrIsPermanent as String: true,
                kSecAttrApplicationTag as String: seKeyTag,
                kSecAttrAccessControl as String: access,
            ],
        ]
        return SecKeyCreateRandomKey(attributes as CFDictionary, nil)
    }

    private static let seAlgorithm = SecKeyAlgorithm.eciesEncryptionCofactorVariableIVX963SHA256AESGCM

    private static func seEncrypt(_ plaintext: Data, with privateKey: SecKey) -> Data? {
        guard let publicKey = SecKeyCopyPublicKey(privateKey),
              SecKeyIsAlgorithmSupported(publicKey, .encrypt, seAlgorithm)
        else { return nil }
        return SecKeyCreateEncryptedData(publicKey, seAlgorithm, plaintext as CFData, nil) as Data?
    }

    private static func seDecrypt(_ ciphertext: Data, with privateKey: SecKey) -> Data? {
        guard SecKeyIsAlgorithmSupported(privateKey, .decrypt, seAlgorithm) else { return nil }
        return SecKeyCreateDecryptedData(privateKey, seAlgorithm, ciphertext as CFData, nil) as Data?
    }

    // MARK: Keychain (generic-password items, this-device-only)

    private static func keychainRead(account: String) -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
        ]
        var item: CFTypeRef?
        guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
        return item as? Data
    }

    @discardableResult
    private static func keychainWrite(_ data: Data, account: String) -> Bool {
        let delete: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
        ]
        SecItemDelete(delete as CFDictionary)
        let add: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
            kSecValueData as String: data,
        ]
        return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
    }
}
