//
//  VPNProfile.swift
//  SwanDesk — shared model between the app and the privileged helper.
//
//  A VPNProfile holds every parameter the bash reference (ecs-fi-vpn/
//  start-ecsfi-vpn-clean + confs/swanctl.conf) exposed, generalised so the app
//  can manage several named connections. Secrets (xauth password, private key)
//  live in `VPNSecrets`, kept out of the persisted profile and passed to the
//  helper only at start() time.
//

import Foundation

/// How the client authenticates to the gateway. The XAuth flow is IKEv1-only
/// (there is no XAuth in IKEv2); the EAP flows are IKEv2-only. Certificate-only
/// auth works under either version.
enum VPNAuthMethod: String, Codable, CaseIterable, Identifiable {
    /// IKEv1: certificate (round 1) + XAuth username/password (round 2). The
    /// original SwanDesk behaviour.
    case xauth
    /// Pure public-key auth: client certificate + private key, no username.
    case certificate
    /// IKEv2 EAP-MSCHAPv2: username + password, gateway authenticated by cert.
    case eapMSCHAPv2
    /// IKEv2 EAP-TLS: mutual certificate auth carried over EAP.
    case eapTLS
    /// Pre-shared key for the client, gateway still authenticated by certificate
    /// (asymmetric auth — IKEv2 authenticates each direction independently).
    case psk
    /// Pre-shared key on both sides; no certificates involved at all.
    case pskMutual

    var id: String { rawValue }

    /// Human label for pickers.
    var label: String {
        switch self {
        case .xauth:        return "Certificate + XAuth"
        case .certificate:  return "Certificate only"
        case .eapMSCHAPv2:  return "EAP-MSCHAPv2 (username/password)"
        case .eapTLS:       return "EAP-TLS (certificate)"
        case .psk:          return "Pre-shared key + gateway certificate"
        case .pskMutual:    return "Pre-shared key (mutual)"
        }
    }

    /// IKE version this method requires. XAuth is IKEv1-only; EAP is IKEv2-only;
    /// certificate-only and PSK work under either (kept at the profile's version).
    var requiredIKEVersion: Int? {
        switch self {
        case .xauth:                  return 1
        case .eapMSCHAPv2, .eapTLS:   return 2
        case .certificate, .psk, .pskMutual: return nil
        }
    }

    /// Methods offered for a given IKE version.
    static func methods(forVersion v: Int) -> [VPNAuthMethod] {
        v == 1 ? [.xauth, .certificate, .psk, .pskMutual]
               : [.certificate, .eapMSCHAPv2, .eapTLS, .psk, .pskMutual]
    }

    /// True when the method presents a client certificate + private key.
    var usesClientCert: Bool {
        switch self {
        case .xauth, .certificate, .eapTLS:        return true
        case .eapMSCHAPv2, .psk, .pskMutual:       return false
        }
    }

    /// True when the method needs a username + password (XAuth or EAP-MSCHAPv2).
    var usesPassword: Bool {
        self == .xauth || self == .eapMSCHAPv2
    }

    /// True when the method authenticates the client with a pre-shared key.
    var usesPSK: Bool {
        self == .psk || self == .pskMutual
    }

    /// True when we verify the *gateway* with a certificate (so a CA cert is
    /// needed). Only mutual PSK skips it — there both sides use the shared key.
    var verifiesGatewayWithCert: Bool {
        self != .pskMutual
    }

    /// Hint shown beside the Local ID field, which means different things per
    /// method (cert subject vs. the identity the gateway looks the PSK up by).
    var localIDHint: String {
        usesPSK ? "Local ID (identifies you to the gateway)"
                : "Local ID (blank = from certificate)"
    }

    /// Label for the runtime password prompt shown when no password is stored.
    var passwordLabel: String {
        if usesPSK    { return "Pre-shared Key" }
        if self == .eapMSCHAPv2 { return "EAP Password" }
        return "XAuth Password"
    }
}

/// All non-secret fields of one VPN connection. Codable so the app can persist a
/// `[VPNProfile]` to UserDefaults and ship one over XPC to the helper.
///
/// New fields are decoded leniently (`decodeIfPresent ?? default`) so a profile
/// persisted by an older build — which lacks the newer keys — still loads instead
/// of failing the whole `[VPNProfile]` decode and wiping the user's connections.
struct VPNProfile: Codable, Identifiable, Equatable {
    var id: UUID = UUID()
    var name: String = "New VPN"

