import Foundation
import Network

/// Watches the device's network path (Wi‑Fi ⇄ cellular ⇄ offline) so the app can switch transports
/// the instant the link changes, instead of waiting for a dead LAN socket to time out. This is the
/// missing proactive signal behind the "iOS sticks on LAN after leaving Wi‑Fi" stall: the phone's
/// pinned `lanHost:lanPort` is only reachable over Wi‑Fi/wired, so on cellular the LAN candidate is a
/// guaranteed stall. `HostConnection` skips LAN when `canUseLAN` is false and re‑plans on `onChange`.
@MainActor
final class NetworkPathMonitor: ObservableObject {
    /// True when a LAN‑capable path (Wi‑Fi or wired Ethernet) is available — the only paths on which
    /// the pinned `lanHost:lanPort` could resolve. Cellular‑only ⇒ false, so the LAN candidate is
    /// dropped and we dial tailnet/relay straight away. Defaults optimistic so a not‑yet‑started
    /// monitor never spuriously suppresses LAN.
    @Published private(set) var canUseLAN = true
    /// True when any network path is available at all (used to decide whether an immediate reconnect
    /// is even worth attempting).
    @Published private(set) var isSatisfied = true

    /// Fired on the main actor whenever a *relevant* aspect of the path changed — LAN availability or
    /// overall reachability. The cue for connections to re‑plan their transport immediately rather
    /// than waiting out a reconnect backoff.
    var onChange: (() -> Void)?

    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "nucleic.remote.path")
    private var started = false

    /// A coarse fingerprint of the last path, so a *same-class* transition (a cellular tower roam
    /// that reprovisions the pdp interface, a Wi‑Fi interface swap) fires `onChange` too — not just
    /// the LAN/satisfied flips. Without this, a roam that keeps the path `satisfied` on `.cellular`
    /// leaves both booleans unchanged and the proactive re‑plan never fires, so a silently‑rebound
    /// socket is only caught by the slow reactive keepalive.
    private var lastSignature = ""

    func start() {
        guard !started else { return }
        started = true
        monitor.pathUpdateHandler = { [weak self] path in
            Task { @MainActor in self?.apply(path) }
        }
        monitor.start(queue: queue)
    }

    /// Re‑read the current path synchronously — used on foreground, where an update that landed while
    /// the app was suspended may not have been redelivered yet.
    func refresh() { apply(monitor.currentPath) }

    private func apply(_ path: NWPath) {
        let lan = path.status == .satisfied
            && (path.usesInterfaceType(.wifi) || path.usesInterfaceType(.wiredEthernet))
        let satisfied = path.status == .satisfied
        // Also move on a same‑class transition (cellular tower roam, Wi‑Fi interface swap) so a live
        // relay/tailnet tunnel gets re‑validated instead of waiting out socket‑death detection. The
        // interface identity + expensiveness/constrained flags are what `NWPath` surfaces on a
        // handoff; the IP itself isn't exposed, so this is best‑effort — the follow‑on probe is the
        // gate, and it's cheap on a still‑healthy link.
        let signature = "\(path.status)|"
            + path.availableInterfaces.map { "\($0.name)#\($0.index)" }.joined(separator: ",")
            + "|\(path.isExpensive)|\(path.isConstrained)"
        let changed = lan != canUseLAN || satisfied != isSatisfied || signature != lastSignature
        canUseLAN = lan
        isSatisfied = satisfied
        lastSignature = signature
        if changed { onChange?() }
    }
}
