import XCTest

/// W5 · The ONE scripted XCUITest happy path (PLAN_IOS_CLIENT §9, fragile-
/// surface rule): 配对(手输) → 会话列表 → attach → 输入 → gate approve —
/// against a REAL web-terminal server on loopback.
///
/// Contract with the harness:
/// - The server URL arrives in the RUNNER process env as `WEBTERM_SERVER_URL`
///   (xcodebuild delivers it via the `TEST_RUNNER_` prefix:
///   `TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port>`). Missing env =
///   explicit FAILURE with instructions, never a silent skip (a green-by-skip
///   CI leg would be worthless).
/// - The runner process makes its OWN URLSession calls to the server for
///   server-side assertions (attach visible in /live-sessions, typed marker in
///   /live-sessions/:id/preview, held POST /hook/permission resolving to
///   allow) — XCUITest cannot read SwiftTerm glyphs, the server is the oracle.
///
/// Idempotency (documented decision): the app persists hosts in the REAL
/// Keychain and a UI-test runner cannot uninstall the app, so the flow is
/// TOLERANT of prior state instead of resetting it:
/// - fresh install → first-run PairingScreen path;
/// - host(s) already paired → pair the CURRENT server via the 配对新主机
///   sheet, then select the just-added host (last menu row with the loopback
///   label — `HostStore.upsert` appends, so ours is last).
/// CI simulators are always fresh, so CI exercises the first-run branch; local
/// re-runs exercise the tolerant branch.
///
/// Waits: every UI wait is `waitForExistence` (predicate-based) with a
/// deadline; server polling is bounded loops with 1 s pauses — no fixed sleep
/// exceeds 2 s. BUDGET NOTE (measured deviation from the ~120 s aspiration):
/// SwiftTerm's caret blink keeps Core Animation permanently non-quiescent, so
/// EVERY XCUITest event on the terminal screen first burns XCUITest's fixed
/// 60 s "wait for app to idle" timeout ("App animations complete notification
/// not received" in the log). Interactions there are minimized to three
/// (focus tap · KeyBar ^L · gate approve) → the whole test runs ~4-5 min.
///
/// `@MainActor`: the Xcode 16.3 SDK isolates XCUIApplication/XCUIElement to
/// the main actor; XCTest runs test methods on the main thread, so class-wide
/// isolation is the correct spelling under Swift 6 strict concurrency. The
/// bounded semaphore waits below block only the RUNNER's main thread — the
/// app under test is a separate process and keeps running.
@MainActor
final class HappyPathUITests: XCTestCase {

    // MARK: - Deadlines (seconds; total budget ~120 s)

    private enum Deadline {
        static let launchSurface: TimeInterval = 30
        static let ui: TimeInterval = 20
        static let http: TimeInterval = 10
        static let attach: TimeInterval = 30
        static let marker: TimeInterval = 30
        static let gateBanner: TimeInterval = 25
        static let heldDecision: TimeInterval = 30
        static let pollPause: TimeInterval = 1
    }

    private enum TestError: Error {
        case surfaceNotFound
        case pollTimedOut(String)
        case httpFailed(String)
    }

    override func setUpWithError() throws {
        continueAfterFailure = false
    }

    // MARK: - The one happy path