    // MARK: Identity (the secret password + private key live in VPNSecrets)
    /// How the client authenticates. Defaults to `.xauth` so profiles saved by
    /// pre-IKEv2 builds keep their original IKEv1/XAuth behaviour.
    var authMethod: VPNAuthMethod = .xauth
    /// Username for XAuth / EAP-MSCHAPv2 (the script's XAUTHID). Unused by the
    /// certificate-only and EAP-TLS methods.
    var xauthID: String = ""
    /// Local IKE identity. Blank → derived from the client certificate's subject
    /// (certificate / EAP-TLS) or taken from `xauthID` (XAuth / EAP-MSCHAPv2).
    var localID: String = ""
    /// PEM of the client certificate (public — fine to persist).
    var clientCertPEM: String = ""
    /// PEM of the CA certificate used to verify the gateway.
    var caCertPEM: String = ""

    // MARK: IKE / connection
    /// Gateway address (swanctl `remote_addrs`).
    var remoteAddr: String = ""
    /// Pinned remote identity DN (swanctl remote `id`). Empty → %any.
    var remoteID: String = ""
    var ikeProposals: String = "aes256-sha512-ecp384"
    var ikeVersion: Int = 1
    var fragmentation: Bool = true
    var encap: Bool = true
    var rekeyTimeIKE: String = "86400s"

    // MARK: Child SA
    var espProposals: String = "aes256-sha512-ecp384"
    var remoteTS: String = "0.0.0.0/0"
    var rekeyTimeChild: String = "6500s"
    var lifeTimeChild: String = "7200s"
    /// Request a virtual IP (swanctl `vips = %any`).
    var requestVIP: Bool = true

    // MARK: Post-up networking (the script's ADDROUTES / DNS / search)
    /// Manual routes to install over the tunnel, as CIDR strings. Empty → use the
    /// routes negotiated by the SA.
    var routes: [String] = []
    /// DNS servers to set on the active network service while connected.
    var dnsServers: [String] = []
    /// Default search domains to set while connected.
    var searchDomains: [String] = []

    // MARK: dnsmasq local proxy
    /// When true, start dnsmasq bound to 127.0.0.1:53 on connect and point the OS
    /// resolver at it instead of directly at dnsServers. Enables split-DNS and
    /// per-domain forwarding rules via dnsmasqConfig.
    var dnsmasqEnabled: Bool = false
    /// Extra dnsmasq config lines appended after the auto-generated preamble (listen
    /// address, port, upstream servers). Use for per-domain forwarding, e.g.:
    ///   server=/corp.example.com/10.0.0.1
    ///   address=/dev.internal/192.168.1.50
    var dnsmasqConfig: String = ""

    // MARK: Security
    /// When true and the tunnel routes all IPv4 traffic (0.0.0.0/0) without
    /// offering an IPv6 VIP or IPv6 routes, disable IPv6 on all active network
    /// services while connected. Prevents IPv6 from leaking outside the tunnel.
    var preventIPv6Leak: Bool = false
    /// Kill switch: while this profile is connected, a pf firewall ruleset blocks
    /// all outbound traffic except loopback, the tunnel itself, and IKE/ESP to the
    /// VPN gateway — so if the tunnel drops unexpectedly, nothing leaks outside it.
    /// Removed only on a user-initiated disconnect.
    var killSwitchEnabled: Bool = false

    // MARK: Multi-tunnel behaviour
    /// Priority of this profile among simultaneously connected tunnels — the
    /// profile's position in the configuration list (0 = top = highest). Set by
    /// the app at connect time; when two active tunnels claim the same route (or
    /// both want DNS/dnsmasq), the higher-priority one wins.
    var priority: Int = 0
    /// Connect this profile automatically when SwanDesk launches.
    var connectOnLaunch: Bool = false

    // MARK: Keepalive
    /// Ping target; empty disables the keepalive/auto-reconnect loop.
    var keepaliveIP: String = ""
    var keepaliveInterval: Int = 30

    init() {}

