import Foundation
import NucleicProtocol

#if canImport(Security)
import Security
#endif
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// Settings → pool: the client side of the runner control plane (docs/ANTIMATTER_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 the login Keychain,
/// 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`) or production.
    public static func resolveBaseURL() -> URL {
        guard let override = RunnerSettings.controlURL, let url = URL(string: override) else {
            return defaultBaseURL
        }
        return url
    }

    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)

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

    /// This device's pool — the same string as its `DeviceIdentity.hostID`.
    public nonisolated var poolID: String { 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 (or confirm) the pool's host-of-record container. Idempotent on the pool side.
    public func provision() async throws {
        _ = try await authed("POST", "/v1/pool/provision")
    }

    /// Wake the host container (provision-by-another-name; the relay presence hook calls the
    /// same pool verb server-side).
    public func wake() async throws {
        _ = try await authed("POST", "/v1/pool/wake")
    }

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

    /// 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.maxContainersRange`; keep them in lockstep), so a stale client
    /// can't exceed the cap.
    public func pushSettings(
        maxContainers: Int? = nil, balancerPolicy: RunnerBalancerPolicy? = nil,
        idleTimeoutSeconds: Int? = nil
    ) async throws {
        var patch: [String: Any] = [:]
        if let maxContainers { patch["maxContainers"] = maxContainers }
        if let balancerPolicy { patch["balancerPolicy"] = balancerPolicy.rawValue }
        if let idleTimeoutSeconds { patch["idleTimeoutSeconds"] = idleTimeoutSeconds }
        _ = try await authed("PATCH", "/v1/pool/settings", body: patch)
    }

    /// The one-shot pairing payload of a freshly provisioned host container (the Worker proxies
    /// to nucleicd's loopback control endpoint). Returns the `nucleic://pair?d=…` string; a
    /// second call answers 410 — restart the container to re-provision.
    public func pairingCode() async throws -> String {
        let credential = try await ensureEnrolled()
        var request = URLRequest(url: baseURL.appendingPathComponent("/v1/pool/pairing"))
        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.requestFailed("pairing unavailable (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)
        RunnerPoolCredentialStore.save(fresh)
        credential = fresh
        lastEnrollAttempt = nil
        return fresh
    }

    // MARK: - Plumbing

    /// One authenticated round-trip, with the single 401-re-enroll retry every authed verb
    /// shares (a lapsed/rotated credential is recoverable by possession alone).
    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") {
            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 maxContainers: Int
        public let balancerPolicy: String
        public let idleTimeoutSeconds: Int
    }

    public struct Instance: Decodable, Sendable, Equatable, Identifiable {
        public let instanceId: String
        /// "starting" | "ready" | "stopped".
        public let state: String
        public let sessions: Int
        public let epoch: Int
        /// ms since epoch (the Worker's clock).
        public let lastSeenAt: Double
        /// 0 = in-place (host), 1 = session sandbox.
        public let tier: Int
        /// Heartbeat older than the staleness window — running but not placeable.
        public let stale: Bool

        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.
struct RunnerPoolCredential: Sendable {
    let poolID: String
    let secret: String

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

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

    #if canImport(Security)
    private static var identity: [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
        ]
    }

    static func load() -> RunnerPoolCredential? {
        guard let data = KeychainOwnedAccess.copyData(identity),
              let joined = String(data: data, encoding: .utf8),
              let dot = joined.firstIndex(of: ".")
        else { return nil }
        return RunnerPoolCredential(
            poolID: String(joined[..<dot]), secret: String(joined[joined.index(after: dot)...]))
    }

    static func save(_ credential: RunnerPoolCredential) {
        var base = identity
        base[kSecValueData as String] = Data(credential.bearer.utf8)
        KeychainOwnedAccess.set(base)
    }
    #else
    // Linux: the 0600-file secret store (CLOUD_RUNTIME §3.1's Keychain seam).
    static func load() -> RunnerPoolCredential? {
        guard let data = LinuxSecretStore.read(account: account),
              let joined = String(data: data, encoding: .utf8),
              let dot = joined.firstIndex(of: ".")
        else { return nil }
        return RunnerPoolCredential(
            poolID: String(joined[..<dot]), secret: String(joined[joined.index(after: dot)...]))
    }

    static func save(_ credential: RunnerPoolCredential) {
        LinuxSecretStore.write(account: account, data: Data(credential.bearer.utf8))
    }
    #endif
}
