package wang.yaojia.webterm.api.pairing

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.wire.HostEndpoint
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException

/** Re-derives iOS `PairingError.classify` for JVM exceptions (plan R12: drop localNetworkDenied). */
class PairingErrorTest {
    private val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))

    @Test
    fun connectionRefusedMapsToHostUnreachable() {
        val result = PairingError.classify(ConnectException("Connection refused"), lan)
        assertInstanceOf(PairingError.HostUnreachable::class.java, result)
    }

    @Test
    fun dnsFailureMapsToHostUnreachable() {
        val result = PairingError.classify(UnknownHostException("no such host"), lan)
        assertInstanceOf(PairingError.HostUnreachable::class.java, result)
    }

    @Test
    fun socketTimeoutMapsToTimeout() {
        assertEquals(PairingError.Timeout, PairingError.classify(SocketTimeoutException("read timed out"), lan))
    }

    @Test
    fun interruptedIoMapsToTimeout() {
        // OkHttp's overall call-timeout surfaces as a bare InterruptedIOException.
        assertEquals(PairingError.Timeout, PairingError.classify(InterruptedIOException("timeout"), lan))
    }

    @Test
    fun sslHandshakeFailureMapsToTlsFailure() {
        assertEquals(PairingError.TlsFailure, PairingError.classify(SSLHandshakeException("handshake_failure"), lan))
    }

    @Test
    fun genericSslAndCertFailuresMapToTlsFailure() {
        assertEquals(PairingError.TlsFailure, PairingError.classify(SSLException("tls"), lan))
        assertEquals(PairingError.TlsFailure, PairingError.classify(CertificateException("bad cert"), lan))
    }

    @Test
    fun cleartextBlockMapsToCleartextBlockedWithHost() {
        val err = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted")
        val result = PairingError.classify(err, lan)
        assertInstanceOf(PairingError.CleartextBlocked::class.java, result)
        assertEquals("192.168.1.5", (result as PairingError.CleartextBlocked).host)
    }

    @Test
    fun wrappedCauseIsWalked() {
        // OkHttp frequently wraps the real cause; the chain walk must see it.
        val wrappedConnect = IOException("unexpected end of stream", ConnectException("refused"))
        assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(wrappedConnect, lan))

        val wrappedTls = IOException("io", SSLHandshakeException("handshake"))
        assertEquals(PairingError.TlsFailure, PairingError.classify(wrappedTls, lan))
    }

    @Test
    fun unrecognizedErrorUsesFallbackWhenProvided() {
        // Probe step ②: after ① passed, an unclassifiable connect failure is the Origin 401.
        val fallback = PairingError.OriginRejected("hint")
        assertEquals(fallback, PairingError.classify(IllegalStateException("weird"), lan, unrecognizedFallback = fallback))
    }

    @Test
    fun unrecognizedErrorWithoutFallbackIsHostUnreachable() {
        assertInstanceOf(
            PairingError.HostUnreachable::class.java,
            PairingError.classify(IllegalStateException("weird"), lan),
        )
    }

    @Test
    fun causeCycleTerminates() {
        // A→B→A cycle must not loop forever; B is a ConnectException so it classifies as reachable-failure.
        val a = IOException("a")
        val b = ConnectException("b")
        a.initCause(b)
        b.initCause(a)
        assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(a, lan))
    }

    @Test
    fun originRejectedHintDerivesFromOriginHeaderWithoutDefaultPort() {
        val lanHint = PairingError.originRejectedHint(lan)
        assertTrue(lanHint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))

        val tls = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
        val tlsHint = PairingError.originRejectedHint(tls)
        assertTrue(tlsHint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
        assertFalse(tlsHint.contains(":443"), "default https port must be omitted (no :443 迷信)")
    }
}
