import Foundation
import Testing

@testable import NucleicCore

/// `CodexAppServerDecoder`: app-server notifications → normalized `AgentEvent` kinds
/// (ADAPTERS §2.5). Inputs here are **source-shaped synthetic** payloads built from the
/// confirmed `openai/codex` wire contract (`v2/{item,turn}.rs`, tagged camelCase items);
/// they become byte-level golden fixtures once a live `codex` capture lands (M3).
@Suite struct CodexAppServerDecoderTests {
    private func tags(_ decoded: [CodexAppServerDecoder.Decoded]) -> [String] {
        decoded.map(\.kind.tag)
    }

    @Test func threadStartedIsSuppressed() {
        // The backend emits the authoritative sessionStarted from the thread/start response
        // (the notification's Thread lacks the resolved model id), so the decoder ignores it.
        let decoder = CodexAppServerDecoder()
        let out = decoder.decode(
            method: "thread/started",
            params: ["thread": ["id": "th_123", "modelProvider": "openai", "cwd": "/repo"]])
        #expect(out.isEmpty)
    }

    @Test func agentMessageCompletedIsFullAssistantText() {
        let decoder = CodexAppServerDecoder()
        // started carries empty text → no event; completed carries the final text.
        #expect(decoder.decode(
            method: "item/started",
            params: ["item": ["type": "agentMessage", "id": "m1", "text": ""]]).isEmpty)
        let out = decoder.decode(
            method: "item/completed",
            params: ["item": ["type": "agentMessage", "id": "m1", "text": "All done."]])
        guard case .assistantText(let chunk) = out.first?.kind else { Issue.record("not assistantText"); return }
        #expect(chunk.text == "All done.")
        #expect(chunk.isPartial == false)
    }

    @Test func agentMessageDeltaIsPartial() {
        let decoder = CodexAppServerDecoder()
        let out = decoder.decode(
            method: "item/agentMessage/delta", params: ["itemId": "m1", "delta": "Hel"])
        guard case .assistantText(let chunk) = out.first?.kind else { Issue.record("not assistantText"); return }
        #expect(chunk.text == "Hel")
        #expect(chunk.isPartial == true)
        #expect(chunk.messageID == "m1")
    }

    @Test func reasoningBecomesThinking() {
        let decoder = CodexAppServerDecoder()
        let out = decoder.decode(
            method: "item/completed",
            params: ["item": ["type": "reasoning", "id": "r1",
                              "summary": ["Plan the fix"], "content": ["Edit auth.swift"]]])
        guard case .thinking(let chunk) = out.first?.kind else { Issue.record("not thinking"); return }
        #expect(chunk.text == "Plan the fix\nEdit auth.swift")
    }

    @Test func commandExecutionStartsThenCompletesWithResult() {
        let decoder = CodexAppServerDecoder()
        let started = decoder.decode(
            method: "item/started",
            params: ["item": ["type": "commandExecution", "id": "c1",
                              "command": "ls -la", "cwd": "/repo", "status": "inProgress"]])
        #expect(tags(started) == ["toolCallStarted"])
        guard case .toolCallStarted(let call) = started[0].kind else { Issue.record("not toolCallStarted"); return }
        #expect(call.name == "Bash")
        #expect(call.input["command"]?.stringValue == "ls -la")

        let completed = decoder.decode(
            method: "item/completed",
            params: ["item": ["type": "commandExecution", "id": "c1", "command": "ls -la",
                              "status": "completed", "exitCode": 0, "aggregatedOutput": "a\nb"]])
        // No re-announce of toolCallStarted (dedup by id).
        #expect(tags(completed) == ["toolCallCompleted", "toolResult"])
        guard case .toolResult(let result) = completed[1].kind else { Issue.record("not toolResult"); return }
        #expect(result.isError == false)
        #expect(result.content.stringValue == "a\nb")
    }

    @Test func commandExecutionNonZeroExitIsError() {
        let decoder = CodexAppServerDecoder()
        let out = decoder.decode(
            method: "item/completed",
            params: ["item": ["type": "commandExecution", "id": "c9", "command": "false",
                              "status": "failed", "exitCode": 1]])
        // started + completed + result, since item/started was never seen for c9.
        #expect(tags(out) == ["toolCallStarted", "toolCallCompleted", "toolResult"])
        guard case .toolResult(let result) = out[2].kind else { Issue.record("not toolResult"); return }
        #expect(result.isError == true)
    }

    @Test func fileChangeEmitsOnePerChangeWithKinds() {
        let decoder = CodexAppServerDecoder()
        let out = decoder.decode(
            method: "item/completed",
            params: ["item": ["type": "fileChange", "id": "f1", "status": "completed",
                              "changes": [
                                  ["path": "new.swift", "kind": ["type": "add"], "diff": "+x"],
                                  ["path": "mod.swift", "kind": ["type": "update"], "diff": "~x"],
                                  ["path": "gone.swift", "kind": ["type": "delete"], "diff": "-x"],
                                  ["path": "to.swift",
                                   "kind": ["type": "update", "move_path": "from.swift"], "diff": ""],
                              ]]])
        #expect(out.count == 4)
        let changes: [(String, FileChange.ChangeKind)] = out.compactMap {
            guard case .fileChange(let fc) = $0.kind else { return nil }
            return (fc.path, fc.kind)
        }
        #expect(changes.count == 4)
        #expect(changes[0] == ("new.swift", .add))
        #expect(changes[1] == ("mod.swift", .update))
        #expect(changes[2] == ("gone.swift", .delete))
        #expect(changes[3] == ("to.swift", .rename))
    }

    @Test func declinedFileChangeEmitsNothing() {
        let decoder = CodexAppServerDecoder()
        let out = decoder.decode(
            method: "item/completed",
            params: ["item": ["type": "fileChange", "id": "f2", "status": "declined",
                              "changes": [["path": "x.swift", "kind": ["type": "add"], "diff": ""]]]])
        #expect(out.isEmpty)
    }

    @Test func tokenUsageBecomesUsage() {
        let decoder = CodexAppServerDecoder()
        let out = decoder.decode(
            method: "thread/tokenUsage/updated",
            params: ["tokenUsage": ["last": [
                "inputTokens": 100, "cachedInputTokens": 20,
                "outputTokens": 50, "reasoningOutputTokens": 10]]])
        guard case .usage(let usage) = out.first?.kind else { Issue.record("not usage"); return }
        #expect(usage.inputTokens == 100)
        #expect(usage.cachedInputTokens == 20)
        #expect(usage.outputTokens == 50)
        #expect(usage.reasoningTokens == 10)
        #expect(usage.contextInputTokens == 120)
        #expect(usage.costUSD == nil)
    }

    @Test func turnCompletedSuccess() {
        let decoder = CodexAppServerDecoder()
        let completed = decoder.decode(
            method: "turn/completed", params: ["turn": ["id": "t1", "status": "completed"]])
        #expect(tags(completed) == ["turnCompleted"])
        guard case .turnCompleted(let tc) = completed[0].kind else { Issue.record("not turnCompleted"); return }
        #expect(tc.stopReason == "completed")
    }

    @Test func turnCompletedFailedAlsoEmitsError() {
        // There is no `turn/failed`; failure surfaces as `turn/completed` with status "failed"
        // and a populated `turn.error`.
        let decoder = CodexAppServerDecoder()
        let out = decoder.decode(
            method: "turn/completed",
            params: ["turn": ["id": "t2", "status": "failed", "error": ["message": "model error"]]])
        #expect(tags(out) == ["error", "turnCompleted"])
        guard case .error(let err) = out[0].kind else { Issue.record("not error"); return }
        #expect(err.message == "model error")
        #expect(err.recoverable == false)
    }

    @Test func userMessageItemIsSkipped() {
        let decoder = CodexAppServerDecoder()
        let out = decoder.decode(
            method: "item/completed",
            params: ["item": ["type": "userMessage", "id": "u1", "content": []]])
        #expect(out.isEmpty)
    }

    /// Replays a real `codex app-server` (codex-cli 0.141.0) turn capture through the decoder.
    /// Locks the byte-level wire layout against drift (ADAPTERS §4); the fixture was captured
    /// live, so this is golden, not synthetic.
    @Test func realCaptureDecodesToExpectedEvents() throws {
        let repoRoot = URL(fileURLWithPath: #filePath)
            .deletingLastPathComponent()  // NucleicCoreTests
            .deletingLastPathComponent()  // Tests
            .deletingLastPathComponent()  // repo root
        let fixture = repoRoot
            .appendingPathComponent("fixtures/codex-appserver/0.141.0/01-simple-turn.jsonl")
        let content = try String(contentsOf: fixture, encoding: .utf8)

        let decoder = CodexAppServerDecoder()
        var kinds: [AgentEvent.Kind] = []
        for line in content.split(separator: "\n") {
            let root = try JSONValue(parsing: String(line))
            guard let method = root["method"]?.stringValue else { continue }
            kinds.append(contentsOf: decoder.decode(method: method, params: root["params"] ?? .object([:])).map(\.kind))
        }

        let tagList = kinds.map(\.tag)
        // The streamed delta ("pong") + the final agentMessage, then token usage and turn
        // completion. The thread/started + turn/started + userMessage lines yield nothing
        // (sessionStarted is emitted by the backend from the thread/start response).
        #expect(tagList == ["assistantText", "assistantText", "usage", "turnCompleted"])

        guard case .assistantText(let delta) = kinds[0] else { Issue.record("not assistantText"); return }
        #expect(delta.text == "pong")
        #expect(delta.isPartial == true)  // the agentMessage/delta
        guard case .assistantText(let final) = kinds[1] else { Issue.record("not assistantText"); return }
        #expect(final.text == "pong")
        #expect(final.isPartial == false)  // the item/completed agentMessage
        guard case .usage(let usage) = kinds[2] else { Issue.record("not usage"); return }
        #expect(usage.inputTokens == 14746)
        #expect(usage.cachedInputTokens == 11520)
        #expect(usage.outputTokens == 5)
        #expect(usage.contextInputTokens == 14746 + 11520)
        guard case .turnCompleted(let tc) = kinds[3] else { Issue.record("not turnCompleted"); return }
        #expect(tc.stopReason == "completed")
    }

    @Test func unknownNotificationPassesThroughAsRaw() {
        let decoder = CodexAppServerDecoder()
        let out = decoder.decode(method: "thread/somethingNew", params: ["a": 1])
        #expect(tags(out) == ["raw"])
        #expect(out[0].nativeType == "thread/somethingNew")
    }

    // MARK: - fileChange item paths (conflict arbitration acquire side)

    @Test func fileChangeItemPathsForArbitration() {
        // A fileChange item announces its edited paths via item/started; the requestApproval that
        // follows carries only the item id, so the backend captures paths here to acquire locks
        // before the patch applies.
        let params: JSONValue = [
            "item": [
                "type": "fileChange", "id": "fc1",
                "changes": [
                    ["path": "Sources/A.swift", "kind": ["type": "update"]],
                    ["path": "Sources/B.swift", "kind": ["type": "add"]],
                ],
            ]
        ]
        let got = CodexAppServerDecoder.fileChangeItemPaths(method: "item/started", params: params)
        #expect(got?.id == "fc1")
        #expect(got?.paths == ["Sources/A.swift", "Sources/B.swift"])
        // item/completed carries the same shape and is also captured.
        #expect(
            CodexAppServerDecoder.fileChangeItemPaths(method: "item/completed", params: params)?.paths
                == ["Sources/A.swift", "Sources/B.swift"])
        // Non-fileChange items and non-item methods yield nil.
        #expect(
            CodexAppServerDecoder.fileChangeItemPaths(
                method: "item/started",
                params: ["item": ["type": "commandExecution", "id": "c1", "command": "ls"]])?.id == nil)
        #expect(
            CodexAppServerDecoder.fileChangeItemPaths(
                method: "turn/completed", params: ["turn": ["status": "completed"]])?.id == nil)
    }
}
