package wang.yaojia.webterm.api.pairing

import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import javax.net.ssl.SSLHandshakeException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

/**
 * Ports iOS `PairingProbeTests`: the two-step probe (① GET reachability/shape, ② WS attach→adopt→
 * kill-with-Origin), each failure mode mapped to a [PairingError], full-success leaves no orphan
 * session, and the injected-deadline timeout — all under `runTest` virtual time (zero real waits).
 */
class PairingProbeTest {
    private data class Fixture(
        val endpoint: HostEndpoint,
        val http: FakeHttpTransport,
        val ws: FakeTermTransport,
        val liveSessionsUrl: String,
        val killUrl: String,
    )

    private fun fixture(base: String = BASE): Fixture {
        val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(base))
        return Fixture(
            endpoint = endpoint,
            http = FakeHttpTransport(),
            ws = FakeTermTransport(),
            liveSessionsUrl = "$base/live-sessions",
            killUrl = "$base/live-sessions/$SESSION_ID",
        )
    }

    /** Non-timeout cases use `timeout = null` so the probe never enters the race — deterministic. */
    private suspend fun runProbe(f: Fixture, timeout: Duration? = null): PairingProbeResult =
        runPairingProbeCore(f.endpoint, f.http, f.ws, timeout)

    private fun failureError(result: PairingProbeResult): PairingError {
        assertInstanceOf(PairingProbeResult.Failure::class.java, result)
        return (result as PairingProbeResult.Failure).error
    }

    // ── Probe ① failure branches ────────────────────────────────────────────────────────────────

    @Test
    fun stepOneConnectionRefusedMapsToHostUnreachableAndNeverTouchesWs() = runTest {
        val f = fixture()
        f.http.queueFailure(url = f.liveSessionsUrl, error = ConnectException("refused"))

        val result = runProbe(f)

        assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(result))
        assertTrue(f.ws.connectAttempts.isEmpty(), "probe must not touch WS when ① fails")
    }

    @Test
    fun stepOneHtmlBodyMapsToNotWebTerminal() = runTest {
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "<html><body>router admin</body></html>".toByteArray())

        assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
    }

    @Test
    fun stepOne404MapsToNotWebTerminal() = runTest {
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, status = 404)

        assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
    }

    @Test
    fun stepOneNonArrayJsonMapsToNotWebTerminal() = runTest {
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "{\"ok\":true}".toByteArray())

        assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
    }

    @Test
    fun stepOneTlsFailureMapsToTlsFailure() = runTest {
        val f = fixture()
        f.http.queueFailure(url = f.liveSessionsUrl, error = SSLHandshakeException("handshake_failure"))

        assertEquals(PairingError.TlsFailure, failureError(runProbe(f)))
    }

    @Test
    fun stepOneTransportTimeoutMapsToTimeout() = runTest {
        val f = fixture()
        f.http.queueFailure(url = f.liveSessionsUrl, error = SocketTimeoutException("read timed out"))

        assertEquals(PairingError.Timeout, failureError(runProbe(f)))
    }

    @Test
    fun stepOneCleartextBlockMapsToCleartextBlockedWithHost() = runTest {
        val f = fixture()
        f.http.queueFailure(
            url = f.liveSessionsUrl,
            error = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted"),
        )

        val error = failureError(runProbe(f))
        assertInstanceOf(PairingError.CleartextBlocked::class.java, error)
        assertEquals("192.168.1.5", (error as PairingError.CleartextBlocked).host)
    }

    @Test
    fun stepOneDnsFailureMapsToHostUnreachable() = runTest {
        val f = fixture()
        f.http.queueFailure(url = f.liveSessionsUrl, error = UnknownHostException("nxdomain"))

        assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(runProbe(f)))
    }

    // ── Probe ② failure branches ────────────────────────────────────────────────────────────────

    @Test
    fun stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() = runTest {
        // ① passes; ② upgrade fails (a 401 is a shapeless connect error at the transport layer).
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
        f.ws.scriptConnectFailure()

        val error = failureError(runProbe(f))
        assertInstanceOf(PairingError.OriginRejected::class.java, error)
        val hint = (error as PairingError.OriginRejected).hint
        assertTrue(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"), hint)
        assertFalse(hint.contains(":443"), hint)
    }

    @Test
    fun originRejectedHintOmitsDefaultPortForHttps() = runTest {
        val f = fixture(base = "https://mac.tailnet.ts.net")
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
        f.ws.scriptConnectFailure()

        val error = failureError(runProbe(f))
        assertInstanceOf(PairingError.OriginRejected::class.java, error)
        val hint = (error as PairingError.OriginRejected).hint
        assertTrue(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"), hint)
        assertFalse(hint.contains(":443"), hint)
    }

    @Test
    fun stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() = runTest {
        // A queued finish is flushed into the connection at connect → stream closes before `attached`.
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
        f.ws.finishFrames()

        assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
    }

    // ── Full pass + kill round-trip ──────────────────────────────────────────────────────────────

    @Test
    fun fullProbeSuccessAttachesWithNullSessionIdThenKillsWithOrigin() = runTest {
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
        f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
        f.ws.emit(ATTACHED_FRAME)

        val result = runProbe(f)

        // Success payload is the probed endpoint.
        assertEquals(PairingProbeResult.Success(f.endpoint), result)

        // attach(null) is the only WS frame, with an explicit JSON null sessionId key.
        assertEquals(1, f.ws.sentFrames.size)
        val attach = Json.parseToJsonElement(f.ws.sentFrames.single()).jsonObject
        assertEquals("attach", attach["type"]?.jsonPrimitive?.content)
        assertTrue(attach["sessionId"] is JsonNull, "sessionId key must be present and JSON null")

        // Exactly two HTTP requests: RO GET (NO Origin) then guarded DELETE (byte-equal Origin).
        val requests = f.http.recordedRequests
        assertEquals(2, requests.size)
        assertFalse(requests.first().headers.containsKey("Origin"), "RO GET must not carry Origin")
        val kill = requests.last()
        assertEquals(HttpMethod.DELETE, kill.method)
        assertEquals(f.killUrl, kill.url)
        assertEquals(f.endpoint.originHeader, kill.headers["Origin"])

        // The probe never holds the connection.
        assertEquals(1, f.ws.closeCallCount)
    }

    @Test
    fun publicWrapperHappyPathReturnsEndpoint() = runTest {
        // The public entry uses the default Tunables deadline; immediate fakes never trip it.
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
        f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
        f.ws.emit(ATTACHED_FRAME)

        val result = runPairingProbe(f.endpoint, f.http, f.ws)

        assertEquals(PairingProbeResult.Success(f.endpoint), result)
        assertEquals(1, f.ws.closeCallCount)
    }

    @Test
    fun outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() = runTest {
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
        f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
        f.ws.emit("""{"type":"output","data":"[0mreplay"}""")
        f.ws.emit(ATTACHED_FRAME)

        assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
    }

    @Test
    fun killRejectedByOriginGuardMapsToOriginRejected() = runTest {
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
        f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 403)
        f.ws.emit(ATTACHED_FRAME)

        assertInstanceOf(PairingError.OriginRejected::class.java, failureError(runProbe(f)))
    }

    @Test
    fun killReturning404StillCountsAsSuccess() = runTest {
        // Session exited between attach and kill — the goal state (no orphan) is already reached.
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
        f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 404)
        f.ws.emit(ATTACHED_FRAME)

        assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
    }

    // ── Timeout (virtual time, zero real waits) ──────────────────────────────────────────────────

    @Test
    fun probeTimesOutWhenServerNeverSendsAttached() = runTest {
        // ① passes; ② connects but the server never replies `attached` → the probe hangs on the
        // frame stream until the injected deadline cancels it.
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())

        val result = runProbe(f, timeout = 10.seconds)

        assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
    }

    @Test
    fun timeoutPathAlwaysClosesTheConnectionExactlyOnce() = runTest {
        // ① passes; ② connects but the server never sends `attached` → the probe hangs on the frame
        // stream until the injected deadline cancels it MID-adopt. The connection must still close
        // (try/finally + NonCancellable), or the probe leaks the WS + its orphan session.
        val f = fixture()
        f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())

        val result = runProbe(f, timeout = 10.seconds)

        assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
        assertEquals(1, f.ws.closeCallCount, "the probe must close the WS even on the timeout path")
    }

    private companion object {
        const val BASE = "http://192.168.1.5:3000"
        const val SESSION_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
        const val ATTACHED_FRAME = """{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"""
    }
}
