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.util.UnstableApi
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import java.io.ByteArrayOutputStream

/**
 * What is playing, shared between the plugin (which hears the WebView) and
 * the service (which owns the session). The service is BOUND on the first
 * update and refreshed on every one after.
 *
 * 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.
 */
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

    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?) {}
    }

    fun update(ctx: Context, np: NowPlayingArgs) {
        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(ctx)
        /* 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 {
            service?.syncFocus(np.playing)
            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, which the
     *  old service did by dying. */
    fun clear(ctx: Context) {
        state = null
        artBytes = null
        artKey = null
        mainHandler.post {
            service?.dropFocus()
            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: MediaSessionService.onBind answers only for
               SERVICE_INTERFACE and returns null for a bare intent, and
               BIND_AUTO_CREATE is what creates the service at all */
            val intent = Intent(ctx, MediaService::class.java)
                .setAction(MediaSessionService.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 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)
    }
}

/**
 * The media3 session service: a `MediaSession` over [SoulStreamPlayer], the
 * card on the lock screen and in the shade, audio focus (pause when another
 * app takes the output, or the headphones come out), and the reason the
 * process is not frozen in the background. The audio never passes through
 * here.
 *
 * media3 posts and owns the notification now, and promotes and demotes the
 * service around it — there is no `buildNotification` and no
 * `MediaButtonReceiver` in this file any more, and the manifest's
 * `androidx.media3.session.MediaSessionService` filter is what routes a
 * headset button here while the process is alive.
 *
 * Focus and becoming-noisy are still hand-rolled, 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 these 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 : MediaSessionService() {
    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
    }

    private var session: MediaSession? = null
    private var player: SoulStreamPlayer? = null
    private var focus: AudioFocusRequest? = null
    private var pausedByFocus = false
    private var noisyRegistered = false

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

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

    override fun onCreate() {
        super.onCreate()
        /* 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". ensureNotification
           Channel leaves an existing channel alone, so getting in first is
           the whole fix. */
        createChannel()

        val p = SoulStreamPlayer(applicationContext)
        player = p
        MediaHub.player = p

        /* 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. */
        val provider = DefaultMediaNotificationProvider.Builder(this)
            .setChannelId(CHANNEL)
            .setChannelName(R.string.ss_playback_channel)
            .setNotificationId(NOTIFICATION_ID)
            .build()
        provider.setSmallIcon(R.drawable.ic_soulstream_note)
        setMediaNotificationProvider(provider)

        val builder = MediaSession.Builder(this, p).setId("SoulStream")
        val launch = packageManager.getLaunchIntentForPackage(packageName)
        if (launch != null) {
            builder.setSessionActivity(
                PendingIntent.getActivity(
                    this, 0, launch,
                    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
                ),
            )
        }
        val s = builder.build()
        session = s
        addSession(s)

        /* a system broadcast reaches a non-exported receiver; Android 14 wants
           the flag said out loud */
        ContextCompat.registerReceiver(
            this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
            ContextCompat.RECEIVER_NOT_EXPORTED,
        )
        noisyRegistered = true
        MediaHub.service = this
        /* The update that MADE this service is already sitting in MediaHub,
           and its own post to the main looper found `service` still null —
           bindService creates the service a round trip later. Without this
           line the focus request waits for whatever update happens to come
           next (a status change, or the thirty-second top-up), and the first
           track of a cold start plays with no focus held. */
        if (MediaHub.state?.playing == true) requestFocus()
    }

    override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session

    /* ── 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 the manifest's
     *  `androidx.media3.session.MediaSessionService` filter. 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 = session
        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()
    }

    override fun onDestroy() {
        if (noisyRegistered) {
            try { unregisterReceiver(noisy) } catch (_: Exception) {}
            noisyRegistered = false
        }
        abandonFocus()
        /* the player first, then the session — media3's own order. `release()`
           is SimpleBasePlayer's, needs COMMAND_RELEASE (advertised) and the
           main thread (onDestroy runs there); markReleased() is our own latch
           so a refresh() that slipped through MediaHub does nothing. */
        player?.release()
        player?.markReleased()
        session?.release()
        session = null
        player = null
        MediaHub.player = null
        MediaHub.service = null
        super.onDestroy()
    }

    /** Focus is asked for when something starts playing and kept until the
     *  bar empties or the service dies — called from MediaHub's own post to
     *  the main looper, so it rides the same thread everything else here
     *  does. */
    fun syncFocus(playing: Boolean) {
        if (playing) requestFocus()
    }

    /** The bar emptied. The old service gave focus back by dying; this one
     *  outlives the music, so it gives it back by hand. */
    fun dropFocus() {
        pausedByFocus = false
        abandonFocus()
    }

    private fun createChannel() {
        if (Build.VERSION.SDK_INT < 26) return
        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        if (manager.getNotificationChannel(CHANNEL) != null) return
        val channel = NotificationChannel(CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)
        channel.description = "What is playing"
        channel.setShowBadge(false)
        channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
        manager.createNotificationChannel(channel)
    }

    private fun requestFocus() {
        if (focus != null) return
        val manager = 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
    }

    private fun abandonFocus() {
        val request = focus ?: return
        (getSystemService(Context.AUDIO_SERVICE) as AudioManager).abandonAudioFocusRequest(request)
        focus = null
    }
}
