package com.github.libretube.api

import com.github.libretube.api.obj.Channel
import com.github.libretube.api.obj.ChannelTabResponse
import com.github.libretube.api.obj.CommentsPage
import com.github.libretube.api.obj.DeArrowContent
import com.github.libretube.api.obj.Playlist
import com.github.libretube.api.obj.SearchResult
import com.github.libretube.api.obj.SegmentData
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.Streams
import kotlinx.coroutines.CancellationException

/**
 * Decorator that adds a short-lived device cache to the expensive read-only media calls.
 * Fresh data is preferred; on a temporary network failure a still-reasonable stale entry can be
 * returned so revisiting a screen does not immediately require another API round trip.
 */
class CachedMediaServiceRepository(
    private val delegate: MediaServiceRepository
) : MediaServiceRepository {

    private val backendKey: String = buildString {
        append(delegate::class.java.name)
        if (delegate is PipedMediaServiceRepository) {
            append(':')
            append(PipedMediaServiceRepository.apiUrl)
        }
    }

    override fun getTrendingCategories(): List<TrendingCategory> = delegate.getTrendingCategories()

    override suspend fun getTrending(region: String, category: TrendingCategory): List<StreamItem> =
        cached("trending:$region:${category.name}", HOME_TTL, HOME_STALE_TTL) {
            delegate.getTrending(region, category)
        }

    override suspend fun getStreams(videoId: String): Streams =
        cached("streams:$videoId", STREAM_TTL, STREAM_STALE_TTL) {
            delegate.getStreams(videoId)
        }

    override suspend fun getComments(videoId: String): CommentsPage = delegate.getComments(videoId)

    override suspend fun getSegments(
        videoId: String,
        category: List<String>,
        actionType: List<String>?
    ): SegmentData = cached(
        "segments:$videoId:${category.sorted().joinToString(",")}:${actionType.orEmpty().sorted().joinToString(",")}",
        SEGMENTS_TTL,
        SEGMENTS_STALE_TTL
    ) {
        delegate.getSegments(videoId, category, actionType)
    }

    override suspend fun getDeArrowContent(videoId: String): DeArrowContent? =
        cachedNullable("dearrow:$videoId", DEARROW_TTL, DEARROW_STALE_TTL) {
            delegate.getDeArrowContent(videoId)
        }

    override suspend fun getCommentsNextPage(videoId: String, nextPage: String): CommentsPage =
        delegate.getCommentsNextPage(videoId, nextPage)

    override suspend fun getSearchResults(searchQuery: String, filter: String): SearchResult =
        cached("search:$filter:${searchQuery.trim().lowercase()}", HOME_TTL, HOME_STALE_TTL) {
            delegate.getSearchResults(searchQuery, filter)
        }

    override suspend fun getSearchResultsNextPage(
        searchQuery: String,
        filter: String,
        nextPage: String
    ): SearchResult = cached(
        "search-next:$filter:${searchQuery.trim().lowercase()}:$nextPage",
        HOME_TTL,
        HOME_STALE_TTL
    ) {
        delegate.getSearchResultsNextPage(searchQuery, filter, nextPage)
    }

    override suspend fun getSuggestions(query: String): List<String> =
        cached("suggest:${query.trim().lowercase()}", SUGGEST_TTL, SUGGEST_STALE_TTL) {
            delegate.getSuggestions(query)
        }

    override suspend fun getChannel(channelId: String): Channel =
        cached("channel:$channelId", DETAIL_TTL, DETAIL_STALE_TTL) {
            delegate.getChannel(channelId)
        }

    override suspend fun getChannelTab(data: String, nextPage: String?): ChannelTabResponse =
        cached("channel-tab:$data:${nextPage.orEmpty()}", DETAIL_TTL, DETAIL_STALE_TTL) {
            delegate.getChannelTab(data, nextPage)
        }

    override suspend fun getChannelByName(channelName: String): Channel =
        cached("channel-name:${channelName.trim().lowercase()}", DETAIL_TTL, DETAIL_STALE_TTL) {
            delegate.getChannelByName(channelName)
        }

    override suspend fun getChannelNextPage(channelId: String, nextPage: String): Channel =
        cached("channel-next:$channelId:$nextPage", DETAIL_TTL, DETAIL_STALE_TTL) {
            delegate.getChannelNextPage(channelId, nextPage)
        }

    override suspend fun getPlaylist(playlistId: String): Playlist =
        cached("playlist:$playlistId", DETAIL_TTL, DETAIL_STALE_TTL) {
            delegate.getPlaylist(playlistId)
        }

    override suspend fun getPlaylistNextPage(playlistId: String, nextPage: String): Playlist =
        cached("playlist-next:$playlistId:$nextPage", DETAIL_TTL, DETAIL_STALE_TTL) {
            delegate.getPlaylistNextPage(playlistId, nextPage)
        }

    private suspend inline fun <reified T> cached(
        key: String,
        ttlMs: Long,
        staleTtlMs: Long,
        fetch: suspend () -> T
    ): T {
        val scopedKey = "$backendKey:$key"
        ApiResponseCache.get<T>(scopedKey, ttlMs)?.let { return it }

        return try {
            val fresh = fetch()
            // Caching is an optimization only. A serialization/disk problem must never turn a
            // successful API response into a playback/navigation failure.
            try {
                ApiResponseCache.put(scopedKey, fresh)
            } catch (cancelled: CancellationException) {
                throw cancelled
            } catch (_: Exception) {
                // Ignore cache write failures and return the fresh response.
            }
            fresh
        } catch (throwable: Throwable) {
            if (throwable is CancellationException) throw throwable
            ApiResponseCache.get<T>(scopedKey, staleTtlMs) ?: throw throwable
        }
    }

    private suspend inline fun <reified T> cachedNullable(
        key: String,
        ttlMs: Long,
        staleTtlMs: Long,
        fetch: suspend () -> T?
    ): T? {
        val scopedKey = "$backendKey:$key"
        ApiResponseCache.get<T>(scopedKey, ttlMs)?.let { return it }

        return try {
            val fresh = fetch()
            if (fresh != null) {
                try {
                    ApiResponseCache.put(scopedKey, fresh)
                } catch (cancelled: CancellationException) {
                    throw cancelled
                } catch (_: Exception) {
                    // Ignore cache write failures and return the fresh response.
                }
            }
            fresh
        } catch (throwable: Throwable) {
            if (throwable is CancellationException) throw throwable
            ApiResponseCache.get<T>(scopedKey, staleTtlMs) ?: throw throwable
        }
    }

    companion object {
        private const val STREAM_TTL = 10L * 60L * 1000L
        private const val STREAM_STALE_TTL = 60L * 60L * 1000L
        private const val HOME_TTL = 10L * 60L * 1000L
        private const val HOME_STALE_TTL = 6L * 60L * 60L * 1000L
        private const val DETAIL_TTL = 15L * 60L * 1000L
        private const val DETAIL_STALE_TTL = 12L * 60L * 60L * 1000L
        private const val SUGGEST_TTL = 5L * 60L * 1000L
        private const val SUGGEST_STALE_TTL = 60L * 60L * 1000L
        private const val SEGMENTS_TTL = 12L * 60L * 60L * 1000L
        private const val SEGMENTS_STALE_TTL = 3L * 24L * 60L * 60L * 1000L
        private const val DEARROW_TTL = 6L * 60L * 60L * 1000L
        private const val DEARROW_STALE_TTL = 24L * 60L * 60L * 1000L
    }
}
