package invalid.lena.rsend

import java.io.ByteArrayInputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class RsyncRunnerTest {

    private val remote = Remote("nas", "host", 22, "user")

    // The exact vector, because this command line is the whole data-safety
    // surface of the app: any change to it should be a deliberate edit here.
    @Test
    fun basicArgs() {
        val a = RsyncRunner.args("RSH", remote, Folder(name = "n", local = "/a", remoteName = "nas", remotePath = "/b"))
        assertEquals(
            listOf(
                "-rt", "--out-format=%o %n", "--partial-dir=.rsend-partial", "--timeout=300",
                "--no-perms", "--no-owner", "--no-group", "--omit-dir-times",
                "-e", "RSH", "/a/", "user@host:/b/",
            ),
            a,
        )
    }

    // A bare --partial renames the truncated temp file over the destination
    // name, so an interrupted sync destroys a complete remote copy.
    @Test
    fun partialsStayOutOfTheDestinationName() {
        val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b"))
        assertTrue(a.contains("--partial-dir=${RsyncRunner.PARTIAL_DIR}"))
        assertFalse(a.contains("--partial"))
    }

    // Every line is labelled with its operation, so deletions can be logged and
    // transfers counted, and no filename ever sits at column 0 where it could
    // forge rsh's unreachable marker. --info would be tidier but rsync forwards
    // it to the remote and pre-3.1.0 rsyncs reject it; --out-format is sent as
    // --log-format, which every rsync understands.
    @Test
    fun outputIsLabelledWithTheOperation() {
        val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = true))
        assertTrue(a.contains("--out-format=%o %n"))
        assertFalse(a.contains("-v"))
        assertFalse(a.any { it.startsWith("--info") })
    }

    // Deleting during the transfer removes the server's copy before its
    // replacement arrives, and this worker is routinely killed mid-run, so a
    // rename on the phone plus one interruption would leave neither copy.
    @Test
    fun mirrorDeletesOnlyAfterEverythingIsTransferred() {
        val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = true))
        assertTrue(a.contains("--delete-after"))
        assertFalse(a.contains("--delete"))
    }

    @Test
    fun additiveOmitsDelete() {
        assertFalse(
            RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", delete = false))
                .any { it.startsWith("--delete") }
        )
    }

    @Test
    fun excludesBecomeFlags() {
        val a = RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b", excludes = listOf(".x/", ".y")))
        assertTrue(a.contains("--exclude=.x/"))
        assertTrue(a.contains("--exclude=.y"))
    }

    // rsync splits USER@HOST:PATH on the first colon, so an IPv6 literal has to
    // be bracketed or the address is cut in half.
    @Test
    fun ipv6DestinationIsBracketed() {
        val v6 = Remote("nas", "2001:db8::1", 22, "user")
        val a = RsyncRunner.args("RSH", v6, Folder(local = "/a", remotePath = "/b"))
        assertTrue(a.contains("user@[2001:db8::1]:/b/"))
        // A hostname must not gain brackets.
        assertTrue(RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = "/b"))
            .contains("user@host:/b/"))
    }

    @Test
    fun trailingSlashIdempotent() {
        val a = RsyncRunner.args("RSH", remote, Folder(local = "/a/", remotePath = "/b/"))
        assertTrue(a.contains("/a/"))
        assertTrue(a.contains("user@host:/b/"))
    }

    @Test(expected = IllegalArgumentException::class)
    fun daemonStyleRemotePathIsRejectedAtExecution() {
        RsyncRunner.args("RSH", remote, Folder(local = "/a", remotePath = ":module"))
    }

    // Exit 24 is "some files vanished before they could be transferred", which
    // on a live phone is routine for an additive push. For a mirror it is not:
    // rsync reports 24 when a source directory vanishes mid-run, and deletes
    // that directory's contents on the server, so it must not read as success.
    @Test
    fun vanishedSourceFilesAreNotAFailureForAdditive() {
        assertTrue(RsyncRunner.succeeded(0, mirror = false))
        assertTrue(RsyncRunner.succeeded(24, mirror = false))
        assertFalse(RsyncRunner.succeeded(23, mirror = false))
        assertFalse(RsyncRunner.succeeded(12, mirror = false))
        assertFalse(RsyncRunner.succeeded(1, mirror = false))
    }

    @Test
    fun vanishedSourceFilesFailAMirror() {
        assertTrue(RsyncRunner.succeeded(0, mirror = true))
        assertFalse(RsyncRunner.succeeded(24, mirror = true))
        assertFalse(RsyncRunner.succeeded(23, mirror = true))
    }

    @Test
    fun outputLinesAreBoundedWithoutBlockingTheNextLine() {
        val hostile = ByteArray(RsyncRunner.MAX_OUTPUT_LINE_BYTES * 8) { 'x'.code.toByte() }
        val input = ByteArrayInputStream(hostile + "\nsend next\n".toByteArray())
        val lines = ArrayList<String>()
        RsyncRunner.boundedLines(input, lines::add)
        assertEquals(2, lines.size)
        assertTrue(lines[0].endsWith("[truncated]"))
        assertTrue(lines[0].length <= RsyncRunner.MAX_OUTPUT_LINE_BYTES + 20)
        assertEquals("send next", lines[1])
    }

    // Real rsync --out-format='%o %n' output. A mirror may legitimately empty
    // its destination, so deletions are counted and reported rather than
    // blocked. Transfers are counted but not logged one line each; everything
    // else, including errors and rsh's marker, reaches the log verbatim.
    @Test
    fun deletionsAreCountedAndKeptInTheLog() {
        val out = """
            send DCIM/Camera/IMG_0001.jpg
            del. DCIM/Camera/IMG_9998.jpg
            send DCIM/Camera/IMG_0002.jpg
            del. DCIM/Camera/IMG_9999.jpg
            del. DCIM/Camera/old/
            rsync: some error worth keeping
        """.trimIndent() + "\n"
        val logged = ArrayList<String>()
        val t = RsyncRunner.tally(ByteArrayInputStream(out.toByteArray()), logged::add)

        assertEquals(2L, t.sent)
        assertEquals(3L, t.deleted)
        assertFalse(t.unreachable)
        // Deletions and errors stay readable; transfers do not flood the log.
        assertTrue(logged.none { it.startsWith("send ") })
        assertEquals(3, logged.count { it.startsWith("del. ") })
        assertTrue(logged.contains("rsync: some error worth keeping"))
    }

    @Test
    fun unreachableMarkerIsDetected() {
        val out = "rsh: unreachable: dial tcp 10.0.0.1:22: i/o timeout\n"
        val t = RsyncRunner.tally(ByteArrayInputStream(out.toByteArray())) {}
        assertTrue(t.unreachable)
        assertEquals(0L, t.deleted)
    }

    @Test
    fun nativeOutputIsBoundedWhileInputIsFullyDrained() {
        val hostile = ByteArray(1024 * 1024) { 'z'.code.toByte() }
        val input = ByteArrayInputStream(hostile)
        val output = Native.boundedOutput(input)
        assertTrue(output.length < hostile.size)
        assertTrue(output.endsWith("[output truncated]\n"))
        assertEquals(0, input.available())
    }
}
