package com.soulstream.autoprober

import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.media.browse.MediaBrowser
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import org.xmlpull.v1.XmlPullParser

/**
 * The only eye this project has on Android Auto without a car.
 *
 * Auto talks to a media app through `MediaBrowserService`. With SoulStream
 * force-stopped there is no Activity, no WebView and therefore no DevTools
 * socket, so `tests/mobile/smoke.mjs` — which drives the app over CDP — is
 * blind to every bit of it. This app binds the same way Auto does, with
 * Auto's own root hints, walks whatever tree comes back, and prints one JSON
 * object per event under a single logcat tag, so the whole thing can be read
 * with `adb logcat -d -s SoulAutoProbe:I`.
 *
 * Framework classes only — `android.media.browse.MediaBrowser`, not
 * androidx, not media3. That is deliberate: it is the path Android Auto
 * itself takes into a media3 app (media3 serves the legacy
 * `android.media.browse.MediaBrowserService` action through
 * `MediaSessionService.onBind`), and a prober built out of the same library
 * as the thing it is probing would not prove that path works.
 *
 * It changes nothing on the phone. No permissions, no network, no storage,
 * no playback command. It force-stops nothing itself; `probe.sh` does that,
 * to SoulStream only, exactly as the phone walk already does. Every exit
 * path disconnects, and `onDestroy` disconnects again, so no binding is left
 * hanging on Kyle's phone if a step throws.
 *
 * Run it through `tools/auto-prober/probe.sh`, which builds, installs,
 * clears logcat, force-stops SoulStream and starts this with the right
 * extras.
 */
class ProbeActivity : Activity() {
    companion object {
        private const val TAG = "SoulAutoProbe"
        private const val PKG = "com.soulstream.app"
        private const val SVC = "com.soulstream.mediasession.MediaService"
        private const val BROWSE = "android.media.browse.MediaBrowserService"
        private const val LIBRARY = "androidx.media3.session.MediaLibraryService"
        private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"

        /** Longer than Auto's own patience and shorter than a person's. A
         *  root that has not come back by now is the failure this whole app
         *  exists to catch. */
        private const val WATCHDOG_MS = 8000L
    }

    private val main = Handler(Looper.getMainLooper())
    private var browser: MediaBrowser? = null
    private var t0 = 0L
    private var depth = 2
    /** subscriptions asked for and not yet answered; zero means the walk is
     *  over. Main thread only — every callback lands there. */
    private var pending = 0
    private var nodes = 0
    private var leaves = 0
    private var finished = false
    private val subscribed = HashSet<String>()
    private val watchdog = Runnable { fail("watchdog after " + WATCHDOG_MS + "ms") }

    private fun ms() = System.currentTimeMillis() - t0
    private fun say(o: JSONObject) { Log.i(TAG, o.toString()) }

    override fun onCreate(saved: Bundle?) {
        super.onCreate(saved)
        t0 = System.currentTimeMillis()
        depth = intent?.getIntExtra("depth", 2) ?: 2
        val recent = intent?.getBooleanExtra("recent", false) ?: false

        say(
            JSONObject().put("ev", "start").put("pkg", PKG).put("svc", SVC)
                .put("depth", depth).put("recent", recent),
        )
        describe()
        filters()

        /* Auto's own root hints, spelled the way the framework spells them.
           media3's LegacyConversions.convertToLibraryParams reads exactly
           these four and turns them into the LibraryParams that
           LibraryTree.onGetLibraryRoot logs on the far side — so what goes in
           here and what comes out of `adb logcat -s SoulStream:I` are two
           ends of one wire. */
        val hints = Bundle()
        hints.putBoolean("android.service.media.extra.RECENT", recent)
        hints.putInt("androidx.media.MediaBrowserCompat.Extras.KEY_ROOT_CHILDREN_LIMIT", 4)
        hints.putInt(
            "androidx.media.MediaBrowserCompat.Extras.KEY_ROOT_CHILDREN_SUPPORTED_FLAGS",
            MediaBrowser.MediaItem.FLAG_BROWSABLE or MediaBrowser.MediaItem.FLAG_PLAYABLE,
        )
        hints.putInt("android.media.extras.MEDIA_ART_SIZE_HINT_PIXELS", 320)

        main.postDelayed(watchdog, WATCHDOG_MS)
        try {
            val b = MediaBrowser(this, ComponentName(PKG, SVC), connection, hints)
            browser = b
            b.connect()
        } catch (e: Exception) {
            fail("connect threw: " + e)
        }
    }

    private val connection = object : MediaBrowser.ConnectionCallback() {
        /** The number the whole exercise is for: milliseconds from
         *  `connect()` to here, with the app's process cold. Auto gives a
         *  media app a few seconds and then drops it. */
        override fun onConnected() {
            val b = browser ?: return
            val root = try { b.root } catch (e: Exception) { "" }
            val token = try { b.sessionToken != null } catch (e: Exception) { false }
            say(
                JSONObject().put("ev", "root").put("ms", ms())
                    .put("id", root).put("tokenOk", token),
            )
            if (root.isEmpty()) { fail("no root id"); return }
            walk(root, 0)
        }

        /* the two ways a bind ends badly. "connection failed" with nothing
           else in the log is usually package visibility — see <queries> in
           the manifest — or a service that answered null from onGetSession. */
        override fun onConnectionFailed() { fail("connection failed") }
        override fun onConnectionSuspended() { fail("connection suspended") }
    }

