package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult

/**
 * A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
 * flows through the fetcher, files→hunks→lines flatten in order, and the untrusted body decodes
 * lossily (a malformed file/hunk/line is dropped, its siblings survive). Compose render → device QA.
 */
@OptIn(ExperimentalCoroutinesApi::class)
class DiffViewModelTest {

    // ── DiffLineKind.fromWire ───────────────────────────────────────────────────────────────────
    @Test
    fun `fromWire maps known kinds and degrades an unknown kind to CONTEXT`() {
        assertEquals(DiffLineKind.ADDED, DiffLineKind.fromWire("added"))
        assertEquals(DiffLineKind.REMOVED, DiffLineKind.fromWire("removed"))
        assertEquals(DiffLineKind.CONTEXT, DiffLineKind.fromWire("context"))
        assertEquals(DiffLineKind.META, DiffLineKind.fromWire("meta"))
        assertEquals(DiffLineKind.HUNK, DiffLineKind.fromWire("hunk"))
        // A future/unknown wire kind must degrade, never crash (mirror of the web default).
        assertEquals(DiffLineKind.CONTEXT, DiffLineKind.fromWire("future-kind"))
    }

    // ── flattenDiff ordering ────────────────────────────────────────────────────────────────────
    @Test
    fun `flattenDiff emits file then hunk then lines in order, and a single binary row for a binary file`() {
        val text = DiffFile(
            oldPath = "a.kt", newPath = "a.kt", status = "modified", added = 1, removed = 1, binary = false,
            hunks = listOf(
                DiffHunk("@@ -1 +1 @@", listOf(
                    DiffLine(DiffLineKind.REMOVED, "old"),
                    DiffLine(DiffLineKind.ADDED, "new"),
                )),
            ),
        )
        val bin = DiffFile("img.png", "img.png", "modified", 0, 0, binary = true, hunks = emptyList())
        val rows = flattenDiff(DiffResult(listOf(text, bin), staged = false, truncated = false))

        assertEquals(6, rows.size)
        assertTrue(rows[0] is DiffFileHeaderRow)
        assertTrue(rows[1] is DiffHunkHeaderRow)
        assertEquals(DiffLineKind.REMOVED, (rows[2] as DiffLineRow).kind)
        assertEquals(DiffLineKind.ADDED, (rows[3] as DiffLineRow).kind)
        assertTrue(rows[4] is DiffFileHeaderRow) // binary file's header
        assertTrue(rows[5] is DiffBinaryRow)     // then exactly one binary marker (no hunks)
        // Ids are stable + monotonic (LazyColumn keys).
        assertEquals(listOf(0L, 1L, 2L, 3L, 4L, 5L), rows.map { it.id })
    }

    @Test
    fun `a renamed file shows old to new in its header path`() {
        val renamed = DiffFile("old.kt", "new.kt", status = "renamed", added = 0, removed = 0, binary = false, hunks = emptyList())
        val header = flattenDiff(DiffResult(listOf(renamed), false, false)).first() as DiffFileHeaderRow
        assertEquals("old.kt → new.kt", header.path)
    }

    // ── decodeDiffResult lossy decode ───────────────────────────────────────────────────────────
    @Test
    fun `decode drops a file with no newPath and a hunk with no header, keeping the rest`() {
        val json = """
          { "staged": true, "truncated": true, "files": [
            { "status": "modified" },
            { "newPath": "keep.kt", "added": 2, "removed": 1, "hunks": [
                { "lines": [ { "kind": "added", "text": "x" } ] },
                { "header": "@@ -1 +1 @@", "lines": [
                    { "kind": "added", "text": "ok" },
                    { "kind": "added" }
                ] }
            ] }
          ] }
        """.trimIndent().toByteArray()

        val result = decodeDiffResult(json)

        assertTrue(result.staged)
        assertTrue(result.truncated)
        assertEquals(1, result.files.size) // the newPath-less file was dropped
        val file = result.files.single()
        assertEquals("keep.kt", file.newPath)
        assertEquals(1, file.hunks.size)   // the header-less hunk was dropped
        assertEquals("@@ -1 +1 @@", file.hunks.single().header)
        assertEquals(1, file.hunks.single().lines.size) // the text-less line was dropped
        assertEquals("ok", file.hunks.single().lines.single().text)
    }

