import Foundation

/// Translates `codex app-server` JSON-RPC notifications into normalized event kinds
/// (ADAPTERS §2.5). Lenient by contract: anything unrecognized becomes `.raw`, never a
/// trap — the CLI-drift early-warning signal (OBSERVABILITY A.5).
///
/// Wire shapes are confirmed against the schema `codex app-server generate-ts` emits for
/// **codex-cli 0.141.0**: items are tagged objects `{ "type": "agentMessage", "id": …, … }`
/// (serde `tag = "type"`) nested under a notification's `item` field, alongside `threadId`
/// and `turnId`. Per-item lifecycle is `item/started → (deltas) → item/completed` — there is
/// **no `item/updated`**.
///
/// Equivalent operations are normalized onto Claude's tool vocabulary (`Bash`, `WebSearch`,
/// `mcp__server__tool`) so the UI, risk classifier, and command summaries are shared across
/// backends — the cross-adapter symmetry the protocol is designed for (ADAPTERS §5).
public final class CodexAppServerDecoder {
    public struct Decoded: Sendable, Equatable {
        public let nativeType: String?
        public let kind: AgentEvent.Kind
        public init(nativeType: String?, kind: AgentEvent.Kind) {
            self.nativeType = nativeType
            self.kind = kind
        }
    }

    private enum Phase { case started, completed }

    /// Tool-call item ids already announced via `toolCallStarted`, so `item/completed`
    /// for a call whose `item/started` we already saw doesn't double-announce it.
    private var startedToolCallIDs: Set<String> = []

    public init() {}

    public func decode(method: String, params: JSONValue) -> [Decoded] {
        switch method {
        case "thread/started":
            // The backend emits the authoritative `sessionStarted` from the `thread/start`
            // response (which carries the real model id + cwd; the notification's Thread does
            // not). Suppress here to avoid a duplicate.
            return []
        case "turn/started":
            return []  // status only; the run is already `running`
        case "turn/completed":
            return decodeTurnCompleted(params, method: method)
        case "item/started":
            return decodeItem(params["item"], phase: .started, method: method)
        case "item/completed":
            return decodeItem(params["item"], phase: .completed, method: method)
        case "item/agentMessage/delta":
            return [textDelta(params, kind: .assistant, method: method)]
        case "item/reasoning/textDelta", "item/reasoning/summaryTextDelta":
            return [textDelta(params, kind: .reasoning, method: method)]
        case "thread/tokenUsage/updated":
            return decodeTokenUsage(params, method: method)
        case "error":
            return decodeError(params, method: method)
        case "item/reasoning/summaryPartAdded",
            "item/commandExecution/outputDelta", "item/commandExecution/terminalInteraction",
            "item/fileChange/outputDelta", "item/fileChange/patchUpdated",
            "item/mcpToolCall/progress", "item/plan/delta",
            "item/autoApprovalReview/started", "item/autoApprovalReview/completed":
            // Advisory streaming/structural notifications; authoritative content lands on
            // `item/completed`, so these are intentional no-ops.
            return []
        case "thread/status/changed", "mcpServer/startupStatus/updated",
            "account/rateLimits/updated", "remoteControl/status/changed":
            // Benign server lifecycle chatter observed in live captures (codex 0.141.0) — drop
            // rather than surface as `.raw` so the transcript isn't littered with infra status.
            return []
        default:
            return [Decoded(nativeType: method, kind: .raw(Raw(native: params)))]
        }
    }

    // MARK: - turn

    /// `turn/completed` carries `turn.status` ∈ completed/interrupted/failed/inProgress and a
    /// `turn.error` populated only on failure (there is no separate `turn/failed`).
    private func decodeTurnCompleted(_ params: JSONValue, method: String) -> [Decoded] {
        let turn = params["turn"] ?? params
        let status = turn["status"]?.stringValue
        var out: [Decoded] = []
        if status == "failed" {
            let message = turn["error"]?["message"]?.stringValue
                ?? turn["error"]?.stringValue ?? "Turn failed"
            out.append(
                Decoded(
                    nativeType: method,
                    kind: .error(
                        AgentError(recoverable: false, message: message, native: turn["error"]))))
        }
        out.append(
            Decoded(
                nativeType: method,
                kind: .turnCompleted(TurnCompleted(stopReason: status, usage: nil))))
        return out
    }

    // MARK: - items

