package com.fabledsword.minstrel.update.data

import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.IntentSender
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume

private const val STAGED_APK_NAME = "minstrel-update"

/** Whole-file write: openWrite takes a Long offset, and Kotlin won't widen 0. */
private const val WRITE_FROM_START = 0L

/** Our own broadcast, delivered by the platform via the session's IntentSender. */
private const val RESULT_ACTION = "com.fabledsword.minstrel.INSTALL_RESULT"

/**
 * Installs an APK over ourselves through a [PackageInstaller] session (#2438).
 *
 * Split from [ApkInstaller] because the two halves are different work — one
 * speaks HTTP, the other speaks to the package manager — and the session half
 * carries a receiver, a PendingIntent and version-gated params that would
 * crowd the downloader out of its own file.
 *
 * ## Why a session, rather than the ACTION_VIEW intent this replaced
 *
 * Two reasons, and the second is the one that matters to users.
 *
 * The old path fired `ACTION_VIEW` at an `application/vnd.android.package-archive`
 * URI and hoped. It could not report an outcome, so a failed install and a
 * user who declined looked identical — see [InstallOutcome].
 *
 * More importantly, a session is where the platform lets a self-updater say it
 * is one. [PackageInstaller.SessionParams.setRequireUserAction] with
 * `USER_ACTION_NOT_REQUIRED`, paired with the `UPDATE_PACKAGES_WITHOUT_USER_ACTION`
 * manifest permission, is the sanctioned way to update with **no dialog at
 * all**. The platform grants that when all of: the installer opts in (here),
 * the installed app targets API 29+ (we're on 36), the installer holds the
 * permission (we do), and the target is the installer itself or something it
 * first installed (we are updating ourselves). All four hold.
 *
 * ## What is deliberately absent
 *
 * No `setRequestUpdateOwnership(true)`. It reads like the right declaration for
 * a self-updater and it is not: ownership can only be claimed on **initial**
 * installation — setting it on an update is documented as a no-op — and it also
 * wants the privileged `ENFORCE_UPDATE_OWNERSHIP` permission. It exists for app
 * stores claiming the apps they install, not for an app updating itself.
 */
