//
//  VPNEngine.swift
//  SwanDeskHelper — the privileged VPN lifecycle (runs as root).
//
//  Originally a Swift port of ecs-fi-vpn/start-ecsfi-vpn-clean, now generalised to
//  drive SEVERAL tunnels at once from a single charon: each profile is rendered as
//  its own swanctl connection (`sd-<profile-uuid>`) into a per-profile include
//  under the private, root-owned staging dir (SWANCTL_DIR/conf.d/), loaded with
//  swanctl and initiated independently. charon runs with a STRONGSWAN_CONF that
//  includes the system config but forces install_routes=no.
//
//  Cross-tunnel conflicts are resolved by profile priority (0 = top of the app's
//  list = highest): a `reconcileNetworking()` pass — run after every connect,
//  disconnect, reconnect and priority change — assigns each route CIDR to the
//  highest-priority established tunnel that claims it, points the OS resolver at
//  the highest-priority tunnel that wants DNS, and runs at most ONE dnsmasq (owned
//  by the highest-priority dnsmasq-enabled tunnel). Live connect/disconnect
//  transitions are driven by libvici ike-updown / child-updown events (Vici.swift).
//
//  Profiles with the kill switch enabled additionally load a pf ruleset (a
//  sub-anchor under com.apple/*, which the stock /etc/pf.conf evaluates) that
//  blocks all outbound traffic except loopback, the tunnels themselves, and
//  IKE/ESP to the gateways — so an unexpected SA drop leaks nothing while the
//  auto-reconnect paths bring the tunnel back. The ruleset is removed only on a
//  user-initiated disconnect.
//
//  Secrets live only here, in the 0700 staging dir, and are wiped when the last
//  tunnel stops.
//

