import Foundation
import Testing

@testable import NucleicCore

/// `GrokACPDecoder`: Grok's **ACP** `session/update` notifications → normalized `AgentEvent`s
/// (BACKEND_PROTOCOL §3). Unlike the previous inferred streaming-json vocab, these shapes are
/// pinned to the published Agent Client Protocol schema. Note ACP carries no session-start /
/// turn-complete / usage *notifications* — those are synthesized by `GrokACPBackend` from the
/// handshake + the `session/prompt` response — so the decoder's surface is message/thought
/// chunks, tool calls, and (synthesized) file changes.
@Suite struct GrokACPDecoderTests {
    /// Decode a stream of `session/update` notification lines (one JSON object per line).
    private func decodeNotifications(_ s: String) -> [GrokACPDecoder.Decoded] {
        let decoder = GrokACPDecoder()
        return s.split(separator: "\n").flatMap { line -> [GrokACPDecoder.Decoded] in
            guard let msg = try? JSONValue(parsing: Data(line.utf8)),
                let method = msg["method"]?.stringValue
            else { return [] }
            return decoder.decode(method: method, params: msg["params"] ?? .object([:]))
        }
    }
    private func tags(_ d: [GrokACPDecoder.Decoded]) -> [String] { d.map(\.kind.tag) }

    private func fixture(_ name: String) throws -> String {
        let repoRoot = URL(fileURLWithPath: #filePath)
            .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
        return try String(
            contentsOf: repoRoot.appendingPathComponent("fixtures/grok/acp/\(name)"),
            encoding: .utf8)
    }

    @Test func simpleTurnFixture() throws {
        let out = decodeNotifications(try fixture("01-simple-turn.jsonl"))
        #expect(tags(out) == ["thinking", "assistantText"])

        guard case .thinking(let think) = out[0].kind else { Issue.record("not thinking"); return }
        #expect(think.text == "The user said ping, so I should reply pong.")
        #expect(think.isPartial == true)  // ACP streams content as chunks

        guard case .assistantText(let msg) = out[1].kind else { Issue.record("not assistantText"); return }
        #expect(msg.text == "pong")
        #expect(msg.messageID == "m1")
    }

    @Test func fileChangeFixtureSynthesizesUpdateOnSuccess() throws {
        let out = decodeNotifications(try fixture("02-file-change.jsonl"))
        #expect(tags(out) == [
            "toolCallStarted", "toolCallCompleted", "toolResult", "fileChange", "assistantText",
        ])
        guard case .toolCallStarted(let call) = out[0].kind else { Issue.record("not toolCallStarted"); return }
        #expect(call.name == "Edit")  // ACP kind "edit" → Nucleic "Edit"
        #expect(call.input["file_path"]?.stringValue == "hello.txt")

