package invalid.lena.rsend

import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.io.ByteArrayInputStream
import java.nio.file.Files

class ConfigTest {

    // Exactly what 0.1.3 wrote: one "remote" object, and each folder carrying
    // its destination in a "remote" field. 0.1.3 is the last published
    // release, so this is what every upgrading user has on disk.
    private fun legacyJson(host: String = "home.example.net"): JSONObject =
        JSONObject(
            """
            {
              "remote": {"host": "$host", "port": 2222, "user": "backup"},
              "schedule": {"enabled": true, "intervalMinutes": 120,
                           "wifiOnly": true, "requireCharging": false},
              "folders": [
                {"name": "DCIM", "local": "/storage/emulated/0/DCIM",
                 "remote": "phone/DCIM", "delete": false, "excludes": [".thumbnails/"]}
              ]
            }
            """.trimIndent()
        )

    private val legacyPin =
        "[home.example.net]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBIbNeHUrulCimZ9t3gfTxVGB067yehRny9XwwL24o8i"

    @Test
    fun legacyShapeIsRecognised() {
        assertTrue(Config.legacyShape(legacyJson()))
        // A current config, and an empty one, are not legacy.
        assertFalse(Config.legacyShape(Config().toJson()))
        assertFalse(Config.legacyShape(JSONObject("""{"remotes": [], "schedule": {}, "folders": []}""")))
    }

    // The upgrade must produce something the strict parser accepts unchanged:
    // that is the whole point of converting before validating rather than
    // teaching the parser a second shape.
    @Test
    fun legacyConfigUpgradesAndStillValidates() {
        val c = Config.fromJson(Config.upgradeLegacy(legacyJson(), legacyPin))

        assertEquals(1, c.remotes.size)
        val r = c.remotes[0]
        assertEquals("home.example.net", r.name)
        assertEquals("home.example.net", r.host)
        assertEquals(2222, r.port)
        assertEquals("backup", r.user)
        assertEquals(legacyPin, r.hostKey)

        assertEquals(1, c.folders.size)
        val f = c.folders[0]
        assertEquals("/storage/emulated/0/DCIM", f.local)
        // The old "remote" field was the destination path, not a remote name.
        assertEquals("phone/DCIM", f.remotePath)
        assertEquals("home.example.net", f.remoteName)
        assertEquals(listOf(".thumbnails/"), f.excludes)
        assertTrue(c.schedule.enabled)
        assertEquals(120, c.schedule.intervalMinutes)
    }

    @Test
    fun legacyScheduleBelowWorkManagerMinimumIsClamped() {
        val json = legacyJson()
        json.getJSONObject("schedule").put("intervalMinutes", 5)

        val c = Config.fromJson(Config.upgradeLegacy(json, legacyPin))

        assertEquals(Schedule.MIN_INTERVAL_MINUTES, c.schedule.intervalMinutes)
    }

    @Test
    fun legacyUpgradeKeepsSafeMappingsAndOmitsUnsafeOnes() {
        val json = legacyJson()
        val folders = json.getJSONArray("folders")
        folders.put(
            JSONObject()
                .put("name", "unsafe")
                .put("local", "/storage/emulated/0")
                .put("remote", "..")
                .put("delete", true)
                .put("excludes", org.json.JSONArray()),
        )

        val c = Config.fromJson(Config.upgradeLegacy(json, legacyPin))

        assertEquals(listOf("DCIM"), c.folders.map(Folder::name))
    }

    @Test
    fun unfinishedLegacySetupStillMigratesToAValidConfig() {
        val json = legacyJson(host = "")
        json.getJSONObject("remote").put("user", "")

        val c = Config.fromJson(Config.upgradeLegacy(json, ""))

        assertTrue(c.remotes.isEmpty())
        assertTrue(c.folders.isEmpty())
        assertTrue(c.schedule.enabled)
    }

    @Test
    fun legacyBracketedIpv6HostIsNormalised() {
        val json = legacyJson(host = "[2001:db8::1]")

        val c = Config.fromJson(Config.upgradeLegacy(json, ""))

        assertEquals("2001:db8::1", c.remotes.single().host)
        assertEquals("2001:db8::1", c.folders.single().remoteName)
    }

    // Losing one stale pin costs a Test connection. Rejecting the config would
    // cost every remote, folder and pin the user had.
    @Test
    fun aPinThatNoLongerMatchesIsDroppedNotFatal() {
        val wrongHost = "[other.example.net]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBIbNeHUrulCimZ9t3gfTxVGB067yehRny9XwwL24o8i"
        val c = Config.fromJson(Config.upgradeLegacy(legacyJson(), wrongHost))
        assertEquals("", c.remotes[0].hostKey)
        assertEquals(1, c.folders.size)
    }

    @Test
    fun legacyConfigWithNoPinFileStillUpgrades() {
        val c = Config.fromJson(Config.upgradeLegacy(legacyJson(), ""))
        assertEquals("", c.remotes[0].hostKey)
        assertEquals("home.example.net", c.folders[0].remoteName)
    }

