//
//  Helper.swift
//  SwanDeskHelper — XPC server running as root.
//
//  Ported from AppVersionChecker's helper, with one deliberate divergence: this
//  daemon does NOT quit when the last XPC connection drops. It owns the long-lived
//  `charon` process and the keepalive loop (via VPNEngine), so the tunnel must
//  survive the GUI quitting or its menu closing. The listener stays resumed for
//  the daemon's lifetime; launchd tears it down on logout/shutdown, at which point
//  we stop charon and restore networking.
//

import Foundation
import Security

final class Helper: NSObject, VPNHelperProtocol, NSXPCListenerDelegate {

    // Code-signing requirement the connecting app must satisfy before we expose
    // any privileged operation. Mirrors SMAuthorizedClients in the helper's
    // embedded Info.plist — only a genuine, Juvex-signed SwanDesk.app may drive us.
    private static let clientRequirement =
        "anchor apple generic and identifier \"fi.juvex.swandesk\" and " +
        "(certificate leaf[field.1.2.840.113635.100.6.1.9] /* exists */ or " +
        "certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and " +
        "certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and " +
        "certificate leaf[subject.OU] = CXJY846WKU)"

    private var connections = [NSXPCConnection]()
    /// The single connection that last called subscribe(); status/log events are
    /// pushed only to it. The app may briefly hold several connections (version
    /// probe, reconnects); broadcasting to all of them delivered every line twice.
    private var subscriber: NSXPCConnection?
    private let connectionsLock = NSLock()
    private let listener: NSXPCListener
    private let engine: VPNEngine

    override init() {
        self.listener = NSXPCListener(machServiceName: HelperConstants.machServiceName)
        self.engine = VPNEngine()
        super.init()
        self.listener.delegate = self
        // The engine pushes status/log to whichever app connections are subscribed.
        self.engine.onStatus = { [weak self] data in self?.broadcastStatus(data) }
        self.engine.onLog = { [weak self] line in self?.broadcastLog(line) }
    }

    /// Starts the daemon and runs forever. Unlike AVC, there is no shouldQuit:
    /// launchd owns our lifetime so the VPN survives the GUI coming and going.
    func run() {
        signal(SIGTERM) { _ in
            // Best-effort teardown so a launchd stop/shutdown doesn't leave charon
            // running and DNS/routes mangled.
            NSLog("SwanDeskHelper: SIGTERM — tearing down VPN")
            VPNEngine.sharedForSignal?.stopSync()
            exit(0)
        }
        VPNEngine.sharedForSignal = engine
        engine.registerPowerNotifications()   // recover the tunnel on wake from sleep
        self.listener.resume()
        RunLoop.current.run()
    }

    // MARK: Client trust

    private func isClientTrusted(_ connection: NSXPCConnection) -> Bool {
        let pid = connection.processIdentifier
        guard pid > 0 else { return false }

        var code: SecCode?
        let attributes = [kSecGuestAttributePid: pid] as CFDictionary
        guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &code) == errSecSuccess,
              let guestCode = code else {
            return false
        }

        var requirement: SecRequirement?
        guard SecRequirementCreateWithString(Helper.clientRequirement as CFString, [], &requirement) == errSecSuccess,
              let req = requirement else {
            return false
        }

        return SecCodeCheckValidity(guestCode, [], req) == errSecSuccess
    }

    func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
        guard isClientTrusted(newConnection) else {
            NSLog("SwanDeskHelper: rejected XPC connection from untrusted client (pid \(newConnection.processIdentifier))")
            return false
        }

        newConnection.exportedInterface = NSXPCInterface(with: VPNHelperProtocol.self)
        newConnection.exportedObject = self
        // The app exports a VPNClientProtocol so we can push status/log back.
        newConnection.remoteObjectInterface = NSXPCInterface(with: VPNClientProtocol.self)
        newConnection.invalidationHandler = { [weak self] in
            self?.removeConnection(newConnection)
        }
        addConnection(newConnection)
        newConnection.resume()
        return true
    }

    private func addConnection(_ connection: NSXPCConnection) {
        connectionsLock.lock(); defer { connectionsLock.unlock() }
        connections.append(connection)
    }

    private func removeConnection(_ connection: NSXPCConnection) {
        connectionsLock.lock(); defer { connectionsLock.unlock() }
        if let idx = connections.firstIndex(of: connection) {
            connections.remove(at: idx)
        }
        if subscriber === connection { subscriber = nil }
        // NOTE: intentionally do NOT set any shouldQuit here — see class comment.
    }

    /// The proxy for the current subscriber, fetched under the lock and used
    /// outside it (avoid holding the lock across an XPC send).
    private func subscriberClient() -> VPNClientProtocol? {
        connectionsLock.lock(); defer { connectionsLock.unlock() }
        return subscriber?.remoteObjectProxy as? VPNClientProtocol
    }

    private func broadcastStatus(_ data: Data) {
        subscriberClient()?.statusChanged(data)
    }

    private func broadcastLog(_ line: String) {
        subscriberClient()?.logLine(line)
    }

    // MARK: VPNHelperProtocol

    func getVersion(reply: @escaping (String) -> Void) {
        reply(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0")
    }

    func subscribe(reply: @escaping (Bool) -> Void) {
        // Make the calling connection the sole recipient of pushed status/log, so a
        // leftover connection from the same app can't double every event.
        if let current = NSXPCConnection.current() {
            connectionsLock.lock(); subscriber = current; connectionsLock.unlock()
        }
        reply(true)
        broadcastStatus(engine.encodedStatus())
    }

    func start(configData: Data, secrets: Data, reply: @escaping (Int, String) -> Void) {
        // Access is already gated by isClientTrusted (only the genuine, signed
        // SwanDesk.app can reach us), so no separate admin-authorization prompt.
        do {
            let profile = try JSONDecoder().decode(VPNProfile.self, from: configData)
            let creds = try JSONDecoder().decode(VPNSecrets.self, from: secrets)
            engine.start(profile: profile, secrets: creds, reply: reply)
        } catch {
            reply(-2, "Invalid configuration: \(error.localizedDescription)")
        }
    }

    func stop(profileID: String, reply: @escaping (Int, String) -> Void) {
        engine.stop(profileID: profileID, reply: reply)
    }

    func stopAll(reply: @escaping (Int, String) -> Void) {
        engine.stopAll(reply: reply)
    }

    func forceKillAll(reply: @escaping (Int, String) -> Void) {
        engine.forceKillAll(reply: reply)
    }

    func setPriorities(profileIDs: [String], reply: @escaping () -> Void) {
        engine.setPriorities(profileIDs: profileIDs, reply: reply)
    }

    func status(reply: @escaping (Data) -> Void) {
        reply(engine.encodedStatus())
    }

    func terminate(reply: @escaping () -> Void) {
        reply()
        // Brief delay so XPC can flush the reply before the process exits.
        DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) {
            kill(getpid(), SIGTERM)
        }
    }
}
