import Foundation
import WireProtocol

/// Error taxonomy for the pairing probe (frozen contract, plan §3.4
/// `[S:hybrid]`) — each case maps one probe failure mode to actionable UI copy
/// (T-iOS-12 renders these inline).
public enum PairingError: Error, Equatable, Sendable {
    /// iOS Local Network permission denied: connects fail with POSIX
    /// "Network is down" (ENETDOWN) while the target is a LAN/private host
    /// (plan §5.2). UI: deep-link to 设置 → 隐私 → 本地网络.
    case localNetworkDenied
    /// Nothing answered (connection refused / no route / DNS failure…).
    case hostUnreachable(underlying: String)
    /// The port speaks HTTP but `GET /live-sessions` did not return the
    /// web-terminal shape (e.g. an HTML admin page) — "端口对吗?".
    case httpOkButNotWebTerminal
    /// 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=<scheme>://<拨号 host>[:port]` line
    /// (default ports omitted — the server normalizes both sides via
    /// `new URL()`, so there is NO ":443 迷信", plan §5.1).
    case originRejected(hint: String)
    /// ATS blocked cleartext HTTP to a host outside the app's CIDR exception
    /// list (NSURLError -1022, plan §5.2).
    case atsBlocked(host: String)
    /// TLS negotiation / certificate failure on an https/wss target.
    case tlsFailure
    /// The injected probe deadline elapsed (or the transport timed out).
    case timeout
}

extension PairingError {
    /// Actionable copy for `originRejected` — always derived from the SINGLE
    /// origin source (`endpoint.originHeader`), never hand-assembled.
    static func originRejectedHint(for endpoint: HostEndpoint) -> String {
        "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=\(endpoint.originHeader)"
            + "（与 App 连接的 URL 完全一致）后重启 web-terminal，再重试配对。"
    }

    /// Classify a transport-level error thrown by `HTTPTransport.send` /
    /// `TermTransport.connect` into the probe taxonomy. Walks the NSError
    /// underlying chain (bounded) so wrapped POSIX causes are seen.
    ///
    /// - Parameter 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, src/server.ts:646-651).
    static func classify(
        _ error: any Error,
        endpoint: HostEndpoint,
        unrecognizedFallback: PairingError? = nil
    ) -> PairingError {
        let chain = underlyingChain(of: error as NSError)
        let host = endpoint.baseURL.host ?? ""
        if chain.contains(where: isATSBlockError) {
            return .atsBlocked(host: host)
        }
        if chain.contains(where: isTimeoutError) {
            return .timeout
        }
        if chain.contains(where: isTLSError) {
            return .tlsFailure
        }
        if chain.contains(where: isPosixNetworkDownError) {
            // plan §5.2: Local-Network-denied presents as POSIX "Network is
            // down" — but only claim it for LAN-class targets.
            return isPrivateOrLocalHost(host)
                ? .localNetworkDenied
                : .hostUnreachable(underlying: describe(error))
        }
        if chain.contains(where: isConnectivityError) {
            return .hostUnreachable(underlying: describe(error))
        }
        return unrecognizedFallback ?? .hostUnreachable(underlying: describe(error))
    }

    // MARK: - Error-shape predicates

    private static func isATSBlockError(_ error: NSError) -> Bool {
        error.domain == NSURLErrorDomain
            && error.code == NSURLErrorAppTransportSecurityRequiresSecureConnection
    }

    private static func isTimeoutError(_ error: NSError) -> Bool {
        (error.domain == NSURLErrorDomain && error.code == NSURLErrorTimedOut)
            || (error.domain == NSPOSIXErrorDomain
                && error.code == Int(POSIXErrorCode.ETIMEDOUT.rawValue))
    }

    private static let tlsErrorCodes: Set<Int> = [
        NSURLErrorSecureConnectionFailed,
        NSURLErrorServerCertificateHasBadDate,
        NSURLErrorServerCertificateUntrusted,
        NSURLErrorServerCertificateHasUnknownRoot,
        NSURLErrorServerCertificateNotYetValid,
        NSURLErrorClientCertificateRejected,
        NSURLErrorClientCertificateRequired,
    ]

    private static func isTLSError(_ error: NSError) -> Bool {
        error.domain == NSURLErrorDomain && tlsErrorCodes.contains(error.code)
    }

    private static func isPosixNetworkDownError(_ error: NSError) -> Bool {
        error.domain == NSPOSIXErrorDomain
            && error.code == Int(POSIXErrorCode.ENETDOWN.rawValue)
    }

    private static let connectivityURLErrorCodes: Set<Int> = [
        NSURLErrorCannotConnectToHost,
        NSURLErrorCannotFindHost,
        NSURLErrorDNSLookupFailed,
        NSURLErrorNetworkConnectionLost,
        NSURLErrorNotConnectedToInternet,
    ]

    private static let connectivityPosixCodes: Set<Int> = [
        Int(POSIXErrorCode.ECONNREFUSED.rawValue),
        Int(POSIXErrorCode.EHOSTUNREACH.rawValue),
        Int(POSIXErrorCode.EHOSTDOWN.rawValue),
        Int(POSIXErrorCode.ENETUNREACH.rawValue),
    ]

    private static func isConnectivityError(_ error: NSError) -> Bool {
        (error.domain == NSURLErrorDomain && connectivityURLErrorCodes.contains(error.code))
            || (error.domain == NSPOSIXErrorDomain && connectivityPosixCodes.contains(error.code))
    }

    // MARK: - Helpers

    /// Bounded walk of `NSUnderlyingErrorKey` (defensive: never trust error
    /// graphs not to cycle).
    private static let maxUnderlyingDepth = 8

    private static func underlyingChain(of error: NSError) -> [NSError] {
        var chain: [NSError] = []
        var current: NSError? = error
        while let nsError = current, chain.count < maxUnderlyingDepth {
            chain.append(nsError)
            current = nsError.userInfo[NSUnderlyingErrorKey] as? NSError
        }
        return chain
    }

    private static func describe(_ error: any Error) -> String {
        (error as NSError).localizedDescription
    }

    /// LAN-class targets for the `localNetworkDenied` mapping — a VIEW over
    /// `HostClassifier` (T-iOS-38 dedup; plan §5.4 tiers are the single
    /// classification source):
    /// - `.loopback` / `.privateLAN` → true (Local-Network-permission gated);
    /// - `.tailscale` → true only for CGNAT 100.64/10 IPs dialed on a local
    ///   interface; MagicDNS `*.ts.net` rides the WireGuard tunnel, so an
    ///   ENETDOWN there is NOT the Local Network permission → false;
    /// - `.public` → false (never misdiagnose 权限被拒 for public targets).
    static func isPrivateOrLocalHost(_ host: String) -> Bool {
        switch HostClassifier.classify(host: host) {
        case .loopback, .privateLAN:
            return true
        case .tailscale:
            return HostClassifier.ipv4Octets(host.lowercased()) != nil
        case .public:
            return false
        }
    }
}
