import CoreFoundation
import Foundation

enum MacNodeCodexThreadCatalogContract {
    static let pluginId = "codex"
    static let capability = "codex-app-server-threads"
    static let listCommand = "codex.appServer.threads.list.v1"
    static let turnsCommand = "codex.appServer.thread.turns.list.v1"
    static let commands = [listCommand, turnsCommand]
}

enum MacNodeCodexThreadCatalog {
    struct ResolvedInvocation: Equatable, Sendable {
        var executable: String
        var arguments: [String]
        var cwd: URL?
        var clearEnv: [String] = []
    }

    enum CatalogError: LocalizedError, Equatable {
        case invalidParams(String)
        case catalogDisabled
        case invalidAppServerConfiguration
        case codexUnavailable
        case unsupportedAppServerTransport
        case unsupportedAppServerHomeScope
        case appServerUnavailable
        case responseTooLarge
        case timedOut

        var errorDescription: String? {
            switch self {
            case let .invalidParams(message):
                "INVALID_REQUEST: \(message)"
            case .catalogDisabled:
                "UNAVAILABLE: Codex session catalog is disabled"
            case .invalidAppServerConfiguration:
                "UNAVAILABLE: Codex app-server configuration is invalid"
            case .codexUnavailable:
                "UNAVAILABLE: Codex CLI not found"
            case .unsupportedAppServerTransport:
                "UNAVAILABLE: paired macOS Codex catalog supports appServer.transport stdio only"
            case .unsupportedAppServerHomeScope:
                "UNAVAILABLE: paired macOS Codex catalog requires appServer.homeScope user"
            case .appServerUnavailable:
                "UNAVAILABLE: Codex app-server request failed"
            case .responseTooLarge:
                "UNAVAILABLE: Codex app-server response exceeded the size limit"
            case .timedOut:
                "UNAVAILABLE: Codex app-server request timed out"
            }
        }

        var isInvalidRequest: Bool {
            if case .invalidParams = self {
                return true
            }
            return false
        }
    }

    private struct ListParams {
        var cursor: String?
        var limit = 50
        var searchTerm: String?
        var cwd: String?
    }

    private struct TurnParams {
        var threadId: String
        var cursor: String?
        var limit = 20
    }

    private struct ConfiguredAppServer {
        var transport: String?
        var homeScope: String?
        var command: String?
        var args: [String]?
        var clearEnv: [String]
    }

    private struct ConfiguredPlugin {
        var supervisionEnabled: Bool
        var appServer: ConfiguredAppServer?
    }

    private enum StringOverflow {
        case omit
        case truncate
    }

    private static let defaultArguments = ["app-server", "--listen", "stdio://"]
    private static let commandEnvironmentKey = "OPENCLAW_CODEX_APP_SERVER_BIN"
    private static let argumentsEnvironmentKey = "OPENCLAW_CODEX_APP_SERVER_ARGS"
    private static let pluginConfigKeys = Set([
        "codexDynamicToolsLoading",
        "codexDynamicToolsExclude",
        "discovery",
        "computerUse",
        "codexPlugins",
        "supervision",
        "appServer",
    ])
    private static let appServerConfigKeys = Set([
        "mode",
        "transport",
        "homeScope",
        "command",
        "args",
        "url",
        "authToken",
        "headers",
        "clearEnv",
        "remoteWorkspaceRoot",
        "codeModeOnly",
        "requestTimeoutMs",
        "turnCompletionIdleTimeoutMs",
        "postToolRawAssistantCompletionIdleTimeoutMs",
        "approvalPolicy",
        "sandbox",
        "approvalsReviewer",
        "serviceTier",
        "networkProxy",
        "defaultWorkspaceDir",
        "experimental",
    ])
    static let defaultMacOSChatGPTAppExecutable =
        "/Applications/ChatGPT.app/Contents/Resources/codex"
    static let defaultUserMacOSChatGPTAppExecutable = FileManager.default.homeDirectoryForCurrentUser
        .appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex")
        .path
    static let defaultMacOSAppExecutable = "/Applications/Codex.app/Contents/Resources/codex"
    static let defaultUserMacOSAppExecutable = FileManager.default.homeDirectoryForCurrentUser
        .appendingPathComponent("Applications/Codex.app/Contents/Resources/codex")
        .path
    static let defaultMacOSBetaAppExecutable = "/Applications/Codex Beta.app/Contents/Resources/codex"
    static let defaultUserMacOSBetaAppExecutable = FileManager.default.homeDirectoryForCurrentUser
        .appendingPathComponent("Applications/Codex Beta.app/Contents/Resources/codex")
        .path
    static let defaultTimeoutSeconds: Double = 60
    static let defaultIdleTimeoutSeconds: Double = 30
    private static let maxSessionIdLength = 256
    private static let maxSessionNameLength = 500
    private static let maxCwdLength = 4096
    private static let maxStatusLength = 64
    private static let maxMetadataLength = 500
    private static let maxActiveFlags = 16
    private static let maxActiveFlagLength = 128
    private static let maxCursorLength = 4096
    private static let maxSearchPageCalls = 4

    private struct WireResponse: Encodable {
        var sessions: [WireSession]
        var nextCursor: String?
        var backwardsCursor: String?
    }

    private struct WireSession: Encodable {
        var threadId: String
        var sessionId: String?
        var name: String?
        var cwd: String?
        var status: String
        var activeFlags: [String]?
        var createdAt: Int64?
        var updatedAt: Int64?
        var recencyAt: Int64?
        var source: String?
        var modelProvider: String?
        var cliVersion: String?
        var gitBranch: String?
        var archived: Bool
    }

