package com.ponzischeme89.memby.update

import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.core.content.pm.PackageInfoCompat
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.concurrent.TimeUnit

/** Result of a "check for updates" against the configured Gitea release. */
sealed interface UpdateStatus {
    /** A newer release with a downloadable APK is available. */
    data class Available(val version: String, val apkUrl: String, val notes: String) : UpdateStatus
    /** The latest release is not newer than what's installed. */
    data class UpToDate(val version: String) : UpdateStatus
    /** Something went wrong; [message] is safe to show on screen. */
    data class Error(val message: String) : UpdateStatus
}

/**
 * Checks a Gitea repository's latest release for a newer APK and, when the user
 * confirms, downloads it and hands it to the system package installer.
 *
 * Gitea exposes a GitHub-compatible API: GET /api/v1/repos/{owner}/{repo}/releases/latest.
 * A personal access token is sent for private repos (both for the API call and the
 * asset download).
 */
class UpdateChecker(private val context: Context) {

    private val json = Json { ignoreUnknownKeys = true }

    private val http = OkHttpClient.Builder()
        .connectTimeout(15, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .build()

    /** The installed versionName (e.g. "1.0"), or "?" if it can't be read. */
    val installedVersion: String
        get() = runCatching {
            context.packageManager.getPackageInfo(context.packageName, 0).versionName
        }.getOrNull() ?: "?"

    /**
     * Checks for a newer build.
     *
     * Two sources, chosen by what the update URL points at:
     *
     *  - a **manifest** (`…/latest.json`) — a static file on any web server or NAS. The
     *    simplest thing that works: drop an APK and a JSON file next to it.
     *  - a **Gitea host** — the repo's latest release, with its first `.apk` asset.
     */
    suspend fun check(baseUrl: String, repo: String, token: String): UpdateStatus {
        val url = baseUrl.trim().trimEnd('/')
        if (url.isEmpty()) {
            return UpdateStatus.Error("Set the update URL first.")
        }
        return if (isManifestUrl(url)) {
            checkManifest(url, token)
        } else {
            checkGitea(url, repo, token)
        }
    }

    private suspend fun checkManifest(manifestUrl: String, token: String): UpdateStatus =
        withContext(Dispatchers.IO) {
            val manifest = runCatching {
                val req = Request.Builder().url(manifestUrl).apply {
                    // Optional: a NAS behind basic auth or a token-guarded path.
                    if (token.isNotBlank()) header("Authorization", "Bearer ${token.trim()}")
                    // Manifests are small and change on release; never serve a stale one
                    // from a proxy cache.
                    header("Cache-Control", "no-cache")
                }.build()
                http.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) {
                        return@withContext UpdateStatus.Error(
                            when (resp.code) {
                                401, 403 -> "Update check unauthorized — check the access token."
                                404 -> "No update manifest found at that URL."
                                else -> "Update server error (${resp.code})."
                            },
                        )
                    }
                    json.decodeFromString<UpdateManifest>(resp.body?.string().orEmpty())
                }
            }.getOrElse {
                return@withContext UpdateStatus.Error("Couldn't reach the update server.")
            }

