import Foundation
import NucleicCore

// On Linux, URLSession (the pool heartbeat client) lives in FoundationNetworking.
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// `nucleicd` — the headless Covalence runner host (docs/COVALENCE_RUNNER.md §9).
///
/// The same host-of-record runtime the macOS cockpit drives — one `AppStore` object graph:
/// single-writer `SessionController`s, `TranscriptWriter` seq authority, `ApprovalCoordinator`,
/// worktrees, all backends — with no UI attached. Devices reach it over the mesh exactly as
/// they reach a Mac host; it advertises `PeerKind.cloud` through `Hello.deviceKind` when the
/// runner env is present.
///
/// v0 runs on macOS: dispatch agents onto a spare Mac with no cockpit attached (the self-host
/// story's first rung), and the container contract below is what the Linux build (CLOUD_RUNTIME
/// Phase 2) slots into unchanged.
///
/// Configuration (env, all optional):
///   NUCLEIC_RUNNER_DATA_DIR        store root (default ~/Library/Application Support/NucleicRunner)
///   NUCLEIC_RUNNER_NAME            mesh display name (default "<hostname> Runner", or "Cloud
///                                  Runner" on a headless host with no machine name)
///   NUCLEIC_RUNNER_PROJECTS        git URLs (comma/whitespace-separated) to clone + register at
///                                  boot (COVALENCE_RUNNER §0.2 item 1a) — the interim seeding
///                                  path until devices drive `createProject` over the wire.
///                                  Idempotent: a URL whose repo is already registered is skipped
///   NUCLEIC_SYNC_TRANSPORTS        listener set, e.g. "relay" or "lan,relay" (default relay-only)
///   NUCLEIC_RELAY_URL              relay override (dev/self-host)
///   NUCLEIC_RUNNER_CONTROL_URL     pool control plane; with the vars below, enables heartbeats
///   NUCLEIC_RUNNER_INSTANCE_TOKEN  instance bearer minted by the pool
///   NUCLEIC_RUNNER_INSTANCE_ID     this slot's id ("host", "sb-1", …)
///   NUCLEIC_RUNNER_POOL_ID         owning pool
///   NUCLEIC_RUNNER_EPOCH           fencing epoch stamped at boot (heartbeats carry it; a 409
///                                  means this instance is a zombie and must exit)
///   NUCLEIC_RUNNER_INTELLIGENCE_MODE   how soft-AI runs (COVALENCE_RUNNER §5): "mesh"
///                                  (default — delegate to AFM-capable mesh devices), "agent"
///                                  (one-shot `claude -p` on a small SKU), or "heuristic".
///                                  Overrides the `nucleic.runner.intelligenceMode` default
///   NUCLEIC_RUNNER_INTELLIGENCE_MODEL  the agent mode's model SKU (default "haiku")
///   NUCLEIC_RUNNER_CONTROL_PORT    control endpoint port (default 9200). The endpoint
///                                  (GET /pairing one-shot + GET /health) starts automatically
///                                  in pool/container mode; setting this env starts it on a
///                                  bare self-hosted runner too
///   NUCLEIC_RUNNER_CONTROL_BIND    control endpoint bind address. Defaults: 0.0.0.0 in
///                                  pool/container mode (the Worker's getTcpPort proxy arrives
///                                  on the container's virtual interface — loopback is
///                                  unreachable from it), 127.0.0.1 on a self-host
///
/// Flags: `--print-pairing` mints a one-time join code on stdout after the sync server is up —
/// the provisioning channel for a self-hosted runner (the container path serves the same
/// payload once on the loopback control port instead).
@main
@MainActor
struct Nucleicd {
    static func main() async {
        let env = ProcessInfo.processInfo.environment

        // Blank inherited API keys would 401 every session — scrub them (shared with hostd).
        NucleicHeadless.scrubBlankAPIKeys(environment: env)

        let support = dataDirectory(env: env)
        do {
            try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true)
        } catch {
            fail("could not create data dir \(support.path): \(error)")
        }

        let store: AppStore
        do {
            store = try NucleicHeadless.makeStore(support: support)
        } catch {
            fail("could not open the runner store at \(support.path): \(error)")
        }