    static func list(
        paramsJSON: String?,
        loadRoot: () -> [String: Any]) async throws -> String
    {
        let client = CodexAppServerThreadClient()
        return try await self.withEphemeralClient(client) {
            try await self.list(paramsJSON: paramsJSON, loadRoot: loadRoot, client: client)
        }
    }

    static func list(
        paramsJSON: String?,
        loadRoot: () -> [String: Any],
        client: CodexAppServerThreadClient) async throws -> String
    {
        let params = try self.decodeParams(paramsJSON)
        // Keep authorization and spawn selection on one config snapshot. A second read could
        // otherwise approve one command and launch another after a concurrent config rewrite.
        let root = loadRoot()
        guard self.shouldAdvertise(root: root) else {
            throw CatalogError.catalogDisabled
        }
        let invocation = try self.resolveInvocation(root: root)
        return try await self.list(params: params, invocation: invocation, client: client)
    }

    static func turns(
        paramsJSON: String?,
        loadRoot: () -> [String: Any],
        client: CodexAppServerThreadClient) async throws -> String
    {
        let params = try decodeTurnParams(paramsJSON)
        let root = loadRoot()
        guard self.shouldAdvertise(root: root) else {
            throw CatalogError.catalogDisabled
        }
        return try await self.turns(
            params: params,
            invocation: resolveInvocation(root: root),
            client: client)
    }

    static func turns(
        paramsJSON: String?,
        executable: String,
        arguments: [String]? = nil,
        cwd: URL? = nil,
        clearEnv: [String] = [],
        timeoutSeconds: Double = MacNodeCodexThreadCatalog.defaultTimeoutSeconds,
        maxLineBytes: Int = 20 * 1024 * 1024) async throws -> String
    {
        let params = try decodeTurnParams(paramsJSON)
        let client = CodexAppServerThreadClient()
        return try await self.withEphemeralClient(client) {
            try await self.turns(
                params: params,
                invocation: ResolvedInvocation(
                    executable: executable,
                    arguments: arguments ?? self.defaultArguments,
                    cwd: cwd,
                    clearEnv: clearEnv),
                client: client,
                timeoutSeconds: timeoutSeconds,
                maxLineBytes: maxLineBytes)
        }
    }

    private static func turns(
        params: TurnParams,
        invocation: ResolvedInvocation,
        client: CodexAppServerThreadClient,
        timeoutSeconds: Double = MacNodeCodexThreadCatalog.defaultTimeoutSeconds,
        maxLineBytes: Int = 20 * 1024 * 1024) async throws -> String
    {
        let deadline = Date().addingTimeInterval(max(0.01, timeoutSeconds))
        try await self.requireCatalogThread(
            params.threadId,
            invocation: invocation,
            client: client,
            deadline: deadline)
        var requestParams: [String: Any] = [
            "threadId": params.threadId,
            "limit": params.limit,
            "sortDirection": "desc",
            "itemsView": "full",
        ]
        if let cursor = params.cursor {
            requestParams["cursor"] = cursor
        }
        let resultData = try await client.request(
            invocation: invocation,
            method: "thread/turns/list",
            requestParams: requestParams,
            timeoutSeconds: max(0.01, deadline.timeIntervalSinceNow),
            maxLineBytes: maxLineBytes)
        guard let payload = String(data: resultData, encoding: .utf8) else {
            throw CatalogError.appServerUnavailable
        }
        return payload
    }

