package com.soulstream.mediasession

import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.SimpleBasePlayer
import androidx.media3.common.util.UnstableApi
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import java.io.File

/**
 * The `Player` media3 shows the world, over an engine it cannot see.
 *
 * rodio owns playback, in Rust, and Kotlin has never touched a sample — so
 * there is no `ExoPlayer` here and there never will be. `SimpleBasePlayer`
 * is media3's supported answer to exactly that shape: implement
 * [getState] as a pure read of whatever the real engine last said, route
 * every transport call into the engine, and call [invalidateState] when the
 * engine answers. `MediaHub.state` is that "whatever it last said" — the
 * last `NowPlayingArgs` the WebView pushed across the bridge — and
 * `MediaHub.act` is the way back out to it.
 *
 * The opt-in is `androidx.annotation.OptIn`, imported by name because
 * `kotlin.OptIn` is in every file's default scope and would otherwise win —
 * and written `markerClass = [...]` because that is the attribute's real
 * name in either reading of it. `@UnstableApi` is `RetentionPolicy.CLASS`
 * plus androidx's own `RequiresOptIn`, which makes it a LINT error and not a
 * compiler one: the debug build this project has always made never runs
 * lint, `lintVitalArm64Release` would, and the annotation keeps that honest
 * either way.
 */
@OptIn(markerClass = [UnstableApi::class])
class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMainLooper()) {
    companion object {
        /** How long a transport command may wait for the WebView before the
         *  card is allowed to snap back to the truth. See [awaitUpdate]. */
        const val ACT_TIMEOUT_MS = 2500L
    }

    private val main = Handler(Looper.getMainLooper())
    /** Commands sent out to the WebView and not yet answered. Main thread only. */
    private val pending = ArrayList<SettableFuture<Void>>()
    private val expire = Runnable { settle() }
    private var released = false

    /* ── the state media3 reads ──────────────────────────────────────── */

