package no.svorka.mcms.core

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
import kotlin.coroutines.resumeWithException

private fun Response.closeQuietly() { runCatching { close() } }

/** Suspend bridge for an OkHttp call, with coroutine cancellation. */
@OptIn(ExperimentalCoroutinesApi::class)   // resume(value) { onCancellation } overload
suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
    enqueue(object : Callback {
        override fun onResponse(call: Call, response: Response) {
            // If the continuation was already cancelled, resume() is a no-op and the
            // Response would otherwise leak — close it via the onCancellation handler.
            cont.resume(response) { _ -> response.closeQuietly() }
        }
        override fun onFailure(call: Call, e: IOException) {
            if (!cont.isCancelled) cont.resumeWithException(e)
        }
    })
    cont.invokeOnCancellation { runCatching { cancel() } }
}

/**
 * Transport for the MCMS REST API. Builds requests, injects auth headers, unwraps
 * the `{status,data}` envelope, and maps status codes to [ApiError]. Returns raw
 * [JsonElement] trees that the repositories wrap. Endpoint versions are fixed per
 * endpoint in [McmsEndpoint.path].
 */
class ApiClient(
    private val config: ApiConfiguration,
    private val session: SessionStore,
) {
    /** Fired on 401 so the app can route back to login. */
    var onAuthExpired: (() -> Unit)? = null

    private val jsonMedia = "application/json".toMediaType()

    private val client: OkHttpClient = OkHttpClient.Builder().apply {
        cookieJar(session.cookieJar)
        callTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
        ServerTrust.apply(this, config.trustPolicy)
    }.build()

    suspend fun getElement(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY): JsonElement =
        unwrapData(perform("GET", endpoint, query, null))

    suspend fun getList(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY): List<JsonElement> =
        getElement(endpoint, query).array ?: emptyList()

    /** Raw body, bypassing the envelope — for the bare-array CPE helper. */
    suspend fun getRaw(endpoint: McmsEndpoint): JsonElement {
        val resp = perform("GET", endpoint, ApiQuery.EMPTY, null)
        if (resp.body.isBlank()) return JsonNull
        return runCatching { MCMSJson.parseToJsonElement(resp.body) }
            .getOrElse { throw ApiError.Decoding(it.message ?: "invalid JSON in response") }
    }

    suspend fun send(method: String, endpoint: McmsEndpoint, body: JsonElement, wrapInData: Boolean = true): JsonElement {
        val payload = if (wrapInData) buildJsonObject { put("data", body) } else body
        return unwrapData(perform(method, endpoint, ApiQuery.EMPTY, payload.toString()))
    }

    suspend fun action(method: String, endpoint: McmsEndpoint): JsonElement =
        unwrapData(perform(method, endpoint, ApiQuery.EMPTY, null))

    suspend fun delete(endpoint: McmsEndpoint) {
        perform("DELETE", endpoint, ApiQuery.EMPTY, null)
    }

    /** Paginates by the `next` cursor; page size 100 (field guide §3.4). */
    suspend fun getAll(
        endpoint: McmsEndpoint,
        baseQuery: ApiQuery = ApiQuery.EMPTY,
        pageLimit: Int = 100,
        idOf: (JsonElement) -> String?,
    ): List<JsonElement> {
        val all = mutableListOf<JsonElement>()
        var cursor: String? = null
        do {
            val q = baseQuery.copy(limit = pageLimit, next = cursor)
            val page = getList(endpoint, q)
            all += page
            cursor = if (page.size == pageLimit) {
                // A full page with no usable cursor would silently truncate the result —
                // fail loudly instead of returning a partial fleet as if it were complete.
                page.lastOrNull()?.let(idOf)
                    ?: throw ApiError.Decoding("pagination cursor missing on a full page; cannot guarantee a complete result")
            } else null
        } while (cursor != null)
        return all
    }

    private data class Resp(val code: Int, val body: String)

    private fun buildUrl(endpoint: McmsEndpoint, query: ApiQuery): HttpUrl {
        val base = config.baseUrl.toHttpUrlOrNull() ?: throw ApiError.InvalidUrl
        val builder = base.newBuilder()
        // Raw ids; addPathSegments encodes once and keeps ':' in MAC ids raw.
        builder.addPathSegments(endpoint.path().removePrefix("/"))
        for ((name, value) in query.items()) builder.addQueryParameter(name, value)
        return builder.build()
    }

    private suspend fun perform(method: String, endpoint: McmsEndpoint, query: ApiQuery, bodyJson: String?): Resp {
        val url = buildUrl(endpoint, query)
        val reqBody = when {
            bodyJson != null -> bodyJson.toRequestBody(jsonMedia)
            method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody()
            else -> null
        }
        val builder = Request.Builder().url(url)
            .header("Accept", "application/json")
            .header("User-Agent", USER_AGENT)   // §3.1: an empty UA is rejected
            .header("Referer", config.refererValue)
            .method(method, reqBody)
        if (bodyJson != null) builder.header("Content-Type", "application/json")
        if (method != "GET") session.csrfToken()?.let { builder.header("X-CSRFToken", it) }
        val request = builder.build()

        // The blocking socket read (and connect) runs off the main thread; the
        // status mapping + onAuthExpired callback resume on the caller (Main) so the
        // Compose-state write in onAuthExpired stays on the main thread.
        val (code, body) = withContext(Dispatchers.IO) {
            val response = try {
                client.newCall(request).await()
            } catch (e: SSLException) {
                throw ApiError.ServerTrust
            } catch (e: IOException) {
                throw ApiError.Transport(e.message ?: "request failed")
            }
            response.use { it.code to (it.body?.string() ?: "") }
        }

        if (code !in 200..299) {
            val details = runCatching { MCMSJson.parseToJsonElement(body)["details"] }.getOrNull()
            val error = ApiError.fromStatus(code, details)
            if (error.requiresReauthentication) onAuthExpired?.invoke()
            throw error
        }
        return Resp(code, body)
    }

    private fun unwrapData(resp: Resp): JsonElement {
        if (resp.body.isBlank()) return JsonNull
        val root = runCatching { MCMSJson.parseToJsonElement(resp.body) }
            .getOrElse { throw ApiError.Decoding(it.message ?: "invalid JSON in response") }
        val status = root["status"].string
        // MCMS treats "warning" as success (the write committed; only a schema
        // mismatch is reported in `details`) — see MCMS_API.md §3.5.
        if (status != null && !status.equals("success", ignoreCase = true) && !status.equals("warning", ignoreCase = true)) {
            throw ApiError.ApiFailure(root["details"])
        }
        return root["data"] ?: JsonNull
    }

    private companion object {
        const val USER_AGENT = "PONGo-Android/1.0"
    }
}
