//
//  VPNEngine.swift
//  SwanDeskHelper — the privileged VPN lifecycle (runs as root).
//
//  A Swift port of ecs-fi-vpn/start-ecsfi-vpn-clean. Renders a swanctl config into
//  a private, root-owned staging dir (SWANCTL_DIR), launches strongSwan's charon
//  with a STRONGSWAN_CONF that includes the system config but forces
//  install_routes=no, loads the config with swanctl, initiates the tunnel, parses
//  charon's output for the assigned virtual IP + utun device, installs the manual
//  routes, rewrites DNS/search domains on the active network service (saving the
//  prior values), and runs a ping keepalive. Live connect/disconnect transitions
//  are driven by libvici ike-updown / child-updown events (see Vici.swift).
//
//  Secrets live only here, in the 0700 staging dir, and are wiped on stop.
//

import Foundation
import IOKit
import IOKit.pwr_mgt

final class VPNEngine {

    /// Reachable from the SIGTERM handler (a C function pointer can't capture).
    static weak var sharedForSignal: VPNEngine?

    var onStatus: ((Data) -> Void)?
    var onLog: ((String) -> Void)?

    private let queue = DispatchQueue(label: "fi.juvex.swandesk.engine")
    private let stateLock = NSLock()
    private var status = VPNRuntimeStatus()

    // Live connection state.
    private var charon: Process?
    private var charonOutput: FileHandle?   // charon's stdout/err; handler cleared on teardown
    private var vici: ViciConnection?
    private var current: VPNProfile?
    private var installedRoutes: [String] = []
    private var tunDevice: String?
    private var savedDNSService: String?
    private var savedDNSServers: [String] = []
    private var savedSearchDomains: [String] = []
    private var keepaliveTimer: DispatchSourceTimer?
    private var keepaliveFailures = 0

    // Sleep/wake recovery (IOKit system power notifications).
    private var pmRootPort: io_connect_t = 0
    private var pmNotifierPort: IONotificationPortRef?
    private var pmNotifier: io_object_t = 0

    // Stable IKE/child connection names (single active tunnel).
    private let connName = "swandesk"
    private let childName = "swandesk"

    // MARK: Paths / bundled strongSwan toolchain

    private let stagingRoot = "/Library/Application Support/SwanDesk/run"
    private var swanctlDir: String { "\(stagingRoot)/swanctl" }
    private var strongswanConf: String { "\(stagingRoot)/strongswan.conf" }
    /// Our own deterministic VICI socket (set in the generated strongswan.conf),
    /// so we never depend on charon's compiled-in piddir.
    private var viciSocketPath: String { "\(stagingRoot)/charon.vici" }
    private var viciURI: String { "unix://\(viciSocketPath)" }

    /// A path inside the app bundle's relocated, self-contained strongSwan tree at
    /// App.app/Contents/Resources/strongswan/ (the helper runs from
    /// App.app/Contents/Library/LaunchServices/).
    private func bundledStrongswan(_ rel: String) -> String {
        let exe = Bundle.main.executablePath ?? CommandLine.arguments[0]
        let contents = (exe as NSString).deletingLastPathComponent
            .replacingOccurrences(of: "/Library/LaunchServices", with: "")
        return "\(contents)/Resources/strongswan/\(rel)"
    }

    private func locateSwanctl() -> String? {
        let p = bundledStrongswan("sbin/swanctl")
        return FileManager.default.isExecutableFile(atPath: p) ? p : nil
    }

    private func locateCharon() -> String? {
        let p = bundledStrongswan("libexec/ipsec/charon")
        return FileManager.default.isExecutableFile(atPath: p) ? p : nil
    }

    /// Directory holding the bundled strongswan.conf + strongswan.d.
    private func strongswanEtcDir() -> String {
        bundledStrongswan("etc")
    }

    private func swanctlEnv() -> [String: String] {
        ["STRONGSWAN_CONF": strongswanConf, "SWANCTL_DIR": swanctlDir]
    }

    /// Run swanctl against our charon over the deterministic VICI socket.
    @discardableResult
    private func swanctl(_ bin: String, _ args: [String], timeout: TimeInterval = 30) -> (status: Int32, output: String) {
        runCommand(bin, args + ["--uri", viciURI], env: swanctlEnv(), timeout: timeout)
    }

