package wang.yaojia.webterm.api.pairing

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.MessageCodec
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.TermTransport
import wang.yaojia.webterm.wire.TransportConnection
import wang.yaojia.webterm.wire.Tunables
import java.net.URI
import kotlin.time.Duration

/**
 * Result of a pairing probe — the validated [HostEndpoint] on success, a [PairingError] on failure.
 * Android analogue of iOS `Result<HostEndpoint, PairingError>`. The pairing UI (A19) constructs the
 * persisted `Host{id,name}` from the returned endpoint (id/name are not the probe's to know).
 */
public sealed interface PairingProbeResult {
    public data class Success(val endpoint: HostEndpoint) : PairingProbeResult

    public data class Failure(val error: PairingError) : PairingProbeResult
}

/**
 * Public probe entry (Android port of iOS `runPairingProbe`). Two-step probe:
 *  1. `GET /live-sessions` (NO Origin) — reachability + web-terminal shape.
 *  2. WS `attach(null)` → adopt the server-issued `attached` id → close → **immediately
 *     `DELETE /live-sessions/:id` WITH Origin** (verifies the Origin guard on both the upgrade and
 *     the guarded-HTTP side, and never leaks the probe's orphan session).
 *
 * **Confirm-before-network contract:** callers MUST run this only after the user confirmed a
 * scanned/typed host (A19). Step ① already talks to the network and step ② spawns a PTY on the
 * target machine — nothing here is speculative, so the UI gate is the caller's responsibility.
 *
 * The wall-clock deadline is [Tunables.PAIRING_PROBE_TIMEOUT]; tests drive [runPairingProbeCore]
 * with an explicit (or `null`) timeout for deterministic virtual-time coverage.
 */
public suspend fun runPairingProbe(
    endpoint: HostEndpoint,
    http: HttpTransport,
    ws: TermTransport,
): PairingProbeResult =
    runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)

/**
 * Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
 * still apply — fast, race-free tests); otherwise the whole probe is cancelled past the deadline and
 * resolves [PairingError.Timeout]. Cancellation is the coroutine analogue of iOS's `group.cancelAll`.
 */
internal suspend fun runPairingProbeCore(
    endpoint: HostEndpoint,
    http: HttpTransport,
    ws: TermTransport,
    timeout: Duration?,
): PairingProbeResult {
    if (timeout == null) return performProbe(endpoint, http, ws)
    return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
        ?: PairingProbeResult.Failure(PairingError.Timeout)
}

// ── Probe body ────────────────────────────────────────────────────────────────────────────────

private suspend fun performProbe(
    endpoint: HostEndpoint,
    http: HttpTransport,
    ws: TermTransport,
): PairingProbeResult {
    // ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
    //    means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
    probeReachability(endpoint, http)?.let { return it }

    // ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
    //    unrecognizable connect failure is classified as originRejected.
    val connection: TransportConnection = try {
        ws.connect(endpoint)
    } catch (cancel: CancellationException) {
        throw cancel
    } catch (error: Throwable) {
        return PairingProbeResult.Failure(
            PairingError.classify(
                error,
                endpoint,
                unrecognizedFallback = PairingError.OriginRejected(
                    PairingError.originRejectedHint(endpoint),
                ),
            ),
        )
    }

    // Once connected, the connection MUST be closed on every exit path — including the timeout/cancel
    // path, where withTimeoutOrNull cancels us mid-`firstOrNull`. Without this finally, a cancel skips
    // close() and leaks the WS + its orphan PTY session on the host. NonCancellable so the close still
    // runs while we are already being cancelled.
    return try {
        when (val adoption = adoptAttachedSession(connection)) {
            is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
            is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
        }
    } finally {
        withContext(NonCancellable) { runCatching { connection.close() } }
    }
}

/**
 * Probe step ①. Returns a [PairingProbeResult.Failure] to short-circuit, or `null` to proceed.
 * Reachable + web-terminal-shaped = HTTP 200 with a JSON-array body (an HTML admin page, a 404, or
 * a non-array body are all "not web-terminal").
 */
