package exh.recs

import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SMangaImpl
import eu.kanade.tachiyomi.ui.browse.source.browse.NoResultsException
import eu.kanade.tachiyomi.ui.browse.source.browse.Pager
import eu.kanade.tachiyomi.util.lang.runAsObservable
import eu.kanade.tachiyomi.util.lang.withIOContext
import exh.log.maybeInjectEHLogger
import exh.util.MangaType
import exh.util.mangaType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber

abstract class API(_endpoint: String) {
    var endpoint: String = _endpoint
    val client = OkHttpClient.Builder()
        .maybeInjectEHLogger()
        .build()

    abstract suspend fun getRecsBySearch(search: String): List<SMangaImpl>
}

class MyAnimeList : API("https://api.jikan.moe/v3/") {
    private suspend fun getRecsById(id: String): List<SMangaImpl> {
        val httpUrl = endpoint.toHttpUrlOrNull() ?: throw Exception("Could not convert endpoint url")
        val apiUrl = httpUrl.newBuilder()
            .addPathSegment("manga")
            .addPathSegment(id)
            .addPathSegment("recommendations")
            .build()
            .toString()

        val response = client.newCall(GET(apiUrl)).await()
        val body = withIOContext { response.body?.string() } ?: throw Exception("Null Response")
        val data = Json.decodeFromString<JsonObject>(body)
        val recommendations = data["recommendations"] as? JsonArray
        return recommendations?.filterIsInstance<JsonObject>()?.map { rec ->
            Timber.tag("RECOMMENDATIONS").d("MYANIMELIST > RECOMMENDATION: %s", rec["title"]?.jsonPrimitive?.content.orEmpty())
            SMangaImpl().apply {
                title = rec["title"]!!.jsonPrimitive.content
                thumbnail_url = rec["image_url"]!!.jsonPrimitive.content
                initialized = true
                url = rec["url"]!!.jsonPrimitive.content
            }
        }.orEmpty()
    }

    override suspend fun getRecsBySearch(search: String): List<SMangaImpl> {
        val httpUrl = endpoint.toHttpUrlOrNull() ?: throw Exception("Could not convert endpoint url")
        val url = httpUrl.newBuilder()
            .addPathSegment("search")
            .addPathSegment("manga")
            .addQueryParameter("q", search)
            .build()
            .toString()

        val response = client.newCall(GET(url)).await()
        val body = withIOContext { response.body?.string() } ?: throw Exception("Null Response")
        val data = Json.decodeFromString<JsonObject>(body)
        val results = data["results"] as? JsonArray
        if (results.isNullOrEmpty()) {
            throw Exception("'$search' not found")
        }
        val result = results.first().jsonObject
        val id = result["mal_id"]!!.jsonPrimitive.content
        return getRecsById(id)
    }
}

class Anilist : API("https://graphql.anilist.co/") {
    private fun countOccurrence(arr: JsonArray, search: String): Int {
        return arr.count {
            val synonym = it.jsonPrimitive.content
            synonym.contains(search, true)
        }
    }

    private fun languageContains(obj: JsonObject, language: String, search: String): Boolean {
        return obj["title"]?.jsonObject?.get(language)?.jsonPrimitive?.content?.contains(search, true) == true
    }

    private fun getTitle(obj: JsonObject): String {
        val titleObj = obj["title"]!!.jsonObject

        val english = titleObj["english"]?.jsonPrimitive?.contentOrNull
        val romaji = titleObj["romaji"]?.jsonPrimitive?.contentOrNull
        val native = titleObj["native"]?.jsonPrimitive?.contentOrNull
        val synonym = obj["synonyms"]!!.jsonArray.getOrNull(0)?.jsonPrimitive?.contentOrNull

        val isJP = obj["countryOfOrigin"]!!.jsonPrimitive.content == "JP"

        return when {
            !english.isNullOrBlank() -> english
            isJP && !romaji.isNullOrBlank() -> romaji
            !synonym.isNullOrBlank() -> synonym
            !isJP && !romaji.isNullOrBlank() -> romaji
            else -> native ?: "NO NAME FOUND"
        }
    }