    // MARK: Status

    func encodedStatus() -> Data {
        stateLock.lock(); defer { stateLock.unlock() }
        return (try? JSONEncoder().encode(status)) ?? Data()
    }

    private func update(_ mutate: (inout VPNRuntimeStatus) -> Void) {
        stateLock.lock()
        mutate(&status)
        let snapshot = status
        stateLock.unlock()
        if let data = try? JSONEncoder().encode(snapshot) { onStatus?(data) }
    }

    func log(_ line: String) {
        NSLog("SwanDeskHelper: \(line)")
        onLog?(line)
    }

    // MARK: Lifecycle

    func start(profile: VPNProfile, secrets: VPNSecrets, reply: @escaping (Int, String) -> Void) {
        queue.async {
            do {
                try self.doStart(profile: profile, secrets: secrets)
                reply(0, self.connectedMessage())
            } catch {
                self.log("start failed: \(error.localizedDescription)")
                self.update { $0.state = .error; $0.message = error.localizedDescription }
                self.teardown(restoreNetworking: true)
                reply(1, error.localizedDescription)
            }
        }
    }

    func stop(reply: @escaping (Int, String) -> Void) {
        queue.async {
            self.teardown(restoreNetworking: true)
            self.update { $0.state = .disconnected; $0.assignedVIP = nil; $0.tunDevice = nil; $0.message = "Disconnected" }
            reply(0, "Disconnected")
        }
    }

    /// Synchronous teardown for the SIGTERM handler.
    func stopSync() {
        teardown(restoreNetworking: true)
    }

    private func connectedMessage() -> String {
        stateLock.lock(); defer { stateLock.unlock() }
        if let vip = status.assignedVIP { return "Connected (\(vip))" }
        return "Connected"
    }

    // MARK: Start steps (a faithful port of the bash flow)

    private func doStart(profile: VPNProfile, secrets: VPNSecrets) throws {
        // If a tunnel is already up (e.g. switching profiles, or a retried start
        // after a lost reply), fully tear it down first — restoring the user's
        // original DNS/routes — so we never re-save the VPN's own DNS as the
        // "original" or leak the prior charon/routes. Makes start idempotent.
        if current != nil { teardown(restoreNetworking: true) }
        current = profile
        update {
            $0.state = .connecting
            $0.profileID = profile.id.uuidString
            $0.profileName = profile.name
            $0.assignedVIP = nil
            $0.tunDevice = nil
            $0.message = "Connecting…"
        }

        guard let charonBin = locateCharon() else {
            throw EngineError("bundled charon not found in the app — rebuild the app (the strongSwan tree is missing from Resources)")
        }
        guard let swanctlBin = locateSwanctl() else {
            throw EngineError("bundled swanctl not found in the app — rebuild the app (the strongSwan tree is missing from Resources)")
        }

        try stageConfig(profile: profile, secrets: secrets)

        // Stop any stray charon, then launch ours.
        _ = runCommand("/usr/bin/killall", ["charon"])
        try launchCharon(charonBin)
        try waitForViciSocket()

        // Load credentials + connection, then initiate.
        let load = swanctl(swanctlBin, ["--load-all"])
        log(load.output)
        if load.status != 0 { throw EngineError("swanctl --load-all failed (\(load.status))") }

        try initiate(swanctlBin: swanctlBin)

        // Manual routes over the tunnel.
        installRoutes(profile)

        // DNS + search domains on the active service (saving prior values).
        applyDNS(profile)

        // Event-driven status + keepalive.
        connectVici()
        startKeepalive(profile)

        update { $0.state = .connected; $0.message = "Connected" }
    }

    private func initiate(swanctlBin: String) throws {
        // Clear any VIP/utun from a prior connection BEFORE initiating, so the
        // success check below reflects only this attempt — otherwise a stale VIP
        // (on reconnect/wake) masks a failed initiate and we'd falsely report
        // Connected and install routes on a dead utun.
        resetTunnelInfo()
        let result = swanctl(swanctlBin, ["--initiate", "--child", childName], timeout: 20)
        log(result.output)
        parseTunnelInfo(from: result.output)
        // After resetTunnelInfo(), vipOrTun() reflects only THIS attempt; a real
        // failure shows up as no VIP/utun AND a non-zero swanctl exit.
        if vipOrTun() == nil && result.status != 0 {
            throw EngineError("Tunnel did not establish (check credentials / gateway). swanctl exit \(result.status)")
        }
    }

