import Foundation
import NucleicProtocol

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// Settings → pool: the client side of the runner control plane (docs/COVALENCE_RUNNER.md §7–8,
/// §0.2 item 4). Mirrors `RelayAccess` exactly: an X25519 proof-of-possession enrollment binds
/// this Mac to *its* pool (`poolId = sha256(static public key)` — the same string as its
/// `hostID`/relay roomId), the minted `poolId.poolSecret` credential lives in Nucleic’s secret store,
/// and the pool-credential routes drive provisioning, the Settings knobs, and the status list.
///
/// A 401 on any authed call means the credential lapsed server-side (KV TTL after a long dark
/// spell) or was rotated by a re-enroll elsewhere: re-enroll once — possession of the static
/// private key is always sufficient — and retry.
///
/// Sharing the credential to the owner's other mesh devices (so any of them can manage the
/// pool, the way relay memberships ride the mesh) is the follow-up named in §0.2 item 4.
public actor RunnerPoolClient {
    /// The production control plane every distributed build talks to.
    public static let defaultBaseURL = URL(string: "https://runner.nucleic.blakeslee.xyz")!

    /// Resolve the control-plane base URL: the dev/self-host override (Settings field, stored
    /// under `RunnerSettings.controlURLKey`), then the URL a mesh-shared credential carries
    /// (the pool lives where its owner enrolled it — item 4), then production.
    public static func resolveBaseURL() -> URL {
        if let override = RunnerSettings.controlURL, let url = URL(string: override) {
            return url
        }
        if let shared = RunnerPoolCredentialStore.load()?.url, let url = URL(string: shared) {
            return url
        }
        return defaultBaseURL
    }

    public let baseURL: URL
    private let identity: DeviceIdentity
    private let session: URLSession
    private var credential: RunnerPoolCredential?
    /// Enrollment backoff so an unreachable control plane isn't hammered by every Settings poke.
    private var lastEnrollAttempt: Date?
    private static let enrollRetryInterval: TimeInterval = 60

    public init(identity: DeviceIdentity, baseURL: URL? = nil, session: URLSession = .shared) {
        self.identity = identity
        self.baseURL = baseURL ?? Self.resolveBaseURL()
        self.session = session
        self.credential = RunnerPoolCredentialStore.load()
    }

    public enum Error: Swift.Error, LocalizedError {
        case enrollFailed(String)
        case requestFailed(String)
        /// `GET /v1/pool/pairing` answered a non-200: 410 = the one-shot payload was already
        /// served this boot; 409/5xx = the container isn't up (or reachable) yet — retryable.
        case pairingUnavailable(status: Int)

        public var errorDescription: String? {
            switch self {
            case .enrollFailed(let why): "Runner enrollment failed: \(why)"
            case .requestFailed(let why): "Runner pool request failed: \(why)"
            case .pairingUnavailable(let status): "Runner pairing unavailable (status \(status))"
            }
        }
    }

    /// The pool this client manages: the stored credential's pool when one exists — possibly a
    /// credential another mesh device enrolled and shared (item 4) — else this device's own
    /// (`DeviceIdentity.hostID`, what a fresh enrollment would create).
    public var poolID: String {
        credential?.poolID ?? identity.hostID
    }

    // MARK: - Pool management (the Settings surface)

    // The mutating verbs return Void across the actor boundary — their JSON bodies are
    // acknowledgments, not data (`GET /status` is the read surface), and `[String: Any]`
    // isn't Sendable.

    /// Boot one more runner if the fleet is under the cap (demand-driven scale-up). Returns the
    /// booted slot's `instanceId` (e.g. `runner-2`) so the caller can pull that runner's pairing
    /// payload and complete its mesh join, or `nil` when the pool is already at the `maxRunners`
    /// cap (a normal, non-error outcome — the caller keeps the chat queued on the mesh).
    @discardableResult
    public func provision() async throws -> String? {
        let json = try await authed("POST", "/v1/pool/provision")
        if json["decision"] as? String == "at_capacity" { return nil }
        return json["instanceId"] as? String
    }

    /// Bare (untargeted) pool wake — provision-by-another-name: ensure one more runner under
    /// the cap. Note the relay presence hook's *targeted* wake is different: the pool declines
    /// to reboot a dead slot there (`identityRotated`), because a reboot mints a fresh identity
    /// that can never serve the room the device was dialing — capacity returns through
    /// `provision` + the device-side adopt/join choreography instead.
    public func wake() async throws {
        _ = try await authed("POST", "/v1/pool/wake")
    }

    /// Stop every runner in the pool.
    public func stopAll() async throws {
        _ = try await authed("POST", "/v1/pool/stop")
    }

    /// Delete every stopped runner row from the pool registry. A stopped row holds no
    /// container — its slot is already reusable — but rows otherwise pile up in the Settings
    /// list forever. Live runners are untouched.
    public func pruneStopped() async throws {
        _ = try await authed("POST", "/v1/pool/prune")
    }

    /// The pool's registry — settings, epoch, and the per-instance rows the Settings list
    /// renders.
    public func status() async throws -> RunnerPoolStatus {
        let json = try await authed("GET", "/v1/pool/status")
        let data = try JSONSerialization.data(withJSONObject: json)
        do {
            return try JSONDecoder().decode(RunnerPoolStatus.self, from: data)
        } catch {
            throw Error.requestFailed("malformed status: \(error)")
        }
    }

    /// Push the Settings knobs. The DO re-clamps server-side (`clampSettings` — same 1…16
    /// bounds as `RunnerSettings.maxRunnersRange`; keep them in lockstep), so a stale client
    /// can't exceed the cap. `intelligenceMode` reaches a container as
    /// `NUCLEIC_RUNNER_INTELLIGENCE_MODE` on its next boot (COVALENCE_RUNNER §5, item 6).
    public func pushSettings(
        maxRunners: Int? = nil,
        idleTimeoutSeconds: Int? = nil, intelligenceMode: RunnerIntelligenceMode? = nil
    ) async throws {
        var patch: [String: Any] = [:]
        if let maxRunners { patch["maxRunners"] = maxRunners }
        if let idleTimeoutSeconds { patch["idleTimeoutSeconds"] = idleTimeoutSeconds }
        if let intelligenceMode { patch["intelligenceMode"] = intelligenceMode.rawValue }
        _ = try await authed("PATCH", "/v1/pool/settings", body: patch)
    }

    /// A one-time pairing payload for a runner container (the Worker proxies to that runner's
    /// nucleicd control endpoint, addressed by slot id). Returns the `nucleic://pair?d=…`
    /// string. Current runners mint a fresh code per call, so a failed pull or join is
    /// recovered by calling again; a runner on an older image burns its single code after one
    /// serve and answers 410 thereafter (restart it to re-arm).
    public func pairingCode(instance: String) async throws -> String {
        let credential = try await ensureEnrolled()
        var components = URLComponents(
            url: baseURL.appendingPathComponent("/v1/pool/pairing"), resolvingAgainstBaseURL: false)
        components?.queryItems = [URLQueryItem(name: "instance", value: instance)]
        guard let url = components?.url else { throw Error.requestFailed("bad pairing URL") }
        var request = URLRequest(url: url)
        request.timeoutInterval = 15
        request.setValue("Bearer \(credential.bearer)", forHTTPHeaderField: "Authorization")
        guard let (data, response) = try? await session.data(for: request),
              let http = response as? HTTPURLResponse
        else { throw Error.requestFailed("control plane unreachable") }
        guard http.statusCode == 200, let code = String(data: data, encoding: .utf8) else {
            throw Error.pairingUnavailable(status: http.statusCode)
        }
        return code.trimmingCharacters(in: .whitespacesAndNewlines)
    }

    // MARK: - Enrollment (PoP, mirroring RelayAccess)

    private func ensureEnrolled() async throws -> RunnerPoolCredential {
        if let credential { return credential }
        return try await enroll()
    }

    private func enroll() async throws -> RunnerPoolCredential {
        let now = Date()
        if let last = lastEnrollAttempt, now.timeIntervalSince(last) < Self.enrollRetryInterval {
            throw Error.enrollFailed("control plane unreachable (retrying shortly)")
        }
        lastEnrollAttempt = now

        let staticKeyB64 = RunnerEnrollment.staticKeyEncoding(identity.staticPublicKey)
        let challenge = try await postJSON(
            "/v1/runner/enroll/challenge", body: ["staticKey": staticKeyB64],
            or: Error.enrollFailed("challenge request"))
        guard let challengeID = challenge["challengeId"] as? String,
              let ephB64 = challenge["ephemeralPublicKey"] as? String,
              let ephemeralPublicKey = Data(base64URLNoPad: ephB64)
        else { throw Error.enrollFailed("malformed challenge") }

        let proof = try RunnerEnrollment.proof(
            staticKey: identity.staticKey, ephemeralPublicKey: ephemeralPublicKey,
            staticKeyB64: staticKeyB64)
        let enrolled = try await postJSON(
            "/v1/runner/enroll",
            body: ["challengeId": challengeID, "proof": proof.base64URLNoPadEncoded],
            or: Error.enrollFailed("proof rejected"))
        guard let poolID = enrolled["poolId"] as? String, poolID == identity.hostID,
              let secret = enrolled["poolSecret"] as? String
        else { throw Error.enrollFailed("malformed enrollment response") }

        let fresh = RunnerPoolCredential(
            poolID: poolID, secret: secret,
            url: baseURL == Self.defaultBaseURL ? nil : baseURL.absoluteString,
            updatedAt: Date())
        RunnerPoolCredentialStore.save(fresh)
        credential = fresh
        lastEnrollAttempt = nil
        return fresh
    }

    /// Adopt a credential shared over the mesh (item 4) — already merged (newest `updatedAt`
    /// wins) and persisted by the app; this just swaps the in-memory copy so in-flight clients
    /// use it without a rebuild.
    public func adopt(_ credential: RunnerPoolCredential) {
        self.credential = credential
    }

    // MARK: - Plumbing

    /// One authenticated round-trip, with the single 401-re-enroll retry every authed verb
    /// shares. Re-enrollment is only attempted for this device's OWN pool — possession of the
    /// static key recovers that one. A 401 on a *shared* credential (another device's pool,
    /// item 4) means it was rotated elsewhere; re-enrolling here would silently create THIS
    /// device's pool and point the UI at the wrong one, so surface it instead.
    private func authed(
        _ method: String, _ path: String, body: [String: Any]? = nil
    ) async throws -> [String: Any] {
        let credential = try await ensureEnrolled()
        do {
            return try await perform(method, path, body: body, bearer: credential.bearer)
        } catch Error.requestFailed(let why) where why.contains("status 401") {
            guard credential.poolID == identity.hostID else {
                throw Error.requestFailed(
                    "the shared pool credential was rotated — re-share it from the Mac that owns the pool")
            }
            self.credential = nil
            let fresh = try await enroll()
            return try await perform(method, path, body: body, bearer: fresh.bearer)
        }
    }

    private func perform(
        _ method: String, _ path: String, body: [String: Any]?, bearer: String
    ) async throws -> [String: Any] {
        var request = URLRequest(url: baseURL.appendingPathComponent(path))
        request.httpMethod = method
        request.timeoutInterval = 15
        request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
        if let body {
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        }
        guard let (data, response) = try? await session.data(for: request),
              let http = response as? HTTPURLResponse
        else { throw Error.requestFailed("control plane unreachable") }
        guard (200..<300).contains(http.statusCode) else {
            throw Error.requestFailed("status \(http.statusCode)")
        }
        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            throw Error.requestFailed("malformed response")
        }
        return json
    }

    private func postJSON(
        _ path: String, body: [String: String], or error: Error
    ) async throws -> [String: Any] {
        var request = URLRequest(url: baseURL.appendingPathComponent(path))
        request.httpMethod = "POST"
        request.timeoutInterval = 10
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        guard let (data, response) = try? await session.data(for: request),
              (response as? HTTPURLResponse)?.statusCode == 200,
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
        else { throw error }
        return json
    }
}

