package com.soulstream.mediasession

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.content.FileProvider
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.Executors

/**
 * The browse tree, between media3 and the Rust that reads library.db.
 *
 * The car asks a `MediaLibrarySession.Callback` for a root and for children;
 * `src-tauri/src/auto.rs` answers both out of the database, read-only, in a
 * process that has no Activity, no Tauri and no WebView. This file is the
 * hinge: it loads the native library, hands the two directories down, turns
 * the JSON that comes back into `MediaItem`s, mints a `content://` for every
 * cover and grants the browser permission to read it.
 *
 * ## Why `System.loadLibrary` is here
 *
 * The only other loader in the app is `com.soulstream.app.Rust`'s static
 * initialiser, and nothing constructs that without an Activity. A service
 * started for a browse has none — that is the entire point of the round — so
 * the first thing that wants the native side has to load it. Loading is
 * idempotent: with the app already running this is a no-op, and
 * `JNI_OnLoad` (android_glue.rs) has already published the VM and the
 * Context either way.
 *
 * The symbol lives in `libapp_lib.so`, the APP crate's cdylib — the crate
 * that has the `library` and `player` dependencies. Names link at runtime,
 * so this plugin's Rust side needs no dependency on the app crate at all.
 *
 * ## Why none of it runs on the calling thread
 *
 * media3 dispatches every callback on the session's application handler,
 * which is the main looper. A SQLite open plus a query there is an ANR in a
 * moving car, and an "app isn't responding" dialog is not something a driver
 * can deal with. So [children] answers a `SettableFuture` and the work runs
 * on one thread of this object's own — media3 is built for exactly that, and
 * `onGetChildren` returning a pending future is its documented shape.
 *
 * ## The failure that has no symptom
 *
 * media3 grants no URI permission of its own — there is no
 * `grantUriPermission` anywhere in `MediaSessionImpl`,
 * `MediaSessionLegacyStub`, `MediaLibraryServiceLegacyStub` or
 * `LegacyConversions` at 1.10.1 — and the app's FileProvider is
 * `android:exported="false"`. Forget [grant] and the car browses perfectly
 * and shows a grid of grey squares, with nothing in any log and nothing
 * wrong at build time. It is the single easiest thing in this file to lose.
 *
 * Artwork goes across as a URI and NEVER as bytes.
 * `MediaLibraryServiceLegacyStub` decodes `MediaMetadata.artworkData` into a
 * Bitmap for every item it converts, and Google's own guidance is blunt
 * about where that ends: a few hundred bitmaps in one Binder transaction is
 * over the 1 MB limit and the browse fails outright.
 */
@OptIn(markerClass = [UnstableApi::class])
object LibraryBridge {
    /** The root node id. Must match `auto.rs`'s `ROOT`. */
    const val ROOT = "ss:root"

    private const val TAG = "SoulStream"

    /** How many tabs there are, and therefore the largest root-children
     *  limit that means anything. Google's docs put the default at four and
     *  this app has exactly four. */
    private const val TABS = 4

    private var loaded = false
    private var loadError = ""

    init {
        try {
            System.loadLibrary("app_lib")
            loaded = true
        } catch (e: Throwable) {
            /* An empty tree with a reason, not a dead service: the card and
               the session must still work even if the browse side cannot. */
            loadError = e.toString()
            Log.w(TAG, "auto: libapp_lib.so would not load: $e")
        }
    }

    /** One JSON answer for one node. Declared on the object, so JNI hands
     *  the singleton where a static would hand a class — `auto.rs` ignores
     *  it either way. */
    external fun browse(node: String, dataDir: String, cacheDir: String): String

    /** ONE thread, named, so a browse that blocks blocks only browses. */
    private val io = Executors.newSingleThreadExecutor { r -> Thread(r, "ss-auto-browse") }

    /** What the browser said it could show at the root, latched in
     *  `onGetLibraryRoot` because the legacy path — which is the path Auto
     *  takes — hands `onGetChildren` a NULL `LibraryParams` whenever the
     *  browser subscribed without pagination options, and a
     *  `MediaBrowserCompat.subscribe()` always does. The hints exist only in
     *  the root call. */
    @Volatile private var rootLimit = TABS