        if let name = env["NUCLEIC_RUNNER_NAME"], !name.isEmpty {
            store.syncHostName = name
        } else if store.syncHostName == "Mac" {
            // This runner presents as `.cloud` (see below), so its default name must not claim to be
            // a Mac. A headless cloud host has no machine name (`localizedName` is nil on Linux),
            // which previously fell through to the misleading "Mac Runner" in the Covalence Mesh
            // Viewer. Fall back to "Cloud" instead; a runner that *does* carry a real machine name
            // (e.g. nucleicd on an actual Mac) still keeps it as "<name> Runner".
            store.syncHostName = (Host.current().localizedName ?? "Cloud") + " Runner"
        }
        // Present on the mesh as a cloud host, not a Mac — so peers pin it as `.cloud`
        // (icon/label) and the transfer picker treats it correctly (COVALENCE_RUNNER §3).
        store.meshSelfKind = .cloud
        // A headless runner has no operator to answer the "let this Mac join?" confirm, and the
        // one-shot pairing code it hands the provisioning device already authorizes the join —
        // so auto-approve inbound pairings. Without this a provisioned runner declines the very
        // device that provisioned it (120 s timeout) and never enters the mesh (COVALENCE_RUNNER §3).
        store.meshAutoApprovePairing = true

        // The credential-mesh receiving half (COVALENCE_RUNNER §6, item 5): a sealing keypair
        // (minted on first boot, persisted in the secret store) + the landing map. With it set,
        // the host advertises `canReceiveSealedCredentials`, asks connecting devices for the
        // kinds it's missing, and lands sealed records as 0600 files the CLIs read directly.
        let vault = RunnerCredentialVault.loadOrCreate()
        vault.exportPersistedTokens()  // a github token landed on a prior boot → back into env
        if vault.adoptGitHubTokenFallback() {
            // A persisted github token with no other git credential configured (e.g. the
            // bundle never arrived before the last shutdown) → token-mode Managed Git, so
            // clones/pushes authenticate from the first request of this boot.
            log("git credential: adopted persisted github token (token mode)")
        }
        store.runnerCredentialVault = vault
        let missing = vault.missingKinds()
        if !missing.isEmpty {
            log("credentials missing: \(missing.map(\.rawValue).joined(separator: ", ")) — will ask connecting devices")
        }

        // Intelligence (COVALENCE_RUNNER §5, item 6): replace the AFMs a headless Linux host
        // doesn't have with the user's choice. `mesh` (the default) delegates onto the
        // mesh-wide AFM queue — connected Apple-Intelligence devices execute, the highest
        // tiers restricted to desktop-class ones and biased toward the strongest chip; `agent`
        // runs a small SKU of the user's own agent (the credential mesh put its login here);
        // `heuristic` skips models. Every mode terminates in the heuristic fallback.
        let intelligenceMode = env["NUCLEIC_RUNNER_INTELLIGENCE_MODE"]
            .flatMap(RunnerIntelligenceMode.init(rawValue:)) ?? RunnerSettings.intelligenceMode
        switch intelligenceMode {
        case .agent:
            let model = env["NUCLEIC_RUNNER_INTELLIGENCE_MODEL"] ?? "haiku"
            store.intelligence = DelegatedIntelligenceProvider.agent(model: model)
            log("intelligence: agent (claude, model \(model))")
        case .mesh:
            store.intelligence = DelegatedIntelligenceProvider.mesh(queue: store.meshIntelligence)
            store.delegatesIntelligence = true  // advertise `canDelegateIntelligence`
            log("intelligence: mesh-delegated (AFM-capable devices execute)")
        case .heuristic:
            log("intelligence: heuristics only")
        }

        // The same startup choreography as the cockpit's `.task`, minus UI concerns.
        await NucleicHeadless.loadPersistentState(store)

        // Interim project seeding (COVALENCE_RUNNER §0.2 item 1a): a fresh runner's store is
        // empty, and neither `startChat` nor a session transfer can target a project that isn't
        // registered — so clone + register the configured repos before the listener comes up.
        // Devices with the `createProject` verb can add more over the wire.
        if let seeds = env["NUCLEIC_RUNNER_PROJECTS"], !seeds.isEmpty {
            await seedProjects(seeds, into: store)
        }

        // A runner listens on the relay unless told otherwise; a self-hosted LAN runner opts
        // into "lan,relay". `SyncTransportSetting` already honors this env key.
        if env["NUCLEIC_SYNC_TRANSPORTS"] == nil { setenv("NUCLEIC_SYNC_TRANSPORTS", "relay", 1) }
        UserDefaults.standard.set(true, forKey: "nucleic.relay.enabled")
        await store.startSyncServer()
        for (transport, failure) in store.syncTransportHealth.sorted(by: { $0.key.rawValue < $1.key.rawValue }) {
            log("transport \(transport.rawValue) failed: \(failure)")
        }
        guard store.syncRunning else {
            fail("sync server failed to start — no transport could listen")
        }

        log("nucleicd up — host \(store.syncHostID ?? "?") (\(store.syncHostName))")
        log("listening on: \(store.syncActiveTransports.map(\.rawValue).sorted().joined(separator: ", "))")
        log("data dir: \(support.path)")