    // Once rewritten, toJson always emits "remotes", so the migration can never
    // run a second time on the same file.
    @Test
    fun migratingTwiceIsNotPossible() {
        val migrated = Config.fromJson(Config.upgradeLegacy(legacyJson(), legacyPin))
        assertFalse(Config.legacyShape(migrated.toJson()))
    }

    @Test
    fun roundTrip() {
        val c = Config(
            remotes = listOf(
                Remote("nas", "home", 2222, "backup", "[home]:2222 ssh-ed25519 AAAA"),
                Remote("reader", "kobo.lan", 22, "sync"),
            ),
            schedule = Schedule(enabled = true, intervalMinutes = 30, wifiOnly = false, requireCharging = true),
            folders = listOf(
                Folder("DCIM", "/storage/emulated/0/DCIM", "nas", "/b/DCIM", false, listOf(".thumbnails/")),
                Folder("Books", "/books", "reader", "/sd/books", true, emptyList()),
            ),
        )
        assertEquals(c, Config.fromJson(c.toJson()))
    }

    @Test
    fun currentConfigRequiresEveryField() {
        try {
            Config.fromJson(JSONObject("{}"))
            throw AssertionError("empty config accepted")
        } catch (_: IllegalArgumentException) {
        }
    }

    @Test
    fun scheduleBelowWorkManagerMinimumIsRejected() {
        val json = Config().toJson()
        json.getJSONObject("schedule").put("intervalMinutes", 1)
        try {
            Config.fromJson(json)
            throw AssertionError("invalid interval accepted")
        } catch (_: IllegalArgumentException) {
        }
    }

    @Test
    fun syncReadyNeedsKeyFolderAndPinnedRemote() {
        val pinned = Remote("nas", "home", 22, "backup", "line")
        val folder = Folder("d", "/d", "nas", "/b")
        assertTrue(Config(remotes = listOf(pinned), folders = listOf(folder)).syncReady(keyExists = true))
        assertFalse(Config(remotes = listOf(pinned), folders = listOf(folder)).syncReady(keyExists = false))
        assertFalse(Config(remotes = listOf(pinned)).syncReady(keyExists = true))
        assertFalse(
            Config(remotes = listOf(pinned.copy(hostKey = "")), folders = listOf(folder))
                .syncReady(keyExists = true)
        )
        // A remote added but not yet pinned must not stop the folders that
        // already work: the run proceeds, and the half-configured folder fails
        // on its own and is named in the log.
        assertTrue(
            Config(
                remotes = listOf(pinned, pinned.copy(name = "new", hostKey = "")),
                folders = listOf(folder, folder.copy(remoteName = "new")),
            ).syncReady(keyExists = true)
        )
    }

    @Test
    fun emptyDirectoryIsAReadableSource() {
        val dir = Files.createTempDirectory("rsend-empty").toFile()
        val link = File(dir.parentFile, "${dir.name}-link")
        try {
            assertTrue(FolderRules.sourceReadable(dir.absolutePath))
            assertFalse(FolderRules.sourceReadable(File(dir, "missing").absolutePath))
            assertFalse(FolderRules.sourceReadable(File.separator))
            assertFalse(FolderRules.sourceReadable("relative"))
            Files.createSymbolicLink(link.toPath(), dir.toPath())
            assertFalse(FolderRules.sourceReadable(link.absolutePath))
        } finally {
            link.delete()
            assertTrue(dir.delete())
        }
    }

    @Test
    fun remotePathConflictIsConservative() {
        assertTrue(FolderRules.remotePathsConflict("backup", "backup/DCIM"))
        assertTrue(FolderRules.remotePathsConflict("~/backup", "backup/DCIM"))
        assertTrue(FolderRules.remotePathsConflict("/srv", "/srv/backup"))
        assertTrue(FolderRules.remotePathsConflict("Photos", "photos/2026"))
        assertTrue(FolderRules.remotePathsConflict("/backup", "backup"))
        assertFalse(FolderRules.remotePathsConflict("backup-a", "backup-b"))
    }

    @Test
    fun remotePathCannotSelectTheRsyncDaemonProtocol() {
        assertFalse(FolderRules.remotePathAllowed(":module"))
        assertFalse(FolderRules.remotePathAllowed("path\u001b"))
        for (path in listOf("/", ".", "..", "~", "~/", "../backup", "backup/../other", "~other/backup")) {
            assertFalse("unsafe path accepted: $path", FolderRules.remotePathAllowed(path))
        }
        assertFalse(FolderRules.excludeAllowed("pattern\tvalue"))
        assertTrue(FolderRules.remotePathAllowed("/srv/backup"))
        assertTrue(FolderRules.remotePathAllowed("~/backup"))
        assertTrue(FolderRules.remotePathAllowed("backup path;literal\$dollar'quote"))
    }