    func testHappyPath_pairListAttachTypeGateApprove() throws {
        let server = try serverBaseURL()

        // Precondition: server reachable (fail fast with an actionable message).
        let (probeStatus, _) = try httpGET(server.appendingPathComponent("live-sessions"))
        XCTAssertEqual(
            probeStatus, 200,
            "web-terminal server not reachable at \(server) — start it and pass "
                + "TEST_RUNNER_WEBTERM_SERVER_URL to xcodebuild"
        )

        // 1 · Launch and pair (tolerant of pre-existing Keychain hosts).
        let app = XCUIApplication()
        app.launch()
        try pairCurrentServer(app, server: server)

        // 2 · Session list → + New session.
        let newButton = app.buttons["sessions.newButton"].firstMatch
        XCTAssertTrue(
            newButton.waitForExistence(timeout: Deadline.ui),
            "session list 新建会话 button not found after pairing"
        )
        let sessionsBefore = try liveSessionIds(server)
        newButton.tap()

        // 3 · attach — server-side oracle: a NEW session with a client attached.
        let sessionId = try pollForAttachedSession(server, excluding: sessionsBefore)

        // 4 · 输入 — via the KeyBar, asserted server-side.
        //
        // WHY NOT typeText (verified empirically, run 7): XCUITest's typeText
        // requires an AX element with hasKeyboardFocus == 1, and SwiftTerm's
        // TerminalView never exposes one — the runner stalls hunting for
        // focus and no character ever reaches the PTY. The KeyBar is a plain
        // UIButton row (inputAccessoryView) needing no keyboard focus, and it
        // exercises the SAME input pipeline as typed text: KeyByteMap →
        // TerminalViewModel.send(key:) → engine → WS input frame → PTY.
        // Oracle: ^L (0x0C) makes bash-readline answer clear-screen
        // ESC[H ESC[2J — bytes that appear in the ring buffer ONLY when the
        // shell received our input (resize redraws are \r ESC[K, no overlap).
        app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
        XCTAssertTrue(
            app.keyboards.firstMatch.waitForExistence(timeout: Deadline.ui),
            "software keyboard (with the KeyBar accessory) never appeared — "
                + "disable the simulator's hardware keyboard if testing locally"
        )
        let ctrlLButton = app.buttons["Ctrl+L — redraw screen"].firstMatch
        XCTAssertTrue(
            ctrlLButton.waitForExistence(timeout: Deadline.ui),
            "KeyBar ^L button not found above the keyboard"
        )
        ctrlLButton.tap()
        try pollPreview(server, sessionId: sessionId, contains: "\u{1B}[H\u{1B}[2J")

        // 5 · gate approve — hold a PermissionRequest exactly like a Claude
        // Code hook would (loopback POST, X-Webterm-Session header), approve
        // from the banner, and assert the HELD response resolves to allow.
        let held = startHeldPermissionRequest(server, sessionId: sessionId, tool: "UITestTool")
        let approveButton = app.buttons["gate.decision.approve"].firstMatch
        XCTAssertTrue(
            approveButton.waitForExistence(timeout: Deadline.gateBanner),
            "GateBanner approve button never appeared for the held permission"
        )
        approveButton.tap()

        guard let result = held.waitForResult(timeout: Deadline.heldDecision) else {
            XCTFail("held POST /hook/permission did not resolve within \(Deadline.heldDecision)s of approving")
            return
        }
        XCTAssertEqual(result.status, 200, "held /hook/permission answered HTTP \(String(describing: result.status))")
        let behavior = Self.decisionBehavior(in: result.data)
        XCTAssertEqual(
            behavior, "allow",
            "held permission resolved to \(behavior ?? "<no decision>") — expected allow "
                + "(body: \(String(data: result.data ?? Data(), encoding: .utf8) ?? "<non-utf8>"))"
        )
    }

    // MARK: - Pairing (tolerant two-branch flow)

    private func serverBaseURL() throws -> URL {
        let raw = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"]
        guard let raw, let url = URL(string: raw), url.host != nil else {
            XCTFail(
                "WEBTERM_SERVER_URL missing/invalid in the runner env. Run: "
                    + "xcodebuild -scheme WebTermUITests "
                    + "TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port> test "
                    + "(with the server running on that port)."
            )
            throw TestError.surfaceNotFound
        }
        return url
    }

    /// Waits for whichever entry surface the persisted state produced, then
    /// drives pairing so the CURRENT server ends up as the ACTIVE host.
    private func pairCurrentServer(_ app: XCUIApplication, server: URL) throws {
        let urlField = app.textFields["pairing.urlField"].firstMatch
        let hostMenu = app.buttons["sessions.hostMenu"].firstMatch
        let emptyAddHost = app.buttons["配对新主机"].firstMatch

        let deadline = Date().addingTimeInterval(Deadline.launchSurface)
        while Date() < deadline {
            if urlField.exists {
                // Branch A — fresh install: first-run PairingScreen (CI path).
                try fillPairingForm(app, server: server)
                return
            }
            if hostMenu.exists || emptyAddHost.exists {
                // Branch B — prior state: add the current server via the sheet.
                if hostMenu.exists {
                    hostMenu.tap()
                }
                XCTAssertTrue(
                    emptyAddHost.waitForExistence(timeout: Deadline.ui),
                    "配对新主机 entry not found on the session list"
                )
                emptyAddHost.tap()
                try fillPairingForm(app, server: server)
                try selectJustPairedHost(app, server: server, hostMenu: hostMenu)
                return
            }
            Thread.sleep(forTimeInterval: 0.5)
        }
        XCTFail("neither PairingScreen nor session list appeared within \(Deadline.launchSurface)s")
        throw TestError.surfaceNotFound
    }