        if CommandLine.arguments.contains("--print-pairing") {
            if let code = await store.mintPairingCode() {
                // stdout on purpose: the provisioning channel captures exactly this line.
                print(code)
            } else {
                log("warning: could not mint a pairing code (sync server not accepting?)")
            }
        }

        // Loopback control endpoint (COVALENCE_RUNNER §0.2 item 3d): GET /pairing mints a fresh
        // one-time provisioning payload per call for the pool's `GET /v1/pool/pairing` proxy (the
        // container path's `--print-pairing`); GET /health is local liveness. On automatically
        // in pool/container mode; a self-hosted runner opts in with NUCLEIC_RUNNER_CONTROL_PORT.
        var controlEndpoint: ControlEndpoint?
        if env["NUCLEIC_RUNNER_CONTROL_URL"] != nil || env["NUCLEIC_RUNNER_CONTROL_PORT"] != nil {
            let port = env["NUCLEIC_RUNNER_CONTROL_PORT"].flatMap(UInt16.init) ?? 9200
            // Pool/container mode must bind all interfaces: the RunnerContainer DO's
            // `getTcpPort` proxy reaches the container over its virtual network interface, so
            // a loopback listener answers nothing and every `GET /v1/pool/pairing` fails
            // (Cloudflare container ports are DO-proxy-only, never public — 0.0.0.0 is safe
            // there). A bare self-host keeps loopback. NUCLEIC_RUNNER_CONTROL_BIND overrides.
            let bindAddress = env["NUCLEIC_RUNNER_CONTROL_BIND"]
                ?? (env["NUCLEIC_RUNNER_CONTROL_URL"] != nil ? "0.0.0.0" : "127.0.0.1")
            let endpoint = ControlEndpoint(port: port, bindAddress: bindAddress) { [weak store] in
                await store?.mintPairingCode()
            }
            do {
                try endpoint.start()
                controlEndpoint = endpoint
                log("control endpoint on \(bindAddress):\(port)")
            } catch {
                // Non-fatal: the runner still serves the mesh; only proxied provisioning is lost.
                log("warning: control endpoint failed to start: \(error)")
            }
        }

        // Pool integration (container mode): heartbeat liveness + session counts so the pool's
        // load balancer can place work, carrying the fencing epoch — a 409 means a newer boot
        // of this slot exists and this process must exit, never write.
        let control = RunnerControlClient(env: env)
        let heartbeats: Task<Void, Never>? = control.map { client in
            Task { [weak store] in
                while !Task.isCancelled {
                    let summaries = await store?.sessionSummaries() ?? []
                    let sessions = summaries.count
                    // Sessions still doing something — the complement of Done-and-quiet. The pool
                    // sleeps a runner once this reaches 0 and stays there past its window
                    // (COVALENCE_RUNNER §7): all sessions Done, none holding a queued follow-up.
                    let activeSessions = summaries.filter { !$0.isQuiescent }.count
                    // The host's relay roomId rides every heartbeat so the pool can register the
                    // room → pool mapping the relay's presence→wake hook resolves (§0.2 item 3e).
                    let roomID = store?.syncHostID
                    switch await client.heartbeat(
                        sessions: sessions, activeSessions: activeSessions, roomID: roomID) {
                    case .ok: break
                    case .fenced:
                        log("fenced by the pool (stale epoch) — exiting")
                        exit(75)  // EX_TEMPFAIL: the supervisor must not restart this epoch
                    case .unreachable(let reason):
                        log("heartbeat failed (\(reason)) — retrying")
                    }
                    try? await Task.sleep(for: .seconds(30))
                }
            }
        }

