package com.ponzischeme89.memby.ui.player

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.widget.ImageView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.data.Trickplay
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

/**
 * The frame a skip will land on, drawn into the seek indicator.
 *
 * The idea and the shape of it are borrowed from
 * [Wholphin](https://github.com/damontecres/Wholphin), a Jellyfin television client under
 * the same GPL-2.0 licence. What differs is the format underneath: Jellyfin serves tile
 * sheets and Emby serves BIF files, so where Wholphin crops a sub-image out of a grid,
 * this asks for one frame's bytes (see [com.ponzischeme89.memby.data.Trickplay]).
 *
 * Everything here is arranged around one property: a preview must never be the reason a
 * press feels slow. Nothing blocks, every failure is silent, and the thumbnail is composed
 * *beside* the words rather than in place of them — the chip has always said where the skip
 * lands, and it still says it on a title with no previews, on a server that will not answer,
 * and in the moment before the first frame arrives.
 *
 * There are two places a frame is drawn, because there are two ways to seek: the centred
 * chip a press of Left or Right raises with the transport hidden, and the strip above the
 * time bar when the controls are up and the viewer is scrubbing it. They share one instance
 * deliberately — one layout fetched per title and one cache of frames between them, since a
 * viewer who skips and then opens the controls is travelling through the same film.
 */
class TrickplayPreview(
    private val scope: CoroutineScope,
    /** The layout for a title, or null when it has none. Called once per item. */
    private val loadTrack: suspend (String) -> Trickplay?,
    /** One thumbnail's JPEG bytes, or null. */
    private val loadFrame: suspend (Trickplay, Int) -> ByteArray?,
) {
    private val views = mutableListOf<ImageView>()
    private var shownIn: ImageView? = null
    private var itemId: String? = null
    private var track: Trickplay? = null
    private var trackJob: Job? = null
    private var frameJob: Job? = null
    private var shownFrame = NO_FRAME

    /**
     * The bytes of frames already fetched, rather than the bitmaps.
     *
     * A JPEG this size is a few kilobytes and its decoded form is a few hundred, so
     * holding pixels would be most of a megabyte for a handful of thumbnails on a device
     * that has better uses for it. Decoding one takes a millisecond or two, and it happens
     * off the main thread anyway.
     */
    private val frames = object : LinkedHashMap<Int, ByteArray>(FRAME_CACHE_SIZE, 0.75f, true) {
        override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, ByteArray>) =
            size > FRAME_CACHE_SIZE
    }

    /**
     * Attaches a surface once the view holding it has been inflated. Called once for the
     * seek chip and once for the scrubbing strip; either may be absent.
     */
    fun bind(preview: ImageView) {
        if (views.none { it === preview }) views += preview
        applyAspect(preview)
    }

    /** True when there is a layout to draw from, so a caller can leave its own chrome down. */
    fun hasFrames(): Boolean = track != null

    /**
     * Begins on a title. [available] is the backend saying whether it will answer at all,
     * so an older or deliberately-configured-off gateway is never asked once per playback.
     *
     * The layout is fetched now rather than on the first press, because the first press is
     * exactly when it must already be there — but it is fetched in the background and
     * nothing waits on it.
     */
    fun prepare(itemId: String, available: Boolean) {
        if (this.itemId == itemId) return
        reset()
        this.itemId = itemId
        if (!available || itemId.isBlank()) return
        trackJob = scope.launch {
            val resolved = loadTrack(itemId)
            if (this@TrickplayPreview.itemId == itemId) track = resolved
        }
    }

    /**
     * Draws the frame covering [positionMs], if there is one.
     *
     * [forward] is the direction the viewer is travelling, which is used only to warm the
     * frame they are most likely to ask for next. Presses come in bursts at a fixed step,
     * so the one after this is a good guess and a wrong guess costs a few kilobytes.
     *
     * [into] names which of the bound surfaces is asking. The other is taken down rather
     * than left holding a frame from the last thing that used it.
     */
    fun show(positionMs: Long, forward: Boolean, into: ImageView? = views.firstOrNull()) {
        val current = track ?: return
        val preview = into ?: return
        val frame = current.frameAt(positionMs)
        if (frame == shownFrame && preview === shownIn && preview.isVisible) return

        // Cancelling the previous load is the load-bearing part. Presses arrive faster than
        // a fetch completes, and without this a slow response for a frame the viewer has
        // already skipped past would land on screen after the one they are waiting for —
        // the same reason the search pipeline uses collectLatest.
        frameJob?.cancel()
        frameJob = scope.launch {
            val bytes = frames[frame]
                ?: loadFrame(current, frame)?.also { frames[frame] = it }
                ?: return@launch
            val bitmap = decode(bytes) ?: return@launch
            shownFrame = frame
            shownIn = preview
            applyAspect(preview)
            preview.setImageBitmap(bitmap)
            preview.visibility = View.VISIBLE
            views.forEach { if (it !== preview) clear(it) }
            warm(current, frame + if (forward) 1 else -1)
        }
    }

    /** Takes the preview down without forgetting anything. */
    fun hide() {
        frameJob?.cancel()
        frameJob = null
        shownFrame = NO_FRAME
        shownIn = null
        views.forEach(::clear)
    }

    private fun clear(preview: ImageView) {
        preview.visibility = View.GONE
        preview.setImageDrawable(null)
    }

    /**
     * Forgets the title entirely. The player advances between episodes inside the running
     * player, so this is what stops one episode's thumbnails being shown over the next.
     */
    fun reset() {
        trackJob?.cancel()
        trackJob = null
        track = null
        itemId = null
        frames.clear()
        hide()
    }

    private suspend fun decode(bytes: ByteArray): Bitmap? = withContext(Dispatchers.Default) {
        runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
    }

    private fun warm(current: Trickplay, frame: Int) {
        if (frame < 0 || frame >= current.count || frames.containsKey(frame)) return
        scope.launch {
            val bytes = loadFrame(current, frame) ?: return@launch
            if (track === current) frames[frame] = bytes
        }
    }

    /**
     * Gives the preview the shape the frames actually are.
     *
     * The layout carries a 16:9 box so the chip is about the right size on the very first
     * press, before anything is known; a title whose thumbnails are a wider crop would
     * otherwise be letterboxed inside it for the life of the playback.
     */
    private fun applyAspect(preview: ImageView) {
        val current = track ?: return
        val params = preview.layoutParams ?: return
        val width = current.widthFor(params.height)
        if (params.height <= 0 || params.width == width) return
        params.width = width
        preview.layoutParams = params
    }

    private companion object {
        const val NO_FRAME = -1

        /**
         * A burst of presses walks through frames in one direction and a viewer often walks
         * back over the same ones. Forty entries is a couple of hundred kilobytes of JPEG
         * and about seven minutes of a title at ten seconds a frame.
         */
        const val FRAME_CACHE_SIZE = 40
    }
}
