import Foundation

/// Transport layer for the MCMS REST API.
///
/// Responsibilities:
///  - build versioned requests from `MCMSEndpoint` + `APIQuery`
///  - inject auth headers: `Referer`, `Content-Type`, and `X-CSRFToken`
///    (sourced from the CSRF cookie) on mutating requests
///  - let `URLSession` manage the session/CSRF cookies
///  - unwrap the `{ status, data, details }` envelope and map status codes to `APIError`
///
/// Works identically for direct and proxied access — only `APIConfiguration.baseURL`
/// (and optionally `refererOverride`) differ.
@MainActor
final class APIClient {

    let config: APIConfiguration
    private let session: URLSession
    private let trustDelegate: ServerTrustEvaluator
    private let session_: SessionStore

    /// Invoked when a request fails with 401/403 so the app can route back to
    /// login. Set by `MCMSConnection`/`AppEnvironment`.
    var onAuthExpired: (@MainActor () -> Void)?

    /// Documents decode their bodies into `JSONValue`, so no `Date` is ever
    /// decoded here — a custom `dateDecodingStrategy` would be dead code (and it
    /// tripped a `@MainActor`-isolation warning). Timestamps are surfaced as
    /// strings and parsed for display by `MongoTimestamp`. If you promote a field
    /// to a typed `Date`, install a strategy backed by `MongoTimestamp.date(from:)`.
    static func makeDecoder() -> JSONDecoder { JSONDecoder() }

    private let decoder = APIClient.makeDecoder()
    private let encoder = JSONEncoder()

    init(config: APIConfiguration, session: SessionStore) {
        self.config = config
        self.session_ = session
        self.trustDelegate = ServerTrustEvaluator(policy: config.trustPolicy)

        let cfg = URLSessionConfiguration.default
        cfg.httpCookieStorage = session.cookieStorage
        cfg.httpShouldSetCookies = true
        cfg.httpCookieAcceptPolicy = .always
        cfg.timeoutIntervalForRequest = config.timeout
        cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
        self.session = URLSession(configuration: cfg, delegate: trustDelegate, delegateQueue: nil)
    }

    /// A `URLSession` created with a delegate retains that delegate (and itself)
    /// until invalidated — Apple: an un-invalidated session "leaks memory until
    /// it exits." This client is rebuilt on every `configure`/`probe`, so tear the
    /// session down when the client goes away.
    deinit { session.invalidateAndCancel() }

    // MARK: - Public API

    /// GET returning a decoded `T` from the envelope's `data`.
    func get<T: Decodable>(
        _ endpoint: MCMSEndpoint,
        query: APIQuery = .empty,
        as type: T.Type = T.self,
        version: String? = nil
    ) async throws -> T {
        let request = try makeRequest(method: "GET", endpoint: endpoint, query: query, version: version)
        return try await execute(request)
    }

    /// GET returning the raw JSON body as `JSONValue`, bypassing the
    /// `{status,data}` envelope — for web-helper endpoints (e.g. the CPE helper)
    /// that return a bare array.
    func getRaw(_ endpoint: MCMSEndpoint, query: APIQuery = .empty, version: String? = nil) async throws -> JSONValue {
        let request = try makeRequest(method: "GET", endpoint: endpoint, query: query, version: version)
        let (data, response) = try await perform(request)
        let http = try httpResponse(response)
        guard (200...299).contains(http.statusCode) else {
            throw failure(status: http.statusCode, data: data)
        }
        guard !data.isEmpty else { return .null }
        do { return try decoder.decode(JSONValue.self, from: data) }
        catch { throw APIError.decoding(message: error.localizedDescription) }
    }

    /// PUT/POST/PATCH with an `Encodable` body. By default the body is wrapped
    /// as `{ "data": <body> }` per the standard endpoints; pass
    /// `wrapInData: false` for action endpoints that take a raw body
    /// (e.g. disable-onu `{ "disable": true }`).
    @discardableResult
    func send<T: Decodable, B: Encodable>(
        _ method: String,
        _ endpoint: MCMSEndpoint,
        body: B,
        wrapInData: Bool = true,
        query: APIQuery = .empty,
        as type: T.Type = T.self,
        version: String? = nil
    ) async throws -> T {
        let data: Data
        do {
            data = wrapInData
                ? try encoder.encode(DataEnvelope(data: body))
                : try encoder.encode(body)
        } catch {
            throw APIError.encoding(message: error.localizedDescription)
        }
        var request = try makeRequest(method: method, endpoint: endpoint, query: query, version: version)
        request.httpBody = data
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        injectCSRF(into: &request)
        return try await execute(request)
    }

    /// Mutating request with no body (e.g. ONU reset, broadcast-enable).
    @discardableResult
    func action<T: Decodable>(
        _ method: String,
        _ endpoint: MCMSEndpoint,
        as type: T.Type = T.self,
        version: String? = nil
    ) async throws -> T {
        var request = try makeRequest(method: method, endpoint: endpoint, query: .empty, version: version)
        injectCSRF(into: &request)
        return try await execute(request)
    }

    /// DELETE; returns the raw envelope details (often empty / 204).
    func delete(_ endpoint: MCMSEndpoint, version: String? = nil) async throws {
        var request = try makeRequest(method: "DELETE", endpoint: endpoint, query: .empty, version: version)
        injectCSRF(into: &request)
        let _: JSONValue? = try await executeOptional(request)
    }