    /// Clear the assigned VIP + utun (status and the private ivar). Must run on the
    /// engine queue.
    private func resetTunnelInfo() {
        tunDevice = nil
        update { $0.assignedVIP = nil; $0.tunDevice = nil }
    }

    private func vipOrTun() -> String? {
        stateLock.lock(); defer { stateLock.unlock() }
        return status.assignedVIP ?? status.tunDevice
    }

    /// Parse charon/swanctl output for the assigned VIP and utun device.
    private func parseTunnelInfo(from text: String) {
        for line in text.split(separator: "\n") {
            let s = String(line)
            if s.contains("installing new virtual IP"), let ip = s.split(separator: " ").last {
                update { $0.assignedVIP = String(ip) }
            }
            if s.contains("created TUN device"), let dev = s.split(separator: " ").last {
                tunDevice = String(dev)
                update { $0.tunDevice = String(dev) }
            }
        }
    }

    // MARK: Config staging

    private func stageConfig(profile: VPNProfile, secrets: VPNSecrets) throws {
        let fm = FileManager.default
        // Recreate a clean 0700 staging tree owned by root.
        try? fm.removeItem(atPath: stagingRoot)
        // Create the full set of standard swanctl credential subdirectories so
        // `swanctl --load-all` doesn't log "opening directory … failed" warnings
        // for the ones it scans but we don't populate.
        let swanctlSubdirs = ["x509", "x509ca", "x509aa", "x509ac", "x509crl", "x509ocsp",
                              "private", "rsa", "ecdsa", "pkcs8", "pkcs12", "pubkey", "conf.d"]
        let dirs = [stagingRoot, swanctlDir] + swanctlSubdirs.map { "\(swanctlDir)/\($0)" }
        for d in dirs {
            try fm.createDirectory(atPath: d, withIntermediateDirectories: true,
                                   attributes: [.posixPermissions: 0o700])
        }

        // The CA cert verifies the gateway — needed for every method except mutual
        // PSK (where both sides authenticate with the shared key, no certs). The
        // client cert + key are written only for methods that present one (XAuth,
        // certificate, EAP-TLS); EAP-MSCHAPv2 and PSK have none, so writing empty
        // files would just make swanctl warn.
        if profile.authMethod.verifiesGatewayWithCert {
            try write(profile.caCertPEM, to: "\(swanctlDir)/x509ca/ca.pem", perms: 0o600)
        }
        if profile.authMethod.usesClientCert {
            try write(profile.clientCertPEM, to: "\(swanctlDir)/x509/client.crt", perms: 0o600)
            try write(secrets.privateKeyPEM, to: "\(swanctlDir)/private/client.key", perms: 0o600)
        }

        // swanctl.conf and a strongswan.conf that includes the system config but
        // disables charon's own route installation (we add routes ourselves).
        try write(renderSwanctlConf(profile: profile, secrets: secrets),
                  to: "\(swanctlDir)/swanctl.conf", perms: 0o600)
        try write(renderStrongswanConf(), to: strongswanConf, perms: 0o644)
    }

    private func renderStrongswanConf() -> String {
        // Include the bundled (or system) strongswan.conf for plugin load config,
        // then force route installation off (we add routes ourselves) and pin the
        // VICI socket to our staging dir so we never rely on a compiled piddir.
        """
        include \(strongswanEtcDir())/strongswan.conf
        charon {
            install_routes = no
            plugins {
                vici {
                    socket = \(viciURI)
                }
                # SwanDesk manages DNS itself via networksetup. Disable charon's own
                # DNS handlers: the resolve plugin writes a resolv.conf at the
                # build-time path (wrong on a deployed machine) and osx-attr sets DNS
                # via SystemConfiguration — both redundant and conflicting here.
                resolve {
                    load = no
                }
                osx-attr {
                    load = no
                }
            }
        }
        """
    }

