import Foundation
#if canImport(TailscaleKit)
import TailscaleKit
#endif

/// The one embedded tsnet node this process runs — the device's identity on the user's
/// tailnet (each side of a Nucleic pairing runs its own). Wraps TailscaleKit's
/// `TailscaleNode` for lifecycle (start/login/addresses) and drops to the framework's
/// public C API (`tailscale_dial`/`tailscale_listen`) for the data path, because the raw
/// calls hand back full-duplex fds — the Swift wrapper's connection actors are one-way.
///
/// Built without TailscaleKit, the type still exists (so callers compile) and every entry
/// point throws `TailnetError.notBuiltIn`.
public actor TailnetNode {
    public static let shared = TailnetNode()

    public enum Status: Sendable, Equatable {
        case stopped
        case starting
        /// Interactive (browser) login: the user must approve this device at `url`.
        /// Reached only on a first start with no auth key and no existing state.
        case needsLogin(url: String)
        case running(ip4: String?, ip6: String?)
        case failed(String)

        /// Short user-facing status line for the Settings UI.
        public var label: String {
            switch self {
            case .stopped: "Off"
            case .starting: "Starting…"
            case .needsLogin: "Waiting for browser login…"
            case .running(let ip4, let ip6): "Connected · \(ip4 ?? ip6 ?? "no address")"
            case .failed(let message): message
            }
        }
    }

    public private(set) var status: Status = .stopped
    private var statusContinuations: [UUID: AsyncStream<Status>.Continuation] = [:]

    /// Live status updates, current value first — how a caller awaiting `ensureRunning`
    /// still sees the `.needsLogin` URL appear mid-start. Each caller gets its own stream;
    /// it detaches when the caller stops iterating.
    public func statusStream() -> AsyncStream<Status> {
        let (stream, continuation) = AsyncStream<Status>.makeStream()
        let id = UUID()
        statusContinuations[id] = continuation
        continuation.yield(status)
        continuation.onTermination = { [weak self] _ in
            Task { [weak self] in await self?.removeStatusContinuation(id) }
        }
        return stream
    }

    private func removeStatusContinuation(_ id: UUID) {
        statusContinuations[id] = nil
    }

    private func setStatus(_ new: Status) {
        status = new
        for continuation in statusContinuations.values { continuation.yield(new) }
    }

    #if canImport(TailscaleKit)
    private var node: TailscaleNode?
    private var handle: Int32?
    private var activeConfig: TailnetConfig?
    private var startTask: Task<Void, Error>?
    /// Bumped by every shutdown. `performStart` may be parked in the un-cancellable `up()`
    /// for up to its deadline while the actor stays reentrant; a start may only commit its
    /// node if no stop()/restart superseded it in that window (else it closes its own node).
    private var startGeneration = 0
    #endif

    /// The node's tailnet addresses once running (what pairing advertises to the phone).
    public var addresses: (ip4: String?, ip6: String?)? {
        guard case .running(let ip4, let ip6) = status else { return nil }
        return (ip4, ip6)
    }

    /// Start the node (or join an in-flight start) and wait until it's usable on the
    /// tailnet. Idempotent for an unchanged config; a changed config restarts the node.
    /// With an auth key (or existing state) `up()` normally returns in a couple of
    /// seconds — `upTimeout` exists because with a bad key tsnet waits forever. A first
    /// start with no key goes through the interactive browser flow first (`.needsLogin`
    /// on `statusStream()`), human-paced, bounded by `loginTimeout`.
    public func ensureRunning(
        config: TailnetConfig, upTimeout: TimeInterval = 45, loginTimeout: TimeInterval = 240
    ) async throws {
        #if canImport(TailscaleKit)
        if let startTask, activeConfig == config {
            return try await startTask.value
        }
        await shutdownNode()
        activeConfig = config
        let generation = startGeneration
        let task = Task {
            try await self.performStart(
                config: config, upTimeout: upTimeout, loginTimeout: loginTimeout,
                generation: generation)
        }
        startTask = task
        do {
            try await task.value
        } catch {
            // Only clear our own memoization — a reentrant restart may have installed a
            // newer task while we were parked on this one's value.
            if startTask == task { startTask = nil }
            throw error
        }
        #else
        throw TailnetError.notBuiltIn
        #endif
    }

    public func stop() async {
        #if canImport(TailscaleKit)
        await shutdownNode()
        #endif
        setStatus(.stopped)
    }

    /// Dial `host:port` over the tailnet and wrap the resulting fd as a `FrameChannel`.
    public func dial(host: String, port: UInt16, timeout: TimeInterval = 20) async throws -> FDFrameChannel {
        #if canImport(TailscaleKit)
        guard case .running = status, let handle else {
            throw TailnetError.notConfigured("The Tailscale node isn't running.")
        }
        let address = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)"
        let once = ResumeOnce()
        return try await withCheckedThrowingContinuation { cont in
            // tailscale_dial blocks with no cancellation hook — give it its own thread and
            // race a deadline; if the deadline wins, the late fd is closed so nothing leaks.
            Thread.detachNewThread {
                var fd: Int32 = 0
                let rc = tailscale_dial(handle, "tcp", address, &fd)
                if once.claim() {
                    if rc == 0 {
                        cont.resume(returning: FDFrameChannel(fd: fd, peerDescription: "\(address) (tailnet)"))
                    } else {
                        cont.resume(throwing: TailnetError.dialFailed(lastTailscaleError(handle: handle)))
                    }
                } else if rc == 0 {
                    Darwin.close(fd)
                }
            }
            Task.detached {
                try? await Task.sleep(for: .seconds(timeout))
                if once.claim() {
                    cont.resume(throwing: TailnetError.timedOut("Couldn't reach the Mac over Tailscale."))
                }
            }
        }
        #else
        throw TailnetError.notBuiltIn
        #endif
    }

    /// A listener bound to the tailnet on `port`, for the host side. The returned listener
    /// is inert until its `start()` — matching the `SyncListener` shape NucleicCore adapts
    /// it to.
    public func makeListener(port: UInt16) throws -> TailnetListener {
        #if canImport(TailscaleKit)
        guard case .running(let ip4, let ip6) = status, let handle else {
            throw TailnetError.notConfigured("The Tailscale node isn't running.")
        }
        return TailnetListener(handle: handle, port: port, ip4: ip4, ip6: ip6)
        #else
        throw TailnetError.notBuiltIn
        #endif
    }

    // MARK: - Internals

    #if canImport(TailscaleKit)
    private func performStart(
        config: TailnetConfig, upTimeout: TimeInterval, loginTimeout: TimeInterval, generation: Int
    ) async throws {
        guard generation == startGeneration else { throw TailnetError.nodeFailed("stopped during startup") }
        setStatus(.starting)
        do {
            try FileManager.default.createDirectory(
                at: config.stateDirectory, withIntermediateDirectories: true)
            // Go-side logs are a firehose (netmap dumps); keep them opt-in for debugging.
            let verbose = ProcessInfo.processInfo.environment["NUCLEIC_TAILNET_VERBOSE"] == "1"
            let tsConfig = Configuration(
                hostName: config.hostName,
                path: config.stateDirectory.path,
                authKey: config.authKey,
                controlURL: config.controlURL ?? kDefaultControlURL,
                ephemeral: config.ephemeral)
            let node = try TailscaleNode(config: tsConfig, logger: verbose ? StderrLogSink() : nil)
            // No auth key: ask the backend whether it actually needs a login, and drive the
            // browser flow if so — `up()` alone would block forever on a login nothing
            // initiated. This must key off IPN state, not the state directory: tsnet writes
            // logs + a machine key on every start, registered or not, so "directory
            // non-empty" says nothing about whether an identity exists.
            if config.authKey == nil {
                do {
                    let client = LocalAPIClient(localNode: node, logger: nil)
                    if try await needsInteractiveLogin(client: client, generation: generation) {
                        try await interactiveLogin(client: client, generation: generation, timeout: loginTimeout)
                    }
                } catch {
                    Task.detached { try? await node.close() }
                    throw error
                }
            }
            do {
                try await Self.withDeadline(
                    seconds: upTimeout,
                    timeoutMessage: "Tailscale login timed out — check the auth key."
                ) {
                    try await node.up()
                }
            } catch {
                // up() may still be blocked inside the node; close in the background.
                Task.detached { try? await node.close() }
                throw error
            }
            let addrs = try await node.addrs()
            let handle = await node.tailscale
            // A stop() or config-change restart superseded this start while up() was
            // parked — this node must die quietly, not win the commit.
            guard generation == startGeneration else {
                Task.detached { try? await node.close() }
                throw TailnetError.nodeFailed("stopped during startup")
            }
            self.node = node
            self.handle = handle
            setStatus(.running(ip4: addrs.ip4, ip6: addrs.ip6))
        } catch {
            let message = (error as? TailnetError)?.errorDescription ?? "\(error)"
            // A superseded start must not clobber the current attempt's status either.
            if generation == startGeneration { setStatus(.failed(message)) }
            if error is TailnetError { throw error }
            throw TailnetError.nodeFailed(message)
        }
    }

    /// Whether the freshly started backend is waiting for a login. Right after start the
    /// state is briefly NoState/Starting — wait for a decisive NeedsLogin/Running answer.
    /// Indecision falls through to `up()`, which carries its own deadline.
    private func needsInteractiveLogin(client: LocalAPIClient, generation: Int) async throws -> Bool {
        let deadline = ContinuousClock.now.advanced(by: .seconds(10))
        while ContinuousClock.now < deadline {
            guard generation == startGeneration else {
                throw TailnetError.nodeFailed("stopped during startup")
            }
            if let backend = try? await client.backendStatus() {
                if backend.BackendState == "NeedsLogin" { return true }
                if backend.BackendState == "Running" { return false }
            }
            try? await Task.sleep(for: .milliseconds(500))
        }
        return false
    }

    /// Drive the tsnet interactive (browser) login: ask control for an auth URL, surface
    /// it as `.needsLogin` (the UIs open it), and wait for the backend to reach Running.
    /// Polling `backendStatus` is deliberately simple — the IPN-bus watcher is the push
    /// alternative, but a 1.5s poll for a human-paced flow isn't worth its machinery.
    private func interactiveLogin(client: LocalAPIClient, generation: Int, timeout: TimeInterval) async throws {
        try await client.startLoginInteractive()
        var surfacedURL: String?
        let deadline = ContinuousClock.now.advanced(by: .seconds(timeout))
        while ContinuousClock.now < deadline {
            guard generation == startGeneration else {
                throw TailnetError.nodeFailed("stopped during startup")
            }
            if let backend = try? await client.backendStatus() {
                if backend.BackendState == "Running" { return }
                if !backend.AuthURL.isEmpty, backend.AuthURL != surfacedURL {
                    surfacedURL = backend.AuthURL
                    setStatus(.needsLogin(url: backend.AuthURL))
                }
            }
            try? await Task.sleep(for: .seconds(1.5))
        }
        throw TailnetError.timedOut(
            "Tailscale login wasn't completed — try again, or paste an auth key instead.")
    }

    private func shutdownNode() async {
        // Fence out any in-flight start, and clear all state BEFORE the suspension so a
        // reentrant caller can't observe (and double-close) a half-shut node.
        startGeneration += 1
        startTask = nil
        let node = self.node
        self.node = nil
        handle = nil
        activeConfig = nil
        if let node {
            // close() is the real teardown — upstream's down() mistakenly calls tailscale_up.
            try? await node.close()
        }
    }

    /// Race an un-cancellable async operation against a deadline. Deliberately unstructured:
    /// a task group would await the blocked child even after cancelAll, defeating the point.
    private static func withDeadline<T: Sendable>(
        seconds: TimeInterval, timeoutMessage: String,
        _ operation: @escaping @Sendable () async throws -> T
    ) async throws -> T {
        let once = ResumeOnce()
        return try await withCheckedThrowingContinuation { cont in
            Task.detached {
                do {
                    let value = try await operation()
                    if once.claim() { cont.resume(returning: value) }
                } catch {
                    if once.claim() { cont.resume(throwing: error) }
                }
            }
            Task.detached {
                try? await Task.sleep(for: .seconds(seconds))
                if once.claim() { cont.resume(throwing: TailnetError.timedOut(timeoutMessage)) }
            }
        }
    }
    #endif
}