    private static func requireCatalogThread(
        _ threadId: String,
        invocation: ResolvedInvocation,
        client: CodexAppServerThreadClient,
        deadline: Date) async throws
    {
        var cursor: String?
        var seenCursors = Set<String>()
        for _ in 0..<100 {
            let remainingTimeout = deadline.timeIntervalSinceNow
            guard remainingTimeout > 0 else { throw CatalogError.timedOut }
            let payload = try await list(
                params: ListParams(cursor: cursor, limit: 100),
                invocation: invocation,
                client: client,
                timeoutSeconds: remainingTimeout)
            guard let response = try JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any],
                  let sessions = response["sessions"] as? [[String: Any]]
            else {
                throw CatalogError.appServerUnavailable
            }
            if sessions.contains(where: { $0["threadId"] as? String == threadId }) {
                return
            }
            guard let nextCursor = response["nextCursor"] as? String,
                  !nextCursor.isEmpty,
                  !seenCursors.contains(nextCursor)
            else { break }
            seenCursors.insert(nextCursor)
            cursor = nextCursor
        }
        throw CatalogError.invalidParams(
            "Codex session is not a non-archived interactive CLI or VS Code session")
    }

    static func shouldAdvertise(root: [String: Any]? = nil) -> Bool {
        let root = root ?? OpenClawConfigFile.loadDict()
        guard OpenClawConfigFile.configuredBundledPluginAllowed(
            MacNodeCodexThreadCatalogContract.pluginId,
            root: root)
        else { return false }
        let plugin: ConfiguredPlugin?
        do {
            plugin = try self.configuredPlugin(root: root)
        } catch {
            return false
        }
        guard plugin?.supervisionEnabled == true else { return false }
        return self.supportsConfiguredTransport(plugin?.appServer) &&
            self.supportsConfiguredHomeScope(plugin?.appServer)
    }

    static func list(
        paramsJSON: String?,
        executable: String,
        arguments: [String]? = nil,
        cwd: URL? = nil,
        clearEnv: [String] = [],
        timeoutSeconds: Double = MacNodeCodexThreadCatalog.defaultTimeoutSeconds,
        maxLineBytes: Int = 5 * 1024 * 1024) async throws -> String
    {
        let params = try self.decodeParams(paramsJSON)
        let client = CodexAppServerThreadClient()
        return try await self.withEphemeralClient(client) {
            try await self.list(
                params: params,
                invocation: ResolvedInvocation(
                    executable: executable,
                    arguments: arguments ?? self.defaultArguments,
                    cwd: cwd,
                    clearEnv: clearEnv),
                client: client,
                timeoutSeconds: timeoutSeconds,
                maxLineBytes: maxLineBytes)
        }
    }

    private static func list(
        params: ListParams,
        invocation: ResolvedInvocation,
        client: CodexAppServerThreadClient,
        timeoutSeconds: Double = MacNodeCodexThreadCatalog.defaultTimeoutSeconds,
        maxLineBytes: Int = 5 * 1024 * 1024) async throws -> String
    {
        guard params.searchTerm != nil else {
            let resultData = try await client.request(
                invocation: invocation,
                method: "thread/list",
                requestParams: self.appServerParams(params),
                timeoutSeconds: timeoutSeconds,
                maxLineBytes: maxLineBytes)
            return try self.normalize(listResultData: resultData)
        }

        // Native search also inspects transcript-derived previews. Scan a bounded
        // number of unsearched pages and filter normalized titles locally instead.
        let deadline = Date().addingTimeInterval(max(0.01, timeoutSeconds))
        var sessions: [WireSession] = []
        var cursor = params.cursor
        var seenCursors = Set(cursor.map { [$0] } ?? [])
        var backwardsCursor: String?
        var nextCursor: String?

        for pageIndex in 0..<self.maxSearchPageCalls {
            let remainingLimit = params.limit - sessions.count
            guard remainingLimit > 0 else { break }
            let remainingTimeout = deadline.timeIntervalSinceNow
            guard remainingTimeout > 0 else { throw CatalogError.timedOut }

            var pageParams = params
            pageParams.cursor = cursor
            pageParams.limit = remainingLimit
            let resultData = try await client.request(
                invocation: invocation,
                method: "thread/list",
                requestParams: self.appServerParams(pageParams),
                timeoutSeconds: remainingTimeout,
                maxLineBytes: maxLineBytes)
            let page = try self.normalizedResponse(
                listResultData: resultData,
                searchTerm: params.searchTerm)
            if pageIndex == 0 {
                backwardsCursor = page.backwardsCursor
            }
            sessions.append(contentsOf: page.sessions)

            guard let candidateCursor = page.nextCursor else {
                nextCursor = nil
                break
            }
            guard !seenCursors.contains(candidateCursor) else {
                // A repeated opaque cursor cannot make forward progress. Stop the
                // page chain instead of handing callers a permanent load-more loop.
                nextCursor = nil
                break
            }
            nextCursor = candidateCursor
            if sessions.count >= params.limit || pageIndex + 1 == self.maxSearchPageCalls {
                break
            }
            seenCursors.insert(candidateCursor)
            cursor = candidateCursor
        }

        return try self.encodeResponse(WireResponse(
            sessions: sessions,
            nextCursor: nextCursor,
            backwardsCursor: backwardsCursor))
    }

    private static func withEphemeralClient<T>(
        _ client: CodexAppServerThreadClient,
        operation: () async throws -> T) async throws -> T
    {
        do {
            let result = try await operation()
            await client.shutdown()
            return result
        } catch {
            await client.shutdown()
            throw error
        }
    }
}

extension MacNodeCodexThreadCatalog {
    static func resolveInvocation(
        root: [String: Any]? = nil,
        environment: [String: String] = ProcessInfo.processInfo.environment,
        searchPaths: [String]? = nil,
        currentDirectoryURL: URL = URL(
            fileURLWithPath: FileManager.default.currentDirectoryPath,
            isDirectory: true),
        defaultMacOSChatGPTAppExecutable: String = MacNodeCodexThreadCatalog
            .defaultMacOSChatGPTAppExecutable,
        defaultUserMacOSChatGPTAppExecutable: String = MacNodeCodexThreadCatalog
            .defaultUserMacOSChatGPTAppExecutable,
        defaultMacOSAppExecutable: String = MacNodeCodexThreadCatalog.defaultMacOSAppExecutable,
        defaultUserMacOSAppExecutable: String = MacNodeCodexThreadCatalog.defaultUserMacOSAppExecutable,
        defaultMacOSBetaAppExecutable: String = MacNodeCodexThreadCatalog.defaultMacOSBetaAppExecutable,
        defaultUserMacOSBetaAppExecutable: String = MacNodeCodexThreadCatalog.defaultUserMacOSBetaAppExecutable) throws
        -> ResolvedInvocation
    {
        let root = root ?? OpenClawConfigFile.loadDict()
        let appServer = try self.configuredPlugin(root: root)?.appServer
        guard self.supportsConfiguredTransport(appServer) else {
            throw CatalogError.unsupportedAppServerTransport
        }
        guard self.supportsConfiguredHomeScope(appServer) else {
            throw CatalogError.unsupportedAppServerHomeScope
        }
        let configuredCommand = self.nonEmptyString(appServer?.command)
        let environmentCommand = self.nonEmptyString(environment[self.commandEnvironmentKey])
        let customCommand = configuredCommand ?? environmentCommand
        let rawCommand = customCommand ?? "codex"
        let command = rawCommand.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !command.isEmpty else { throw CatalogError.codexUnavailable }

        let executable: String?
        var installedAppExecutable: String?
        if customCommand == nil {
            installedAppExecutable = [
                defaultMacOSChatGPTAppExecutable,
                defaultUserMacOSChatGPTAppExecutable,
                defaultMacOSAppExecutable,
                defaultUserMacOSAppExecutable,
                defaultMacOSBetaAppExecutable,
                defaultUserMacOSBetaAppExecutable,
            ]
                .first { FileManager.default.isExecutableFile(atPath: $0) }
        }
        if let installedAppExecutable {
            executable = installedAppExecutable
        } else if command.contains("/") || command.hasPrefix("~") {
            let url = self.resolvePath(command, relativeTo: currentDirectoryURL)
            executable = FileManager.default.isExecutableFile(atPath: url.path) ? url.path : nil
        } else {
            executable = CommandResolver.findExecutable(named: command, searchPaths: searchPaths)
        }
        guard let executable else { throw CatalogError.codexUnavailable }
        let configuredArguments = appServer?.args ?? environment[self.argumentsEnvironmentKey].map {
            self.splitShellWords($0)
        }
        let arguments = if let configuredArguments, !configuredArguments.isEmpty {
            configuredArguments
        } else {
            self.defaultArguments
        }
        return ResolvedInvocation(
            executable: executable,
            arguments: arguments,
            cwd: nil,
            clearEnv: appServer?.clearEnv ?? [])
    }