    override fun getState(): SimpleBasePlayer.State {
        val np = MediaHub.state
            ?: return SimpleBasePlayer.State.Builder()
                .setAvailableCommands(commands(null))
                .setPlaybackState(Player.STATE_IDLE)
                .setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
                .build()

        /* THE PLAYLIST MUST NEVER BE EMPTY WHILE A TRACK EXISTS.
           MediaNotificationManager.shouldShowNotification returns false the
           moment `controller.getCurrentTimeline().isEmpty()`, and it says
           nothing anywhere when it does — the card simply never appears.
           Today's frontend cannot send an empty window with a track on it:
           media.svelte.js builds `win = []` only when `player.qi < 0`, and
           `player.track` is `queue[qi]`, so that same state sends `clear`
           instead. The one-item fallback is here because the failure it
           guards is invisible and the guard is three lines: a frontend older
           or newer than this plugin, or a `queue` that did not survive the
           two JSON hops, would otherwise take the card down silently. */
        val window = np.queue ?: emptyList()
        var at = np.queueIndex ?: -1
        val items = ArrayList<SimpleBasePlayer.MediaItemData>()
        if (window.isEmpty() || at < 0 || at >= window.size) {
            at = 0
            items.add(item("solo", null, np.title, np.artist, np.album, np.duration, null, true))
        } else {
            for (i in window.indices) {
                val q = window[i]
                /* A STABLE uid. `queueBase` is where the window starts in the
                   whole queue, so an entry keeps its identity as the window
                   slides past it and media3 reports one media-item transition
                   per track change rather than re-labelling all twenty-one.
                   Without it (an older frontend across the bridge) fall back
                   to the slot, which is unique within one playlist — that is
                   all setPlaylist demands — at the cost of a rebuild. */
                val base = np.queueBase
                val uid = if (base != null) "q" + (base + i) else "w" + i + ":" + (q.id ?: "")
                items.add(item(uid, q.id, q.title, q.artist, q.album, q.duration, q.artKey, i == at))
            }
        }

        val posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
        return SimpleBasePlayer.State.Builder()
            .setAvailableCommands(commands(np))
            /* STATE_READY even when paused. shouldShowNotification treats
               STATE_IDLE as "nothing to show" unless it has been prepared and
               stopped, so a paused player reporting IDLE loses its card.
               There is no buffering state to report either: media.svelte.js
               already folds `buffering` into `playing`. */
            .setPlaybackState(Player.STATE_READY)
            .setPlayWhenReady(np.playing, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
            .setPlaylist(items)
            .setCurrentMediaItemIndex(at)
            /* The long overload, not a PositionSupplier: State's own
               constructor turns it into PositionSupplier.getExtrapolating
               when playWhenReady and STATE_READY, and getConstant otherwise —
               which is precisely "the card ticks while playing and holds
               while paused", for free and in one place. */
            .setContentPositionMs(posMs)
            /* The app's numbering is 0 off · 1 ALL · 2 ONE; media3's is
               0 OFF · 1 ONE · 2 ALL. They are SWAPPED, and mapping them by
               value would silently turn repeat-all into repeat-one. It is
               mirrored at all so the player's state is not a lie to whatever
               reads it — the system's own media controls now, Android Auto
               later — and so media3's view of "is there a next item" agrees
               with the hasNext the next button is advertised on. The BUTTON
               does not hang on it: BasePlayer forwards a seek it cannot
               resolve rather than dropping it (ignoreSeek -> handleSeek with
               C.INDEX_UNSET), so an advertised command always arrives. */
            .setRepeatMode(
                when (np.repeat) {
                    1 -> Player.REPEAT_MODE_ALL
                    2 -> Player.REPEAT_MODE_ONE
                    else -> Player.REPEAT_MODE_OFF
                },
            )
            .setAudioAttributes(
                AudioAttributes.Builder()
                    .setUsage(C.USAGE_MEDIA)
                    .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
                    .build(),
            )
            .build()
    }

    /** One row of the window as media3 wants it. `current` is the one whose
     *  cover rides along as bytes; the rest name a file and media3's own
     *  BitmapLoader reads it if anything ever asks. */
    private fun item(
        uid: String,
        id: String?,
        title: String?,
        artist: String?,
        album: String?,
        duration: Double?,
        artKey: String?,
        current: Boolean,
    ): SimpleBasePlayer.MediaItemData {
        val md = MediaMetadata.Builder()
            .setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
            .setArtist(artist ?: "")
            .setAlbumTitle(album ?: "")
            .setIsBrowsable(false)
            .setIsPlayable(true)
        if (current) {
            /* The cover, or the app's own icon. `artBytes` is cleared the
               moment `artKey` changes and only refilled once the frontend has
               decoded one — and a track with no cover never refills it — so
               without the fallback every track change has a window where the
               lock screen shows a grey hole. media3 decodes these bytes
               itself (CacheBitmapLoader over DataSourceBitmapLoader), which
               is one BitmapFactory call this file no longer makes. */
            val bytes = MediaHub.artBytes ?: MediaHub.appIcon(ctx)
            if (bytes != null) md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
        } else if (!artKey.isNullOrEmpty()) {
            /* A LOCAL path only. A catalog record's art key is an http URL,
               and media3's default loader would happily fetch it — twenty
               times, off a queue nothing is looking at. */
            val f = File(artKey)
            if (f.isAbsolute) md.setArtworkUri(Uri.fromFile(f))
        }
        val meta = md.build()
        val durUs = if (duration != null && duration > 0) (duration * 1_000_000).toLong() else C.TIME_UNSET
        return SimpleBasePlayer.MediaItemData.Builder(uid)
            .setMediaItem(MediaItem.Builder().setMediaId(id ?: uid).setMediaMetadata(meta).build())
            .setMediaMetadata(meta)
            .setIsSeekable(true)
            .setIsDynamic(false)
            .setDurationUs(durUs)
            .build()
    }

    /** What the card, the lock screen and a headset are allowed to ask for.
     *
     *  COMMAND_GET_TIMELINE and COMMAND_GET_CURRENT_MEDIA_ITEM go together
     *  and neither is optional. The media notification controller reads the
     *  playlist THROUGH the session, and PlayerInfo.filterByAvailableCommands
     *  cuts what it may see down to what its commands allow: one window
     *  without COMMAND_GET_TIMELINE, and Timeline.EMPTY without either. An
     *  empty timeline on the far side is shouldShowNotification refusing the
     *  card — the same silent failure as an empty playlist, from the other
     *  direction.
     *
     *  Both spellings of next and previous, because
     *  DefaultMediaNotificationProvider.getMediaButtons SHOWS the button on
     *  containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) and
     *  then sets the button's command to the _MEDIA_ITEM one: advertise only
     *  the plain one and the button appears and does nothing.
     *
     *  Not here, on purpose: COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS
     *  and COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the queue is Android
     *  Auto's job and needs a window-index → queue-index mapping; `queueBase`
     *  is half of it and it lands with the round that uses it. */
    private fun commands(np: NowPlayingArgs?): Player.Commands {
        val b = Player.Commands.Builder()
            .add(Player.COMMAND_PLAY_PAUSE)
            .add(Player.COMMAND_PREPARE)
            .add(Player.COMMAND_STOP)
            .add(Player.COMMAND_RELEASE)
            .add(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
            .add(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)
            .add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
            .add(Player.COMMAND_GET_TIMELINE)
            .add(Player.COMMAND_GET_METADATA)
            .add(Player.COMMAND_GET_AUDIO_ATTRIBUTES)
            .add(Player.COMMAND_SET_AUDIO_ATTRIBUTES)
        if (np != null) {
            b.addIf(Player.COMMAND_SEEK_TO_NEXT, np.hasNext)
            b.addIf(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, np.hasNext)
            b.add(Player.COMMAND_SEEK_TO_PREVIOUS)
            b.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
        }
        return b.build()
    }

    /* ── the transport, back out to the WebView ──────────────────────── */

    override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
        MediaHub.act(if (playWhenReady) "play" else "pause")
        return awaitUpdate()
    }

    /** Nothing to prepare: the Rust side sources a track on its own, and
     *  `getState` never reports STATE_IDLE while there is one. Answered
     *  rather than left to throw, because the default body of every
     *  `handle*` is `throw IllegalStateException`. */
    override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture()

    override fun handleStop(): ListenableFuture<*> {
        MediaHub.act("stop")
        return awaitUpdate()
    }

    override fun handleRelease(): ListenableFuture<*> {
        released = true
        main.removeCallbacks(expire)
        val batch = ArrayList(pending)
        pending.clear()
        /* posted, not completed here: finishing a future re-enters media3's
           own listener, and this one is already inside a player call */
        main.post { for (f in batch) f.set(null) }
        return Futures.immediateVoidFuture()
    }

    /** A no-op that must exist. `setAudioAttributes(attrs, handleAudioFocus)`
     *  is an ExoPlayer convenience: on SimpleBasePlayer it lands HERE, whose
     *  default body throws, and media3 does none of the focus work for a
     *  custom player — `AudioFocusManager` is inside media3-exoplayer and is
     *  not public API. Focus is still ours, requested and abandoned by hand
     *  in MediaService, exactly as it was before the migration. */
    override fun handleSetAudioAttributes(
        audioAttributes: AudioAttributes,
        handleAudioFocus: Boolean,
    ): ListenableFuture<*> = Futures.immediateVoidFuture()

    /** Every seek, next and previous arrives here — there is no
     *  `handleSeekToNext` and never was — distinguished by the command that
     *  triggered it. The index is ignored on purpose: the app's own `next()`
     *  and `prev()` know about repeat, about the five-second restart rule and
     *  about the end of the queue, and the window is only ever a view.
     *
     *  It arrives even when media3 has nothing to seek TO: `BasePlayer`
     *  forwards such a press rather than dropping it, as
     *  `ignoreSeek` -> `handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)`.
     *  Which is why the previous button still works at the very head of a
     *  queue, where the timeline has no previous item but the app's own
     *  `prev()` seeks to zero. The gate is the COMMAND being advertised,
     *  and nothing else. */
    override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
        when (seekCommand) {
            Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> MediaHub.act("next")
            Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> MediaHub.act("prev")
            else -> MediaHub.act("seek", if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0)
        }
        return awaitUpdate()
    }

