package invalid.lena.rsend

import android.content.Context
import android.util.AtomicFile
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream
import java.util.Base64

// Config is rsend's whole state: the named remote targets, the schedule, and
// the folders to push. It is stored as plain JSON in app-private storage.
// config.json owns the host-key pins (Remote.hostKey). The known_hosts file rsh
// reads is derived from the one remote used by the current connection.

data class Remote(
    val name: String = "",
    val host: String = "",
    val port: Int = 22,
    val user: String = "",
    val hostKey: String = "",
) {
    fun pinned(): Boolean = hostKey.isNotEmpty()
}

object RemoteRules {
    private const val MAX_NAME_CHARS = 128
    private const val MAX_HOST_CHARS = 255
    private const val MAX_USER_CHARS = 128
    private const val MAX_HOST_KEY_CHARS = 16 * 1024
    private val user = Regex("[A-Za-z0-9._-]+")
    private val hostKeyTypes = setOf(
        "ssh-ed25519",
        "ecdsa-sha2-nistp256",
        "ecdsa-sha2-nistp384",
        "ecdsa-sha2-nistp521",
        "ssh-rsa",
    )

    fun nameAllowed(value: String): Boolean =
        value.isNotEmpty() && value.length <= MAX_NAME_CHARS && value.none { it.isISOControl() }

    fun userAllowed(value: String): Boolean =
        value.length <= MAX_USER_CHARS && !value.startsWith('-') && user.matches(value)

    fun hostAllowed(value: String): Boolean =
        value.isNotEmpty() && value.length <= MAX_HOST_CHARS && !value.startsWith('-') && value.all {
            it in 'A'..'Z' || it in 'a'..'z' || it in '0'..'9' ||
                it == '.' || it == '-' || it == '_' ||
                it == ':' || it == '%'
        }

    fun hostKeyAllowed(remote: Remote): Boolean {
        if (remote.hostKey.isEmpty()) return true
        if (remote.hostKey.length > MAX_HOST_KEY_CHARS) return false
        val parts = remote.hostKey.split(' ')
        if (parts.size != 3 || parts.any(String::isEmpty)) return false
        val address = if (remote.port == 22) remote.host else "[${remote.host}]:${remote.port}"
        if (parts[0] != address || parts[1] !in hostKeyTypes) return false
        return try {
            Base64.getDecoder().decode(parts[2]).isNotEmpty()
        } catch (_: IllegalArgumentException) {
            false
        }
    }
}

data class Schedule(
    val enabled: Boolean = false,
    val intervalMinutes: Int = 120,
    val wifiOnly: Boolean = true,
    val requireCharging: Boolean = false,
) {
    companion object {
        const val MIN_INTERVAL_MINUTES = 15
    }
}

data class Folder(
    val name: String = "",
    val local: String = "",
    val remoteName: String = "",
    val remotePath: String = "",
    val delete: Boolean = false,
    val excludes: List<String> = emptyList(),
)