    private func renderSwanctlConf(profile: VPNProfile, secrets: VPNSecrets) -> String {
        // The IKE version XAuth (v1) and EAP (v2) imply overrides the stored
        // value; certificate-only honours the profile's chosen version.
        let version = profile.authMethod.requiredIKEVersion ?? profile.ikeVersion
        let vips = profile.requestVIP ? "                vips = %any\n" : ""
        // `send_cert = always` only makes sense when we actually present a cert;
        // for EAP-MSCHAPv2 / PSK (no client cert) let strongSwan negotiate it.
        let sendCert = profile.authMethod.usesClientCert ? "            send_cert = always\n" : ""

        return """
        connections {
            \(connName) {
                remote_addrs = \(profile.remoteAddr)
                fragmentation = \(profile.fragmentation ? "yes" : "no")
                encap = \(profile.encap ? "yes" : "no")
                aggressive = no
                keyingtries = 1
                local_addrs = %any
        \(vips)        version = \(version)
                proposals = \(profile.ikeProposals)
                rekey_time = \(profile.rekeyTimeIKE)
        \(sendCert)
        \(renderLocalAuth(profile))
        \(renderRemoteAuth(profile))
                children {
                    \(childName) {
                        mode = tunnel
                        start_action = none
                        remote_ts = \(profile.remoteTS)
                        esp_proposals = \(profile.espProposals)
                        rekey_time = \(profile.rekeyTimeChild)
                        life_time = \(profile.lifeTimeChild)
                    }
                }
            }
        }

        secrets {
        \(renderSecrets(profile, secrets))
        }
        """
    }

    /// The `local*` auth round(s) for the chosen method. XAuth needs two rounds
    /// (pubkey then xauth); the others are a single local block.
    private func renderLocalAuth(_ profile: VPNProfile) -> String {
        let localID = profile.localID.trimmingCharacters(in: .whitespaces)
        let idLine = localID.isEmpty ? "" : "\n                    id = \"\(localID)\""
        switch profile.authMethod {
        case .xauth:
            return """
                        local-pubkey {
                            round = 1
                            auth = pubkey
                            certs = client.crt
                        }
                        local-xauth {
                            round = 2
                            auth = xauth
                            xauth_id = \(profile.xauthID)
                        }
            """
        case .certificate:
            return """
                        local {
                            auth = pubkey
                            certs = client.crt\(idLine)
                        }
            """
        case .eapMSCHAPv2:
            // EAP identity defaults to the username; an explicit localID overrides.
            let eapID = localID.isEmpty ? profile.xauthID : localID
            return """
                        local {
                            auth = eap-mschapv2
                            eap_id = \(eapID)
                        }
            """
        case .eapTLS:
            return """
                        local {
                            auth = eap-tls
                            certs = client.crt\(idLine)
                        }
            """
        case .psk, .pskMutual:
            // PSK really wants a local id — it's how the gateway selects which
            // shared secret to apply; blank falls back to our IP (often wrong).
            return """
                        local {
                            auth = psk\(idLine)
                        }
            """
        }
    }

    /// The `remote {}` block: how we require the *gateway* to authenticate. Every
    /// method verifies it with a certificate except mutual PSK, where the gateway
    /// proves itself with the same shared key.
    private func renderRemoteAuth(_ profile: VPNProfile) -> String {
        let remoteID = profile.remoteID.isEmpty ? "%any" : "\"\(profile.remoteID)\""
        if profile.authMethod.verifiesGatewayWithCert {
            return """
                        remote {
                            auth = pubkey
                            cacerts = ca.pem
                            id = \(remoteID)
                        }
            """
        }
        return """
                    remote {
                        auth = psk
                        id = \(remoteID)
                    }
        """
    }

