package com.soulstream.mediasession

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.graphics.Canvas
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Base64
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import java.io.ByteArrayOutputStream

/**
 * What is playing, shared between the plugin (which hears the WebView) and
 * the service (which owns the session) — and, since round forty-nine, the
 * owner of everything that must outlive the service.
 *
 * Bound, not started, and that is half the safety story of this file.
 * `MediaSessionService.onStartCommand` promotes the service for a media
 * action, a custom action or media3's own self-start intent — which it
 * recognises by a uid extra of its own making — and returns START_STICKY
 * without calling `startForeground()` for anything else. A plain
 * `Intent(ctx, MediaService::class.java)` is anything else. So a
 * `startForegroundService` here would arm Android's ten-second timer and
 * satisfy nothing, which is the 2026-09-13 bug with a new coat on. media3
 * promotes ITSELF, once there is something playing to promote for:
 * `MediaNotificationManager.startForeground()` does the
 * `startForegroundService` and the `setForegroundServiceNotification` in the
 * same call. `bindService` merely creates the service, is not subject to the
 * Android 8+/12+ background-start restriction, and never arms the timer.
 *
 * The other half is [MediaService.onStartCommand], because the SYSTEM starts
 * this service in the foreground on a button press whether media3 then wants
 * it there or not.
 *
 * ── WHY THE SESSION, THE PLAYER, THE FOCUS AND THE RECEIVER LIVE HERE ────
 *
 * androidx/media issue #3158, open since 2026-04-03 and still open when this
 * round was written (last confirmed 2026-09-04, reproduced on media3 1.11.0):
 * when a host binds a `MediaLibraryService` with nothing playing, the service
 * runs `onCreate → onBind → onGetSession → onConnect → onDestroy` at about
 * thirty cycles a second and keeps doing it. It is 100% reproducible after a
 * force-stop with no existing binding, which is exactly what Android Auto
 * does on a fresh connection — and exactly what `tools/auto-prober/` does.
 * marcbaechinger's answer on the issue is that this is the Service contract
 * (the last client unbinds, nothing called `onStartCommand`, so the system
 * destroys it) and that the fix is being prepared on the Android Auto side;
 * media3 will not act first. So it cannot be fixed from here — only made
 * harmless.
 *
 * Made harmless by allocating NOTHING per cycle. Everything below the line
 * is built once per PROCESS and never released: one `MediaLibrarySession`,
 * one [SoulStreamPlayer], one notification provider, one audio-focus
 * request, one becoming-noisy receiver, one notification channel. What round
 * forty-eight did in `onCreate`/`onDestroy` would, under that loop, have
 * built a platform session and a legacy browser stub thirty times a second
 * (the exact retention the issue names), registered and unregistered a
 * broadcast receiver sixty times a second ("Too many receivers registered"),
 * and — worst, because it is audible — abandoned audio focus thirty times a
 * second WHILE MUSIC WAS PLAYING.
 *
 * The session is built against `applicationContext` and not the Service, and
 * that is not tidiness: `MediaSessionImpl.getLegacyBrowserServiceBinder()`
 * hands the session's own context to
 * `MediaSessionServiceLegacyStub.initialize()`, which calls
 * `attachToBaseContext(sessionImpl.getContext())`. A session built against
 * the Service therefore pins a destroyed Service as the base context of a
 * live `MediaBrowserServiceCompat` — which is the leak comment 9 of #3158
 * describes from the other end.
 *
 * The counter is the instrument. `MediaService.onCreate` logs
 * `auto service created n=…`; the last such line in `adb logcat -d -s
 * SoulStream:I` is how many times this process has built the service, and a
 * number that climbs during a bind is #3158 firing. Without it an
 * unmitigated version of this bug reaches Kyle as an unexplained OOM in a
 * car, hours later.
 */
