import Foundation
import Testing

@testable import NucleicCore

/// Tier-0 golden normalization (OBSERVABILITY B.3): recorded native line →
/// expected AgentEvent kinds, exact equality.
@Suite struct ClaudeStreamDecoderTests {
    @Test func simpleTurn() throws {
        let kinds = try Fixtures.decode("01-simple-turn")
        #expect(
            kinds == [
                .sessionStarted(
                    SessionStarted(
                        backendSessionID: "sess-claude-001",
                        model: "claude-fable-5",
                        cwd: "/tmp/demo",
                        toolNames: ["Bash", "Read", "Edit"])),
                .assistantText(
                    TextChunk(messageID: "msg_01", text: "Hello! Build is green.", isPartial: false)),
                .usage(
                    Usage(inputTokens: 12, cachedInputTokens: 100, outputTokens: 9, costUSD: 0.0042,
                        contextInputTokens: 112)),
                // A successful `result` is a TURN boundary in streaming mode; the
                // run's terminal runFinished is synthesized on process exit (§adapter).
                .turnCompleted(
                    TurnCompleted(
                        stopReason: nil,
                        usage: Usage(
                            inputTokens: 12, cachedInputTokens: 100, outputTokens: 9, costUSD: 0.0042,
                            contextInputTokens: 112))),
            ])
    }

    @Test func toolUseLifecycle() throws {
        let kinds = try Fixtures.decode("02-tool-approval")
        let expectedCall = ToolCall(
            toolCallID: "toolu_01", name: "Bash", input: ["command": "rm -rf build/"])
        #expect(kinds[1] == .toolCallStarted(expectedCall))
        #expect(kinds[2] == .toolCallCompleted(expectedCall))
        #expect(
            kinds[3]
                == .toolResult(
                    ToolResult(toolCallID: "toolu_01", content: "removed", isError: false)))
        #expect(
            kinds[4]
                == .assistantText(
                    TextChunk(
                        messageID: "msg_03", text: "Removed the build directory.", isPartial: false)))
    }

    @Test func partialStreamingAssembly() throws {
        let kinds = try Fixtures.decode("03-partial-streaming")
        // Deltas carry the streamed message id and isPartial=true…
        #expect(kinds[1] == .assistantText(TextChunk(messageID: "msg_04", text: "Hel", isPartial: true)))
        #expect(kinds[2] == .assistantText(TextChunk(messageID: "msg_04", text: "lo", isPartial: true)))
        // …and the whole-message event still arrives as the authoritative full text.
        #expect(kinds[3] == .assistantText(TextChunk(messageID: "msg_04", text: "Hello", isPartial: false)))
    }

    /// A subagent's message carries `parent_tool_use_id`; the decoder threads it onto the
    /// text/thinking chunks (whole-message and streamed alike) so the projection can nest the
    /// subagent's prose under its spawn instead of leaking it into the main conversation.
    @Test func subagentProseCarriesParentToolID() {
        let decoder = ClaudeStreamDecoder()
        func decode(_ json: String) -> [AgentEvent.Kind] {
            decoder.decode(line: Data(json.utf8)).map(\.kind)
        }
        // Whole-message assistant text from inside a subagent.
        let whole = decode(
            #"{"type":"assistant","parent_tool_use_id":"toolu_task","message":{"id":"sm_1","content":[{"type":"text","text":"done"}]}}"#)
        #expect(whole == [
            .assistantText(TextChunk(messageID: "sm_1", text: "done", isPartial: false,
                                     parentToolCallID: "toolu_task"))
        ])
        // Streamed partial: message_start carries the parent, the following delta inherits it.
        _ = decode(
            #"{"type":"stream_event","parent_tool_use_id":"toolu_task","event":{"type":"message_start","message":{"id":"sm_2"}}}"#)
        let delta = decode(
            #"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}}"#)
        #expect(delta == [
            .assistantText(TextChunk(messageID: "sm_2", text: "hi", isPartial: true,
                                     parentToolCallID: "toolu_task"))
        ])
    }

    /// The main agent's own messages carry no `parent_tool_use_id`, so their chunks stay
    /// top-level (parentToolCallID nil) — no regression for ordinary turns.
    @Test func mainAgentProseHasNoParentToolID() {
        let decoder = ClaudeStreamDecoder()
        let kinds = decoder.decode(
            line: Data(#"{"type":"assistant","message":{"id":"m1","content":[{"type":"text","text":"hi"}]}}"#.utf8))
            .map(\.kind)
        #expect(kinds == [
            .assistantText(TextChunk(messageID: "m1", text: "hi", isPartial: false))
        ])
    }

    @Test func fileChangeSynthesizedFromEditAfterSuccessfulResult() throws {
        let kinds = try Fixtures.decode("04-edit-filechange")
        let fileChanges = kinds.compactMap { kind -> FileChange? in
            if case .fileChange(let change) = kind { return change }
            return nil
        }
        #expect(
            fileChanges == [
                FileChange(path: "/tmp/demo/main.swift", kind: .update, toolCallID: "toolu_02")
            ])
        // Synthesized only once the result confirms success, so it follows toolResult.
        let resultIndex = try #require(kinds.firstIndex { if case .toolResult = $0 { return true } else { return false } })
        let changeIndex = try #require(kinds.firstIndex { if case .fileChange = $0 { return true } else { return false } })
        #expect(changeIndex > resultIndex)
    }

    /// M0 live-capture regression (2026-06-12): a denied Write produced a phantom
    /// fileChange. A failed/denied editing tool changed nothing on disk.
    @Test func deniedWriteEmitsNoFileChange() {
        let decoder = ClaudeStreamDecoder()
        let toolUse = #"{"type":"assistant","message":{"id":"m","role":"assistant","content":[{"type":"tool_use","id":"toolu_d","name":"Write","input":{"file_path":"/tmp/blocked.txt","content":"x"}}]},"session_id":"s"}"#
        let deniedResult = #"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_d","content":"Denied by user","is_error":true}]},"session_id":"s"}"#

        var kinds: [AgentEvent.Kind] = []
        for line in [toolUse, deniedResult] {
            kinds += decoder.decode(line: Data(line.utf8)).map(\.kind)
        }
        let hasFileChange = kinds.contains { if case .fileChange = $0 { return true } else { return false } }
        #expect(!hasFileChange)
    }

    @Test func streamedToolUseDoesNotDoubleStart() throws {
        let decoder = ClaudeStreamDecoder()
        let streamStart = #"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_99","name":"Bash","input":{}}},"session_id":"s"}"#
        let delta = #"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"command\":"}},"session_id":"s"}"#
        let full = #"{"type":"assistant","message":{"id":"m","role":"assistant","content":[{"type":"tool_use","id":"toolu_99","name":"Bash","input":{"command":"ls"}}]},"session_id":"s"}"#

        var kinds: [AgentEvent.Kind] = []
        for line in [streamStart, delta, full] {
            kinds += decoder.decode(line: Data(line.utf8)).map(\.kind)
        }
        let starts = kinds.filter {
            if case .toolCallStarted = $0 { return true }
            return false
        }
        let completions = kinds.filter {
            if case .toolCallCompleted = $0 { return true }
            return false
        }
        #expect(starts.count == 1)
        #expect(completions.count == 1)
        #expect(
            kinds.contains(
                .toolCallInputDelta(
                    ToolInputDelta(toolCallID: "toolu_99", partialJSON: #"{"command":"#))))
    }

    @Test func unknownEventBecomesRawNotCrash() {
        let decoder = ClaudeStreamDecoder()
        let unknown = #"{"type":"hologram","payload":{"v":2}}"#
        let decoded = decoder.decode(line: Data(unknown.utf8))
        #expect(decoded.count == 1)
        #expect(decoded[0].nativeType == "hologram")
        guard case .raw = decoded[0].kind else {
            Issue.record("expected .raw, got \(decoded[0].kind)")
            return
        }
    }

    @Test func nonJSONLineBecomesRaw() {
        let decoder = ClaudeStreamDecoder()
        let decoded = decoder.decode(line: Data("not json at all".utf8))
        #expect(decoded.count == 1)
        guard case .raw(let raw) = decoded[0].kind else {
            Issue.record("expected .raw")
            return
        }
        #expect(raw.native == .string("not json at all"))
    }

    @Test func apiRetryIsRecoverableError() {
        let decoder = ClaudeStreamDecoder()
        let line = #"{"type":"system","subtype":"api_retry","attempt":1,"message":"overloaded"}"#
        let decoded = decoder.decode(line: Data(line.utf8))
        guard case .error(let error) = decoded[0].kind else {
            Issue.record("expected .error")
            return
        }
        #expect(error.recoverable)
        #expect(error.message == "overloaded")
    }

    @Test func maxTurnsOutcome() {
        let decoder = ClaudeStreamDecoder()
        let line = #"{"type":"result","subtype":"error_max_turns","is_error":true,"duration_ms":10,"session_id":"s"}"#
        let decoded = decoder.decode(line: Data(line.utf8))
        guard case .runFinished(let finished) = decoded.last?.kind else {
            Issue.record("expected .runFinished")
            return
        }
        #expect(finished.outcome == .maxTurns)
    }

    /// Context-window occupancy must come from the turn's *final* model call (the last
    /// `iterations` entry), not the cumulative top-level usage that sums every call — the
    /// latter far exceeds the window on agentic turns and would peg the indicator at 100%.
    @Test func contextUsesFinalIterationNotCumulative() {
        let decoder = ClaudeStreamDecoder()
        let line = #"{"type":"result","subtype":"success","is_error":false,"total_cost_usd":1.5,"usage":{"input_tokens":500,"cache_read_input_tokens":900000,"cache_creation_input_tokens":1000,"output_tokens":40,"iterations":[{"input_tokens":300,"cache_read_input_tokens":0,"cache_creation_input_tokens":50000},{"input_tokens":200,"cache_read_input_tokens":150000,"cache_creation_input_tokens":0}]},"session_id":"s"}"#
        let kinds = decoder.decode(line: Data(line.utf8)).map(\.kind)
        let usage = kinds.compactMap { kind -> Usage? in
            if case .usage(let u) = kind { return u } else { return nil }
        }.first
        // Final iteration: 200 + 150_000 + 0.
        #expect(usage?.contextInputTokens == 150_200)
        // Cumulative fields are still preserved (they drive cost, not context).
        #expect(usage?.cachedInputTokens == 900_000)
    }
}
