//
// SimServerHarness.swift — T-iOS-15 hosted live-server smoke 的基础设施。
//
// WebTermTests 跑在【模拟器内的签名 App 宿主】里：iOS SDK 没有
// Foundation.Process，但模拟器进程本质是宿主 mac 上的原生进程 ——
// `posix_spawn`（iOS 2.0+ 可用，见 SDK spawn.h）在模拟器上直接工作，
// 模拟器也能直达宿主 loopback（App 的 ATS 127.0.0.0/8 例外已在 project.yml）。
//
// 生命周期与 ios/IntegrationTests/ServerHarness.swift 同思路（death-pipe）：
// 只 spawn 一个 /bin/sh，它把真服务器（node_modules/.bin/tsx src/server.ts）
// 拉起为后台子进程后阻塞在 `read`（stdin = 本测试进程持有写端的管道）。
// 测试进程无论以何种方式退出，写端被内核关闭 → `read` 得 EOF → sh `kill` 服务器。
// 无孤儿、无需 atexit。
//
// 运行模式（同 ServerHarness 的 A/B 两式）：
//   A. 自举（默认）：repo 不在 TCC 保护目录时，测试进程内 posix_spawn 起真服务器。
//   B. 外部服务器：TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port>
//      （TEST_RUNNER_ 前缀是 xcodebuild 向测试进程透传 env 的官方通道）。
//      repo 根可用 TEST_RUNNER_WEBTERM_REPO_ROOT 覆盖（默认由 #filePath 推导）。
//
// TCC 硬墙（T-iOS-15 实测，2026-07-05）：repo 位于 ~/Documents|Desktop|Downloads
// 时模式 A 必挂——模拟器 App 语境 spawn 的 node 打开该目录下文件会被 tccd 无限
// 阻塞（sample 实测主线程卡 uv_cwd → __open_nocancel；sh 内 head 同样挂起；
// /bin/sh、/bin/date 等平台二进制不受影响）。此时 harness 快速失败并给出模式 B
// 的可操作指引，绝不无声挂 60 秒。CI checkout（如 /Users/runner/work）不受影响。

import Darwin
import Foundation
import WireProtocol

enum SmokeTunables {
    static let serverReadyTimeout: Duration = .seconds(60)
    static let serverReadyPollInterval: Duration = .milliseconds(200)
    /// bash 冷启动 + echo 往返的总预算。
    static let outputTimeout: Duration = .seconds(45)
    /// tsx shebang 是 `#!/usr/bin/env node` —— sh 命令里显式前置常见 node 安装位。
    static let spawnPathPrefix = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
}

enum SmokeHarnessError: Error, CustomStringConvertible {
    case setup(String)
    case timeout(String)

    var description: String {
        switch self {
        case .setup(let detail): return "setup: \(detail)"
        case .timeout(let detail): return "timeout: \(detail)"
        }
    }
}