    @Test
    fun `decode of a non-object or unparseable body yields an empty result, never throws`() {
        assertEquals(0, decodeDiffResult("[]".toByteArray()).files.size)
        assertEquals(0, decodeDiffResult("not json".toByteArray()).files.size)
        assertEquals(0, decodeDiffResult(ByteArray(0)).files.size)
    }

    // ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
    private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
        val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
        val bases = mutableListOf<String?>() // records the base arg of each fetch
        override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
            calls += staged
            bases += base
            error?.let { throw it }
            return result!!
        }
    }

    private fun oneFileResult(staged: Boolean) = DiffResult(
        files = listOf(DiffFile("a", "a", "modified", 1, 0, false, listOf(DiffHunk("@@", listOf(DiffLine(DiffLineKind.ADDED, "x")))))),
        staged = staged, truncated = false,
    )

    @Test
    fun `bind loads then transitions to LOADED with flattened rows`() = runTest {
        val fetcher = FakeFetcher(oneFileResult(false))
        val vm = DiffViewModel(fetcher, path = "/repo")
        val scope = CoroutineScope(StandardTestDispatcher(testScheduler))

        vm.bind(scope)
        advanceUntilIdle()

        assertEquals(DiffPhase.LOADED, vm.uiState.value.phase)
        assertTrue(vm.uiState.value.rows.isNotEmpty())
        assertEquals(listOf(false), fetcher.calls) // fetched the working tree (staged=false)
    }

    @Test
    fun `an empty diff is EMPTY and a fetch error is ERROR`() = runTest {
        val emptyVm = DiffViewModel(FakeFetcher(DiffResult(emptyList(), false, false)), "/repo")
        val errVm = DiffViewModel(FakeFetcher(null, error = RuntimeException("boom")), "/repo")
        val scope = CoroutineScope(StandardTestDispatcher(testScheduler))

        emptyVm.bind(scope); errVm.bind(scope)
        advanceUntilIdle()

        assertEquals(DiffPhase.EMPTY, emptyVm.uiState.value.phase)
        assertEquals(DiffPhase.ERROR, errVm.uiState.value.phase)
        assertTrue(errVm.uiState.value.rows.isEmpty())
    }

    @Test
    fun `selectStaged toggles the flag and re-fetches with staged true, but is a no-op for the same value`() = runTest {
        val fetcher = FakeFetcher(oneFileResult(true))
        val vm = DiffViewModel(fetcher, "/repo")
        val scope = CoroutineScope(StandardTestDispatcher(testScheduler))

        vm.bind(scope); advanceUntilIdle()      // initial fetch: staged=false
        vm.selectStaged(true); advanceUntilIdle() // toggles → re-fetch staged=true
        vm.selectStaged(true); advanceUntilIdle() // same value → NO re-fetch

        assertTrue(vm.uiState.value.staged)
        assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
    }

    // ── diffUrl base mode (Phase B) ───────────────────────────────────────────────────────────────
    @Test
    fun `diffUrl appends staged in working mode and base (omitting staged) in base mode`() {
        assertEquals(
            "http://h:3000/projects/diff?path=%2Frepo&staged=1",
            diffUrl("http://h:3000", "/repo", staged = true, base = null),
        )
        // base mode: no staged param, base percent-encoded.
        assertEquals(
            "http://h:3000/projects/diff?path=%2Frepo&base=feature%2Fx",
            diffUrl("http://h:3000", "/repo", staged = true, base = "feature/x"),
        )
        // a blank base is treated as working mode.
        assertEquals(
            "http://h:3000/projects/diff?path=%2Frepo&staged=0",
            diffUrl("http://h:3000", "/repo", staged = false, base = "  "),
        )
    }

    // ── DiffViewModel base mode (Phase B) ─────────────────────────────────────────────────────────
    @Test
    fun `setBase enters base mode, threads base to the fetcher, and suppresses the staged toggle`() = runTest {
        val fetcher = FakeFetcher(oneFileResult(false))
        val vm = DiffViewModel(fetcher, "/repo")
        val scope = CoroutineScope(StandardTestDispatcher(testScheduler))

        vm.bind(scope); advanceUntilIdle()
        vm.setBase("main"); advanceUntilIdle()

        assertEquals("main", vm.uiState.value.base)
        assertEquals(listOf(null, "main"), fetcher.bases) // base threaded on the re-fetch

        // In base mode the staged toggle is a no-op (server ignores staged when base is set).
        vm.selectStaged(true); advanceUntilIdle()
        assertFalse(vm.uiState.value.staged)
        assertEquals(2, fetcher.calls.size, "selectStaged must not re-fetch in base mode")

        // Leaving base mode returns to the working/staged view.
        vm.setBase(null); advanceUntilIdle()
        assertNull(vm.uiState.value.base)
        assertEquals(listOf(null, "main", null), fetcher.bases)
    }

    // ── DiffViewModel git-write (Phase C) ─────────────────────────────────────────────────────────
    private class FakeWriter(
        var stage: GitWriteOutcome<StageResult> = GitWriteOutcome.Ok(StageResult(staged = true, count = 1)),
        var commit: GitWriteOutcome<CommitResult> = GitWriteOutcome.Ok(CommitResult(commit = "abc123")),
        var push: GitWriteOutcome<PushResult> = GitWriteOutcome.Ok(PushResult(branch = "main", remote = "origin")),
    ) : GitWriteGateway {
        val stageCalls = mutableListOf<Triple<String, List<String>, Boolean>>()
        var commitCalls = 0; var pushCalls = 0
        override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> {
            stageCalls += Triple(path, files, stage); return this.stage
        }
        override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> { commitCalls++; return commit }
        override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> { pushCalls++; return push }
    }

    @Test
    fun `toggleStage posts the file and refreshes the diff`() = runTest {
        val fetcher = FakeFetcher(oneFileResult(false))
        val writer = FakeWriter()
        val vm = DiffViewModel(fetcher, "/repo", writer)
        val scope = CoroutineScope(StandardTestDispatcher(testScheduler))

        vm.bind(scope); advanceUntilIdle()
        vm.toggleStage("src/A.kt", staged = true); advanceUntilIdle()

        assertEquals(Triple("/repo", listOf("src/A.kt"), true), writer.stageCalls.single())
        assertEquals(2, fetcher.calls.size, "a successful stage must refresh the diff")
        assertEquals(false, vm.uiState.value.writeBanner?.isError)
    }

    @Test
    fun `commit surfaces an Ok banner and an empty message is rejected client-side with no I O`() = runTest {
        val fetcher = FakeFetcher(oneFileResult(false))
        val writer = FakeWriter()
        val vm = DiffViewModel(fetcher, "/repo", writer)
        val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
        vm.bind(scope); advanceUntilIdle()

        vm.commit("   "); advanceUntilIdle() // blank → client-side reject
        assertEquals(0, writer.commitCalls, "a blank commit message must not hit the network")
        assertEquals(true, vm.uiState.value.writeBanner?.isError)

        vm.commit("real message"); advanceUntilIdle()
        assertEquals(1, writer.commitCalls)
        assertEquals(false, vm.uiState.value.writeBanner?.isError)
        assertTrue(vm.uiState.value.writeBanner!!.message.contains("abc123"))
    }

    @Test
    fun `push maps a 409 rejection to the inert server message and does not refresh`() = runTest {
        val fetcher = FakeFetcher(oneFileResult(false))
        val writer = FakeWriter(push = GitWriteOutcome.Rejected(409, "Push rejected: remote has diverged."))
        val vm = DiffViewModel(fetcher, "/repo", writer)
        val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
        vm.bind(scope); advanceUntilIdle()

        vm.push(); advanceUntilIdle()

        assertEquals(1, writer.pushCalls)
        assertEquals(true, vm.uiState.value.writeBanner?.isError)
        assertEquals("Push rejected: remote has diverged.", vm.uiState.value.writeBanner?.message)
        assertEquals(1, fetcher.calls.size, "a failed push must NOT refresh the diff")
    }

    @Test
    fun `git-write is disabled in base mode`() = runTest {
        val fetcher = FakeFetcher(oneFileResult(false))
        val writer = FakeWriter()
        val vm = DiffViewModel(fetcher, "/repo", writer)
        val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
        vm.bind(scope); advanceUntilIdle()
        vm.setBase("main"); advanceUntilIdle()

        vm.toggleStage("a.kt", true); vm.commit("m"); vm.push(); advanceUntilIdle()

        assertTrue(writer.stageCalls.isEmpty() && writer.commitCalls == 0 && writer.pushCalls == 0)
        assertFalse(vm.uiState.value.writeEnabled)
    }

    @Test
    fun `writeEnabled is false without a writer and true with one in working mode`() {
        assertFalse(DiffUiState(canWrite = false).writeEnabled)
        assertTrue(DiffUiState(canWrite = true).writeEnabled)
        assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
    }
}