    /// Types the URL, submits, and confirms (loopback → §5.4 no-warning tier,
    /// so the confirm page has no blocking acknowledgement).
    private func fillPairingForm(_ app: XCUIApplication, server: URL) throws {
        let urlField = app.textFields["pairing.urlField"].firstMatch
        XCTAssertTrue(
            urlField.waitForExistence(timeout: Deadline.ui),
            "pairing URL field not found"
        )
        urlField.tap()
        urlField.typeText(server.absoluteString)
        app.buttons["pairing.submitButton"].firstMatch.tap()

        let confirmButton = app.buttons["pairing.confirmButton"].firstMatch
        XCTAssertTrue(
            confirmButton.waitForExistence(timeout: Deadline.ui),
            "pairing confirm page (连接) not reached — input rejected? "
                + "(on-screen: \(visibleStaticTexts(app)))"
        )
        // Guard against typeText mangling: the confirm page must display the
        // EXACT parsed origin (single-point derivation shows what will probe).
        XCTAssertTrue(
            app.staticTexts[expectedOrigin(server)].firstMatch.exists,
            "confirm page does not show \(expectedOrigin(server)) — the typed "
                + "URL was mangled (on-screen: \(visibleStaticTexts(app)))"
        )
        confirmButton.tap()
        // Probe runs (GET /live-sessions + a WS round trip). Wait for the
        // WHOLE pairing surface to leave — its navigation bar (配对主机)
        // covers the confirm page, the transient 已配对 page AND the sheet's
        // dismissal animation (waiting only for the confirm button raced the
        // sheet and tapped an occluded toolbar — run-5 hit point {-1,-1}).
        // A probe failure shows the FailureView (重试) — fail FAST with its
        // on-screen copy.
        let pairingNavBar = app.navigationBars["配对主机"].firstMatch
        let retryButton = app.buttons["重试"].firstMatch
        let resolveDeadline = Date().addingTimeInterval(Deadline.ui)
        while pairingNavBar.exists {
            if retryButton.exists {
                XCTFail("pairing probe FAILED — on-screen: \(visibleStaticTexts(app))")
                throw TestError.surfaceNotFound
            }
            if Date() >= resolveDeadline {
                XCTFail("pairing did not complete within \(Deadline.ui)s — "
                    + "on-screen: \(visibleStaticTexts(app))")
                throw TestError.surfaceNotFound
            }
            Thread.sleep(forTimeInterval: 0.5)
        }
    }

    /// `scheme://host:port` exactly as HostEndpoint.originHeader derives it.
    private func expectedOrigin(_ server: URL) -> String {
        let scheme = server.scheme ?? "http"
        let host = server.host ?? ""
        let port = server.port.map { ":\($0)" } ?? ""
        return "\(scheme)://\(host)\(port)"
    }

    /// First dozen visible static texts — failure-message diagnostics.
    private func visibleStaticTexts(_ app: XCUIApplication) -> String {
        app.staticTexts.allElementsBoundByIndex
            .prefix(12)
            .map(\.label)
            .joined(separator: " | ")
    }

    /// Branch B only: the add-host sheet upserts (appends) the new host but
    /// keeps the previously active one. The just-added host is the LAST menu
    /// row bearing the loopback host label; tapping it selects it (or no-ops
    /// if it is already active — both are fine).
    private func selectJustPairedHost(
        _ app: XCUIApplication, server: URL, hostMenu: XCUIElement
    ) throws {
        let hostLabel = server.host ?? "127.0.0.1"
        XCTAssertTrue(
            hostMenu.waitForExistence(timeout: Deadline.ui),
            "host menu not visible after add-host sheet dismissed"
        )
        // The sheet's dismissal animation can leave the menu present-but-not-
        // hittable for a beat — tapping then dies with kAXErrorCannotComplete.
        let hittableDeadline = Date().addingTimeInterval(Deadline.ui)
        while !hostMenu.isHittable {
            if Date() >= hittableDeadline {
                XCTFail("host menu never became hittable after the add-host sheet")
                throw TestError.surfaceNotFound
            }
            Thread.sleep(forTimeInterval: 0.5)
        }
        hostMenu.tap()
        let rows = app.buttons.matching(
            NSPredicate(format: "label == %@ AND identifier != %@", hostLabel, "sessions.hostMenu")
        )
        let rowDeadline = Date().addingTimeInterval(Deadline.ui)
        while rows.count == 0, Date() < rowDeadline {
            Thread.sleep(forTimeInterval: 0.5)
        }
        let allRows = rows.allElementsBoundByIndex
        guard let lastRow = allRows.last else {
            XCTFail("no host menu row labelled \(hostLabel) after pairing it")
            throw TestError.surfaceNotFound
        }
        lastRow.tap()
    }

    // MARK: - Server oracles (runner-side URLSession)

    private func liveSessionIds(_ server: URL) throws -> Set<String> {
        let (status, data) = try httpGET(server.appendingPathComponent("live-sessions"))
        guard status == 200, let data,
              let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
        else {
            throw TestError.httpFailed("GET /live-sessions → \(status.map(String.init) ?? "no response")")
        }
        return Set(list.compactMap { $0["id"] as? String })
    }

