package wang.yaojia.webterm.api.pairing

import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.PortUnreachableException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import java.security.cert.CertPathBuilderException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException

/**
 * Error taxonomy for the pairing probe (Android port of iOS `APIClient.PairingError`). Each case
 * maps one probe failure mode to actionable UI copy (`PairingViewModel`, A19, renders these).
 *
 * DEVIATION from iOS (plan R12): the iOS `localNetworkDenied` case is DROPPED — Android has no
 * iOS-style Local-Network permission prompt (a plain WS to a LAN IP needs no runtime permission),
 * so the ENETDOWN→localNetworkDenied diagnosis is dead weight here. iOS `atsBlocked` is kept as
 * [CleartextBlocked]: the genuine Android analogue is a `network_security_config` cleartext block
 * surfaced as [java.net.UnknownServiceException] (plan §6.9/§8 cleartext posture).
 */
public sealed interface PairingError {
    /** Nothing answered — connection refused / no route / DNS failure (`ConnectException`, `UnknownHostException`, …). */
    public data class HostUnreachable(val underlying: String) : PairingError

    /**
     * The port speaks HTTP but `GET /live-sessions` did not return the web-terminal shape (a JSON
     * array) — "端口对吗?". Also used when probe ② connects but the socket never speaks our protocol.
     */
    public data object HttpOkButNotWebTerminal : PairingError

    /**
     * The WS upgrade (or the guarded kill round-trip) was rejected — the host's Origin whitelist
     * does not contain our dialed origin. [hint] carries the exact `ALLOWED_ORIGINS=<origin>` line,
     * always derived from [HostEndpoint.originHeader] (never hand-assembled; default ports omitted).
     */
    public data class OriginRejected(val hint: String) : PairingError

    /**
     * Cleartext (`ws://`/`http://`) to a host outside the app's `network_security_config` allowlist
     * was blocked by the platform ([java.net.UnknownServiceException]). Android analogue of iOS
     * `atsBlocked`. [host] is the dialed host for the actionable copy.
     */
    public data class CleartextBlocked(val host: String) : PairingError

    /** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
    public data object TlsFailure : PairingError

    /** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
    public data object Timeout : PairingError

    public companion object {
        /**
         * Actionable copy for [OriginRejected] — always derived from the SINGLE origin source
         * ([HostEndpoint.originHeader]), never hand-assembled. Ported verbatim from iOS.
         */
        public fun originRejectedHint(endpoint: HostEndpoint): String =
            "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=${endpoint.originHeader}" +
                "（与 App 连接的 URL 完全一致）后重启 web-terminal，再重试配对。"

        /**
         * Classify a transport-level [Throwable] thrown by [wang.yaojia.webterm.wire.HttpTransport]
         * or [wang.yaojia.webterm.wire.TermTransport] into the probe taxonomy. Walks the bounded
         * `cause` chain (OkHttp wraps causes; never trust an error graph not to cycle) so wrapped
         * roots (e.g. an `IOException` wrapping a `ConnectException`) are seen.
         *
         * @param unrecognizedFallback used by probe step ② — after step ① proved the host reachable
         *   AND web-terminal-shaped, an upgrade failure with no recognizable network cause is, by
         *   elimination, the server's Origin 401 (its only upgrade-reject path).
         */
        public fun classify(
            error: Throwable,
            endpoint: HostEndpoint,
            unrecognizedFallback: PairingError? = null,
        ): PairingError {
            val chain = causeChain(error)
            if (chain.any { it is UnknownServiceException }) {
                return CleartextBlocked(host = HostClassifier.hostOf(endpoint))
            }
            if (chain.any { isTimeout(it) }) return Timeout
            if (chain.any { isTls(it) }) return TlsFailure
            if (chain.any { isConnectivity(it) }) {
                return HostUnreachable(underlying = describe(error))
            }
            return unrecognizedFallback ?: HostUnreachable(underlying = describe(error))
        }

        private const val MAX_CAUSE_DEPTH = 8

        /** Bounded walk of [Throwable.cause] with an identity cycle-guard (defensive). */
        private fun causeChain(error: Throwable): List<Throwable> {
            val chain = mutableListOf<Throwable>()
            var current: Throwable? = error
            while (current != null && chain.size < MAX_CAUSE_DEPTH) {
                if (chain.any { it === current }) break
                chain.add(current)
                current = current.cause
            }
            return chain
        }

        // `SocketTimeoutException` extends `InterruptedIOException`; OkHttp's overall call-timeout
        // also surfaces as a bare `InterruptedIOException` — both mean "timed out".
        private fun isTimeout(e: Throwable): Boolean = e is InterruptedIOException

        private fun isTls(e: Throwable): Boolean =
            e is SSLException ||
                e is CertificateException ||
                e is CertPathValidatorException ||
                e is CertPathBuilderException

        // `ConnectException` / `NoRouteToHostException` / `PortUnreachableException` all extend
        // `SocketException`; listed explicitly for readability. `UnknownHostException` is a DNS
        // failure (extends `IOException`, not `SocketException`).
        private fun isConnectivity(e: Throwable): Boolean =
            e is ConnectException ||
                e is NoRouteToHostException ||
                e is PortUnreachableException ||
                e is SocketException ||
                e is UnknownHostException

        private fun describe(error: Throwable): String =
            error.message ?: error::class.simpleName ?: "unknown"
    }
}