            if (manifest.version.isBlank() || manifest.apkUrl.isBlank()) {
                return@withContext UpdateStatus.Error("The update manifest is incomplete.")
            }
            return@withContext if (isNewer(manifest.version, installedVersion)) {
                UpdateStatus.Available(
                    version = normalizeVersion(manifest.version),
                    apkUrl = resolveApkUrl(manifestUrl, manifest.apkUrl),
                    notes = manifest.notes.trim(),
                )
            } else {
                UpdateStatus.UpToDate(installedVersion)
            }
        }

    private suspend fun checkGitea(host: String, repo: String, token: String): UpdateStatus =
        withContext(Dispatchers.IO) {
            val repoPath = repo.trim().trim('/')
            if (repoPath.isEmpty()) {
                return@withContext UpdateStatus.Error("Set the repository (owner/repo) first.")
            }
            val url = "$host/api/v1/repos/$repoPath/releases/latest"

            val release = runCatching {
                val req = Request.Builder().url(url).apply {
                    if (token.isNotBlank()) header("Authorization", "token ${token.trim()}")
                }.build()
                http.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) {
                        return@withContext UpdateStatus.Error(
                            when (resp.code) {
                                401, 403 -> "Update check unauthorized — check the access token."
                                404 -> "No releases found at $repoPath."
                                else -> "Update server error (${resp.code})."
                            }
                        )
                    }
                    json.decodeFromString<GiteaRelease>(resp.body?.string().orEmpty())
                }
            }.getOrElse {
                return@withContext UpdateStatus.Error("Couldn't reach the update server.")
            }

            val apk = release.assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) }
                ?: return@withContext UpdateStatus.Error("Latest release has no APK attached.")

            val latest = release.tagName.ifBlank { release.name }
            return@withContext if (isNewer(latest, installedVersion)) {
                UpdateStatus.Available(
                    version = normalizeVersion(latest),
                    apkUrl = apk.browserDownloadUrl,
                    notes = release.body.trim(),
                )
            } else {
                UpdateStatus.UpToDate(installedVersion)
            }
        }

    /**
     * Downloads the APK, verifies it, and commits it to a [PackageInstaller] session. On
     * Android O+ the app needs the "install unknown apps" permission; if it's missing we
     * send the user to that settings screen and return a message asking them to retry.
     *
     * Success here means the *session was committed*, not that the update is installed —
     * the rest of the story arrives on [AppInstall.messages] via [InstallResultReceiver].
     */
    suspend fun downloadAndInstall(
        apkUrl: String,
        token: String,
        expectedVersion: String = "",
        expectedSHA256: String = "",
        expectedSizeBytes: Long = 0,
    ): Result<Unit> =
        withContext(Dispatchers.IO) {
            // Gate on the install-unknown-apps permission before spending a download.
            //
            // Sending the viewer to Android's per-app permission screen is best, but many
            // television builds do not implement it. When it is missing, saying so and
            // giving the path through the TV's own settings is the only thing that gets
            // anyone unstuck — before this, the activity failed to start, the failure was
            // swallowed, and the screen said "the update will continue when you return"
            // about a screen that never opened.
            if (!InstallPermission.granted(context)) {
                val opened = InstallPermission.requestScreen(context)
                return@withContext Result.failure(
                    InstallPermissionRequiredException(
                        if (opened) {
                            "Allow Memby to install apps. The update will continue when you return."
                        } else {
                            "Memby needs permission to install updates. On this TV, open " +
                                "${InstallPermission.MANUAL_PATH} and turn on Memby, then press " +
                                "Update again."
                        },
                    ),
                )
            }

            runCatching {
                val updateDir = File(context.cacheDir, "updates").apply { mkdirs() }
                val partial = File(updateDir, "memby-update.part")
                val file = File(updateDir, "memby-update.apk")
                partial.delete()
                val req = Request.Builder().url(apkUrl).apply {
                    if (token.isNotBlank()) header("Authorization", "token ${token.trim()}")
                }.build()
                http.newCall(req).execute().use { resp ->
                    if (!resp.isSuccessful) error("Download failed (${resp.code}).")
                    val body = resp.body ?: error("Empty download.")
                    val declaredSize = body.contentLength()
                    if (declaredSize > MAX_APK_BYTES) error("The update is unexpectedly large.")
                    if (expectedSizeBytes > 0 && declaredSize >= 0 &&
                        declaredSize != expectedSizeBytes
                    ) {
                        error("The update size does not match the published release.")
                    }

                    val digest = MessageDigest.getInstance("SHA-256")
                    var written = 0L
                    try {
                        FileOutputStream(partial).use { out ->
                            body.byteStream().use { input ->
                                val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
                                while (true) {
                                    val read = input.read(buffer)
                                    if (read < 0) break
                                    written += read
                                    if (written > MAX_APK_BYTES) {
                                        error("The update is unexpectedly large.")
                                    }
                                    digest.update(buffer, 0, read)
                                    out.write(buffer, 0, read)
                                }
                            }
                            out.fd.sync()
                        }
                    } catch (error: Throwable) {
                        partial.delete()
                        throw error
                    }
                    if (written == 0L || (expectedSizeBytes > 0 && written != expectedSizeBytes)) {
                        partial.delete()
                        error("The update download was incomplete.")
                    }
                    val actualSHA256 = digest.digest().joinToString("") {
                        "%02x".format(it.toInt() and 0xff)
                    }
                    if (expectedSHA256.isNotBlank() &&
                        !actualSHA256.equals(expectedSHA256.trim(), ignoreCase = true)
                    ) {
                        partial.delete()
                        error("The update failed its integrity check.")
                    }
                }

                verifyApk(partial, expectedVersion)
                file.delete()
                if (!partial.renameTo(file)) {
                    partial.delete()
                    error("The verified update could not be prepared.")
                }
                commitInstallSession(file)
            }
        }

    /**
     * Hands the verified APK to [PackageInstaller].
     *
     * The previous implementation sent an `ACTION_VIEW` intent at the APK's content URI.
     * That is the phone idiom and it fails quietly on televisions: the implicit intent is
     * subject to package visibility on Android 11+, several TV builds expose no activity
     * for the package-archive MIME type at all, and either way the app learns nothing about
     * what the installer then did. A session is explicit, needs no viewer activity, and
     * reports its outcome to [InstallResultReceiver].
     */
    private fun commitInstallSession(file: File) {
        val installer = context.packageManager.packageInstaller
        // An abandoned session from an earlier attempt holds its staged copy of the APK
        // and counts against the per-app session limit, so a TV that failed a few times
        // would eventually stop being able to start one at all.
        installer.mySessions.forEach { runCatching { installer.abandonSession(it.sessionId) } }

        val params = PackageInstaller.SessionParams(
            PackageInstaller.SessionParams.MODE_FULL_INSTALL,
        ).apply {
            setAppPackageName(context.packageName)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                // Honoured only once Memby is its own installer of record — that is, from
                // the second self-update onwards — and ignored otherwise, in which case the
                // system asks as usual. Nothing here depends on it being granted.
                setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
            }
        }

        val sessionId = installer.createSession(params)
        var committed = false
        try {
            installer.openSession(sessionId).use { session ->
                session.openWrite(SESSION_ENTRY, 0, file.length()).use { out ->
                    file.inputStream().use { it.copyTo(out) }
                    session.fsync(out)
                }
                session.commit(installStatusSender(sessionId))
                committed = true
            }
        } finally {
            if (!committed) runCatching { installer.abandonSession(sessionId) }
        }
    }

    /** Where the system reports the session's outcome. Mutable: the system fills in extras. */
    private fun installStatusSender(sessionId: Int) = PendingIntent.getBroadcast(
        context,
        sessionId,
        Intent(context, InstallResultReceiver::class.java)
            .setAction(InstallResultReceiver.ACTION)
            .setPackage(context.packageName),
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
        } else {
            PendingIntent.FLAG_UPDATE_CURRENT
        },
    ).intentSender

    @Suppress("DEPRECATION")
    private fun verifyApk(file: File, expectedVersion: String) {
        // Both flags, on both sides, on every API level. GET_SIGNING_CERTIFICATES alone is
        // correct for an *installed* package but not for an archive: getPackageArchiveInfo
        // leaves signingInfo null on several Android versions, and asking only for it meant
        // the digests came back empty and a correctly signed update was rejected as forged.
        val flags = PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES
        val archive = context.packageManager.getPackageArchiveInfo(file.absolutePath, flags)
            ?: error("The downloaded file is not a valid Android app.")
        if (archive.packageName != context.packageName) {
            error("The update belongs to a different app.")
        }
        if (expectedVersion.isNotBlank() &&
            normalizeVersion(archive.versionName.orEmpty()) != normalizeVersion(expectedVersion)
        ) {
            error("The downloaded app version does not match the published release.")
        }

        val installed = context.packageManager.getPackageInfo(context.packageName, flags)
        if (PackageInfoCompat.getLongVersionCode(archive) <=
            PackageInfoCompat.getLongVersionCode(installed)
        ) {
            error("Android requires an update with a higher version code.")
        }
        if (signerVerdict(signerDigests(archive), signerDigests(installed)) ==
            SignerVerdict.MISMATCH
        ) {
            error("The update was not signed by Memby’s trusted release key.")
        }
    }

    @Suppress("DEPRECATION")
    private fun signerDigests(info: PackageInfo): Set<String> {
        // Prefer the modern accessor and fall back, rather than choosing by SDK level: which
        // of the two a given Android version fills in depends on whether this PackageInfo
        // describes an installed package or a file on disk, not only on the API level.
        val modern = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            info.signingInfo?.apkContentsSigners
        } else {
            null
        }
        val signatures = modern?.takeIf { it.isNotEmpty() } ?: info.signatures.orEmpty()
        return signatures.filterNotNull().mapTo(linkedSetOf()) { signature ->
            MessageDigest.getInstance("SHA-256")
                .digest(signature.toByteArray())
                .joinToString("") { "%02x".format(it.toInt() and 0xff) }
        }
    }

    /** True when [remote] parses to a strictly higher version than [installed]. */
    private fun isNewer(remote: String, installed: String): Boolean {
        val r = parseVersion(remote)
        val i = parseVersion(installed)
        for (k in 0 until maxOf(r.size, i.size)) {
            val rv = r.getOrElse(k) { 0 }
            val iv = i.getOrElse(k) { 0 }
            if (rv != iv) return rv > iv
        }
        return false
    }

    private fun parseVersion(v: String): List<Int> =
        normalizeVersion(v).split('.', '-', ' ', '+').mapNotNull { it.toIntOrNull() }

    private fun normalizeVersion(v: String): String = v.trim().trimStart('v', 'V')

    companion object {
        private const val MAX_APK_BYTES = 250L * 1024L * 1024L
        private const val SESSION_ENTRY = "memby-update.apk"
    }
}