        // Credential mirror-back sweep (COVALENCE_RUNNER §6, item 5): a session's CLI refreshes
        // the OAuth token in place during a turn; this periodically pushes any rotation back to
        // the devices that shared their sealing key, sealed per-device — so the group's Keychain
        // stays coherent (the `syncClaudeLoginBack` analogue). Digest-gated, so an unchanged
        // credential is a no-op. Only meaningful with the vault (always set above).
        let mirror = Task { [weak store] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(30))
                guard !Task.isCancelled else { break }
                await store?.mirrorRefreshedCredentials()
            }
        }

        // Run until the supervisor says stop. SIGTERM (pool stop / container sleep) gets a
        // clean shutdown so transcripts flush and the relay socket closes politely.
        let (terminated, termination) = AsyncStream<Void>.makeStream()
        for sig in [SIGTERM, SIGINT] {
            signal(sig, SIG_IGN)
            let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
            source.setEventHandler { termination.yield() }
            source.resume()
            signalSources.append(source)
        }
        for await _ in terminated { break }

        log("shutting down")
        heartbeats?.cancel()
        mirror.cancel()
        controlEndpoint?.stop()
        await store.stopSyncServer()
        exit(0)
    }

    /// Clone + register each git URL in `list` (comma/whitespace-separated), skipping any whose
    /// repo is already registered (matched by normalized origin remote, so a persistent-disk
    /// reboot doesn't stack `repo-2`, `repo-3` clones). A failed seed logs and moves on — a
    /// runner with one bad URL still comes up serving the rest.
    private static func seedProjects(_ list: String, into store: AppStore) async {
        let urls = list.split(whereSeparator: { $0 == "," || $0.isWhitespace }).map(String.init)
        for url in urls {
            if let existing = await store.projectMatching(remote: url) {
                log("project seed: \(url) already registered as “\(existing.name)”")
                continue
            }
            if let project = await store.addClonedProject(
                name: "", gitURL: url, nucleicControl: true, destinationParent: nil) {
                log("project seed: cloned \(url) → “\(project.name)”")
            } else {
                log("warning: project seed failed for \(url): \(store.lastError ?? "unknown error")")
            }
        }
    }

    private static func dataDirectory(env: [String: String]) -> URL {
        if let override = env["NUCLEIC_RUNNER_DATA_DIR"], !override.isEmpty {
            return URL(fileURLWithPath: override, isDirectory: true)
        }
        // A store of its own by default — a runner sharing the cockpit app's live SQLite/WAL
        // and container names from a second process is a corruption hazard, not a feature.
        let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
            .first ?? FileManager.default.homeDirectoryForCurrentUser
        return base.appendingPathComponent("NucleicRunner", isDirectory: true)
    }

    private static func log(_ message: String) {
        FileHandle.standardError.write(Data("nucleicd: \(message)\n".utf8))
    }

    private static func fail(_ message: String) -> Never {
        log(message)
        exit(1)
    }
}

/// Signal sources must outlive `main`'s scope while the stream is awaited.
@MainActor private var signalSources: [DispatchSourceSignal] = []

/// The pool-plumbing client (COVALENCE_RUNNER §8): heartbeats with epoch fencing. Present only
/// in container mode — a bare self-hosted `nucleicd` has no pool and skips it entirely.
///
/// An actor because the token *rotates*: the pool re-mints an instance token in every accepted
/// heartbeat response (`{ok, token}`) precisely so a live container's credential never lapses
/// (`INSTANCE_TTL` is 30 days). Adopting each fresh token is what makes that true — reusing the
/// boot token forever would 401 every heartbeat once the runner outlives the TTL.
private actor RunnerControlClient {
    let baseURL: URL
    private var token: String
    let epoch: Int

    init?(env: [String: String]) {
        guard
            let base = env["NUCLEIC_RUNNER_CONTROL_URL"].flatMap(URL.init(string:)),
            let token = env["NUCLEIC_RUNNER_INSTANCE_TOKEN"], !token.isEmpty
        else { return nil }
        self.baseURL = base
        self.token = token
        self.epoch = env["NUCLEIC_RUNNER_EPOCH"].flatMap(Int.init) ?? 0
    }

    enum Outcome {
        case ok
        /// 409: a newer boot of this slot exists — this process is a zombie and must exit.
        case fenced
        case unreachable(String)
    }

    func heartbeat(sessions: Int, activeSessions: Int, roomID: String? = nil) async -> Outcome {
        var request = URLRequest(url: baseURL.appendingPathComponent("v1/pool/heartbeat"))
        request.httpMethod = "POST"
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        // `activeSessions` (Done-and-quiet sessions excluded) lets the pool sleep an all-done
        // runner (COVALENCE_RUNNER §7); `sessions` stays the total for the status list.
        var body: [String: Any] = ["epoch": epoch, "sessions": sessions, "activeSessions": activeSessions]
        // The relay roomId (= this host's HostID) — the Worker registers room→pool from it so
        // relay presence can wake this pool after the container sleeps (§0.2 item 3e).
        if let roomID { body["roomId"] = roomID }
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            guard let http = response as? HTTPURLResponse else { return .unreachable("no response") }
            if http.statusCode == 409 { return .fenced }
            guard (200..<300).contains(http.statusCode) else {
                return .unreachable("status \(http.statusCode)")
            }
            // Adopt the re-minted token (see the actor doc). Absent/malformed ⇒ keep the
            // current one — an older Worker that doesn't re-mint still heartbeats fine.
            if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
               let fresh = json["token"] as? String, !fresh.isEmpty {
                token = fresh
            }
            return .ok
        } catch {
            return .unreachable(error.localizedDescription)
        }
    }
}