@OptIn(markerClass = [UnstableApi::class])
object MediaHub {
    @Volatile var onAction: ((String, Double) -> Unit)? = null
    @Volatile var state: NowPlayingArgs? = null
    /** the cover as ENCODED bytes — media3 decodes them itself */
    @Volatile var artBytes: ByteArray? = null
    @Volatile var artKey: String? = null
    @Volatile var service: MediaService? = null
    @Volatile var player: SoulStreamPlayer? = null
    /** the app's own icon, encoded once — see appIcon() */
    @Volatile private var icon: ByteArray? = null

    /* ── the process-lifetime half: built once, never released ────────── */
    @Volatile private var libSession: MediaLibrarySession? = null
    @Volatile private var noteProvider: DefaultMediaNotificationProvider? = null
    @Volatile private var focus: AudioFocusRequest? = null
    @Volatile private var pausedByFocus = false
    @Volatile private var noisyOn = false
    @Volatile private var channelDone = false
    /** how many `MediaService` instances this process has built. See the
     *  class comment: this is the #3158 instrument. */
    @Volatile var creations = 0
        private set

    private val mainHandler = Handler(Looper.getMainLooper())
    @Volatile private var bound = false
    private val conn = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {}
        /* the service died; the binding survives and the system rebuilds it */
        override fun onServiceDisconnected(name: ComponentName?) {}
    }

    private val noisy = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) act("pause")
        }
    }

    private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
        when (change) {
            AudioManager.AUDIOFOCUS_LOSS -> {
                pausedByFocus = false
                act("pause")
            }
            AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
                if (state?.playing == true) {
                    pausedByFocus = true
                    act("pause")
                }
            }
            AudioManager.AUDIOFOCUS_GAIN -> {
                if (pausedByFocus) {
                    pausedByFocus = false
                    act("play")
                }
            }
            /* CAN_DUCK: the framework ducks us on its own */
        }
    }

    fun update(ctx: Context, np: NowPlayingArgs) {
        val app = ctx.applicationContext
        if (np.artKey != artKey) {
            artKey = np.artKey
            artBytes = null
        }
        np.art?.let { b64 ->
            try {
                artBytes = Base64.decode(b64, Base64.DEFAULT)
            } catch (_: Exception) {
                /* an unreadable cover is no cover */
            }
        }
        state = np
        bind(app)
        /* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
           verifyApplicationThread(), a Tauri @Command is not promised to run
           on the main thread, and the IllegalStateException that follows
           would be swallowed by media.svelte.js's own `.catch(() => {})` —
           the card would just quietly stop updating, with nothing in any log. */
        mainHandler.post {
            /* focus is no longer the service's, so it no longer waits for
               one. Round forty-eight had to catch this up from `onCreate`,
               because the update that MADE the service found `service` still
               null; there is nothing to catch up now. */
            if (np.playing) requestFocus(app)
            player?.let {
                it.settle()
                it.refresh()
            }
        }
    }

    /** The bar emptied. The playlist goes empty, which is how media3 is told
     *  to take the card down (`shouldShowNotification` refuses an empty
     *  timeline) and to step out of the foreground. The service is left
     *  alive and the session left registered ON PURPOSE: a headset button
     *  should still reach us with nothing on the bar, and re-creating the
     *  service is the expensive half. Audio focus IS given back — by this
     *  object rather than by a service that may not exist. */
    fun clear(ctx: Context) {
        val app = ctx.applicationContext
        state = null
        artBytes = null
        artKey = null
        mainHandler.post {
            dropFocus(app)
            player?.let {
                it.settle()
                it.refresh()
            }
        }
    }

    /* two updates can arrive on two binder threads; one binding is enough */
    @Synchronized
    private fun bind(ctx: Context) {
        if (bound) return
        try {
            /* The action matters: `onBind` answers only for an action it
               knows and returns null for a bare intent. Three are declared
               in the manifest now and any of them would do — the component
               is explicit, so no filter is consulted — and the LibraryService
               one is named here because that is what this service is.
               BIND_AUTO_CREATE is what creates the service at all. */
            val intent = Intent(ctx, MediaService::class.java)
                .setAction(MediaLibraryService.SERVICE_INTERFACE)
            bound = ctx.bindService(intent, conn, Context.BIND_AUTO_CREATE)
            if (!bound) {
                android.util.Log.w("SoulStream", "media service would not bind")
                try { ctx.unbindService(conn) } catch (_: Exception) {}
            }
        } catch (e: Exception) {
            android.util.Log.w("SoulStream", "media service could not bind: $e")
        }
    }

    /* ── the singletons ──────────────────────────────────────────────── */

    /** The one session this process will ever have, and the one player under
     *  it. Built on the first `MediaService.onCreate` and handed back to
     *  every one after it — which is the whole #3158 mitigation.
     *
     *  `MediaLibrarySession.Builder(Context, Player, Callback)` rather than
     *  the `(MediaLibraryService, …)` overload, which is a one-line delegate
     *  to it (`this((Context) service, player, callback)`) that exists only
     *  to keep construction inside a service. The Context overload is
     *  @UnstableApi; this file is already opted in, and see the class
     *  comment for why a Service context is the wrong thing to pin here.
     *
     *  Main thread only in practice: `onCreate` runs there and always before
     *  any bind, so by the time `onGetSession` asks, this is a pure read. */
    @Synchronized
    fun session(app: Context): MediaLibrarySession {
        libSession?.let { return it }
        val p = SoulStreamPlayer(app)
        player = p
        val builder = MediaLibrarySession.Builder(app, p, LibraryTree).setId("SoulStream")
        val launch = app.packageManager.getLaunchIntentForPackage(app.packageName)
        if (launch != null) {
            builder.setSessionActivity(
                PendingIntent.getActivity(
                    app, 0, launch,
                    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
                ),
            )
        }
        val s = builder.build()
        libSession = s
        return s
    }

    /** The session if one was ever built, without building one. `onDestroy`
     *  and `holdForeground` both want exactly this. */
    fun sessionOrNull(): MediaLibrarySession? = libSession

    /** The notification provider, also once. It is configuration and a
     *  Context and nothing else, so one instance serves every service
     *  instance; the `MediaNotificationManager` that READS it is media3's
     *  own and is per-service, which is why `setMediaNotificationProvider`
     *  still runs in every `onCreate`. */
    @Synchronized
    fun provider(app: Context): DefaultMediaNotificationProvider {
        noteProvider?.let { return it }
        val pr = DefaultMediaNotificationProvider.Builder(app)
            .setChannelId(MediaService.CHANNEL)
            .setChannelName(R.string.ss_playback_channel)
            .setNotificationId(MediaService.NOTIFICATION_ID)
            .build()
        pr.setSmallIcon(R.drawable.ic_soulstream_note)
        noteProvider = pr
        return pr
    }

    /** Ours first. media3 would create the channel itself, at IMPORTANCE_LOW
     *  and nothing else — no lockscreenVisibility, and setShowBadge only
     *  below API 28 — so on a fresh install the card would be hidden behind
     *  "sensitive notifications". `ensureNotificationChannel` leaves an
     *  existing channel alone, so getting in first is the whole fix.
     *
     *  Latched, and the latch is set BEFORE the work: this used to run in
     *  every `onCreate`, which under #3158 is thirty `NotificationManager`
     *  round trips a second for a channel that already exists. */
    @Synchronized
    fun ensureChannel(app: Context) {
        if (channelDone) return
        channelDone = true
        if (Build.VERSION.SDK_INT < 26) return
        try {
            val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            if (manager.getNotificationChannel(MediaService.CHANNEL) != null) return
            val channel =
                NotificationChannel(MediaService.CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)
            channel.description = "What is playing"
            channel.setShowBadge(false)
            channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
            manager.createNotificationChannel(channel)
        } catch (e: Exception) {
            android.util.Log.w("SoulStream", "playback channel could not be made: $e")
        }
    }

    /** Headphones out pauses. Registered ONCE, on the application context,
     *  and never unregistered — the process dying is what takes it down.
     *  A system broadcast reaches a non-exported receiver; Android 14 wants
     *  the flag said out loud. */
    @Synchronized
    fun ensureNoisy(app: Context) {
        if (noisyOn) return
        noisyOn = true
        try {
            ContextCompat.registerReceiver(
                app, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
                ContextCompat.RECEIVER_NOT_EXPORTED,
            )
        } catch (e: Exception) {
            noisyOn = false
            android.util.Log.w("SoulStream", "noisy receiver would not register: $e")
        }
    }

    /** Focus is asked for when something starts playing and kept until the
     *  bar empties. It is NOT given back when the service dies, which is the
     *  point: under #3158 the service dies thirty times a second and the
     *  music is still playing. */
    @Synchronized
    fun requestFocus(app: Context) {
        if (focus != null) return
        val manager = app.getSystemService(Context.AUDIO_SERVICE) as AudioManager
        val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
            .setAudioAttributes(
                AudioAttributes.Builder()
                    .setUsage(AudioAttributes.USAGE_MEDIA)
                    .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                    .build(),
            )
            .setOnAudioFocusChangeListener(focusListener)
            .build()
        if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) focus = request
    }

    /** The bar emptied. The pre-media3 service gave focus back by dying;
     *  nothing dies on purpose any more, so it is given back by hand. */
    @Synchronized
    fun dropFocus(app: Context) {
        pausedByFocus = false
        val request = focus ?: return
        try {
            (app.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
                .abandonAudioFocusRequest(request)
        } catch (e: Exception) {
            android.util.Log.w("SoulStream", "focus would not go back: $e")
        }
        focus = null
    }

    /** One more `MediaService` exists. Returns the running total, which is
     *  what the log line carries. */
    @Synchronized
    fun noteCreation(): Int {
        creations += 1
        return creations
    }

    /** The app's own icon, encoded ONCE and kept, as the card's artwork when
     *  there is no cover yet. `artBytes` is cleared the moment `artKey`
     *  changes and only refilled when the frontend has finished decoding a
     *  cover into bytes — and a track with no cover never refills it at all —
     *  so every track change has a real window with no artwork and the lock
     *  screen shows a grey hole. This is what stands in it.
     *
     *  PNG bytes rather than a Bitmap since the media3 migration:
     *  `MediaMetadata.setArtworkData` wants encoded bytes and media3 decodes
     *  them itself, which is one BitmapFactory call this plugin no longer
     *  makes.
     *
     *  Through the PackageManager rather than a resource id on purpose: this
     *  is a library module and its own R has no `ic_launcher`, so from in here
     *  the app's icon is only reachable this way.
     *
     *  Drawn at the tile's own bounds, which is the whole trick. On API 26+
     *  what comes back is an AdaptiveIconDrawable, and an adaptive icon's
     *  BOUNDS are the 72 units the launcher shows — not the 108 its layers
     *  are drawn in. The drawable scales its own layers up by 3/2 and masks
     *  them inside whatever box it is handed, which is why its intrinsic
     *  width is two thirds of the layer PNG. So setBounds(0, 0, 512, 512) IS
     *  the launcher's view; a box set outside the tile to "reveal the 18%
     *  bleed" does the opposite and crops into the safe zone. Every other
     *  kind of drawable simply fills the tile.
     *
     *  Encoded on the first ask and never again — getState() runs on every
     *  invalidation, and this must not. A failure caches nothing and answers
     *  null, which is the card exactly as it was before this existed. */
    fun appIcon(ctx: Context): ByteArray? {
        val cached = icon
        if (cached != null) return cached
        return try {
            val drawable = ctx.packageManager.getApplicationIcon(ctx.packageName)
            val bitmap = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888)
            drawable.setBounds(0, 0, 512, 512)
            drawable.draw(Canvas(bitmap))
            val out = ByteArrayOutputStream()
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
            bitmap.recycle()
            val bytes = out.toByteArray()
            icon = bytes
            bytes
        } catch (e: Exception) {
            android.util.Log.w("SoulStream", "app icon could not be encoded: $e")
            null
        }
    }

    fun act(action: String, position: Double = -1.0) {
        onAction?.invoke(action, position)
    }
}