    override suspend fun getRecsBySearch(search: String): List<SMangaImpl> {
        val query =
            """
            |query Recommendations(${'$'}search: String!) {
                |Page {
                    |media(search: ${'$'}search, type: MANGA) {
                        |title {
                            |romaji
                            |english
                            |native
                        |}
                        |synonyms
                        |recommendations {
                            |edges {
                                |node {
                                    |mediaRecommendation {
                                        |countryOfOrigin
                                        |siteUrl
                                        |title {
                                            |romaji
                                            |english
                                            |native
                                        |}
                                        |synonyms
                                        |coverImage {
                                            |large
                                        |}
                                    |}
                                |}
                            |}
                        |}
                    |}
                |}
            |}
            |""".trimMargin()
        val variables = buildJsonObject {
            put("search", search)
        }
        val payload = buildJsonObject {
            put("query", query)
            put("variables", variables)
        }
        val payloadBody = payload.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())

        val response = client.newCall(POST(endpoint, body = payloadBody)).await()
        val body = withIOContext { response.body?.string() } ?: throw Exception("Null Response")
        val data = Json.decodeFromString<JsonObject>(body)["data"] as? JsonObject ?: throw Exception("Unexpected response")

        val media = data["Page"]?.jsonObject?.get("media")?.jsonArray
        if (media.isNullOrEmpty()) {
            throw Exception("'$search' not found")
        }
        val result = media.sortedWith(
            compareBy(
                { languageContains(it.jsonObject, "romaji", search) },
                { languageContains(it.jsonObject, "english", search) },
                { languageContains(it.jsonObject, "native", search) },
                { countOccurrence(it.jsonObject["synonyms"]!!.jsonArray, search) > 0 }
            )
        ).last().jsonObject

        return result["recommendations"]?.jsonObject?.get("edges")?.jsonArray?.map {
            val rec = it.jsonObject["node"]!!.jsonObject["mediaRecommendation"]!!.jsonObject
            val recTitle = getTitle(rec)
            Timber.tag("RECOMMENDATIONS").d("ANILIST > RECOMMENDATION: %s", recTitle)
            SMangaImpl().apply {
                title = recTitle
                thumbnail_url = rec["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content
                initialized = true
                url = rec["siteUrl"]!!.jsonPrimitive.content
            }
        }.orEmpty()
    }
}

open class RecommendsPager(
    private val manga: Manga,
    private val smart: Boolean = true,
    private var preferredApi: API = API.MYANIMELIST
) : Pager() {
    override fun requestNext(): Observable<MangasPage> {
        return runAsObservable({
            if (smart) preferredApi = if (manga.mangaType() != MangaType.TYPE_MANGA) API.ANILIST else preferredApi

            val apiList = API_MAP.toList().sortedByDescending { it.first == preferredApi }

            val recs = apiList
                .asSequence()
                .map { (key, api) ->
                    try {
                        val recs = runBlocking(Dispatchers.IO) { api.getRecsBySearch(manga.originalTitle) }
                        Timber.tag("RECOMMENDATIONS").d("%s > Results: %s", key, recs.count())
                        recs
                    } catch (e: Exception) {
                        Timber.tag("RECOMMENDATIONS").e("%s > Error: %s", key, e.message)
                        listOf<SMangaImpl>()
                    }
                }
                .firstOrNull { it.isNotEmpty() }
                .orEmpty()

            MangasPage(recs, false)
        })
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .doOnNext {
                if (it.mangas.isNotEmpty()) {
                    onPageReceived(it)
                } else {
                    throw NoResultsException()
                }
            }
    }

    companion object {
        val API_MAP = mapOf(
            API.MYANIMELIST to MyAnimeList(),
            API.ANILIST to Anilist()
        )

        enum class API { MYANIMELIST, ANILIST }
    }
}