import Foundation
import IOKit
import IOKit.pwr_mgt
import SystemConfiguration

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")
    /// Guards `statusSnapshot` only — all other state is engine-queue-confined.
    private let stateLock = NSLock()
    private var statusSnapshot = VPNRuntimeStatus()

    /// Live state of one tunnel. Engine-queue-confined.
    private final class Tunnel {
        let profile: VPNProfile
        var priority: Int
        var state: VPNConnState = .connecting
        var vip: String?
        var tun: String?
        /// Gateway address as an IP, parsed from the live SA (remoteAddr may be a
        /// hostname — the kill-switch pf rules must not need DNS to re-load).
        var remoteIP: String?
        var message: String?
        var keepaliveTimer: DispatchSourceTimer?
        var keepaliveFailures = 0
        var keepalivePingInFlight = false
        var lastAutoReconnectAttempt = Date.distantPast
        /// Coalesces the delayed post-VICI-event reconnect checks (one armed at a time).
        var reconnectCheckScheduled = false

        var id: UUID { profile.id }
        /// Stable swanctl connection/child name, unique per profile.
        var connName: String { "sd-" + profile.id.uuidString.lowercased() }

        init(profile: VPNProfile) {
            self.profile = profile
            self.priority = profile.priority
        }
    }

    private var tunnels: [Tunnel] = []
    /// Kept so a failed connect stays visible in the UI (state .error) even though
    /// the tunnel itself was cleaned up. Cleared by the next start/stop touching it.
    private var lastError: VPNTunnelStatus?

    // Shared processes.
    private var charon: Process?
    private var charonOutput: FileHandle?   // charon's stdout/err; handler cleared on teardown
    private var vici: ViciConnection?
    private var dnsmasqProcess: Process?
    private var dnsmasqOutput: FileHandle?
    private var dnsmasqOwnerID: UUID?

    // Networking bookkeeping (all engine-queue-confined).
    private var installedRoutes: [String: UUID] = [:]        // CIDR → owning tunnel
    private var installedGatewayExclusions: Set<String> = [] // gateway host routes we added
    /// The physical default router, captured BEFORE any full-tunnel /1 routes are
    /// installed; cached until every tunnel is down.
    private var underlyingDefaultGateway: String?
    // DNS override via configd's dynamic store — see MARK: DNS.
    private var dnsStore: SCDynamicStore?
    private var dnsOverrideActive = false
    private var desiredDNSServers: [String] = []
    private var desiredSearchDomains: [String] = []
    /// Service currently carrying our State: DNS stamp (the primary service).
    private var overriddenServiceID: String?
    /// Every service ID we stamped this session — each needs its original
    /// State: value put back on restore (absent from `savedStateDNS` = the key
    /// didn't exist and must be removed, not rewritten).
    private var dnsTouchedServiceIDs: Set<String> = []
    private var savedStateDNS: [String: [String: Any]] = [:]
    /// Services whose *manual* (Setup:) DNS we had to override via networksetup.
    private var savedSetupDNS: [String: (name: String, servers: [String], domains: [String])] = [:]
    private var upstreamDNSCaptured = false
    private var savedIPv6Settings: [(service: String, mode: String)] = []
    private var ipv6LeakPreventionActive = false
    /// pf enable reference token (`pfctl -E`), held while kill-switch rules are loaded.
    private var pfToken: String?
    private var killSwitchLoaded = false
    private let autoReconnectCooldown: TimeInterval = 10
    /// Safety-net retry cadence: while any tunnel is user-intended-up but not
    /// connected, the watchdog attempts a reconnect this often.
    private let reconnectRetryInterval: TimeInterval = 30
    private var reconnectWatchdog: DispatchSourceTimer?
    /// Coalesces the delayed path-change recovery checks (one armed at a time).
    private var pathChangeCheckScheduled = false

    // 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

    // MARK: Paths / bundled strongSwan toolchain

    private let stagingRoot = StagingPaths.root
    private var swanctlDir: String { "\(stagingRoot)/swanctl" }
    private var strongswanConf: String { "\(stagingRoot)/strongswan.conf" }
    private var upstreamResolvConfPath: String { StagingPaths.upstreamResolvConf }
    /// 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)" }
    /// pf sub-anchor for the kill switch. Lives under com.apple/* because the
    /// stock /etc/pf.conf evaluates `anchor "com.apple/*"` — our own top-level
    /// anchor would load but never be consulted.
    private let pfAnchor = "com.apple/250.SwanDeskKillSwitch"

    /// 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 bundledDnsmasq() -> String {
        let exe = Bundle.main.executablePath ?? CommandLine.arguments[0]
        let contents = (exe as NSString).deletingLastPathComponent
            .replacingOccurrences(of: "/Library/LaunchServices", with: "")
        return "\(contents)/Resources/dnsmasq/sbin/dnsmasq"
    }

    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(statusSnapshot)) ?? Data()
    }

    /// Rebuild the status snapshot from the tunnel list and push it to the app.
    /// Must run on the engine queue.
    private func pushStatus() {
        var status = VPNRuntimeStatus()
        status.tunnels = tunnels.sorted { $0.priority < $1.priority }.map { t in
            VPNTunnelStatus(profileID: t.profile.id.uuidString,
                            profileName: t.profile.name,
                            state: t.state,
                            assignedVIP: t.vip,
                            tunDevice: t.tun,
                            message: t.message,
                            priority: t.priority,
                            killSwitchActive: killSwitchLoaded && t.profile.killSwitchEnabled)
        }
        if let err = lastError, !status.tunnels.contains(where: { $0.profileID == err.profileID }) {
            status.tunnels.append(err)
        }
        stateLock.lock()
        statusSnapshot = status
        stateLock.unlock()
        if let data = try? JSONEncoder().encode(status) { onStatus?(data) }
    }

    private func setState(_ t: Tunnel, _ state: VPNConnState, _ message: String?) {
        t.state = state
        t.message = message
        pushStatus()
    }

    private func tunnel(withProfileID id: String) -> Tunnel? {
        guard let uuid = UUID(uuidString: id) else { return nil }
        return tunnels.first { $0.profile.id == uuid }
    }

    /// Active tunnels, highest priority first. Ties (possible when the app's
    /// list was edited while tunnels were up) break deterministically by name so
    /// route/DNS ownership never flips arbitrarily between reconciles.
    private func byPriority() -> [Tunnel] {
        tunnels.sorted { ($0.priority, $0.connName) < ($1.priority, $1.connName) }
    }

    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)
                let vip = self.tunnel(withProfileID: profile.id.uuidString)?.vip
                reply(0, vip.map { "Connected (\($0))" } ?? "Connected")
            } catch {
                self.log("start failed for '\(profile.name)': \(error.localizedDescription)")
                self.cleanupFailedStart(profile: profile, error: error)
                reply(1, error.localizedDescription)
            }
        }
    }

    func stop(profileID: String, reply: @escaping (Int, String) -> Void) {
        queue.async {
            // Any explicit action moves past a surfaced connect failure.
            self.lastError = nil
            guard let t = self.tunnel(withProfileID: profileID) else {
                // Nothing tracked for this profile — but if nothing is tracked at
                // ALL, sweep anyway: a helper restart can leave a stale staging
                // dir (with key material) that only a teardown wipes.
                if self.tunnels.isEmpty { self.teardownAll(restoreNetworking: true) }
                self.pushStatus()
                reply(0, "Not connected")
                return
            }
            let name = t.profile.name
            self.stopTunnel(t)
            reply(0, "Disconnected \(name)")
        }
    }

    func stopAll(reply: @escaping (Int, String) -> Void) {
        queue.async {
            self.lastError = nil
            self.teardownAll(restoreNetworking: true)
            self.pushStatus()
            reply(0, "Disconnected")
        }
    }

    /// Emergency stop: don't trust our own process handles — SIGKILL any charon /
    /// dnsmasq on the system (the whole point is catching strays the helper lost
    /// track of), then restore networking and wipe the staging dir.
    func forceKillAll(reply: @escaping (Int, String) -> Void) {
        queue.async {
            self.log("force-kill requested — killing all charon/dnsmasq processes")
            self.lastError = nil
            self.stopReconnectWatchdog()
            self.setNetworkWatch(false)
            for t in self.tunnels { self.stopKeepalive(t) }
            self.tunnels.removeAll()

            self.vici?.close()
            self.vici = nil
            self.charonOutput?.readabilityHandler = nil
            self.charonOutput = nil
            self.dnsmasqOutput?.readabilityHandler = nil
            self.dnsmasqOutput = nil
            _ = self.runCommand("/usr/bin/killall", ["-9", "charon"])
            _ = self.runCommand("/usr/bin/killall", ["-9", "dnsmasq"])
            self.charon = nil
            self.dnsmasqProcess = nil
            self.dnsmasqOwnerID = nil

            self.removeAllRoutes()
            // Belt and braces: the /1 pair may exist even if our tracking is stale.
            _ = self.runCommand("/sbin/route", ["-n", "delete", "-net", "0.0.0.0/1"])
            _ = self.runCommand("/sbin/route", ["-n", "delete", "-net", "128.0.0.0/1"])
            self.restoreIPv6()
            self.restoreDNS()
            self.deactivateKillSwitch()
            self.underlyingDefaultGateway = nil
            try? FileManager.default.removeItem(atPath: self.stagingRoot)
            self.pushStatus()
            reply(0, "Force-killed all VPN processes and restored networking")
        }
    }

    func setPriorities(profileIDs: [String], reply: @escaping () -> Void) {
        queue.async {
            let order = profileIDs.compactMap { UUID(uuidString: $0) }
            var changed = false
            for t in self.tunnels {
                if let idx = order.firstIndex(of: t.profile.id), t.priority != idx {
                    t.priority = idx
                    changed = true
                }
            }
            if changed {
                self.log("priorities changed — re-resolving route/DNS conflicts")
                self.reconcileNetworking()
            }
            self.pushStatus()
            reply()
        }
    }

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

    // MARK: Start steps

    private func doStart(profile: VPNProfile, secrets: VPNSecrets) throws {
        // Re-connecting a profile that is already up: tear that one tunnel down
        // first (others stay up) so start is idempotent per profile.
        if let existing = tunnel(withProfileID: profile.id.uuidString) {
            stopTunnel(existing)
        }
        // Any new connect moves past a previously surfaced failure (matching the
        // old single-tunnel semantics, where the next action replaced the error).
        lastError = nil

        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)")
        }

        let t = Tunnel(profile: profile)
        tunnels.append(t)
        startReconnectWatchdog()
        setState(t, .connecting, "Connecting…")

        try stageProfile(t, secrets: secrets)

        try ensureCharonRunning(charonBin)

        // Side effects of the already-active tunnels can block THIS tunnel's IKE
        // exchange — open them before initiating: engaged kill-switch pf rules
        // must pass the new gateway, and an active full-tunnel's /1 routes must
        // not capture it (pin the gateway via the physical default router, or the
        // new SA would negotiate THROUGH the other tunnel and then break when the
        // exclusion route flips its path mid-session).
        if killSwitchLoaded { reconcileKillSwitch() }
        if installedRoutes.keys.contains("0.0.0.0/1") || installedRoutes.keys.contains("128.0.0.0/1") {
            addGatewayExclusionRoute(for: profile.remoteAddr)
        }

        // (Re)load credentials + connections. No --clear here: momentarily
        // dropping the OTHER tunnels' keys could fail an in-flight rekey; stale
        // credentials are purged on the stop path instead.
        let creds = swanctl(swanctlBin, ["--load-creds"])
        log(creds.output)
        if creds.status != 0 { throw EngineError("swanctl --load-creds failed (\(creds.status))") }
        let conns = swanctl(swanctlBin, ["--load-conns"])
        log(conns.output)
        if conns.status != 0 { throw EngineError("swanctl --load-conns failed (\(conns.status))") }

        try initiate(t, swanctlBin: swanctlBin)

        // Routes, DNS/dnsmasq, IPv6 leak prevention and kill-switch rules for the
        // whole set of active tunnels, in priority order.
        reconcileNetworking()

        startKeepalive(t)
        setState(t, .connected, "Connected")
    }

    /// Cleanup after a failed doStart: drop the half-started tunnel (terminating
    /// any half-established IKE_SA), keep the other tunnels running, and leave an
    /// .error status entry so the UI shows what happened.
    private func cleanupFailedStart(profile: VPNProfile, error: Error) {
        // Prefer the live tunnel's priority — setPriorities may have re-stamped
        // it after the profile was sent over.
        let priority = tunnel(withProfileID: profile.id.uuidString)?.priority ?? profile.priority
        lastError = VPNTunnelStatus(profileID: profile.id.uuidString,
                                    profileName: profile.name,
                                    state: .error,
                                    message: error.localizedDescription,
                                    priority: priority)
        if let t = tunnel(withProfileID: profile.id.uuidString) {
            stopTunnel(t)
        } else {
            pushStatus()
        }
    }

    /// Launch (or relaunch) charon if it isn't actually running. `charon == nil`
    /// is not enough: a crashed (or externally killed) charon leaves a dead
    /// Process behind, and skipping the launch would fail every future connect
    /// against a gone VICI socket. Returns true when charon was (re)launched —
    /// a fresh charon has no connections or credentials loaded, so the caller
    /// must `--load-creds` / `--load-conns` before initiating.
    @discardableResult
    private func ensureCharonRunning(_ charonBin: String) throws -> Bool {
        guard charon?.isRunning != true else { return false }
        if charon != nil {
            log("tracked charon is not running — relaunching")
            vici?.close()
            vici = nil
            stopCharon()
        }
        // Stop any stray charon, then launch ours.
        _ = runCommand("/usr/bin/killall", ["charon"])
        try launchCharon(charonBin)
        try waitForViciSocket()
        connectVici()
        return true
    }

    private func initiate(_ t: Tunnel, swanctlBin: String) throws {
        t.vip = nil
        t.tun = nil
        let result = swanctl(swanctlBin, ["--initiate", "--child", t.connName], timeout: 20)
        log(result.output)
        parseTunnelInfo(t, from: result.output)

        // The "created TUN device" line comes from charon's OWN stdout, whose
        // reader can't attribute lines to a tunnel (and is dispatched to this —
        // currently blocked — queue anyway). Recover the VIP from the live SA and
        // the utun from the routing table instead.
        if t.vip == nil || t.remoteIP == nil {
            parseSAInfo(t, swanctlBin: swanctlBin)
        }
        if t.tun == nil {
            discoverTun(t)
        }

        // A real failure shows up as no VIP/utun AND a non-zero swanctl exit.
        if t.vip == nil && t.tun == nil && result.status != 0 {
            throw EngineError("Tunnel did not establish (check credentials / gateway). swanctl exit \(result.status)")
        }

        // The VIP/utun above only proves Phase 1 (IKE_SA) came up — charon assigns
        // it from the IKE_AUTH config payload before the CHILD_SA is negotiated.
        // Phase 2 (the CHILD_SA / ESP) can still fail on its own (e.g. mismatched
        // ESP proposals, or a PFS group the peer never asked for) while leaving the
        // VIP/utun in place. Require an actually-installed CHILD_SA.
        guard childSAInstalled(t, swanctlBin: swanctlBin) else {
            throw EngineError("IKE_SA (Phase 1) established but CHILD_SA (Phase 2/ESP) failed to install — check ESP proposals/PFS group")
        }
        pushStatus()
    }

    /// True when swanctl reports a live IKE_SA with an installed CHILD_SA for THIS
    /// tunnel's connection — the only reliable signal that Phase 2 (ESP) is up.
    private func childSAInstalled(_ t: Tunnel, swanctlBin: String) -> Bool {
        let sas = swanctl(swanctlBin, ["--list-sas", "--ike", t.connName], timeout: 5).output
        return sas.contains("ESTABLISHED") && sas.contains("INSTALLED")
    }

    /// Same check against an already-fetched, unfiltered `--list-sas` dump. Both
    /// the IKE_SA header and the CHILD_SA line start with the connection/child
    /// name (they share it), so match only lines belonging to this tunnel.
    private func childSAInstalled(_ t: Tunnel, inListSAs output: String) -> Bool {
        var established = false, installed = false
        let prefix = "\(t.connName): #"
        for line in output.split(separator: "\n") {
            let s = line.trimmingCharacters(in: .whitespaces)
            guard s.hasPrefix(prefix) else { continue }
            if s.contains("ESTABLISHED") { established = true }
            if s.contains("INSTALLED") { installed = true }
        }
        return established && installed
    }

    /// Parse swanctl --initiate output for the assigned VIP and utun device.
    private func parseTunnelInfo(_ t: Tunnel, 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 {
                t.vip = String(ip)
            }
            if s.contains("created TUN device"), let dev = s.split(separator: " ").last {
                t.tun = String(dev)
            }
        }
    }

    /// Recover the VIP + resolved gateway IP from `swanctl --list-sas` for this
    /// tunnel's connection. Lines look like:
    ///   local  'C=FI, …' @ 192.168.1.10[4500] [192.168.2.151]
    ///   remote 'gw' @ 203.0.113.5[4500]
    private func parseSAInfo(_ t: Tunnel, swanctlBin: String) {
        for attempt in 0..<4 {
            let out = swanctl(swanctlBin, ["--list-sas", "--ike", t.connName], timeout: 5).output
            for line in out.split(separator: "\n") {
                let s = String(line).trimmingCharacters(in: .whitespaces)
                guard let atRange = s.range(of: " @ ") else { continue }
                let tail = String(s[atRange.upperBound...])   // "1.2.3.4[4500] [vip]"
                let addr = tail.split(separator: "[").first.map {
                    String($0).trimmingCharacters(in: .whitespaces)
                }
                if s.hasPrefix("local") {
                    // Trailing [vip] after the port bracket, if a VIP was assigned.
                    let parts = tail.split(separator: " ").map(String.init)
                    if parts.count >= 2, parts[1].hasPrefix("["), parts[1].hasSuffix("]"), t.vip == nil {
                        t.vip = String(parts[1].dropFirst().dropLast())
                    }
                } else if s.hasPrefix("remote"), let a = addr, !a.isEmpty {
                    t.remoteIP = a
                }
            }
            if t.vip != nil || !t.profile.requestVIP { return }
            if attempt < 3 { Thread.sleep(forTimeInterval: 0.25) }
        }
    }

    /// Find the utun the tunnel landed on: `route get <vip>` when the VIP is
    /// known, else scan for a utun UH host route not already owned by another
    /// tunnel (the kernel adds one for the VIP the moment charon assigns it).
    private func discoverTun(_ t: Tunnel) {
        if let vip = t.vip {
            let r = runCommand("/sbin/route", ["get", vip])
            for line in r.output.split(separator: "\n") {
                let s = String(line).trimmingCharacters(in: .whitespaces)
                if s.hasPrefix("interface:") {
                    let iface = s.dropFirst("interface:".count).trimmingCharacters(in: .whitespaces)
                    if iface.hasPrefix("utun") {
                        t.tun = iface
                        log("utun discovered via route get: \(iface)")
                        return
                    }
                }
            }
        }
        let taken = Set(tunnels.compactMap { $0 === t ? nil : $0.tun })
        let ns = runCommand("/usr/sbin/netstat", ["-rn", "-f", "inet"])
        for line in ns.output.split(separator: "\n") {
            let cols = String(line).split(separator: " ").map(String.init).filter { !$0.isEmpty }
            guard cols.count >= 4 else { continue }
            let (dst, gw, flags, iface) = (cols[0], cols[1], cols[2], cols[3])
            guard flags.contains("H"), !flags.contains("L"),
                  iface.hasPrefix("utun"), dst == gw, !taken.contains(iface) else { continue }
            t.tun = iface
            if t.vip == nil { t.vip = dst }
            log("utun discovered via netstat: \(iface) (VIP \(dst))")
            return
        }
        log("utun discovery failed for '\(t.profile.name)' — its routes will not be installed")
    }

    // MARK: Config staging (per-profile conf.d includes)

    /// Short suffix namespacing this profile's files inside the staging dir.
    private func fileID(_ t: Tunnel) -> String { t.profile.id.uuidString.lowercased() }

    /// Create the staging tree (fresh when no other tunnel is using it) and write
    /// this profile's certs, key and swanctl include.
    private func stageProfile(_ t: Tunnel, secrets: VPNSecrets) throws {
        let fm = FileManager.default
        let othersActive = tunnels.contains { $0 !== t }
        if !othersActive {
            // First tunnel: 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-creds` 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])
        }

        let id = fileID(t)
        let profile = t.profile
        // 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-\(id).pem", perms: 0o600)
        }
        if profile.authMethod.usesClientCert {
            try write(profile.clientCertPEM, to: "\(swanctlDir)/x509/client-\(id).crt", perms: 0o600)
            try write(secrets.privateKeyPEM, to: "\(swanctlDir)/private/client-\(id).key", perms: 0o600)
        }

        // Top-level swanctl.conf just pulls in one include per active tunnel; a
        // strongswan.conf includes the system config but disables charon's own
        // route installation (we add routes ourselves).
        try write(renderSwanctlConf(t, secrets: secrets),
                  to: "\(swanctlDir)/conf.d/\(id).conf", perms: 0o600)
        try write("include conf.d/*.conf\n", to: "\(swanctlDir)/swanctl.conf", perms: 0o600)
        try write(renderStrongswanConf(), to: strongswanConf, perms: 0o644)
    }

    /// Remove one profile's staged files (its include, certs and key), so the next
    /// `swanctl --load-conns` unloads the connection and `--load-creds --clear`
    /// drops its secrets.
    private func unstageProfile(_ t: Tunnel) {
        let id = fileID(t)
        let fm = FileManager.default
        for path in ["\(swanctlDir)/conf.d/\(id).conf",
                     "\(swanctlDir)/x509ca/ca-\(id).pem",
                     "\(swanctlDir)/x509/client-\(id).crt",
                     "\(swanctlDir)/private/client-\(id).key"] {
            try? fm.removeItem(atPath: path)
        }
    }

    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 the SCDynamicStore. 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(_ t: Tunnel, secrets: VPNSecrets) -> String {
        let profile = t.profile
        // 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 {
            \(t.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(t))
        \(renderRemoteAuth(t))
                children {
                    \(t.connName) {
                        mode = tunnel
                        start_action = none
                        remote_ts = \(profile.remoteTS)
                        esp_proposals = \(profile.espProposals)
                        rekey_time = \(profile.rekeyTimeChild)
                        life_time = \(profile.lifeTimeChild)
                    }
                }
            }
        }

        secrets {
        \(renderSecrets(t, 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(_ t: Tunnel) -> String {
        let profile = t.profile
        let id = fileID(t)
        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-\(id).crt
                        }
                        local-xauth {
                            round = 2
                            auth = xauth
                            xauth_id = \(profile.xauthID)
                        }
            """
        case .certificate:
            return """
                        local {
                            auth = pubkey
                            certs = client-\(id).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-\(id).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(_ t: Tunnel) -> String {
        let profile = t.profile
        let remoteID = profile.remoteID.isEmpty ? "%any" : "\"\(profile.remoteID)\""
        if profile.authMethod.verifiesGatewayWithCert {
            return """
                        remote {
                            auth = pubkey
                            cacerts = ca-\(fileID(t)).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. Section names
    /// carry the profile id so several profiles' secrets can coexist in charon.
    private func renderSecrets(_ t: Tunnel, _ secrets: VPNSecrets) -> String {
        let profile = t.profile
        let id = fileID(t)
        var blocks: [String] = []
        if profile.authMethod.usesClientCert {
            blocks.append("""
                    private-\(id) {
                        file = client-\(id).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)-\(id) {
                        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-\(id) {"]
            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 stageProfile 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") {
                self?.onLog?(String(line))
            }
        }
        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)")
    }

    private func stopCharon() {
        // 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
    }

    // MARK: dnsmasq process

    private func startDnsmasq(profile: VPNProfile) throws {
        let bin = bundledDnsmasq()
        log("dnsmasq: binary = \(bin)")
        guard FileManager.default.isExecutableFile(atPath: bin) else {
            throw EngineError("bundled dnsmasq not found at \(bin) — rebuild the app")
        }

        let confPath = "\(stagingRoot)/dnsmasq.conf"
        let trimmedCustom = profile.dnsmasqConfig.trimmingCharacters(in: .whitespacesAndNewlines)
        let conf: String
        if trimmedCustom.isEmpty {
            // No user config: generate a minimal forwarding config from profile DNS servers.
            var lines = [
                "listen-address=127.0.0.1",
                "bind-interfaces",
                "port=53",
                "no-hosts",
                "no-resolv",
            ]
            for server in profile.dnsServers { lines.append("server=\(server)") }
            if profile.dnsServers.isEmpty {
                // No explicit upstreams: fall back to whatever DNS the Mac was
                // using just before this VPN connected, so dnsmasq has some
                // upstream instead of none (no-resolv + no server= otherwise
                // leaves it unable to resolve anything outside its own cache).
                lines.append("resolv-file=\(upstreamResolvConfPath)")
            }
            conf = lines.joined(separator: "\n") + "\n"
        } else {
            // User config is the complete dnsmasq.conf — use it verbatim.
            // Include listen-address, port, server= lines etc. in the config text itself.
            conf = trimmedCustom + "\n"
        }
        log("dnsmasq: config ---------\n\(conf)-------------------------")
        guard let confData = conf.data(using: .utf8) else {
            throw EngineError("failed to encode dnsmasq config")
        }
        FileManager.default.createFile(atPath: confPath, contents: confData,
                                       attributes: [.posixPermissions: 0o600])

        let p = Process()
        p.executableURL = URL(fileURLWithPath: bin)
        p.arguments = ["--no-daemon", "--conf-file=\(confPath)", "--log-facility=-"]
        log("dnsmasq: launching \(([bin] + p.arguments!).joined(separator: " "))")

        let pipe = Pipe()
        p.standardOutput = pipe
        p.standardError = pipe
        let outHandle = pipe.fileHandleForReading
        dnsmasqOutput = 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") {
                self?.onLog?("[dnsmasq] \(line)")
            }
        }
        try p.run()
        dnsmasqProcess = p
        log("dnsmasq: launched (pid \(p.processIdentifier)), waiting 0.5 s…")
        Thread.sleep(forTimeInterval: 0.5)
        if !p.isRunning {
            // Drain any remaining output before throwing so the error appears in the log.
            outHandle.readabilityHandler = nil
            let tail = outHandle.availableData
            if let text = String(data: tail, encoding: .utf8), !text.isEmpty {
                for line in text.split(separator: "\n") { log("[dnsmasq] \(line)") }
            }
            throw EngineError("dnsmasq exited (status \(p.terminationStatus)) — see log above for details")
        }
        log("dnsmasq: ready (pid \(p.processIdentifier))")
    }

    private func stopDnsmasq() {
        dnsmasqOutput?.readabilityHandler = nil
        dnsmasqOutput = nil
        dnsmasqOwnerID = nil
        guard let proc = dnsmasqProcess else { return }
        if proc.isRunning {
            proc.terminate()
            let deadline = Date().addingTimeInterval(3)
            while proc.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.05) }
            if proc.isRunning { kill(proc.processIdentifier, SIGKILL) }
        }
        dnsmasqProcess = nil
        log("dnsmasq stopped")
    }

    // 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 each tunnel's connected/disconnected
    /// state from the live SA list (event-triggered, not periodic polling). ONE
    /// unfiltered `--list-sas` serves every tunnel; a per-tunnel query would fork
    /// N swanctl processes per event. Tunnels in `.error` are included so a
    /// gateway-initiated re-establish (or a lucky rekey) marks them healthy again.
    private func handleViciEvent(_ name: String) {
        guard let swanctlBin = locateSwanctl() else { return }
        let watched = tunnels.filter { $0.state == .connected || $0.state == .reconnecting || $0.state == .error }
        guard !watched.isEmpty else { return }
        let sas = swanctl(swanctlBin, ["--list-sas"], timeout: 5).output
        for t in watched {
            if childSAInstalled(t, inListSAs: sas) {
                if t.state != .connected { setState(t, .connected, "Connected") }
            } else if charon != nil {
                if t.state == .connected { setState(t, .reconnecting, "SA down — reconnecting…") }
                scheduleAutoReconnectCheck(t, swanctlBin: swanctlBin)
            }
        }
    }

    /// A down event alone doesn't prove the tunnel is dead — a routine rekey
    /// transiently shows the same "no CHILD_SA installed" state while the new SA
    /// negotiates. Re-check after a short grace period, so we only act on a drop
    /// that's still down a few seconds later. This is what lets a dropped SA
    /// recover on its own even when the profile has no keepaliveIP configured.
    /// Coalesced per tunnel: a burst of flapping events arms one check, not N.
    private func scheduleAutoReconnectCheck(_ t: Tunnel, swanctlBin: String) {
        guard !t.reconnectCheckScheduled else { return }
        t.reconnectCheckScheduled = true
        queue.asyncAfter(deadline: .now() + 3) { [weak self] in
            guard let self else { return }
            t.reconnectCheckScheduled = false
            guard self.tunnels.contains(where: { $0 === t }), self.charon != nil,
                  !self.childSAInstalled(t, swanctlBin: swanctlBin),
                  Date().timeIntervalSince(t.lastAutoReconnectAttempt) > self.autoReconnectCooldown
            else { return }
            self.reconnectInPlace(t, reason: "SA down (VICI event)")
        }
    }

    /// Safety-net retry loop: every 30 s, reconnect any tunnel that is
    /// user-intended-up but not connected (`.reconnecting` or `.error`) and
    /// hasn't been attempted recently. The event-driven paths above give fast
    /// recovery; this timer guarantees retries never stop until the tunnel
    /// comes back or the user disconnects it — essential when the kill switch
    /// is blocking all traffic behind the drop, and immune to any gap in the
    /// event-driven re-arming (missed VICI events, cooldown races, dead charon).
    private func startReconnectWatchdog() {
        guard reconnectWatchdog == nil else { return }
        let timer = DispatchSource.makeTimerSource(queue: queue)
        timer.schedule(deadline: .now() + reconnectRetryInterval,
                       repeating: reconnectRetryInterval, leeway: .seconds(2))
        timer.setEventHandler { [weak self] in
            guard let self else { return }
            for t in self.tunnels
            where (t.state == .reconnecting || t.state == .error)
                && Date().timeIntervalSince(t.lastAutoReconnectAttempt) >= self.reconnectRetryInterval - 5 {
                self.reconnectInPlace(t, reason: "retry watchdog")
            }
        }
        timer.resume()
        reconnectWatchdog = timer
    }

    private func stopReconnectWatchdog() {
        reconnectWatchdog?.cancel()
        reconnectWatchdog = nil
    }

    /// Fast failover on a network path change (Ethernet unplugged/plugged,
    /// Wi-Fi joined/left — anything that moves State:/Network/Global/IPv4).
    /// The IKE SA is pinned to the local address it was initiated from (IKEv1
    /// has no MOBIKE), so losing that interface kills the tunnel until a full
    /// reconnect re-initiates from the surviving path. Without this hook that
    /// reconnect waits out 8 keepalive failures (~4 min); here each tunnel is
    /// verified as soon as the path settles and dead ones reconnect right away.
    /// A tunnel that failed over and is healthy is left alone when the original
    /// interface returns — flapping back would drop live sessions for nothing.
    private func schedulePathChangeRecovery() {
        guard !tunnels.isEmpty, !pathChangeCheckScheduled else { return }
        pathChangeCheckScheduled = true
        queue.asyncAfter(deadline: .now() + 3) { [weak self] in
            guard let self else { return }
            self.pathChangeCheckScheduled = false
            guard !self.tunnels.isEmpty, self.charon != nil else { return }
            self.log("network path changed — verifying \(self.tunnels.count) tunnel(s)")
            for t in self.tunnels {
                switch t.state {
                case .connected where !self.tunnelHealthy(t),
                     .reconnecting, .error:
                    guard Date().timeIntervalSince(t.lastAutoReconnectAttempt) > self.autoReconnectCooldown
                    else { continue }
                    t.keepaliveFailures = 0
                    self.reconnectInPlace(t, reason: "network path changed")
                case .connected:
                    self.log("'\(t.profile.name)' healthy after path change")
                default:
                    break
                }
            }
        }
    }

    // MARK: Networking reconciliation (routes / DNS / dnsmasq / IPv6 / kill switch)

    /// Bring every OS-level side effect in line with the current tunnel set, in
    /// priority order. Runs after every connect, disconnect, reconnect and
    /// priority change; each part diffs desired vs. current state, so it is cheap
    /// and idempotent when nothing changed.
    private func reconcileNetworking() {
        setNetworkWatch(!tunnels.isEmpty)
        reconcileRoutes()
        reconcileDNS()
        reconcileIPv6()
        reconcileKillSwitch()
        pushStatus()
    }

    /// The CIDRs a profile wants routed over its tunnel (manual routes, or the
    /// negotiated traffic selector when none are configured), with 0.0.0.0/0
    /// expanded into the /1 pair. Two /1 routes are more specific than the /0
    /// kernel default route, so they take priority while the original default
    /// route stays intact and re-asserts itself when they are removed.
    private func effectiveCIDRs(_ profile: VPNProfile) -> [String] {
        var raw = profile.routes
        if raw.isEmpty {
            let ts = profile.remoteTS.trimmingCharacters(in: .whitespaces)
            guard !ts.isEmpty else { return [] }
            raw = [ts]
        }
        return raw.flatMap { $0 == "0.0.0.0/0" ? ["0.0.0.0/1", "128.0.0.0/1"] : [$0] }
    }

    private func reconcileRoutes() {
        // Desired table: walk tunnels from highest to lowest priority; the first
        // tunnel to claim a CIDR owns it, so on overlap the higher-priority
        // profile's route wins (and a lower one takes over if it disconnects).
        var desired: [String: Tunnel] = [:]
        for t in byPriority() where t.tun != nil || t.vip != nil {
            for cidr in effectiveCIDRs(t.profile) where desired[cidr] == nil {
                desired[cidr] = t
            }
        }
        let fullTunnelPresent = desired.keys.contains("0.0.0.0/1") || desired.keys.contains("128.0.0.0/1")

        // For a full-tunnel config, pin EVERY tunnel's gateway IP to the real
        // default router BEFORE the /1 routes exist. Without this, the /1 routes
        // capture the gateways' own IPs and break IKE/keepalive traffic — the
        // connections appear up but all traffic stops immediately.
        if fullTunnelPresent {
            // Prefer the gateway IP resolved from the live SA: `route add -host
            // <hostname>` needs DNS, which may be dead while a tunnel is down.
            let wanted = Set(tunnels.map { $0.remoteIP ?? $0.profile.remoteAddr }.filter { !$0.isEmpty })
            for stale in installedGatewayExclusions.subtracting(wanted) {
                _ = runCommand("/sbin/route", ["delete", "-host", stale])
                installedGatewayExclusions.remove(stale)
                log("gateway exclusion route removed for \(stale)")
            }
            for addr in wanted.subtracting(installedGatewayExclusions) {
                addGatewayExclusionRoute(for: addr)
            }
        }

        // Remove routes whose owner changed or vanished, then add missing ones
        // via the owner's VIP. On macOS `-interface utun` can be silently rejected
        // when the destination prefix doesn't contain the interface's own address,
        // so the VIP next-hop (resolved to the utun by the kernel's UH host route)
        // is the reliable form; the interface form is only the no-VIP fallback.
        for (cidr, ownerID) in installedRoutes where desired[cidr]?.id != ownerID {
            _ = runCommand("/sbin/route", ["-n", "delete", "-net", cidr])
            installedRoutes.removeValue(forKey: cidr)
            log("route removed: \(cidr)")
        }
        for (cidr, t) in desired where installedRoutes[cidr] == nil {
            let r: (status: Int32, output: String)
            if let vip = t.vip {
                r = runCommand("/sbin/route", ["-n", "add", "-net", cidr, vip])
            } else if let tun = t.tun {
                r = runCommand("/sbin/route", ["-n", "add", "-net", cidr, "-interface", tun])
            } else {
                continue
            }
            if r.status == 0 { installedRoutes[cidr] = t.id }
            log("route add \(cidr) → \(t.vip ?? t.tun ?? "?") ['\(t.profile.name)']: \(r.status == 0 ? "ok" : r.output.isEmpty ? "exit(\(r.status))" : r.output)")
        }

        // No full tunnel left: the exclusions are redundant (the real default
        // route handles the gateways again).
        if !fullTunnelPresent {
            for addr in installedGatewayExclusions {
                _ = runCommand("/sbin/route", ["delete", "-host", addr])
                log("gateway exclusion route removed for \(addr)")
            }
            installedGatewayExclusions.removeAll()
        }
    }

    /// Add a host route for a VPN gateway via the physical default router so that
    /// IKE / keepalive packets can still reach the peer after the full-tunnel /1
    /// routes redirect everything else into the tunnels.
    private func addGatewayExclusionRoute(for remoteAddr: String) {
        // Capture the physical gateway once, while the default route still points
        // at it (before any /1 routes exist); it stays cached until all tunnels
        // are down, because `route get 0.0.0.0` afterwards would name a VPN.
        if underlyingDefaultGateway == nil {
            let routeGet = runCommand("/sbin/route", ["get", "0.0.0.0"])
            for line in routeGet.output.split(separator: "\n") {
                let s = String(line).trimmingCharacters(in: .whitespaces)
                if s.hasPrefix("gateway:") {
                    underlyingDefaultGateway = s.replacingOccurrences(of: "gateway:", with: "")
                        .trimmingCharacters(in: .whitespaces)
                    break
                }
            }
        }
        guard let viaGW = underlyingDefaultGateway else {
            log("gateway exclusion: could not determine default gateway — skipping")
            return
        }
        let r = runCommand("/sbin/route", ["add", "-host", remoteAddr, viaGW])
        if r.status == 0 {
            installedGatewayExclusions.insert(remoteAddr)
            log("gateway exclusion route: \(remoteAddr) via \(viaGW)")
        } else {
            log("gateway exclusion route add failed: \(r.output)")
        }
    }

    private func removeAllRoutes() {
        // Delete by prefix only — no -interface or gateway flag — so the deletion
        // matches regardless of whether the route was added via VIP or -interface.
        for cidr in installedRoutes.keys {
            _ = runCommand("/sbin/route", ["-n", "delete", "-net", cidr])
        }
        installedRoutes.removeAll()
        // Remove the gateway exclusions AFTER the /1 routes are gone — once the /1
        // routes are deleted the original default route re-asserts, making the
        // host routes redundant.
        for addr in installedGatewayExclusions {
            _ = runCommand("/sbin/route", ["delete", "-host", addr])
        }
        installedGatewayExclusions.removeAll()
    }

    // MARK: DNS

    /// True when a tunnel has anything DNS-related to contribute.
    private func wantsDNS(_ profile: VPNProfile) -> Bool {
        profile.dnsmasqEnabled || !profile.dnsServers.isEmpty || !profile.searchDomains.isEmpty
    }

    private func reconcileDNS() {
        let established = byPriority().filter { $0.tun != nil || $0.vip != nil }
        let winner = established.first { wantsDNS($0.profile) }

        // Capture the pre-VPN DNS state (and persist it for dnsmasq's
        // resolv-file= fallback) before dnsmasq potentially starts below —
        // startDnsmasq() may reference the snapshot file we write here.
        if winner != nil { captureOriginalDNSIfNeeded() }

        // Only ONE dnsmasq can bind 127.0.0.1:53 — the highest-priority tunnel
        // that wants it owns the instance; when ownership changes (a higher-
        // priority tunnel connects, or the owner disconnects), restart dnsmasq
        // with the new owner's config.
        let dnsmasqOwner = established.first { $0.profile.dnsmasqEnabled }
        if dnsmasqOwner?.id != dnsmasqOwnerID {
            if dnsmasqProcess != nil { stopDnsmasq() }
            if let owner = dnsmasqOwner {
                do {
                    try startDnsmasq(profile: owner.profile)
                    dnsmasqOwnerID = owner.id
                    log("dnsmasq owned by '\(owner.profile.name)' (highest-priority dnsmasq profile)")
                } catch {
                    // Don't fail the tunnel over a DNS proxy: fall back to the
                    // owner's raw DNS servers and surface the problem in the log.
                    // Mark ownership anyway so every subsequent reconcile doesn't
                    // re-run the (0.5 s) launch attempt; an ownership change (the
                    // profile reconnecting, or priorities shifting) retries.
                    dnsmasqOwnerID = owner.id
                    log("dnsmasq failed to start: \(error.localizedDescription) — falling back to direct DNS servers")
                    owner.message = "dnsmasq failed: \(error.localizedDescription)"
                }
            }
        }

        // The OS resolver follows the highest-priority tunnel that wants DNS.
        guard let w = winner else {
            restoreDNS()
            return
        }
        let servers = (w.id == dnsmasqOwnerID && dnsmasqProcess != nil)
            ? ["127.0.0.1"] : w.profile.dnsServers
        applyDNS(servers: servers, searchDomains: w.profile.searchDomains, owner: w.profile.name)
    }

    // The VPN's DNS is published by overriding the *primary* network service's
    // `State:/Network/Service/<id>/DNS` key in configd's dynamic store — the key
    // the system resolver actually follows — instead of `networksetup`, which
    // edits one service picked at connect time: with Wi-Fi AND Ethernet active,
    // the primary-service election can land on (or later flip to) the service we
    // did NOT touch, silently dropping the VPN DNS mid-session. State: keys are
    // also volatile, so a helper crash or reboot can never leave the user's
    // persistent settings dirty. A dynamic-store watcher re-asserts the stamp
    // whenever the primary service changes or DHCP re-publishes a service's DNS.
    // One exception: manually-configured DNS lives in the service's persistent
    // Setup: config, which beats State: in configd's merge — for that case the
    // manual values are additionally overridden (and restored) via networksetup.

    /// Snapshots the pre-VPN resolvers once, on the first override in a
    /// connection session, to upstreamResolvConfPath so a dnsmasq config can
    /// forward to them via resolv-file=. Later winner changes must NOT
    /// re-capture (they'd snapshot a VPN's DNS as "original").
    private func captureOriginalDNSIfNeeded() {
        guard !upstreamDNSCaptured else { return }
        upstreamDNSCaptured = true
        // /etc/resolv.conf is kept in sync by configd with whatever the system
        // resolver is actually using (DHCP-assigned or manual alike).
        let content = effectiveSystemDNSServers().map { "nameserver \($0)\n" }.joined()
        FileManager.default.createFile(atPath: upstreamResolvConfPath,
                                       contents: content.data(using: .utf8),
                                       attributes: [.posixPermissions: 0o600])
    }

    /// Nameservers macOS is actually resolving through right now, read from
    /// /etc/resolv.conf (kept current by configd — covers DHCP-assigned DNS,
    /// which `networksetup -getdnsservers` cannot see).
    private func effectiveSystemDNSServers() -> [String] {
        guard let text = try? String(contentsOfFile: "/etc/resolv.conf", encoding: .utf8) else { return [] }
        return text.split(separator: "\n").compactMap { line -> String? in
            let s = line.trimmingCharacters(in: .whitespaces)
            guard s.hasPrefix("nameserver") else { return nil }
            let parts = s.split(separator: " ", maxSplits: 1).map(String.init)
            guard parts.count == 2 else { return nil }
            return parts[1].trimmingCharacters(in: .whitespaces)
        }
    }

    private func applyDNS(servers: [String], searchDomains: [String], owner: String) {
        guard !servers.isEmpty || !searchDomains.isEmpty else { return }
        if servers != desiredDNSServers || searchDomains != desiredSearchDomains {
            log("DNS override → servers \(servers), search \(searchDomains) — from '\(owner)'")
        }
        desiredDNSServers = servers
        desiredSearchDomains = searchDomains
        dnsOverrideActive = true
        enforceDNSOverride()
    }

    private func restoreDNS() {
        guard dnsOverrideActive || upstreamDNSCaptured else { return }
        if let store = dnsDynamicStore() {
            for psid in dnsTouchedServiceIDs { restoreServiceStateDNS(psid, store: store) }
        }
        for original in savedSetupDNS.values {
            let servers = original.servers.isEmpty ? ["empty"] : original.servers
            let domains = original.domains.isEmpty ? ["empty"] : original.domains
            _ = runCommand("/usr/sbin/networksetup", ["-setdnsservers", original.name] + servers)
            _ = runCommand("/usr/sbin/networksetup", ["-setsearchdomains", original.name] + domains)
        }
        if !dnsTouchedServiceIDs.isEmpty || !savedSetupDNS.isEmpty {
            log("DNS restored (services: \(dnsTouchedServiceIDs.sorted().joined(separator: ", ")))")
        }
        dnsTouchedServiceIDs = []
        savedStateDNS = [:]
        savedSetupDNS = [:]
        overriddenServiceID = nil
        desiredDNSServers = []
        desiredSearchDomains = []
        dnsOverrideActive = false
        upstreamDNSCaptured = false
    }

    /// Idempotently stamp the desired DNS onto the CURRENT primary service,
    /// un-stamping the previous primary first if the election moved. Safe to
    /// call repeatedly — the store watcher re-runs it on every network
    /// configuration change (including our own writes, which then no-op).
    private func enforceDNSOverride() {
        guard dnsOverrideActive, let store = dnsDynamicStore() else { return }
        guard let psid = primaryServiceID() else {
            log("DNS override: no primary network service right now — will re-apply when one appears")
            return
        }
        if let prev = overriddenServiceID, prev != psid {
            restoreServiceStateDNS(prev, store: store)
            log("DNS override: primary service changed (\(prev) → \(psid)) — moving override")
        }
        overriddenServiceID = psid

        let stateKey = "State:/Network/Service/\(psid)/DNS"
        let current = storeValue(stateKey)

        // The stamp we want on the service. A winner that only sets one of the
        // two must not blow away the user's other setting: an empty list falls
        // back to the service's original.
        func stampDict(original: [String: Any]?) -> [String: Any] {
            let effServers = desiredDNSServers.isEmpty
                ? (original?["ServerAddresses"] as? [String] ?? []) : desiredDNSServers
            let effDomains = desiredSearchDomains.isEmpty
                ? (original?["SearchDomains"] as? [String] ?? []) : desiredSearchDomains
            var d: [String: Any] = [:]
            if !effServers.isEmpty { d["ServerAddresses"] = effServers }
            if !effDomains.isEmpty { d["SearchDomains"] = effDomains }
            return d
        }

        if !dnsTouchedServiceIDs.contains(psid) {
            // First touch of this service this session: remember its original
            // value so restore can put it back exactly (absent key included).
            dnsTouchedServiceIDs.insert(psid)
            if let current = current { savedStateDNS[psid] = current }
        } else if let current = current {
            // The service re-published its DNS mid-session (DHCP renew, network
            // change): a live value that is neither our stamp nor the saved
            // original IS the new original — re-save it so restore doesn't
            // resurrect stale pre-VPN servers.
            let cur = current as NSDictionary
            let ours = stampDict(original: savedStateDNS[psid]) as NSDictionary
            let saved = savedStateDNS[psid].map { $0 as NSDictionary }
            if cur != ours, cur != saved {
                savedStateDNS[psid] = current
                log("DNS override: service \(psid) re-published its DNS underneath us — updated saved original")
            }
        }

        let desired = stampDict(original: savedStateDNS[psid])
        if current.map({ $0 as NSDictionary }) != desired as NSDictionary {
            if SCDynamicStoreSetValue(store, stateKey as CFString, desired as CFDictionary) {
                log("DNS set on primary service \(psid) (\(desired))")
            } else {
                log("DNS set FAILED on \(stateKey): \(String(cString: SCErrorString(SCError())))")
            }
        }
        overrideManualSetupDNS(psid)
    }

    /// Manually-configured DNS lives in the service's persistent Setup: config,
    /// which beats our State: stamp in configd's merge — override it too, via
    /// networksetup (the supported writer of Setup:), saving the original for
    /// restore. No-op for the common DHCP-DNS case (no manual Setup values).
    private func overrideManualSetupDNS(_ psid: String) {
        let setup = storeValue("Setup:/Network/Service/\(psid)/DNS")
        let curServers = setup?["ServerAddresses"] as? [String] ?? []
        let curDomains = setup?["SearchDomains"] as? [String] ?? []
        if savedSetupDNS[psid] == nil && curServers.isEmpty && curDomains.isEmpty { return }
        guard let name = storeValue("Setup:/Network/Service/\(psid)")?["UserDefinedName"] as? String else {
            log("DNS override: service \(psid) has manual DNS but no name — cannot override it via networksetup")
            return
        }
        let original = savedSetupDNS[psid]
            ?? (name: name, servers: curServers, domains: curDomains)
        if savedSetupDNS[psid] == nil { savedSetupDNS[psid] = original }

        let effServers = desiredDNSServers.isEmpty ? original.servers : desiredDNSServers
        let effDomains = desiredSearchDomains.isEmpty ? original.domains : desiredSearchDomains
        if curServers != effServers {
            _ = runCommand("/usr/sbin/networksetup",
                           ["-setdnsservers", name] + (effServers.isEmpty ? ["empty"] : effServers))
        }
        if curDomains != effDomains {
            _ = runCommand("/usr/sbin/networksetup",
                           ["-setsearchdomains", name] + (effDomains.isEmpty ? ["empty"] : effDomains))
        }
        if curServers != effServers || curDomains != effDomains {
            log("DNS override: replaced manual Setup DNS on '\(name)' (servers \(effServers), search \(effDomains))")
        }
    }

    /// Put one service's State: DNS key back to its pre-override value.
    private func restoreServiceStateDNS(_ psid: String, store: SCDynamicStore) {
        let key = "State:/Network/Service/\(psid)/DNS"
        if let original = savedStateDNS[psid] {
            if !SCDynamicStoreSetValue(store, key as CFString, original as CFDictionary) {
                log("DNS restore FAILED on \(key): \(String(cString: SCErrorString(SCError())))")
            }
        } else {
            _ = SCDynamicStoreRemoveValue(store, key as CFString)
        }
    }

    /// Fires (on the engine queue) when the primary service changes or any
    /// service's State: DNS is re-published (DHCP renew, cable plug/unplug).
    private func networkConfigurationChanged(_ changedKeys: [String]) {
        if dnsOverrideActive { enforceDNSOverride() }
        // A Global/IPv4 change means the primary path itself moved (cable
        // unplugged/plugged, Wi-Fi joined/left) — the tunnels may be dead.
        if changedKeys.contains("State:/Network/Global/IPv4") {
            schedulePathChangeRecovery()
        }
    }

    /// Watch the primary-path key and every service's DNS while any tunnel is
    /// up. On while tunnels exist (not just while a DNS override is applied):
    /// the path-change recovery must fire for DNS-less profiles too.
    private func setNetworkWatch(_ on: Bool) {
        guard let store = dnsDynamicStore() else { return }
        let keys = on ? ["State:/Network/Global/IPv4"] : []
        let patterns = on ? ["State:/Network/Service/[^/]+/DNS"] : []
        _ = SCDynamicStoreSetNotificationKeys(store, keys as CFArray, patterns as CFArray)
    }

    /// The lazily-created dynamic-store session (also delivers watch callbacks,
    /// on the engine queue).
    private func dnsDynamicStore() -> SCDynamicStore? {
        if let store = dnsStore { return store }
        var ctx = SCDynamicStoreContext(version: 0,
                                        info: Unmanaged.passUnretained(self).toOpaque(),
                                        retain: nil, release: nil, copyDescription: nil)
        let callback: SCDynamicStoreCallBack = { _, changedKeys, info in
            guard let info = info else { return }
            Unmanaged<VPNEngine>.fromOpaque(info).takeUnretainedValue()
                .networkConfigurationChanged((changedKeys as? [String]) ?? [])
        }
        guard let store = SCDynamicStoreCreate(nil, "fi.juvex.swandesk" as CFString, callback, &ctx) else {
            log("SCDynamicStoreCreate FAILED: \(String(cString: SCErrorString(SCError()))) — cannot apply DNS")
            return nil
        }
        _ = SCDynamicStoreSetDispatchQueue(store, queue)
        dnsStore = store
        return store
    }

    private func storeValue(_ key: String) -> [String: Any]? {
        guard let store = dnsDynamicStore() else { return nil }
        return SCDynamicStoreCopyValue(store, key as CFString) as? [String: Any]
    }

    /// The service whose DNS the system resolver follows.
    private func primaryServiceID() -> String? {
        storeValue("State:/Network/Global/IPv4")?["PrimaryService"] as? String
    }

    // MARK: IPv6 leak prevention

    /// True when the tunnel is full-IPv4 (includes 0.0.0.0/0) and neither the
    /// configured routes nor the assigned VIP suggest that IPv6 is carried.
    private func isFullIPv4TunnelWithoutIPv6(_ t: Tunnel) -> Bool {
        let profile = t.profile
        let effectiveRoutes = profile.routes.isEmpty
            ? [profile.remoteTS.trimmingCharacters(in: .whitespaces)]
            : profile.routes
        guard effectiveRoutes.contains("0.0.0.0/0") else { return false }
        if effectiveRoutes.contains(where: { $0.contains(":") }) { return false }
        if let v = t.vip, v.contains(":") { return false }
        return true
    }

    private func reconcileIPv6() {
        let wantDisabled = tunnels.contains {
            ($0.tun != nil || $0.vip != nil) && $0.profile.preventIPv6Leak && isFullIPv4TunnelWithoutIPv6($0)
        }
        if wantDisabled {
            // Flag, not `savedIPv6Settings.isEmpty`: when every service is
            // already Off, the saved list stays empty and the empty-check would
            // rescan all services (N slow networksetup spawns) on every reconcile.
            guard !ipv6LeakPreventionActive else { return }
            ipv6LeakPreventionActive = true
            for service in allNetworkServiceNames() {
                guard let mode = currentIPv6Mode(for: service), mode != "Off" else { continue }
                savedIPv6Settings.append((service: service, mode: mode))
                _ = runCommand("/usr/sbin/networksetup", ["-setv6off", service])
                log("IPv6 disabled on '\(service)' (was: \(mode)) — leak prevention")
            }
        } else {
            restoreIPv6()
        }
    }

    private func restoreIPv6() {
        ipv6LeakPreventionActive = false
        for (service, mode) in savedIPv6Settings {
            let flag: String
            switch mode {
            case "Automatic": flag = "-setv6automatic"
            case "LinkLocal":  flag = "-setv6LinkLocal"
            default:
                log("IPv6 restore: unrecognised saved mode '\(mode)' for '\(service)' — skipping")
                continue
            }
            let r = runCommand("/usr/sbin/networksetup", [flag, service])
            if r.status == 0 {
                log("IPv6 restored on '\(service)' → \(mode)")
            } else {
                log("IPv6 restore FAILED on '\(service)' → \(mode) (exit \(r.status)): \(r.output)")
            }
        }
        savedIPv6Settings = []
    }

    /// All enabled network service names in priority order.
    private func allNetworkServiceNames() -> [String] {
        let out = runCommand("/usr/sbin/networksetup", ["-listnetworkserviceorder"]).output
        var names: [String] = []
        var pendingName: String? = nil
        for line in out.split(separator: "\n") {
            let s = String(line).trimmingCharacters(in: .whitespaces)
            if s.hasPrefix("(*") { pendingName = nil; continue }
            if s.hasPrefix("("), let rp = s.range(of: ") ") {
                pendingName = String(s[rp.upperBound...])
                continue
            }
            if s.contains("Hardware Port"), let name = pendingName {
                names.append(name)
                pendingName = nil
            }
        }
        return names
    }

    /// Returns the current IPv6 mode string for a service (e.g. "Automatic", "Off",
    /// "LinkLocal") by parsing `networksetup -getinfo` output.
    private func currentIPv6Mode(for service: String) -> String? {
        let out = runCommand("/usr/sbin/networksetup", ["-getinfo", service]).output
        for line in out.split(separator: "\n") {
            let s = String(line).trimmingCharacters(in: .whitespaces)
            if s.hasPrefix("IPv6: ") {
                return String(s.dropFirst("IPv6: ".count)).trimmingCharacters(in: .whitespaces)
            }
        }
        return nil
    }

    // MARK: Kill switch (pf)

    /// Load/refresh or remove the kill-switch pf ruleset based on the tunnel set.
    /// Active whenever ANY active tunnel has the kill switch enabled: everything
    /// outbound is blocked except loopback, the tunnels themselves, IKE/ESP to the
    /// gateways (so auto-reconnect still works) and DHCP. The rules stay loaded
    /// while a kill-switch tunnel is reconnecting — that's the whole point — and
    /// are removed only when the last kill-switch tunnel is stopped by the user.
    private func reconcileKillSwitch() {
        let wantActive = tunnels.contains { $0.profile.killSwitchEnabled }
        guard wantActive else {
            deactivateKillSwitch()
            return
        }

        var rules = ["pass out quick on lo0 all"]
        for t in tunnels {
            if let tun = t.tun {
                rules.append("pass out quick on \(tun) all")
            }
            // Prefer the gateway IP resolved from the live SA — re-resolving a
            // hostname while the kill switch is blocking DNS would fail.
            let gw = t.remoteIP ?? t.profile.remoteAddr
            if !gw.isEmpty {
                rules.append("pass out quick proto udp from any to \(gw) port {500, 4500}")
                rules.append("pass out quick proto esp from any to \(gw)")
            }
        }
        rules.append("pass out quick proto udp from any port 68 to any port 67")  // DHCP renewal
        rules.append("block drop out quick all")
        let ruleset = rules.joined(separator: "\n") + "\n"

        let rulesPath = "\(stagingRoot)/killswitch.pf.conf"
        do {
            try FileManager.default.createDirectory(atPath: stagingRoot, withIntermediateDirectories: true)
            try write(ruleset, to: rulesPath, perms: 0o600)
        } catch {
            log("kill switch: failed to write ruleset: \(error.localizedDescription)")
            return
        }
        let load = runCommand("/sbin/pfctl", ["-a", pfAnchor, "-f", rulesPath])
        if load.status != 0 {
            log("kill switch: pfctl load FAILED (exit \(load.status)): \(load.output)")
            // Surface it — the user believes they're leak-protected otherwise.
            for t in tunnels where t.profile.killSwitchEnabled {
                t.message = "Kill switch failed to engage — see log"
            }
            return
        }
        if pfToken == nil {
            // Reference-counted enable; the token releases exactly our reference
            // on disable, so we never turn pf off under another subsystem.
            let enable = runCommand("/sbin/pfctl", ["-E"])
            for line in enable.output.split(separator: "\n") {
                let s = String(line)
                if let r = s.range(of: "Token : ") {
                    pfToken = String(s[r.upperBound...]).trimmingCharacters(in: .whitespaces)
                }
            }
            if pfToken == nil {
                log("kill switch: could not parse pf enable token (output: \(enable.output)) — pf may already be enabled")
            }
        }
        if !killSwitchLoaded {
            killSwitchLoaded = true
            log("kill switch ENGAGED (pf anchor \(pfAnchor))")
        }
    }

    private func deactivateKillSwitch() {
        guard killSwitchLoaded || pfToken != nil else { return }
        _ = runCommand("/sbin/pfctl", ["-a", pfAnchor, "-F", "all"])
        if let token = pfToken {
            _ = runCommand("/sbin/pfctl", ["-X", token])
            pfToken = nil
        }
        killSwitchLoaded = false
        log("kill switch disengaged")
    }

    // MARK: Keepalive

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

    private func keepaliveTick(_ t: Tunnel) {
        // Run the (up to 6 s) ping off the engine queue: N tunnels share that
        // serial queue, and blocking it per tick would stall VICI events and
        // user connect/disconnects behind pings on a degraded network.
        guard !t.keepalivePingInFlight else { return }
        t.keepalivePingInFlight = true
        let ip = t.profile.keepaliveIP
        DispatchQueue.global(qos: .utility).async { [weak self, weak t] in
            guard let self else { return }
            let r = self.runCommand("/sbin/ping", ["-s", "1", "-W", "3", "-c", "1", ip], timeout: 6)
            self.queue.async { [weak self, weak t] in
                guard let self, let t, self.tunnels.contains(where: { $0 === t }) else { return }
                t.keepalivePingInFlight = false
                if r.status == 0 {
                    t.keepaliveFailures = 0
                    return
                }
                t.keepaliveFailures += 1
                self.log("keepalive ping failed for '\(t.profile.name)' (\(t.keepaliveFailures)/8)")
                if t.keepaliveFailures >= 8 {
                    self.reconnectInPlace(t, reason: "keepalive failed 8×")
                }
            }
        }
    }

    private func stopKeepalive(_ t: Tunnel) {
        t.keepaliveTimer?.cancel()
        t.keepaliveTimer = nil
    }

    /// Re-establish one tunnel without restarting charon: terminate its IKE SA,
    /// re-initiate (which lands on a fresh utun), and reconcile routes/DNS for the
    /// whole tunnel set. Used by the keepalive failure path, wake recovery,
    /// path-change recovery and VICI-event auto-reconnect. While this tunnel is down, an
    /// overlapping lower-priority tunnel temporarily takes over its routes —
    /// they come back here once the reconnect succeeds.
    private func reconnectInPlace(_ t: Tunnel, reason: String) {
        guard let swanctlBin = locateSwanctl(), let charonBin = locateCharon() else { return }
        t.keepaliveFailures = 0
        t.lastAutoReconnectAttempt = Date()
        log("reconnecting '\(t.profile.name)' (\(reason))")
        setState(t, .reconnecting, "Reconnecting…")
        _ = swanctl(swanctlBin, ["--terminate", "--ike", t.connName], timeout: 4)
        t.vip = nil
        t.tun = nil
        reconcileNetworking()   // drop the dead utun's routes (others may take over)
        do {
            // A crashed/killed charon would fail every retry forever — relaunch
            // it and reload the still-staged creds/conns so the retry can succeed.
            if try ensureCharonRunning(charonBin) {
                let creds = swanctl(swanctlBin, ["--load-creds"])
                if creds.status != 0 { throw EngineError("swanctl --load-creds failed (\(creds.status))") }
                let conns = swanctl(swanctlBin, ["--load-conns"])
                if conns.status != 0 { throw EngineError("swanctl --load-conns failed (\(conns.status))") }
            }
            try initiate(t, swanctlBin: swanctlBin)
            reconcileNetworking()
            setState(t, .connected, "Connected")
        } catch {
            log("reconnect failed for '\(t.profile.name)': \(error.localizedDescription)")
            // The failed initiate() may have left a half-established IKE_SA (Phase 1
            // up, Phase 2 rejected) running in charon — tear it down. The watchdog
            // keeps retrying on a slow cadence (the tunnel is still user-intended-
            // up, and with a kill switch engaged nothing else will unblock traffic).
            _ = swanctl(swanctlBin, ["--terminate", "--ike", t.connName], timeout: 4)
            setState(t, .error, "Reconnect failed: \(error.localizedDescription)")
        }
    }

    // MARK: Sleep/wake recovery

    /// Register for system power events so the tunnels recover 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: give the network a moment to reassociate, then reconnect every
    /// tunnel whose SA didn't survive the sleep.
    private func recoverAfterWake() {
        guard !tunnels.isEmpty else { return }   // no active tunnel — nothing to do
        log("system woke — verifying tunnels")
        Thread.sleep(forTimeInterval: 2.0)
        for t in tunnels {
            t.keepaliveFailures = 0
            if tunnelHealthy(t) {
                log("'\(t.profile.name)' healthy after wake")
            } else {
                reconnectInPlace(t, reason: "woke from sleep")
            }
        }
    }

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

    // MARK: Teardown

    /// Stop ONE tunnel (user intent): terminate its SA, unstage its config, hand
    /// its routes/DNS to the remaining tunnels — or, if it was the last one, tear
    /// the whole stack down and restore networking.
    private func stopTunnel(_ t: Tunnel) {
        stopKeepalive(t)
        if let swanctlBin = locateSwanctl() {
            _ = swanctl(swanctlBin, ["--terminate", "--ike", t.connName], timeout: 4)
        }
        unstageProfile(t)
        tunnels.removeAll { $0 === t }

        if tunnels.isEmpty {
            teardownAll(restoreNetworking: true)
        } else if let swanctlBin = locateSwanctl() {
            // Sync charon with the shrunk config: --load-conns unloads the removed
            // connection, --load-creds --clear drops its key + secrets.
            _ = swanctl(swanctlBin, ["--load-creds", "--clear"])
            _ = swanctl(swanctlBin, ["--load-conns"])
            reconcileNetworking()
        }
        pushStatus()
        log("stopped tunnel '\(t.profile.name)'")
    }

    /// Full teardown (user intent — kill switch comes off too).
    private func teardownAll(restoreNetworking: Bool) {
        stopReconnectWatchdog()
        setNetworkWatch(false)
        for t in tunnels { stopKeepalive(t) }

        // Restore local networking first so the user's connectivity returns
        // immediately, before the (bounded) network teardown.
        removeAllRoutes()
        stopDnsmasq()
        deactivateKillSwitch()
        if restoreNetworking { restoreIPv6(); restoreDNS() }
        underlyingDefaultGateway = nil

        // Politely tell each peer its SA is going away. Bounded so a slow/
        // unreachable gateway can't stall the disconnect.
        if let swanctlBin = locateSwanctl() {
            for t in tunnels {
                _ = swanctl(swanctlBin, ["--terminate", "--ike", t.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

        stopCharon()
        dnsmasqOutput?.readabilityHandler = nil
        dnsmasqOutput = nil

        // Wipe the staging dir (removes the private keys + secrets from disk).
        try? FileManager.default.removeItem(atPath: stagingRoot)
        tunnels.removeAll()
    }

    // 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 }
}
