package de.ladkau.schwertundmagieonpebblecompanionapp

import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.OpenableColumns
import android.util.Log
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.Choreographer
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.ImageView
import android.widget.ScrollView
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.drawerlayout.widget.DrawerLayout
import fi.iki.elonen.NanoHTTPD
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.GZIPInputStream

private const val PORT = 8888
private const val TAG  = "SuM"

private data class DiskSlot(
    val episode: Int,
    val part: Char,
    val btnId: Int,
    val label: String,
    val fileName: String = "SCHWUM${episode}${part}.D64"
)

class MainActivity : AppCompatActivity() {

    private lateinit var tvLog:     TextView
    private lateinit var scrollLog: ScrollView
    private lateinit var display:   C64DisplayView
    private lateinit var keyboard:  C64KeyboardView

    private val mainHandler = Handler(Looper.getMainLooper())
    private val timeFormat  = SimpleDateFormat("HH:mm:ss", Locale.getDefault())

    private lateinit var drawerLayout: DrawerLayout

    private var server:    CompanionServer? = null
    private val engine     = C64Engine()
    private lateinit var choreographer: Choreographer
    @Volatile private var renderActive = false
    private var soundEnabled = false
    private lateinit var driveLedDrawable: GradientDrawable
    @Volatile private var driveLedState = false

    // ---- disk slots ---------------------------------------------------------

    // ---- D64 helpers (shared by createBlankD64 and the hero editor) ----------

    private val d64Spt = intArrayOf(
        0,
        21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21,
        19, 19, 19, 19, 19, 19, 19,
        18, 18, 18, 18, 18, 18,
        17, 17, 17, 17, 17
    )

    private fun d64Off(track: Int, sector: Int): Int {
        var off = 0
        for (t in 1 until track) off += d64Spt[t] * 256
        return off + sector * 256
    }

    // ---- disk slots ---------------------------------------------------------

    private val diskSlots = listOf(
        DiskSlot(1, 'A', R.id.btn_ep_1a, "Folge 1: Das geheimnisvolle Kraut"),
        DiskSlot(1, 'B', R.id.btn_ep_1b, "Folge 2: Der unheimliche Tempel"),
        DiskSlot(2, 'A', R.id.btn_ep_2a, "Folge 3: Das Piratenhaus"),
        DiskSlot(2, 'B', R.id.btn_ep_2b, "Folge 4: Die Burg des Magiers"),
        DiskSlot(3, 'A', R.id.btn_ep_3a, "Folge 5: Das Haus des Vampirs"),
        DiskSlot(3, 'B', R.id.btn_ep_3b, "Folge 6: Der Turm des Todes"),
        DiskSlot(4, 'A', R.id.btn_ep_4a, "Folge 7: Unter Wasser"),
        DiskSlot(4, 'B', R.id.btn_ep_4b, "Folge 8: Insel der Wunder"),
        DiskSlot(0, ' ', R.id.btn_hero_1, "Held 1", "HELD1.D64"),
        DiskSlot(0, ' ', R.id.btn_hero_2, "Held 2", "HELD2.D64"),
        DiskSlot(0, ' ', R.id.btn_hero_3, "Held 3", "HELD3.D64"),
    )
    private val episodeButtons  = mutableMapOf<DiskSlot, Button>()
    private val heroEditButtons = mutableMapOf<DiskSlot, Button>()
    private val loadedADisks    = mutableSetOf<Int>()
    private var activeSlot: DiskSlot? = null
    private var currentDiskPath: String? = null  // path last passed to engine.loadDisk()

    // ---- Disk import (multi-select) -----------------------------------------

    private val diskPickerLauncher: ActivityResultLauncher<Intent> =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
            if (result.resultCode == Activity.RESULT_OK) {
                val uris = mutableListOf<Uri>()
                result.data?.clipData?.let { clip ->
                    for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri)
                } ?: result.data?.data?.let { uris.add(it) }

