// SPDX-License-Identifier: GPL-3.0-only

import Foundation
import Network
import NetworkExtension
import OSLog

private let packetTunnelErrorDomain = "net.hivetechnology.o20010.06.packet-tunnel"

final class PacketTunnelProvider: NEPacketTunnelProvider {
    private let logger = Logger(subsystem: "net.hivetechnology.o20010.06", category: "packet-tunnel")
    private let api = MobileVpnAPI()
    private let store = SecureSessionStore()
    private let nativeQueue = DispatchQueue(label: "net.hivetechnology.o20010.06.n3n", qos: .userInitiated)
    private let stateQueue = DispatchQueue(label: "net.hivetechnology.o20010.06.state")
    private let monitorQueue = DispatchQueue(label: "net.hivetechnology.o20010.06.network-monitor")
    private var edge = NativeEdge()
    private var configuration: VpnConfiguration?
    private var credentials: SessionCredentials?
    private var controlSocket: WebSocketChannel?
    private var runtimeSocket: TunnelWebSocketChannel?
    private var controlHeartbeatTask: Task<Void, Never>?
    private var controlReconnectTask: Task<Void, Never>?
    private var healthTask: Task<Void, Never>?
    private var reconnectTask: Task<Void, Never>?
    private var networkMonitor: NWPathMonitor?
    private var networkWasUnavailable = false
    private var running = false
    private var reconnecting = false
    private var controlReconnecting = false
    private var localTerminationInProgress = false
    private var remoteTerminationHandled = false
    private var tunnelPhase = "idle"
    private var includedRouteDescriptions: [String] = []
    private var skippedRouteDescriptions: [String] = []
    private var runtimeConnected = false
    private var diagnosticError: String?
    private var resolvedSupernodes: [String] = []
    private var currentTransportAttemptIndex = -1
    private var remoteObservedPath = "unknown"
    private var remoteObservedPathAt = Date.distantPast
    private var hasEverRuntimeConnected = false

    override func startTunnel(options: [String : NSObject]? = nil,
                              completionHandler: @escaping (Error?) -> Void) {
        stateQueue.sync {
            localTerminationInProgress = false
            remoteTerminationHandled = false
        }
        Task {
            var startCompleted = false
            do {
                updateTunnelPhase("starting")
                logger.info("Starting packet tunnel")
                let credentials = try loadCredentials(options: options)
                self.credentials = credentials
                try await openControlSocket(credentials)
                updateTunnelPhase("waiting-runtime")
                logger.info("Control socket connected")
                _ = try await api.start(credentials)
                let configuration = try await waitForConfiguration(credentials)
                self.configuration = configuration
                logger.info("VPN configuration received")
                _ = try await api.update(credentials, status: .connecting, transport: "unknown")
                let supernodes = try await applyNetworkSettings(configuration)
                self.resolvedSupernodes = supernodes
                updateTunnelPhase("connecting-n3n")
                logger.info("Network settings applied")
                let latency = try await establishRuntimePath(
                    configuration,
                    credentials: credentials,
                    supernodes: supernodes,
                    startAt: 0
                ) {
                    self.setRunning(true)
                    self.startPacketPumps()
                    startCompleted = true
                    completionHandler(nil)
                }
                logger.info("Runtime socket connected in \(latency, privacy: .public) ms")
                _ = try await api.update(credentials, status: .connected, transport: edge.transport,
                    quality: "good", latencyMs: latency)
                setRuntimeConnected(true)
                updateTunnelPhase("connected")
                startHealthLoop()
                startNetworkMonitor()
            } catch {
                let reportedError = providerError(error)
                setDiagnosticError(reportedError.localizedDescription)
                updateTunnelPhase("failed")
                beginLocalTermination()
                reasserting = false
                logger.error("Packet tunnel failed: \(reportedError.localizedDescription, privacy: .public) [\(reportedError.code, privacy: .public)]")
                if let credentials {
                    _ = try? await api.update(credentials, status: .failed, transport: edge.transport,
                        errorCode: "ERR_MOBILE_VPN_IOS_\(reportedError.code)",
                        errorMessage: reportedError.localizedDescription)
                }
                cleanup()
                if startCompleted { cancelTunnelWithError(reportedError) }
                else { completionHandler(reportedError) }
            }
        }
    }

