import Foundation
import NucleicCore

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

/// `nucleicd` — the headless Antimatter runner host (docs/ANTIMATTER_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")
///   NUCLEIC_RUNNER_PROJECTS        git URLs (comma/whitespace-separated) to clone + register at
///                                  boot (ANTIMATTER_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_CONTROL_PORT    loopback 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
///
/// 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

        // A daemon launched by launchd/a supervisor inherits the same blank-API-key hazard the
        // GUI app scrubs: Claude/Grok/ACP CLIs prefer the env var over their logins, so a blank
        // inherited key would 401 every session. Purge blanks; real keys stay.
        for key in [
            "ANTHROPIC_API_KEY", "XAI_API_KEY", "OPENCODE_API_KEY", "CURSOR_API_KEY",
            "HERMES_API_KEY",
        ] where SessionController.isBlankAPIKey(env[key]) {
            unsetenv(key)
        }

        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 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" {
            store.syncHostName = (Host.current().localizedName ?? "Mac") + " 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 (ANTIMATTER_RUNNER §3).
        store.meshSelfKind = .cloud

        // The same startup choreography as the cockpit's `.task`, minus UI concerns.
        await store.activateConflictArbitration()
        await store.loadProjects()
        await store.loadSessions()
        store.sweepOrphanedScratchDirs()
        await store.recoverInterruptedTransfers()
        await store.loadTodos()

        // Interim project seeding (ANTIMATTER_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 (ANTIMATTER_RUNNER §0.2 item 3d): GET /pairing serves the
        // one-shot provisioning payload the pool's `GET /v1/pool/pairing` proxy expects (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
            let endpoint = ControlEndpoint(port: port) { [weak store] in
                await store?.mintPairingCode()
            }
            do {
                try endpoint.start()
                controlEndpoint = endpoint
                log("control endpoint on 127.0.0.1:\(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 sessions = await store?.sessionSummaries().count ?? 0
                    // 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, 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))
                }
            }
        }

        // 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()
        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)
    }

    /// The cockpit's object graph (NucleicApp.init), headless: same stores, same backend
    /// factory, no AppKit, no `AppleIntelligenceProvider` — intelligence stays on the Core
    /// default (heuristics) until the runner providers land (ANTIMATTER_RUNNER §5: agent-backed
    /// or mesh-delegated, both falling back to exactly this default).
    private static func makeStore(support: URL) throws -> AppStore {
        let database = try GRDBMetadataStore(
            path: support.appendingPathComponent("nucleic.sqlite").path)
        let worktrees = GitWorktreeManager()
        let transcriptsDir = support.appendingPathComponent("sessions", isDirectory: true)
        let approvalServerRegistry = ApprovalServerRegistry()
        let containerManager = ContainerManager(
            onSharedContainerRemoved: { name in
                await approvalServerRegistry.teardown(containerName: name)
            })
        let macVMManager = MacVMManager()
        let conflictCoordinator = ConflictCoordinator()
        let store = AppStore(
            database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
            containerManager: containerManager, macVMManager: macVMManager,
            conflictCoordinator: conflictCoordinator
        ) { session in
            switch session.backend {
            case .codex:
                return CodexAppServerBackend(
                    containerManager: containerManager,
                    conflictCoordinator: conflictCoordinator,
                    approvalServerRegistry: approvalServerRegistry)
            case .codexExec:
                return CodexExecBackend(
                    containerManager: containerManager,
                    conflictCoordinator: conflictCoordinator,
                    approvalServerRegistry: approvalServerRegistry)
            case .claudeCode:
                return ClaudeCodeBackend(
                    configuration: .init(closeStdinAfterPrompt: true),
                    containerManager: containerManager,
                    macVMManager: macVMManager,
                    conflictCoordinator: conflictCoordinator,
                    approvalServerRegistry: approvalServerRegistry)
            case .grok, .opencode, .openclaw, .hermes, .cursorAgent, .acp:
                let agent = ACPAgent.forBackend(session.backend) ?? .grok
                return ACPBackend(
                    configuration: .init(agent: agent),
                    conflictCoordinator: conflictCoordinator,
                    containerManager: containerManager,
                    approvalServerRegistry: approvalServerRegistry)
            }
        }
        // The new-chat *defaults* (`ModelCatalog.stored*`) live in the App layer and don't apply
        // headlessly: a runner receives model/effort explicitly in each dispatched
        // `StartChatRequest`, so `AppStore`'s own field defaults are the right fallback and the
        // phone's picker uses its built-in list (an empty `Welcome.modelCatalog` decodes to that
        // fallback by design). Nothing to seed here.
        return store
    }

    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 (ANTIMATTER_RUNNER §8): heartbeats with epoch fencing. Present only
/// in container mode — a bare self-hosted `nucleicd` has no pool and skips it entirely.
private struct RunnerControlClient: Sendable {
    let baseURL: URL
    let 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, 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")
        var body: [String: Any] = ["epoch": epoch, "sessions": sessions]
        // 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 (_, 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)")
            }
            return .ok
        } catch {
            return .unreachable(error.localizedDescription)
        }
    }
}