                var count = 0
                for (uri in uris) {
                    val name = displayNameForUri(uri) ?: continue
                    importFileToAssets(uri, name.uppercase())
                    appendLog("Imported: $name")
                    count++
                }
                if (count > 0) refreshDiskButtons()
                appendLog("Imported $count disk image(s)")
            }
        }

    // ---- Disk download (Internet Archive C64 Preservation Project) ----------

    // Each entry is one side of one physical disk, dumped as a raw-GCR NIB by
    // the preservation project (these old disks aren't always cleanly
    // sector-readable, so the source format preserves whatever the original
    // drive actually saw rather than reconstructing a — possibly wrong — D64).
    // nibtools' nibconv (see jni/build_nibtools.sh) converts each to G64,
    // which VICE attaches exactly like a D64 (engine.loadDisk/attachDisk
    // detect the format from file content, not the extension).
    private data class DiskDownload(val baseName: String, val url: String)

    private val diskDownloads = listOf(
        DiskDownload("SCHWUM1A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_1_und_2_s1%5Bgdg_1989%5D%28german%29.nbz"),
        DiskDownload("SCHWUM1B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_1_und_2_s2%5Bgdg_1989%5D%28german%29.nbz"),
        DiskDownload("SCHWUM2A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_3_und_4_s1%5Bgdg_1989%5D%28german%29.nbz"),
        DiskDownload("SCHWUM2B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_3_und_4_s2%5Bgdg_1989%5D%28german%29.nbz"),
        DiskDownload("SCHWUM3A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_5_und_6_s1%5Bgdg_1991%5D%28german%29.nbz"),
        DiskDownload("SCHWUM3B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_5_und_6_s2%5Bgdg_1991%5D%28german%29.nbz"),
        DiskDownload("SCHWUM4A", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_7_und_8_s1%5Bgdg_1991%5D%28german%29.nbz"),
        DiskDownload("SCHWUM4B", "https://archive.org/download/C64_Preservation_Project_10th_Anniversary_Collection/C64_Preservation_Project_10th_Anniversary_Collection.zip/c64pp%2Fnon-english%2Fschwert_und_magie_folge_7_und_8_s2%5Bgdg_1991%5D%28german%29.nbz"),
    )

    private fun showDownloadDisksDialog() {
        AlertDialog.Builder(this)
            .setTitle(R.string.btn_download_disks)
            .setMessage(R.string.disks_download_msg)
            .setPositiveButton(R.string.disks_download_confirm) { _, _ -> downloadDisks() }
            .setNegativeButton(R.string.cancel, null)
            .show()
    }

    private fun downloadDisks() {
        val assetDir = getExternalFilesDir(null) ?: filesDir
        val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
        val progressBar = android.widget.ProgressBar(
            this, null, android.R.attr.progressBarStyleHorizontal
        )
        val statusTv = TextView(this).apply {
            textSize = 12f
            setTextColor(Color.parseColor("#CCCCCC"))
            setPadding(0, dp(8), 0, 0)
        }
        val content = android.widget.LinearLayout(this).apply {
            orientation = android.widget.LinearLayout.VERTICAL
            setPadding(dp(20), dp(16), dp(20), dp(8))
            setBackgroundColor(Color.parseColor("#1A1A2E"))
            addView(progressBar)
            addView(statusTv)
        }
        val progressDialog = AlertDialog.Builder(this)
            .setTitle(R.string.btn_download_disks)
            .setView(content)
            .setCancelable(false)
            .show()

        Thread {
            var successCount = 0
            for ((index, disk) in diskDownloads.withIndex()) {
                mainHandler.post {
                    progressBar.isIndeterminate = false
                    progressBar.progress = 0
                    statusTv.text = getString(R.string.disks_downloading, index + 1, diskDownloads.size)
                }
                val tmpFile = File(cacheDir, "${disk.baseName}.nbz")
                try {
                    val conn = (URL(disk.url).openConnection() as HttpURLConnection).apply {
                        connectTimeout = 15000
                        readTimeout = 30000
                        instanceFollowRedirects = true
                    }
                    try {
                        conn.connect()
                        if (conn.responseCode != HttpURLConnection.HTTP_OK) {
                            throw IOException("HTTP ${conn.responseCode}")
                        }
                        val total = conn.contentLengthLong
                        var lastPercent = -1
                        val countingStream = CountingInputStream(conn.inputStream) { downloaded ->
                            if (total > 0) {
                                val percent = ((downloaded * 100) / total).toInt()
                                if (percent != lastPercent) {
                                    lastPercent = percent
                                    mainHandler.post { progressBar.progress = percent }
                                }
                            }
                        }
                        tmpFile.outputStream().use { out -> countingStream.copyTo(out) }
                    } finally {
                        conn.disconnect()
                    }
                    val finalFile = File(assetDir, "${disk.baseName}.G64")
                    if (engine.convertDiskImage(tmpFile.absolutePath, finalFile.absolutePath)) {
                        successCount++
                        mainHandler.post { appendLog("Downloaded: ${disk.baseName}") }
                    } else {
                        mainHandler.post { appendLog("Conversion failed: ${disk.baseName}") }
                    }
                } catch (e: Exception) {
                    Log.e(TAG, "Disk download failed: ${disk.baseName}", e)
                    mainHandler.post { appendLog("Download failed: ${disk.baseName} (${e.message})") }
                } finally {
                    tmpFile.delete()
                }
            }
            mainHandler.post {
                progressDialog.dismiss()
                refreshDiskButtons()
                val msg = getString(R.string.disks_download_done, successCount, diskDownloads.size)
                appendLog(msg)
                Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
            }
        }.start()
    }

    // ---- lifecycle ----------------------------------------------------------

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        drawerLayout = findViewById(R.id.drawer_layout)

        driveLedDrawable = GradientDrawable().apply {
            shape = GradientDrawable.OVAL
            setColor(Color.parseColor("#2A2A2A"))
        }
        findViewById<android.view.View>(R.id.drive_led).background = driveLedDrawable

        // On Android 15+ the app draws behind the status bar (edge-to-edge enforced).
        // Shift the main content down by the actual status bar height so the
        // hamburger button isn't hidden underneath it.
        val mainContent = findViewById<android.view.View>(R.id.main_content)
        ViewCompat.setOnApplyWindowInsetsListener(mainContent) { view, insets ->
            val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            val pad8 = (8 * resources.displayMetrics.density).toInt()
            view.setPadding(pad8, bars.top + pad8, pad8, pad8)
            insets
        }
        val navDrawer = findViewById<android.view.View>(R.id.nav_drawer)
        ViewCompat.setOnApplyWindowInsetsListener(navDrawer) { view, insets ->
            val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            val pad12 = (12 * resources.displayMetrics.density).toInt()
            view.setPadding(pad12, bars.top + pad12, pad12, pad12)
            insets
        }
        tvLog     = findViewById(R.id.tv_log)
        scrollLog = findViewById(R.id.scroll_log)
        display   = findViewById(R.id.c64_display)
        keyboard  = findViewById(R.id.c64_keyboard)

        findViewById<Button>(R.id.btn_menu).setOnClickListener {
            if (drawerLayout.isDrawerOpen(GravityCompat.START))
                drawerLayout.closeDrawer(GravityCompat.START)
            else
                drawerLayout.openDrawer(GravityCompat.START)
        }

        findViewById<Button>(R.id.btn_help).setOnClickListener { showHelp() }
        findViewById<Button>(R.id.btn_anleitung).setOnClickListener { showAnleitung() }
        findViewById<Button>(R.id.btn_savestate).setOnClickListener { showSaveStatePanel() }

        findViewById<Button>(R.id.btn_import_disks).setOnClickListener {
            launchPicker(diskPickerLauncher, getString(R.string.picker_title), multiSelect = true)
        }
        findViewById<Button>(R.id.btn_download_disks).setOnClickListener {
            showDownloadDisksDialog()
        }

        val btnSound = findViewById<Button>(R.id.btn_sound)
        btnSound.setOnClickListener {
            soundEnabled = !soundEnabled
            engine.setSoundEnabled(soundEnabled)
            btnSound.text = getString(if (soundEnabled) R.string.sound_on else R.string.sound_off)
        }

        val btnReset = findViewById<Button>(R.id.btn_reset)
        btnReset.setOnClickListener {
            val ok = engine.resetMachine()
            if (ok) {
                loadedADisks.clear()
                activeSlot = null
                refreshDiskButtons()
                drawerLayout.closeDrawer(GravityCompat.START)
            }
            appendLog(if (ok) "Reset: C64 reset to BASIC" else "Reset failed")
        }

        for (slot in diskSlots) {
            val btn = findViewById<Button>(slot.btnId)
            episodeButtons[slot] = btn
            btn.setOnClickListener { loadDiskSlot(slot) }
        }

        val neuButtons = mapOf(
            R.id.btn_hero_1_new to diskSlots.first { it.fileName == "HELD1.D64" },
            R.id.btn_hero_2_new to diskSlots.first { it.fileName == "HELD2.D64" },
            R.id.btn_hero_3_new to diskSlots.first { it.fileName == "HELD3.D64" },
        )
        for ((btnId, slot) in neuButtons) {
            findViewById<Button>(btnId).setOnClickListener { confirmCreateHeroDisk(slot) }
        }

        val editButtonIds = mapOf(
            R.id.btn_hero_1_edit to diskSlots.first { it.fileName == "HELD1.D64" },
            R.id.btn_hero_2_edit to diskSlots.first { it.fileName == "HELD2.D64" },
            R.id.btn_hero_3_edit to diskSlots.first { it.fileName == "HELD3.D64" },
        )
        for ((btnId, slot) in editButtonIds) {
            val btn = findViewById<Button>(btnId)
            heroEditButtons[slot] = btn
            btn.setOnClickListener { showHeroEditor(slot) }
        }

        choreographer = Choreographer.getInstance()
        initEmulator()
        refreshDiskButtons()
        startHttpServer()
        wireKeyboard()
        dismissSplashDelayed()
    }

    // ---- emulator -----------------------------------------------------------

    private fun initEmulator() {
        val assetDir = getExternalFilesDir(null) ?: filesDir
        assetDir.mkdirs()
        if (missingRoms(assetDir).isNotEmpty()) {
            showRomImportDialog(assetDir)
            return
        }
        startEmulator(assetDir)
    }

    private fun startEmulator(assetDir: File) {
        val romDir = assetDir.absolutePath
        val ok = engine.initEmulator(romDir)
        Log.d(TAG, "initEmulator=$ok romDir=$romDir")

        /* Choreographer drives rendering: fires on every hardware vsync (60/90/120 Hz).
         * captureFrame() reads the VICE framebuffer on this main-thread callback, then
         * invalidate() causes onDraw() to blit it — all under vsync, no tearing. */
        renderActive = true
        choreographer.postFrameCallback(object : Choreographer.FrameCallback {
            override fun doFrame(frameTimeNanos: Long) {
                if (!renderActive) return
                display.captureFrame(engine)
                display.invalidate()
                val led = engine.getDriveLed()
                if (led != driveLedState) {
                    driveLedState = led
                    driveLedDrawable.setColor(
                        if (led) Color.parseColor("#44DD66")
                        else     Color.parseColor("#2A2A2A")
                    )
                }
                choreographer.postFrameCallback(this)
            }
        })
    }

    // ---- ROM import -----------------------------------------------------

    // The companion app does not ship Commodore ROMs (they're copyrighted) —
    // the user must supply their own dump on first run. See docs/publish.md §0.
    private val requiredRoms = listOf("kernal", "basic", "chargen", "1541")

    private fun missingRoms(dir: File): List<String> =
        requiredRoms.filter { !File(dir, it).exists() }

    // Identifies which canonical ROM name a picked file corresponds to, based
    // on the versioned filenames VICE ships (e.g. "kernal-901227-03.bin") as
    // well as plain names a user might have renamed a dump to.
    private fun romNameForFile(fileName: String): String? {
        val lower = fileName.lowercase()
        return when {
            "kernal"  in lower -> "kernal"
            "chargen" in lower -> "chargen"
            "basic"   in lower -> "basic"
            "1541"    in lower -> "1541"
            else -> null
        }
    }

    private val romPickerLauncher: ActivityResultLauncher<Intent> =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
            val assetDir = getExternalFilesDir(null) ?: filesDir
            if (result.resultCode == Activity.RESULT_OK) {
                val uris = mutableListOf<Uri>()
                result.data?.clipData?.let { clip ->
                    for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri)
                } ?: result.data?.data?.let { uris.add(it) }

                for (uri in uris) {
                    val name = displayNameForUri(uri) ?: continue
                    val romName = romNameForFile(name) ?: continue
                    contentResolver.openInputStream(uri)?.use { input ->
                        File(assetDir, romName).outputStream().use { output -> input.copyTo(output) }
                    }
                    appendLog("Imported ROM: $romName")
                }
            }
            val stillMissing = missingRoms(assetDir)
            if (stillMissing.isEmpty()) {
                startEmulator(assetDir)
            } else {
                Toast.makeText(
                    this, getString(R.string.roms_still_missing, stillMissing.joinToString()),
                    Toast.LENGTH_LONG
                ).show()
                showRomImportDialog(assetDir)
            }
        }

    private fun showRomImportDialog(dir: File) {
        AlertDialog.Builder(this)
            .setTitle(R.string.roms_missing_title)
            .setMessage(getString(R.string.roms_missing_msg, missingRoms(dir).joinToString()))
            .setCancelable(false)
            .setPositiveButton(R.string.roms_import_button) { _, _ ->
                launchPicker(romPickerLauncher, getString(R.string.rom_picker_title), multiSelect = true)
            }
            .setNeutralButton(R.string.roms_download_button) { _, _ -> downloadRoms(dir) }
            .show()
    }

    // The same VICE 3.8 source tarball already bundled at res/vice-3.8.tar.gz
    // (used to cross-compile VICE itself), fetched from VICE's own official
    // GitHub release rather than shipped in the APK. The four ROMs are
    // extracted from it client-side — see docs/publish.md §0 for why this is
    // not equivalent to bundling them in the app.
    private val viceTarballUrl =
        "https://github.com/VICE-Team/svn-mirror/releases/download/3.8.0/vice-3.8.tar.gz"

    private fun downloadRoms(destDir: File) {
        val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
        val progressBar = android.widget.ProgressBar(
            this, null, android.R.attr.progressBarStyleHorizontal
        ).apply { isIndeterminate = true }
        val statusTv = TextView(this).apply {
            text = getString(R.string.roms_downloading)
            textSize = 12f
            setTextColor(Color.parseColor("#CCCCCC"))
            setPadding(0, dp(8), 0, 0)
        }
        val content = android.widget.LinearLayout(this).apply {
            orientation = android.widget.LinearLayout.VERTICAL
            setPadding(dp(20), dp(16), dp(20), dp(8))
            setBackgroundColor(Color.parseColor("#1A1A2E"))
            addView(progressBar)
            addView(statusTv)
        }
        val progressDialog = AlertDialog.Builder(this)
            .setTitle(R.string.roms_download_button)
            .setView(content)
            .setCancelable(false)
            .show()

        Thread {
            try {
                val conn = (URL(viceTarballUrl).openConnection() as HttpURLConnection).apply {
                    connectTimeout = 15000
                    readTimeout = 30000
                    instanceFollowRedirects = true
                }
                try {
                    conn.connect()
                    if (conn.responseCode != HttpURLConnection.HTTP_OK) {
                        throw IOException("HTTP ${conn.responseCode}")
                    }
                    val total = conn.contentLengthLong
                    var lastPercent = -1
                    val countingStream = CountingInputStream(conn.inputStream) { downloaded ->
                        if (total > 0) {
                            val percent = ((downloaded * 100) / total).toInt()
                            if (percent != lastPercent) {
                                lastPercent = percent
                                mainHandler.post {
                                    progressBar.isIndeterminate = false
                                    progressBar.progress = percent
                                    statusTv.text = getString(R.string.roms_downloading_progress, percent)
                                }
                            }
                        }
                    }
                    extractRomsFromTarGz(countingStream, destDir)
                } finally {
                    conn.disconnect()
                }
                mainHandler.post {
                    progressDialog.dismiss()
                    val stillMissing = missingRoms(destDir)
                    if (stillMissing.isEmpty()) {
                        appendLog("ROM download complete")
                        startEmulator(destDir)
                    } else {
                        appendLog("ROM download incomplete: ${stillMissing.joinToString()}")
                        Toast.makeText(
                            this, getString(R.string.roms_still_missing, stillMissing.joinToString()),
                            Toast.LENGTH_LONG
                        ).show()
                        showRomImportDialog(destDir)
                    }
                }
            } catch (e: Exception) {
                Log.e(TAG, "ROM download failed", e)
                mainHandler.post {
                    progressDialog.dismiss()
                    appendLog("ROM download failed: ${e.message}")
                    Toast.makeText(this, R.string.roms_download_failed, Toast.LENGTH_LONG).show()
                    showRomImportDialog(destDir)
                }
            }
        }.start()
    }

    // Minimal USTAR reader — pulls just the four named ROM entries out of the
    // gzipped VICE source tarball without needing a tar library dependency.
    private fun extractRomsFromTarGz(rawInput: InputStream, destDir: File) {
        val wantedEntries = mapOf(
            "vice-3.8/data/C64/kernal-901227-03.bin"            to "kernal",
            "vice-3.8/data/C64/basic-901226-01.bin"             to "basic",
            "vice-3.8/data/C64/chargen-901225-01.bin"           to "chargen",
            "vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin" to "1541",
        )
        GZIPInputStream(rawInput).use { gz ->
            val header = ByteArray(512)
            while (true) {
                var read = 0
                while (read < 512) {
                    val n = gz.read(header, read, 512 - read)
                    if (n < 0) return
                    read += n
                }
                val name = String(header, 0, 100, Charsets.US_ASCII).trimEnd('\u0000', ' ')
                val sizeField = String(header, 124, 12, Charsets.US_ASCII).trim('\u0000', ' ')
                val size = if (sizeField.isEmpty()) 0L else sizeField.toLong(8)

                val destName = wantedEntries[name]
                if (destName != null) {
                    File(destDir, destName).outputStream().use { out -> copyExactly(gz, out, size) }
                } else {
                    skipExactly(gz, size)
                }
                val remainder = size % 512
                if (remainder != 0L) skipExactly(gz, 512 - remainder)
            }
        }
    }

    private fun copyExactly(input: InputStream, output: OutputStream, size: Long) {
        val buf = ByteArray(8192)
        var remaining = size
        while (remaining > 0) {
            val n = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
            if (n < 0) break
            output.write(buf, 0, n)
            remaining -= n
        }
    }

    private fun skipExactly(input: InputStream, size: Long) {
        val buf = ByteArray(8192)
        var remaining = size
        while (remaining > 0) {
            val n = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
            if (n < 0) break
            remaining -= n
        }
    }

    // ---- virtual keyboard ---------------------------------------------------

    private fun wireKeyboard() {
        keyboard.onKeyEvent = { key, pressed ->
            for (code in key.codes) engine.injectKey(code, pressed)
            if (pressed) {
                Log.d(TAG, "Key pressed: ${key.label}")
                mainHandler.post { appendLog("KEY: ${key.label}") }
            }
        }
    }

    // ---- watch key wheel ------------------------------------------------

    // Labels sent by the watch's SELECT key wheel (see k_wheel_items in
    // SchwertUndMagieOnPebbleFrontend.c) — movement in this game is mostly
    // done with number keys, plus RETURN/SPACE to confirm.
    private val watchKeyMap: Map<String, List<Int>> = mapOf(
        "1" to listOf(C64Engine.KEY_1), "2" to listOf(C64Engine.KEY_2),
        "3" to listOf(C64Engine.KEY_3), "4" to listOf(C64Engine.KEY_4),
        "5" to listOf(C64Engine.KEY_5), "6" to listOf(C64Engine.KEY_6),
        "7" to listOf(C64Engine.KEY_7), "8" to listOf(C64Engine.KEY_8),
        "9" to listOf(C64Engine.KEY_9), "0" to listOf(C64Engine.KEY_0),
        "RETURN" to listOf(C64Engine.KEY_RETURN),
        "SPACE"  to listOf(C64Engine.KEY_SPACE),
    )

    // Pulses a key press+release in response to a watch wheel selection.
    // The companion HTTP server calls this off the main thread, so the
    // release is posted via mainHandler like the rest of this class's
    // cross-thread UI/engine touchpoints.
    private fun injectWatchKey(label: String) {
        val codes = watchKeyMap[label] ?: return
        for (code in codes) engine.injectKey(code, true)
        mainHandler.postDelayed({
            for (code in codes) engine.injectKey(code, false)
        }, 80)
    }

    // ---- HTTP server (watch bridge) -----------------------------------------

    private fun startHttpServer() {
        server = CompanionServer(PORT, timeFormat,
            onTimeFetched = { _ -> },
            onKey = { cmd ->
                Log.d(TAG, "Watch key: $cmd")
                mainHandler.post { appendLog("WATCH: $cmd") }
                injectWatchKey(cmd)
            },
            getScreenText = { engine.getScreenText() }
        )
        try {
            server?.start()
            Log.d(TAG, "HTTP server started on port $PORT")
        } catch (e: Exception) {
            Log.e(TAG, "Failed to start server", e)
        }
    }

    // ---- disk helpers -------------------------------------------------------

    private fun diskFile(slot: DiskSlot): File? {
        val assetDir = getExternalFilesDir(null) ?: filesDir
        // Episode disks may exist either as an imported/created D64 or as a
        // G64 produced by downloadDisks() (nibconv output) — same base name.
        val g64Name = slot.fileName.removeSuffix(".D64") + ".G64"
        return assetDir.listFiles()?.firstOrNull {
            val name = it.name.uppercase()
            name == slot.fileName || name == g64Name
        }
    }

    private fun refreshDiskButtons() {
        for (slot in diskSlots) {
            val exists = diskFile(slot) != null
            val btn = episodeButtons[slot] ?: continue
            btn.isEnabled = exists
            btn.backgroundTintList = if (slot == activeSlot)
                ColorStateList.valueOf(Color.parseColor("#1A4A1A"))
            else
                ColorStateList.valueOf(Color.TRANSPARENT)
            heroEditButtons[slot]?.isEnabled = exists
        }
    }

    private fun loadDiskSlot(slot: DiskSlot) {
        if (slot.part == 'B' && slot.episode !in loadedADisks) {
            val aLabel = diskSlots.first { it.episode == slot.episode && it.part == 'A' }.label
            val msg = getString(R.string.load_a_first, aLabel, slot.label)
            Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
            appendLog(msg)
            return
        }
        val file = diskFile(slot) ?: run {
            appendLog("Disk not found: ${slot.fileName}")
            return
        }
        // Only reset + autostart for the first load of an A-side episode disk.
        // Returning to an already-loaded episode (or any B-side / hero disk) is a hot-swap.
        // Use the explicit Reset button to restart the machine on demand.
        val resetMachine = slot.part == 'A' && slot.episode !in loadedADisks
        val ok = if (resetMachine) engine.loadDisk(file.absolutePath)
                 else              engine.attachDisk(file.absolutePath)
        if (ok) {
            if (resetMachine) {
                loadedADisks.add(slot.episode)
                currentDiskPath = file.absolutePath
                findViewById<Button>(R.id.btn_reset).isEnabled = true
            }
            activeSlot = slot
            refreshDiskButtons()
        }
        appendLog(if (ok) "Inserted: ${slot.label}" else "Insert failed: ${slot.fileName}")
        if (ok) drawerLayout.closeDrawer(GravityCompat.START)
    }

    // ---- file import helpers ------------------------------------------------

    private fun launchPicker(
        launcher: ActivityResultLauncher<Intent>,
        title: String,
        multiSelect: Boolean = false
    ) {
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = "*/*"
            if (multiSelect) putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
        }
        launcher.launch(Intent.createChooser(intent, title))
    }

    private fun importFileToAssets(uri: Uri, fileName: String): File {
        val assetDir = getExternalFilesDir(null) ?: filesDir
        val dest = File(assetDir, fileName)
        contentResolver.openInputStream(uri)?.use { input ->
            dest.outputStream().use { output -> input.copyTo(output) }
            Log.d(TAG, "Imported $uri → ${dest.absolutePath}")
        } ?: appendLog("Could not open: $uri")
        return dest
    }

    private fun displayNameForUri(uri: Uri): String? =
        contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
            ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }

    // ---- hero disk creation -------------------------------------------------

    private fun confirmCreateHeroDisk(slot: DiskSlot) {
        val assetDir = getExternalFilesDir(null) ?: filesDir
        val dest = File(assetDir, slot.fileName)
        if (dest.exists()) {
            AlertDialog.Builder(this)
                .setTitle(R.string.replace_disk_title)
                .setMessage(getString(R.string.replace_disk_msg, slot.label))
                .setPositiveButton(R.string.replace_disk_confirm) { _, _ -> writeHeroDisk(slot) }
                .setNegativeButton(R.string.cancel, null)
                .show()
        } else {
            writeHeroDisk(slot)
        }
    }

    private fun writeHeroDisk(slot: DiskSlot) {
        val assetDir = getExternalFilesDir(null) ?: filesDir
        val dest = File(assetDir, slot.fileName)
        try {
            dest.writeBytes(createBlankD64(slot.label))
            refreshDiskButtons()
            appendLog(getString(R.string.hero_disk_created, slot.label))
        } catch (e: Exception) {
            appendLog(getString(R.string.hero_disk_create_failed, slot.label))
            Log.e(TAG, "createHeroDisk failed", e)
        }
    }

    /** Returns a 174 848-byte blank formatted 1541 D64 image with [diskLabel] as the disk name. */
    private fun createBlankD64(diskLabel: String): ByteArray {
        val data = ByteArray(174848)

        // BAM sector — track 18, sector 0
        val bam = d64Off(18, 0)
        data[bam + 0] = 18          // first directory track
        data[bam + 1] = 1           // first directory sector
        data[bam + 2] = 0x41        // DOS version 'A'
        data[bam + 3] = 0x00

        for (t in 1..35) {
            val entry   = bam + 4 + (t - 1) * 4
            val sectors = d64Spt[t]
            // All sectors free; track 18 has sectors 0 (BAM) and 1 (dir) pre-allocated
            var mask = (1 shl sectors) - 1
            var free = sectors
            if (t == 18) {
                mask = mask and (1 shl 0).inv() and (1 shl 1).inv()
                free -= 2
            }
            data[entry + 0] = free.toByte()
            data[entry + 1] = (mask         and 0xFF).toByte()
            data[entry + 2] = ((mask shr 8)  and 0xFF).toByte()
            data[entry + 3] = ((mask shr 16) and 0xFF).toByte()
        }

        // Disk name — 16 PETSCII bytes padded with 0xA0
        val name = diskLabel.uppercase().toByteArray(Charsets.ISO_8859_1)
        for (i in 0..15) data[bam + 144 + i] = if (i < name.size) name[i] else 0xA0.toByte()
        data[bam + 160] = 0xA0.toByte()
        data[bam + 161] = 0xA0.toByte()
        data[bam + 162] = 'H'.code.toByte()  // disk ID byte 1
        data[bam + 163] = 'D'.code.toByte()  // disk ID byte 2
        data[bam + 164] = 0xA0.toByte()
        data[bam + 165] = '2'.code.toByte()  // DOS type "2A"
        data[bam + 166] = 'A'.code.toByte()
        for (i in 167..255) data[bam + i] = 0xA0.toByte()

        // First directory sector — track 18, sector 1
        val dir = d64Off(18, 1)
        data[dir + 0] = 0x00        // no next track (end of chain)
        data[dir + 1] = 0xFF.toByte()

        return data
    }

    // ---- hero save editor ---------------------------------------------------

    private fun petsciiToString(bytes: ByteArray): String = buildString {
        for (b in bytes) {
            val v = b.toInt() and 0xFF
            when {
                v == 0x00 || v == 0xA0 -> return@buildString
                v in 0xC1..0xDA -> append((v - 0x80).toChar())          // shifted   → A-Z uppercase
                v in 0x41..0x5A -> append((v + 0x20).toChar())          // unshifted → a-z lowercase
                v in 0x30..0x39 -> append(v.toChar())
                v == 0x20       -> append(' ')
            }
        }
    }

    /** Walks the directory chain and returns every PRG file found on the disk. */
    private fun findAllHeroPrgs(d64: ByteArray): List<HeroPrg> {
        val result = mutableListOf<HeroPrg>()
        var track = 18; var sector = 1
        while (track != 0) {
            val base = d64Off(track, sector)
            for (i in 0 until 8) {
                val e = base + 2 + i * 32
                val ft = d64[e].toInt() and 0xFF
                if ((ft and 0x80) != 0 && (ft and 0x0F) == 0x02) {
                    val t = d64[e + 1].toInt() and 0xFF
                    val s = d64[e + 2].toInt() and 0xFF
                    if (t != 0) {
                        val sectorOff = d64Off(t, s)
                        val prgBase   = sectorOff + 2
                        val name = petsciiToString(d64.copyOfRange(prgBase + 0x02, prgBase + 0x12))
                        result.add(HeroPrg(
                            name      = name,
                            sectorOff = sectorOff,
                            values    = intArrayOf(
                                d64[prgBase + 0x12].toInt() and 0xFF,
                                d64[prgBase + 0x13].toInt() and 0xFF,
                                d64[prgBase + 0x14].toInt() and 0xFF,
                                d64[prgBase + 0x15].toInt() and 0xFF,
                                d64[prgBase + 0x16].toInt() and 0xFF,
                                d64[prgBase + 0x17].toInt() and 0xFF,
                                d64[prgBase + 0x18].toInt() and 0xFF,
                                d64[prgBase + 0x19].toInt() and 0xFF,
                                d64[prgBase + 0x1A].toInt() and 0xFF,
                            )
                        ))
                    }
                }
            }
            track  = d64[base].toInt() and 0xFF
            sector = d64[base + 1].toInt() and 0xFF
        }
        return result
    }

    private fun showHeroEditor(slot: DiskSlot) {
        val file   = diskFile(slot) ?: return
        val d64    = try { file.readBytes() } catch (e: Exception) { return }
        val heroes = findAllHeroPrgs(d64)

        if (heroes.isEmpty()) {
            Toast.makeText(this, getString(R.string.no_hero_save), Toast.LENGTH_SHORT).show()
            return
        }

        val labels    = arrayOf("Vitalität", "Tapferkeit", "Intelligenz", "Charme",
                                "Stärke", "Geschicklichkeit", "Angriffswert", "Verteidigung", "Grade")
        val isPercent = booleanArrayOf(false, true, true, true, true, true, true, true, false)
        val minVals   = intArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 1)
        val maxVals   = intArrayOf(27, 20, 20, 20, 20, 20, 20, 20, 9)

        var selectedIdx = 0
        val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }

        fun displayStr(heroIdx: Int, statIdx: Int) =
            if (isPercent[statIdx]) "${heroes[heroIdx].values[statIdx] * 5}%"
            else "${heroes[heroIdx].values[statIdx]}"

        val content = android.widget.LinearLayout(this).apply {
            orientation = android.widget.LinearLayout.VERTICAL
            setPadding(dp(16), dp(12), dp(16), dp(8))
            setBackgroundColor(Color.parseColor("#1A1A2E"))
        }

        // Hero selector — always shown (informative even with a single hero)
        val spinner = Spinner(this).apply {
            adapter = ArrayAdapter(
                this@MainActivity,
                android.R.layout.simple_spinner_item,
                heroes.map { it.name }
            ).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
            layoutParams = android.widget.LinearLayout.LayoutParams(
                android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
                android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
            ).apply { bottomMargin = dp(12) }
        }
        content.addView(spinner)

        // Stat value displays — shared across hero switches
        val valueTvs = Array(labels.size) { i ->
            TextView(this).apply {
                text = displayStr(0, i)
                textSize = 13f
                setTextColor(Color.WHITE)
                gravity = android.view.Gravity.CENTER
                layoutParams = android.widget.LinearLayout.LayoutParams(dp(52),
                    android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
            }
        }

        for (i in labels.indices) {
            val row = android.widget.LinearLayout(this).apply {
                orientation = android.widget.LinearLayout.HORIZONTAL
                gravity = android.view.Gravity.CENTER_VERTICAL
                layoutParams = android.widget.LinearLayout.LayoutParams(
                    android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
                    android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
                ).apply { bottomMargin = dp(4) }
            }
            row.addView(TextView(this).apply {
                text = labels[i]
                textSize = 12f
                setTextColor(Color.parseColor("#CCCCCC"))
                layoutParams = android.widget.LinearLayout.LayoutParams(
                    0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
            })
            row.addView(Button(this).apply {
                text = "−"; textSize = 14f
                layoutParams = android.widget.LinearLayout.LayoutParams(dp(44),
                    android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
                setOnClickListener {
                    val v = heroes[selectedIdx].values
                    if (v[i] > minVals[i]) { v[i]--; valueTvs[i].text = displayStr(selectedIdx, i) }
                }
            })
            row.addView(valueTvs[i])
            row.addView(Button(this).apply {
                text = "+"; textSize = 14f
                layoutParams = android.widget.LinearLayout.LayoutParams(dp(44),
                    android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
                setOnClickListener {
                    val v = heroes[selectedIdx].values
                    if (v[i] < maxVals[i]) { v[i]++; valueTvs[i].text = displayStr(selectedIdx, i) }
                }
            })
            content.addView(row)
        }

        // Switching heroes refreshes all value views
        spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
            override fun onItemSelected(p: AdapterView<*>?, v: android.view.View?, pos: Int, id: Long) {
                selectedIdx = pos
                for (i in labels.indices) valueTvs[i].text = displayStr(selectedIdx, i)
            }
            override fun onNothingSelected(p: AdapterView<*>?) {}
        }

        AlertDialog.Builder(this)
            .setTitle(getString(R.string.editor_title, slot.label))
            .setView(android.widget.ScrollView(this).apply {
                addView(content)
                setBackgroundColor(Color.parseColor("#1A1A2E"))
            })
            .setPositiveButton(R.string.editor_save) { _, _ ->
                for (hero in heroes) {
                    val prgBase = hero.sectorOff + 2
                    for (i in hero.values.indices)
                        d64[prgBase + 0x12 + i] = hero.values[i].toByte()
                }
                try {
                    file.writeBytes(d64)
                    appendLog("Saved: ${heroes.joinToString { it.name }} (${slot.label})")
                } catch (e: Exception) {
                    appendLog("Save failed: ${slot.label}")
                    Log.e(TAG, "Hero editor write failed", e)
                }
            }
            .setNegativeButton(R.string.cancel, null)
            .show()
    }

    // ---- splash screen ------------------------------------------------------

    private fun dismissSplashDelayed() {
        mainHandler.postDelayed({
            val splash = findViewById<ImageView>(R.id.splash_screen) ?: return@postDelayed
            splash.animate().alpha(0f).setDuration(700).withEndAction {
                splash.visibility = android.view.View.GONE
            }.start()
        }, 1500)
    }

    // ---- helpers ------------------------------------------------------------

    // ---- save state panel ---------------------------------------------------

    private fun stateFile(slot: Int): File {
        val dir = getExternalFilesDir(null) ?: filesDir
        return File(dir, "SAVESTATE_$slot.VSF")
    }

    private fun showSaveStatePanel() {
        val dp     = { v: Int -> (v * resources.displayMetrics.density).toInt() }
        val dateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())

        val content = android.widget.LinearLayout(this).apply {
            orientation = android.widget.LinearLayout.VERTICAL
            setPadding(dp(16), dp(12), dp(16), dp(8))
            setBackgroundColor(Color.parseColor("#1A1A2E"))
        }

        // Per-slot views that need refreshing after a Save
        val timeTvs   = arrayOfNulls<TextView>(5)
        val loadBtns  = arrayOfNulls<Button>(5)
        var dialog: AlertDialog? = null

        for (slot in 1..5) {
            val file   = stateFile(slot)
            val idx    = slot - 1

            val row = android.widget.LinearLayout(this).apply {
                orientation = android.widget.LinearLayout.HORIZONTAL
                gravity = android.view.Gravity.CENTER_VERTICAL
                layoutParams = android.widget.LinearLayout.LayoutParams(
                    android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
                    android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
                ).apply { bottomMargin = dp(6) }
            }

            row.addView(TextView(this).apply {
                text = getString(R.string.savestate_slot, slot)
                textSize = 12f
                setTextColor(Color.parseColor("#AAFFAA"))
                layoutParams = android.widget.LinearLayout.LayoutParams(dp(52),
                    android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
            })

            val timeTv = TextView(this).apply {
                val exists = file.exists()
                text = if (exists) dateFmt.format(Date(file.lastModified()))
                       else getString(R.string.savestate_empty)
                textSize = 11f
                setTextColor(if (exists) Color.parseColor("#AAAAAA") else Color.parseColor("#444444"))
                layoutParams = android.widget.LinearLayout.LayoutParams(0,
                    android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
            }
            timeTvs[idx] = timeTv
            row.addView(timeTv)

            val loadBtn = Button(this).apply {
                text = getString(R.string.savestate_load)
                textSize = 11f
                isEnabled = file.exists()
                layoutParams = android.widget.LinearLayout.LayoutParams(dp(80),
                    android.widget.LinearLayout.LayoutParams.WRAP_CONTENT).apply {
                    marginStart = dp(4)
                }
                setOnClickListener {
                    engine.loadState(file.absolutePath)
                    appendLog("State loaded: slot $slot")
                    dialog?.dismiss()
                }
            }
            loadBtns[idx] = loadBtn
            row.addView(loadBtn)

            row.addView(Button(this).apply {
                text = getString(R.string.savestate_save)
                textSize = 11f
                layoutParams = android.widget.LinearLayout.LayoutParams(dp(80),
                    android.widget.LinearLayout.LayoutParams.WRAP_CONTENT).apply {
                    marginStart = dp(4)
                }
                setOnClickListener {
                    engine.saveState(file.absolutePath)
                    appendLog("State saved: slot $slot")
                    // VICE writes asynchronously; refresh the row after one second
                    mainHandler.postDelayed({
                        if (file.exists()) {
                            timeTvs[idx]?.text = dateFmt.format(Date(file.lastModified()))
                            timeTvs[idx]?.setTextColor(Color.parseColor("#AAAAAA"))
                            loadBtns[idx]?.isEnabled = true
                        }
                    }, 1000)
                }
            })

            content.addView(row)
        }

        dialog = AlertDialog.Builder(this)
            .setTitle(R.string.savestate_title)
            .setView(android.widget.ScrollView(this).apply {
                addView(content)
                setBackgroundColor(Color.parseColor("#1A1A2E"))
            })
            .setNegativeButton(R.string.cancel, null)
            .show()
    }

    private fun showHelp() {
        fun load(german: Boolean) = try {
            assets.open(if (german) "help_de.md" else "help_en.md").bufferedReader().readText()
        } catch (e: Exception) { "Help text unavailable." }

        var german = java.util.Locale.getDefault().language == "de"

        val tv = android.widget.TextView(this).apply {
            text = load(german)
            typeface = android.graphics.Typeface.MONOSPACE
            textSize = 12f
            setTextColor(Color.parseColor("#DDDDDD"))
            val p = (16 * resources.displayMetrics.density).toInt()
            setPadding(p, p, p, p)
        }
        val scroll = android.widget.ScrollView(this).apply {
            addView(tv)
            setBackgroundColor(Color.parseColor("#1A1A2E"))
        }
        val dialog = AlertDialog.Builder(this)
            .setTitle("Schwert und Magie auf Pebble")
            .setView(scroll)
            .setPositiveButton("OK", null)
            .setNeutralButton(if (german) "English" else "Deutsch", null)
            .show()

        dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener {
            german = !german
            tv.text = load(german)
            scroll.scrollTo(0, 0)
            (it as android.widget.Button).text = if (german) "English" else "Deutsch"
        }
    }

    // Transcript of res/schwert-und-magie-anleitung.pdf, used to answer the
    // game's copy-protection prompt ("Nenne Wort X in Zeile Y von Absatz Z").
    // Paragraph/line breaks are preserved exactly as printed — see the note
    // at the top of the asset file for the counting convention.
    private fun showAnleitung() {
        val text = try {
            assets.open("anleitung.txt").bufferedReader().readText()
        } catch (e: Exception) { "Anleitung nicht verfügbar." }

        val tv = TextView(this).apply {
            this.text = text
            typeface = android.graphics.Typeface.MONOSPACE
            textSize = 12f
            setTextColor(Color.parseColor("#DDDDDD"))
            val p = (16 * resources.displayMetrics.density).toInt()
            setPadding(p, p, p, p)
        }
        val scroll = ScrollView(this).apply {
            addView(tv)
            setBackgroundColor(Color.parseColor("#1A1A2E"))
        }
        AlertDialog.Builder(this)
            .setTitle("Anleitung")
            .setView(scroll)
            .setPositiveButton("OK", null)
            .show()
    }

    private fun appendLog(entry: String) {
        val ts = timeFormat.format(Date())
        tvLog.text = "[$ts] $entry\n${tvLog.text}"
        scrollLog.post { scrollLog.scrollTo(0, 0) }
    }

    override fun onBackPressed() {
        if (drawerLayout.isDrawerOpen(GravityCompat.START))
            drawerLayout.closeDrawer(GravityCompat.START)
        else
            super.onBackPressed()
    }

    override fun onDestroy() {
        renderActive = false
        server?.stop()
        super.onDestroy()
    }
}

// ---------------------------------------------------------------------------

class CompanionServer(
    port: Int,
    private val timeFormat: SimpleDateFormat,
    private val onTimeFetched: (String) -> Unit,
    private val onKey: (String) -> Unit,
    private val getScreenText: () -> String
) : NanoHTTPD(port) {

    override fun serve(session: IHTTPSession): Response {
        val response = when (session.uri) {
            "/time" -> {
                val time = timeFormat.format(Date())
                onTimeFetched(time)
                newFixedLengthResponse(
                    Response.Status.OK, "application/json", """{"time":"$time"}"""
                )
            }
            "/key" -> {
                val cmd = session.parameters["cmd"]?.firstOrNull().orEmpty()
                if (cmd.isNotEmpty()) onKey(cmd)
                newFixedLengthResponse(Response.Status.OK, "text/plain", "ok")
            }
            "/screen" -> {
                val body = JSONObject().put("text", getScreenText()).toString()
                newFixedLengthResponse(Response.Status.OK, "application/json", body)
            }
            else -> newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "not found")
        }
        response.addHeader("Access-Control-Allow-Origin", "*")
        return response
    }
}

// ---------------------------------------------------------------------------

/** One hero's save data read from a D64 PRG sector. [values] is mutable for in-place editing. */
private class HeroPrg(val name: String, val sectorOff: Int, val values: IntArray)

/** Reports cumulative bytes read via [onProgress] as the wrapped stream is consumed. */
private class CountingInputStream(
    private val wrapped: InputStream,
    private val onProgress: (Long) -> Unit
) : InputStream() {
    private var count = 0L

    override fun read(): Int {
        val b = wrapped.read()
        if (b >= 0) { count++; onProgress(count) }
        return b
    }

    override fun read(b: ByteArray, off: Int, len: Int): Int {
        val n = wrapped.read(b, off, len)
        if (n > 0) { count += n; onProgress(count) }
        return n
    }

    override fun close() = wrapped.close()
}
