package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.designsystem.DesignSpec
import wang.yaojia.webterm.wire.TimelineEvent
import java.time.ZoneId
import java.util.UUID

/**
 * A28 TimelineViewModel — the JVM-testable activity-timeline logic (plan §5 A28, §4.2, §8): fetch →
 * present, the class→glyph/color mapping, lossy downstream drop of unknown-class events (keep the rest),
 * and the empty/failed/retry state machine. The Compose sheet render is DEVICE-QA (plan §7).
 */
@OptIn(ExperimentalCoroutinesApi::class)
class TimelineViewModelTest {

    private val utc = ZoneId.of("UTC")

    private fun event(at: Long, cls: String = "tool", label: String = "ran Bash") =
        TimelineEvent(at = at, eventClass = cls, toolName = null, label = label)

    private class FakeSource(private val error: Throwable? = null, private val events: List<TimelineEvent> = emptyList()) {
        val ids = mutableListOf<UUID>()
        suspend fun fetch(id: UUID): List<TimelineEvent> {
            ids += id
            error?.let { throw it }
            return events
        }
    }

    // ── Phase state machine ───────────────────────────────────────────────────────────────────────
    @Test
    fun `initial phase is Loading before load runs`() {
        val vm = TimelineViewModel(fetch = { emptyList() }, zone = utc)
        assertEquals(TimelinePhase.Loading, vm.phase.value)
    }

    @Test
    fun `load presents server oldest-first as newest-first, mirroring web reverse`() = runTest {
        val oldestFirst = listOf(
            event(at = 1L, label = "ran Bash"),
            event(at = 2L, cls = "waiting", label = "waiting for approval"),
            event(at = 3L, cls = "done", label = "finished"),
        )
        val vm = TimelineViewModel(fetch = { oldestFirst }, zone = utc)

        vm.load()

        val loaded = assertInstanceOf(TimelinePhase.Loaded::class.java, vm.phase.value)
        assertEquals(listOf("finished", "waiting for approval", "ran Bash"), loaded.rows.map { it.label })
        assertEquals(listOf(0, 1, 2), loaded.rows.map { it.key }) // stable positional keys.
    }

    @Test
    fun `over maxEvents caps to first 50 then reverses, exactly like web slice-then-reverse`() = runTest {
        val sixty = (1L..60L).map { event(at = it, label = "e$it") }
        val vm = TimelineViewModel(fetch = { sixty }, zone = utc)

        vm.load()

        val loaded = assertInstanceOf(TimelinePhase.Loaded::class.java, vm.phase.value)
        assertEquals(TimelineViewModel.MAX_EVENTS, loaded.rows.size)
        assertEquals("e50", loaded.rows.first().label) // newest of the first-50 slice.
        assertEquals("e1", loaded.rows.last().label) // oldest kept, at the tail after reverse.
    }

    @Test
    fun `maxEvents is pinned to the web parity value 50`() {
        assertEquals(50, TimelineViewModel.MAX_EVENTS)
    }

    // ── Empty state (never an error) ──────────────────────────────────────────────────────────────
    @Test
    fun `an empty array becomes Empty, never Failed`() = runTest {
        val vm = TimelineViewModel(fetch = { emptyList() }, zone = utc)

        vm.load()

        assertEquals(TimelinePhase.Empty, vm.phase.value)
    }

    @Test
    fun `a list of only unknown-class events filters to Empty`() = runTest {
        val onlyUnknown = listOf(event(at = 1L, cls = "future-class"), event(at = 2L, cls = ""))
        val vm = TimelineViewModel(fetch = { onlyUnknown }, zone = utc)

        vm.load()

        assertEquals(TimelinePhase.Empty, vm.phase.value)
    }

    // ── Lossy decode: drop unknown-class, keep the rest ───────────────────────────────────────────
    @Test
    fun `presentation drops unknown-class events and keeps the known ones`() {
        val mixed = listOf(
            event(at = 1L, cls = "tool", label = "keep-tool"),
            event(at = 2L, cls = "future-class", label = "drop-me"),
            event(at = 3L, cls = "user", label = "keep-user"),
            event(at = 4L, cls = "", label = "drop-blank"),
        )

        val phase = TimelineViewModel.presentation(mixed, utc)

        val loaded = assertInstanceOf(TimelinePhase.Loaded::class.java, phase)
        // Two known events survive; reversed → newest (user) first.
        assertEquals(listOf("keep-user", "keep-tool"), loaded.rows.map { it.label })
    }