    /// The `secrets {}` body: the private-key entry for cert-bearing methods, and
    /// the shared-secret entry for the username/password methods.
    private func renderSecrets(_ profile: VPNProfile, _ secrets: VPNSecrets) -> String {
        var blocks: [String] = []
        if profile.authMethod.usesClientCert {
            blocks.append("""
                    private {
                        file = client.key
                    }
            """)
        }
        if profile.authMethod.usesPassword {
            // strongSwan reads both `xauth-…` and `eap-…` secrets the same way; use
            // the keyword matching the method so the gateway log stays legible.
            let kind = profile.authMethod == .xauth ? "xauth" : "eap"
            blocks.append("""
                    \(kind)-\(profile.xauthID) {
                        id = \(profile.xauthID)
                        secret = "\(secrets.xauthPassword)"
                    }
            """)
        }
        if profile.authMethod.usesPSK {
            // An `ike` secret matched by identity. For mutual PSK the same key must
            // also verify the peer, so list the gateway id too; for PSK+cert the
            // gateway uses its certificate, so only our own id matters.
            let localID = profile.localID.trimmingCharacters(in: .whitespaces)
            let remoteID = profile.remoteID.trimmingCharacters(in: .whitespaces)
            var lines = ["        ike-\(connName) {"]
            if !localID.isEmpty { lines.append("            id = \"\(localID)\"") }
            if profile.authMethod == .pskMutual && !remoteID.isEmpty {
                lines.append("            id-remote = \"\(remoteID)\"")
            }
            lines.append("            secret = \"\(secrets.xauthPassword)\"")
            lines.append("        }")
            blocks.append(lines.joined(separator: "\n"))
        }
        return blocks.joined(separator: "\n")
    }

    // MARK: charon process

    private func launchCharon(_ bin: String) throws {
        // The VICI socket lives in our staging dir (set in strongswan.conf); it is
        // created by stageConfig before this runs.
        try? FileManager.default.createDirectory(atPath: stagingRoot,
                                                 withIntermediateDirectories: true)
        let p = Process()
        p.executableURL = URL(fileURLWithPath: bin)
        var env = ProcessInfo.processInfo.environment
        env["STRONGSWAN_CONF"] = strongswanConf
        p.environment = env

        let pipe = Pipe()
        p.standardOutput = pipe
        p.standardError = pipe
        let outHandle = pipe.fileHandleForReading
        charonOutput = outHandle
        outHandle.readabilityHandler = { [weak self] handle in
            let data = handle.availableData
            guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
            for line in text.split(separator: "\n") {
                let s = String(line)
                self?.onLog?(s)
                // Serialize tunnel-info parsing onto the engine queue — `tunDevice`
                // is also touched by installRoutes/teardown there; writing it from
                // this background reader thread was a data race.
                self?.queue.async { self?.parseTunnelInfo(from: s) }
            }
        }
        try p.run()
        charon = p
        log("charon launched (pid \(p.processIdentifier))")
        Thread.sleep(forTimeInterval: 1.0)  // let it initialise (script sleeps 1s)
    }

    private func waitForViciSocket() throws {
        for _ in 0..<50 {
            if FileManager.default.fileExists(atPath: viciSocketPath) { return }
            Thread.sleep(forTimeInterval: 0.1)
        }
        throw EngineError("charon VICI socket did not appear at \(viciSocketPath)")
    }

    // MARK: VICI events (drive green/red)

    private func connectVici() {
        guard let v = ViciConnection(uri: "unix://\(viciSocketPath)") else {
            log("warning: could not connect libvici for events; status will rely on initiate result")
            return
        }
        v.onEvent = { [weak self] name, _ in
            self?.queue.async { self?.handleViciEvent(name) }
        }
        v.register(event: "ike-updown")
        v.register(event: "child-updown")
        vici = v
        log("subscribed to VICI ike-updown / child-updown events")
    }

    /// A VICI up/down event fired — refresh the connected/disconnected state from
    /// the live SA list (event-triggered, not periodic polling).
    private func handleViciEvent(_ name: String) {
        guard let swanctlBin = locateSwanctl() else { return }
        let sas = swanctl(swanctlBin, ["--list-sas"], timeout: 5).output
        let up = sas.contains("ESTABLISHED") && sas.contains("INSTALLED")
        if up {
            update { if $0.state != .connected { $0.state = .connected; $0.message = "Connected" } }
        } else if charon != nil {
            update { $0.state = .reconnecting; $0.message = "SA down — reconnecting…" }
        }
    }

