import Foundation
import Testing
import TestSupport
import WireProtocol
@testable import APIClient

/// T-iOS-8 · 两步配对探针（plan §3.4）：
/// ① `GET /live-sessions`（无 Origin，验可达 + 形状）
/// ② WS attach(null) → adopt attached → **立即 DELETE kill（带 Origin，不留孤儿会话）**。
/// 每种失败模式逐项映射 `PairingError`；超时经注入 clock/deadline（FakeClock，零真实等待）。
///
/// 注：§3.4 经 2026-07-04 契约裁定改为返回 `Result<HostEndpoint, PairingError>`
///（Host{id,name} 由 T-iOS-12 VM 构造）。失败分支经 `@testable` 测内部核心
/// `runPairingProbeCore`（可注入 FakeClock，零真实等待）；公开包装
/// `runPairingProbe`（ContinuousClock + Tunables.pairingProbeTimeout）另有
/// 全通冒烟测试兜签名与默认 deadline 接线。
struct PairingProbeTests {
    private static let base = "http://192.168.1.5:3000"
    private static let sessionIdString = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
    private static let attachedFrame =
        #"{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"#

    private struct Fixture {
        let endpoint: HostEndpoint
        let http: FakeHTTPTransport
        let ws: FakeTransport
        let liveSessionsURL: URL
        let killURL: URL
    }

    private func makeFixture(base: String = PairingProbeTests.base) throws -> Fixture {
        let baseURL = try #require(URL(string: base))
        let endpoint = try #require(HostEndpoint(baseURL: baseURL))
        return Fixture(
            endpoint: endpoint,
            http: FakeHTTPTransport(),
            ws: FakeTransport(),
            liveSessionsURL: try #require(URL(string: base + "/live-sessions")),
            killURL: try #require(URL(string: base + "/live-sessions/\(Self.sessionIdString)"))
        )
    }

    /// 非超时用例统一 timeout: nil —— 探针不进入 race,全路径确定性完成。
    private func runProbe(_ fixture: Fixture, timeout: Duration? = nil,
                          clock: FakeClock = FakeClock()) async -> Result<HostEndpoint, PairingError> {
        await runPairingProbeCore(
            endpoint: fixture.endpoint, http: fixture.http, ws: fixture.ws,
            clock: clock, timeout: timeout
        )
    }

    // MARK: - 探针①失败分支

    @Test("探针①连接拒绝 → hostUnreachable,且不触碰 WS(不发任何升级请求)")
    func stepOneConnectionRefusedMapsToHostUnreachable() async throws {
        // Arrange
        let fixture = try makeFixture()
        await fixture.http.queueFailure(
            url: fixture.liveSessionsURL, error: URLError(.cannotConnectToHost)
        )

        // Act
        let result = await runProbe(fixture)

        // Assert
        guard case .failure(.hostUnreachable) = result else {
            Issue.record("expected hostUnreachable, got \(result)")
            return
        }
        #expect(await fixture.ws.connectAttempts.isEmpty)
    }

    @Test("探针①返回 HTML → httpOkButNotWebTerminal(端口对吗?)")
    func stepOneHTMLBodyMapsToNotWebTerminal() async throws {
        // Arrange
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(
            url: fixture.liveSessionsURL,
            body: Data("<html><body>router admin</body></html>".utf8)
        )

        // Act + Assert
        #expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
    }

    @Test("探针① 404 → httpOkButNotWebTerminal(HTTP 有应答但不是 web-terminal)")
    func stepOne404MapsToNotWebTerminal() async throws {
        // Arrange
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: fixture.liveSessionsURL, status: 404)