    private static func supportsConfiguredTransport(_ appServer: ConfiguredAppServer?) -> Bool {
        appServer?.transport == nil || appServer?.transport == "stdio"
    }

    private static func supportsConfiguredHomeScope(_ appServer: ConfiguredAppServer?) -> Bool {
        appServer?.homeScope == nil || appServer?.homeScope == "user"
    }

    private static func configuredPlugin(root: [String: Any]) throws -> ConfiguredPlugin? {
        guard let entry = OpenClawConfigFile.pluginEntry(
            MacNodeCodexThreadCatalogContract.pluginId,
            root: root)
        else { return nil }
        guard let rawConfig = entry["config"] else {
            return ConfiguredPlugin(supervisionEnabled: false, appServer: nil)
        }
        guard let config = rawConfig as? [String: Any] else {
            throw CatalogError.invalidAppServerConfiguration
        }
        try self.validateKeys(config, allowed: self.pluginConfigKeys)
        try self.validateEnum(
            config,
            key: "codexDynamicToolsLoading",
            allowed: ["searchable", "direct"])
        try self.validateStringArray(config, key: "codexDynamicToolsExclude")
        try self.validateDiscoveryConfig(config["discovery"])
        try self.validateComputerUseConfig(config["computerUse"])
        // `codexPlugins` is intentionally parsed independently by readCodexPluginConfig.
        // Its validity does not decide whether supervision remains enabled.
        let supervisionEnabled = try self.validateSupervisionConfig(config["supervision"])
        let appServer = try self.validateAppServerConfig(config["appServer"])
        return ConfiguredPlugin(
            supervisionEnabled: supervisionEnabled,
            appServer: appServer)
    }

    private static func validateAppServerConfig(_ rawValue: Any?) throws -> ConfiguredAppServer? {
        guard let rawValue else { return nil }
        guard let appServer = rawValue as? [String: Any] else {
            throw CatalogError.invalidAppServerConfiguration
        }
        try self.validateKeys(appServer, allowed: self.appServerConfigKeys)
        try self.validateEnum(appServer, key: "mode", allowed: ["yolo", "guardian"])
        try self.validateEnum(appServer, key: "transport", allowed: ["stdio", "websocket", "unix"])
        try self.validateEnum(appServer, key: "homeScope", allowed: ["agent", "user"])
        try self.validateString(appServer, key: "command")
        try self.validateString(appServer, key: "url")
        try self.validateSecretInput(appServer["authToken"])
        try self.validateHeaders(appServer["headers"])
        try self.validateStringArray(appServer, key: "clearEnv")
        try self.validateNonEmptyString(appServer, key: "remoteWorkspaceRoot")
        try self.validateBoolean(appServer, key: "codeModeOnly")
        try self.validatePositiveNumber(appServer, key: "requestTimeoutMs")
        try self.validatePositiveNumber(appServer, key: "turnCompletionIdleTimeoutMs")
        try self.validatePositiveNumber(
            appServer,
            key: "postToolRawAssistantCompletionIdleTimeoutMs")
        try self.validateEnum(
            appServer,
            key: "approvalPolicy",
            allowed: ["never", "on-request", "on-failure", "untrusted"])
        try self.validateEnum(
            appServer,
            key: "sandbox",
            allowed: ["read-only", "workspace-write", "danger-full-access"])
        try self.validateEnum(
            appServer,
            key: "approvalsReviewer",
            allowed: ["user", "auto_review", "guardian_subagent"])
        try self.validateStringOrNull(appServer, key: "serviceTier")
        try self.validateNetworkProxyConfig(appServer["networkProxy"])
        try self.validateString(appServer, key: "defaultWorkspaceDir")
        try self.validateExperimentalConfig(appServer["experimental"])

        let transport = try self.optionalConfiguredString(appServer, key: "transport")
        let homeScope = try self.optionalConfiguredString(appServer, key: "homeScope")
        let command = try self.optionalConfiguredString(appServer, key: "command")
        let args = try self.configuredArguments(appServer, key: "args")
        let clearEnv = try self.configuredStringList(appServer, key: "clearEnv")

        return ConfiguredAppServer(
            transport: transport,
            homeScope: homeScope,
            command: self.nonEmptyString(command),
            args: args,
            clearEnv: clearEnv)
    }

