import Foundation
import NucleicProtocol
import NucleicTailnet

/// One way a peer Mac can be dialed right now — derived from its stored `PeerAddresses`
/// or a pairing payload's connection hints.
public enum PeerEndpoint: Sendable, Equatable {
    case lan(host: String, port: UInt16)
    case tailnet(host: String, port: UInt16)
    /// A peer rediscovered live on the LAN via Bonjour (`_nucleic._tcp`), matched on its static-key
    /// fingerprint in the TXT record. Dialed as a `.service` endpoint (Bonjour resolves it to the
    /// peer's *current* address at connect time), so it finds a sibling whose OS-assigned LAN port
    /// changed on relaunch even before any stored hint refreshes. Same-subnet only.
    case bonjour(serviceName: String, domain: String)
    /// The peer's relay room, reached with the membership token it issued us. Port-independent:
    /// the room is the peer's permanent identity, so this reconnects a sibling Mac whose LAN port
    /// churned on relaunch/update — the last-resort candidate no address staleness can invalidate.
    /// `baseURLString` is a dev/self-host override (nil ⇒ production relay).
    case relay(baseURLString: String?, membershipToken: String)

    public var transport: SyncTransportHint {
        switch self {
        case .lan: .lan
        case .bonjour: .lan
        case .tailnet: .tailnet
        case .relay: .relay
        }
    }
}

/// Resolves a live LAN address for a peer, keyed by its static-key fingerprint (mesh Bonjour
/// rediscovery). The app backs this with `LANBrowser`; `PeerClient` holds it behind this portable
/// protocol so its dial loop can prefer a freshly-discovered endpoint over a stale stored one.
public protocol PeerLANBrowsing: Sendable {
    /// The current Bonjour endpoint for the peer whose static key hashes to `fingerprint`, or nil
    /// when it isn't visible on this LAN right now.
    func endpoint(forFingerprint fingerprint: String) async -> PeerEndpoint?
}

/// Turns one endpoint into a live `FrameChannel`, or throws. The seam that keeps `PeerClient`
/// testable: loopback tests inject a dialer that hands out in-memory pipes; the app uses
/// `MacPeerDialer` (outbound TCP + the shared tailnet node).
public protocol PeerDialer: Sendable {
    func dial(_ endpoint: PeerEndpoint) async throws -> any FrameChannel
}

public enum PeerDialError: Error, Equatable {
    case timedOut
    case unreachable(String)
}

/// The production dialer. LAN dials an outbound TCP connection (`LANDialChannel`); tailnet
/// dials through the one shared embedded node — which the sync server already runs when the
/// tailnet method is enabled, so no extra node state is created here (`TailnetNode.dial`
/// throws `.notConfigured` when the node isn't up, and the caller just tries the next path).
public struct MacPeerDialer: PeerDialer {
    private let lanTimeout: Duration

    public init(lanTimeout: Duration = .seconds(4)) {
        self.lanTimeout = lanTimeout
    }

    public func dial(_ endpoint: PeerEndpoint) async throws -> any FrameChannel {
        switch endpoint {
        case .lan(let host, let port):
            #if canImport(Network)
            return try await LANDialChannel.dial(host: host, port: port, timeout: lanTimeout)
            #else
            throw PeerDialError.unreachable("LAN dialing unavailable on this platform")
            #endif
        case .bonjour(let serviceName, let domain):
            #if canImport(Network)
            return try await LANDialChannel.dial(
                serviceName: serviceName, domain: domain, timeout: lanTimeout)
            #else
            throw PeerDialError.unreachable("Bonjour dialing unavailable on this platform")
            #endif
        case .tailnet(let host, let port):
            return try await TailnetNode.shared.dial(host: host, port: port)
        case .relay(let baseURLString, let membershipToken):
            return try await RelayFrameChannel.dial(
                base: RelayAPI.baseURL(baseURLString), membershipToken: membershipToken)
        }
    }
}

#if canImport(Network)
import Network

/// Client-side `FrameChannel` over an outbound `NWConnection` — the dial-side mirror of
/// `LANChannel` (which wraps listener-accepted connections) and of the iPhone's
/// `NWFrameChannel`. `dial` resolves once TCP reaches `.ready` (or throws on failure or
/// timeout), so callers always hand `SyncClient` a live pipe — a silently-stalled connect
/// would otherwise only surface as a generic "handshake closed" much later.
public final class LANDialChannel: FrameChannel, @unchecked Sendable {
    public let peerDescription: String
    private let connection: NWConnection
    private let queue = DispatchQueue(label: "nucleic.sync.peerdial")
    private let accumulator = FrameAccumulator()
    private let stream: AsyncStream<Data>
    private let continuation: AsyncStream<Data>.Continuation
    private let ready = ReadyGate()