    /** `none` until the first cover is minted, then `ok` or `fail` — one
     *  `openFileDescriptor` per process against the first URI there is. It
     *  is the only thing on the device that proves the FileProvider root
     *  actually reaches the library's artwork rather than merely producing a
     *  plausible-looking URI. */
    @Volatile private var uriProbe = "none"

    /** Grants already made, so a re-browse of the same tab is not five
     *  hundred more binder calls. Bounded: a library big enough to overflow
     *  this simply pays for the grants again. */
    private val granted = HashSet<String>()

    /**
     * The callback [MediaService] hands to `MediaLibrarySession.Builder`.
     *
     * An anonymous object rather than a class of its own so the latched
     * state above is plainly in scope, and so there is one place — this file
     * — where the whole of the car's view of the library lives.
     */
    fun callback(context: Context): MediaLibraryService.MediaLibrarySession.Callback {
        val ctx = context.applicationContext
        return object : MediaLibraryService.MediaLibrarySession.Callback {
            override fun onGetLibraryRoot(
                session: MediaLibraryService.MediaLibrarySession,
                browser: MediaSession.ControllerInfo,
                params: MediaLibraryService.LibraryParams?,
            ): ListenableFuture<LibraryResult<MediaItem>> {
                /* EXTRAS_KEY_ROOT_CHILDREN_LIMIT is media3's own alias for
                   the legacy BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, and
                   it is the only spelling available to us: the legacy class
                   is @RestrictTo(LIBRARY), and androidx.media is runtime
                   scope only since round forty-eight, so neither
                   androidx.media.utils.MediaConstants nor
                   androidx.media3.session.legacy.MediaConstants is on this
                   module's compile classpath. LegacyConversions carries the
                   browser's whole root-hints Bundle into params.extras, so
                   the int is there to be read. */
                val hinted = try {
                    params?.extras?.getInt(MediaConstants.EXTRAS_KEY_ROOT_CHILDREN_LIMIT, 0) ?: 0
                } catch (e: Exception) {
                    0
                }
                rootLimit = if (hinted in 1..TABS) hinted else TABS
                return Futures.immediateFuture(
                    LibraryResult.ofItem(
                        item(ROOT, "tab", "SoulStream", "", browsable = true, playable = false, art = null, durationMs = null),
                        params,
                    ),
                )
            }

            override fun onGetChildren(
                session: MediaLibraryService.MediaLibrarySession,
                browser: MediaSession.ControllerInfo,
                parentId: String,
                page: Int,
                pageSize: Int,
                params: MediaLibraryService.LibraryParams?,
            ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
                /* page and pageSize are ignored, and that is correct rather
                   than lazy: the legacy stub calls us with
                   (page = 0, pageSize = Integer.MAX_VALUE) for a browser that
                   sent no pagination option, which Auto never does. The cap
                   that matters is auto.rs's five hundred rows, because the
                   whole answer crosses one Binder transaction. */
                val node = if (parentId == ROOT) ROOT + "?limit=" + rootLimit else parentId
                val out = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
                io.execute {
                    val items = try {
                        children(ctx, node, browser.packageName)
                    } catch (e: Throwable) {
                        /* never an error code: an empty list is a library
                           that says it is empty, and an error is a head unit
                           that spins */
                        Log.w(TAG, "auto: browse of $node failed: $e")
                        ImmutableList.of<MediaItem>()
                    }
                    out.set(LibraryResult.ofItemList(items, params))
                }
                return out
            }

            override fun onGetItem(
                session: MediaLibraryService.MediaLibrarySession,
                browser: MediaSession.ControllerInfo,
                mediaId: String,
            ): ListenableFuture<LibraryResult<MediaItem>> {
                /* Answered from the GRAMMAR, not the database, and it is not
                   a shortcut. The default onSubscribe implementation calls
                   onGetItem and REJECTS the subscription unless what comes
                   back is a browsable item — so leaving this at its default
                   (ERROR_NOT_SUPPORTED) would refuse every subscribe a
                   browser makes. What it needs is the browsable flag, and
                   the id alone says that. */
                if (!mediaId.startsWith("ss:")) {
                    return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
                }
                val kind = kindOf(mediaId)
                val leaf = kind == "track" || kind == "plitem"
                return Futures.immediateFuture(
                    LibraryResult.ofItem(
                        item(mediaId, kind, labelOf(mediaId), "", !leaf, leaf, null, null),
                        null,
                    ),
                )
            }
        }
    }