    /** Breadth-ish, depth-limited, and every node subscribed at most once.
     *  A `MediaBrowserService` may answer a subscription more than once
     *  (that is how it pushes a changed folder), so both callbacks latch. */
    private fun walk(parent: String, level: Int) {
        val b = browser ?: return
        if (parent.isEmpty() || level > depth) { maybeDone(); return }
        if (!subscribed.add(parent)) { maybeDone(); return }
        pending += 1
        b.subscribe(
            parent,
            object : MediaBrowser.SubscriptionCallback() {
                private var answered = false

                override fun onChildrenLoaded(
                    parentId: String,
                    children: MutableList<MediaBrowser.MediaItem>,
                ) {
                    if (answered) return
                    answered = true
                    val arr = JSONArray()
                    for (c in children) {
                        arr.put(
                            JSONObject()
                                .put("id", c.mediaId ?: "")
                                .put("title", (c.description.title ?: "").toString())
                                .put("browsable", c.isBrowsable)
                                .put("playable", c.isPlayable),
                        )
                    }
                    say(
                        JSONObject().put("ev", "children").put("parent", parentId)
                            .put("n", children.size).put("items", arr),
                    )
                    nodes += 1
                    /* descend BEFORE this one is retired, so `pending` never
                       touches zero while there is more tree to read */
                    for (c in children) {
                        if (c.isBrowsable) walk(c.mediaId ?: "", level + 1) else leaves += 1
                    }
                    pending -= 1
                    maybeDone()
                }

                override fun onError(parentId: String) {
                    if (answered) return
                    answered = true
                    say(
                        JSONObject().put("ev", "children").put("parent", parentId)
                            .put("n", -1).put("items", JSONArray()),
                    )
                    pending -= 1
                    maybeDone()
                }
            },
        )
    }

    /** The app's Auto declaration, read out of the app's OWN resources.
     *  This is the half `adb shell pm dump` cannot do: `dumpsys package`
     *  prints resolver tables and package state and no `<meta-data>` at all,
     *  so the only way to prove `com.google.android.gms.car.application`
     *  points at a real resource — rather than at a dangling id — is to
     *  resolve the int and then open it. */
    private fun describe() {
        val o = JSONObject().put("ev", "desc")
        try {
            val ai = packageManager.getApplicationInfo(PKG, PackageManager.GET_META_DATA)
            val id = ai.metaData?.getInt("com.google.android.gms.car.application", 0) ?: 0
            o.put("metaResId", id)
            if (id != 0) {
                val res = packageManager.getResourcesForApplication(ai)
                o.put("resName", res.getResourceName(id))
                val uses = JSONArray()
                val xml = res.getXml(id)
                var ev = xml.eventType
                while (ev != XmlPullParser.END_DOCUMENT) {
                    if (ev == XmlPullParser.START_TAG && xml.name == "uses") {
                        uses.put(xml.getAttributeValue(ANDROID_NS, "name") ?: "")
                    }
                    ev = xml.next()
                }
                xml.close()
                o.put("uses", uses)
            }
        } catch (e: Exception) {
            o.put("err", e.toString())
        }
        say(o)
    }

    /** What the PackageManager thinks each of the two actions resolves to,
     *  scoped to SoulStream. An empty list for `browse` and a present one
     *  for `media3` is a manifest that landed half; two empty lists with a
     *  working app is package visibility eating this prober. */
    private fun filters() {
        val o = JSONObject().put("ev", "filter")
        for (pair in listOf(Pair("browse", BROWSE), Pair("media3", LIBRARY))) {
            val arr = JSONArray()
            try {
                for (ri in packageManager.queryIntentServices(Intent(pair.second).setPackage(PKG), 0)) {
                    arr.put(ri.serviceInfo.packageName + "/" + ri.serviceInfo.name)
                }
            } catch (e: Exception) {
                arr.put("ERR " + e)
            }
            o.put(pair.first, arr)
        }
        say(o)
    }

    private fun maybeDone() {
        if (finished || pending > 0) return
        say(
            JSONObject().put("ev", "done").put("ms", ms())
                .put("nodes", nodes).put("leaves", leaves),
        )
        stop()
    }

    private fun fail(why: String) {
        if (finished) return
        say(JSONObject().put("ev", "fail").put("why", why).put("ms", ms()))
        stop()
    }

    /** One way out, and `onDestroy` repeats it. A prober that left a binding
     *  behind would hold SoulStream's service up for as long as this process
     *  lived, which is the opposite of what it is for. */
    private fun stop() {
        if (finished) return
        finished = true
        main.removeCallbacks(watchdog)
        try { browser?.disconnect() } catch (_: Exception) {}
        browser = null
        finish()
    }

    override fun onDestroy() {
        main.removeCallbacks(watchdog)
        try { browser?.disconnect() } catch (_: Exception) {}
        browser = null
        super.onDestroy()
    }
}
