package wang.yaojia.webterm.session

import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
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.testsupport.FakeConnectionPinger
import wang.yaojia.webterm.wire.Tunables

class PingSchedulerTest {

    /** Fire exactly one ping interval on the virtual clock (mirrors the repo's `tick` helper). */
    private fun TestScope.advanceOneInterval() {
        testScheduler.advanceTimeBy(Tunables.PING_INTERVAL)
        testScheduler.runCurrent()
    }

    @Test
    fun `sends one ping per interval on virtual time`() = runTest {
        val pinger = FakeConnectionPinger() // defaultPong = true -> every ping answers
        val scheduler = PingScheduler()

        val job = launch { scheduler.run { pinger.ping() } }

        assertEquals(0, pinger.pingCallCount) // nothing before the first interval elapses
        advanceOneInterval()
        assertEquals(1, pinger.pingCallCount)
        advanceOneInterval()
        assertEquals(2, pinger.pingCallCount)

        job.cancel()
    }

    @Test
    fun `two consecutive missed pongs declare the connection lost`() = runTest {
        val pinger = FakeConnectionPinger()
        pinger.scriptPongs(false, false) // the first two pings both miss
        val scheduler = PingScheduler()

        var outcome: PingScheduler.Outcome? = null
        launch { outcome = scheduler.run { pinger.ping() } }

        advanceOneInterval() // miss #1 — tolerated
        assertNull(outcome)

        advanceOneInterval() // miss #2 — dead
        assertEquals(PingScheduler.Outcome.CONNECTION_LOST, outcome)
    }

    @Test
    fun `an answered pong resets the consecutive miss counter so isolated misses survive`() = runTest {
        val pinger = FakeConnectionPinger()
        // miss, pong, miss, pong, miss, pong — never two misses in a row.
        pinger.scriptPongs(false, true, false, true, false, true)
        val scheduler = PingScheduler()

        var outcome: PingScheduler.Outcome? = null
        val job = launch { outcome = scheduler.run { pinger.ping() } }

        repeat(6) { advanceOneInterval() }

        assertNull(outcome) // three isolated misses, each reset — still alive
        assertEquals(6, pinger.pingCallCount)

        job.cancel()
    }

    @Test
    fun `cancelling the run job propagates cancellation instead of returning a value`() = runTest {
        val pinger = FakeConnectionPinger() // always pong -> the loop never ends on its own
        val scheduler = PingScheduler()

        var returned: PingScheduler.Outcome? = null
        var completedNormally = false
        val job = launch {
            returned = scheduler.run { pinger.ping() }
            completedNormally = true // only reached if run() swallowed cancellation and returned
        }

        advanceOneInterval() // one ping fires; the loop is now suspended on the next delay
        assertEquals(1, pinger.pingCallCount)

        job.cancel()
        job.join()

        // The fix: CancellationException propagates out of run() — it does NOT resume the loop and
        // return an Outcome. Swallowing (the old bug) would set both of these.
        assertTrue(job.isCancelled, "cancellation must propagate out of run()")
        assertFalse(completedNormally, "run() must not resume and return after being cancelled")
        assertNull(returned, "run() must not swallow cancellation into an Outcome value")

        // No further pings after cancellation, even as virtual time marches on.
        testScheduler.advanceTimeBy(Tunables.PING_INTERVAL * 3)
        testScheduler.runCurrent()
        assertEquals(1, pinger.pingCallCount)
    }

    @Test
    fun `a custom interval paces the pings`() = runTest {
        val pinger = FakeConnectionPinger()
        val scheduler = PingScheduler(interval = Tunables.PING_INTERVAL * 2)

        val job = launch { scheduler.run { pinger.ping() } }

        // One default interval is NOT enough for the doubled period.
        testScheduler.advanceTimeBy(Tunables.PING_INTERVAL)
        testScheduler.runCurrent()
        assertEquals(0, pinger.pingCallCount)

        // A second default interval reaches the custom period.
        testScheduler.advanceTimeBy(Tunables.PING_INTERVAL)
        testScheduler.runCurrent()
        assertEquals(1, pinger.pingCallCount)

        job.cancel()
    }
}