    /// Polls /live-sessions until a session NOT in `excluding` reports
    /// clientCount >= 1 (the app's WS attach) — returns its id.
    private func pollForAttachedSession(
        _ server: URL, excluding: Set<String>
    ) throws -> String {
        let deadline = Date().addingTimeInterval(Deadline.attach)
        while Date() < deadline {
            let (status, data) = try httpGET(server.appendingPathComponent("live-sessions"))
            if status == 200, let data,
               let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
                for entry in list {
                    guard let id = entry["id"] as? String,
                          !excluding.contains(id),
                          let clientCount = entry["clientCount"] as? Int,
                          clientCount >= 1
                    else { continue }
                    return id
                }
            }
            Thread.sleep(forTimeInterval: Deadline.pollPause)
        }
        XCTFail("no newly attached session appeared in /live-sessions within \(Deadline.attach)s")
        throw TestError.pollTimedOut("attach")
    }

    /// Polls /live-sessions/:id/preview until the ring-buffer tail contains
    /// `marker` (the shell OUTPUT of the typed command).
    private func pollPreview(
        _ server: URL, sessionId: String, contains marker: String
    ) throws {
        let url = server
            .appendingPathComponent("live-sessions")
            .appendingPathComponent(sessionId)
            .appendingPathComponent("preview")
        let deadline = Date().addingTimeInterval(Deadline.marker)
        while Date() < deadline {
            let (status, data) = try httpGET(url)
            if status == 200, let data,
               let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
               let text = obj["data"] as? String,
               text.contains(marker) {
                return
            }
            Thread.sleep(forTimeInterval: Deadline.pollPause)
        }
        XCTFail("marker \(marker) never showed up in /live-sessions/\(sessionId)/preview within \(Deadline.marker)s")
        throw TestError.pollTimedOut("marker")
    }

    // MARK: - Held permission (mimics the Claude Code PermissionRequest hook)

    /// Fire-and-park POST /hook/permission: the server HOLDS the response until
    /// a client decides (or its 5-min default timeout). Returns a handle the
    /// test awaits AFTER tapping ✓ Approve.
    private func startHeldPermissionRequest(
        _ server: URL, sessionId: String, tool: String
    ) -> HeldHTTPResponse {
        var request = URLRequest(url: server.appendingPathComponent("hook/permission"))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(sessionId, forHTTPHeaderField: "X-Webterm-Session")
        request.timeoutInterval = 120
        request.httpBody = try? JSONSerialization.data(withJSONObject: [
            "hook_event_name": "PermissionRequest",
            "tool_name": tool,
        ])
        let held = HeldHTTPResponse()
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            held.complete(
                status: (response as? HTTPURLResponse)?.statusCode,
                data: data,
                errorDescription: error.map(String.init(describing:))
            )
        }
        task.resume()
        return held
    }

    /// Extracts hookSpecificOutput.decision.behavior from the resolved hold.
    private static func decisionBehavior(in data: Data?) -> String? {
        guard let data,
              let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let output = obj["hookSpecificOutput"] as? [String: Any],
              let decision = output["decision"] as? [String: Any]
        else { return nil }
        return decision["behavior"] as? String
    }

    // MARK: - Bounded synchronous HTTP (URLSession callbacks run off-thread)

    private func httpGET(_ url: URL) throws -> (Int?, Data?) {
        var request = URLRequest(url: url)
        request.timeoutInterval = Deadline.http
        let box = HeldHTTPResponse()
        URLSession.shared.dataTask(with: request) { data, response, error in
            box.complete(
                status: (response as? HTTPURLResponse)?.statusCode,
                data: data,
                errorDescription: error.map(String.init(describing:))
            )
        }.resume()
        guard let result = box.waitForResult(timeout: Deadline.http + 2) else {
            throw TestError.httpFailed("GET \(url) timed out after \(Deadline.http + 2)s")
        }
        if let errorDescription = result.errorDescription {
            throw TestError.httpFailed("GET \(url) failed: \(errorDescription)")
        }
        return (result.status, result.data)
    }
}

/// Lock-protected completion box for URLSession callbacks (they arrive on the
/// session's delegate queue; the test thread waits with a bounded semaphore).
/// `@unchecked Sendable`: every mutable field is guarded by `lock`.
private final class HeldHTTPResponse: @unchecked Sendable {
    struct Result {
        let status: Int?
        let data: Data?
        let errorDescription: String?
    }

    private let lock = NSLock()
    private let semaphore = DispatchSemaphore(value: 0)
    private var result: Result?

    func complete(status: Int?, data: Data?, errorDescription: String?) {
        lock.lock()
        result = Result(status: status, data: data, errorDescription: errorDescription)
        lock.unlock()
        semaphore.signal()
    }

    /// Bounded wait for the response; nil = still pending after `timeout`.
    func waitForResult(timeout: TimeInterval) -> Result? {
        guard semaphore.wait(timeout: .now() + timeout) == .success else { return nil }
        lock.lock()
        defer { lock.unlock() }
        return result
    }
}