data class Config(
    val remotes: List<Remote> = emptyList(),
    val schedule: Schedule = Schedule(),
    val folders: List<Folder> = emptyList(),
) {
    fun remote(name: String): Remote? = remotes.firstOrNull { it.name == name }

    // syncReady reports whether a run can do useful work: an identity key and
    // at least one folder with a pinned target. Requiring every folder to be
    // ready would let one half-configured folder, a remote added but not yet
    // pinned, stop the backups that were already working. A folder whose
    // remote is not pinned fails on its own and is named in the log.
    fun syncReady(keyExists: Boolean): Boolean =
        keyExists && folders.any { remote(it.remoteName)?.pinned() == true }

    fun toJson(): JSONObject = JSONObject().apply {
        put("remotes", JSONArray().apply {
            remotes.forEach { r ->
                put(JSONObject().apply {
                    put("name", r.name)
                    put("host", r.host)
                    put("port", r.port)
                    put("user", r.user)
                    put("hostKey", r.hostKey)
                })
            }
        })
        put("schedule", JSONObject().apply {
            put("enabled", schedule.enabled)
            put("intervalMinutes", schedule.intervalMinutes)
            put("wifiOnly", schedule.wifiOnly)
            put("requireCharging", schedule.requireCharging)
        })
        put("folders", JSONArray().apply {
            folders.forEach { f ->
                put(JSONObject().apply {
                    put("name", f.name)
                    put("local", f.local)
                    put("remoteName", f.remoteName)
                    put("remotePath", f.remotePath)
                    put("delete", f.delete)
                    put("excludes", JSONArray(f.excludes))
                })
            }
        })
    }

    companion object {
        internal const val MAX_CONFIG_BYTES = 1024 * 1024
        private const val MAX_REMOTES = 64
        private const val MAX_FOLDERS = 256
        fun file(ctx: Context): File = File(ctx.filesDir, "config.json")
        private fun recoveryFile(ctx: Context): File = File(ctx.filesDir, "config-recovery.txt")
        private fun legacyFile(ctx: Context): File = File(ctx.filesDir, "config.json.0.1")

        @Synchronized
        fun load(ctx: Context): Config {
            val f = file(ctx)
            val text = try {
                AtomicFile(f).openRead().use { readText(it, MAX_CONFIG_BYTES) }
            } catch (_: FileNotFoundException) {
                return Config()
            } catch (e: Exception) {
                return recover(ctx, f, "read failed", e)
            }
            return try {
                val o = JSONObject(text)
                if (!legacyShape(o)) return fromJson(o)
                // Retire the old shape on the first read rather than waiting
                // for the user to edit something, and write it back so this
                // path is never taken twice: toJson always emits "remotes", so
                // legacyShape cannot match again.
                val upgraded = upgradeLegacy(o, Keys.legacyPin(ctx))
                val migrated = fromJson(upgraded)
                preserveLegacy(ctx, text, o, migrated)
                save(ctx, migrated)
                migrated
            } catch (e: Exception) {
                recover(ctx, f, "parse failed", e)
            }
        }

        // AtomicFile keeps the previous complete config if a write is interrupted.
        @Synchronized
        fun save(ctx: Context, c: Config) {
            val json = c.toJson()
            fromJson(json)
            val bytes = json.toString(2).toByteArray(Charsets.UTF_8)
            require(bytes.size <= MAX_CONFIG_BYTES) { "config exceeds $MAX_CONFIG_BYTES bytes" }
            val file = AtomicFile(file(ctx))
            val out = file.startWrite()
            try {
                out.write(bytes)
                file.finishWrite(out)
            } catch (e: Exception) {
                file.failWrite(out)
                throw e
            }
        }

        // legacyShape reports whether o is the pre-0.2 config 0.1.x wrote: one
        // "remote" object instead of a "remotes" array.
        fun legacyShape(o: JSONObject): Boolean =
            !o.has("remotes") && o.optJSONObject("remote") != null

        // upgradeLegacy rewrites a 0.1.x config into the current shape so the
        // strict parser validates it exactly like any other config. The single
        // remote becomes the sole entry named after its host, every retained
        // safe folder points at it, and the folder's old "remote" field was the
        // destination path. pin is the known_hosts line 0.1.x kept in a separate
        // file; it is carried over only if it still describes this host and port, so a
        // pin that no longer matches costs one Test connection rather than the
        // whole config.
        //
        // This is a deliberate, documented exception to the no-compatibility
        // rule: 0.1.3 is the last published release, so it is what every
        // upgrading user has, and the alternative is that all of them silently
        // lose their remotes, pins and folders. Delete it in 0.4.0.
        fun upgradeLegacy(o: JSONObject, pin: String): JSONObject {
            val old = o.getJSONObject("remote")
            val host = old.optString("host").trim().removeSurrounding("[", "]").trim()
            val port = old.optInt("port", 22)
            val user = old.optString("user")
            val candidate = Remote(
                name = host,
                host = host,
                port = port,
                user = user,
                hostKey = pin,
            )
            val endpointAllowed = RemoteRules.nameAllowed(host) &&
                RemoteRules.hostAllowed(host) && RemoteRules.userAllowed(user) && port in 1..65535
            val remote = JSONObject()
                .put("name", host)
                .put("host", host)
                .put("port", port)
                .put("user", user)
                .put("hostKey", "")
            if (endpointAllowed && RemoteRules.hostKeyAllowed(candidate) && pin.isNotEmpty()) {
                remote.put("hostKey", pin)
            }

            val folders = JSONArray()
            val accepted = ArrayList<Folder>()
            val fa = o.optJSONArray("folders") ?: JSONArray()
            for (i in 0 until fa.length()) {
                if (!endpointAllowed || accepted.size >= MAX_FOLDERS) break
                val f = fa.getJSONObject(i)
                val oldExcludes = f.optJSONArray("excludes") ?: JSONArray()
                if (oldExcludes.length() > FolderRules.MAX_EXCLUDES) continue
                val excludes = JSONArray()
                for (j in 0 until oldExcludes.length()) {
                    excludes.put(oldExcludes.getString(j))
                }
                val folder = Folder(
                    name = f.optString("name"),
                    local = f.optString("local"),
                    remoteName = host,
                    remotePath = f.optString("remote"),
                    delete = f.optBoolean("delete", false),
                    excludes = (0 until excludes.length()).map(excludes::getString),
                )
                val trial = Config(
                    remotes = if (endpointAllowed) listOf(candidate.copy(hostKey = "")) else emptyList(),
                    folders = accepted,
                )
                if (FolderRules.nameAllowed(folder.name) &&
                    FolderRules.localPathAllowed(folder.local) &&
                    FolderRules.remotePathAllowed(folder.remotePath) &&
                    FolderRules.excludesAllowed(folder.excludes) &&
                    FolderRules.destinationConflict(trial, folder, -1) == null
                ) {
                    accepted.add(folder)
                    folders.put(
                        JSONObject()
                            .put("name", folder.name)
                            .put("local", folder.local)
                            .put("remoteName", host)
                            .put("remotePath", folder.remotePath)
                            .put("delete", folder.delete)
                            .put("excludes", excludes),
                    )
                }
            }
            val oldSchedule = o.optJSONObject("schedule") ?: JSONObject()
            val schedule = JSONObject()
                .put("enabled", oldSchedule.optBoolean("enabled", false))
                .put(
                    "intervalMinutes",
                    oldSchedule.optInt("intervalMinutes", 120)
                        .coerceAtLeast(Schedule.MIN_INTERVAL_MINUTES),
                )
                .put("wifiOnly", oldSchedule.optBoolean("wifiOnly", true))
                .put("requireCharging", oldSchedule.optBoolean("requireCharging", false))
            return JSONObject()
                .put("remotes", if (endpointAllowed) JSONArray().put(remote) else JSONArray())
                .put("schedule", schedule)
                .put("folders", folders)
        }

        private fun preserveLegacy(ctx: Context, text: String, old: JSONObject, migrated: Config) {
            atomicWrite(legacyFile(ctx), text.toByteArray(Charsets.UTF_8))
            val oldFolders = old.optJSONArray("folders")?.length() ?: 0
            val endpointOmitted = old.optJSONObject("remote")?.optString("host").orEmpty().isNotEmpty() &&
                migrated.remotes.isEmpty()
            val omitted = oldFolders - migrated.folders.size
            if (!endpointOmitted && omitted == 0) return
            val what = buildList {
                if (endpointOmitted) add("the invalid remote")
                if (omitted > 0) add("$omitted unsafe folder mapping(s)")
            }.joinToString(" and ")
            val message =
                "The 0.1.x configuration was upgraded with omissions: $what. " +
                    "The exact original was preserved as ${legacyFile(ctx).name}."
            atomicWrite(recoveryFile(ctx), (message + "\n").toByteArray(Charsets.UTF_8))
        }

        fun fromJson(o: JSONObject): Config {
            requireFields(o, setOf("remotes", "schedule", "folders"))
            val ra = o.getJSONArray("remotes")
            require(ra.length() <= MAX_REMOTES) { "too many remotes" }
            val remotes = ArrayList<Remote>()
            for (i in 0 until ra.length()) {
                val ro = ra.getJSONObject(i)
                requireFields(ro, setOf("name", "host", "port", "user", "hostKey"))
                remotes.add(
                    Remote(
                        name = string(ro, "name"),
                        host = string(ro, "host"),
                        port = integer(ro, "port"),
                        user = string(ro, "user"),
                        hostKey = string(ro, "hostKey"),
                    )
                )
            }
            require(remotes.map { it.name }.distinct().size == remotes.size) { "remote names must be unique" }
            require(remotes.all { RemoteRules.nameAllowed(it.name) }) { "remote name is invalid" }
            require(remotes.all { RemoteRules.hostAllowed(it.host) }) { "remote host is invalid" }
            require(remotes.all { RemoteRules.userAllowed(it.user) }) { "remote user is invalid" }
            require(remotes.all { it.port in 1..65535 }) { "remote port must be between 1 and 65535" }
            require(remotes.all(RemoteRules::hostKeyAllowed)) { "remote host key is invalid" }

            val s = o.getJSONObject("schedule")
            requireFields(s, setOf("enabled", "intervalMinutes", "wifiOnly", "requireCharging"))
            val interval = integer(s, "intervalMinutes")
            require(interval >= Schedule.MIN_INTERVAL_MINUTES) {
                "schedule interval must be at least ${Schedule.MIN_INTERVAL_MINUTES} minutes"
            }
            val fa = o.getJSONArray("folders")
            require(fa.length() <= MAX_FOLDERS) { "too many folders" }
            val folders = ArrayList<Folder>(fa.length())
            for (i in 0 until fa.length()) {
                val fo = fa.getJSONObject(i)
                requireFields(fo, setOf("name", "local", "remoteName", "remotePath", "delete", "excludes"))
                val ex = fo.getJSONArray("excludes")
                require(ex.length() <= FolderRules.MAX_EXCLUDES) { "too many folder excludes" }
                val excludes = ArrayList<String>(ex.length())
                for (j in 0 until ex.length()) {
                    val value = ex.get(j)
                    require(value is String) { "folder exclude must be a string" }
                    excludes.add(value)
                }
                folders.add(
                    Folder(
                        name = string(fo, "name"),
                        local = string(fo, "local"),
                        remoteName = string(fo, "remoteName"),
                        remotePath = string(fo, "remotePath"),
                        delete = bool(fo, "delete"),
                        excludes = excludes,
                    )
                )
            }
            require(folders.all { FolderRules.nameAllowed(it.name) }) { "folder name is invalid" }
            require(folders.all { FolderRules.localPathAllowed(it.local) }) { "folder local path is invalid" }
            require(folders.all { FolderRules.remotePathAllowed(it.remotePath) }) { "folder remote path is invalid" }
            val remoteNames = remotes.map(Remote::name).toSet()
            require(folders.all { it.remoteName in remoteNames }) { "folder refers to a missing remote" }
            require(folders.all { f -> FolderRules.excludesAllowed(f.excludes) }) { "folder exclude is invalid" }
            val config = Config(
                remotes = remotes,
                schedule = Schedule(
                    enabled = bool(s, "enabled"),
                    intervalMinutes = interval,
                    wifiOnly = bool(s, "wifiOnly"),
                    requireCharging = bool(s, "requireCharging"),
                ),
                folders = folders,
            )
            require(FolderRules.firstDestinationConflict(config) == null) {
                "folder remote destinations conflict"
            }
            return config
        }

        private fun requireFields(o: JSONObject, expected: Set<String>) {
            val missing = expected.firstOrNull { !o.has(it) }
            require(missing == null) { "missing config field: $missing" }
            val unexpected = o.keys().asSequence().firstOrNull { it !in expected }
            require(unexpected == null) { "unexpected config field: $unexpected" }
        }

        private fun string(o: JSONObject, name: String): String {
            val value = o.get(name)
            require(value is String) { "$name must be a string" }
            return value
        }

        private fun integer(o: JSONObject, name: String): Int {
            val value = o.get(name)
            require(value is Int) { "$name must be an integer" }
            return value
        }

        private fun bool(o: JSONObject, name: String): Boolean {
            val value = o.get(name)
            require(value is Boolean) { "$name must be a boolean" }
            return value
        }

        fun recoveryMessage(ctx: Context): String {
            val f = recoveryFile(ctx)
            return try {
                AtomicFile(f).openRead().use { readText(it, 4096) }
            } catch (_: FileNotFoundException) {
                ""
            }
        }

        fun clearRecoveryMessage(ctx: Context) {
            AtomicFile(recoveryFile(ctx)).delete()
        }

        private fun recover(ctx: Context, source: File, what: String, error: Exception): Config {
            val broken = quarantine(source)
            val detail = (error.message ?: error.javaClass.simpleName)
                .replace('\n', ' ')
                .replace('\r', ' ')
                .take(300)
            val message =
                "Configuration $what and was reset. The original file was preserved as ${broken.name}. " +
                    "Reason: $detail"
            atomicWrite(recoveryFile(ctx), (message + "\n").toByteArray())
            return Config()
        }

        internal fun quarantine(source: File): File {
            var n = 0
            var target: File
            do {
                val suffix = if (n == 0) ".broken" else ".broken.$n"
                target = File(source.parentFile, source.name + suffix)
                n++
            } while (target.exists())
            if (!source.renameTo(target)) {
                throw IllegalStateException("could not preserve unreadable ${source.name}")
            }
            return target
        }

        internal fun readText(input: InputStream, limit: Int): String {
            val out = ByteArrayOutputStream(minOf(limit, 8192))
            val buf = ByteArray(4096)
            while (true) {
                val n = input.read(buf)
                if (n < 0) break
                if (out.size() + n > limit) throw IllegalArgumentException("file exceeds $limit bytes")
                out.write(buf, 0, n)
            }
            return String(out.toByteArray(), Charsets.UTF_8)
        }

        private fun atomicWrite(f: File, bytes: ByteArray) {
            val file = AtomicFile(f)
            val out = file.startWrite()
            try {
                out.write(bytes)
                file.finishWrite(out)
            } catch (e: Exception) {
                file.failWrite(out)
                throw e
            }
        }
    }
}