    // ── Failure + retry ───────────────────────────────────────────────────────────────────────────
    @Test
    fun `a thrown fetch becomes Failed`() = runTest {
        val vm = TimelineViewModel(fetch = { throw RuntimeException("boom") }, zone = utc)

        vm.load()

        assertEquals(TimelinePhase.Failed, vm.phase.value)
    }

    @Test
    fun `retry after a failure recovers to Loaded`() = runTest {
        var firstCall = true
        val events = listOf(event(at = 7L, label = "recovered"))
        val vm = TimelineViewModel(
            fetch = {
                if (firstCall) {
                    firstCall = false
                    throw RuntimeException("transient")
                }
                events
            },
            zone = utc,
        )

        vm.load()
        assertEquals(TimelinePhase.Failed, vm.phase.value)

        vm.load() // the sheet's「重试」button path.

        val loaded = assertInstanceOf(TimelinePhase.Loaded::class.java, vm.phase.value)
        assertEquals(listOf("recovered"), loaded.rows.map { it.label })
    }

    // ── forSession assembly seam (digest「展开」entry point) ───────────────────────────────────────
    @Test
    fun `forSession with a null id returns null so no sheet is shown`() {
        assertNull(TimelineViewModel.forSession(null, source = { emptyList() }))
    }

    @Test
    fun `forSession passes the sessionId through to the events source on load`() = runTest {
        val expected = UUID.randomUUID()
        val source = FakeSource(events = listOf(event(at = 42L, label = "hit")))
        val vm = TimelineViewModel.forSession(expected, source = source::fetch)!!

        vm.load()

        assertEquals(listOf(expected), source.ids)
        val loaded = assertInstanceOf(TimelinePhase.Loaded::class.java, vm.phase.value)
        assertEquals(listOf("hit"), loaded.rows.map { it.label })
    }

    // ── class → glyph mapping (verbatim web timelineIcon) ─────────────────────────────────────────
    @Test
    fun `glyph mirrors the web timelineIcon set`() {
        assertEquals("🔧", TimelineEventStyle.glyph("tool"))
        assertEquals("⏳", TimelineEventStyle.glyph("waiting"))
        assertEquals("✓", TimelineEventStyle.glyph("done"))
        assertEquals("⚠", TimelineEventStyle.glyph("stuck"))
        assertEquals("💬", TimelineEventStyle.glyph("user"))
    }

    @Test
    fun `an unknown class degrades to the fallback glyph, never throwing`() {
        assertEquals(TimelineEventStyle.FALLBACK_GLYPH, TimelineEventStyle.glyph("future-class"))
        assertEquals(TimelineEventStyle.FALLBACK_GLYPH, TimelineEventStyle.glyph(""))
    }

    // ── class → color mapping (frozen A13/DesignSpec tokens; unknown → theme secondary = null) ─────
    @Test
    fun `colorSpec maps each known class to its frozen token and unknown to null`() {
        assertEquals(DesignSpec.TIMELINE_TOOL, TimelineEventStyle.colorSpec("tool"))
        assertEquals(DesignSpec.STATUS_WAITING, TimelineEventStyle.colorSpec("waiting"))
        assertEquals(DesignSpec.STATUS_WORKING, TimelineEventStyle.colorSpec("done"))
        assertEquals(DesignSpec.STATUS_STUCK, TimelineEventStyle.colorSpec("stuck"))
        assertEquals(DesignSpec.TIMELINE_USER, TimelineEventStyle.colorSpec("user"))
        assertNull(TimelineEventStyle.colorSpec("future-class")) // → theme-adaptive secondary text.
    }

    // ── HH:mm 24-hour formatting (mirror web formatHHMM), deterministic under a fixed zone ─────────
    @Test
    fun `timeLabel is 24-hour HH-mm and deterministic under a fixed timezone`() {
        val onePm = (13L * 3_600 + 5 * 60) * 1_000 // 1970-01-01 13:05 UTC
        assertEquals("00:00", TimelineRowFormat.timeLabel(0L, utc))
        assertEquals("13:05", TimelineRowFormat.timeLabel(onePm, utc))
    }

    // ── Copy: empty ≠ error, all present ──────────────────────────────────────────────────────────
    @Test
    fun `empty and error copy exist, are non-empty and distinct`() {
        assertTrue(TimelineCopy.TITLE.isNotEmpty())
        assertTrue(TimelineCopy.EMPTY_TITLE.isNotEmpty())
        assertTrue(TimelineCopy.LOAD_FAILED.isNotEmpty())
        assertTrue(TimelineCopy.RETRY.isNotEmpty())
        assertTrue(TimelineCopy.EMPTY_TITLE != TimelineCopy.LOAD_FAILED)
    }
}