    private func decodeItem(_ item: JSONValue?, phase: Phase, method: String) -> [Decoded] {
        guard let item, let type = item["type"]?.stringValue else {
            return [Decoded(nativeType: method, kind: .raw(Raw(native: item ?? .string(method))))]
        }
        switch type {
        case "agentMessage":
            // Final text lands on completion; the in-flight body streams as deltas.
            guard phase == .completed else { return [] }
            let chunk = TextChunk(
                messageID: item["id"]?.stringValue ?? "msg-unknown",
                text: item["text"]?.stringValue ?? "", isPartial: false)
            return [Decoded(nativeType: "item/\(type)", kind: .assistantText(chunk))]

        case "reasoning":
            guard phase == .completed else { return [] }
            let chunk = TextChunk(
                messageID: item["id"]?.stringValue ?? "msg-unknown",
                text: Self.joinedReasoning(item), isPartial: false)
            return [Decoded(nativeType: "item/\(type)", kind: .thinking(chunk))]

        case "commandExecution":
            return decodeCommandExecution(item, phase: phase)

        case "fileChange":
            return decodeFileChange(item, phase: phase)

        case "mcpToolCall":
            return decodeMCPToolCall(item, phase: phase)

        case "webSearch":
            return decodeWebSearch(item, phase: phase)

        case "userMessage":
            // We inject user turns into the transcript ourselves (RUNTIME §2.1).
            return []

        default:
            // plan / todoList / imageView / dynamicToolCall / unknown — pass through once.
            guard phase == .completed else { return [] }
            return [Decoded(nativeType: "item/\(type)", kind: .raw(Raw(native: item)))]
        }
    }

    /// `commandExecution` → a `Bash` tool call (normalized name) so it shares the
    /// command-summary UI + risk classifier. started → `toolCallStarted`; completed →
    /// `toolCallCompleted` + `toolResult` (isError from exit code / status).
    private func decodeCommandExecution(_ item: JSONValue, phase: Phase) -> [Decoded] {
        let id = item["id"]?.stringValue ?? "cmd-unknown"
        var input: [String: JSONValue] = ["command": .string(item["command"]?.stringValue ?? "")]
        if let cwd = item["cwd"]?.stringValue { input["cwd"] = .string(cwd) }
        let call = ToolCall(toolCallID: id, name: "Bash", input: .object(input))

        var out: [Decoded] = []
        if startedToolCallIDs.insert(id).inserted {
            out.append(Decoded(nativeType: "item/commandExecution", kind: .toolCallStarted(call)))
        }
        guard phase == .completed else { return out }
        out.append(Decoded(nativeType: "item/commandExecution", kind: .toolCallCompleted(call)))
        let exitCode = item["exitCode"]?.intValue
        let isError = (item["status"]?.stringValue == "failed") || (exitCode != nil && exitCode != 0)
        out.append(
            Decoded(
                nativeType: "item/commandExecution",
                kind: .toolResult(
                    ToolResult(
                        toolCallID: id,
                        content: item["aggregatedOutput"] ?? .string(""),
                        isError: isError))))
        return out
    }

    /// `fileChange` is native + authoritative (`emitsFileChangeEvents = true`); emit one
    /// `fileChange` per change once the patch is applied (`status: completed`). `kind` is an
    /// object `{type:"add"|"delete"|"update", move_path?}` — an `update` with `move_path` is a
    /// rename.
    private func decodeFileChange(_ item: JSONValue, phase: Phase) -> [Decoded] {
        guard phase == .completed, item["status"]?.stringValue == "completed" else { return [] }
        let id = item["id"]?.stringValue
        return (item["changes"]?.arrayValue ?? []).compactMap { change in
            guard let path = change["path"]?.stringValue else { return nil }
            let kindType = change["kind"]?["type"]?.stringValue
            let hasMove = change["kind"]?["move_path"]?.stringValue != nil
            let kind: FileChange.ChangeKind
            switch kindType {
            case "add": kind = .add
            case "delete": kind = .delete
            case "update": kind = hasMove ? .rename : .update
            default: kind = .update
            }
            return Decoded(
                nativeType: "item/fileChange",
                kind: .fileChange(FileChange(path: path, kind: kind, toolCallID: id, diffHint: nil)))
        }
    }

    /// The edited paths a `fileChange` item announces, keyed by item id — for conflict arbitration,
    /// which must acquire locks BEFORE the patch applies. The `requestApproval` carries no paths
    /// (only the item id), and `decodeFileChange` surfaces paths only at `completed` (after apply),
    /// so the backend captures them here from the item lifecycle (`item/started`·`updated`·
    /// `completed`, each carrying `changes[].path`). Returns nil for non-fileChange notifications.
    static func fileChangeItemPaths(method: String, params: JSONValue) -> (id: String, paths: [String])? {
        guard method == "item/started" || method == "item/updated" || method == "item/completed"
        else { return nil }
        let item = params["item"] ?? .null
        guard item["type"]?.stringValue == "fileChange", let id = item["id"]?.stringValue
        else { return nil }
        let paths = (item["changes"]?.arrayValue ?? []).compactMap { $0["path"]?.stringValue }
        return paths.isEmpty ? nil : (id, paths)
    }