        guard case .fileChange(let change) = out[3].kind else { Issue.record("not fileChange"); return }
        #expect(change.path == "hello.txt")
        #expect(change.kind == .update)
        #expect(change.toolCallID == "call_1")
    }

    @Test func executeKindMapsToBash() {
        let out = decodeNotifications(#"""
            {"method":"session/update","params":{"update":{"sessionUpdate":"tool_call","toolCallId":"c1","kind":"execute","status":"pending","rawInput":{"command":"ls"}}}}
            """#)
        #expect(tags(out) == ["toolCallStarted"])
        guard case .toolCallStarted(let call) = out[0].kind else { Issue.record("not toolCallStarted"); return }
        #expect(call.name == "Bash")
        #expect(call.input["command"]?.stringValue == "ls")
    }

    @Test func inlineCompletedToolCallEmitsFullTrio() {
        // A tool_call that already carries a terminal status completes inline (no separate update).
        let out = decodeNotifications(#"""
            {"method":"session/update","params":{"update":{"sessionUpdate":"tool_call","toolCallId":"c1","kind":"edit","status":"completed","rawInput":{"file_path":"a.swift"},"locations":[{"path":"a.swift"}]}}}
            """#)
        #expect(tags(out) == ["toolCallStarted", "toolCallCompleted", "toolResult", "fileChange"])
    }

    @Test func failedToolEmitsNoFileChange() {
        // A failed edit changed nothing — no fileChange is synthesized (mirrors the other decoders).
        let out = decodeNotifications(#"""
            {"method":"session/update","params":{"update":{"sessionUpdate":"tool_call","toolCallId":"w1","kind":"edit","status":"pending","rawInput":{"file_path":"a.txt"},"locations":[{"path":"a.txt"}]}}}
            {"method":"session/update","params":{"update":{"sessionUpdate":"tool_call_update","toolCallId":"w1","status":"failed","content":[{"type":"content","content":{"type":"text","text":"permission denied"}}]}}}
            """#)
        #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 inProgressUpdateIsAdvisory() {
        let out = decodeNotifications(#"""
            {"method":"session/update","params":{"update":{"sessionUpdate":"tool_call_update","toolCallId":"c1","status":"in_progress"}}}
            """#)
        #expect(out.isEmpty)  // non-terminal updates are advisory, no AgentEvent
    }

    @Test func toolResultExtractsTextContent() {
        let out = decodeNotifications(#"""
            {"method":"session/update","params":{"update":{"sessionUpdate":"tool_call","toolCallId":"c1","kind":"execute","status":"pending","rawInput":{"command":"echo hi"}}}}
            {"method":"session/update","params":{"update":{"sessionUpdate":"tool_call_update","toolCallId":"c1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"hi"}}]}}}
            """#)
        guard case .toolResult(let result) = out.first(where: { $0.kind.tag == "toolResult" })!.kind else {
            Issue.record("no toolResult"); return
        }
        #expect(result.content.stringValue == "hi")
        #expect(result.isError == false)
    }

    @Test func unknownSessionUpdateVariantBecomesRaw() {
        // Lenient by contract: an unrecognized update variant is passthrough, never a trap.
        let out = decodeNotifications(#"""
            {"method":"session/update","params":{"update":{"sessionUpdate":"telemetry_heartbeat","seq":7}}}
            """#)
        #expect(tags(out) == ["raw"])
    }

    @Test func structuralUpdatesAreDropped() {
        // plan / available_commands / mode updates are structural — no first-class AgentEvent.
        let out = decodeNotifications(#"""
            {"method":"session/update","params":{"update":{"sessionUpdate":"plan","entries":[]}}}
            {"method":"session/update","params":{"update":{"sessionUpdate":"current_mode_update","currentModeId":"default"}}}
            """#)
        #expect(out.isEmpty)
    }

    @Test func nonSessionUpdateNotificationBecomesRaw() {
        let out = decodeNotifications(#"{"method":"some/other","params":{"x":1}}"#)
        #expect(tags(out) == ["raw"])
        #expect(out[0].nativeType == "some/other")
    }

    @Test func toolCallUpdateMergesRawInputIntoCompletedCall() {
        // The tool_call announces an empty rawInput; the terminal update fills `command`. The
        // re-emitted toolCallCompleted must carry the MERGED input, not the stale announce.
        let out = decodeNotifications(#"""
            {"method":"session/update","params":{"update":{"sessionUpdate":"tool_call","toolCallId":"c1","kind":"execute","status":"pending","rawInput":{}}}}
            {"method":"session/update","params":{"update":{"sessionUpdate":"tool_call_update","toolCallId":"c1","status":"completed","rawInput":{"command":"ls -la"}}}}
            """#)
        #expect(tags(out) == ["toolCallStarted", "toolCallCompleted", "toolResult"])
        guard case .toolCallCompleted(let call) = out[1].kind else { Issue.record("not toolCallCompleted"); return }
        #expect(call.input["command"]?.stringValue == "ls -la")
    }

    @Test func moveKindLocksBothEndpoints() {
        // A rename touches source AND destination — both must be returned for conflict locking.
        let fromRaw = GrokACPDecoder.editPaths(
            forKind: "move", rawInput: ["old_path": "a.txt", "new_path": "b.txt"], locations: [])
        #expect(fromRaw == ["a.txt", "b.txt"])

        let fromLocations = GrokACPDecoder.editPaths(
            forKind: "move", rawInput: .object([:]), locations: ["x.txt", "y.txt"])
        #expect(fromLocations == ["x.txt", "y.txt"])
    }
}
