package wang.yaojia.webterm.viewmodels

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.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.ProjectSessionRef
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID

/**
 * A23 Projects logic (JVM). The byte-parity contract: [ProjectGrouping] produces the SAME group KEYS as
 * web `public/projects.ts` / iOS `ProjectGrouping` (collapse state is persisted by key into the shared
 * `/prefs`), and the [ProjectsViewModel] prefs round-trip preserves unknown top-level keys + never PUTs
 * when `/prefs` never loaded (R11). Grid/sheet layout is device-QA (plan §7).
 */
class ProjectsViewModelTest {

    // ── ProjectGrouping: exact group-key parity ─────────────────────────────────────────────────

    @Test
    fun `group keys are byte-identical to web - namespace first-seen casing, case-insensitive bucket, sentinel other`() {
        val projects = listOf(
            proj("Acme.Web.frontend", "/a1", lastActive = 10),
            proj("Acme.Web.backend", "/a2", lastActive = 20),
            // lower-cased name → SAME bucket as "Acme.Web" (case-insensitive), first-seen casing wins.
            proj("acme.web.tools", "/a3", lastActive = 5),
            proj("solo", "/s1", lastActive = 1), // no dot → "other"
            proj("Foo.Bar", "/f1", lastActive = 1), // single-member namespace → collapses into "other"
        )

        val groups = ProjectGrouping.group(projects, favourites = emptySet())

        // Frozen keys: the first-seen-cased namespace, then the leading-space sentinel " other".
        assertEquals(listOf("Acme.Web", " other"), groups.map { it.key })
        assertEquals(ProjectGroupKind.NAMESPACE, groups[0].kind)
        assertEquals(3, groups[0].projects.size) // all three Acme.Web.* including the lower-cased one
        assertEquals(ProjectGroupKind.OTHER, groups[1].kind)
        assertEquals(2, groups[1].projects.size) // solo + Foo.Bar
    }

    @Test
    fun `displayLabel strips the namespace prefix case-insensitively, sentinels keep the full name`() {
        assertEquals("frontend", ProjectGrouping.displayLabel("Acme.Web.frontend", "Acme.Web"))
        assertEquals("tools", ProjectGrouping.displayLabel("acme.web.tools", "Acme.Web"))
        // A non-matching name is returned whole; sentinel groups never strip.
        assertEquals("other", ProjectGrouping.displayLabel("other", ProjectGrouping.OTHER_GROUP_KEY))
    }

    @Test
    fun `a running session pins an Active now group with the frozen space-active key`() {
        val groups = ProjectGrouping.group(
            listOf(
                proj("Acme.Web.frontend", "/a1", running = true),
                proj("Acme.Web.backend", "/a2"),
            ),
            favourites = emptySet(),
        )
        assertEquals(ProjectGrouping.ACTIVE_GROUP_KEY, groups.first().key) // " active"
        assertEquals(ProjectGroupKind.ACTIVE, groups.first().kind)
        assertTrue(groups.any { it.key == "Acme.Web" })
    }

    @Test
    fun `no shared namespace falls back to a single flat group keyed space-other`() {
        val groups = ProjectGrouping.group(listOf(proj("solo", "/s1"), proj("plain", "/p1")), emptySet())
        assertEquals(1, groups.size)
        assertEquals(ProjectGrouping.OTHER_GROUP_KEY, groups.single().key) // " other"
        assertEquals(ProjectGroupKind.FLAT, groups.single().kind)
    }

    @Test
    fun `sort puts favourites first then recency descending, stable on ties`() {
        val a = proj("a", "/a", lastActive = 100)
        val b = proj("b", "/b", lastActive = 50)
        val sorted = ProjectGrouping.sort(listOf(a, b), favourites = setOf("/b"))
        assertEquals(listOf("/b", "/a"), sorted.map { it.path }) // fav /b first despite lower recency
    }

    @Test
    fun `filter matches name or path substring case-insensitively`() {
        val projects = listOf(proj("Acme.Web", "/srv/acme"), proj("Widget", "/srv/widget"))
        assertEquals(listOf("/srv/acme"), ProjectGrouping.filter(projects, "ACME").map { it.path })
        assertEquals(listOf("/srv/widget"), ProjectGrouping.filter(projects, "widget").map { it.path })
        assertEquals(2, ProjectGrouping.filter(projects, "  ").size) // blank → all
    }

    // ── /prefs unknown-key round-trip (R11) ─────────────────────────────────────────────────────

    @Test
    fun `toggling a favourite preserves unknown top-level keys and keeps an Int an Int, not a Double`() = runTest {
        val json = """{"favourites":["/a"],"collapsed":{"Acme.Web":true},"schemaVersion":3,"nested":{"x":1}}"""
        val gateway = FakeGateway(prefsValue = UiPrefs.decode(json.toByteArray())!!)
        val vm = ProjectsViewModel(gateway)

        vm.load()
        vm.toggleFavourite("/b")

        assertEquals(1, gateway.putCalls.size)
        val body = gateway.putCalls.single().encodeBody().decodeToString()
        // Unknown top-level keys are carried through verbatim …
        assertTrue(body.contains("\"schemaVersion\":3"), "unknown Int key preserved as an Int: $body")
        assertTrue(body.contains("\"nested\""), "unknown nested key preserved: $body")
        // … and the Int never widened to a Double.
        assertFalse(body.contains("3.0"), "Int must not re-encode as a Double: $body")
        // The known key was rewritten (new favourite appended, order preserved).
        val echoed = UiPrefs.decode(gateway.putCalls.single().encodeBody())!!
        assertEquals(listOf("/a", "/b"), echoed.favourites)
        assertEquals(mapOf("Acme.Web" to true), echoed.collapsed)
    }