    // MARK: Routes

    private func installRoutes(_ profile: VPNProfile) {
        guard let tun = tunDevice else {
            log("no utun device parsed — skipping manual routes")
            return
        }
        for cidr in profile.routes {
            let r = runCommand("/sbin/route", ["-n", "add", "-net", cidr, "-interface", tun])
            if r.status == 0 { installedRoutes.append(cidr) }
            log("route add \(cidr) via \(tun): \(r.status == 0 ? "ok" : r.output)")
        }
    }

    private func removeRoutes() {
        if let tun = tunDevice {
            for cidr in installedRoutes {
                _ = runCommand("/sbin/route", ["-n", "delete", "-net", cidr, "-interface", tun])
            }
        }
        // Always clear tracking, even if the utun already vanished — otherwise the
        // list accumulates duplicates across reconnects.
        installedRoutes.removeAll()
    }

    // MARK: DNS

    private func applyDNS(_ profile: VPNProfile) {
        guard !profile.dnsServers.isEmpty || !profile.searchDomains.isEmpty else { return }
        guard let service = firstActiveService() else {
            log("no active network service found — skipping DNS")
            return
        }
        savedDNSService = service
        savedDNSServers = currentNetworkList("-getdnsservers", service)
        savedSearchDomains = currentNetworkList("-getsearchdomains", service)

        if !profile.dnsServers.isEmpty {
            _ = runCommand("/usr/sbin/networksetup", ["-setdnsservers", service] + profile.dnsServers)
        }
        if !profile.searchDomains.isEmpty {
            _ = runCommand("/usr/sbin/networksetup", ["-setsearchdomains", service] + profile.searchDomains)
        }
        log("DNS set on '\(service)' (servers \(profile.dnsServers), search \(profile.searchDomains))")
    }

    private func restoreDNS() {
        guard let service = savedDNSService else { return }
        let servers = savedDNSServers.isEmpty ? ["empty"] : savedDNSServers
        let domains = savedSearchDomains.isEmpty ? ["empty"] : savedSearchDomains
        _ = runCommand("/usr/sbin/networksetup", ["-setdnsservers", service] + servers)
        _ = runCommand("/usr/sbin/networksetup", ["-setsearchdomains", service] + domains)
        log("DNS restored on '\(service)'")
        savedDNSService = nil
        savedDNSServers = []
        savedSearchDomains = []
    }

    /// The script's get_first_macos_nic: first hardware service whose device is active.
    private func firstActiveService() -> String? {
        let order = runCommand("/usr/sbin/networksetup", ["-listnetworkserviceorder"]).output
        // Lines like: "(Hardware Port: Wi-Fi, Device: en0)"
        for line in order.split(separator: "\n") where line.contains("Hardware Port") {
            let parts = line.components(separatedBy: ", ")
            guard parts.count >= 2 else { continue }
            let name = parts[0].replacingOccurrences(of: "(Hardware Port: ", with: "")
            let dev = parts[1].replacingOccurrences(of: "Device: ", with: "")
                .replacingOccurrences(of: ")", with: "")
                .trimmingCharacters(in: .whitespaces)
            guard !dev.isEmpty else { continue }
            let info = runCommand("/sbin/ifconfig", [dev]).output
            if info.contains("status: active") { return name.trimmingCharacters(in: .whitespaces) }
        }
        return nil
    }

    private func currentNetworkList(_ flag: String, _ service: String) -> [String] {
        let out = runCommand("/usr/sbin/networksetup", [flag, service]).output
        if out.contains("aren't any") || out.contains("There aren't") { return [] }
        return out.split(separator: "\n").map(String.init).filter { !$0.isEmpty }
    }

    // MARK: Keepalive

    private func startKeepalive(_ profile: VPNProfile) {
        guard !profile.keepaliveIP.isEmpty else { return }
        keepaliveFailures = 0
        let timer = DispatchSource.makeTimerSource(queue: queue)
        timer.schedule(deadline: .now() + .seconds(profile.keepaliveInterval),
                       repeating: .seconds(max(5, profile.keepaliveInterval)))
        timer.setEventHandler { [weak self] in self?.keepaliveTick(profile) }
        timer.resume()
        keepaliveTimer = timer
    }

