//
// WSTestClient.swift — T-iOS-16 集成测试的 WS 客户端与协议流程助手
//
// 由 OriginSpikeTests.swift 拆分而来。与 spike 版的关键差异：帧的编/解码一律走
// 冻结契约 `MessageCodec`（T-iOS-3）——集成测试因此同时守护"客户端复刻的协议"
// 与服务器实现之间的漂移（plan §9：这是 Origin spike 的常驻化）。
// 默认 `maximumMessageSize = Tunables.maxWSMessageBytes`（16 MiB，plan §3.2.1）；
// 只有 spike③ 的复现分支显式传 nil 保持平台默认 1 MiB。

import Foundation
import WireProtocol
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

// ─── WS 客户端薄封装（每测一个；不可变持有 task） ───────────────────────────

final class WSTestClient: @unchecked Sendable {
    let task: URLSessionWebSocketTask

    /// - Parameters:
    ///   - origin: nil = 不带 Origin header（守卫负路径）。带值时必须来自
    ///     `TestServer.origin`（HostEndpoint 单点派生）或刻意构造的失配值。
    ///   - maxMessageBytes: 默认 `Tunables.maxWSMessageBytes`（16 MiB）；
    ///     nil = 保持平台默认 1 MiB（仅 spike③ 复现分支）。
    init(server: TestServer, origin: String?, maxMessageBytes: Int? = Tunables.maxWSMessageBytes) {
        var request = URLRequest(url: server.wsURL)
        request.timeoutInterval = 15
        if let origin {
            // T-iOS-2 已定案的平台事实：Origin 不在 reserved-header 列表，setValue 生效。
            request.setValue(origin, forHTTPHeaderField: "Origin")
        }
        let task = URLSession.shared.webSocketTask(with: request)
        if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes }
        self.task = task
        task.resume()
    }

    /// 握手响应状态码（升级被拒时应为 401；成功时 101）。
    var handshakeStatusCode: Int? {
        (task.response as? HTTPURLResponse)?.statusCode
    }

    /// 经冻结契约编码后发送（客户端帧永远走 MessageCodec，不手拼 JSON）。
    func send(_ message: ClientMessage) async throws {
        try await task.send(.string(MessageCodec.encode(message)))
    }

    /// 收下一条文本帧；binary 帧防御性丢弃（服务器只发文本，但不信任它）。
    func receiveText(timeout: Duration) async throws -> String {
        try await withTimeout(timeout) { [self] in
            while true {
                switch try await task.receive() {
                case .string(let text): return text
                case .data: continue
                @unknown default: continue
                }
            }
        }
    }

    /// 升级失败探测：能收到帧/正常等待 → nil；handshake 抛错 → 返回错误。
    func handshakeFailure(timeout: Duration) async -> (any Error)? {
        do {
            _ = try await receiveText(timeout: timeout)
            return nil
        } catch {
            return error
        }
    }

    func close() {
        task.cancel(with: .normalClosure, reason: nil)
    }
}

func withTimeout<T: Sendable>(
    _ timeout: Duration,
    _ operation: @escaping @Sendable () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await operation() }
        group.addTask {
            try await Task.sleep(for: timeout)
            throw HarnessError.timeout("操作超过 \(timeout)")
        }
        guard let first = try await group.next() else {
            throw HarnessError.timeout("task group 无结果")
        }
        group.cancelAll()
        return first
    }
}

// ─── 协议流程助手（解码一律 MessageCodec.decodeServer；非法帧丢弃，同服务器语义） ──

/// input 帧：Enter 是 \r（0x0D），不是 \n（CLAUDE.md gotcha）。
func shellCommand(_ line: String) -> ClientMessage {
    .input(data: line + "\r")
}