    private static func optionalConfiguredString(
        _ object: [String: Any],
        key: String) throws -> String?
    {
        guard let value = object[key] else { return nil }
        guard let value = value as? String else {
            throw CatalogError.invalidAppServerConfiguration
        }
        return value
    }

    private static func configuredArguments(
        _ object: [String: Any],
        key: String) throws -> [String]?
    {
        guard let value = object[key] else { return nil }
        let args: [String]
        if let values = value as? [Any] {
            guard values.allSatisfy({ $0 is String }) else {
                throw CatalogError.invalidAppServerConfiguration
            }
            args = values.compactMap(self.nonEmptyString)
        } else if let value = value as? String {
            args = self.splitShellWords(value)
        } else {
            throw CatalogError.invalidAppServerConfiguration
        }
        return args
    }

    private static func configuredStringList(
        _ object: [String: Any],
        key: String) throws -> [String]
    {
        guard let value = object[key] else { return [] }
        guard let values = value as? [Any], values.allSatisfy({ $0 is String }) else {
            throw CatalogError.invalidAppServerConfiguration
        }
        return values.compactMap(self.nonEmptyString)
    }

    private static func validateDiscoveryConfig(_ rawValue: Any?) throws {
        guard let rawValue else { return }
        guard let config = rawValue as? [String: Any] else {
            throw CatalogError.invalidAppServerConfiguration
        }
        try self.validateKeys(config, allowed: ["enabled", "timeoutMs"])
        try self.validateBoolean(config, key: "enabled")
        try self.validatePositiveNumber(config, key: "timeoutMs")
    }

    private static func validateComputerUseConfig(_ rawValue: Any?) throws {
        guard let rawValue else { return }
        guard let config = rawValue as? [String: Any] else {
            throw CatalogError.invalidAppServerConfiguration
        }
        try self.validateKeys(config, allowed: [
            "enabled",
            "autoInstall",
            "marketplaceDiscoveryTimeoutMs",
            "marketplaceSource",
            "marketplacePath",
            "marketplaceName",
            "pluginName",
            "mcpServerName",
        ])
        try self.validateBoolean(config, key: "enabled")
        try self.validateBoolean(config, key: "autoInstall")
        try self.validatePositiveNumber(config, key: "marketplaceDiscoveryTimeoutMs")
        for key in [
            "marketplaceSource",
            "marketplacePath",
            "marketplaceName",
            "pluginName",
            "mcpServerName",
        ] {
            try self.validateString(config, key: key)
        }
    }

    private static func validateSupervisionConfig(_ rawValue: Any?) throws -> Bool {
        guard let rawValue else { return false }
        guard let config = rawValue as? [String: Any] else {
            throw CatalogError.invalidAppServerConfiguration
        }
        try self.validateKeys(config, allowed: [
            "enabled",
            "endpoints",
            "allowRawTranscripts",
            "allowWriteControls",
        ])
        try self.validateBoolean(config, key: "enabled")
        try self.validateBoolean(config, key: "allowRawTranscripts")
        try self.validateBoolean(config, key: "allowWriteControls")
        if let rawEndpoints = config["endpoints"] {
            guard let endpoints = rawEndpoints as? [Any] else {
                throw CatalogError.invalidAppServerConfiguration
            }
            for endpoint in endpoints {
                try self.validateSupervisionEndpoint(endpoint)
            }
        }
        return self.literalBoolean(config["enabled"]) == true
    }

    private static func validateSupervisionEndpoint(_ rawValue: Any) throws {
        guard let endpoint = rawValue as? [String: Any] else {
            throw CatalogError.invalidAppServerConfiguration
        }
        let transport = endpoint["transport"] as? String
        if transport == nil || transport == "stdio-proxy" {
            try self.validateKeys(
                endpoint,
                allowed: ["id", "label", "transport", "command", "args", "cwd"])
            for key in ["id", "label", "command", "cwd"] {
                try self.validateString(endpoint, key: key)
            }
            try self.validateEnum(endpoint, key: "transport", allowed: ["stdio-proxy"])
            try self.validateStringArray(endpoint, key: "args")
            return
        }
        guard transport == "websocket" else {
            throw CatalogError.invalidAppServerConfiguration
        }
        try self.validateKeys(
            endpoint,
            allowed: ["id", "label", "transport", "url", "authTokenEnv"])
        for key in ["id", "label", "authTokenEnv"] {
            try self.validateString(endpoint, key: key)
        }
        guard endpoint["url"] is String else {
            throw CatalogError.invalidAppServerConfiguration
        }
    }