    private func loadCredentials(options: [String: NSObject]?) throws -> SessionCredentials {
        if let data = options?["sessionCredentials"] as? Data {
            guard let value = try? JSONDecoder().decode(SessionCredentials.self, from: data) else {
                throw TunnelError.invalidSessionOptions
            }
            try store.save(value)
            return value
        }
        guard let value = try store.load() else { throw TunnelError.missingSession }
        return value
    }

    private func providerError(_ error: Error) -> NSError {
        if let value = error as? TunnelError { return value.nsError }
        let source = error as NSError
        if source.domain == packetTunnelErrorDomain { return source }
        let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
        return NSError(
            domain: packetTunnelErrorDomain,
            code: 2000,
            userInfo: [
                NSLocalizedDescriptionKey: message.isEmpty
                    ? L10n.text("error.vpn_internal", fallback: "Internal VPN tunnel error")
                    : message,
                "sourceDomain": source.domain,
                "sourceCode": source.code,
            ]
        )
    }

    override func stopTunnel(with reason: NEProviderStopReason,
                             completionHandler: @escaping () -> Void) {
        logger.info("Stopping packet tunnel (reason=\(reason.rawValue, privacy: .public))")
        beginLocalTermination()
        let current = credentials
        cleanup()
        store.clear()
        completionHandler()
        logger.info("Packet tunnel stopped locally")
        if let current {
            Task { try? await api.stop(current) }
        }
    }