/// attach 后等 attached 帧；容忍先到的 output/status（attachWs 在 attached
/// 之前就回放 snapshot，src/session/session.ts:158-170 / src/server.ts:721-733）。
func attachAndAwaitAttached(client: WSTestClient, sessionId: UUID?) async throws -> UUID {
    try await client.send(.attach(sessionId: sessionId, cwd: nil))
    let deadline = ContinuousClock.now + HarnessTunables.frameTimeout
    while ContinuousClock.now < deadline {
        let text = try await client.receiveText(timeout: HarnessTunables.frameTimeout)
        if case let .attached(id)? = MessageCodec.decodeServer(text) { return id }
    }
    throw HarnessError.timeout("attach 后未收到 attached 帧")
}

/// 累计 output 帧的原始字节数（UTF-8），直到 ≥ minBytes。
func accumulateOutputBytes(client: WSTestClient, minBytes: Int) async throws -> Int {
    var total = 0
    let deadline = ContinuousClock.now + HarnessTunables.outputAccumulationTimeout
    while ContinuousClock.now < deadline {
        let text = try await client.receiveText(timeout: HarnessTunables.outputAccumulationTimeout)
        guard case let .output(data)? = MessageCodec.decodeServer(text) else { continue }
        total += data.utf8.count
        if total >= minBytes { return total }
    }
    throw HarnessError.timeout("输出累计 \(total)/\(minBytes) 字节后超时")
}

/// 顺序累积 output 数据、等待标记出现（标记可跨帧，故必须整段累积再查找）。
/// 值类型：每个测试自己持有一份，随用随弃（不可变风格；transcript 只增不改）。
struct TranscriptReader {
    let client: WSTestClient
    private(set) var transcript = ""

    mutating func awaitContains(
        _ marker: String, timeout: Duration = HarnessTunables.markerTimeout
    ) async throws {
        let deadline = ContinuousClock.now + timeout
        while !transcript.contains(marker) {
            guard ContinuousClock.now < deadline else {
                throw HarnessError.timeout(
                    "等 \"\(marker)\" 超时；transcript 尾部: …\(transcript.suffix(200))")
            }
            let text = try await client.receiveText(timeout: timeout)
            if case let .output(data)? = MessageCodec.decodeServer(text) {
                transcript += data
            }
        }
    }
}

struct ReplaySummary: Sendable {
    let adoptedSessionId: UUID
    let totalOutputBytes: Int
    let containsSoftReset: Bool
}

/// reattach 并接收全量回放。成功条件：收到 attached 且 output ≥ minBytes。
func replayResult(
    client: WSTestClient, sessionId: UUID, minBytes: Int
) async -> Result<ReplaySummary, any Error> {
    do {
        try await client.send(.attach(sessionId: sessionId, cwd: nil))
        var adopted: UUID?
        var total = 0
        var sawSoftReset = false
        let deadline = ContinuousClock.now + HarnessTunables.outputAccumulationTimeout
        while ContinuousClock.now < deadline {
            let text = try await client.receiveText(timeout: HarnessTunables.frameTimeout)
            switch MessageCodec.decodeServer(text) {
            case let .attached(id)?:
                adopted = id
            case let .output(data)?:
                total += data.utf8.count
                // 回放前缀 soft-reset \x1b[0m（src/types.ts:167-170 / M2）。
                if !sawSoftReset, data.contains(WireConstants.replaySoftResetPrefix) {
                    sawSoftReset = true
                }
            default:
                break
            }
            if let adopted, total >= minBytes {
                return .success(ReplaySummary(
                    adoptedSessionId: adopted, totalOutputBytes: total,
                    containsSoftReset: sawSoftReset))
            }
        }
        return .failure(HarnessError.timeout(
            "回放不完整: attached=\(adopted.map(\.uuidString) ?? "无") bytes=\(total)/\(minBytes)"))
    } catch {
        return .failure(error)
    }
}