#if canImport(TailscaleKit)
/// Read `tailscale_errmsg` for a handle (best-effort; the C API's only error detail).
func lastTailscaleError(handle: Int32) -> String {
    var buf = [CChar](repeating: 0, count: 1024)
    guard tailscale_errmsg(handle, &buf, buf.count) == 0 else { return "unknown tailscale error" }
    let message = nulTerminatedString(buf)
    return message.isEmpty ? "unknown tailscale error" : message
}

/// Verbose-mode sink for the node's logs (`NUCLEIC_TAILNET_VERBOSE=1`): Go's log stream
/// goes straight to stderr via the file handle; Swift-side wrapper messages likewise.
private struct StderrLogSink: LogSink {
    var logFileHandle: Int32? { STDERR_FILENO }
    func log(_ message: String) {
        FileHandle.standardError.write(Data(("tailnet: " + message + "\n").utf8))
    }
}
#endif

/// Decode a NUL-terminated C string buffer (what the tailscale C API writes into).
func nulTerminatedString(_ buf: [CChar]) -> String {
    String(decoding: buf.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) }, as: UTF8.self)
}

/// First-caller-wins flag for racing an un-cancellable operation against a deadline.
final class ResumeOnce: @unchecked Sendable {
    private let lock = NSLock()
    private var claimed = false

    /// True exactly once, for the first claimant.
    func claim() -> Bool {
        lock.lock()
        defer { lock.unlock() }
        if claimed { return false }
        claimed = true
        return true
    }
}