    private static func validateNetworkProxyConfig(_ rawValue: Any?) throws {
        guard let rawValue else { return }
        guard let config = rawValue as? [String: Any] else {
            throw CatalogError.invalidAppServerConfiguration
        }
        try self.validateKeys(config, allowed: [
            "enabled",
            "profileName",
            "baseProfile",
            "mode",
            "domains",
            "unixSockets",
            "proxyUrl",
            "socksUrl",
            "enableSocks5",
            "enableSocks5Udp",
            "allowUpstreamProxy",
            "allowLocalBinding",
            "dangerouslyAllowNonLoopbackProxy",
            "dangerouslyAllowAllUnixSockets",
        ])
        for key in [
            "enabled",
            "enableSocks5",
            "enableSocks5Udp",
            "allowUpstreamProxy",
            "allowLocalBinding",
            "dangerouslyAllowNonLoopbackProxy",
            "dangerouslyAllowAllUnixSockets",
        ] {
            try self.validateBoolean(config, key: key)
        }
        for key in ["profileName", "proxyUrl", "socksUrl"] {
            try self.validateNonEmptyString(config, key: key)
        }
        try self.validateEnum(config, key: "baseProfile", allowed: ["read-only", "workspace"])
        try self.validateEnum(config, key: "mode", allowed: ["limited", "full"])
        try self.validateStringRecord(
            config,
            key: "domains",
            allowedValues: ["allow", "deny"])
        try self.validateStringRecord(
            config,
            key: "unixSockets",
            allowedValues: ["allow", "none"])
    }

    private static func validateExperimentalConfig(_ rawValue: Any?) throws {
        guard let rawValue else { return }
        guard let config = rawValue as? [String: Any] else {
            throw CatalogError.invalidAppServerConfiguration
        }
        try self.validateKeys(config, allowed: ["sandboxExecServer"])
        try self.validateBoolean(config, key: "sandboxExecServer")
    }

    private static func validateHeaders(_ rawValue: Any?) throws {
        guard let rawValue else { return }
        guard let headers = rawValue as? [String: Any] else {
            throw CatalogError.invalidAppServerConfiguration
        }
        for value in headers.values {
            try self.validateSecretInput(value)
        }
    }

    private static func validateSecretInput(_ rawValue: Any?) throws {
        guard let rawValue else { return }
        if rawValue is String {
            return
        }
        guard let secret = rawValue as? [String: Any] else {
            throw CatalogError.invalidAppServerConfiguration
        }
        try self.validateKeys(secret, allowed: ["source", "provider", "id"])
        guard secret.keys.count == 3,
              let source = secret["source"] as? String,
              let provider = secret["provider"] as? String,
              let id = secret["id"] as? String,
              self.matches(provider, pattern: "^[a-z][a-z0-9_-]{0,63}$")
        else {
            throw CatalogError.invalidAppServerConfiguration
        }
        let validId = switch source {
        case "env":
            self.matches(id, pattern: "^[A-Z][A-Z0-9_]{0,127}$")
        case "file":
            self.validFileSecretId(id)
        case "exec":
            self.matches(id, pattern: "^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$") &&
                !id.split(separator: "/", omittingEmptySubsequences: false)
                .contains(where: { $0 == "." || $0 == ".." })
        default:
            false
        }
        guard validId else { throw CatalogError.invalidAppServerConfiguration }
    }

    private static func validFileSecretId(_ value: String) -> Bool {
        if value == "value" {
            return true
        }
        guard value.hasPrefix("/") else { return false }
        return value.dropFirst().split(separator: "/", omittingEmptySubsequences: false)
            .allSatisfy { segment in
                segment.range(of: "~(?:[^01]|$)", options: .regularExpression) == nil
            }
    }

    private static func validateKeys(
        _ object: [String: Any],
        allowed: Set<String>) throws
    {
        guard object.keys.allSatisfy(allowed.contains) else {
            throw CatalogError.invalidAppServerConfiguration
        }
    }

    private static func validateBoolean(_ object: [String: Any], key: String) throws {
        guard let value = object[key] else { return }
        guard self.literalBoolean(value) != nil else {
            throw CatalogError.invalidAppServerConfiguration
        }
    }

    private static func validateString(_ object: [String: Any], key: String) throws {
        guard let value = object[key] else { return }
        guard value is String else { throw CatalogError.invalidAppServerConfiguration }
    }

    private static func validateNonEmptyString(_ object: [String: Any], key: String) throws {
        guard let value = object[key] else { return }
        guard let value = value as? String,
              !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        else {
            throw CatalogError.invalidAppServerConfiguration
        }
    }

    private static func validateStringOrNull(_ object: [String: Any], key: String) throws {
        guard let value = object[key] else { return }
        guard value is String || value is NSNull else {
            throw CatalogError.invalidAppServerConfiguration
        }
    }

    private static func validateEnum(
        _ object: [String: Any],
        key: String,
        allowed: Set<String>) throws
    {
        guard let value = object[key] else { return }
        guard let value = value as? String, allowed.contains(value) else {
            throw CatalogError.invalidAppServerConfiguration
        }
    }

    private static func validatePositiveNumber(_ object: [String: Any], key: String) throws {
        guard let value = object[key] else { return }
        guard let number = value as? NSNumber,
              CFGetTypeID(number) != CFBooleanGetTypeID(),
              number.doubleValue.isFinite,
              number.doubleValue > 0
        else {
            throw CatalogError.invalidAppServerConfiguration
        }
    }

    private static func validateStringArray(_ object: [String: Any], key: String) throws {
        guard let value = object[key] else { return }
        guard let values = value as? [Any], values.allSatisfy({ $0 is String }) else {
            throw CatalogError.invalidAppServerConfiguration
        }
    }