/// 读到 socket 结束为止：返回（是否见到 exit 帧，是否观察到 close）。
/// 本地等待超时（HarnessError）≠ close —— 二者显式区分，绝不混淆断言语义。
func drainUntilClose(
    client: WSTestClient, timeout: Duration = HarnessTunables.closeObserveTimeout
) async -> (sawExit: Bool, closed: Bool) {
    var sawExit = false
    let deadline = ContinuousClock.now + timeout
    while ContinuousClock.now < deadline {
        do {
            let text = try await client.receiveText(timeout: timeout)
            if case .exit? = MessageCodec.decodeServer(text) { sawExit = true }
        } catch is HarnessError {
            return (sawExit, false) // 本地超时：socket 还开着
        } catch {
            return (sawExit, true) // receive 抛平台错误 = 流已结束（close/断开）
        }
    }
    return (sawExit, false)
}

/// 等 exit 帧（自然退出广播路径，src/session/session.ts:146-151）。
func awaitExitFrame(
    client: WSTestClient, timeout: Duration = HarnessTunables.markerTimeout
) async throws -> (code: Int, reason: String?) {
    let deadline = ContinuousClock.now + timeout
    while ContinuousClock.now < deadline {
        let text = try await client.receiveText(timeout: timeout)
        if case let .exit(code, reason)? = MessageCodec.decodeServer(text) {
            return (code, reason)
        }
    }
    throw HarnessError.timeout("未收到 exit 帧")
}

// ─── 超限失败模式判定（spike③ 复现分支） ────────────────────────────────────

/// 超 maximumMessageSize 的失败模式：NSPOSIXErrorDomain 40（Darwin errno 40 =
/// EMSGSIZE "Message too long"；plan §1 勘误 —— 原文误标 ENOBUFS，Darwin 上
/// ENOBUFS 实为 55，两个码都接受）。
func isMessageTooLongError(_ error: any Error) -> Bool {
    func matches(_ ns: NSError) -> Bool {
        ns.domain == NSPOSIXErrorDomain && (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS))
    }
    let ns = error as NSError
    if matches(ns) { return true }
    if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { return matches(underlying) }
    return false
}

func describeError(_ error: any Error) -> String {
    let ns = error as NSError
    let underlying = (ns.userInfo[NSUnderlyingErrorKey] as? NSError)
        .map { " underlying=\($0.domain)#\($0.code)" } ?? ""
    return "\(ns.domain)#\(ns.code)\(underlying): \(ns.localizedDescription)"
}

// ─── HTTP 助手（G 类端点带 Origin；RO GET 一律不带 —— plan §3.4 铁律） ────────

/// DELETE /live-sessions/:id（G 类，src/server.ts:354-359）。origin=nil 用于
/// 守卫负路径（应得 403）。返回 HTTP 状态码。
func deleteLiveSession(server: TestServer, id: String, origin: String?) async throws -> Int {
    var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)"))
    request.httpMethod = "DELETE"
    if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") }
    let (_, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse else {
        throw HarnessError.setup("DELETE /live-sessions/\(id) 非 HTTP 响应")
    }
    return http.statusCode
}

/// GET /live-sessions（RO，无 Origin）→ 当前会话 id 列表。
func liveSessionIds(server: TestServer) async throws -> [String] {
    let url = server.baseURL.appending(path: "live-sessions")
    let (data, response) = try await URLSession.shared.data(from: url)
    guard (response as? HTTPURLResponse)?.statusCode == 200,
        let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
    else {
        throw HarnessError.setup("GET /live-sessions 非预期形状")
    }
    return array.compactMap { $0["id"] as? String }
}

/// 测后清理：尽力而为 —— 失败只影响宿主残留一个 bash 会话，不影响断言结果，
/// 故显式忽略错误（这是唯一允许吞错的地方，且吞的只是清理路径）。
func killSessionBestEffort(server: TestServer, id: UUID) async {
    _ = try? await deleteLiveSession(
        server: server, id: id.uuidString.lowercased(), origin: server.origin)
}