/**
 * Everything Android Auto (or any other `MediaBrowser`) is allowed to ask
 * this app for. Round forty-nine answers exactly one question — where the
 * tree starts — and nothing under it.
 *
 * A named object rather than an anonymous one because the rounds after this
 * fill it in: `onGetChildren` is the tree, `onGetItem` a single row,
 * `onSearch`/`onGetSearchResult` the voice query. Each of those has a
 * default on `MediaLibrarySession.Callback` that answers
 * `LibraryResult.ofError(ERROR_NOT_SUPPORTED)` immediately, so leaving them
 * un-overridden is a root with nothing under it and no hang anywhere.
 *
 * `onConnect` is deliberately NOT overridden either.
 * `ConnectionResult.AcceptedResultBuilder` defaults a `MediaLibrarySession`
 * to `DEFAULT_SESSION_AND_LIBRARY_COMMANDS`, and that is what carries
 * `COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT`. Without it
 * `MediaLibraryServiceLegacyStub.onGetRoot` returns null before it ever
 * reaches [onGetLibraryRoot] and Auto retries six times with backoff and
 * gives up. If a package validator is ever added here it has to be additive,
 * and it has to keep accepting `tools/auto-prober/`.
 */
@OptIn(markerClass = [UnstableApi::class])
object LibraryTree : MediaLibrarySession.Callback {
    /** The id of the root node. Everything the later rounds hang under the
     *  tree is named relative to this; `ss:` keeps our ids from colliding
     *  with a media id that happens to be a file path. */
    const val ROOT = "ss:root"