    private static func validateStringRecord(
        _ object: [String: Any],
        key: String,
        allowedValues: Set<String>) throws
    {
        guard let value = object[key] else { return }
        guard let values = value as? [String: Any],
              values.values.allSatisfy({ value in
                  guard let value = value as? String else { return false }
                  return allowedValues.contains(value)
              })
        else {
            throw CatalogError.invalidAppServerConfiguration
        }
    }

    private static func literalBoolean(_ value: Any?) -> Bool? {
        guard let number = value as? NSNumber,
              CFGetTypeID(number) == CFBooleanGetTypeID()
        else { return nil }
        return number.boolValue
    }

    private static func matches(_ value: String, pattern: String) -> Bool {
        value.range(of: pattern, options: .regularExpression) != nil
    }

    /// Match the TypeScript app-server config parser exactly: quotes only group
    /// words and backslashes are ordinary characters. The result never uses a shell.
    private static func splitShellWords(_ value: String) -> [String] {
        var words: [String] = []
        var current = ""
        var activeQuote: Character?
        for character in value {
            if let expectedQuote = activeQuote {
                if character == expectedQuote {
                    activeQuote = nil
                } else {
                    current.append(character)
                }
                continue
            }
            if character == "\"" || character == "'" {
                activeQuote = character
            } else if character.isWhitespace {
                if !current.isEmpty {
                    words.append(current)
                    current = ""
                }
            } else {
                current.append(character)
            }
        }
        if !current.isEmpty {
            words.append(current)
        }
        return words
    }

    private static func resolvePath(
        _ path: String,
        relativeTo base: URL,
        isDirectory: Bool = false) -> URL
    {
        let expanded = (path as NSString).expandingTildeInPath
        if expanded.hasPrefix("/") {
            return URL(fileURLWithPath: expanded, isDirectory: isDirectory).standardizedFileURL
        }
        return URL(fileURLWithPath: expanded, isDirectory: isDirectory, relativeTo: base)
            .standardizedFileURL
    }

    private static func decodeParams(_ paramsJSON: String?) throws -> ListParams {
        guard let paramsJSON, !paramsJSON.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
            return ListParams()
        }
        guard let data = paramsJSON.data(using: .utf8) else {
            throw CatalogError.invalidParams("parameters must be valid JSON")
        }
        let raw: Any
        do {
            raw = try JSONSerialization.jsonObject(with: data)
        } catch {
            throw CatalogError.invalidParams("parameters must be valid JSON")
        }
        guard let raw = raw as? [String: Any] else {
            throw CatalogError.invalidParams("parameters must be an object")
        }
        let allowed = Set(["cursor", "limit", "searchTerm", "cwd"])
        if let unknown = raw.keys.first(where: { !allowed.contains($0) }) {
            throw CatalogError.invalidParams("unknown Codex session catalog parameter: \(unknown)")
        }

