package com.fabledsword.minstrel.connectivity

import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class ReachabilityMachineTest {

    private fun machine() = ReachabilityMachine()

    @Test
    fun `starts healthy`() {
        val m = machine()
        m.onLinkChange(up = true)
        assertEquals(ServerHealth.Healthy, m.health())
    }

    @Test
    fun `no link is offline regardless of probes`() {
        val m = machine()
        m.onLinkChange(up = false)
        m.onProbeFailure(nowMs = 1)
        assertEquals(ServerHealth.Offline, m.health())
    }

    @Test
    fun `single probe failure is unstable not down`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onProbeFailure(nowMs = 1_000)
        assertEquals(ServerHealth.Unstable, m.health())
    }

    @Test
    fun `probe success from unstable recovers to healthy`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onProbeFailure(nowMs = 1_000)
        m.onSuccess()
        assertEquals(ServerHealth.Healthy, m.health())
    }

    @Test
    fun `op success from unstable recovers to healthy`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onProbeFailure(nowMs = 1_000)
        m.onSuccess() // a successful stream read / API 2xx is self-proving
        assertEquals(ServerHealth.Healthy, m.health())
    }

    @Test
    fun `two SPACED op failures plus a failed probe escalate immediately`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onOpFailure(nowMs = 1_000)
        // Spacing matters as of #1209: these must be far enough apart to be
        // separate evidence rather than one event's worth of fallout. This
        // test previously used 1_500 — 500ms — which is now deliberately
        // treated as a burst and does NOT corroborate.
        m.onOpFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS)
        m.onProbeFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS + 500)
        assertEquals(ServerHealth.ServerDown, m.health())
    }

    // The #1209 mechanism: an OS network handoff fails every in-flight request
    // at once. That must NOT reach ServerDown, because ServerDown makes
    // OfflineGatedDataSource refuse uncached tracks outright — the app would
    // decline to play music that plays fine, for a blip already over.
    @Test
    fun `a burst of op failures does not corroborate itself into ServerDown`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onOpFailure(nowMs = 1_000)
        m.onOpFailure(nowMs = 1_050)
        m.onOpFailure(nowMs = 1_100)
        m.onOpFailure(nowMs = 1_200)
        m.onProbeFailure(nowMs = 1_500)
        // Unstable is non-gating, so playback keeps working.
        assertEquals(ServerHealth.Unstable, m.health())
    }

    @Test
    fun `a burst still escalates via the sustained backstop if it never recovers`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onOpFailure(nowMs = 1_000)
        m.onOpFailure(nowMs = 1_050)
        m.onProbeFailure(nowMs = 1_500) // unstable, streak starts here
        // Dropping burst duplicates must not make a REAL outage undetectable —
        // the time backstop is what guarantees escalation either way.
        m.onProbeFailure(nowMs = 1_500 + ESCALATE_AFTER_MS)
        assertEquals(ServerHealth.ServerDown, m.health())
    }

    @Test
    fun `op failures with a successful probe stay healthy (track-specific)`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onOpFailure(nowMs = 1_000)
        m.onOpFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS)
        m.onSuccess() // arbiter says server is fine
        assertEquals(ServerHealth.Healthy, m.health())
    }

    @Test
    fun `stale op failures do not corroborate`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onOpFailure(nowMs = 0)
        // Spaced so this test exercises STALENESS, not the burst rule — with
        // 1_000 it would have passed for the wrong reason after #1209.
        m.onOpFailure(nowMs = CORROBORATION_MIN_SPACING_MS)
        // both op failures are now older than the corroboration window:
        m.onProbeFailure(nowMs = CORROBORATION_MIN_SPACING_MS + CORROBORATION_WINDOW_MS + 1)
        assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
    }

    @Test
    fun `sustained failure backstop escalates after the window`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onProbeFailure(nowMs = 1_000) // unstable, streak starts
        m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop
        assertEquals(ServerHealth.ServerDown, m.health())
    }

    @Test
    fun `link restored keeps last-known down until a fresh result`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onProbeFailure(nowMs = 1_000)
        m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown
        m.onLinkChange(up = false)
        assertEquals(ServerHealth.Offline, m.health())
        m.onLinkChange(up = true)
        // link back but no fresh probe result yet — last known reachability was down:
        assertEquals(ServerHealth.ServerDown, m.health())
        m.onSuccess()
        assertEquals(ServerHealth.Healthy, m.health())
    }

    @Test
    fun `unstable does not gate — it is not offline or serverdown`() {
        val m = machine()
        m.onLinkChange(up = true)
        m.onProbeFailure(nowMs = 1_000)
        val health = m.health()
        assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown)
        assertEquals(ServerHealth.Unstable, health)
    }
}
