//
//  ProfilePortability.swift
//  SwanDesk — import / export of VPN profiles as portable `.swandesk` files.
//
//  A `.swandesk` file is a small JSON document holding the full (non-secret)
//  `VPNProfile` plus the profile's secrets (XAuth/EAP password + client private
//  key) sealed with a user-supplied passphrase:
//
//      passphrase ──PBKDF2-HMAC-SHA256(200k, 16-byte salt)──► 256-bit key
//      VPNSecrets (JSON) ──AES-256-GCM(key)──► sealed box (nonce|ct|tag)
//
//  So the file is portable between machines but useless without the passphrase —
//  the private key never touches disk in the clear. Decryption fails closed: a
//  wrong passphrase surfaces as a clear "incorrect passphrase" error (GCM auth
//  failure), never as a silently empty credential.
//

import Foundation
import CryptoKit
import CommonCrypto

/// On-disk `.swandesk` document. The profile travels in the clear (it holds no
/// secrets); the secrets travel inside `encryption`.
struct ProfileExportDocument: Codable, Identifiable {
    static let formatID = "fi.juvex.swandesk.profile"
    static let currentVersion = 1

    /// Identity for SwiftUI's `.sheet(item:)` — the sealed blob is unique per
    /// exported file, so it distinguishes one picked document from another.
    var id: String { encryption.sealed }

    var format: String = ProfileExportDocument.formatID
    var version: Int = ProfileExportDocument.currentVersion
    /// The profile's display name, duplicated out of the payload for a friendly
    /// import preview without having to decrypt anything.
    var name: String
    var profile: VPNProfile
    var encryption: SealedSecrets
}

/// The encrypted `VPNSecrets`, plus everything needed to re-derive the key.
struct SealedSecrets: Codable {
    var kdf: String = "PBKDF2-HMAC-SHA256"
    var rounds: Int
    /// base64 PBKDF2 salt.
    var salt: String
    var cipher: String = "AES-256-GCM"
    /// base64 of `AES.GCM.SealedBox.combined` (nonce ‖ ciphertext ‖ tag).
    var sealed: String
}

enum ProfilePortabilityError: LocalizedError {
    case notASwanDeskFile
    case unsupportedVersion(Int)
    case malformed
    case incorrectPassphrase
    case keyDerivationFailed
    case randomFailed

    var errorDescription: String? {
        switch self {
        case .notASwanDeskFile:      return "This isn’t a SwanDesk profile file."
        case .unsupportedVersion(let v): return "This profile file uses a newer format (version \(v)) than this app understands."
        case .malformed:             return "The profile file is damaged or incomplete."
        case .incorrectPassphrase:   return "Incorrect passphrase — the secrets couldn’t be decrypted."
        case .keyDerivationFailed:   return "Could not derive the encryption key from the passphrase."
        case .randomFailed:          return "Could not generate secure random data for encryption."
        }
    }
}

enum ProfilePortability {
    /// PBKDF2 work factor. High enough to be costly to brute-force a weak
    /// passphrase, low enough to be instant on a Mac.
    private static let pbkdf2Rounds = 200_000

    // MARK: Export

    /// Build a `.swandesk` document, encrypting `secrets` under `passphrase`.
    static func makeExportData(profile: VPNProfile, secrets: VPNSecrets,
                               passphrase: String) throws -> Data {
        let salt = try randomData(count: 16)
        let key = try deriveKey(passphrase: passphrase, salt: salt, rounds: pbkdf2Rounds)
        let plaintext = try JSONEncoder().encode(secrets)
        let box = try AES.GCM.seal(plaintext, using: key)
        guard let combined = box.combined else { throw ProfilePortabilityError.malformed }

        let doc = ProfileExportDocument(
            name: profile.name,
            profile: profile,
            encryption: SealedSecrets(rounds: pbkdf2Rounds,
                                      salt: salt.base64EncodedString(),
                                      sealed: combined.base64EncodedString()))
        let enc = JSONEncoder()
        enc.outputFormatting = [.prettyPrinted, .sortedKeys]
        return try enc.encode(doc)
    }

    // MARK: Import

    /// Parse + validate the envelope without needing the passphrase, so the UI can
    /// show the profile name and reject non-SwanDesk files before prompting.
    static func readDocument(_ data: Data) throws -> ProfileExportDocument {
        guard let doc = try? JSONDecoder().decode(ProfileExportDocument.self, from: data) else {
            throw ProfilePortabilityError.notASwanDeskFile
        }
        guard doc.format == ProfileExportDocument.formatID else {
            throw ProfilePortabilityError.notASwanDeskFile
        }
        guard doc.version <= ProfileExportDocument.currentVersion else {
            throw ProfilePortabilityError.unsupportedVersion(doc.version)
        }
        return doc
    }

    /// Decrypt a document's secrets and return a ready-to-store (profile, secrets)
    /// pair. The profile is given a fresh id so importing never overwrites or
    /// collides with an existing one on this machine.
    static func decode(_ document: ProfileExportDocument,
                       passphrase: String) throws -> (profile: VPNProfile, secrets: VPNSecrets) {
        guard let salt = Data(base64Encoded: document.encryption.salt),
              let combined = Data(base64Encoded: document.encryption.sealed) else {
            throw ProfilePortabilityError.malformed
        }
        let key = try deriveKey(passphrase: passphrase, salt: salt,
                                rounds: document.encryption.rounds)
        let plaintext: Data
        do {
            let box = try AES.GCM.SealedBox(combined: combined)
            plaintext = try AES.GCM.open(box, using: key)
        } catch {
            // GCM tag mismatch ⇒ wrong key ⇒ wrong passphrase (the common case).
            throw ProfilePortabilityError.incorrectPassphrase
        }
        guard let secrets = try? JSONDecoder().decode(VPNSecrets.self, from: plaintext) else {
            throw ProfilePortabilityError.malformed
        }
        var profile = document.profile
        profile.id = UUID()
        return (profile, secrets)
    }

    // MARK: Crypto primitives

    /// PBKDF2-HMAC-SHA256 → 32-byte AES key. CryptoKit has no passphrase KDF
    /// (HKDF assumes a high-entropy input), so we use CommonCrypto's PBKDF2.
    private static func deriveKey(passphrase: String, salt: Data, rounds: Int) throws -> SymmetricKey {
        let pw = Array(passphrase.utf8)
        var derived = [UInt8](repeating: 0, count: 32)
        let status = salt.withUnsafeBytes { saltBuf -> Int32 in
            CCKeyDerivationPBKDF(
                CCPBKDFAlgorithm(kCCPBKDF2),
                // An empty passphrase yields a nil utf8 baseAddress; pass a valid
                // (zero-length) pointer instead of nil so PBKDF2 still runs.
                pw, pw.count,
                saltBuf.bindMemory(to: UInt8.self).baseAddress, salt.count,
                CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
                UInt32(rounds),
                &derived, derived.count)
        }
        guard status == kCCSuccess else { throw ProfilePortabilityError.keyDerivationFailed }
        return SymmetricKey(data: Data(derived))
    }

    private static func randomData(count: Int) throws -> Data {
        var bytes = [UInt8](repeating: 0, count: count)
        guard SecRandomCopyBytes(kSecRandomDefault, count, &bytes) == errSecSuccess else {
            throw ProfilePortabilityError.randomFailed
        }
        return Data(bytes)
    }
}