        var params = ListParams()
        params.cursor = try self.optionalString(raw, key: "cursor", maxLength: self.maxCursorLength)
        params.searchTerm = try self.optionalString(
            raw,
            key: "searchTerm",
            maxLength: self.maxSessionNameLength)
        params.cwd = try self.optionalString(raw, key: "cwd", maxLength: self.maxCwdLength)
        if let value = raw["limit"] {
            guard let number = value as? NSNumber,
                  CFGetTypeID(number) != CFBooleanGetTypeID(),
                  number.doubleValue.rounded() == number.doubleValue,
                  (1...100).contains(number.intValue)
            else {
                throw CatalogError.invalidParams("limit must be an integer from 1 to 100")
            }
            params.limit = number.intValue
        }
        return params
    }

    private static func decodeTurnParams(_ paramsJSON: String?) throws -> TurnParams {
        guard let paramsJSON,
              let data = paramsJSON.data(using: .utf8),
              let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
        else {
            throw CatalogError.invalidParams("parameters must be a valid JSON object")
        }
        let allowed = Set(["threadId", "cursor", "limit"])
        if let unknown = raw.keys.first(where: { !allowed.contains($0) }) {
            throw CatalogError.invalidParams("unknown Codex transcript parameter: \(unknown)")
        }
        guard let threadId = try optionalString(
            raw,
            key: "threadId",
            maxLength: maxSessionIdLength)
        else {
            throw CatalogError.invalidParams("threadId is required")
        }
        var params = TurnParams(threadId: threadId)
        params.cursor = try self.optionalString(raw, key: "cursor", maxLength: self.maxCursorLength)
        if let value = raw["limit"] {
            guard let number = value as? NSNumber,
                  CFGetTypeID(number) != CFBooleanGetTypeID(),
                  number.doubleValue.rounded() == number.doubleValue,
                  (1...50).contains(number.intValue)
            else {
                throw CatalogError.invalidParams("limit must be an integer from 1 to 50")
            }
            params.limit = number.intValue
        }
        return params
    }

    private static func optionalString(
        _ params: [String: Any],
        key: String,
        maxLength: Int) throws -> String?
    {
        guard let value = params[key] else { return nil }
        guard let value = value as? String else {
            throw CatalogError.invalidParams("\(key) must be a string")
        }
        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return nil }
        guard trimmed.utf16.count <= maxLength else {
            throw CatalogError.invalidParams("\(key) must be at most \(maxLength) characters")
        }
        return trimmed
    }

    private static func appServerParams(_ params: ListParams) -> [String: Any] {
        var result: [String: Any] = [
            "limit": params.limit,
            "sortKey": "recency_at",
            "sortDirection": "desc",
            // An empty provider list means all providers. Omitting sourceKinds keeps
            // Codex's stable interactive-session default.
            "modelProviders": [String](),
            "archived": false,
            "useStateDbOnly": false,
        ]
        if let cursor = params.cursor {
            result["cursor"] = cursor
        }
        // Search only the normalized names below. App Server title search can
        // match transcript-derived previews that the node boundary withholds.
        if let cwd = params.cwd {
            result["cwd"] = cwd
        }
        return result
    }

    static func normalize(
        listResultData: Data,
        searchTerm: String? = nil) throws -> String
    {
        try self.encodeResponse(self.normalizedResponse(
            listResultData: listResultData,
            searchTerm: searchTerm))
    }

    private static func normalizedResponse(
        listResultData: Data,
        searchTerm: String? = nil) throws -> WireResponse
    {
        guard let result = try JSONSerialization.jsonObject(with: listResultData) as? [String: Any],
              let rawThreads = result["data"] as? [Any]
        else {
            throw CatalogError.appServerUnavailable
        }

        let sessions = rawThreads.compactMap { value -> WireSession? in
            guard let thread = value as? [String: Any],
                  let threadId = self.boundedString(
                      thread["id"],
                      maxLength: self.maxSessionIdLength)
            else { return nil }
            let statusRecord = thread["status"] as? [String: Any]
            let status = self.boundedString(
                statusRecord?["type"],
                maxLength: self.maxStatusLength) ?? "notLoaded"
            let decodedActiveFlags = (statusRecord?["activeFlags"] as? [Any])?
                .compactMap {
                    self.boundedString($0, maxLength: self.maxActiveFlagLength)
                }
                .prefix(self.maxActiveFlags)
            let activeFlags = decodedActiveFlags?.isEmpty == false ? decodedActiveFlags : nil
            let gitInfo = thread["gitInfo"] as? [String: Any]
            let name = self.boundedString(
                thread["name"],
                maxLength: self.maxSessionNameLength,
                overflow: .truncate)
            if let searchTerm,
               name?.range(of: searchTerm, options: [.caseInsensitive, .literal]) == nil
            {
                return nil
            }
            return WireSession(
                threadId: threadId,
                sessionId: self.boundedString(
                    thread["sessionId"],
                    maxLength: self.maxSessionIdLength),
                name: name,
                cwd: self.boundedString(thread["cwd"], maxLength: self.maxCwdLength),
                status: status,
                activeFlags: activeFlags.map(Array.init),
                createdAt: self.integer(thread["createdAt"]),
                updatedAt: self.integer(thread["updatedAt"]),
                recencyAt: self.integer(thread["recencyAt"]),
                source: self.sourceName(thread["source"]),
                modelProvider: self.boundedString(
                    thread["modelProvider"],
                    maxLength: self.maxMetadataLength,
                    overflow: .truncate),
                cliVersion: self.boundedString(
                    thread["cliVersion"],
                    maxLength: self.maxMetadataLength,
                    overflow: .truncate),
                gitBranch: self.boundedString(
                    gitInfo?["branch"],
                    maxLength: self.maxMetadataLength,
                    overflow: .truncate),
                archived: false)
        }

        return WireResponse(
            sessions: sessions,
            nextCursor: self.boundedCursor(result["nextCursor"]),
            backwardsCursor: self.boundedCursor(result["backwardsCursor"]))
    }

    private static func encodeResponse(_ response: WireResponse) throws -> String {
        let data = try JSONEncoder().encode(response)
        guard let json = String(data: data, encoding: .utf8) else {
            throw CatalogError.appServerUnavailable
        }
        return json
    }

    fileprivate static func nonEmptyString(_ value: Any?) -> String? {
        guard let value = value as? String else { return nil }
        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }

    private static func boundedString(
        _ value: Any?,
        maxLength: Int,
        overflow: StringOverflow = .omit) -> String?
    {
        guard let value = self.nonEmptyString(value) else { return nil }
        guard value.utf16.count > maxLength else { return value }
        guard case .truncate = overflow else { return nil }
        return self.truncateUTF16(value, maxLength: maxLength)
    }

    private static func boundedCursor(_ value: Any?) -> String? {
        guard let value = value as? String,
              self.nonEmptyString(value) != nil,
              value.utf16.count <= self.maxCursorLength
        else { return nil }
        // App Server cursors are opaque; do not trim or regenerate them after
        // locally filtering a page by its normalized session names.
        return value
    }

    private static func truncateUTF16(_ value: String, maxLength: Int) -> String {
        var result = ""
        var length = 0
        for scalar in value.unicodeScalars {
            let scalarLength = scalar.value > 0xFFFF ? 2 : 1
            guard length + scalarLength <= maxLength else { break }
            result.unicodeScalars.append(scalar)
            length += scalarLength
        }
        return result
    }

    private static func integer(_ value: Any?) -> Int64? {
        guard let number = value as? NSNumber,
              CFGetTypeID(number) != CFBooleanGetTypeID()
        else { return nil }
        return number.int64Value
    }

    private static func sourceName(_ value: Any?) -> String? {
        let raw: String? = if let source = self.nonEmptyString(value) {
            source
        } else if let source = value as? [String: Any],
                  let custom = self.nonEmptyString(source["custom"])
        {
            "custom:\(custom)"
        } else if let source = value as? [String: Any] {
            source.keys.min()
        } else {
            nil
        }
        return self.boundedString(
            raw,
            maxLength: self.maxMetadataLength,
            overflow: .truncate)
    }
}