    /// `mcpToolCall` → a `mcp__server__tool` call mirroring Claude's MCP naming.
    private func decodeMCPToolCall(_ item: JSONValue, phase: Phase) -> [Decoded] {
        let id = item["id"]?.stringValue ?? "mcp-unknown"
        let server = item["server"]?.stringValue ?? "server"
        let tool = item["tool"]?.stringValue ?? "tool"
        let call = ToolCall(
            toolCallID: id, name: "mcp__\(server)__\(tool)",
            input: item["arguments"] ?? .object([:]))

        var out: [Decoded] = []
        if startedToolCallIDs.insert(id).inserted {
            out.append(Decoded(nativeType: "item/mcpToolCall", kind: .toolCallStarted(call)))
        }
        guard phase == .completed else { return out }
        out.append(Decoded(nativeType: "item/mcpToolCall", kind: .toolCallCompleted(call)))
        out.append(
            Decoded(
                nativeType: "item/mcpToolCall",
                kind: .toolResult(
                    ToolResult(
                        toolCallID: id, content: item["result"] ?? .null,
                        isError: item["status"]?.stringValue == "failed"))))
        return out
    }

    private func decodeWebSearch(_ item: JSONValue, phase: Phase) -> [Decoded] {
        let id = item["id"]?.stringValue ?? "web-unknown"
        let call = ToolCall(
            toolCallID: id, name: "WebSearch",
            input: .object(["query": .string(item["query"]?.stringValue ?? "")]))
        var out: [Decoded] = []
        if startedToolCallIDs.insert(id).inserted {
            out.append(Decoded(nativeType: "item/webSearch", kind: .toolCallStarted(call)))
        }
        if phase == .completed {
            out.append(Decoded(nativeType: "item/webSearch", kind: .toolCallCompleted(call)))
        }
        return out
    }

    // MARK: - deltas / usage / errors

    private enum DeltaKind { case assistant, reasoning }

    private func textDelta(_ params: JSONValue, kind: DeltaKind, method: String) -> Decoded {
        let text = params["delta"]?.stringValue
            ?? params["text"]?.stringValue ?? ""
        let chunk = TextChunk(
            messageID: params["itemId"]?.stringValue ?? "msg-unknown",
            text: text, isPartial: true)
        switch kind {
        case .assistant: return Decoded(nativeType: method, kind: .assistantText(chunk))
        case .reasoning: return Decoded(nativeType: method, kind: .thinking(chunk))
        }
    }

    /// `thread/tokenUsage/updated` → `usage`. `tokenUsage.last` is this turn's final-call
    /// breakdown; cost in dollars is not reported (`costUSD = nil`, ADAPTERS §2.5).
    private func decodeTokenUsage(_ params: JSONValue, method: String) -> [Decoded] {
        let tokenUsage = params["tokenUsage"] ?? params
        let last = tokenUsage["last"] ?? tokenUsage
        let input = last["inputTokens"]?.intValue
        let cached = last["cachedInputTokens"]?.intValue
        let context = (input != nil || cached != nil) ? (input ?? 0) + (cached ?? 0) : nil
        let usage = Usage(
            inputTokens: input,
            cachedInputTokens: cached,
            cacheCreationInputTokens: nil,
            outputTokens: last["outputTokens"]?.intValue,
            reasoningTokens: last["reasoningOutputTokens"]?.intValue,
            costUSD: nil,
            contextInputTokens: context)
        return [Decoded(nativeType: method, kind: .usage(usage))]
    }

    private func decodeError(_ params: JSONValue, method: String) -> [Decoded] {
        let message = params["error"]?["message"]?.stringValue
            ?? params["error"]?.stringValue
            ?? params["message"]?.stringValue ?? "Error"
        let recoverable = params["willRetry"]?.boolValue ?? false
        return [
            Decoded(
                nativeType: method,
                kind: .error(AgentError(recoverable: recoverable, message: message, native: params)))
        ]
    }

    // MARK: - helpers

    private static func joinedReasoning(_ item: JSONValue) -> String {
        let summary = (item["summary"]?.arrayValue ?? []).compactMap(\.stringValue)
        let content = (item["content"]?.arrayValue ?? []).compactMap(\.stringValue)
        let pieces = summary + content
        if !pieces.isEmpty { return pieces.joined(separator: "\n") }
        return item["text"]?.stringValue ?? ""
    }
}