    /// Lenient decoder: every field falls back to its default when the key is
    /// absent, so adding fields never invalidates profiles persisted by an older
    /// build (synthesized decoding would throw `.keyNotFound` and lose them all).
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        func v<T: Decodable>(_ key: CodingKeys, _ fallback: T) throws -> T {
            try c.decodeIfPresent(T.self, forKey: key) ?? fallback
        }
        let blank = VPNProfile()
        id              = try v(.id, blank.id)
        name            = try v(.name, blank.name)
        authMethod      = try v(.authMethod, blank.authMethod)
        xauthID         = try v(.xauthID, blank.xauthID)
        localID         = try v(.localID, blank.localID)
        clientCertPEM   = try v(.clientCertPEM, blank.clientCertPEM)
        caCertPEM       = try v(.caCertPEM, blank.caCertPEM)
        remoteAddr      = try v(.remoteAddr, blank.remoteAddr)
        remoteID        = try v(.remoteID, blank.remoteID)
        ikeProposals    = try v(.ikeProposals, blank.ikeProposals)
        ikeVersion      = try v(.ikeVersion, blank.ikeVersion)
        fragmentation   = try v(.fragmentation, blank.fragmentation)
        encap           = try v(.encap, blank.encap)
        rekeyTimeIKE    = try v(.rekeyTimeIKE, blank.rekeyTimeIKE)
        espProposals    = try v(.espProposals, blank.espProposals)
        remoteTS        = try v(.remoteTS, blank.remoteTS)
        rekeyTimeChild  = try v(.rekeyTimeChild, blank.rekeyTimeChild)
        lifeTimeChild   = try v(.lifeTimeChild, blank.lifeTimeChild)
        requestVIP      = try v(.requestVIP, blank.requestVIP)
        routes          = try v(.routes, blank.routes)
        dnsServers      = try v(.dnsServers, blank.dnsServers)
        searchDomains   = try v(.searchDomains, blank.searchDomains)
        dnsmasqEnabled  = try v(.dnsmasqEnabled, blank.dnsmasqEnabled)
        dnsmasqConfig   = try v(.dnsmasqConfig, blank.dnsmasqConfig)
        preventIPv6Leak = try v(.preventIPv6Leak, blank.preventIPv6Leak)
        killSwitchEnabled = try v(.killSwitchEnabled, blank.killSwitchEnabled)
        priority        = try v(.priority, blank.priority)
        connectOnLaunch = try v(.connectOnLaunch, blank.connectOnLaunch)
        keepaliveIP     = try v(.keepaliveIP, blank.keepaliveIP)
        keepaliveInterval = try v(.keepaliveInterval, blank.keepaliveInterval)
    }
}

/// Secret material for a profile, stored in the Keychain by the app and handed to
/// the helper transiently at start() time. Never persisted in `VPNProfile`.
struct VPNSecrets: Codable, Equatable {
    var xauthPassword: String = ""
    /// PEM of the client private key.
    var privateKeyPEM: String = ""
}

/// Connection state pushed from helper → app and rendered as the menu-bar colour.
enum VPNConnState: String, Codable {
    case disconnected
    case connecting
    case connected
    case reconnecting
    case error
}

/// Per-tunnel runtime snapshot. One of these exists for every profile the helper
/// is currently driving (several tunnels can be up at once).
struct VPNTunnelStatus: Codable, Equatable, Identifiable {
    /// String form of `VPNProfile.id`.
    var profileID: String = ""
    var profileName: String = ""
    var state: VPNConnState = .disconnected
    /// Virtual IP assigned by the gateway, once connected.
    var assignedVIP: String? = nil
    /// utun device the tunnel landed on.
    var tunDevice: String? = nil
    /// Human-readable detail (last error, or "established").
    var message: String? = nil
    /// Priority the tunnel was started with (0 = highest).
    var priority: Int = 0
    /// True while the pf kill-switch ruleset for this tunnel is loaded.
    var killSwitchActive: Bool = false

    var id: String { profileID }
}

/// Runtime snapshot the helper reports via `status()` and `statusChanged(_:)`:
/// every active tunnel, ordered by priority (highest first).
struct VPNRuntimeStatus: Codable, Equatable {
    var tunnels: [VPNTunnelStatus] = []

    /// Lenient decode so a status frame from a mismatched helper build (which
    /// used a single-tunnel shape) degrades to "no tunnels" instead of throwing.
    init() {}
    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        tunnels = (try? c.decodeIfPresent([VPNTunnelStatus].self, forKey: .tunnels)) ?? []
    }

    func tunnel(for profileID: String) -> VPNTunnelStatus? {
        tunnels.first { $0.profileID == profileID }
    }

    /// Menu-bar aggregate across all tunnels: any failure/transition outranks a
    /// steady state, so the icon flags trouble even while other tunnels are fine.
    var aggregateState: VPNConnState {
        if tunnels.contains(where: { $0.state == .error }) { return .error }
        if tunnels.contains(where: { $0.state == .reconnecting }) { return .reconnecting }
        if tunnels.contains(where: { $0.state == .connecting }) { return .connecting }
        if tunnels.contains(where: { $0.state == .connected }) { return .connected }
        return .disconnected
    }

    var connectedCount: Int { tunnels.filter { $0.state == .connected }.count }
}