        // Act + Assert
        #expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
    }

    @Test("探针① ATS 拦明文(-1022) → atsBlocked(host)")
    func stepOneATSBlockMapsToAtsBlocked() async throws {
        // Arrange
        let fixture = try makeFixture()
        let ats = NSError(
            domain: NSURLErrorDomain,
            code: NSURLErrorAppTransportSecurityRequiresSecureConnection
        )
        await fixture.http.queueFailure(url: fixture.liveSessionsURL, error: ats)

        // Act + Assert
        #expect(await runProbe(fixture) == .failure(.atsBlocked(host: "192.168.1.5")))
    }

    @Test("探针① POSIX Network-down + LAN IP → localNetworkDenied(本地网络权限被拒)")
    func stepOnePosixNetworkDownOnLANMapsToLocalNetworkDenied() async throws {
        // Arrange
        let fixture = try makeFixture()
        await fixture.http.queueFailure(
            url: fixture.liveSessionsURL, error: Self.networkDownError()
        )

        // Act + Assert
        #expect(await runProbe(fixture) == .failure(.localNetworkDenied))
    }

    @Test("探针① POSIX Network-down + 公网 host → hostUnreachable(不误报本地网络权限)")
    func stepOnePosixNetworkDownOnPublicHostMapsToHostUnreachable() async throws {
        // Arrange
        let publicBase = "http://203.0.113.7:3000"
        let fixture = try makeFixture(base: publicBase)
        await fixture.http.queueFailure(
            url: fixture.liveSessionsURL, error: Self.networkDownError()
        )

        // Act
        let result = await runProbe(fixture)

        // Assert
        guard case .failure(.hostUnreachable) = result else {
            Issue.record("expected hostUnreachable, got \(result)")
            return
        }
    }

    @Test("探针① TLS 失败(-1200) → tlsFailure;URLSession 层超时(-1001) → timeout")
    func stepOneTLSAndURLTimeoutClassification() async throws {
        // Arrange — TLS
        let tlsFixture = try makeFixture()
        await tlsFixture.http.queueFailure(
            url: tlsFixture.liveSessionsURL, error: URLError(.secureConnectionFailed)
        )
        // Arrange — URLSession 超时
        let timeoutFixture = try makeFixture()
        await timeoutFixture.http.queueFailure(
            url: timeoutFixture.liveSessionsURL, error: URLError(.timedOut)
        )

        // Act + Assert
        #expect(await runProbe(tlsFixture) == .failure(.tlsFailure))
        #expect(await runProbe(timeoutFixture) == .failure(.timeout))
    }

    // MARK: - 探针②失败分支

    @Test("探针② WS 升级被拒(401) → originRejected,hint 含 ALLOWED_ORIGINS=<拨号 origin>")
    func stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() async throws {
        // Arrange — ① 通过,② 升级失败(401 在传输层是无特定形状的连接错误)
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
        await fixture.ws.scriptConnectFailure()

        // Act
        let result = await runProbe(fixture)

        // Assert
        guard case .failure(.originRejected(let hint)) = result else {
            Issue.record("expected originRejected, got \(result)")
            return
        }
        #expect(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))
        #expect(!hint.contains(":443"))
    }

    @Test("originRejected hint 对 https 默认端口不写 :443(与拨号 URL 一致,无端口迷信)")
    func originRejectedHintOmitsDefaultPortForHTTPS() async throws {
        // Arrange
        let fixture = try makeFixture(base: "https://mac.tailnet.ts.net")
        await fixture.http.queueSuccess(
            url: try #require(URL(string: "https://mac.tailnet.ts.net/live-sessions")),
            body: Data("[]".utf8)
        )
        await fixture.ws.scriptConnectFailure()

        // Act
        let result = await runProbe(fixture)

        // Assert
        guard case .failure(.originRejected(let hint)) = result else {
            Issue.record("expected originRejected, got \(result)")
            return
        }
        #expect(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
        #expect(!hint.contains(":443"))
    }

    @Test("探针②流在 attached 前结束 → httpOkButNotWebTerminal(升级已成功,非 Origin 问题)")
    func stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() async throws {
        // Arrange — 预先入队的 finish 会在 connect 时立刻灌入新连接
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
        await fixture.ws.finishFrames()

        // Act + Assert
        #expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
    }

    // MARK: - 全通 + kill 往返

    @Test("探针全通 → success,attach 首帧 sessionId 为显式 null,attached 后必发带 Origin 的 kill(不留孤儿会话)")
    func fullProbeSuccessKillsProbeSessionWithOrigin() async throws {
        // Arrange
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
        await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
        await fixture.ws.emit(frame: Self.attachedFrame)

        // Act
        let result = await runProbe(fixture)

        // Assert — 成功载荷
        #expect(result == .success(fixture.endpoint))
        // Assert — attach(null) 是唯一 WS 帧,sessionId 键必在且为 null
        let sentFrames = await fixture.ws.sentFrames
        #expect(sentFrames.count == 1)
        let frame = try #require(sentFrames.first)
        let object = try #require(
            try JSONSerialization.jsonObject(with: Data(frame.utf8)) as? [String: Any]
        )
        #expect(object["type"] as? String == "attach")
        #expect(object["sessionId"] is NSNull)
        // Assert — kill 在 attached 之后立刻发出,带逐字符 Origin
        let requests = await fixture.http.recordedRequests
        #expect(requests.count == 2)
        let kill = try #require(requests.last)
        #expect(kill.httpMethod == "DELETE")
        #expect(kill.url == fixture.killURL)
        #expect(kill.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
        // Assert — WS 已关闭(探针不持连接)
        #expect(await fixture.ws.closeCallCount == 1)
    }

    @Test("公开包装 runPairingProbe:全通 → success(endpoint)(§3.4 裁定签名;默认 deadline 走 Tunables.pairingProbeTimeout,快路径不触发)")
    func publicWrapperHappyPathReturnsEndpoint() async throws {
        // Arrange — 与全通用例同布景;fakes 即时应答,ContinuousClock race 不等待
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
        await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
        await fixture.ws.emit(frame: Self.attachedFrame)

        // Act
        let result = await runPairingProbe(
            endpoint: fixture.endpoint, http: fixture.http, ws: fixture.ws
        )

        // Assert
        #expect(result == .success(fixture.endpoint))
        #expect(await fixture.ws.closeCallCount == 1)
    }

    @Test("attached 前收到 output 帧仍成功(不可信服务器容忍:跳过非 attached 帧)")
    func outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() async throws {
        // Arrange
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
        await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
        await fixture.ws.emit(frame: #"{"type":"output","data":"\u001b[0mreplay"}"#)
        await fixture.ws.emit(frame: Self.attachedFrame)

        // Act + Assert
        #expect(await runProbe(fixture) == .success(fixture.endpoint))
    }

    @Test("kill 被 G 守卫拒(403) → originRejected(HTTP 侧 Origin 校验也是配对必验项)")
    func killRejectedByOriginGuardMapsToOriginRejected() async throws {
        // Arrange
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
        await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 403)
        await fixture.ws.emit(frame: Self.attachedFrame)

        // Act
        let result = await runProbe(fixture)

        // Assert
        guard case .failure(.originRejected) = result else {
            Issue.record("expected originRejected, got \(result)")
            return
        }
    }

    @Test("kill 返回 404(会话已自行退出) → 仍算全通(目标态就是会话不存在)")
    func killReturning404StillCountsAsSuccess() async throws {
        // Arrange
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
        await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 404)
        await fixture.ws.emit(frame: Self.attachedFrame)

        // Act + Assert
        #expect(await runProbe(fixture) == .success(fixture.endpoint))
    }

    // MARK: - classify 分支（POSIX 形状）

    @Test("POSIX ETIMEDOUT → timeout;POSIX ECONNREFUSED → hostUnreachable")
    func posixTimeoutAndRefusedClassification() async throws {
        // Arrange
        let timeoutFixture = try makeFixture()
        await timeoutFixture.http.queueFailure(
            url: timeoutFixture.liveSessionsURL,
            error: NSError(domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ETIMEDOUT.rawValue))
        )
        let refusedFixture = try makeFixture()
        await refusedFixture.http.queueFailure(
            url: refusedFixture.liveSessionsURL,
            error: NSError(domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ECONNREFUSED.rawValue))
        )

        // Act
        let timeoutResult = await runProbe(timeoutFixture)
        let refusedResult = await runProbe(refusedFixture)

        // Assert
        #expect(timeoutResult == .failure(.timeout))
        guard case .failure(.hostUnreachable) = refusedResult else {
            Issue.record("expected hostUnreachable, got \(refusedResult)")
            return
        }
    }

    @Test("LAN/私网 host 判定表:RFC1918+link-local+loopback+CGNAT+.local 为真,公网/畸形为假")
    func privateOrLocalHostPredicateTable() {
        // Assert — LAN 级(localNetworkDenied 诊断适用)
        let lanHosts = [
            "localhost", "mac-mini.local", "10.0.0.5", "172.20.10.1",
            "192.168.0.9", "169.254.1.1", "100.100.1.1", "127.0.0.1",
        ]
        for host in lanHosts {
            #expect(PairingError.isPrivateOrLocalHost(host), "\(host) 应判为私网级")
        }
        // Assert — 非 LAN(不得误报本地网络权限)
        let publicHosts = [
            "8.8.8.8", "203.0.113.7", "mac.tailnet.ts.net",
            "256.1.1.1", "172.32.0.1", "100.128.0.1", "1.2.3",
        ]
        for host in publicHosts {
            #expect(!PairingError.isPrivateOrLocalHost(host), "\(host) 不应判为私网级")
        }
    }

    // MARK: - 超时（注入 FakeClock，零真实等待）

    @Test("超时:探针卡在等待 attached 时,注入的 FakeClock 到期 → .timeout")
    func probeTimesOutViaInjectedClockDeadline() async throws {
        // Arrange — ① 通过;② 连接成功但服务器永不回 attached → 探针挂起
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
        let clock = FakeClock()
        let probeTimeout: Duration = .seconds(10)

        // Act — 等 deadline sleeper 停好(确定性栅栏),再推进时钟
        async let result = runProbe(fixture, timeout: probeTimeout, clock: clock)
        await clock.waitForSleepers(count: 1)
        clock.advance(by: probeTimeout)

        // Assert
        #expect(await result == .failure(.timeout))
    }

    // MARK: - fixtures

    private static func networkDownError() -> NSError {
        let posixDown = NSError(
            domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ENETDOWN.rawValue)
        )
        return NSError(
            domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet,
            userInfo: [NSUnderlyingErrorKey: posixDown]
        )
    }
}