private suspend fun probeReachability(
    endpoint: HostEndpoint,
    http: HttpTransport,
): PairingProbeResult.Failure? {
    val response = try {
        http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
    } catch (cancel: CancellationException) {
        throw cancel
    } catch (error: Throwable) {
        return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
    }
    if (response.status != HTTP_OK || !isJsonArray(response.body)) {
        return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
    }
    return null
}

/**
 * Send `attach(null)` (explicit JSON `"sessionId":null` via [MessageCodec]) and wait for the
 * server-issued `attached` id, skipping any other or undecodable frame (untrusted server; only
 * `attached` matters here). A stream that ends or errors before speaking our protocol is NOT an
 * Origin problem — the upgrade already succeeded — so it maps to [PairingError.HttpOkButNotWebTerminal].
 */
private suspend fun adoptAttachedSession(connection: TransportConnection): Adoption =
    try {
        connection.send(MessageCodec.encode(ClientMessage.Attach(sessionId = null)))
        val sessionId = connection.frames
            .mapNotNull { frame -> (MessageCodec.decodeServer(frame) as? ServerMessage.Attached)?.sessionId }
            .firstOrNull()
        if (sessionId != null) Adoption.Success(sessionId) else Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
    } catch (cancel: CancellationException) {
        throw cancel
    } catch (_: Throwable) {
        Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
    }

/**
 * The guarded kill round-trip is part of pairing verification itself (`DELETE` exercises the
 * HTTP-side Origin guard the later `hookDecision` will need) AND guarantees the probe leaves no
 * orphan session. 204 = killed, 404 = already gone (both success), 403 = Origin guard rejected us.
 */
private suspend fun killProbeSession(
    sessionId: String,
    endpoint: HostEndpoint,
    http: HttpTransport,
): PairingProbeResult {
    val response = try {
        http.send(
            HttpRequest(
                method = HttpMethod.DELETE,
                url = killUrl(endpoint, sessionId),
                headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
            ),
        )
    } catch (cancel: CancellationException) {
        throw cancel
    } catch (error: Throwable) {
        return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
    }
    return when (response.status) {
        HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
        HTTP_FORBIDDEN -> PairingProbeResult.Failure(
            PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
        )
        else -> PairingProbeResult.Failure(
            PairingError.HostUnreachable(underlying = "HTTP ${response.status}"),
        )
    }
}

private sealed interface Adoption {
    data class Success(val sessionId: String) : Adoption

    data class Failure(val error: PairingError) : Adoption
}

// ── URL derivation + shape check ────────────────────────────────────────────────────────────────

private val PROBE_JSON = Json { ignoreUnknownKeys = true; isLenient = true }

/** Web-terminal shape = the body parses as a JSON array (the server replies `[]` when idle). */
private fun isJsonArray(body: ByteArray): Boolean =
    runCatching { PROBE_JSON.parseToJsonElement(body.decodeToString()) is JsonArray }.getOrDefault(false)

private fun liveSessionsUrl(endpoint: HostEndpoint): String = httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH

private fun killUrl(endpoint: HostEndpoint, sessionId: String): String =
    httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH + "/" + sessionId

/**
 * `scheme://host[:port]` from the endpoint's dialed URL — path/query/fragment/credentials dropped
 * (same derivation philosophy as [HostEndpoint.wsUrl]). The dialed port is kept verbatim; the host
 * is left as [java.net.URI] returns it (IPv6 literals are already bracketed).
 */
private fun httpBaseUrl(endpoint: HostEndpoint): String {
    val uri = URI(endpoint.baseUrl)
    val scheme = (uri.scheme ?: "http").lowercase()
    val host = uri.host ?: ""
    val portPart = if (uri.port != -1) ":${uri.port}" else ""
    return "$scheme://$host$portPart"
}

private const val LIVE_SESSIONS_PATH = "/live-sessions"
private const val ORIGIN_HEADER = "Origin"
private const val HTTP_OK = 200
private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404