/// spawn 一次、全部用例复用（缓存 in-flight Task，避免 actor 可重入期间重复 spawn）。
actor SimServerHarness {
    static let shared = SimServerHarness()
    private var bootTask: Task<HostEndpoint, any Error>?
    /// death-pipe 写端：持有到测试进程退出（内核代为关闭 → 服务器被回收）。
    private var deathPipeWriteFD: Int32?

    func endpoint() async throws -> HostEndpoint {
        if let bootTask { return try await bootTask.value }
        let task = Task { try await self.bootstrap() }
        bootTask = task
        return try await task.value
    }

    private func bootstrap() async throws -> HostEndpoint {
        let env = ProcessInfo.processInfo.environment
        if let external = env["WEBTERM_SERVER_URL"] {
            guard let url = URL(string: external), let endpoint = HostEndpoint(baseURL: url) else {
                throw SmokeHarnessError.setup("WEBTERM_SERVER_URL 不是合法 http(s) URL: \(external)")
            }
            try await Self.awaitReady(endpoint, logPath: nil)
            return endpoint
        }

        let repoRoot = Self.locateRepoRoot(environment: env)
        if let folder = Self.tccProtectedFolder(containing: repoRoot.path) {
            throw SmokeHarnessError.setup(Self.tccGuidance(folder: folder, repoRoot: repoRoot.path))
        }
        let tsxPath = repoRoot.appending(path: "node_modules/.bin/tsx").path
        guard FileManager.default.isExecutableFile(atPath: tsxPath) else {
            throw SmokeHarnessError.setup("找不到 \(tsxPath) — 先在 repo 根目录跑 npm install/npm ci")
        }
        let port = try Self.findFreeLoopbackPort()
        guard let baseURL = URL(string: "http://127.0.0.1:\(port)"),
              let endpoint = HostEndpoint(baseURL: baseURL) else {
            throw SmokeHarnessError.setup("无法构造 endpoint, port=\(port)")
        }
        let logPath = FileManager.default.temporaryDirectory
            .appending(path: "webterm-hosted-smoke-\(port).log").path
        deathPipeWriteFD = try Self.spawnServerShell(
            repoRoot: repoRoot.path, port: port, logPath: logPath
        )
        try await Self.awaitReady(endpoint, logPath: logPath)
        return endpoint
    }

    // MARK: - Spawn (posix_spawn + death-pipe)

    /// Returns the pipe WRITE fd the caller must keep open for the server's
    /// lifetime (closing it — including by process death — kills the server).
    private static func spawnServerShell(
        repoRoot: String, port: Int, logPath: String
    ) throws -> Int32 {
        var pipeFDs: [Int32] = [0, 0]
        guard pipe(&pipeFDs) == 0 else {
            throw SmokeHarnessError.setup("pipe() 失败: errno \(errno)")
        }
        let (readFD, writeFD) = (pipeFDs[0], pipeFDs[1])

        let command = """
        cd '\(repoRoot)' || exit 1
        echo "[harness] sh up $(date +%T)" >> '\(logPath)'
        PORT=\(port) BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \
        PATH="\(SmokeTunables.spawnPathPrefix):$PATH" \
        node_modules/.bin/tsx src/server.ts >> '\(logPath)' 2>&1 &
        SRV=$!
        read _
        echo "[harness] death-pipe EOF $(date +%T) — killing $SRV" >> '\(logPath)'
        kill $SRV 2>/dev/null
        """

        var fileActions: posix_spawn_file_actions_t?
        posix_spawn_file_actions_init(&fileActions)
        defer { posix_spawn_file_actions_destroy(&fileActions) }
        posix_spawn_file_actions_adddup2(&fileActions, readFD, 0) // sh 的 stdin = 管道读端
        posix_spawn_file_actions_addclose(&fileActions, readFD)
        posix_spawn_file_actions_addclose(&fileActions, writeFD) // 子进程绝不持有写端

        let argv = ["/bin/sh", "-c", command]
        var argvC: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
        argvC.append(nil)
        // MINIMAL clean env — the simulator app's own environment is poison
        // for host binaries: its DYLD_ROOT_PATH/DYLD_FALLBACK_* point into the
        // iOS simulator runtime. /bin/sh is SIP-protected (dyld ignores DYLD_*
        // there), but /usr/local|homebrew node is NOT and dies at dyld stage
        // before printing anything. Only PATH + writable HOME/TMPDIR survive.
        let inherited = ProcessInfo.processInfo.environment
        let cleanEnv = [
            "PATH": SmokeTunables.spawnPathPrefix,
            "HOME": inherited["HOME"] ?? NSTemporaryDirectory(),
            "TMPDIR": inherited["TMPDIR"] ?? NSTemporaryDirectory(),
        ]
        var envC: [UnsafeMutablePointer<CChar>?] = cleanEnv
            .map { strdup("\($0.key)=\($0.value)") }
        envC.append(nil)
        defer {
            for pointer in argvC { free(pointer) }
            for pointer in envC { free(pointer) }
        }

        var pid: pid_t = 0
        let status = posix_spawn(&pid, "/bin/sh", &fileActions, nil, argvC, envC)
        close(readFD) // 父进程不需要读端
        guard status == 0 else {
            close(writeFD)
            throw SmokeHarnessError.setup("posix_spawn(/bin/sh) 失败: \(status)")
        }
        return writeFD
    }

    // MARK: - Readiness

    private static func awaitReady(_ endpoint: HostEndpoint, logPath: String?) async throws {
        let probeURL = endpoint.baseURL.appending(path: "live-sessions")
        let deadline = ContinuousClock.now + SmokeTunables.serverReadyTimeout
        while ContinuousClock.now < deadline {
            if let (data, response) = try? await URLSession.shared.data(from: probeURL),
               (response as? HTTPURLResponse)?.statusCode == 200,
               (try? JSONSerialization.jsonObject(with: data)) is [Any] {
                return
            }
            try await Task.sleep(for: SmokeTunables.serverReadyPollInterval)
        }
        throw SmokeHarnessError.timeout(
            "服务器 \(endpoint.baseURL) 在 \(SmokeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logPath ?? "n/a")"
        )
    }

    // MARK: - TCC guard (fail fast, actionable — see header)

    private static let tccProtectedFolders: Set<String> = ["Documents", "Desktop", "Downloads"]

    /// `/Users/<name>/(Documents|Desktop|Downloads)/…` → the protected folder
    /// name, else nil. Host-path heuristic (the sim's own HOME is a container
    /// path, so the host home cannot come from the environment).
    static func tccProtectedFolder(containing path: String) -> String? {
        let components = URL(fileURLWithPath: path).pathComponents
        guard components.count > 3, components[1] == "Users",
              tccProtectedFolders.contains(components[3]) else { return nil }
        return components[3]
    }

    private static func tccGuidance(folder: String, repoRoot: String) -> String {
        """
        repo 位于 macOS TCC 保护目录（~/\(folder)）：模拟器沙箱内 spawn 的 node \
        读取该目录会被 tccd 无限阻塞（实测 open() 挂起），自举模式必挂。请在宿主机 \
        自行起服务器后以外部模式重跑：
          cd '\(repoRoot)' && PORT=<p> BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 npm start &
          TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<p> xcodebuild … test
        """
    }

    // MARK: - Locations

    private static func locateRepoRoot(environment: [String: String]) -> URL {
        if let override = environment["WEBTERM_REPO_ROOT"] {
            return URL(fileURLWithPath: override, isDirectory: true)
        }
        // #filePath = <repo>/ios/App/WebTermTests/SimServerHarness.swift
        return URL(fileURLWithPath: #filePath)
            .deletingLastPathComponent() // WebTermTests
            .deletingLastPathComponent() // App
            .deletingLastPathComponent() // ios
            .deletingLastPathComponent() // repo root
    }

    /// bind(port=0) 拿空闲 loopback 端口（同 IntegrationTests 手法；竞态窗口可忽略）。
    private static func findFreeLoopbackPort() throws -> Int {
        let fd = socket(AF_INET, SOCK_STREAM, 0)
        guard fd >= 0 else { throw SmokeHarnessError.setup("socket() 失败: errno \(errno)") }
        defer { close(fd) }

        var address = sockaddr_in()
        address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
        address.sin_family = sa_family_t(AF_INET)
        address.sin_port = 0
        address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
        let bound = withUnsafePointer(to: &address) {
            $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
                bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
            }
        }
        guard bound == 0 else { throw SmokeHarnessError.setup("bind() 失败: errno \(errno)") }

        var assigned = sockaddr_in()
        var length = socklen_t(MemoryLayout<sockaddr_in>.size)
        let named = withUnsafeMutablePointer(to: &assigned) {
            $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
                getsockname(fd, $0, &length)
            }
        }
        guard named == 0 else { throw SmokeHarnessError.setup("getsockname() 失败: errno \(errno)") }
        return Int(UInt16(bigEndian: assigned.sin_port))
    }
}