    public static func dial(
        host: String, port: UInt16, timeout: Duration = .seconds(4)
    ) async throws -> LANDialChannel {
        guard let nwPort = NWEndpoint.Port(rawValue: port) else {
            throw PeerDialError.unreachable("bad port \(port)")
        }
        return try await dial(endpoint: .hostPort(host: .init(host), port: nwPort), timeout: timeout)
    }

    /// Dial a Bonjour service by name (mesh LAN rediscovery). Bonjour resolves the peer's *current*
    /// address when the connection opens, so a sibling that moved to a new OS-assigned port is still
    /// reached without any stored hint.
    public static func dial(
        serviceName: String, domain: String, timeout: Duration = .seconds(4)
    ) async throws -> LANDialChannel {
        let endpoint = NWEndpoint.service(
            name: serviceName, type: LANListener.serviceType, domain: domain, interface: nil)
        return try await dial(endpoint: endpoint, timeout: timeout)
    }

    private static func dial(
        endpoint: NWEndpoint, timeout: Duration
    ) async throws -> LANDialChannel {
        let channel = LANDialChannel(endpoint: endpoint)
        // The timeout resolves the gate (and tears the connection down) only if TCP hasn't
        // already reached `.ready` — `ReadyGate.resolve` is first-writer-wins.
        let timeoutTask = Task {
            try? await Task.sleep(for: timeout)
            if channel.ready.resolve(.failure(PeerDialError.timedOut)) { channel.close() }
        }
        defer { timeoutTask.cancel() }
        try await channel.ready.wait()
        return channel
    }

    private init(endpoint: NWEndpoint) {
        // Enable TCP keepalive on the outbound peer link: a sibling Mac that sleeps or drops off
        // the network without a FIN/RST otherwise leaves this connection half-open forever, and
        // the manage loop — which has no post-handshake deadline — never redials. Keepalive lets
        // the OS probe the dead peer and fail the connection, ending the stream so the loop backs
        // off and reconnects when the peer returns (a core "hand the laptop off" scenario).
        let tcp = NWProtocolTCP.Options()
        tcp.enableKeepalive = true
        tcp.keepaliveIdle = 15
        tcp.keepaliveInterval = 15
        tcp.keepaliveCount = 3
        let params = NWParameters(tls: nil, tcp: tcp)
        params.includePeerToPeer = true
        connection = NWConnection(to: endpoint, using: params)
        peerDescription = "\(endpoint)"
        var cont: AsyncStream<Data>.Continuation!
        stream = AsyncStream(bufferingPolicy: .unbounded) { cont = $0 }
        continuation = cont

        connection.stateUpdateHandler = { [weak self] state in
            guard let self else { return }
            switch state {
            case .ready:
                _ = self.ready.resolve(.success(()))
            case .failed(let error):
                _ = self.ready.resolve(.failure(PeerDialError.unreachable("\(error)")))
                // A failed NWConnection still holds resources until cancelled — the timeout
                // path calls close(), but a fast `.failed` resolves the gate before the timeout
                // fires, so cancel here too or the connection + its queue leak per redial.
                self.connection.cancel()
                self.continuation.finish()
            case .cancelled:
                _ = self.ready.resolve(.failure(PeerDialError.unreachable("cancelled")))
                self.continuation.finish()
            default:
                break
            }
        }
        connection.start(queue: queue)
        receiveLoop()
    }

    public func frames() -> AsyncStream<Data> { stream }

    public func send(_ frame: Data) {
        connection.send(content: WireFraming.frame(frame), completion: .idempotent)
    }

    public func close() {
        connection.cancel()
        continuation.finish()
    }

    private func receiveLoop() {
        connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in
            guard let self else { return }
            if let data, !data.isEmpty {
                if let frames = try? self.accumulator.push(data) {
                    for frame in frames { self.continuation.yield(frame) }
                } else {
                    // Oversize/garbage frame → drop the connection.
                    self.close(); return
                }
            }
            if isComplete || error != nil {
                self.continuation.finish()
                return
            }
            self.receiveLoop()
        }
    }
}

/// A one-shot latch bridging NWConnection's callback world into async/await: the first
/// `resolve` wins and wakes the (single) waiter; later calls are no-ops so the connect
/// timeout and the state handler can race safely.
private final class ReadyGate: @unchecked Sendable {
    private let lock = NSLock()
    private var outcome: Result<Void, Error>?
    private var waiter: CheckedContinuation<Void, Error>?

    /// Returns true if this call decided the outcome (first writer).
    @discardableResult
    func resolve(_ result: Result<Void, Error>) -> Bool {
        lock.lock()
        guard outcome == nil else { lock.unlock(); return false }
        outcome = result
        let woken = waiter
        waiter = nil
        lock.unlock()
        woken?.resume(with: result)
        return true
    }

    func wait() async throws {
        try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
            lock.lock()
            if let outcome {
                lock.unlock()
                cont.resume(with: outcome)
                return
            }
            waiter = cont
            lock.unlock()
        }
    }
}
#endif