// MARK: - Status models (the Worker's GET /v1/pool/status shape, pool.ts `status()`)

public struct RunnerPoolStatus: Decodable, Sendable, Equatable {
    public struct Settings: Decodable, Sendable, Equatable {
        public let maxRunners: Int
        public let idleTimeoutSeconds: Int
        /// "mesh" | "agent" | "heuristic" (COVALENCE_RUNNER §5); nil from a Worker that
        /// predates the knob.
        public let intelligenceMode: String?
    }

    public struct Instance: Decodable, Sendable, Equatable, Identifiable {
        public let instanceId: String
        /// "starting" | "ready" | "stopped".
        public let state: String
        public let sessions: Int
        /// Sessions still doing something — total minus the Done-and-quiet ones. When this is 0
        /// while `sessions > 0`, the runner holds only finished chats and the pool sleeps it once
        /// they settle (`DONE_SLEEP_MS`). Nil from a Worker that predates the field.
        public let activeSessions: Int?
        public let epoch: Int
        /// ms since epoch (the Worker's clock).
        public let lastSeenAt: Double
        /// Heartbeat older than the staleness window — running but wedged/gone.
        public let stale: Bool
        /// The runner's relay roomId (= its mesh HostID), from its heartbeats — how a device
        /// tells a mesh-joined runner from an orphan whose fresh identity nobody has paired
        /// (e.g. a wake-hook reboot). Nil until the first heartbeat carries one, or from an
        /// older Worker.
        public let roomId: String?