    /* ── the work, off the main thread ──────────────────────────────── */

    private fun children(ctx: Context, node: String, pkg: String?): ImmutableList<MediaItem> {
        val began = SystemClock.elapsedRealtime()
        /* getDataDir(), not getFilesDir(): paths::app_data is tauri's
           app_data_dir(), which on Android is PathPlugin.getDataDir ->
           activity.dataDir. config.json, the Library fallback and the
           .soulstream folder all hang off that, and none of them is under
           files/. */
        val json = if (!loaded) {
            ""
        } else {
            try {
                browse(node, ctx.dataDir.absolutePath, ctx.cacheDir.absolutePath)
            } catch (e: Throwable) {
                Log.w(TAG, "auto: the bridge threw for $node: $e")
                ""
            }
        }

        val out = ArrayList<MediaItem>()
        val ids = HashSet<String>()
        val kinds = LinkedHashSet<String>()
        var note = if (loaded) "" else "native library: $loadError"
        var ms = -1
        var browsable = 0
        var playable = 0
        var titled = 0
        var arted = 0
        var failed = 0

        if (json.isNotEmpty()) {
            try {
                val answer = JSONObject(json)
                if (note.isEmpty()) note = answer.optString("note", "")
                ms = answer.optInt("ms", -1)
                if (answer.optBoolean("capped", false)) {
                    note = if (note.isEmpty()) "capped" else "$note; capped"
                }
                val rows = answer.optJSONArray("items") ?: JSONArray()
                for (i in 0 until rows.length()) {
                    val row = rows.optJSONObject(i) ?: continue
                    val id = row.optString("id", "")
                    if (id.isEmpty()) continue
                    val kind = row.optString("kind", "?")
                    val title = row.optString("title", "")
                    val isBrowsable = row.optBoolean("browsable", false)
                    val isPlayable = row.optBoolean("playable", false)
                    val path = if (row.isNull("art")) "" else row.optString("art", "")
                    var art: Uri? = null
                    if (path.isNotEmpty()) {
                        art = artUri(ctx, path)
                        if (art == null) {
                            failed++
                        } else {
                            grant(ctx, pkg, art)
                            arted++
                        }
                    }
                    val duration = if (row.isNull("duration")) null else row.optLong("duration", 0L)
                    out.add(item(id, kind, title, row.optString("subtitle", ""), isBrowsable, isPlayable, art, duration))
                    ids.add(id)
                    kinds.add(kind)
                    if (isBrowsable) browsable++
                    if (isPlayable) playable++
                    if (title.isNotEmpty()) titled++
                }
            } catch (e: Exception) {
                note = "json: $e"
            }
        } else if (note.isEmpty()) {
            note = "the bridge answered nothing"
        }

        probeUri(ctx, out)

        /* The one line on the DEVICE that says what the car was handed.
           Nothing else can be asked: there is no screen, no WebView and no
           app — the whole point of the round — so logcat is where
           tests/mobile/smoke.mjs reads this back, one line per node, with
           `note=` LAST because a sqlite message may hold anything, spaces
           and `=` included. `ms` is measured inside the Rust call and
           `elapsed` around the whole hop, so a gap between them is the
           bridge itself. */
        Log.i(
            TAG,
            "auto node=" + node +
                " n=" + out.size +
                " br=" + browsable +
                " pl=" + playable +
                " uniq=" + ids.size +
                " titled=" + titled +
                " art=" + arted +
                " fail=" + failed +
                " lim=" + rootLimit +
                " open=" + uriProbe +
                " ms=" + ms +
                " hop=" + (SystemClock.elapsedRealtime() - began) +
                " kinds=" + kinds.joinToString("|") +
                " note=" + note.replace('\n', ' ').replace('\r', ' '),
        )
        return ImmutableList.copyOf(out)
    }

    /* ── items ──────────────────────────────────────────────────────── */

