import Foundation
import WireProtocol

/// Two-step pairing probe (plan §3.4):
/// ① `GET /live-sessions` — NO Origin (reachability + web-terminal shape).
/// ② WS `attach(null)` → adopt `attached` → close → **immediately
///   `DELETE /live-sessions/:id` WITH Origin** (verifies `isOriginAllowed` on
///   both the upgrade and the guarded-HTTP side, and never leaks the probe's
///   orphan session).
///
/// Callers MUST run this only after the user confirmed a scanned host
/// (T-iOS-12) — step ① already talks to the network and step ② spawns a PTY
/// on the target machine.
///
/// CONTRACT RULING (orchestrator, 2026-07-04 — resolves the T-iOS-8 BLOCKED):
/// §3.4's original `Result<Host, …>` payload was self-contradictory (`Host` is
/// a §3.3 HostRegistry type; this package depends only on WireProtocol, §1/§6
/// zero leaf-coupling). Amended §3.4: the probe's job is validating an
/// endpoint, so the success payload IS the probed `HostEndpoint`; constructing
/// `Host{id,name}` belongs to the pairing UI (T-iOS-12 PairingViewModel —
/// id/name are not the probe's to know). Public deadline comes from
/// `Tunables.pairingProbeTimeout` (§3.2.1 addition, same ruling).
///
/// - Parameters:
///   - clock: injected for deterministic deadline tests (`FakeClock`);
///     production passes `ContinuousClock()`.
///   - timeout: overall probe deadline → `.timeout`. `nil` = no app-level
///     deadline (the transport's own timeouts still apply). The production
///     default is a UI-layer decision (T-iOS-12); a shared
///     `Tunables.pairingProbeTimeout` would be a T-iOS-3 contract addition.
func runPairingProbeCore(
    endpoint: HostEndpoint,
    http: any HTTPTransport,
    ws: any TermTransport,
    clock: any Clock<Duration>,
    timeout: Duration?
) async -> Result<HostEndpoint, PairingError> {
    guard let timeout else {
        return await performProbe(endpoint: endpoint, http: http, ws: ws)
    }
    return await withTaskGroup(
        of: Result<HostEndpoint, PairingError>.self
    ) { group in
        group.addTask {
            await performProbe(endpoint: endpoint, http: http, ws: ws)
        }
        group.addTask {
            // Cancellation (probe won) also lands here; the value is discarded.
            try? await clock.sleep(for: timeout, tolerance: nil)
            return .failure(.timeout)
        }
        guard let first = await group.next() else {
            return .failure(.timeout)
        }
        group.cancelAll()
        return first
    }
}

/// Public probe entry (§3.4 as amended by the 2026-07-04 contract ruling).
/// Wall-clock deadline `Tunables.pairingProbeTimeout` on `ContinuousClock`;
/// tests drive the deterministic core via an injected `FakeClock`.
public func runPairingProbe(
    endpoint: HostEndpoint,
    http: any HTTPTransport,
    ws: any TermTransport
) async -> Result<HostEndpoint, PairingError> {
    await runPairingProbeCore(
        endpoint: endpoint,
        http: http,
        ws: ws,
        clock: ContinuousClock(),
        timeout: Tunables.pairingProbeTimeout
    )
}

// MARK: - Probe body

private func performProbe(
    endpoint: HostEndpoint,
    http: any HTTPTransport,
    ws: any TermTransport
) async -> Result<HostEndpoint, PairingError> {
    let api = APIClient(endpoint: endpoint, http: http)

    // ① Reachability + shape. Any HTTP-level answer that isn't the
    // /live-sessions array shape means "some other service" → 端口对吗?
    do {
        _ = try await api.liveSessions()
    } catch is APIClientError {
        return .failure(.httpOkButNotWebTerminal)
    } catch {
        return .failure(PairingError.classify(error, endpoint: endpoint))
    }

    // ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401
    // (src/server.ts:646-651), so after ① passed, an unrecognizable connect
    // failure is classified as originRejected.
    let connection: TransportConnection
    do {
        connection = try await ws.connect(to: endpoint)
    } catch {
        return .failure(PairingError.classify(
            error, endpoint: endpoint,
            unrecognizedFallback: .originRejected(
                hint: PairingError.originRejectedHint(for: endpoint)
            )
        ))
    }

    let adoption = await adoptAttachedSession(over: connection)
    await connection.close()
    switch adoption {
    case .failure(let failure):
        return .failure(failure)
    case .success(let sessionId):
        return await killProbeSession(id: sessionId, api: api, endpoint: endpoint)
    }
}

/// Send `attach(null)` (explicit JSON `"sessionId":null` — the server requires
/// the key) and wait for the server-issued `attached` id, skipping any other
/// or undecodable frames (untrusted server; only `attached` matters here).
private func adoptAttachedSession(
    over connection: TransportConnection
) async -> Result<UUID, PairingError> {
    do {
        try await connection.send(MessageCodec.encode(.attach(sessionId: nil, cwd: nil)))
        for try await frame in connection.frames {
            if case .attached(let sessionId) = MessageCodec.decodeServer(frame) {
                return .success(sessionId)
            }
        }
        // Upgrade succeeded, so this is NOT an Origin problem: a socket that
        // closes/errors before speaking our protocol is not web-terminal.
        return .failure(.httpOkButNotWebTerminal)
    } catch {
        return .failure(.httpOkButNotWebTerminal)
    }
}

/// The guarded kill round-trip is part of pairing verification itself
/// (`DELETE` exercises the HTTP-side Origin guard that `hookDecision` will
/// need later) — AND it guarantees the probe leaves no orphan session behind.
private func killProbeSession(
    id: UUID,
    api: APIClient,
    endpoint: HostEndpoint
) async -> Result<HostEndpoint, PairingError> {
    do {
        try await api.killSession(id: id)
    } catch APIClientError.sessionNotFound {
        // Already gone (exited between attach and kill) — the goal state.
    } catch APIClientError.forbidden {
        return .failure(.originRejected(
            hint: PairingError.originRejectedHint(for: endpoint)
        ))
    } catch let apiError as APIClientError {
        return .failure(.hostUnreachable(underlying: apiError.message))
    } catch {
        return .failure(PairingError.classify(error, endpoint: endpoint))
    }
    return .success(endpoint)
}