@Singleton
class SelfUpdateSession @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    /**
     * Stage [apk] and hand it to the platform, suspending until a terminal
     * verdict arrives.
     *
     * Never returns on the happy path when the install is silent: the platform
     * replaces this process the moment the new APK lands, so the coroutine dies
     * rather than resuming. Callers must treat that as success, not a hang.
     */
    suspend fun run(apk: File): InstallOutcome {
        val staged = withContext(Dispatchers.IO) { runCatching { stage(apk) } }
        return staged.fold(
            onSuccess = { sessionId -> awaitCommit(sessionId) },
            onFailure = { InstallOutcome.Failed(it.message) },
        )
    }

    /** Open a session, stream the APK in, return the session id. */
    private fun stage(apk: File): Int {
        val installer = context.packageManager.packageInstaller
        val sessionId = installer.createSession(newParams())
        installer.openSession(sessionId).use { session ->
            session.openWrite(STAGED_APK_NAME, WRITE_FROM_START, apk.length()).use { sink ->
                apk.inputStream().use { source -> source.copyTo(sink) }
                // fsync before the session closes: the platform validates the
                // staged bytes at commit, and buffered tail bytes read as a
                // truncated APK.
                session.fsync(sink)
            }
        }
        return sessionId
    }

    // Explicit `params.` receivers rather than an apply {} block: lintVitalRelease
    // runs on assembleRelease, and NewApi is easier for it to reason about when
    // the guarded call has a named receiver instead of an implicit one.
    private fun newParams(): PackageInstaller.SessionParams {
        val params = PackageInstaller.SessionParams(
            PackageInstaller.SessionParams.MODE_FULL_INSTALL,
        )
        params.setAppPackageName(context.packageName)
        params.setInstallReason(PackageManager.INSTALL_REASON_USER)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            // The whole point of this class. Pre-S there is no such API, so the
            // confirm dialog is unavoidable there — degrade, don't fail.
            params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
        }
        return params
    }

    /**
     * Commit the session and wait for the platform to report back.
     *
     * A pending-user-action status is *not* terminal — the platform is asking us
     * to show its dialog, and the real verdict arrives in a second broadcast
     * once the user decides. So the receiver stays registered across it.
     */
    private suspend fun awaitCommit(sessionId: Int): InstallOutcome =
        suspendCancellableCoroutine { continuation ->
            val installer = context.packageManager.packageInstaller
            val receiver = object : BroadcastReceiver() {
                override fun onReceive(unused: Context, intent: Intent) {
                    val status = intent.getIntExtra(
                        PackageInstaller.EXTRA_STATUS,
                        PackageInstaller.STATUS_FAILURE,
                    )
                    if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
                        confirmWithUser(intent)
                    } else {
                        context.unregisterReceiver(this)
                        val why = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
                        if (continuation.isActive) continuation.resume(outcomeOf(status, why))
                    }
                }
            }
            ContextCompat.registerReceiver(
                context,
                receiver,
                IntentFilter(RESULT_ACTION),
                ContextCompat.RECEIVER_NOT_EXPORTED,
            )
            continuation.invokeOnCancellation {
                // Stop listening, but deliberately do NOT abandon the session.
                // Cancellation here means our caller's scope died — the user
                // navigated away, or the VM cleared — and by this point the
                // session is already committed. The user asked for this install;
                // killing it because nobody is watching the banner any more
                // would be the wrong reading of their intent.
                runCatching { context.unregisterReceiver(receiver) }
            }
            runCatching {
                installer.openSession(sessionId).use { it.commit(resultSender(sessionId)) }
            }.onFailure { error ->
                // Resuming normally means invokeOnCancellation never fires, so
                // clean up the staged session here or it sits until it expires.
                runCatching { context.unregisterReceiver(receiver) }
                runCatching { installer.abandonSession(sessionId) }
                if (continuation.isActive) {
                    continuation.resume(InstallOutcome.Failed(error.message))
                }
            }
        }

    private fun resultSender(sessionId: Int): IntentSender {
        // Scoped to our own package so the broadcast can't be answered elsewhere.
        val intent = Intent(RESULT_ACTION).setPackage(context.packageName)
        var flags = PendingIntent.FLAG_UPDATE_CURRENT
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            // The platform writes its status extras into this intent, so it has
            // to stay mutable — FLAG_IMMUTABLE would arrive with none of them.
            flags = flags or PendingIntent.FLAG_MUTABLE
        }
        // Session id as the request code keeps concurrent sessions from
        // colliding on FLAG_UPDATE_CURRENT.
        return PendingIntent.getBroadcast(context, sessionId, intent, flags).intentSender
    }

    /**
     * Show the platform's own confirm dialog, which arrives as an extra.
     *
     * The system-app check is not ceremony. Below API 34 a dynamically
     * registered receiver cannot declare itself unexported, so another app on
     * the device can broadcast [RESULT_ACTION] at us — and calling
     * `startActivity` on an attacker-supplied extra would hand it whatever we
     * can reach. The genuine confirm activity belongs to the platform
     * installer, so demanding a system component costs the real path nothing.
     */
    private fun confirmWithUser(result: Intent) {
        val pending = IntentCompat.getParcelableExtra(
            result,
            Intent.EXTRA_INTENT,
            Intent::class.java,
        ) ?: return
        if (isPlatformActivity(pending)) {
            context.startActivity(pending.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
        }
    }

    private fun isPlatformActivity(intent: Intent): Boolean {
        val flags = intent.resolveActivityInfo(context.packageManager, 0)
            ?.applicationInfo
            ?.flags
            ?: 0
        val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
        return (flags and systemFlags) != 0
    }

    private fun outcomeOf(status: Int, message: String?): InstallOutcome = when (status) {
        PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Installed
        PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
        else -> InstallOutcome.Failed(message)
    }
}
