/**
 * Teapod
 *
 * Copyright 2020-2022  <seil0@mosad.xyz>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
 * MA 02110-1301, USA.
 *
 */

package org.mosad.teapod.parser.crunchyroll

import android.util.Log
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.mosad.teapod.preferences.EncryptedPreferences
import org.mosad.teapod.preferences.Preferences

object Crunchyroll {
    private val TAG = javaClass.name

    private val client = HttpClient {
        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
            })
        }
    }
    private const val baseUrl = "https://beta-api.crunchyroll.com"
    private const val staticUrl = "https://static.crunchyroll.com"
    private const val basicApiTokenUrl = "https://gitlab.com/-/snippets/2274956/raw/main/snippetfile1.txt"
    private var basicApiToken: String = ""

    private lateinit var token: Token
    private var tokenValidUntil: Long = 0
    @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
    private val tokenRefreshContext = newSingleThreadContext("TokenRefreshContext")

    private var accountID = ""
    private var externalID = ""

    private val browsingCache = hashMapOf<String, BrowseResult>()

    /**
     * Load the pai token, see:
     * https://git.mosad.xyz/mosad/NonePublicIssues/issues/1
     *
     * TODO handle empty file
     */
    fun initBasicApiToken() = runBlocking {
        withContext(Dispatchers.IO) {
            basicApiToken = client.get { url(basicApiTokenUrl) }.bodyAsText()
            Log.i(TAG, "basic auth token: $basicApiToken")
        }
    }

    /**
     * Login to the crunchyroll API.
     *
     * @param username The Username/Email of the user to log in
     * @param password The Accounts Password
     *
     * @return Boolean: True if login was successful, else false
     */
    fun login(username: String, password: String): Boolean = runBlocking {
        val tokenEndpoint = "/auth/v1/token"
        val formData = Parameters.build {
            append("username", username)
            append("password", password)
            append("grant_type", "password")
            append("scope", "offline_access")
        }

        var success = false// is false
        withContext(Dispatchers.IO) {
            Log.i(TAG, "getting token ...")

            val status = try {
                val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) {
                    header("Authorization", "Basic $basicApiToken")
                }
                token = response.body()
                tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000)
                response.status
            } catch (ex: ClientRequestException) {
                val status = ex.response.status
                if (status == HttpStatusCode.Unauthorized) {
                    Log.e(TAG, "Could not complete login: " +
                            "${status.value} ${status.description}. " +
                            "Probably wrong username or password")
                }

                status
            }
            Log.i(TAG, "Login complete with code $status")
            success = (status == HttpStatusCode.OK)
        }

        return@runBlocking success
    }

    private fun refreshToken() {
        login(EncryptedPreferences.login, EncryptedPreferences.password)
    }

    /**
     * Requests: get, post, delete
     */

    private suspend inline fun <reified T> request(
        url: String,
        httpMethod: HttpMethod,
        params: List<Pair<String, Any?>> = listOf(),
        bodyObject: Any = Any()
    ): T = coroutineScope {
        withContext(tokenRefreshContext) {
            if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
        }

        return@coroutineScope (Dispatchers.IO) {
            val response = client.request(url) {
                method = httpMethod
                header("Authorization", "${token.tokenType} ${token.accessToken}")
                params.forEach {
                    parameter(it.first, it.second)
                }

                // for json set body and content type
                if (bodyObject is JsonObject) {
                    setBody(bodyObject)
                    contentType(ContentType.Application.Json)
                }
            }

            response.body<T>()
        }
    }

    /**
     * Send a HTTP GET request with [params] to the [endpoint] at [url], if url is empty use baseUrl
     */
    private suspend inline fun <reified T> requestGet(
        endpoint: String,
        params: List<Pair<String, Any?>> = listOf(),
        url: String = ""
    ): T {
        val path = url.ifEmpty { baseUrl }.plus(endpoint)

        return request(path, HttpMethod.Get, params)
    }

    private suspend fun requestPost(
        endpoint: String,
        params: List<Pair<String, Any?>> = listOf(),
        bodyObject: JsonObject
    ) {
        val path = "$baseUrl$endpoint"

        val response: HttpResponse = request(path, HttpMethod.Post, params, bodyObject)
        Log.i(TAG, "Response: $response")
    }

    private suspend fun requestPatch(
        endpoint: String,
        params: List<Pair<String, Any?>> = listOf(),
        bodyObject: JsonObject
    ) {
        val path = "$baseUrl$endpoint"

        val response: HttpResponse = request(path, HttpMethod.Patch, params, bodyObject)
        Log.i(TAG, "Response: $response")
    }

    private suspend fun requestDelete(
        endpoint: String,
        params: List<Pair<String, Any?>> = listOf(),
        url: String = ""
    ) = coroutineScope {
        val path = url.ifEmpty { "$baseUrl$endpoint" }

        val response: HttpResponse = request(path, HttpMethod.Delete, params)
        Log.i(TAG, "Response: $response")
    }

    /**
     * Basic functions: account
     * Needed for other functions to work properly!
     */

    /**
     * Retrieve the account id and set the corresponding global var.
     * The account id is needed for other calls.
     *
     * This must be execute on every start for teapod to work properly!
     */
    suspend fun account() {
        val indexEndpoint = "/accounts/v1/me"

        val account: Account = try {
            requestGet(indexEndpoint)
        } catch (ex: Exception) {
            Log.e(TAG, "SerializationException in account(). This is bad!", ex)
            NoneAccount
        }

        accountID = account.accountId
        externalID = account.externalId
    }

    /**
     * General element/media functions: browse, search, objects, season_list
     */

    /**
     * Browse the media available on crunchyroll.
     *
     * @param start start of the item list, used for pagination, default = 0
     * @param n number of items to return, default = 10
     * @param sortBy the sort order, see **[SortBy]**
     * @param ratings add user rating to the objects, default = false
     * @param seasonTag filter by season tag, if present
     * @param categories filter by category, if present
     * @return A **[BrowseResult]** object is returned.
     */
    suspend fun browse(
        start: Int = 0,
        n: Int = 10,
        sortBy: SortBy = SortBy.ALPHABETICAL,
        ratings: Boolean = false,
        seasonTag: String = "",
        categories: List<Categories> = emptyList()
    ): BrowseResult {
        val browseEndpoint = "/content/v2/discover/browse"
        val parameters = mutableListOf(
            "start" to start,
            "n" to n,
            "sort_by" to sortBy.str,
            "ratings" to ratings,
            "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
        )

        // if a season tag is present add it to the parameters
        if (seasonTag.isNotEmpty()) {
            parameters.add("season_tag" to seasonTag)
        }

        // if a season tag is present add it to the parameters
        if (categories.isNotEmpty()) {
            parameters.add("categories" to categories.joinToString(",") { it.str })
        }

        // fetch result if not already cached
        if (browsingCache.contains(parameters.toString())) {
            Log.d(TAG, "browse result cached: $parameters")
        } else {
            Log.d(TAG, "browse result not cached, fetching: $parameters")
            val browseResult: BrowseResult = try {
                requestGet(browseEndpoint, parameters)
            }catch (ex: Exception) {
                Log.e(TAG, "SerializationException in browse().", ex)
                NoneBrowseResult
            }



            // if the cache has more than 10 entries clear it, so it doesn't become a memory problem
            // Note: this value is totally guessed and should be replaced by a properly researched value
            if (browsingCache.size > 10) {
                browsingCache.clear()
            }

            // add results to cache
            browsingCache[parameters.toString()] = browseResult
        }

        return browsingCache[parameters.toString()] ?: NoneBrowseResult
    }

    /**
     * Search fo a query term.
     * Note: currently this function only supports series/tv shows.
     *
     * @param query The query term as String
     * @param n The maximum number of results to return, default = 10
     * @param ratings add user rating to the objects, default = false
     * @return A **[SearchResult]** object
     */
    suspend fun search(query: String, n: Int = 10, ratings: Boolean = false): SearchResult {
        val searchEndpoint = "/content/v2/discover/search"
        val parameters = listOf(
            "q" to query,
            "n" to n,
            "type" to "series",
            "ratings" to ratings,
            "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        // TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
        // to work around this, for now only tv shows are supported

        return try {
            requestGet(searchEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in search(), with query = \"$query\".", ex)
            NoneSearchResult
        }
    }

    /**
     * Get a collection of series objects.
     * Note: episode objects are currently not supported
     *
     * @param objects The object IDs as list of Strings
     * @param ratings add user rating to the objects
     * @return A **[Collection]** of Panels
     */
    suspend fun objects(objects: List<String>, ratings: Boolean = false): CollectionV2<Item> {
        val episodesEndpoint = "/content/v2/cms/objects/${objects.joinToString(",")}"
        val parameters = listOf(
            "ratings" to ratings,
            "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        return try {
            requestGet(episodesEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in objects().", ex)
            NoneCollectionV2
        }
    }

    /**
     * Main media functions: series, season, episodes, playback
     */

    /**
     * series id == crunchyroll id?
     */
    suspend fun series(seriesId: String): Series {
        val seriesEndpoint = "/content/v2/cms/series/$seriesId"
        val parameters = listOf(
            "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        return try {
            requestGet(seriesEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in series() for id $seriesId.", ex)
            NoneSeries
        }
    }

    /**
     * Get the next episode for a series.
     *
     * FIXME up_next returns no content if the is no next episode
     *
     * @param seriesId The series id for which to call up next
     * @return A **[UpNextSeriesItem]** with a Panel representing the up next episode
     */
    suspend fun upNextSeries(seriesId: String): UpNextSeriesList {
        val upNextSeriesEndpoint = "/content/v2/discover/up_next/$seriesId"
        val parameters = listOf(
            "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        return try {
            requestGet(upNextSeriesEndpoint, parameters)
        } catch (ex: NoTransformationFoundException) {
            // should be 204 No Content
            NoneUpNextSeriesList
        } catch (ex: JsonConvertException) {
            Log.e(TAG, "JsonConvertException in upNextSeries() with seriesId=$seriesId", ex)
            NoneUpNextSeriesList
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in upNextSeries() for seriesId $seriesId.", ex)
            NoneUpNextSeriesList
        }
    }

    /**
     * Get all available seasons for a series.
     *
     * @param seriesId The series id for which to get the seasons
     * @return A **[Seasons]** object with a list of **[Season]**
     */
    suspend fun seasons(seriesId: String): Seasons {
        val seasonsEndpoint = "/content/v2/cms/series/$seriesId/seasons"
        val parameters = listOf(
            "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        return try {
            requestGet(seasonsEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in seasons() for seriesId $seriesId.", ex)
            NoneSeasons
        }
    }

    /**
     * Get all available episodes for a season.
     *
     * @param seasonId The season id for which to get the episodes
     * @return A **[Episodes]** object with a list of **[Episode]**
     */
    suspend fun episodes(seasonId: String): Episodes {
        val episodesEndpoint = "/content/v2/cms/seasons/$seasonId/episodes"
        val parameters = listOf(
            "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        return try {
            requestGet(episodesEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in episodes() for seasonId $seasonId.", ex)
            NoneEpisodes
        }
    }

    /**
     * Get all available subtitles and streams of a episode.
     *
     * @param url The streams url of a episode
     * @return A **[Streams]** object
     */
    suspend fun streams(url: String): Streams {
        val parameters = listOf(
            "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        return try {
            requestGet(url, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in streams() with url $url.", ex)
            NoneStreams
        }
    }

    suspend fun streamsFromMediaGUID(mediaGUID: String): Streams {
        val streamsEndpoint = "/content/v2/cms/videos/$mediaGUID/streams"
        return streams(streamsEndpoint)
    }

    /**
     * Additional media functions: watchlist (series), playhead, similar to
     */

    /**
     * Check if a media is in the user's watchlist.
     *
     * @param seriesId The crunchyroll series id of the media to check
     * @return **[Boolean]**: ture if it was found, else false
     */
    suspend fun isWatchlist(seriesId: String): Boolean {
        val watchlistSeriesEndpoint = "/content/v2/$accountID/watchlist"
        val parameters = listOf(
            "content_ids" to seriesId,
            "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        return try {
            (requestGet(watchlistSeriesEndpoint, parameters) as CollectionV2<IsWatchlistItem>)
                .total == 1
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in isWatchlist() with seriesId $seriesId", ex)
            false
        }
    }

    /**
     * Add a media to the user's watchlist.
     *
     * @param seriesId The crunchyroll series id of the media to check
     */
    suspend fun postWatchlist(seriesId: String) {
        val watchlistPostEndpoint = "/content/v2/$accountID/watchlist"
        val parameters = listOf(
            "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        val json = buildJsonObject {
            put("content_id", seriesId)
        }

        try {
            requestPost(watchlistPostEndpoint, parameters, json)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in postWatchlist() with seriesId $seriesId", ex)
        }
    }

    /**
     * Remove a media from the user's watchlist.
     *
     * @param seriesId The crunchyroll series id of the media to check
     */
    suspend fun deleteWatchlist(seriesId: String) {
        val watchlistDeleteEndpoint = "/content/v2/$accountID/watchlist/$seriesId"
        val parameters = listOf(
            "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        try {
            requestDelete(watchlistDeleteEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in deleteWatchlist() with seriesId $seriesId", ex)
        }
    }

    /**
     * Get playhead information for all episodes in episodeIDs.
     * The Information returned contains the playhead position, watched state
     * and last modified date.
     *
     * @param episodeIDs A **[List]** of episodes IDs as strings.
     * @return A **[Map]**<String, **[PlayheadObject]**> containing playback info.
     */
    suspend fun playheads(episodeIDs: List<String>): Playheads {
        val playheadsEndpoint = "/content/v2/$accountID/playheads"
        val parameters = listOf(
            "content_ids" to episodeIDs.joinToString(","),
            "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
        )

        return try {
            requestGet(playheadsEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in playheads().", ex.cause)
            NonePlayheads
        }
    }

    /**
     * Post the playhead to crunchy (playhead position,watched state)
     *
     * @param episodeId A episode ID as strings.
     * @param playhead The episodes playhead in seconds.
     */
    suspend fun postPlayheads(episodeId: String, playhead: Int) {
        val playheadsEndpoint = "/content/v1/playheads/$accountID"
        val parameters = listOf("locale" to Preferences.preferredSubtitleLocale.toLanguageTag())

        val json = buildJsonObject {
            put("content_id", episodeId)
            put("playhead", playhead)
        }

        try {
            requestPost(playheadsEndpoint, parameters, json)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in postPlayheads()", ex.cause)
        }
    }

    /**
     * Get the intro meta data including start, end and duration of the intro.
     *
     * @param episodeId A episode ID as strings.
     */
    suspend fun datalabIntro(episodeId: String): DatalabIntro {
        val datalabIntroEndpoint = "/datalab-intro-v2/$episodeId.json"

        /*
         * wtf crunchyroll, why do you return an xml error message when some data is missing,
         * this is a json endpoint. For fucks sake, return at least a valid json message.
         */
        return try {
            val response: HttpResponse = requestGet(datalabIntroEndpoint, url = staticUrl)
            Json.decodeFromString(response.bodyAsText())
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in datalabIntro(). EpisodeId=$episodeId", ex)
            NoneDatalabIntro
        }
    }

    /**
     * Get similar media for a show/movie.
     *
     * @param seriesId The crunchyroll series id of the media
     * @param n The maximum number of results to return, default = 10
     * @param ratings add user rating to the objects
     * @return A **[SimilarToResult]** object
     */
    suspend fun similarTo(seriesId: String, n: Int = 10, ratings: Boolean = false): SimilarToResult {
        val similarToEndpoint = "/content/v2/discover/$accountID/similar_to/$seriesId"
        val parameters = listOf(
            "n" to n,
            "ratings" to ratings,
            "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
        )

        return try {
            requestGet(similarToEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in similarTo().", ex)
            NoneSimilarToResult
        }
    }

    /**
     * Listing functions: watchlist (list), up_next_account
     */

    /**
     * List items present in the watchlist.
     *
     * @param n Number of items to return, defaults to 20.
     * @return A **[Collection]** containing up to n **[Item]**.
     */
    suspend fun watchlist(n: Int = 20): CollectionV2<Item> {
        val watchlistEndpoint = "/content/v2/discover/$accountID/watchlist"
        val parameters = listOf(
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
            "n" to n,
            "preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag()
        )

        val list: Watchlist = try {
            requestGet(watchlistEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in watchlist().", ex)
            NoneWatchlist
        }

        val objects = list.data.map{ it.panel.episodeMetadata.seriesId }
        return objects(objects)
    }

    /**
     * List the next up episodes for the logged in account.
     *
     * @param n Number of items to return, default = 20
     * @return A **[HistoryList]** containing up to n **[UpNextAccountItem]**.
     */
    suspend fun upNextAccount(n: Int = 10): HistoryList {
        val watchlistEndpoint = "/content/v2/discover/$accountID/history"
        val parameters = listOf(
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
            "n" to n
        )

        return try {
            requestGet(watchlistEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in upNextAccount().", ex)
            NoneHistoryList
        }
    }

    /**
     * Returns a collection of recommendations for the currently logged in account.
     *
     * @param start start of the item list, used for pagination, default = 0
     * @param n number of items to return, default = 10
     * @param ratings add user rating to the objects, default = false
     * @return A **[RecommendationsList]** containing up to n **[Item]**.
     */
    suspend fun recommendations(start: Int = 0, n: Int = 10, ratings: Boolean = false): RecommendationsList {
        val recommendationsEndpoint = "/content/v2/discover/$accountID/recommendations"
        val parameters = listOf(
            "start" to start,
            "n" to n,
            "ratings" to ratings,
            "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
        )

        return try {
            requestGet(recommendationsEndpoint, parameters)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in recommendations().", ex)
            NoneRecommendationsList
        }
    }

    /**
     * Account/Profile functions
     */

    /**
     * Get profile information for the currently logged in account.
     *
     * @return A **[Profile]** object
     */
    suspend fun profile(): Profile {
        val profileEndpoint = "/accounts/v1/me/profile"

        return try {
            requestGet(profileEndpoint)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in profile().", ex)
            NoneProfile
        }
    }

    /**
     * Post the preferred content subtitle language.
     *
     * @param languageTag the preferred language as language tag
     */
    suspend fun setPreferredSubtitleLanguage(languageTag: String) {
        val profileEndpoint = "/accounts/v1/me/profile"
        val json = buildJsonObject {
            put("preferred_content_subtitle_language", languageTag)
        }

        requestPatch(profileEndpoint, bodyObject = json)
    }

    /**
     * Patch the preferred content audio language.
     *
     * @param languageTag the preferred language as language tag
     */
    suspend fun setPreferredAudioLanguage(languageTag: String) {
        val profileEndpoint = "/accounts/v1/me/profile"
        val json = buildJsonObject {
            put("preferred_content_audio_language", languageTag)
        }

        requestPatch(profileEndpoint, bodyObject = json)
    }

    /**
     * Get additional profile (benefits) information for the currently logged in account.
     *
     * * @return A **[Profile]** object
     */
    suspend fun benefits(): Benefits {
        val profileEndpoint = "/subs/v1/subscriptions/$externalID/benefits"

        return try {
            requestGet(profileEndpoint)
        } catch (ex: Exception) {
            Log.e(TAG, "Exception in benefits().", ex)
            NoneBenefits
        }
    }

}