    private func keepaliveTick(_ profile: VPNProfile) {
        let r = runCommand("/sbin/ping", ["-s", "1", "-W", "3", "-c", "1", profile.keepaliveIP], timeout: 6)
        if r.status == 0 {
            keepaliveFailures = 0
            return
        }
        keepaliveFailures += 1
        log("keepalive ping failed (\(keepaliveFailures)/8)")
        if keepaliveFailures >= 8 {
            reconnectInPlace(profile, reason: "keepalive failed 8×")
        }
    }

    private func stopKeepalive() {
        keepaliveTimer?.cancel()
        keepaliveTimer = nil
    }

    /// Re-establish the tunnel without restarting charon: terminate the IKE SA,
    /// re-initiate (which lands on a fresh utun), and re-install the routes. Used
    /// by both the keepalive failure path and wake recovery.
    private func reconnectInPlace(_ profile: VPNProfile, reason: String) {
        guard let swanctlBin = locateSwanctl() else { return }
        keepaliveFailures = 0
        log("reconnecting (\(reason))")
        update { $0.state = .reconnecting; $0.message = "Reconnecting…" }
        _ = swanctl(swanctlBin, ["--terminate", "--ike", connName], timeout: 4)
        removeRoutes()   // drop the old utun's routes + clear tracking before re-initiating
        do {
            try initiate(swanctlBin: swanctlBin)
            installRoutes(profile)
            update { $0.state = .connected; $0.message = "Connected" }
        } catch {
            log("reconnect failed: \(error.localizedDescription)")
            update { $0.state = .error; $0.message = "Reconnect failed: \(error.localizedDescription)" }
        }
    }

    // MARK: Sleep/wake recovery

    /// Register for system power events so the tunnel recovers immediately on wake
    /// instead of waiting out ~8 keepalive ping timeouts. Call once, from the
    /// daemon's main run loop (Helper.run).
    func registerPowerNotifications() {
        let ctx = Unmanaged.passUnretained(self).toOpaque()
        var notifier: io_object_t = 0
        let root = IORegisterForSystemPower(ctx, &pmNotifierPort, { refcon, _, type, arg in
            guard let refcon = refcon else { return }
            Unmanaged<VPNEngine>.fromOpaque(refcon).takeUnretainedValue()
                .handlePowerMessage(type: type, arg: arg)
        }, &notifier)
        guard root != 0, let port = pmNotifierPort else {
            log("warning: could not register for system power events")
            return
        }
        pmRootPort = root
        pmNotifier = notifier
        CFRunLoopAddSource(CFRunLoopGetMain(),
                           IONotificationPortGetRunLoopSource(port).takeUnretainedValue(),
                           .commonModes)
        log("registered for sleep/wake events")
    }

    // IOKit power message types (iokit_common_msg values from IOKit/IOMessage.h;
    // the macros themselves aren't importable into Swift).
    private static let kPMCanSystemSleep: UInt32 = 0xE000_0270
    private static let kPMSystemWillSleep: UInt32 = 0xE000_0280
    private static let kPMSystemHasPoweredOn: UInt32 = 0xE000_0300

    private func handlePowerMessage(type: UInt32, arg: UnsafeMutableRawPointer?) {
        switch type {
        case Self.kPMCanSystemSleep, Self.kPMSystemWillSleep:
            // Acknowledge immediately — never delay sleep.
            IOAllowPowerChange(pmRootPort, Int(bitPattern: arg))
        case Self.kPMSystemHasPoweredOn:
            queue.async { [weak self] in self?.recoverAfterWake() }
        default:
            break
        }
    }

    /// On wake: if a tunnel is meant to be up, give the network a moment to
    /// reassociate, then reconnect unless the SA is still healthy.
    private func recoverAfterWake() {
        guard let profile = current else { return }   // no active tunnel — nothing to do
        log("system woke — verifying tunnel")
        Thread.sleep(forTimeInterval: 2.0)
        keepaliveFailures = 0
        if tunnelHealthy(profile) {
            log("tunnel healthy after wake")
            return
        }
        reconnectInPlace(profile, reason: "woke from sleep")
    }