    /**
     * The subtitle travels as `artist`, which looks odd and is deliberate:
     * with no `displayTitle` set, `LegacyConversions` fills a legacy
     * description's title/subtitle/description from title, artist, album in
     * that order — so `artist` IS the second line in the car, and setting
     * `displayTitle` instead would throw the artist away entirely (which is
     * what the AVRCP note in media3's own source is about).
     */
    private fun item(
        id: String,
        kind: String,
        title: String,
        subtitle: String,
        browsable: Boolean,
        playable: Boolean,
        art: Uri?,
        durationMs: Long?,
    ): MediaItem {
        val md = MediaMetadata.Builder()
            .setTitle(title)
            /* both, non-null, always: LibraryResult.ofItem and ofItemList
               verify each of them and throw IllegalArgumentException on a
               null — which would take the whole browse down */
            .setIsBrowsable(browsable)
            .setIsPlayable(playable)
            .setMediaType(mediaType(kind, browsable))
        if (subtitle.isNotEmpty()) md.setArtist(subtitle)
        if (art != null) md.setArtworkUri(art)
        if (durationMs != null && durationMs > 0L) md.setDurationMs(durationMs)
        return MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build()
    }

    private fun mediaType(kind: String, browsable: Boolean): Int = when (kind) {
        "album" -> MediaMetadata.MEDIA_TYPE_ALBUM
        "artist" -> MediaMetadata.MEDIA_TYPE_ARTIST
        "playlist" -> MediaMetadata.MEDIA_TYPE_PLAYLIST
        "track", "plitem" -> MediaMetadata.MEDIA_TYPE_MUSIC
        else -> if (browsable) MediaMetadata.MEDIA_TYPE_FOLDER_MIXED else MediaMetadata.MEDIA_TYPE_MUSIC
    }

    /** `ss:album:12` -> `album`; the root is its own kind. */
    private fun kindOf(id: String): String {
        if (id == ROOT) return "tab"
        val rest = id.removePrefix("ss:")
        val cut = rest.indexOf(':')
        return if (cut <= 0) rest else rest.substring(0, cut)
    }

    private fun labelOf(id: String): String = when (id) {
        ROOT -> "SoulStream"
        "ss:tab:recent" -> "Recent"
        "ss:tab:albums" -> "Albums"
        "ss:tab:artists" -> "Artists"
        "ss:tab:playlists" -> "Playlists"
        else -> id
    }

    /* ── artwork ────────────────────────────────────────────────────── */

    /**
     * A file path from the database becomes a `content://` through the app's
     * own FileProvider, whose roots are `src-tauri/android/res/xml/
     * file_paths.xml`. `getUriForFile` throws `IllegalArgumentException` for
     * a path under no configured root, which is caught here and counted: a
     * cover that cannot be addressed is a missing cover, never a failed
     * browse.
     */
    private fun artUri(ctx: Context, path: String): Uri? = try {
        FileProvider.getUriForFile(ctx, ctx.packageName + ".fileprovider", File(path))
    } catch (e: Exception) {
        Log.w(TAG, "auto: no provider root for $path ($e)")
        null
    }

    /**
     * The line without which the car shows grey squares and says nothing.
     * See the class note: media3 grants nothing, the provider is not
     * exported, and only the browser we are answering may read the cover —
     * that exact URI, that one package.
     */
    private fun grant(ctx: Context, pkg: String?, uri: Uri) {
        if (pkg.isNullOrEmpty() || pkg == ctx.packageName) return
        val key = "$pkg $uri"
        synchronized(granted) {
            if (granted.size > 4000) granted.clear()
            if (!granted.add(key)) return
        }
        try {
            ctx.grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
        } catch (e: Exception) {
            Log.w(TAG, "auto: could not grant $uri to $pkg: $e")
        }
    }

    /** Once per process, against the first cover there is: does the URI we
     *  just minted actually open? Nothing else proves the provider root
     *  reaches the library rather than merely looking like it might. */
    private fun probeUri(ctx: Context, items: List<MediaItem>) {
        if (uriProbe != "none") return
        val uri = items.firstNotNullOfOrNull { it.mediaMetadata.artworkUri } ?: return
        uriProbe = try {
            val fd = ctx.contentResolver.openFileDescriptor(uri, "r")
            if (fd == null) {
                "fail"
            } else {
                fd.close()
                "ok"
            }
        } catch (e: Exception) {
            Log.w(TAG, "auto: $uri would not open: $e")
            "fail"
        }
    }
}
