import Foundation

/// `AgentBackend` over the Codex CLI's `app-server` (ADAPTERS §2; BACKEND_PROTOCOL §7.2).
/// Spawns `codex app-server`, speaks JSON-RPC 2.0 over its stdio (`JSONRPCConnection`, with the
/// `"jsonrpc"` header omitted — codex's confirmed dialect),
/// normalizes `thread/`·`turn/`·`item/` notifications into `AgentEvent`s
/// (`CodexAppServerDecoder`), and answers interactive approvals — which the server sends
/// as JSON-RPC server→client requests — through the shared `ApprovalCoordinator`, exactly
/// the seam Claude's MCP approval bridge suspends on (ADAPTERS §5).
///
/// **Turn model (v0).** Like `ClaudeCodeBackend`, each turn is a single-shot run: `start`
/// spawns the process and opens a thread; each follow-up `resume` spawns a fresh process
/// and `thread/resume`s the same thread by id (the documented resume mechanism, ADAPTERS
/// §2.6). A persistent connection with `turn/steer` follow-ups is a later optimization —
/// hence `followUpWhileRunning = false`.
///
/// Wire shapes below are source-confirmed against `openai/codex`
/// (`codex-rs/app-server-protocol`, commit `7a19b14`); byte-level confirmation against a
/// live `codex` awaits a fixture capture (no `codex` on the dev machine — M3).
public actor CodexAppServerBackend: AgentBackend {
    public static let id = BackendID.codex

    public nonisolated let capabilities = BackendCapabilities(
        interactiveApprovals: true,
        allowAlwaysScopes: [.session],          // Codex's only "always" is acceptForSession
        canModifyToolInput: false,              // no arbitrary command rewrite (ADAPTERS §2.4)
        partialMessageStreaming: true,          // item/*/delta notifications
        emitsThinking: true,                    // reasoning items
        emitsFileChangeEvents: true,            // native + authoritative fileChange items
        nativeResume: true,                     // thread/resume
        sandboxModes: [.readOnly, .workspaceWrite, .fullAccess],
        followUpWhileRunning: false)

    public struct Configuration: Sendable {
        /// Executable name (resolved via PATH) or absolute path.
        public var executable: String
        public var clientName: String
        public var clientVersion: String
        /// Interactive approval policy passed to `thread/start`.
        public var approvalPolicy: String
        public var now: @Sendable () -> Date

        public init(
            executable: String = "codex",
            clientName: String = "Nucleic",
            clientVersion: String = "0.1",
            approvalPolicy: String = "on-request",
            now: @escaping @Sendable () -> Date = { Date() }
        ) {
            self.executable = executable
            self.clientName = clientName
            self.clientVersion = clientVersion
            self.approvalPolicy = approvalPolicy
            self.now = now
        }
    }

    private let configuration: Configuration
    private let processHost: ProcessHost
    public let approvals: ApprovalCoordinator
    /// Optional sandbox orchestrator. When set and a run carries a vsock control container, the
    /// agent execs inside it (stdio over vsock) — the per-family control container isolation. `nil` →
    /// always host. Codex's approvals are native (JSON-RPC, no MCP), but a control container still
    /// runs the git/gh/command interceptor, whose reports reach the shared per-container server.
    private let containerManager: ContainerManager?
    /// Name of the container the current run execs in (for the teardown `finished` callback).
    private var activeContainerName: String?
    /// Conflict coordinator (locks + autoship). Joins Codex to the lock/arbitration system: a
    /// file-change approval acquires locks on the edited paths before the patch applies, and the
    /// interceptor reports merges/commands for release + the activity feed. `nil` → not joined.
    private let conflictCoordinator: ConflictCoordinator?
    /// Vends the shared, token-multiplexed approval server for a control container — Codex uses it
    /// only for the git/gh/command interceptor report routes (its approvals are native). `nil` → no
    /// interceptor reporting (host runs, or tests).
    private let approvalServerRegistry: ApprovalServerRegistry?
    /// The interceptor server + token for the current containerized run (for teardown unregister).
    private var interceptorServer: MCPApprovalServer?
    private var interceptorToken: String?
    /// Edited paths announced by each `fileChange` item (id → paths), captured from the item
    /// lifecycle so the file-change approval (which carries only the item id) can arbitrate locks.
    private var fileChangePathsByItem: [String: [String]] = [:]

    private var handle: (any ProcessHandle)?
    private var rpc: JSONRPCConnection?
    private var continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation?
    private var sessionID: SessionID?
    private var provisionalSeq: UInt64 = 0
    private var interruptRequested = false
    private var terminating = false
    private var sawRunFinished = false
    /// The open thread's id (kept across turns so `resume` reattaches the same thread).
    private var currentThreadID: String?
    /// The in-flight turn's id, needed for `turn/interrupt`.
    private var currentTurnID: String?
    public private(set) var backendSessionID: String?

    public init(
        configuration: Configuration = Configuration(),
        processHost: ProcessHost = ProcessHost(),
        approvals: ApprovalCoordinator = ApprovalCoordinator(),
        containerManager: ContainerManager? = nil,
        conflictCoordinator: ConflictCoordinator? = nil,
        approvalServerRegistry: ApprovalServerRegistry? = nil
    ) {
        self.configuration = configuration
        self.processHost = processHost
        self.approvals = approvals
        self.containerManager = containerManager
        self.conflictCoordinator = conflictCoordinator
        self.approvalServerRegistry = approvalServerRegistry
    }

    // MARK: - AgentBackend

    public nonisolated func start(_ run: RunSpec) -> AsyncThrowingStream<AgentEvent, Error> {
        makeStream(run: run, resumeThreadID: nil)
    }

    public nonisolated func resume(_ resume: ResumeSpec) -> AsyncThrowingStream<AgentEvent, Error> {
        let run = RunSpec(
            sessionID: resume.sessionID,
            worktree: resume.worktree,
            prompt: resume.prompt ?? AgentInput(parts: []),
            model: resume.model,
            effort: resume.effort,
            autoApprove: resume.autoApprove,
            container: resume.container,
            allowHostExec: resume.allowHostExec,
            appendSystemPrompt: resume.appendSystemPrompt)
        return makeStream(run: run, resumeThreadID: resume.backendSessionID)
    }

    public func send(_ input: AgentInput) async throws {
        // Conversational follow-ups arrive via resume() (single-shot turn model); there is
        // no mid-stream send in v0. Non-conversational streaming would use turn/steer.
        throw BackendError.unsupported("Codex follow-ups resume a new turn; send() is unused in v0")
    }

    public func respond(to approvalID: ApprovalID, _ decision: Decision) async throws {
        let outcome = await approvals.resolve(approvalID, decision, by: "mac-ui")
        if outcome == .unknownID {
            throw BackendError.protocolViolation("No outstanding approval \(approvalID)")
        }
    }

    public func interrupt() async {
        interruptRequested = true
        await interruptCurrentTurn()
    }

    public func shutdown() async {
        terminating = true
        await approvals.cancelOutstanding(reason: "Session terminated")
        await teardownTurn()
    }

    // MARK: - Run loop

    private nonisolated func makeStream(run: RunSpec, resumeThreadID: String?)
        -> AsyncThrowingStream<AgentEvent, Error>
    {
        AsyncThrowingStream { continuation in
            let task = Task {
                await self.runLoop(run: run, resumeThreadID: resumeThreadID, continuation: continuation)
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }

    private func runLoop(
        run: RunSpec,
        resumeThreadID: String?,
        continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation
    ) async {
        self.continuation = continuation
        self.sessionID = run.sessionID
        self.sawRunFinished = false
        self.interruptRequested = false

        let stderrTail = CodexStderrTail()
        do {
            // 1. Spawn `codex app-server` — inside the shared control container (stdio over vsock)
            // when this run carries a vsock control socket, else on the host. Gated on that signal,
            // so default behavior is unchanged. (Requires `codex` + its auth in the sandbox image.)
            // Host-run (unsandboxed) agent: pin its git (every remote) to the Managed Git key for the
            // run's lifetime (shredded when this `do` block exits — after the agent process ends). A
            // containerized run gets the key via `provision` (cspec.env). No-op when unset.
            let isHostRun = !(containerManager != nil && run.container?.controlSocketHostPath != nil)
            var hostManagedGitEnv: [String: String] = [:]
            let hostManagedGitCleanup: @Sendable () -> Void
            if isHostRun {
                let (e, c) = GitHubCredentialProvisioner.provisionHost()
                hostManagedGitEnv = e
                hostManagedGitEnv.removeValue(forKey: "GH_CONFIG_DIR")
                hostManagedGitCleanup = c
            } else {
                hostManagedGitCleanup = {}
            }
            defer { hostManagedGitCleanup() }

            let handle: any ProcessHandle
            if let containerManager, let cspec = run.container, let controlSock = cspec.controlSocketHostPath {
                let (name, _) = try await containerManager.ensureRunning(cspec)
                activeContainerName = name
                var env = cspec.env.merging(run.extraEnv) { _, new in new }
                // Git/gh/command interceptor: Codex's in-container shims POST observed ops to the
                // shared per-container approval server (over the relayed socket, via the in-guest
                // bridge), which forwards them to the conflict coordinator (release + activity feed),
                // exactly as Claude/Grok. Codex's approvals stay native, so only the report routes
                // register — the acquire side is the file-change arbitration in handleReviewApproval.
                if cspec.installGitInterceptor, conflictCoordinator != nil,
                    let registry = approvalServerRegistry
                {
                    let server = await registry.server(for: cspec.name)
                    try await server.start(unixSocketPath: controlSock)
                    let token = UUID().uuidString
                    interceptorServer = server
                    interceptorToken = token
                    await server.registerGitReport(token: token) { [weak self] call in
                        Task { await self?.handleGitReport(call) }
                    }
                    await server.registerGhReport(token: token) { [weak self] call in
                        Task { await self?.handleGhReport(call) }
                    }
                    await server.registerCommandReport(token: token) { [weak self] call in
                        Task { await self?.handleCommandReport(call) }
                    }
                    env.merge(
                        CommandInterceptor.hookEnv(
                            host: "127.0.0.1", port: ContainerSpec.controlBridgePort,
                            token: token, sessionID: run.sessionID,
                            commandTracing: ContainerServiceSettings.commandTracingEnabled)
                    ) { _, new in new }
                }
                handle = try await containerManager.exec(
                    name: name, workdir: cspec.workdir, env: env,
                    argv: [configuration.executable, "app-server"],
                    uid: cspec.runAsUID, gid: cspec.runAsGID)
            } else {
                let spec = ProcessSpec(
                    executable: configuration.executable,
                    args: ["app-server"],
                    cwd: run.worktree,
                    env: run.extraEnv.merging(hostManagedGitEnv) { _, new in new },
                    stdinMode: .pipe)
                handle = try await processHost.launch(spec)
            }
            self.handle = handle

            let stderrTask = Task {
                for try await line in handle.stderrLines {
                    let text = String(decoding: line, as: UTF8.self)
                    stderrTail.append(text)
                    if ProcessInfo.processInfo.environment["NUCLEIC_DEBUG_STDERR"] != nil {
                        FileHandle.standardError.write(Data(("codex-stderr: " + text + "\n").utf8))
                    }
                }
            }

            // 2. JSON-RPC client + inbound (approval) request handler.
            let rpc = JSONRPCConnection(handle: handle)
            self.rpc = rpc
            await rpc.start { [weak self] request in
                await self?.handleInboundRequest(request)
            }

            // 3. Handshake: initialize (request) → initialized (notification, REQUIRED first).
            // `capabilities` and `clientInfo.title` are required-nullable in the schema.
            _ = try await rpc.request("initialize", params: .object([
                "clientInfo": .object([
                    "name": .string(configuration.clientName),
                    "title": .null,
                    "version": .string(configuration.clientVersion),
                ]),
                "capabilities": .null,
            ]))
            try await rpc.notify("initialized")

            // 4. Open the thread — fresh (`thread/start`) or reattached (`thread/resume` by id,
            //    the preferred resume path; codex loads the thread from its on-disk rollout).
            let threadResult: JSONValue
            if let resumeThreadID {
                var resumeParams: [String: JSONValue] = ["threadId": .string(resumeThreadID)]
                if let prompt = run.appendSystemPrompt {
                    resumeParams["developerInstructions"] = .string(prompt)
                }
                threadResult = try await rpc.request("thread/resume", params: .object(resumeParams))
            } else {
                var startParams: [String: JSONValue] = [
                    "cwd": .string(run.worktree),
                    "approvalPolicy": .string(configuration.approvalPolicy),
                    "sandbox": .string((run.sandbox ?? .workspaceWrite).rawValue),
                ]
                if let model = run.model { startParams["model"] = .string(model) }
                // Nucleic's environment guidance is additional, not a base-prompt override.
                if let prompt = run.appendSystemPrompt {
                    startParams["developerInstructions"] = .string(prompt)
                }
                threadResult = try await rpc.request("thread/start", params: .object(startParams))
            }
            let thread = threadResult["thread"] ?? threadResult
            let threadID = thread["id"]?.stringValue ?? resumeThreadID ?? ""
            currentThreadID = threadID
            backendSessionID = threadID

            // Authoritative sessionStarted from the thread response (the started *notification*
            // carries only the provider, not the resolved model id).
            emit(
                .sessionStarted(
                    SessionStarted(
                        backendSessionID: threadID,
                        model: threadResult["model"]?.stringValue ?? run.model ?? "",
                        cwd: threadResult["cwd"]?.stringValue ?? thread["cwd"]?.stringValue
                            ?? run.worktree,
                        toolNames: [],
                        nativeTranscriptPath: thread["path"]?.stringValue)),
                nativeType: "thread/start")

            let decoder = CodexAppServerDecoder()

            // 5. Start the turn (skip for a re-attach-only resume with no prompt).
            if run.prompt.parts.isEmpty {
                finishRun(outcome: .completed)
                stderrTask.cancel()
                await teardownTurn()
                continuation.finish()
                return
            }
            var turnParams: [String: JSONValue] = [
                "threadId": .string(threadID),
                "input": Self.userInput(for: run.prompt),
            ]
            // `effort` is a per-turn override; only forward a value codex accepts as a
            // `ReasoningEffort` so an unrecognized string (e.g. Nucleic's "max") can't fail the
            // turn. codex gpt-5.x models support low/medium/high/xhigh.
            if let effort = run.effort?.lowercased(),
                ["minimal", "low", "medium", "high", "xhigh"].contains(effort)
            {
                turnParams["effort"] = .string(effort)
            }
            let turnResult = try await rpc.request("turn/start", params: .object(turnParams))
            currentTurnID = (turnResult["turn"] ?? turnResult)["id"]?.stringValue

            // 6. Drain notifications until the turn reaches a terminal state.
            var sawTerminal = false
            var outcome: RunFinished.Outcome = .completed
            for await note in rpc.notifications {
                // Capture each fileChange item's edited paths (id → paths) so the file-change
                // approval — which references the item by id and carries no paths — can acquire locks.
                if let (id, paths) = CodexAppServerDecoder.fileChangeItemPaths(
                    method: note.method, params: note.params)
                {
                    fileChangePathsByItem[id] = paths
                }
                for decoded in decoder.decode(method: note.method, params: note.params) {
                    emit(decoded.kind, nativeType: decoded.nativeType)
                }
                switch note.method {
                case "turn/completed":
                    let status = (note.params["turn"] ?? note.params)["status"]?.stringValue
                    outcome = Self.outcome(forTurnStatus: status, interrupted: interruptRequested)
                    sawTerminal = true
                case "turn/failed":
                    outcome = .errored
                    sawTerminal = true
                default:
                    break
                }
                if sawTerminal { break }
            }

            // 7. The notification stream ran dry without a terminal turn event → the process
            //    exited mid-turn. Classify like Claude does (ADAPTERS §1.5).
            if !sawTerminal {
                if interruptRequested {
                    outcome = .interrupted
                } else if terminating {
                    outcome = .completed
                } else {
                    outcome = .errored
                    emit(
                        .error(
                            AgentError(
                                recoverable: false,
                                message: "codex app-server exited mid-turn: \(stderrTail.joined())")),
                        nativeType: nil)
                }
            }

            finishRun(outcome: outcome)
            stderrTask.cancel()
            await approvals.cancelOutstanding(reason: "Run ended")
            await teardownTurn()
            continuation.finish()
        } catch {
            // A deliberate stop (interrupt/shutdown) can abort an in-flight request — settle
            // the run cleanly. Any other error is a genuine failure; throw so SessionController
            // synthesizes the terminal error + runFinished (matching ClaudeCodeBackend §1.5).
            if interruptRequested || terminating {
                if !sawRunFinished {
                    finishRun(outcome: interruptRequested ? .interrupted : .completed)
                }
                await approvals.cancelOutstanding(reason: "Run interrupted")
                await teardownTurn()
                continuation.finish()
            } else {
                await approvals.cancelOutstanding(reason: "Run failed")
                await teardownTurn()
                continuation.finish(throwing: error)
            }
        }
    }

    private func finishRun(outcome: RunFinished.Outcome) {
        guard !sawRunFinished else { return }
        sawRunFinished = true
        emit(.runFinished(RunFinished(outcome: outcome)), nativeType: nil)
    }

    /// Tear down the just-finished turn's process + JSON-RPC connection. `currentThreadID`
    /// and `backendSessionID` persist so the next `resume` reattaches the same thread.
    private func teardownTurn() async {
        if let rpc { await rpc.close() }
        self.rpc = nil
        if let handle {
            handle.closeStdin()
            await handle.terminate()
        }
        self.handle = nil
        if let token = interceptorToken {
            // Unregister only this run's token; the shared per-container server keeps serving the
            // other sessions in the box.
            await interceptorServer?.unregister(token: token)
            interceptorToken = nil
            interceptorServer = nil
        }
        if let name = activeContainerName {
            await containerManager?.finished(name: name)
            activeContainerName = nil
        }
        currentTurnID = nil
        fileChangePathsByItem.removeAll()
    }

    // MARK: - Interceptor reporting (Nucleic Control containers)

    /// Forward a ground-truth git op reported by the in-container `git` shim to the conflict
    /// coordinator (lock release on merge + autoship), the same seam Claude/Grok use. Best-effort.
    private func handleGitReport(_ call: MCPApprovalServer.GitReportCall) async {
        guard let sessionID, let conflictCoordinator else { return }
        await conflictCoordinator.observeGitOp(
            sessionID: sessionID, argv: call.argv, exitCode: call.exitCode)
    }

    /// Forward a ground-truth `gh` action (PR/release/API) reported by the in-container `gh` shim.
    private func handleGhReport(_ call: MCPApprovalServer.GhReportCall) async {
        guard let sessionID, let conflictCoordinator else { return }
        await conflictCoordinator.observeGhOp(
            sessionID: sessionID, argv: call.argv, exitCode: call.exitCode)
    }

    /// Forward a non-git command the interceptor reported (shim or bash tracer) for the command feed.
    private func handleCommandReport(_ call: MCPApprovalServer.CommandReportCall) async {
        guard let sessionID, let conflictCoordinator else { return }
        await conflictCoordinator.observeCommand(sessionID: sessionID, call: call)
    }

    private func interruptCurrentTurn() async {
        guard let rpc, let threadID = currentThreadID, let turnID = currentTurnID else {
            handle?.interruptThenKill()  // no live turn id → signal + escalate so Stop always lands
            return
        }
        // Cooperative: the server replies `{}` then emits turn/completed(interrupted) (§2.6).
        _ = try? await rpc.request("turn/interrupt", params: .object([
            "threadId": .string(threadID),
            "turnId": .string(turnID),
        ]))
        // Safety net: if the agent ignores the cooperative interrupt and the turn never completes,
        // force-stop the process group after a grace so Stop still takes effect (the next resume
        // reattaches the same thread, so the session survives).
        handle?.killGroupAfter(graceNanos: 8 * 1_000_000_000)
    }

    // MARK: - Approval bridge (server→client requests, ADAPTERS §2.4)

    private func handleInboundRequest(_ request: JSONRPCConnection.InboundRequest) async {
        guard let rpc else { return }
        switch request.method {
        case "item/commandExecution/requestApproval", "item/fileChange/requestApproval":
            await handleReviewApproval(request, rpc: rpc, legacy: false)
        case "execCommandApproval", "applyPatchApproval":
            // Deprecated v1 surface — answered defensively with the v1 `ReviewDecision` enum.
            await handleReviewApproval(request, rpc: rpc, legacy: true)
        case "item/permissions/requestApproval":
            await handlePermissionsApproval(request, rpc: rpc)
        default:
            // Unknown server request — answer with an error so the server never hangs on us.
            try? await rpc.replyError(
                to: request.id, code: -32601, message: "Unsupported request \(request.method)")
        }
    }

    private func handleReviewApproval(
        _ request: JSONRPCConnection.InboundRequest, rpc: JSONRPCConnection, legacy: Bool
    ) async {
        let fields = Self.reviewApprovalFields(request)
        func reply(for decision: Decision) -> JSONValue {
            legacy
                ? CodexDecisionMapping.reviewReplyV1(for: decision)
                : CodexDecisionMapping.reviewReply(for: decision)
        }

        // Conflict pre-check (Nucleic Control): a file-change edit acquires locks on its paths before
        // the patch applies — joining Codex to the lock/arbitration system. Runs ahead of the
        // always-rule cache so an "always allow" can't slip a conflicting edit past. The approval
        // carries no paths, so use the ones captured from its fileChange item. On deferred/cancelled
        // (or an update-on-grant re-ground) deny, so Codex doesn't apply; its natural retry re-grants.
        if let conflictCoordinator,
            request.method == "item/fileChange/requestApproval"
                || request.method == "applyPatchApproval",
            let itemId = request.params["itemId"]?.stringValue,
            let paths = fileChangePathsByItem[itemId], !paths.isEmpty
        {
            let outcome = await conflictCoordinator.arbitrate(
                sessionID: sessionID ?? SessionID(rawValue: "unknown"), task: "", files: paths)
            switch outcome.resolution {
            case .deferred, .cancelled:
                try? await rpc.reply(to: request.id, result: reply(for: .deny(reason: nil)))
                return
            case .proceed, .noConflict:
                if outcome.refreshNotice != nil {
                    try? await rpc.reply(to: request.id, result: reply(for: .deny(reason: nil)))
                    return
                }
            }
        }

        // Always-rule short-circuit: auto-answer without surfacing (RUNTIME §5).
        if let cached = await approvals.cachedDecision(toolName: fields.toolName, input: fields.input)
        {
            try? await rpc.reply(to: request.id, result: reply(for: cached.decision))
            return
        }

        let approval = ApprovalRequest(
            id: .generate(),
            sessionID: sessionID ?? SessionID(rawValue: "unknown"),
            toolCallID: request.params["itemId"]?.stringValue,
            toolName: fields.toolName,
            input: fields.input,
            title: fields.title,
            risk: fields.risk,
            createdAt: configuration.now())

        emit(.approvalRequested(approval), nativeType: request.method)
        let resolved = await approvals.waitForDecision(approval)
        emit(.approvalResolved(resolved), nativeType: nil)

        try? await rpc.reply(to: request.id, result: reply(for: resolved.decision))
        if case .cancelRun = resolved.decision {
            interruptRequested = true
            await interruptCurrentTurn()
        }
    }

    private func handlePermissionsApproval(
        _ request: JSONRPCConnection.InboundRequest, rpc: JSONRPCConnection
    ) async {
        let requested = request.params["permissions"] ?? .object([:])
        let approval = ApprovalRequest(
            id: .generate(),
            sessionID: sessionID ?? SessionID(rawValue: "unknown"),
            toolCallID: request.params["itemId"]?.stringValue,
            toolName: "permissions",
            input: requested,
            title: "Grant permissions",
            risk: .unknown,
            createdAt: configuration.now())

        emit(.approvalRequested(approval), nativeType: request.method)
        let resolved = await approvals.waitForDecision(approval)
        emit(.approvalResolved(resolved), nativeType: nil)

        try? await rpc.reply(
            to: request.id,
            result: CodexDecisionMapping.permissionsReply(
                for: resolved.decision, requestedPermissions: requested))
        if case .cancelRun = resolved.decision {
            interruptRequested = true
            await interruptCurrentTurn()
        }
    }

    /// Map an approval request's params onto a normalized `ApprovalRequest`. A command
    /// approval becomes a `Bash{command}` call (shared risk classifier + command-summary UI);
    /// a file-change approval carries no path in its params (it references the already-announced
    /// `fileChange` item by id), so it surfaces as a `.write` with an explanatory title.
    static func reviewApprovalFields(_ request: JSONRPCConnection.InboundRequest)
        -> (toolName: String, input: JSONValue, title: String, risk: Risk)
    {
        let p = request.params
        let isFileChange =
            request.method == "item/fileChange/requestApproval"
            || request.method == "applyPatchApproval"
        if isFileChange {
            let reason = p["reason"]?.stringValue
            let input = JSONValue.object(reason.map { ["reason": .string($0)] } ?? [:])
            return ("Edit", input, reason ?? "Apply file changes", .write)
        }
        let command = p["command"]?.stringValue ?? ""
        let input = JSONValue.object(["command": .string(command)])
        return (
            "Bash", input,
            RiskClassifier.title(toolName: "Bash", input: input),
            RiskClassifier.classify(toolName: "Bash", input: input)
        )
    }

    // MARK: - Helpers

    /// `UserInput[]` for a turn (ADAPTERS §2.3). Context parts render as labeled text. The
    /// `text` variant carries a required `text_elements` array (snake_case in the schema).
    static func userInput(for input: AgentInput) -> JSONValue {
        func textInput(_ text: String) -> JSONValue {
            .object(["type": .string("text"), "text": .string(text), "text_elements": .array([])])
        }
        var items: [JSONValue] = []
        for part in input.parts {
            switch part {
            case .text(let text):
                items.append(textInput(text))
            case .context(let label, let body):
                items.append(textInput("[\(label)]\n\(body)"))
            }
        }
        return .array(items)
    }

    static func outcome(forTurnStatus status: String?, interrupted: Bool) -> RunFinished.Outcome {
        if interrupted { return .interrupted }
        switch status {
        case "completed": return .completed
        case "interrupted": return .interrupted
        case "failed": return .errored
        default: return .completed
        }
    }

    private func emit(_ kind: AgentEvent.Kind, nativeType: String?) {
        provisionalSeq += 1
        continuation?.yield(
            AgentEvent(
                sessionID: sessionID ?? SessionID(rawValue: "unknown"),
                seq: provisionalSeq,
                at: configuration.now(),
                backend: .codex,
                nativeType: nativeType,
                kind: kind))
    }
}

/// Ring of recent stderr lines for the synthesized error message on an abnormal exit.
private final class CodexStderrTail: @unchecked Sendable {
    private let lock = NSLock()
    private var lines: [String] = []

    func append(_ line: String) {
        lock.lock()
        defer { lock.unlock() }
        lines.append(line)
        if lines.count > 50 { lines.removeFirst(lines.count - 50) }
    }

    func joined() -> String {
        lock.lock()
        defer { lock.unlock() }
        return lines.suffix(10).joined(separator: "\n")
    }
}