    override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) {
        guard String(data: messageData, encoding: .utf8) == "diagnostics" else {
            completionHandler?(nil)
            return
        }
        let state = stateQueue.sync {
            TunnelDiagnostics(
                phase: tunnelPhase,
                transport: edge.transport,
                assignedAddress: configuration?.overlay.address,
                runtimeAddress: configuration.map { "\($0.runtime.host):\($0.runtime.port)" },
                includedRoutes: includedRouteDescriptions,
                skippedRoutes: skippedRouteDescriptions,
                packetSummary: edge.packetDiagnostics,
                runtimeConnected: runtimeConnected,
                lastError: diagnosticError
            )
        }
        completionHandler?(try? JSONEncoder().encode(state))
    }

    override func sleep(completionHandler: @escaping () -> Void) {
        completionHandler()
    }

    override func wake() {
        requestReconnect(reason: "wake")
    }

    private func waitForConfiguration(_ credentials: SessionCredentials) async throws -> VpnConfiguration {
        for _ in 0..<120 {
            do {
                let value = try await api.configuration(credentials)
                try validate(value, credentials: credentials)
                return value
            }
            catch let error as MobileVpnAPIError where error.code == "ERR_MOBILE_VPN_CONFIG_NOT_READY" {
                try await Task.sleep(for: .seconds(1))
            }
        }
        throw TunnelError.runtimeTimeout
    }

    private func validate(_ configuration: VpnConfiguration, credentials: SessionCredentials) throws {
        guard (configuration.configVersion == 1 || configuration.configVersion == 2),
              configuration.sessionId == credentials.sessionId,
              configuration.n3n.version == "n3n-v3",
              !configuration.n3n.supernodeUrls.isEmpty,
              !configuration.n3n.community.isEmpty,
              !configuration.n3n.key.isEmpty,
              IPv4Routing.network(configuration.overlay.address, prefix: configuration.overlay.prefixLength) != nil,
              IPv4Routing.network(configuration.overlay.serverAddress, prefix: 32) != nil,
              (1...65_535).contains(configuration.runtime.port) else {
            throw TunnelError.invalidConfiguration
        }
    }

    private func applyNetworkSettings(_ configuration: VpnConfiguration) async throws -> [String] {
        guard let overlay = IPv4Routing.network(configuration.overlay.address,
                                                prefix: configuration.overlay.prefixLength) else {
            throw TunnelError.invalidConfiguration
        }
        let physical = IPv4Routing.physicalNetworks()
        let physicalDescription = physical.map {
            "\($0.interfaceName)=\($0.network.addressText)/\($0.network.prefix)"
        }.joined(separator: ", ")
        logger.info("Detected local IPv4 networks: \(physicalDescription, privacy: .public)")
        if let conflict = physical.first(where: { $0.network.overlaps(overlay) }) {
            throw NSError(
                domain: packetTunnelErrorDomain,
                code: TunnelError.overlayConflict.rawValue,
                userInfo: [
                    NSLocalizedDescriptionKey:
                        L10n.text(
                            "error.overlay_conflict_detail",
                            fallback: "VPN network %@/%d is already used by local network %@/%d on %@",
                            overlay.addressText,
                            overlay.prefix,
                            conflict.network.addressText,
                            conflict.network.prefix,
                            conflict.interfaceName
                        )
                ]
            )
        }

        let resolvedSupernodes = configuration.n3n.supernodeUrls.flatMap(IPv4Routing.resolvedEndpoints)
        guard !resolvedSupernodes.isEmpty else { throw TunnelError.supernodeResolution }
        let supernodeAddresses = resolvedSupernodes.compactMap { URL(string: "udp://\($0)")?.host }
        guard let tunnelServerAddress = supernodeAddresses.first else {
            throw TunnelError.supernodeResolution
        }

        let ipv4 = NEIPv4Settings(addresses: [configuration.overlay.address], subnetMasks: [overlay.maskText])
        var included = Set([overlay])
        var skipped: [String] = []
        for route in configuration.routes {
            guard let candidate = IPv4Routing.network(route.ip, mask: route.mask) else { continue }
            if physical.contains(where: { $0.network.overlaps(candidate) }) {
                skipped.append("\(candidate.addressText)/\(candidate.prefix)")
            } else {
                included.insert(candidate)
            }
        }
        ipv4.includedRoutes = included.map { NEIPv4Route(destinationAddress: $0.addressText, subnetMask: $0.maskText) }
        ipv4.excludedRoutes = Set(supernodeAddresses).map {
            NEIPv4Route(destinationAddress: $0, subnetMask: "255.255.255.255")
        }
        let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: tunnelServerAddress)
        settings.mtu = NSNumber(value: configuration.overlay.mtu)
        settings.ipv4Settings = ipv4
        try await setTunnelNetworkSettings(settings)
        let routeDescriptions = included
            .map { "\($0.addressText)/\($0.prefix)" }
            .sorted()
        stateQueue.sync {
            includedRouteDescriptions = routeDescriptions
            skippedRouteDescriptions = skipped.sorted()
        }
        return resolvedSupernodes
    }

    private func startPacketPumps() {
        readFromPacketFlow()
        nativeQueue.async { [weak self] in
            while self?.isRunning() == true {
                guard let self else { return }
                if let packet = self.edge.receive() {
                    let accepted: Bool
                    if #available(iOS 17.0, *) {
                        accepted = self.packetFlow.writePacketObjects([
                            NEPacket(data: packet, protocolFamily: sa_family_t(AF_INET))
                        ])
                    } else {
                        accepted = self.packetFlow.writePackets(
                            [packet], withProtocols: [NSNumber(value: AF_INET)])
                    }
                    self.edge.recordInjection(accepted: accepted)
                    if !accepted {
                        self.logger.error("PacketFlow rejected an inbound IPv4 packet")
                    }
                } else {
                    Thread.sleep(forTimeInterval: 0.01)
                }
            }
        }
    }

    private func readFromPacketFlow() {
        packetFlow.readPackets { [weak self] packets, protocols in
            guard let self, isRunning() else { return }
            for (index, packet) in packets.enumerated()
            where index < protocols.count && protocols[index].int32Value == AF_INET {
                edge.send(packet)
            }
            readFromPacketFlow()
        }
    }

    private func openControlSocket(_ credentials: SessionCredentials) async throws {
        guard let url = URL(string: credentials.websocketUrl) else { throw TunnelError.invalidConfiguration }
        var request = URLRequest(url: url)
        request.setValue("Bearer \(credentials.token)", forHTTPHeaderField: "Authorization")
        request.setValue(credentials.sessionId, forHTTPHeaderField: "X-HT-Mobile-VPN-Session")
        let socket = WebSocketChannel(request: request)
        socket.onMessage = { [weak self] text in
            self?.handleControlMessage(text, expectedSessionId: credentials.sessionId)
        }
        do { try await socket.connect(timeout: 12) }
        catch {
            socket.close()
            throw NSError(
                domain: packetTunnelErrorDomain,
                code: TunnelError.controlSocket.rawValue,
                userInfo: [
                    NSLocalizedDescriptionKey: L10n.text(
                        "error.control_socket_detail",
                        fallback: "The VPN control channel cannot be reached: %@",
                        error.localizedDescription
                    ),
                    NSUnderlyingErrorKey: error,
                ]
            )
        }
        socket.onDisconnect = { [weak self] in self?.requestControlReconnect() }
        controlSocket = socket
        controlHeartbeatTask?.cancel()
        controlHeartbeatTask = Task { [weak self] in
            while !Task.isCancelled {
                do { try await socket.send("{\"type\":\"heartbeat\"}") }
                catch {
                    self?.requestControlReconnect()
                    return
                }
                try? await Task.sleep(for: .seconds(20))
            }
        }
    }

    private func handleControlMessage(_ text: String, expectedSessionId: String) {
        guard let data = text.data(using: .utf8),
              let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              object["command"] as? String == "MOBILE_VPN_SESSION_EVENT",
              let session = object["session"] as? [String: Any],
              session["id"] as? String == expectedSessionId,
              let status = session["status"] as? String,
              ["stopped", "failed", "expired"].contains(status) else { return }

        let accepted = stateQueue.sync { () -> Bool in
            if localTerminationInProgress || remoteTerminationHandled { return false }
            remoteTerminationHandled = true
            return true
        }
        guard accepted else { return }

        let serverCode = (session["errorCode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
        let serverMessage = (session["errorMessage"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
        let fallback = status == "expired"
            ? L10n.text("error.session_expired", fallback: "The VPN session has expired")
            : L10n.text(
                "error.session_closed_portal",
                fallback: "The VPN session was closed by the portal"
            )
        let message = serverMessage?.isEmpty == false ? serverMessage! : fallback
        logger.error("Remote VPN session ended: status=\(status, privacy: .public), code=\(serverCode ?? "none", privacy: .public), message=\(message, privacy: .public)")
        cancelTunnelWithError(NSError(
            domain: packetTunnelErrorDomain,
            code: TunnelError.remoteStopped.rawValue,
            userInfo: [
                NSLocalizedDescriptionKey: message,
                "sessionStatus": status,
                "serverErrorCode": serverCode ?? "",
            ]
        ))
    }

    private func requestControlReconnect() {
        let accepted = stateQueue.sync { () -> Bool in
            if controlReconnecting || credentials == nil { return false }
            controlReconnecting = true
            return true
        }
        guard accepted else { return }
        controlReconnectTask = Task { [weak self] in
            guard let self else { return }
            defer { stateQueue.sync { self.controlReconnecting = false } }
            for attempt in 1...8 {
                guard !Task.isCancelled, let credentials else { return }
                controlHeartbeatTask?.cancel()
                controlSocket?.close()
                controlSocket = nil
                do {
                    try await openControlSocket(credentials)
                    return
                } catch {
                    try? await Task.sleep(for: .seconds(min(attempt, 5)))
                }
            }
            cancelTunnelWithError(TunnelError.controlSocket)
        }
    }

    private func establishRuntimePath(
        _ configuration: VpnConfiguration,
        credentials: SessionCredentials,
        supernodes: [String],
        startAt startIndex: Int,
        onTunnelReady: (() -> Void)? = nil
    ) async throws -> Int {
        let attemptCount = NativeEdge.attemptCount(
            configuration: configuration,
            supernodes: supernodes)
        var nextAttemptIndex = max(0, startIndex)
        var lastError: Error = TunnelError.runtimeSocket

        while nextAttemptIndex < attemptCount {
            try Task.checkCancellation()
            updateTunnelPhase(hasEverConnected() ? "connected" : "connecting-n3n")
            let selectedIndex = try await edge.connect(
                configuration: configuration,
                supernodes: supernodes,
                startAt: nextAttemptIndex)
            stateQueue.sync { currentTransportAttemptIndex = selectedIndex }
            logger.info(
                "n3n edge connected using \(self.edge.transport, privacy: .public); \(self.edge.packetDiagnostics, privacy: .public)")

            if !isRunning() {
                onTunnelReady?()
            }
            updateTunnelPhase(hasEverConnected() ? "connected" : "checking-runtime")

            do {
                let latency = try await connectRuntime(configuration, maxAttempts: 2)
                try await verifyP2pPath()
                armRuntimeSocket()
                return latency
            } catch {
                lastError = error
                logger.warning(
                    "VPN transport \(self.edge.transport, privacy: .public) did not provide a stable runtime path: \(error.localizedDescription, privacy: .public)")
                runtimeSocket?.close()
                runtimeSocket = nil
                setRuntimeConnected(false)
                edge.stop()
                nextAttemptIndex = selectedIndex + 1
                guard nextAttemptIndex < attemptCount else { throw lastError }
                let nextMode = transportMode(
                    at: nextAttemptIndex,
                    configuration: configuration,
                    supernodes: supernodes)
                _ = try? await api.update(
                    credentials,
                    status: .reconnecting,
                    transport: nextMode,
                    quality: "weak")
                setDiagnosticError(L10n.text(
                    "state.switching_transport",
                    fallback: "Switching VPN transport to %@",
                    localizedTransport(nextMode)))
            }
        }
        throw lastError
    }

    private func connectRuntime(_ configuration: VpnConfiguration, maxAttempts: Int = 1) async throws -> Int {
        let runtime = configuration.runtime
        let scheme = runtime.secure ? "wss" : "ws"
        guard let url = URL(string: "\(scheme)://\(runtime.host):\(runtime.port)\(runtime.path)") else {
            throw TunnelError.invalidConfiguration
        }
        guard #available(iOS 18.0, *) else {
            throw NSError(domain: packetTunnelErrorDomain, code: TunnelError.runtimeSocket.rawValue,
                userInfo: [NSLocalizedDescriptionKey: L10n.text(
                    "error.ios_version",
                    fallback: "The VPN connection requires iOS 18 or later"
                )])
        }
        var lastError: Error = TunnelError.runtimeSocket
        let attempts = max(1, maxAttempts)
        for attempt in 1...attempts {
            try Task.checkCancellation()
            let clock = ContinuousClock()
            let started = clock.now
            let socket = try TunnelWebSocketChannel(provider: self, url: url,
                fingerprint: runtime.tls.certificateSha256, caCertificatePem: runtime.tls.caCertificatePem)
            socket.onText = { [weak self] text in self?.handleRuntimePathMessage(text) }
            resetRemotePathObservation()
            do {
                try await socket.connect(timeout: 8)
                runtimeSocket = socket
                let duration = started.duration(to: clock.now).components
                return Int(duration.seconds * 1_000 + duration.attoseconds / 1_000_000_000_000_000)
            } catch {
                lastError = error
                socket.close()
                let errorDetail = error.localizedDescription
                let packetDetail = edge.packetDiagnostics
                logger.error("Runtime socket attempt \(attempt, privacy: .public)/\(attempts, privacy: .public) failed: \(errorDetail, privacy: .public); n3n packets \(packetDetail, privacy: .public)")
                if attempt < attempts { try await Task.sleep(for: .seconds(min(attempt, 2))) }
            }
        }
        throw NSError(
            domain: packetTunnelErrorDomain,
            code: TunnelError.runtimeSocket.rawValue,
            userInfo: [
                NSLocalizedDescriptionKey: L10n.text(
                    "error.runtime_diagnostics",
                    fallback: "The runtime is not responding through the VPN: %@. n3n diagnostics: %@",
                    lastError.localizedDescription,
                    edge.packetDiagnostics
                ),
                NSUnderlyingErrorKey: lastError,
                "packetDiagnostics": edge.packetDiagnostics,
            ]
        )
    }

    private func verifyP2pPath() async throws {
        guard edge.transport == "udp-p2p" || edge.transport == "udp-adaptive" else { return }
        var confirmations = 0
        var relayObservations = 0
        for check in 0...6 {
            try Task.checkCancellation()
            let localPath = edge.peerPath
            let remotePath = freshRemotePath()
            if localPath == "relay" || remotePath == "relay" {
                relayObservations += 1
                confirmations = 0
            } else if localPath == "p2p" && remotePath == "p2p" {
                confirmations += 1
                if confirmations >= 2 { return }
            } else {
                confirmations = 0
            }
            if check < 6 { try await Task.sleep(for: .seconds(2)) }
        }
        if relayObservations >= 2 {
            edge.markObservedRelay()
            logger.info("n3n is carrying the working VPN through its UDP relay")
            return
        }
        logger.warning("P2P path verification was inconclusive; keeping the working VPN edge")
    }

    private func armRuntimeSocket() {
        runtimeSocket?.onDisconnect = { [weak self] in
            self?.requestReconnect(reason: "runtime-socket")
        }
    }

    private func handleRuntimePathMessage(_ text: String) {
        guard let data = text.data(using: .utf8),
              let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              message["type"] as? String == "vpn-path-status",
              [1, 2].contains((message["version"] as? NSNumber)?.intValue ?? 0),
              let path = message["observedPath"] as? String,
              ["p2p", "relay", "unknown"].contains(path) else { return }
        stateQueue.sync {
            remoteObservedPath = path
            remoteObservedPathAt = Date()
        }
    }

    private func resetRemotePathObservation() {
        stateQueue.sync {
            remoteObservedPath = "unknown"
            remoteObservedPathAt = .distantPast
        }
    }

    private func freshRemotePath() -> String {
        stateQueue.sync {
            Date().timeIntervalSince(remoteObservedPathAt) <= 5 ? remoteObservedPath : "unknown"
        }
    }

    private func transportMode(
        at index: Int,
        configuration: VpnConfiguration,
        supernodes: [String]
    ) -> String {
        let supported = ["udp-adaptive", "udp-p2p", "udp-relay", "tcp-relay"]
        let configured = configuration.n3n.transportOrder ?? []
        let selected = configured.reduce(into: [String]()) { result, mode in
            if supported.contains(mode), !result.contains(mode) { result.append(mode) }
        }
        let order = selected.isEmpty ? ["udp-adaptive"] : selected
        let count = max(1, Set(supernodes).count)
        return order[min(index / count, order.count - 1)]
    }

    private func localizedTransport(_ mode: String) -> String {
        switch mode {
        case "udp-adaptive":
            L10n.text("diagnostics.transport.udp-adaptive", fallback: "Automatic UDP")
        case "udp-relay":
            L10n.text("diagnostics.transport.udp-relay", fallback: "UDP relay")
        case "tcp-relay":
            L10n.text("diagnostics.transport.tcp-relay", fallback: "TCP relay")
        default:
            L10n.text("diagnostics.transport.udp-p2p", fallback: "UDP peer-to-peer")
        }
    }

    private func startHealthLoop() {
        healthTask?.cancel()
        healthTask = Task { [weak self] in
            while !Task.isCancelled {
                // The runtime closes idle sockets after 10 seconds. Keep a wide
                // margin so scheduling jitter cannot race its read deadline.
                try? await Task.sleep(for: .seconds(5))
                guard let self, let socket = runtimeSocket else { continue }
                do {
                    try await socket.ping()
                } catch { requestReconnect(reason: "heartbeat") }
            }
        }
    }

    private func startNetworkMonitor() {
        let monitor = NWPathMonitor()
        monitor.pathUpdateHandler = { [weak self] path in
            guard let self else { return }
            if path.status == .satisfied {
                let shouldReconnect = networkWasUnavailable
                networkWasUnavailable = false
                if shouldReconnect { requestReconnect(reason: "network-recovered") }
            } else {
                networkWasUnavailable = true
            }
        }
        monitor.start(queue: monitorQueue)
        networkMonitor = monitor
    }

    private func requestReconnect(reason: String) {
        let accepted = stateQueue.sync { () -> Bool in
            if reconnecting || !running { return false }
            reconnecting = true
            return true
        }
        guard accepted else { return }
        logger.warning("Reconnecting VPN after \(reason, privacy: .public)")
        reconnectTask = Task { [weak self] in
            guard let self else { return }
            reasserting = true
            setRuntimeConnected(false)
            updateTunnelPhase(hasEverConnected() ? "connected" : "reconnecting")
            defer {
                reasserting = false
                self.stateQueue.sync { self.reconnecting = false }
                reconnectTask = nil
            }
            guard let configuration, let credentials else { return }
            _ = try? await api.update(credentials, status: .reconnecting, transport: edge.transport, quality: "weak")
            runtimeSocket?.close()
            runtimeSocket = nil
            do {
                var latency: Int
                let restartEdge = reason == "network-recovered" || reason == "wake"
                if restartEdge {
                    let previousTransport = edge.transport
                    edge.stop()
                    let supernodes = try await applyNetworkSettings(configuration)
                    resolvedSupernodes = supernodes
                    let restartIndex = NativeEdge.firstAttemptIndex(
                        configuration: configuration,
                        supernodes: supernodes,
                        transport: previousTransport)
                    latency = try await establishRuntimePath(
                        configuration,
                        credentials: credentials,
                        supernodes: supernodes,
                        startAt: restartIndex)
                } else {
                    while true {
                        try Task.checkCancellation()
                        do {
                            latency = try await connectRuntime(configuration, maxAttempts: 2)
                            try await verifyP2pPath()
                            armRuntimeSocket()
                            break
                        } catch {
                            setDiagnosticError(error.localizedDescription)
                            logger.warning(
                                "Runtime-only reconnect failed; keeping adaptive n3n edge active: \(error.localizedDescription, privacy: .public)")
                            runtimeSocket?.close()
                            runtimeSocket = nil
                            try await Task.sleep(for: .seconds(2))
                        }
                    }
                }
                try Task.checkCancellation()
                _ = try await api.update(credentials, status: .connected, transport: edge.transport,
                    quality: "good", latencyMs: latency)
                setRuntimeConnected(true)
                updateTunnelPhase("connected")
                logger.info("VPN reconnected after \(reason, privacy: .public)")
            } catch {
                if Task.isCancelled { return }
                setDiagnosticError(error.localizedDescription)
                cancelTunnelWithError(error)
            }
        }
    }

    private func cleanup() {
        stateQueue.sync {
            running = false
            reconnecting = false
            controlReconnecting = false
            runtimeConnected = false
            hasEverRuntimeConnected = false
            currentTransportAttemptIndex = -1
            remoteObservedPath = "unknown"
            remoteObservedPathAt = .distantPast
        }
        healthTask?.cancel()
        healthTask = nil
        reconnectTask?.cancel()
        reconnectTask = nil
        controlHeartbeatTask?.cancel()
        controlHeartbeatTask = nil
        controlReconnectTask?.cancel()
        controlReconnectTask = nil
        networkMonitor?.cancel()
        networkMonitor = nil
        runtimeSocket?.close()
        runtimeSocket = nil
        controlSocket?.close()
        controlSocket = nil
        nativeQueue.sync { edge.stop() }
        configuration = nil
        credentials = nil
        resolvedSupernodes = []
        networkWasUnavailable = false
    }

    private func updateTunnelPhase(_ phase: String) {
        stateQueue.sync {
            tunnelPhase = phase
            if phase != "failed" { diagnosticError = nil }
        }
    }

    private func setRuntimeConnected(_ value: Bool) {
        stateQueue.sync {
            runtimeConnected = value
            if value { hasEverRuntimeConnected = true }
        }
    }

    private func hasEverConnected() -> Bool {
        stateQueue.sync { hasEverRuntimeConnected }
    }

    private func setDiagnosticError(_ value: String?) {
        stateQueue.sync { diagnosticError = value }
    }

    private func beginLocalTermination() {
        stateQueue.sync { localTerminationInProgress = true }
    }

    private func setRunning(_ value: Bool) {
        stateQueue.sync { running = value }
    }

    private func isRunning() -> Bool {
        stateQueue.sync { running }
    }
}

private enum TunnelError: Int, LocalizedError {
    case missingSession = 1001
    case invalidSessionOptions = 1002
    case runtimeTimeout = 1003
    case invalidConfiguration = 1004
    case overlayConflict = 1005
    case supernodeResolution = 1006
    case controlSocket, runtimeSocket, remoteStopped

    var nsError: NSError {
        NSError(
            domain: packetTunnelErrorDomain,
            code: rawValue,
            userInfo: [NSLocalizedDescriptionKey: errorDescription
                ?? L10n.text("error.vpn_internal", fallback: "Internal VPN tunnel error")]
        )
    }

    var errorDescription: String? {
        switch self {
        case .missingSession:
            L10n.text("error.missing_session", fallback: "VPN session unavailable")
        case .invalidSessionOptions:
            L10n.text(
                "error.invalid_session_options",
                fallback: "The credentials received at startup are invalid"
            )
        case .runtimeTimeout:
            L10n.text(
                "error.runtime_timeout",
                fallback: "The runtime did not prepare the VPN in time"
            )
        case .invalidConfiguration:
            L10n.text("error.invalid_configuration", fallback: "Invalid VPN configuration")
        case .overlayConflict:
            L10n.text(
                "error.overlay_conflict",
                fallback: "The VPN network is already used by the local network"
            )
        case .supernodeResolution:
            L10n.text(
                "error.supernode_resolution",
                fallback: "The VPN supernode cannot be resolved"
            )
        case .controlSocket:
            L10n.text(
                "error.control_socket",
                fallback: "The VPN control channel cannot be reached"
            )
        case .runtimeSocket:
            L10n.text(
                "error.runtime_socket",
                fallback: "The runtime is not responding through the VPN"
            )
        case .remoteStopped:
            L10n.text("error.remote_stopped", fallback: "The session was closed by the portal")
        }
    }
}

private extension NEPacketTunnelProvider {
    func setTunnelNetworkSettings(_ settings: NEPacketTunnelNetworkSettings) async throws {
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            setTunnelNetworkSettings(settings) { error in
                if let error { continuation.resume(throwing: error) }
                else { continuation.resume() }
            }
        }
    }
}