    @Test
    fun `a toggle NEVER PUTs when prefs never loaded - it stays local-only (R11 anti-clobber)`() = runTest {
        val gateway = FakeGateway(prefsThrows = true) // GET /prefs failed → no base blob
        val vm = ProjectsViewModel(gateway)

        vm.load()
        vm.toggleFavourite("/x")

        assertEquals(0, gateway.putCalls.size, "an empty-base PUT would wipe the server's favourites")
        assertTrue(vm.uiState.value.favourites.contains("/x")) // still applied locally
        assertEquals(ProjectsCopy.PREFS_LOAD_FAILED, vm.uiState.value.prefsError)
    }

    // ── favourite / collapse logic ──────────────────────────────────────────────────────────────

    @Test
    fun `toggleCollapsed adds then removes the key and PUTs each change`() = runTest {
        val gateway = FakeGateway(prefsValue = UiPrefs.create())
        val vm = ProjectsViewModel(gateway)
        vm.load()

        vm.toggleCollapsed("Acme.Web")
        assertEquals(mapOf("Acme.Web" to true), vm.uiState.value.collapsedGroups)

        vm.toggleCollapsed("Acme.Web") // expanded is the default → the key is dropped, not set false
        assertTrue(vm.uiState.value.collapsedGroups.isEmpty())
        assertEquals(2, gateway.putCalls.size)
        assertTrue(gateway.putCalls.last().collapsed.isEmpty())
    }

    @Test
    fun `isCollapsed honours state but a search force-expands every section`() {
        val group = ProjectGroup("Acme.Web", "Acme.Web", ProjectGroupKind.NAMESPACE, emptyList(), activeCount = 0)
        val collapsed = ProjectsUiState(collapsedGroups = mapOf("Acme.Web" to true))
        assertTrue(collapsed.isCollapsed(group))
        // Searching forces the section open so results never hide behind a collapsed caret.
        assertFalse(collapsed.copy(searchText = "acme").isCollapsed(group))
    }

    @Test
    fun `toggleFavourite is a set-like toggle and adopts the server echo`() = runTest {
        val gateway = FakeGateway(prefsValue = UiPrefs.create())
        val vm = ProjectsViewModel(gateway)
        vm.load()

        vm.toggleFavourite("/a")
        assertEquals(listOf("/a"), vm.uiState.value.favourites)
        vm.toggleFavourite("/a") // toggle off
        assertTrue(vm.uiState.value.favourites.isEmpty())
    }

    // ── open Claude here ────────────────────────────────────────────────────────────────────────

    @Test
    fun `requestOpenClaude mints an attach-null-cwd request for an absolute path and rejects a relative one`() = runTest {
        val vm = ProjectsViewModel(FakeGateway())

        vm.requestOpenClaude("relative/path")
        assertNull(vm.uiState.value.openRequest)
        assertEquals(ProjectsCopy.OPEN_CLAUDE_INVALID_PATH, vm.uiState.value.openError)

        vm.requestOpenClaude("/home/dev/app")
        val request = vm.uiState.value.openRequest
        assertEquals("/home/dev/app", request?.cwd)
        assertEquals("claude\r", request?.bootstrapInput) // Enter is \r, not \n
        assertNull(vm.uiState.value.openError)
    }

    // ── Fakes / helpers ─────────────────────────────────────────────────────────────────────────

    private class FakeGateway(
        private val projectList: List<ProjectInfo> = emptyList(),
        private val prefsValue: UiPrefs? = UiPrefs.create(),
        private val prefsThrows: Boolean = false,
    ) : ProjectsGateway {
        val putCalls = mutableListOf<UiPrefs>()
        override suspend fun projects(): List<ProjectInfo> = projectList
        override suspend fun prefs(): UiPrefs {
            if (prefsThrows) throw RuntimeException("prefs unavailable")
            return prefsValue!!
        }

        override suspend fun putPrefs(prefs: UiPrefs): UiPrefs {
            putCalls += prefs
            return prefs // the sanitized server echo == input in this fake
        }

        override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
        override suspend fun projectPr(path: String) = throw NotImplementedError()
        override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError()
        override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError()
        override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError()
        override suspend fun pruneWorktrees(path: String) = throw NotImplementedError()
    }

    private fun proj(
        name: String,
        path: String,
        lastActive: Long? = null,
        running: Boolean = false,
    ): ProjectInfo = ProjectInfo(
        name = name,
        path = path,
        isGit = false,
        branch = null,
        dirty = null,
        lastActiveMs = lastActive,
        sessions = if (running) {
            listOf(ProjectSessionRef(UUID.randomUUID(), null, ClaudeStatus.UNKNOWN, clientCount = 1, createdAt = 0L, exited = false))
        } else {
            emptyList()
        },
    )
}