class InstallPermissionRequiredException(message: String) : IllegalStateException(message)

/** What comparing an update's signers against the installed app's could establish. */
internal enum class SignerVerdict { MATCH, MISMATCH, UNVERIFIABLE }

/**
 * Decides whether a downloaded update may proceed to the installer.
 *
 * The distinction that matters is between *knowing* the key is wrong and *not being able to
 * read* it. Treating the second as the first is what stopped every in-app update on the TVs
 * where `getPackageArchiveInfo` returns no signing information: the APK was correctly signed
 * with the same key it always had, and the app said it was not, so the only way forward was
 * a manual reinstall.
 *
 * Proceeding when the read fails is safe because this check is a courtesy, not the defence.
 * The APK has already been matched against the published SHA-256, its package name and its
 * version; and Android itself enforces signature identity at install time and cannot be
 * talked out of it. All this check can honestly add is a clearer message when it *can* see
 * a genuine mismatch — which is why an unreadable archive falls through to the installer,
 * where a real mismatch surfaces as STATUS_FAILURE_CONFLICT.
 */
internal fun signerVerdict(archive: Set<String>, installed: Set<String>): SignerVerdict = when {
    archive.isEmpty() || installed.isEmpty() -> SignerVerdict.UNVERIFIABLE
    archive == installed -> SignerVerdict.MATCH
    else -> SignerVerdict.MISMATCH
}

/**
 * A `.json` update URL means "static manifest"; anything else is treated as a Gitea host.
 * Chosen by URL shape rather than a mode switch: one fewer setting to get wrong on a TV
 * remote, and the two forms are unambiguous in practice.
 */
internal fun isManifestUrl(url: String): Boolean =
    url.substringBefore('?').trimEnd('/').endsWith(".json", ignoreCase = true)

/**
 * Resolves a manifest's `apkUrl` against the manifest's own location, so a manifest can
 * say `"memby-0.1.53.apk"` and keep working when the folder moves or the host is reached
 * by a different name.
 */
internal fun resolveApkUrl(manifestUrl: String, apkUrl: String): String {
    val apk = apkUrl.trim()
    if (apk.startsWith("http://", true) || apk.startsWith("https://", true)) return apk

    val base = manifestUrl.substringBefore('?')
    return if (apk.startsWith("/")) {
        // Root-relative: keep scheme and host, replace the path.
        val schemeEnd = base.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: return apk
        val hostEnd = base.indexOf('/', schemeEnd).takeIf { it >= 0 } ?: base.length
        base.substring(0, hostEnd) + apk
    } else {
        base.substringBeforeLast('/', "") + "/" + apk
    }
}