    /**
     * IMMEDIATE, UNCONDITIONALLY, AND THAT IS NOT A STYLE CHOICE.
     *
     * Android Auto reaches this through the legacy browser path, and
     * `MediaLibraryServiceLegacyStub.onGetRoot` (media3 1.10.1, lines
     * 100-115) posts this callback to the session's application handler and
     * then does `haveFuture.block()` followed by `future.get()` with NO
     * timeout — on the binder thread serving the host. So a future that
     * waits on anything is main-thread I/O and a blocked binder thread at
     * the same time: an ANR, and a host that drops us.
     * `Futures.immediateFuture` is the only correct answer, and there must
     * be no branch in here that can reach a file, a database or a lock.
     *
     * The log line is the other half of `tools/auto-prober/`: the prober
     * puts Auto's own root hints into the bind,
     * `LegacyConversions.convertToLibraryParams` turns them into the
     * [LibraryParams] read here, and these three booleans are what came out
     * the far end. `isRecent` is the
     * separate connection a host makes to ask for playback resumption — the
     * documented pattern is to REFUSE that root when resumption is not
     * supported, which this app cannot support (playback lives in Rust
     * behind a WebView round trip). Round forty-nine does not take that
     * decision; it records what Auto actually asks for, so the round that
     * fills the tree decides it from evidence.
     */
    override fun onGetLibraryRoot(
        session: MediaLibrarySession,
        browser: MediaSession.ControllerInfo,
        params: LibraryParams?,
    ): ListenableFuture<LibraryResult<MediaItem>> {
        android.util.Log.i(
            "SoulStream",
            "auto root from=" + browser.packageName + " uid=" + browser.uid +
                " recent=" + (params?.isRecent ?: false) +
                " offline=" + (params?.isOffline ?: false) +
                " suggested=" + (params?.isSuggested ?: false),
        )
        return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), params))
    }

    /** A browsable, non-playable folder with nothing in it yet. Browsable
     *  and not playable is what makes a host descend into it rather than try
     *  to play it; MEDIA_TYPE_FOLDER_MIXED says the children will not all be
     *  one kind, which is what the library, the playlists and the queue will
     *  be once they are there. */
    private fun rootItem(): MediaItem =
        MediaItem.Builder()
            .setMediaId(ROOT)
            .setMediaMetadata(
                MediaMetadata.Builder()
                    .setTitle("SoulStream")
                    .setIsBrowsable(true)
                    .setIsPlayable(false)
                    .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
                    .build(),
            )
            .build()
}