    private func tunnelHealthy(_ profile: VPNProfile) -> Bool {
        guard let swanctlBin = locateSwanctl() else { return false }
        let sas = swanctl(swanctlBin, ["--list-sas"], timeout: 5).output
        guard sas.contains("ESTABLISHED") && sas.contains("INSTALLED") else { return false }
        // If a keepalive target is configured, also require it to be reachable.
        guard !profile.keepaliveIP.isEmpty else { return true }
        return runCommand("/sbin/ping", ["-s", "1", "-W", "3", "-c", "1", profile.keepaliveIP], timeout: 6).status == 0
    }

    // MARK: Teardown

    private func teardown(restoreNetworking: Bool) {
        stopKeepalive()

        // Restore local networking first so the user's connectivity returns
        // immediately, before the (bounded) network teardown.
        removeRoutes()
        if restoreNetworking { restoreDNS() }

        // Politely tell the peer the SA is going away. Bounded so a slow/again
        // unreachable gateway can't stall the disconnect (the old 15s wait was
        // effectively unbounded — see runCommand).
        if let swanctlBin = locateSwanctl() {
            _ = swanctl(swanctlBin, ["--terminate", "--ike", connName], timeout: 4)
        }
        // Unregister events + disconnect libvici explicitly (ordered), then drop
        // the ref — rather than relying on ARC dealloc timing to race a callback.
        vici?.close()
        vici = nil

        // Graceful charon shutdown (SIGTERM), then a hard kill if it overruns.
        if let p = charon {
            p.terminate()  // SIGTERM
            let deadline = Date().addingTimeInterval(3)
            while p.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.05) }
            if p.isRunning { kill(p.processIdentifier, SIGKILL) }
            p.waitUntilExit()
        }
        // Stop the background reader so late buffered output can't touch state.
        charonOutput?.readabilityHandler = nil
        charonOutput = nil
        charon = nil
        tunDevice = nil

        // Wipe the staging dir (removes the private key + secrets from disk).
        try? FileManager.default.removeItem(atPath: stagingRoot)
        current = nil
    }

    // MARK: Process helper

    @discardableResult
    private func runCommand(_ path: String, _ args: [String], env: [String: String]? = nil,
                            timeout: TimeInterval = 30) -> (status: Int32, output: String) {
        let p = Process()
        p.executableURL = URL(fileURLWithPath: path)
        p.arguments = args
        if let env = env {
            var merged = ProcessInfo.processInfo.environment
            for (k, v) in env { merged[k] = v }
            p.environment = merged
        }
        let pipe = Pipe()
        p.standardOutput = pipe
        p.standardError = pipe

        // Drain the pipe on a background thread. readDataToEndOfFile() blocks until
        // the child closes the pipe (i.e. exits or we kill it), so reading here —
        // not on this thread — lets the timeout below actually fire. The semaphore
        // is signalled once the read completes, establishing a happens-before edge
        // for the `output` write.
        let handle = pipe.fileHandleForReading
        var output = ""
        let done = DispatchSemaphore(value: 0)
        DispatchQueue.global(qos: .userInitiated).async {
            let data = handle.readDataToEndOfFile()
            output = String(data: data, encoding: .utf8) ?? ""
            done.signal()
        }

        do {
            try p.run()
        } catch {
            return (-1, "failed to launch \(path): \(error.localizedDescription)")
        }

        if done.wait(timeout: .now() + timeout) == .timedOut {
            p.terminate()  // SIGTERM
            if done.wait(timeout: .now() + 1) == .timedOut {
                kill(p.processIdentifier, SIGKILL)  // closes the pipe → read returns
                done.wait()
            }
        }
        p.waitUntilExit()
        return (p.terminationStatus, output.trimmingCharacters(in: .whitespacesAndNewlines))
    }

    private func write(_ contents: String, to path: String, perms: Int) throws {
        try contents.write(toFile: path, atomically: true, encoding: .utf8)
        try FileManager.default.setAttributes([.posixPermissions: perms], ofItemAtPath: path)
    }
}

struct EngineError: LocalizedError {
    let message: String
    init(_ message: String) { self.message = message }
    var errorDescription: String? { message }
}
