//
//  ConnectionManager.swift
//  SwanDesk — app-side VPN driver and source of truth for the UI.
//
//  Owns the current runtime status (pushed from the daemon over reverse XPC),
//  the live log buffer, and connect/disconnect actions. Several tunnels can be
//  up at once; SwiftUI views observe the per-tunnel states and the aggregate.
//

import SwiftUI

@MainActor
@Observable
final class ConnectionManager {
    static let shared = ConnectionManager()

    /// Latest status reported by the daemon (every active tunnel).
    private(set) var status = VPNRuntimeStatus()
    /// Rolling log buffer for the log pane (capped).
    private(set) var logLines: [String] = []
    /// True while a connect/disconnect XPC call is in flight.
    private(set) var busy = false

    private let helper = Helper.shared
    private let maxLogLines = 2000

    private init() {
        // Wire reverse-XPC callbacks from the daemon to the main actor. Use the
        // main queue (FIFO) rather than `Task { @MainActor }` so rapid frames
        // (e.g. connecting → connected) are applied IN ORDER — unstructured Tasks
        // have no ordering guarantee and could leave the UI on a stale state.
        helper.client.onStatus = { [weak self] status in
            DispatchQueue.main.async { MainActor.assumeIsolated { self?.status = status } }
        }
        helper.client.onLog = { [weak self] line in
            DispatchQueue.main.async { MainActor.assumeIsolated { self?.append(log: line) } }
        }
    }

    /// Bring up the daemon, confirm we can reach it, then subscribe to its push
    /// events and pull the current status. Returns `false` if the helper could not
    /// be reached even after recovery — the caller refuses to run in that case.
    @discardableResult
    func bootstrap() async -> Bool {
        guard await helper.establishConnection() else { return false }
        helper.subscribe()
        self.status = await helper.fetchStatus()
        return true
    }

    private func append(log line: String) {
        logLines.append(line)
        if logLines.count > maxLogLines {
            logLines.removeFirst(logLines.count - maxLogLines)
        }
    }

    func clearLog() { logLines.removeAll() }

    // MARK: Actions

    /// Fetch the profile's stored secrets, prompting for the password/PSK when
    /// none is saved. Returns nil when the user cancels the prompt. The single
    /// implementation of the "needs password?" rule for every connect path
    /// (menu, editor, auto-connect).
    func resolveSecrets(for profile: VPNProfile) -> VPNSecrets? {
        var secrets = ProfileStore.shared.secrets(for: profile.id)
        let needsPassword = (profile.authMethod.usesPassword || profile.authMethod.usesPSK)
                            && secrets.xauthPassword.isEmpty
        if needsPassword {
            guard let pw = PasswordPrompt.request(for: profile) else { return nil }
            secrets.xauthPassword = pw
        }
        return secrets
    }

    func connect(profile: VPNProfile, secrets: VPNSecrets) {
        guard !busy else { return }
        busy = true
        // The profile's position in the list is its priority among simultaneous
        // tunnels (0 = top = highest) — stamp it at connect time.
        var prioritized = profile
        prioritized.priority = ProfileStore.shared.profiles.firstIndex { $0.id == profile.id } ?? 0
        Task {
            let (code, message) = await helper.start(profile: prioritized, secrets: secrets)
            self.busy = false
            if code != 0 { self.append(log: "Connect failed: \(message)") }
        }
    }

    func disconnect(profileID: UUID) {
        guard !busy else { return }
        busy = true
        Task {
            let (_, message) = await helper.stop(profileID: profileID.uuidString)
            self.busy = false
            self.append(log: message)
        }
    }

    func disconnectAll() {
        guard !busy else { return }
        busy = true
        Task {
            let (_, message) = await helper.stopAll()
            self.busy = false
            self.append(log: message)
        }
    }

    /// Emergency stop: SIGKILL every charon/dnsmasq (even strays the helper lost
    /// track of), restore DNS/routes/IPv6 and drop kill-switch firewall rules.
    func forceKillAll() {
        busy = true   // deliberately no `guard !busy` — this is the escape hatch
        Task {
            let (_, message) = await helper.forceKillAll()
            self.busy = false
            self.append(log: message)
            self.status = await helper.fetchStatus()
        }
    }

    /// Tell the helper the new profile-list order so active tunnels' route/DNS
    /// conflicts are re-resolved to match.
    func pushPriorities(_ orderedProfileIDs: [UUID]) {
        Task {
            await helper.setPriorities(profileIDs: orderedProfileIDs.map(\.uuidString))
        }
    }