    /* ── the round trip ──────────────────────────────────────────────── */

    /**
     * A transport command is a round trip: out through `Plugin.trigger` to
     * the WebView, into the player, down to Rust, and back as the next
     * `update`. Answering media3 immediately would have it call [getState]
     * on the spot — which still reports the OLD state — and the lock screen
     * flips back before it flips forward.
     *
     * So hand it a future nobody has completed. While one is outstanding
     * media3 shows its own suggested placeholder (play-pause flipped, the
     * seek applied), which is exactly the optimistic answer we want, and
     * [invalidateState] deliberately does nothing until the last one
     * resolves. `MediaHub.update` resolves them; [expire] resolves them
     * anyway after [ACT_TIMEOUT_MS], because a future that is never
     * completed does not degrade the card — it FREEZES it.
     */
    private fun awaitUpdate(): ListenableFuture<*> {
        if (released) return Futures.immediateVoidFuture()
        val f = SettableFuture.create<Void>()
        pending.add(f)
        main.removeCallbacks(expire)
        main.postDelayed(expire, ACT_TIMEOUT_MS)
        return f
    }

    /** The engine answered (or stopped answering). Main thread only — every
     *  caller is `MediaHub`'s own post to the main looper, or [expire]. */
    fun settle() {
        if (pending.isEmpty()) return
        main.removeCallbacks(expire)
        val batch = ArrayList(pending)
        pending.clear()
        /* completing these re-reads getState() on this same thread, inline */
        for (f in batch) f.set(null)
    }

    /** `invalidateState` is `protected final`; this is the door MediaHub
     *  uses. A no-op once released, and a no-op inside media3 anyway while
     *  anything is still pending. */
    fun refresh() {
        if (released) return
        invalidateState()
    }

    fun markReleased() {
        released = true
        main.removeCallbacks(expire)
        pending.clear()
    }
}