    @Test
    fun destinationConflictUsesTheActualEndpoint() {
        val cfg = Config(
            remotes = listOf(
                Remote("nas", "NAS.example", 22, "backup", "pin"),
                Remote("alias", "nas.example", 22, "backup", "pin"),
                Remote("other-user", "nas.example", 22, "reader", "pin"),
            ),
            folders = listOf(Folder("photos", "/photos", "nas", "archive/photos")),
        )
        assertEquals(
            cfg.folders[0],
            FolderRules.destinationConflict(
                cfg,
                Folder("camera", "/camera", "alias", "archive/photos/camera"),
                -1,
            ),
        )
        assertEquals(
            null,
            FolderRules.destinationConflict(
                cfg,
                Folder("reader", "/reader", "other-user", "archive/photos"),
                -1,
            ),
        )
    }

    @Test
    fun quarantineNeverOverwritesAnEarlierRecovery() {
        val dir = Files.createTempDirectory("rsend-config").toFile()
        try {
            val source = File(dir, "config.json")
            source.writeText("first")
            assertEquals("config.json.broken", Config.quarantine(source).name)
            source.writeText("second")
            assertEquals("config.json.broken.1", Config.quarantine(source).name)
            assertEquals("first", File(dir, "config.json.broken").readText())
            assertEquals("second", File(dir, "config.json.broken.1").readText())
        } finally {
            dir.deleteRecursively()
        }
    }

    @Test
    fun invalidEndpointFieldsAreRejected() {
        assertTrue(RemoteRules.hostAllowed("2001:db8::1%wlan0"))
        assertTrue(RemoteRules.userAllowed("backup-user"))
        assertFalse(RemoteRules.nameAllowed("n".repeat(129)))
        assertFalse(RemoteRules.hostAllowed("h".repeat(256)))
        assertFalse(RemoteRules.userAllowed("u".repeat(129)))
        assertFalse(RemoteRules.hostAllowed("höst.example"))
        assertFalse(RemoteRules.hostAllowed("host;command"))
        assertFalse(RemoteRules.hostAllowed("-option"))
        assertFalse(RemoteRules.userAllowed("user name"))
        assertFalse(RemoteRules.userAllowed("-option"))
    }

    @Test
    fun hostPinMustBelongToTheConfiguredEndpoint() {
        val pin = "home ssh-ed25519 AAAA"
        assertTrue(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", pin)))
        assertTrue(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", "")))
        assertFalse(RemoteRules.hostKeyAllowed(Remote("nas", "other", 22, "backup", pin)))
        assertFalse(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", "$pin comment")))
        assertFalse(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", "home ssh-dss AAAA")))
        assertFalse(RemoteRules.hostKeyAllowed(Remote("nas", "home", 22, "backup", "home ssh-ed25519 not-base64")))
    }

    @Test
    fun configRejectsCoercedAndUnknownFields() {
        val badRemotes = Config().toJson().put("remotes", "not an array")
        val badInterval = Config().toJson().apply {
            getJSONObject("schedule").put("intervalMinutes", "120")
        }
        val badFolders = Config().toJson().put("folders", false)
        val unknown = Config().toJson().put("unexpected", true)
        val missing = Config().toJson().apply { getJSONObject("schedule").remove("wifiOnly") }
        val controlName = Config(
            remotes = listOf(Remote("nas", "nas", 22, "backup")),
            folders = listOf(Folder("bad\nname", "/a", "nas", "backup")),
        ).toJson()
        for (json in listOf(badRemotes, badInterval, badFolders, unknown, missing, controlName)) {
            try {
                Config.fromJson(json)
                throw AssertionError("malformed config accepted: $json")
            } catch (_: Exception) {
            }
        }
    }

    @Test
    fun pathsAndExcludeVectorsAreBounded() {
        assertFalse(FolderRules.localPathAllowed("/" + "a".repeat(4096)))
        assertFalse(FolderRules.remotePathAllowed("a".repeat(4097)))
        assertFalse(FolderRules.nameAllowed("n".repeat(257)))
        assertFalse(FolderRules.excludeAllowed("x".repeat(513)))
        assertFalse(FolderRules.excludesAllowed(List(65) { "x" }))
        assertTrue(FolderRules.excludesAllowed(List(64) { "x" }))
    }

    @Test
    fun configRejectsOverlappingDestinations() {
        val json = JSONObject(
            """
            {
              "remotes": [{"name":"nas","host":"nas","port":22,"user":"backup","hostKey":""}],
              "schedule": {"enabled":false,"intervalMinutes":120,"wifiOnly":true,"requireCharging":false},
              "folders": [
                {"name":"a","local":"/a","remoteName":"nas","remotePath":"backup","delete":false,"excludes":[]},
                {"name":"b","local":"/b","remoteName":"nas","remotePath":"backup/b","delete":false,"excludes":[]}
              ]
            }
            """
        )
        try {
            Config.fromJson(json)
            throw AssertionError("overlapping destinations accepted")
        } catch (_: IllegalArgumentException) {
        }
    }

    @Test
    fun configInputIsBounded() {
        val oversized = ByteArray(Config.MAX_CONFIG_BYTES + 1)
        try {
            Config.readText(ByteArrayInputStream(oversized), Config.MAX_CONFIG_BYTES)
            throw AssertionError("oversized config accepted")
        } catch (_: IllegalArgumentException) {
        }
    }
}