    /// Stop all tunnels, shut the helper daemon down, then quit the app.
    func quitAndStopHelper() {
        busy = true
        Task {
            await helper.shutdownHelper()
            // Clear the last pushed status so applicationShouldTerminate doesn't
            // see stale tunnels and fire another stopAll — the XPC call would make
            // launchd relaunch the on-demand daemon we just shut down.
            status = VPNRuntimeStatus()
            NSApp.terminate(nil)
        }
    }

    /// Connect every profile marked "connect on launch", in priority (list)
    /// order, skipping ones already up (the helper daemon outlives the app, so a
    /// relaunch may find tunnels still connected). Sequential — the helper's
    /// engine queue serializes starts anyway, and this keeps the log readable.
    func autoConnectOnLaunch() async {
        let store = ProfileStore.shared
        for (index, profile) in store.profiles.enumerated() where profile.connectOnLaunch {
            guard !isActive(profile.id) else { continue }
            guard let secrets = resolveSecrets(for: profile) else {
                append(log: "Auto-connect skipped for '\(profile.name)' (no password provided)")
                continue
            }
            var prioritized = profile
            prioritized.priority = index
            busy = true
            let (code, message) = await helper.start(profile: prioritized, secrets: secrets)
            busy = false
            append(log: code == 0 ? "Auto-connected '\(profile.name)'"
                                  : "Auto-connect failed for '\(profile.name)': \(message)")
        }
    }

    /// Disconnect everything and wait for teardown to finish. Used on app quit so
    /// tunnels, routes and DNS are fully restored before the process exits.
    func disconnectAndWait() async {
        busy = true
        let (_, message) = await helper.stopAll()
        busy = false
        append(log: message)
    }

    /// Disconnect on quit, but never block termination longer than `timeout` — if
    /// the daemon is wedged we'd rather quit than hang the app forever.
    func disconnectForQuit(timeout: TimeInterval = 6) async {
        await withTaskGroup(of: Void.self) { group in
            group.addTask { await self.disconnectAndWait() }
            group.addTask { try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) }
            await group.next()      // whichever finishes first
            group.cancelAll()
        }
    }

    /// True when there is something to tear down (anything but cleanly
    /// disconnected). `.error` counts: a tunnel whose reconnect failed still has
    /// charon, kill-switch rules and routes to clean up — quitting without a
    /// stopAll would leave the kill switch blocking all traffic with no UI left.
    var hasActiveTunnel: Bool {
        status.tunnels.contains { $0.state != .disconnected }
    }

    // MARK: Derived UI state

    func tunnelStatus(for profileID: UUID) -> VPNTunnelStatus? {
        status.tunnel(for: profileID.uuidString)
    }

    func isConnected(_ profileID: UUID) -> Bool {
        tunnelStatus(for: profileID)?.state == .connected
    }

    /// True when the profile has a live tunnel entry the user may want to stop
    /// (anything but a clean absence).
    func isActive(_ profileID: UUID) -> Bool {
        guard let s = tunnelStatus(for: profileID)?.state else { return false }
        return s != .disconnected
    }

    var isConnected: Bool { status.aggregateState == .connected }

    /// SF Symbol for the menu-bar item, conveying state by shape as well as colour
    /// (menu-bar template rendering can mute colour).
    var menuBarSymbol: String {
        switch status.aggregateState {
        case .connected:                 return "lock.fill"
        case .connecting, .reconnecting: return "lock.rotation"
        case .disconnected, .error:      return "lock.open.fill"
        }
    }

    var stateColor: Color { status.aggregateState.uiColor }

    var stateText: String {
        switch status.aggregateState {
        case .connected:
            let n = status.connectedCount
            if n == 1, let t = status.tunnels.first(where: { $0.state == .connected }) {
                return "Connected: \(t.profileName)" + (t.assignedVIP.map { " (\($0))" } ?? "")
            }
            return "Connected: \(n) tunnels"
        case .connecting:   return "Connecting…"
        case .reconnecting: return "Reconnecting…"
        case .disconnected: return "Disconnected"
        case .error:
            let t = status.tunnels.first { $0.state == .error }
            return t?.message ?? "Error"
        }
    }
}

extension VPNConnState {
    /// The one state → colour mapping, shared by the menu-bar icon and the
    /// profile-list status dots. (Lives here, not in Shared/ — the helper target
    /// compiles the shared models and must not import SwiftUI.)
    var uiColor: Color {
        switch self {
        case .connected:                 return .green
        case .connecting, .reconnecting: return .orange
        case .disconnected, .error:      return .red
        }
    }
}