        public var id: String { instanceId }
    }

    public let poolId: String
    public let settings: Settings
    public let epoch: Int
    public let instances: [Instance]
}

// MARK: - Credential persistence (Keychain on Darwin, 0600 file on Linux)

/// The pool credential (`poolId` + secret) minted by PoP enrollment, presented as
/// `Bearer <poolId>.<poolSecret>` to every /v1/pool/* route. Separate Keychain account from
/// the relay-room credential — different service, different lifecycle. `updatedAt` is the
/// mesh-merge key (item 4): a shared revision replaces this one only when newer.
public struct RunnerPoolCredential: Sendable, Codable {
    public let poolID: String
    public let secret: String
    /// Control-plane base URL override (dev/self-host); nil ⇒ production.
    public let url: String?
    public let updatedAt: Date

    public init(poolID: String, secret: String, url: String? = nil, updatedAt: Date) {
        self.poolID = poolID
        self.secret = secret
        self.url = url
        self.updatedAt = updatedAt
    }

    var bearer: String { "\(poolID).\(secret)" }
}

public enum RunnerPoolCredentialStore {
    private static let account = "com.nucleic.host.runner-pool-credential"

    /// Serialized record. Earlier builds stored the bare `poolId.secret` bearer string —
    /// tolerated on load (updatedAt ⇒ distantPast, so any shared revision supersedes it).
    private static func decode(_ data: Data) -> RunnerPoolCredential? {
        if let credential = try? JSONDecoder().decode(RunnerPoolCredential.self, from: data) {
            return credential
        }
        guard let joined = String(data: data, encoding: .utf8),
              let dot = joined.firstIndex(of: "."), !joined.contains("{")
        else { return nil }
        return RunnerPoolCredential(
            poolID: String(joined[..<dot]), secret: String(joined[joined.index(after: dot)...]),
            updatedAt: .distantPast)
    }

    private static func encode(_ credential: RunnerPoolCredential) -> Data {
        (try? JSONEncoder().encode(credential)) ?? Data()
    }

    public static func load() -> RunnerPoolCredential? {
        guard let data = SecretStores.default.get(service: nil, account: account) else { return nil }
        return decode(data)
    }

    public static func save(_ credential: RunnerPoolCredential) {
        SecretStores.default.set(service: nil, account: account, data: encode(credential))
    }
}
