package {{packageName}}.infrastructure

{{#supportAndroidApiLevel25AndBelow}}
import android.os.Build
{{/supportAndroidApiLevel25AndBelow}}
{{#hasAuthMethods}}
{{#isBasicBasic}}
import okhttp3.Credentials
{{/isBasicBasic}}
{{/hasAuthMethods}}
import okhttp3.OkHttpClient
import okhttp3.RequestBody
{{#jvm-okhttp3}}
import okhttp3.MediaType
{{/jvm-okhttp3}}
{{#jvm-okhttp4}}
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
{{/jvm-okhttp4}}
import okhttp3.FormBody
{{#jvm-okhttp3}}
import okhttp3.HttpUrl
{{/jvm-okhttp3}}
{{#jvm-okhttp4}}
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
{{/jvm-okhttp4}}
import okhttp3.ResponseBody
{{#jvm-okhttp4}}
import okhttp3.MediaType.Companion.toMediaTypeOrNull
{{/jvm-okhttp4}}
import okhttp3.Request
import okhttp3.Headers
import okhttp3.MultipartBody
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.net.URLConnection
{{^threetenbp}}
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.OffsetTime
{{/threetenbp}}
import java.util.Date
import java.util.Locale
{{#kotlinx_serialization}}
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
{{/kotlinx_serialization}}
{{#threetenbp}}
import org.threeten.bp.LocalDate
import org.threeten.bp.LocalDateTime
import org.threeten.bp.LocalTime
import org.threeten.bp.OffsetDateTime
import org.threeten.bp.OffsetTime
{{/threetenbp}}
import kotlinx.coroutines.delay
import kotlin.random.Random

{{#nonPublicApi}}internal {{/nonPublicApi}}open class ApiClient(val baseUrl: String) {
    {{#nonPublicApi}}internal {{/nonPublicApi}}companion object {
        protected const val ContentType = "Content-Type"
        protected const val Accept = "Accept"
        protected const val Authorization = "Authorization"
        protected const val JsonMediaType = "application/json"
        protected const val FormDataMediaType = "multipart/form-data"
        protected const val FormUrlEncMediaType = "application/x-www-form-urlencoded"
        protected const val XmlMediaType = "application/xml"
        protected const val UserAgent = "User-Agent"

        val apiKey: MutableMap<String, String> = mutableMapOf()
        val apiKeyPrefix: MutableMap<String, String> = mutableMapOf()
        var username: String? = null
        var password: String? = null

        @JvmStatic
        val client: OkHttpClient by lazy {
            builder.build()
        }

        @JvmStatic
        val builder: OkHttpClient.Builder = OkHttpClient.Builder()
    }

    var accessToken: String? = null
    var userAgent: String? = null
    var initialRetryDelayMillis = 50L
    var numRetries: Int = 3

    /**
     * Guess Content-Type header from the given file (defaults to "application/octet-stream").
     *
     * @param file The given file
     * @return The guessed Content-Type
     */
    protected fun guessContentTypeFromFile(file: File): String {
        val contentType = URLConnection.guessContentTypeFromName(file.name)
        return contentType ?: "application/octet-stream"
    }

    protected inline fun <reified T> requestBody(content: T, mediaType: String = JsonMediaType): RequestBody =
        when {
            {{#jvm-okhttp3}}
            content is File -> RequestBody.create(MediaType.parse(mediaType), content)
            {{/jvm-okhttp3}}
            {{#jvm-okhttp4}}
            content is File -> content.asRequestBody(mediaType.toMediaTypeOrNull())
            {{/jvm-okhttp4}}
            mediaType == FormDataMediaType -> {
                MultipartBody.Builder()
                    .setType(MultipartBody.FORM)
                    .apply {
                        // content's type *must* be Map<String, Any?>
                        @Suppress("UNCHECKED_CAST")
                        (content as Map<String, Any?>).forEach { (key, value) ->
                            if (value is File) {
                                val partHeaders = Headers.{{#jvm-okhttp3}}of{{/jvm-okhttp3}}{{#jvm-okhttp4}}headersOf{{/jvm-okhttp4}}(
                                    "Content-Disposition",
                                    "form-data; name=\"$key\"; filename=\"${value.name}\""
                                )
                                {{#jvm-okhttp3}}
                                val fileMediaType = MediaType.parse(guessContentTypeFromFile(value))
                                addPart(partHeaders, RequestBody.create(fileMediaType, value))
                                {{/jvm-okhttp3}}
                                {{#jvm-okhttp4}}
                                val fileMediaType = guessContentTypeFromFile(value).toMediaTypeOrNull()
                                addPart(partHeaders, value.asRequestBody(fileMediaType))
                                {{/jvm-okhttp4}}
                            } else {
                                val partHeaders = Headers.{{#jvm-okhttp3}}of{{/jvm-okhttp3}}{{#jvm-okhttp4}}headersOf{{/jvm-okhttp4}}(
                                    "Content-Disposition",
                                    "form-data; name=\"$key\""
                                )
                                addPart(
                                    partHeaders,
                                    {{#jvm-okhttp3}}
                                    RequestBody.create(null, parameterToString(value))
                                    {{/jvm-okhttp3}}
                                    {{#jvm-okhttp4}}
                                    parameterToString(value).toRequestBody(null)
                                    {{/jvm-okhttp4}}
                                )
                            }
                        }
                    }.build()
            }
            mediaType == FormUrlEncMediaType -> {
                FormBody.Builder().apply {
                    // content's type *must* be Map<String, Any?>
                    @Suppress("UNCHECKED_CAST")
                    (content as Map<String, Any?>).forEach { (key, value) ->
                        add(key, parameterToString(value))
                    }
                }.build()
            }
            {{#jvm-okhttp3}}
            mediaType == JsonMediaType -> RequestBody.create(
                {{#moshi}}
                MediaType.parse(mediaType), Serializer.moshi.adapter(T::class.java).toJson(content)
                {{/moshi}}
                {{#gson}}
                MediaType.parse(mediaType), Serializer.gson.toJson(content, T::class.java)
                {{/gson}}
                {{#jackson}}
                MediaType.parse(mediaType), Serializer.jacksonObjectMapper.writeValueAsString(content)
                {{/jackson}}
                {{#kotlinx_serialization}}
                MediaType.parse(mediaType), Serializer.jvmJson.encodeToString(content)
                {{/kotlinx_serialization}}
            )
            {{/jvm-okhttp3}}
            {{#jvm-okhttp4}}
            mediaType == JsonMediaType -> {
                // Don't call `toJson` on Unit type:
                if (T::class.java == Unit::class.java) {
                    ByteArray(0).toRequestBody()
                } else {
                    {{#moshi}}Serializer.moshi.adapter(T::class.java).toJson(content){{/moshi}}{{#gson}}Serializer.gson.toJson(content, T::class.java){{/gson}}{{#jackson}}Serializer.jacksonObjectMapper.writeValueAsString(content){{/jackson}}{{#kotlinx_serialization}}Serializer.jvmJson.encodeToString(content){{/kotlinx_serialization}}.toRequestBody(
                        mediaType.toMediaTypeOrNull()
                    )
                }
            }
            {{/jvm-okhttp4}}
            mediaType == XmlMediaType -> throw UnsupportedOperationException("xml not currently supported.")
            // TODO: this should be extended with other serializers
            else -> throw UnsupportedOperationException("requestBody currently only supports JSON body and File body.")
        }

    protected inline fun <reified T: Any?> responseBody(body: ResponseBody?, mediaType: String? = JsonMediaType): T? {
        if(body == null) {
            return null
        }
        val bodyContent = body.string()
        if (bodyContent.isEmpty()) {
            return null
        }
        if (T::class.java == File::class.java) {
            // return tempfile
            {{^supportAndroidApiLevel25AndBelow}}
            val f = java.nio.file.Files.createTempFile("tmp.{{packageName}}", null).toFile()
            {{/supportAndroidApiLevel25AndBelow}}
            {{#supportAndroidApiLevel25AndBelow}}
            val f = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                java.nio.file.Files.createTempFile("tmp.net.medicineone.teleconsultationandroid.openapi.openapicommon", null).toFile()
            } else {
                @Suppress("DEPRECATION")
                createTempFile("tmp.net.medicineone.teleconsultationandroid.openapi.openapicommon", null)
            }
            {{/supportAndroidApiLevel25AndBelow}}
            f.deleteOnExit()
            val out = BufferedWriter(FileWriter(f))
            out.write(bodyContent)
            out.close()
            return f as T
        }
        return when(mediaType) {
            JsonMediaType -> {{#moshi}}Serializer.moshi.adapter(T::class.java).fromJson(bodyContent){{/moshi}}{{#gson}}Serializer.gson.fromJson(bodyContent, T::class.java){{/gson}}{{#jackson}}Serializer.jacksonObjectMapper.readValue(bodyContent, T::class.java){{/jackson}}{{#kotlinx_serialization}}Serializer.jvmJson.decodeFromString<T>(bodyContent){{/kotlinx_serialization}}
            else ->  throw UnsupportedOperationException("responseBody currently only supports JSON body.")
        }
    }

    {{#hasAuthMethods}}
    protected fun <T> updateAuthParams(requestConfig: RequestConfig<T>) {
        {{#authMethods}}
        {{#isApiKey}}
        {{#isKeyInHeader}}
        if (requestConfig.headers["{{keyParamName}}"].isNullOrEmpty()) {
        {{/isKeyInHeader}}
        {{#isKeyInQuery}}
        if (requestConfig.query["{{keyParamName}}"].isNullOrEmpty()) {
        {{/isKeyInQuery}}
            if (apiKey["{{keyParamName}}"] != null) {
                if (apiKeyPrefix["{{keyParamName}}"] != null) {
                    {{#isKeyInHeader}}
                    requestConfig.headers["{{keyParamName}}"] = apiKeyPrefix["{{keyParamName}}"]!! + " " + apiKey["{{keyParamName}}"]!!
                    {{/isKeyInHeader}}
                    {{#isKeyInQuery}}
                    requestConfig.query["{{keyParamName}}"] = apiKeyPrefix["{{keyParamName}}"]!! + " " + apiKey["{{keyParamName}}"]!!
                    {{/isKeyInQuery}}
                } else {
                    {{#isKeyInHeader}}
                    requestConfig.headers["{{keyParamName}}"] = apiKey["{{keyParamName}}"]!!
                    {{/isKeyInHeader}}
                    {{#isKeyInQuery}}
                    requestConfig.query["{{keyParamName}}"] = apiKey["{{keyParamName}}"]!!
                    {{/isKeyInQuery}}
                }
            }
        }
        {{/isApiKey}}
        {{#isBasic}}
        {{#isBasicBasic}}
        if (requestConfig.headers[Authorization].isNullOrEmpty()) {
            username?.let { username ->
                password?.let { password ->
                    requestConfig.headers[Authorization] = Credentials.basic(username, password)
                }
            }
        }
        {{/isBasicBasic}}
        {{#isBasicBearer}}
        if (requestConfig.headers[Authorization].isNullOrEmpty()) {
            accessToken?.let { accessToken ->
                requestConfig.headers[Authorization] = "Bearer $accessToken"
            }
        }
        {{/isBasicBearer}}
        {{/isBasic}}
        {{#isOAuth}}
        if (requestConfig.headers[Authorization].isNullOrEmpty()) {
            accessToken?.let { accessToken ->
                requestConfig.headers[Authorization] = "Bearer $accessToken "
            }
        }
        {{/isOAuth}}
        {{/authMethods}}
    }
    {{/hasAuthMethods}}

    protected inline suspend fun <reified I, reified T: Any?> request(requestConfig: RequestConfig<I>): ApiInfrastructureResponse<T?> {
        {{#jvm-okhttp3}}
        val httpUrl = HttpUrl.parse(baseUrl) ?: throw IllegalStateException("baseUrl is invalid.")
        {{/jvm-okhttp3}}
        {{#jvm-okhttp4}}
        val httpUrl = baseUrl.toHttpUrlOrNull() ?: throw IllegalStateException("baseUrl is invalid.")
        {{/jvm-okhttp4}}
        {{#hasAuthMethods}}

        // take authMethod from operation
        updateAuthParams(requestConfig)
        {{/hasAuthMethods}}

        // add user agent
        if (requestConfig.headers[UserAgent].isNullOrEmpty()) {
            userAgent?.let { userAgent ->
                requestConfig.headers[UserAgent] = userAgent
            }
        }

        val url = httpUrl.newBuilder()
            .addPathSegments(requestConfig.path.trimStart('/'))
            .apply {
                requestConfig.query.forEach { query ->
                    query.value.forEach { queryValue ->
                        addQueryParameter(query.key, queryValue)
                    }
                }
            }.build()

        // take content-type/accept from spec or set to default (application/json) if not defined
        if (requestConfig.headers[ContentType].isNullOrEmpty()) {
            requestConfig.headers[ContentType] = JsonMediaType
        }
        if (requestConfig.headers[Accept].isNullOrEmpty()) {
            requestConfig.headers[Accept] = JsonMediaType
        }
        if (requestConfig.headers["svix-req-id"].isNullOrEmpty()) {
            requestConfig.headers["svix-req-id"] = Math.abs(Random.nextBits(32)).toString()
        }
        val headers = requestConfig.headers

        if(headers[ContentType] ?: "" == "") {
            throw kotlin.IllegalStateException("Missing Content-Type header. This is required.")
        }

        if(headers[Accept] ?: "" == "") {
            throw kotlin.IllegalStateException("Missing Accept header. This is required.")
        }

        // TODO: support multiple contentType options here.
        val contentType = (headers[ContentType] as String).substringBefore(";").lowercase(Locale.getDefault())

        val request = when (requestConfig.method) {
            RequestMethod.DELETE -> Request.Builder().url(url).delete(requestBody(requestConfig.body, contentType))
            RequestMethod.GET -> Request.Builder().url(url)
            RequestMethod.HEAD -> Request.Builder().url(url).head()
            RequestMethod.PATCH -> Request.Builder().url(url).patch(requestBody(requestConfig.body, contentType))
            RequestMethod.PUT -> Request.Builder().url(url).put(requestBody(requestConfig.body, contentType))
            RequestMethod.POST -> Request.Builder().url(url).post(requestBody(requestConfig.body, contentType))
            RequestMethod.OPTIONS -> Request.Builder().url(url).method("OPTIONS", null)
        }.apply {
            headers.forEach { header -> addHeader(header.key, header.value) }
        }.build()

        var response = client.newCall(request).execute()

        var sleepTime = initialRetryDelayMillis
        var retryCount = 0
        for (i in 0 until numRetries-1) {
            if (response.isSuccessful || response.code < 500) {
                break
            }
            response.close()
            retryCount = retryCount.inc()
            delay(sleepTime)
            sleepTime = sleepTime * 2
            var newRequest = request.newBuilder().header("svix-retry-count", retryCount.toString()).build()
            response = client.newCall(newRequest).execute()
        }

        val accept = response.header(ContentType)?.substringBefore(";")?.lowercase(Locale.getDefault())

        // TODO: handle specific mapping types. e.g. Map<int, Class<?>>
        return when {
            response.isRedirect -> Redirection(
                response.code{{#jvm-okhttp3}}(){{/jvm-okhttp3}},
                response.headers{{#jvm-okhttp3}}(){{/jvm-okhttp3}}.toMultimap()
            )
            response.isInformational -> Informational(
                response.message{{#jvm-okhttp3}}(){{/jvm-okhttp3}},
                response.code{{#jvm-okhttp3}}(){{/jvm-okhttp3}},
                response.headers{{#jvm-okhttp3}}(){{/jvm-okhttp3}}.toMultimap()
            )
            response.isSuccessful -> Success(
                responseBody(response.body{{#jvm-okhttp3}}(){{/jvm-okhttp3}}, accept),
                response.code{{#jvm-okhttp3}}(){{/jvm-okhttp3}},
                response.headers{{#jvm-okhttp3}}(){{/jvm-okhttp3}}.toMultimap()
            )
            response.isClientError -> ClientError(
                response.message{{#jvm-okhttp3}}(){{/jvm-okhttp3}},
                response.body{{#jvm-okhttp3}}(){{/jvm-okhttp3}}?.string(),
                response.code{{#jvm-okhttp3}}(){{/jvm-okhttp3}},
                response.headers{{#jvm-okhttp3}}(){{/jvm-okhttp3}}.toMultimap()
            )
            else -> ServerError(
                response.message{{#jvm-okhttp3}}(){{/jvm-okhttp3}},
                response.body{{#jvm-okhttp3}}(){{/jvm-okhttp3}}?.string(),
                response.code{{#jvm-okhttp3}}(){{/jvm-okhttp3}},
                response.headers{{#jvm-okhttp3}}(){{/jvm-okhttp3}}.toMultimap()
            )
        }
    }

    protected fun parameterToString(value: Any?): String = when (value) {
        null -> ""
        is Array<*> -> toMultiValue(value, "csv").toString()
        is Iterable<*> -> toMultiValue(value, "csv").toString()
        is OffsetDateTime, is OffsetTime, is LocalDateTime, is LocalDate, is LocalTime, is Date ->
            parseDateToQueryString(value)
        else -> value.toString()
    }

    protected inline fun <reified T: Any> parseDateToQueryString(value : T): String {
        {{#toJson}}
        /*
        .replace("\"", "") converts the json object string to an actual string for the query parameter.
        The moshi or gson adapter allows a more generic solution instead of trying to use a native
        formatter. It also easily allows to provide a simple way to define a custom date format pattern
        inside a gson/moshi adapter.
        */
        {{#moshi}}
        return Serializer.moshi.adapter(T::class.java).toJson(value).replace("\"", "")
        {{/moshi}}
        {{#gson}}
        return Serializer.gson.toJson(value, T::class.java).replace("\"", "")
        {{/gson}}
        {{#jackson}}
        return Serializer.jacksonObjectMapper.writeValueAsString(value).replace("\"", "")
        {{/jackson}}
        {{#kotlinx_serialization}}
        return Serializer.jvmJson.encodeToString(value).replace("\"", "")
        {{/kotlinx_serialization}}
        {{/toJson}}
        {{^toJson}}
        return value.toString()
        {{/toJson}}
    }
}
