package com.nisemoe.nise.osu

import com.nisemoe.generated.tables.references.OSU_API_KEYS
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.jooq.DSLContext
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.InitializingBean
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicInteger

class InvalidOsuApiKeyException() : Exception()

@Service
class OsuApi(
    private val tokenService: TokenService,
    private val dslContext: DSLContext
): InitializingBean {

    private val logger = LoggerFactory.getLogger(javaClass)

    private lateinit var apiKeysList: List<String>
    private val currentKeyIndex = AtomicInteger(0)

    override fun afterPropertiesSet() {
        this.loadApiKeys()
    }

    fun loadApiKeys() {
        currentKeyIndex.set(0)

        val keys = dslContext.select(OSU_API_KEYS.API_KEY)
            .from(OSU_API_KEYS)
            .where(OSU_API_KEYS.IS_VALID.eq(true))
            .and(OSU_API_KEYS.IS_ACTIVE.eq(true))
            .fetchInto(String::class.java)

        this.apiKeysList = keys
        this.logger.info("Loaded ${keys.size} valid API keys")
    }

    fun getCurrentApiKey(): String {
        val key = apiKeysList[currentKeyIndex.get() % apiKeysList.size]
        currentKeyIndex.getAndIncrement()
        return key
    }

    fun setKeyAsInvalid(apiKey: String) {
        dslContext.update(OSU_API_KEYS)
            .set(OSU_API_KEYS.IS_VALID, false)
            .where(OSU_API_KEYS.API_KEY.eq(apiKey))
            .execute()

        this.logger.info("Marked API key $apiKey as invalid")
        this.loadApiKeys()
    }

    @OptIn(ExperimentalSerializationApi::class)
    private val serializer = Json { ignoreUnknownKeys = true; explicitNulls = false }

    private val httpClient: HttpClient = HttpClient.newBuilder()
        .version(HttpClient.Version.HTTP_2)
        .build()

    fun doRequest(url: String, queryParams: Map<String, Any>, authorized: Boolean = true, appendToUrl: String? = null): HttpResponse<String>? {
        var accessToken: TokenService.AccessTokenResponse? = null
        if(authorized)
            accessToken = this.tokenService.getAccessToken()

        val uriBuilder = StringBuilder(url)
        queryParams.forEach { (key, value) ->
            uriBuilder.append("$key=$value&")
        }

        if(appendToUrl != null)
            uriBuilder.append(appendToUrl)

        // Remove the last '&' or '?' if no parameters were appended
        if (uriBuilder.endsWith('&') || uriBuilder.endsWith('?'))
            uriBuilder.setLength(uriBuilder.length - 1)

        val request = HttpRequest.newBuilder()
            .uri(URI.create(uriBuilder.toString()))
            .header("Accept", "application/json")
            .header("Content-Type", "application/json")
            .headers("x-api-version", "20220704")
            .also { if(authorized) it.header("Authorization", "Bearer ${accessToken!!.access_token}") }
            .GET()
            .build()

        return this.sendWithRetry(request)
    }

    /**
     * Retrieves the beatmap file for a given beatmap ID from the osu!api
     *
     * @param beatmapId The ID of the beatmap
     */
    fun getBeatmapFile(beatmapId: Int): String? {
        val response = this.doRequest("https://osu.ppy.sh/osu/${beatmapId}", emptyMap(), authorized = false)
        if(response == null) {
            this.logger.info("Error loading beatmap file")
            return null
        }

        return if (response.statusCode() == 200) {
            response.body()
        } else {
            null
        }
    }

    fun getBeatmapFromHash(beatmapHash: String): OsuApiModels.Beatmap? {
        val queryParams = mapOf(
            "checksum" to beatmapHash
        )

        val response = doRequest("https://osu.ppy.sh/api/v2/beatmaps/lookup?", queryParams)
        if(response == null) {
            this.logger.info("Error loading beatmap")
            return null
        }

        return if (response.statusCode() == 200) {
            return serializer.decodeFromString(OsuApiModels.Beatmap.serializer(), response.body())
        } else {
            null
        }
    }

    /**
     * Retrieves the replay data for a given score ID from the osu!api.
     * Efficiently cycles through the API keys to avoid rate limiting.
     * It's limited to 10 requests per minute according to @ https://github.com/ppy/osu-api/wiki#get-replay-data
     *
     * @param scoreId The ID of the score (best_id)
     */
    fun getReplay(scoreId: Long): OsuApiModels.ReplayResponse? {
        while (apiKeysList.isNotEmpty()) {
            val apiKey = this.getCurrentApiKey()
            val queryParams = mapOf(
                "k" to apiKey,
                "s" to scoreId.toString(),
                "m" to "0" // [osu!std]
            )

            try {
                val response = this.doRequest("https://osu.ppy.sh/api/get_replay?", queryParams)
                if (response != null && response.statusCode() == 200) {
                    try {
                        return serializer.decodeFromString(OsuApiModels.ReplayResponse.serializer(), response.body())
                    } catch(exception: Exception) {
                        this.logger.error("Failed to parse successful response: ${response.body()}")
                        this.logger.error(exception.stackTraceToString())
                        break
                    }
                }
            } catch (e: InvalidOsuApiKeyException) {
                this.logger.error("Invalid API key detected: $apiKey, trying next...")
                this.setKeyAsInvalid(apiKey)
            }
        }

        this.logger.error("Failed to load replay data after cycling through all keys.")
        return null
    }

    fun getTopBeatmapScores(beatmapId: Int): OsuApiModels.BeatmapScores? {
        // TODO: Some beatmaps (beatmapId = 736729) return no results if mode is set to anything (specifically osu)
        val queryParams = mapOf(
            "mode" to "osu",
            "limit" to "100"
        )

        val response = doRequest("https://osu.ppy.sh/api/v2/beatmaps/$beatmapId/scores?", queryParams)
        if(response == null) {
            this.logger.info("Error loading top beatmap scores")
            return null
        }

        return if (response.statusCode() == 200) {
            serializer.decodeFromString(OsuApiModels.BeatmapScores.serializer(), response.body())
        } else {
            null
        }
    }

    fun getTopUserScores(userId: Long, type: String = "best"): List<OsuApiModels.Score>? {
        val queryParams = mapOf(
            "mode" to "osu",
            "limit" to "100",
            "legacy_only" to "1"
        )

        val response = doRequest("https://osu.ppy.sh/api/v2/users/$userId/scores/$type?", queryParams)
        if(response == null) {
            this.logger.info("Error loading top user scores")
            return null
        }

        return if (response.statusCode() == 200) {
            serializer.decodeFromString(ListSerializer(OsuApiModels.Score.serializer()), response.body())
        } else {
            null
        }
    }

    fun searchBeatmapsets(cursor: OsuApiModels.BeatmapsetSearchResultCursor?): OsuApiModels.BeatmapsetSearchResult? {
        val queryParams = mutableMapOf(
            "s" to "ranked", // Status [only ranked]
            "m" to "0", // Mode [osu!std]
            "nsfw" to "true", // Include NSFW
            "sort" to "plays_desc", // Sort order
        )
        val response = this.doRequest("https://osu.ppy.sh/api/v2/beatmapsets/search/?", queryParams, appendToUrl = cursor.toString())
        if(response == null) {
            this.logger.info("Error loading beatmapset")
            return null
        }

        return if (response.statusCode() == 200) {
            serializer.decodeFromString(OsuApiModels.BeatmapsetSearchResult.serializer(), response.body())
        } else {
            null
        }
    }

    fun checkIfUserBanned(userId: Long): Boolean? {
        val response = this.doRequest("https://osu.ppy.sh/api/v2/users/$userId/osu?key=id", mapOf())
        if(response == null) {
            this.logger.info("Error loading user with userId = $userId")
            return null
        }

        return response.statusCode() == 404
    }

    fun getUsersBatch(userIds: List<Long>): OsuApiModels.UserBatchResponse? {
        val urlBuilder = StringBuilder("https://osu.ppy.sh/api/v2/users?")
        userIds.forEach { id ->
            urlBuilder.append("ids[]=$id&")
        }
        val url = urlBuilder.toString().removeSuffix("&")

        val response = this.doRequest(url, emptyMap())
        if(response == null) {
            this.logger.info("Error loading users")
            return null
        }

        return if (response.statusCode() == 200) {
            serializer.decodeFromString(OsuApiModels.UserBatchResponse.serializer(), response.body())
        } else {
            null
        }
    }

    fun getUserProfile(userId: String, mode: String? = null, key: String? = null): OsuApiModels.UserExtended? {
        val accessToken = this.tokenService.getAccessToken()

        val encodedUserId = userId.replace(' ', '_')
        val uriBuilder = StringBuilder("https://osu.ppy.sh/api/v2/users/${URLEncoder.encode(encodedUserId, StandardCharsets.UTF_8.toString())}")

        if (mode != null) uriBuilder.append("/$mode")
        if (key != null) uriBuilder.append("?key=$key")

        val request = HttpRequest.newBuilder()
            .uri(URI.create(uriBuilder.toString()))
            .header("Accept", "application/json")
            .header("Content-Type", "application/json")
            .header("Authorization", "Bearer ${accessToken.access_token}")
            .GET()
            .build()

        val response = this.sendWithRetry(request)
        if(response == null) {
            this.logger.info("Error loading user with userId = $userId")
            return null
        }

        return if (response.statusCode() == 200) {
            serializer.decodeFromString(OsuApiModels.UserExtended.serializer(), response.body())
        } else {
            null
        }
    }

    var rateLimitRemaining: Long = 0L
    var rateLimitTotal: Long = 0L

    fun sendWithRetry(request: HttpRequest): HttpResponse<String>? {
        val waitTimes = listOf(15L, 30L, 60L)

        for (waitTime in waitTimes) {
            val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

            this.logger.debug("Request: {}", request.uri())
            this.logger.debug("Result: {}", response.statusCode())
            this.logger.debug("")

            // Handle osu!api v1 invalid api key errors in a specific way; mark the key as invalid so that
            // it won't be used again and return null to indicate that the request failed.
            if(response.statusCode() == 401 && response.body() == "{\"error\":\"Please provide a valid API key.\"}")
                throw InvalidOsuApiKeyException()

            when(response.statusCode()) {
                401 -> {
                    // If the status code is 401, the access token has expired or something, refresh it.
                    this.tokenService.getNewTokenAndSave()
                    continue
                }
                429 -> {
                    // Wait for the specified time before retrying if 429 is received
                    this.logger.warn("Received 429 [${request.uri()}], waiting for $waitTime seconds before retrying...")
                    Thread.sleep(waitTime * 1000)
                }
                else -> {
                    val rateLimitRemainingCount = response.headers().firstValueAsLong("x-ratelimit-remaining")
                    if(rateLimitRemainingCount.isPresent)
                        this.rateLimitRemaining = rateLimitRemainingCount.asLong

                    val rateLimitTotalCount = response.headers().firstValueAsLong("x-ratelimit-limit")
                    if(rateLimitTotalCount.isPresent)
                        this.rateLimitTotal = rateLimitTotalCount.asLong

                    this.logger.debug("Rate limit remaining: $rateLimitRemaining/$rateLimitTotal")

                    return response
                }
            }
        }

        this.logger.error("Failed to get a successful response. URL: ${request.uri()}")
        return null
    }

}