/**
 * The media3 library service: a browsable `MediaLibrarySession` over
 * [SoulStreamPlayer], the card on the lock screen and in the shade, and the
 * reason the process is not frozen in the background. The audio never passes
 * through here.
 *
 * A `MediaLibraryService` since round forty-nine, which is what lets Android
 * Auto list and open the app at all: `MediaLibraryService` extends
 * `MediaSessionService`, and its `onBind` serves the legacy
 * `android.media.browse.MediaBrowserService` action that Auto actually binds
 * as well as media3's own two. The manifest declares all three on one
 * filter.
 *
 * THIS CLASS ALLOCATES NOTHING AND REGISTERS NOTHING OF ITS OWN. Every
 * durable thing it needs lives in [MediaHub] for the life of the process —
 * see the comment there, and androidx/media #3158. Re-introducing a `new`
 * anywhere in `onCreate`, or a `release()` anywhere in `onDestroy`, silently
 * restores the bug; `removeSession` is the correct way to detach a session
 * that outlives its service, and it is what keeps a destroyed instance's
 * `MediaSessionListener` from being called by a `refresh()` that lands in
 * the gap between one destroy and the next create.
 *
 * media3 posts and owns the notification, and promotes and demotes the
 * service around it — there is no `buildNotification` and no
 * `MediaButtonReceiver` in this file.
 *
 * Focus and becoming-noisy are still hand-rolled (in [MediaHub] now), and
 * that is not an oversight. `setAudioAttributes(attrs, handleAudioFocus =
 * true)` and `setHandleAudioBecomingNoisy` are ExoPlayer conveniences; on a
 * custom `Player` the first lands in `handleSetAudioAttributes`, whose
 * default body throws, and the second does not exist. Deleting those blocks
 * would lose "another app takes the output → we pause" and "headphones out →
 * we pause" with nothing failing anywhere and no walk able to catch it.
 */
