import Foundation
import Security
import NucleicProtocol

/// The host's data-path relay account (mesh P2, distinct from `PushRelayClient`'s push
/// credential): the proof-of-possession enrollment that binds this Mac to its relay room,
/// and the token mint that admits devices to it.
///
/// Enrollment (`cloud/nucleic-edge/src/relayEnroll.ts`) is an X25519 challenge: the room is
/// `sha256(host static public key)` — a *public* value that rides in every pairing QR — so
/// the relay only hands out the room's host credential to a caller that proves it holds the
/// matching static *private* key. The credential (`hostId.secret`) lives in the login
/// Keychain and is presented as a bearer to `POST /v1/relay/token`, whose minted membership
/// tokens are what devices (and this Mac itself, `role: host`) later trade for connection
/// tokens. Tokens authorize room admission only; every frame is Noise-E2EE.
public actor RelayAccess {
    /// Resolve the relay base URL for the data path: the dev override
    /// (`NUCLEIC_RELAY_URL` env var, then the same-named defaults key the dev Settings
    /// field writes) or the production relay. Unlike push, there is no enable gate here —
    /// the data path is gated by the Connection-methods checkbox (`SyncTransportSetting`).
    public static func resolveBaseURL(
        environment: [String: String] = ProcessInfo.processInfo.environment,
        defaults: UserDefaults = .standard
    ) -> URL {
        let override = environment["NUCLEIC_RELAY_URL"] ?? defaults.string(forKey: "NUCLEIC_RELAY_URL")
        return RelayAPI.baseURL((override?.isEmpty == false) ? override : nil)
    }

    public let baseURL: URL
    /// True when `baseURL` is a dev/self-host override that paired devices must also be told
    /// about (stamped into `WireRelayMembership.url` / the pairing QR).
    public var urlOverride: String? {
        baseURL == RelayAPI.defaultBaseURL ? nil : baseURL.absoluteString
    }

    private let identity: DeviceIdentity
    private let session: URLSession
    private var credential: RelayRoomCredential?
    /// Enrollment backoff so an unreachable relay isn't hammered by every mint attempt.
    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 = RelayRoomCredentialStore.load()
    }

    public enum Error: Swift.Error, LocalizedError {
        case enrollFailed(String)
        case mintFailed(String)

        public var errorDescription: String? {
            switch self {
            case .enrollFailed(let why): "Relay enrollment failed: \(why)"
            case .mintFailed(let why): "Relay token mint failed: \(why)"
            }
        }
    }

    /// This host's room — the same string as `DeviceIdentity.hostID`.
    public nonisolated var roomID: String { identity.hostID }

    /// Mint a long-lived membership token for a device in this host's room, enrolling first
    /// when no credential is cached. A 401 means the credential lapsed server-side (KV TTL
    /// after a long dark spell): re-enroll once and retry.
    public func mintMembership(deviceID: String, role: String = "client") async throws -> WireRelayMembership {
        let credential = try await ensureEnrolled()
        do {
            return try await mint(deviceID: deviceID, role: role, bearer: credential.bearer)
        } catch Error.mintFailed(let why) where why.contains("401") {
            self.credential = nil
            let fresh = try await enroll()
            return try await mint(deviceID: deviceID, role: role, bearer: fresh.bearer)
        }
    }

    // MARK: - Enrollment (PoP)

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

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

        let staticKeyB64 = RelayEnrollment.staticKeyEncoding(identity.staticPublicKey)
        let challenge = try await postJSON(
            "/v1/relay/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 RelayEnrollment.proof(
            staticKey: identity.staticKey, ephemeralPublicKey: ephemeralPublicKey,
            staticKeyB64: staticKeyB64)
        let enrolled = try await postJSON(
            "/v1/relay/enroll",
            body: ["challengeId": challengeID, "proof": proof.base64URLNoPadEncoded],
            or: Error.enrollFailed("proof rejected"))
        guard let hostID = enrolled["hostId"] as? String,
              let secret = enrolled["hostSecret"] as? String,
              let roomID = enrolled["roomId"] as? String, roomID == identity.hostID
        else { throw Error.enrollFailed("malformed enrollment response") }

        let fresh = RelayRoomCredential(hostID: hostID, secret: secret)
        RelayRoomCredentialStore.save(fresh)
        credential = fresh
        lastEnrollAttempt = nil
        return fresh
    }

    // MARK: - Minting

    private func mint(deviceID: String, role: String, bearer: String) async throws -> WireRelayMembership {
        var request = URLRequest(url: baseURL.appendingPathComponent("/v1/relay/token"))
        request.httpMethod = "POST"
        request.timeoutInterval = 10
        request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: ["deviceId": deviceID, "role": role])
        guard let (data, response) = try? await session.data(for: request),
              let http = response as? HTTPURLResponse
        else { throw Error.mintFailed("relay unreachable") }
        guard http.statusCode == 200 else { throw Error.mintFailed("status \(http.statusCode)") }
        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let token = json["token"] as? String
        else { throw Error.mintFailed("malformed mint response") }
        return WireRelayMembership(
            roomID: roomID, token: token, url: urlOverride, exp: json["exp"] as? Double)
    }

    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
    }
}

/// The room credential (`hostId` + secret) minted by PoP enrollment, presented as
/// `Bearer <hostId>.<secret>` to `/v1/relay/token`. Persisted in the login Keychain,
/// separate from the push credential (`RelayCredentialStore`) — the two enrollments have
/// different scopes and lifecycles.
struct RelayRoomCredential: Sendable {
    let hostID: String
    let secret: String

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

enum RelayRoomCredentialStore {
    private static let account = "com.nucleic.host.relay-room-credential"

    private static var identity: [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
        ]
    }

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

    static func save(_ credential: RelayRoomCredential) {
        var base = identity
        base[kSecValueData as String] = Data(credential.bearer.utf8)
        KeychainOwnedAccess.set(base)
    }
}

// base64url helpers matching the Worker's `b64urlEncode`/`b64Decode` (no padding).
extension Data {
    init?(base64URLNoPad string: String) {
        var b64 = string.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
        while b64.count % 4 != 0 { b64.append("=") }
        self.init(base64Encoded: b64)
    }

    var base64URLNoPadEncoded: String {
        base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}