    /// Fetches a full list by following the `next` cursor until a short page
    /// is returned. `pageLimit` defaults to 100: full Mongo docs are ~30 KB, so
    /// larger pages (the server default is 1000) can exceed the front-end proxy
    /// timeout on big fleets. See MCMS field guide §3.3–3.4.
    ///
    /// `idOf` MUST return the document's real `_id` (the cursor MCMS excludes
    /// at/before), or nil. Pass `{ $0.documentId }`, never the synthesized
    /// `Identifiable.id` — a fabricated id is a meaningless cursor that makes the
    /// server re-serve or skip pages. A nil id safely stops pagination.
    func getAll<T: Decodable>(
        _ endpoint: MCMSEndpoint,
        baseQuery: APIQuery = .empty,
        pageLimit: Int = 100,
        idOf: (T) -> String?,
        version: String? = nil
    ) async throws -> [T] {
        var all: [T] = []
        var cursor: String? = nil
        repeat {
            var q = baseQuery
            q.limit = pageLimit
            q.next = cursor
            let page: [T] = try await get(endpoint, query: q, as: [T].self, version: version)
            all.append(contentsOf: page)
            // Stop on a short/empty page, or when the last doc has no usable `_id`.
            cursor = (page.count == pageLimit) ? page.last.flatMap(idOf) : nil
        } while cursor != nil
        return all
    }

    // MARK: - Request construction

    private func makeRequest(
        method: String,
        endpoint: MCMSEndpoint,
        query: APIQuery,
        version: String?
    ) throws -> URLRequest {
        let path = endpoint.path(version: version ?? config.apiVersion)
        guard var components = URLComponents(url: config.baseURL, resolvingAgainstBaseURL: false)
        else { throw APIError.invalidURL }
        // Append the endpoint path and let URLComponents percent-encode it ONCE.
        // `appendingPathComponent` re-encodes an already-encoded id (a MAC's `:` →
        // `%3A` → `%253A`), which MCMS rejects as an invalid id — so ids are kept
        // raw in MCMSEndpoint and encoded here.
        components.path += path

        let items = query.queryItems()
        if !items.isEmpty { components.queryItems = items }
        guard let url = components.url else { throw APIError.invalidURL }

        var request = URLRequest(url: url)
        request.httpMethod = method
        request.setValue("application/json", forHTTPHeaderField: "Accept")
        request.setValue(config.refererValue, forHTTPHeaderField: "Referer")
        return request
    }

    /// Echo the CSRF cookie value as the `X-CSRFToken` header. Without this,
    /// writes are rejected with 403.
    private func injectCSRF(into request: inout URLRequest) {
        if let token = session_.csrfToken() {
            request.setValue(token, forHTTPHeaderField: "X-CSRFToken")
        }
    }

    // MARK: - Execution

    private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
        let (data, response) = try await perform(request)
        let http = try httpResponse(response)

        guard (200...299).contains(http.statusCode) else {
            throw failure(status: http.statusCode, data: data)
        }
        if http.statusCode == 204 || data.isEmpty {
            if let empty = emptyValue(for: T.self) { return empty }
            throw APIError.decoding(message: "Empty body for non-optional response")
        }
        return try decodeEnvelope(data)
    }

    private func executeOptional<T: Decodable>(_ request: URLRequest) async throws -> T? {
        let (data, response) = try await perform(request)
        let http = try httpResponse(response)
        guard (200...299).contains(http.statusCode) else {
            throw failure(status: http.statusCode, data: data)
        }
        if http.statusCode == 204 || data.isEmpty { return nil }
        return try decodeEnvelope(data)
    }

    /// Maps a non-2xx status to an `APIError` and fires the re-auth hook on 401/403.
    private func failure(status: Int, data: Data) -> APIError {
        let error = APIError.from(statusCode: status, details: decodeDetails(data))
        if error.requiresReauthentication { onAuthExpired?() }
        return error
    }

    /// The "empty" instance for a type that opts into `EmptyRepresentable`.
    private func emptyValue<T>(for type: T.Type) -> T? {
        (type as? EmptyRepresentable.Type)?.emptyValue as? T
    }

    private func perform(_ request: URLRequest) async throws -> (Data, URLResponse) {
        do {
            return try await session.data(for: request)
        } catch let urlError as URLError {
            switch urlError.code {
            case .serverCertificateUntrusted, .serverCertificateHasBadDate,
                 .serverCertificateHasUnknownRoot, .serverCertificateNotYetValid,
                 .secureConnectionFailed:
                throw APIError.serverTrust
            default:
                throw APIError.transport(message: urlError.localizedDescription)
            }
        } catch {
            throw APIError.transport(message: error.localizedDescription)
        }
    }

    private func httpResponse(_ response: URLResponse) throws -> HTTPURLResponse {
        guard let http = response as? HTTPURLResponse else {
            throw APIError.transport(message: "Non-HTTP response")
        }
        return http
    }

    private func decodeEnvelope<T: Decodable>(_ data: Data) throws -> T {
        do {
            let envelope = try decoder.decode(APIResponse<T>.self, from: data)
            guard envelope.isSuccess else {
                throw APIError.apiFailure(details: envelope.details)
            }
            if let value = envelope.data { return value }
            // A successful PUT/POST/PATCH replies `{"status":"success"}` with no
            // `data` (only GET returns `data`). Hand back the type's empty value
            // instead of failing a request that actually succeeded.
            if let empty = emptyValue(for: T.self) { return empty }
            throw APIError.decoding(message: "Success response missing `data`")
        } catch let apiError as APIError {
            throw apiError
        } catch {
            throw APIError.decoding(message: error.localizedDescription)
        }
    }

    /// Best-effort decode of the `details` payload from an error response.
    private func decodeDetails(_ data: Data) -> JSONValue? {
        guard !data.isEmpty else { return nil }
        if let env = try? decoder.decode(APIResponse<JSONValue>.self, from: data) {
            return env.details
        }
        return try? decoder.decode(JSONValue.self, from: data)
    }
}