@OptIn(markerClass = [UnstableApi::class])
class MediaService : MediaLibraryService() {
    companion object {
        /** Literally "playback", and load-bearing twice over: the phone
         *  walk's `media-card` check finds the card by this channel id, and
         *  `Util.ensureNotificationChannel` is a no-op when the channel
         *  already exists — so the channel on Kyle's phone survives the
         *  migration rather than being replaced. */
        const val CHANNEL = "playback"
        const val NOTIFICATION_ID = 1
        /** The stand-in of [holdForeground], on an id of its own — 1 is the
         *  card, 2 is the transfer notice (`TransferHub.NOTIFICATION_ID`;
         *  notification ids are per-PACKAGE and these two plugins share
         *  one), 20938 is media3's own shutdown notification. */
        const val HOLD_NOTIFICATION_ID = 3
    }

    override fun onCreate() {
        super.onCreate()
        val app = applicationContext
        /* all four of these are no-ops after the first time in this process */
        MediaHub.ensureChannel(app)
        val s = MediaHub.session(app)
        MediaHub.ensureNoisy(app)
        /* BEFORE addSession, and the order is real: addSession spins up the
           internal notification controller that reads this provider, and both
           calls post to the same main handler (inline, from here) in order.
           Both DO run per instance: the provider is shared, the
           MediaNotificationManager that reads it is media3's own and belongs
           to this service. */
        setMediaNotificationProvider(MediaHub.provider(app))
        /* Re-adding the same session to a fresh service is exactly what the
           API is for: addSession keys on the session id, finds nothing in
           THIS instance's map, and registers a new MediaSessionListener and
           notification controller for it. */
        addSession(s)
        MediaHub.service = this
        if (MediaHub.state?.playing == true) MediaHub.requestFocus(app)
        /* The #3158 counter. `n=` climbing during a bind is the bug firing;
           see MediaHub's comment for how to read it. */
        val n = MediaHub.noteCreation()
        android.util.Log.i("SoulStream", "auto service created n=" + n + " reused=" + (n > 1))
    }

    /** Never null, on purpose: `MediaSessionService.onBind` returns null to
     *  a legacy browser when this answers null, and a null there is Auto
     *  failing to connect with nothing in any log. */
    override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
        MediaHub.session(applicationContext)

    /* ── the foreground contract media3 does not keep for us ──────────────
     *
     *  Nothing in this plugin starts the service — and the SYSTEM starts it
     *  in the foreground anyway, on every media KEY. `MediaSessionLegacyStub`
     *  hands the framework the media-button PendingIntent, and because the
     *  receiver it resolves is a SERVICE it builds that intent with
     *  `PendingIntent.getForegroundService` (SDK 26+), unconditionally, for
     *  every key. It resolves a service because there is no BroadcastReceiver
     *  any more: it asks the PackageManager for an ACTION_MEDIA_BUTTON
     *  receiver first, finds none, and falls to a service declared under
     *  `androidx.media3.session.MediaLibraryService` — which it queries
     *  BEFORE `MediaSessionService`, and which this service now declares.
     *  (Both are in the manifest; whichever it picks is this component.) So a
     *  headset key, a lock-screen button and anything else the framework
     *  routes to the session arms Android's ten-second `startForeground()`
     *  timer — the same timer that took a Save down with the process on
     *  2026-09-13.
     *
     *  The CARD's own buttons are milder: `DefaultActionFactory` sets
     *  `setStartAsForegroundService(!player.playWhenReady)`, but
     *  `PlaybackPendingIntentBuilder.build()` only honours it for
     *  COMMAND_PLAY_PAUSE, so the paused card's play button is a foreground
     *  start and every other card button is a plain `getService`. Play is the
     *  one press that always ends in media3 promoting anyway. The keys are
     *  the problem.
     *
     *  media3 promotes only for a session it calls user-engaged:
     *  playWhenReady AND STATE_READY/BUFFERING, or inside the ten-minute
     *  tail after that stopped being true
     *  (`MediaNotificationManager.shouldRunInForeground`). Its own
     *  `stopSelfSafely()` — which promotes, demotes and stops — covers the
     *  intents it cannot resolve: a null session from `onGetSession` (ours
     *  never is), a custom action it cannot name (we declare none), and a
     *  stale self-start intent from a destroyed instance. Three presses
     *  qualify for none of it, and each would take the app down about ten
     *  seconds later:
     *
     *    · a key into a process the system built FOR that key — no state,
     *      no WebView, nothing to promote for;
     *    · a key on a card paused longer than ten minutes, where the tail
     *      has run out and the press does not start playback (previous,
     *      which the app's `prev()` answers by seeking to zero);
     *    · any key at all with the bar cleared — which is exactly the case
     *      `MediaHub.clear` deliberately keeps the session alive for.
     *
     *  So promote FIRST, the way this service always did, and let media3
     *  step back out on its own schedule. Two ways, and the split is what
     *  keeps this from fighting the library:
     *
     *    · there is something on the bar → media3's OWN path, with media3's
     *      own card, so its `startedInForeground` bookkeeping stays true and
     *      the shade never sees a stand-in. Harmless when it is already in
     *      the foreground: the same notification is posted again.
     *    · there is not → media3 cannot be in the foreground (an empty
     *      timeline fails `shouldShowNotification`, which removes the
     *      notification and demotes), so a plain notification on an id of
     *      its own, posted and withdrawn in the same breath, disturbs
     *      nothing. The contract is satisfied the moment `startForeground()`
     *      lands.
     */
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        /* BEFORE super, deliberately: super dispatches the button, and it
           may reach media3's `stopSelfSafely()`, which promotes, demotes and
           stops. Promoting after that would put a service already on its way
           out back into the foreground. A null intent is the system
           re-creating a START_STICKY service — not a foreground start, and
           it needs nothing. */
        if (intent != null) holdForeground()
        return super.onStartCommand(intent, flags, startId)
    }

    private fun holdForeground() {
        val s = MediaHub.sessionOrNull()
        if (s != null && MediaHub.state != null) {
            try {
                /* the boolean is startInForegroundRequired */
                onUpdateNotification(s, true)
            } catch (e: Exception) {
                android.util.Log.w("SoulStream", "notification update refused: $e")
            }
            return
        }
        try {
            if (Build.VERSION.SDK_INT >= 29) {
                ServiceCompat.startForeground(
                    this, HOLD_NOTIFICATION_ID, holdNotification(),
                    ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
                )
            } else {
                startForeground(HOLD_NOTIFICATION_ID, holdNotification())
            }
            ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
        } catch (e: Exception) {
            android.util.Log.w("SoulStream", "could not hold the foreground: $e")
        }
    }

    /** The stand-in, up for as long as two binder calls take. Plain on
     *  purpose: it is never the card, it carries no transport, and the only
     *  thing it has to do is exist. */
    private fun holdNotification(): Notification {
        val launch = packageManager.getLaunchIntentForPackage(packageName)
        val open = launch?.let {
            PendingIntent.getActivity(
                this, 0, it,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
            )
        }
        return NotificationCompat.Builder(this, CHANNEL)
            .setSmallIcon(R.drawable.ic_soulstream_note)
            .setContentTitle("SoulStream")
            .setContentIntent(open)
            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
            .setCategory(NotificationCompat.CATEGORY_TRANSPORT)
            .setOnlyAlertOnce(true)
            .setShowWhen(false)
            .build()
    }

    /**
     * DETACH, NEVER RELEASE. Four lines round forty-eight had here are gone
     * and none of them may come back:
     *
     *   · `unregisterReceiver(noisy)` — under #3158 that is sixty binder
     *     calls a second and the "Too many receivers registered" crash the
     *     issue reports. The receiver is MediaHub's now and lives as long as
     *     the process.
     *   · `abandonFocus()` — thirty focus drops a second while music plays.
     *     Audible, and the most user-visible half of the bug.
     *   · `player.release()` + `markReleased()` — `markReleased` latches
     *     `released = true` on SoulStreamPlayer, after which `refresh()` and
     *     `awaitUpdate()` are permanent no-ops. On a per-service player that
     *     was correct; on a process-lifetime one it is a card that never
     *     updates again after the first destroy.
     *   · `session.release()` — the session outlives the service on purpose.
     *
     * `removeSession` is what replaces all of it: it drops the session from
     * THIS instance's map and posts `getMediaNotificationManager()
     * .removeSession(session); session.clearListener()`, which is precisely
     * the "stop calling a dead service back" that `release()` was doing by
     * accident. It throws `checkArgument(sessions.containsKey(id))` when the
     * session was never added, hence the guard.
     */
    override fun onDestroy() {
        val s = MediaHub.sessionOrNull()
        if (s != null && isSessionAdded(s)) {
            try { removeSession(s) } catch (e: Exception) {
                android.util.Log.w("SoulStream", "session would not detach: $e")
            }
        }
        MediaHub.service = null
        android.util.Log.i("SoulStream", "auto service destroyed")
        super.onDestroy()
    }
}
