import Foundation
import Testing
@testable import CodexBarCore

// swiftlint:disable:next type_body_length
struct CostUsageScannerTests {
    @Test
    func `codex session metadata skips an oversized line without retaining it`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let fileURL = env.root.appendingPathComponent("oversized-session-meta.jsonl")
        FileManager.default.createFile(atPath: fileURL.path, contents: nil)
        let handle = try FileHandle(forWritingTo: fileURL)
        defer { try? handle.close() }

        let oversizedPrefix = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"too-large\",\"padding\":\""
        try handle.write(contentsOf: Data(oversizedPrefix.utf8))
        let chunk = Data(repeating: 0x78, count: 64 * 1024)
        for _ in 0..<128 {
            try handle.write(contentsOf: chunk)
        }
        let expectedLine = #"{"type":"session_meta","payload":{"id":"expected-session"}}"#
        try handle.write(contentsOf: Data((#""}}"# + "\n" + expectedLine).utf8))
        try handle.close()

        let sessionID = try CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL)
        #expect(sessionID == "expected-session")
    }

    @Test
    func `codex session metadata accepts a line exactly at the byte limit`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let prefix = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"limit-session\",\"padding\":\""
        let suffix = "\"}}"
        let paddingCount = CostUsageScanner.codexSessionMetadataMaxLineBytes
            - prefix.utf8.count
            - suffix.utf8.count
        var line = Data(prefix.utf8)
        line.append(Data(repeating: 0x78, count: paddingCount))
        line.append(contentsOf: suffix.utf8)
        #expect(line.count == CostUsageScanner.codexSessionMetadataMaxLineBytes)

        let fileURL = env.root.appendingPathComponent("max-size-session-meta.jsonl")
        try line.write(to: fileURL)
        #expect(try (JSONSerialization.jsonObject(with: line)) is [String: Any])

        let sessionID = try CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL)
        #expect(sessionID == "limit-session")
    }

    @Test
    func `codex file metadata detects append truncation and replacement`() throws {
        let root = FileManager.default.temporaryDirectory
            .appendingPathComponent("codexbar-codex-metadata-\(UUID().uuidString)", isDirectory: true)
        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(at: root) }
        let fileURL = root.appendingPathComponent("session.jsonl")
        try Data("abc".utf8).write(to: fileURL)

        let initial = CostUsageScanner.codexFileMetadata(fileURL: fileURL)
        #expect(initial.size == 3)
        #expect(initial.fileId != nil)
        let linkURL = root.appendingPathComponent("linked-session.jsonl")
        try FileManager.default.createSymbolicLink(at: linkURL, withDestinationURL: fileURL)
        let linked = CostUsageScanner.codexFileMetadata(fileURL: linkURL)
        #expect(linked.size == initial.size)
        #expect(linked.fileId == initial.fileId)

        let handle = try FileHandle(forWritingTo: fileURL)
        try handle.seekToEnd()
        try handle.write(contentsOf: Data("def".utf8))
        try handle.close()
        let appended = CostUsageScanner.codexFileMetadata(fileURL: fileURL)
        #expect(appended.size == 6)
        #expect(appended.fileId == initial.fileId)

        let truncateHandle = try FileHandle(forWritingTo: fileURL)
        try truncateHandle.truncate(atOffset: 2)
        try truncateHandle.close()
        let truncated = CostUsageScanner.codexFileMetadata(fileURL: fileURL)
        #expect(truncated.size == 2)
        #expect(truncated.fileId == initial.fileId)

        try FileManager.default.removeItem(at: fileURL)
        try Data("replacement".utf8).write(to: fileURL)
        let replaced = CostUsageScanner.codexFileMetadata(fileURL: fileURL)
        #expect(replaced.size == 11)
        #expect(replaced.fileId != initial.fileId)
    }

    @Test
    func `vertex daily report filters claude logs`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
        let iso0 = env.isoString(for: day)

        let vertexEntry: [String: Any] = [
            "type": "assistant",
            "timestamp": iso0,
            "metadata": [
                "provider": "vertexai",
                "projectId": "vertex-project",
                "location": "us-central1",
            ],
            "message": [
                "model": "claude-sonnet-4-20250514",
                "usage": [
                    "input_tokens": 10,
                    "cache_creation_input_tokens": 0,
                    "cache_read_input_tokens": 0,
                    "output_tokens": 5,
                ],
            ],
        ]
        let claudeEntry: [String: Any] = [
            "type": "assistant",
            "timestamp": iso0,
            "metadata": [
                "provider": "anthropic",
            ],
            "message": [
                "model": "claude-sonnet-4-20250514",
                "usage": [
                    "input_tokens": 200,
                    "cache_creation_input_tokens": 0,
                    "cache_read_input_tokens": 0,
                    "output_tokens": 100,
                ],
            ],
        ]

        _ = try env.writeClaudeProjectFile(
            relativePath: "project-a/session-a.jsonl",
            contents: env.jsonl([vertexEntry, claudeEntry]))

        var options = CostUsageScanner.Options(
            codexSessionsRoot: nil,
            claudeProjectsRoots: [env.claudeProjectsRoot],
            cacheRoot: env.cacheRoot)
        options.refreshMinIntervalSeconds = 0

        let report = CostUsageScanner.loadDailyReport(
            provider: .vertexai,
            since: day,
            until: day,
            now: day,
            options: options)

        #expect(report.data.count == 1)
        #expect(report.data[0].inputTokens == 10)
        #expect(report.data[0].outputTokens == 5)
        #expect(report.data[0].totalTokens == 15)
    }

    @Test
    func `vertex daily report detects by vrtx id prefix`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
        let iso0 = env.isoString(for: day)
        let iso1 = env.isoString(for: day.addingTimeInterval(1))

        // Vertex AI entries have "_vrtx_" in message.id and requestId
        let vertexEntry: [String: Any] = [
            "type": "assistant",
            "timestamp": iso0,
            "requestId": "req_vrtx_011CWjK86SWeFuXqZKUtgB1H",
            "message": [
                "id": "msg_vrtx_0154LUXjFVzQGUca3yK2RUeo",
                "model": "claude-opus-4-5-20251101",
                "usage": [
                    "input_tokens": 100,
                    "cache_creation_input_tokens": 0,
                    "cache_read_input_tokens": 0,
                    "output_tokens": 50,
                ],
            ],
        ]
        // Anthropic API entries have regular IDs without "_vrtx_"
        let claudeEntry: [String: Any] = [
            "type": "assistant",
            "timestamp": iso1,
            "requestId": "req_011CW7BFSFkbK9qJrV8kiptH",
            "message": [
                "id": "msg_0152zX6DsQYcwH1qiXi4B3y2",
                "model": "claude-opus-4-5-20251101",
                "usage": [
                    "input_tokens": 200,
                    "cache_creation_input_tokens": 0,
                    "cache_read_input_tokens": 0,
                    "output_tokens": 100,
                ],
            ],
        ]

        _ = try env.writeClaudeProjectFile(
            relativePath: "project-a/session-a.jsonl",
            contents: env.jsonl([vertexEntry, claudeEntry]))

        var options = CostUsageScanner.Options(
            codexSessionsRoot: nil,
            claudeProjectsRoots: [env.claudeProjectsRoot],
            cacheRoot: env.cacheRoot)
        options.refreshMinIntervalSeconds = 0

        // Vertex AI report should only include entries with _vrtx_ prefix
        let vertexReport = CostUsageScanner.loadDailyReport(
            provider: .vertexai,
            since: day,
            until: day,
            now: day,
            options: options)

        #expect(vertexReport.data.count == 1)
        #expect(vertexReport.data[0].inputTokens == 100)
        #expect(vertexReport.data[0].outputTokens == 50)
        #expect(vertexReport.data[0].totalTokens == 150)

        // Claude report with excludeVertexAI should only include non-vrtx entries
        var claudeOptions = options
        claudeOptions.claudeLogProviderFilter = .excludeVertexAI
        let claudeReport = CostUsageScanner.loadDailyReport(
            provider: .claude,
            since: day,
            until: day,
            now: day,
            options: claudeOptions)

        #expect(claudeReport.data.count == 1)
        #expect(claudeReport.data[0].inputTokens == 200)
        #expect(claudeReport.data[0].outputTokens == 100)
        #expect(claudeReport.data[0].totalTokens == 300)
    }

    @Test
    func `claude report preserves per-request threshold pricing`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9)
        let first = env.isoString(for: day)
        let second = env.isoString(for: day.addingTimeInterval(1))
        let model = "claude-sonnet-4-5"
        let firstEntry: [String: Any] = [
            "type": "assistant",
            "timestamp": first,
            "requestId": "req_one",
            "message": [
                "id": "msg_one",
                "model": model,
                "usage": [
                    "input_tokens": 150_000,
                    "output_tokens": 0,
                ],
            ],
        ]
        let secondEntry: [String: Any] = [
            "type": "assistant",
            "timestamp": second,
            "requestId": "req_two",
            "message": [
                "id": "msg_two",
                "model": model,
                "usage": [
                    "input_tokens": 150_000,
                    "output_tokens": 0,
                ],
            ],
        ]

        _ = try env.writeClaudeProjectFile(
            relativePath: "project-a/threshold.jsonl",
            contents: env.jsonl([firstEntry, secondEntry]))

        var options = CostUsageScanner.Options(
            codexSessionsRoot: nil,
            claudeProjectsRoots: [env.claudeProjectsRoot],
            cacheRoot: env.cacheRoot)
        options.refreshMinIntervalSeconds = 0

        let report = CostUsageScanner.loadDailyReport(
            provider: .claude,
            since: day,
            until: day,
            now: day,
            options: options)
        let expectedRequestCost = CostUsagePricing.claudeCostUSD(
            model: model,
            inputTokens: 150_000,
            cacheReadInputTokens: 0,
            cacheCreationInputTokens: 0,
            outputTokens: 0,
            modelsDevCacheRoot: env.cacheRoot) ?? 0
        let aggregateCost = CostUsagePricing.claudeCostUSD(
            model: model,
            inputTokens: 300_000,
            cacheReadInputTokens: 0,
            cacheCreationInputTokens: 0,
            outputTokens: 0,
            modelsDevCacheRoot: env.cacheRoot) ?? 0
        let expectedCost = expectedRequestCost * 2

        #expect(report.data.count == 1)
        #expect(report.data.first?.inputTokens == 300_000)
        #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001)
        #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001)
        #expect(abs((report.data.first?.modelBreakdowns?.first?.costUSD ?? 0) - expectedCost) < 0.000001)
    }

    @Test
    func `claude parses large lines with usage at tail`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2025, month: 12, day: 28)
        let iso0 = env.isoString(for: day)
        let largeText = String(repeating: "a", count: 70000)

        let assistant: [String: Any] = [
            "type": "assistant",
            "timestamp": iso0,
            "message": [
                "model": "claude-sonnet-4-20250514",
                "content": [
                    ["type": "text", "text": largeText],
                ],
                "usage": [
                    "input_tokens": 3714,
                    "output_tokens": 1,
                ],
            ],
        ]
        _ = try env.writeClaudeProjectFile(
            relativePath: "project-a/large-line.jsonl",
            contents: env.jsonl([assistant]))

        var options = CostUsageScanner.Options(
            codexSessionsRoot: nil,
            claudeProjectsRoots: [env.claudeProjectsRoot],
            cacheRoot: env.cacheRoot)
        options.refreshMinIntervalSeconds = 0

        let report = CostUsageScanner.loadDailyReport(
            provider: .claude,
            since: day,
            until: day,
            now: day,
            options: options)
        #expect(report.data.count == 1)
        #expect(report.data[0].inputTokens == 3714)
        #expect(report.data[0].outputTokens == 1)
        #expect(report.data[0].totalTokens == 3715)
    }

    @Test
    func `claude daily report refreshes when file changes`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
        let iso0 = env.isoString(for: day)
        let iso1 = env.isoString(for: day.addingTimeInterval(1))

        let model = "claude-sonnet-4-20250514"
        let first: [String: Any] = [
            "type": "assistant",
            "timestamp": iso0,
            "message": [
                "model": model,
                "usage": [
                    "input_tokens": 200,
                    "cache_creation_input_tokens": 50,
                    "cache_read_input_tokens": 25,
                    "output_tokens": 80,
                ],
            ],
        ]
        let fileURL = try env.writeClaudeProjectFile(
            relativePath: "project-a/session-a.jsonl",
            contents: env.jsonl([first]))

        var options = CostUsageScanner.Options(
            codexSessionsRoot: nil,
            claudeProjectsRoots: [env.claudeProjectsRoot],
            cacheRoot: env.cacheRoot)
        options.refreshMinIntervalSeconds = 0

        let firstReport = CostUsageScanner.loadDailyReport(
            provider: .claude,
            since: day,
            until: day,
            now: day,
            options: options)
        #expect(firstReport.data.first?.totalTokens == 355)

        let second: [String: Any] = [
            "type": "assistant",
            "timestamp": iso1,
            "message": [
                "model": model,
                "usage": [
                    "input_tokens": 40,
                    "cache_creation_input_tokens": 10,
                    "cache_read_input_tokens": 5,
                    "output_tokens": 20,
                ],
            ],
        ]
        try env.jsonl([first, second]).write(to: fileURL, atomically: true, encoding: .utf8)

        let secondReport = CostUsageScanner.loadDailyReport(
            provider: .claude,
            since: day,
            until: day,
            now: day,
            options: options)
        #expect(secondReport.data.first?.totalTokens == 430)
    }

    @Test
    func `codex incremental parsing uses previous totals`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
        let iso0 = env.isoString(for: day)
        let iso1 = env.isoString(for: day.addingTimeInterval(1))
        let iso2 = env.isoString(for: day.addingTimeInterval(2))

        let model = "openai/gpt-5.2-codex"
        let normalized = CostUsagePricing.normalizeCodexModel(model)
        #expect(normalized == "gpt-5.2-codex")
        let turnContext: [String: Any] = [
            "type": "turn_context",
            "timestamp": iso0,
            "payload": [
                "model": model,
            ],
        ]
        let firstTokenCount: [String: Any] = [
            "type": "event_msg",
            "timestamp": iso1,
            "payload": [
                "type": "token_count",
                "info": [
                    "total_token_usage": [
                        "input_tokens": 100,
                        "cached_input_tokens": 20,
                        "output_tokens": 10,
                        "reasoning_output_tokens": 4,
                    ],
                    "model": model,
                ],
            ],
        ]

        let fileURL = try env.writeCodexSessionFile(
            day: day,
            filename: "session.jsonl",
            contents: env.jsonl([turnContext, firstTokenCount]))

        let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)
        let first = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range)
        #expect(first.parsedBytes > 0)
        #expect(first.lastTotals?.input == 100)
        #expect(first.lastTotals?.cached == 20)
        #expect(first.lastTotals?.output == 10)
        #expect(first.lastTotals?.reasoning == 4)
        #expect(first.rows.first?.reasoning == 4)

        let secondTokenCount: [String: Any] = [
            "type": "event_msg",
            "timestamp": iso2,
            "payload": [
                "type": "token_count",
                "info": [
                    "total_token_usage": [
                        "input_tokens": 160,
                        "cached_input_tokens": 40,
                        "output_tokens": 16,
                        "reasoning_output_tokens": 7,
                    ],
                    "model": model,
                ],
            ],
        ]
        try env.jsonl([turnContext, firstTokenCount, secondTokenCount])
            .write(to: fileURL, atomically: true, encoding: .utf8)

        let delta = CostUsageScanner.parseCodexFile(
            fileURL: fileURL,
            range: range,
            startOffset: first.parsedBytes,
            initialModel: first.lastModel,
            initialTotals: first.lastTotals)
        let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day)
        let packed = delta.days[dayKey]?[normalized] ?? []
        #expect(packed.count >= 3)
        #expect(packed[0] == 60)
        #expect(packed[1] == 20)
        #expect(packed[2] == 6)
        #expect(delta.rows.first?.reasoning == 3)
    }

    @Test
    func `codex incremental parsing keeps current turn id`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
        let iso0 = env.isoString(for: day)
        let iso1 = env.isoString(for: day.addingTimeInterval(1))
        let iso2 = env.isoString(for: day.addingTimeInterval(2))
        let iso3 = env.isoString(for: day.addingTimeInterval(3))

        let model = "openai/gpt-5.5"
        let turnID = "22222222-2222-2222-2222-222222222222"
        let turnContext: [String: Any] = [
            "type": "turn_context",
            "timestamp": iso0,
            "payload": [
                "model": model,
            ],
        ]
        let taskStarted: [String: Any] = [
            "type": "event_msg",
            "timestamp": iso1,
            "payload": [
                "type": "task_started",
                "id": turnID,
            ],
        ]
        let firstTokenCount: [String: Any] = [
            "type": "event_msg",
            "timestamp": iso2,
            "payload": [
                "type": "token_count",
                "info": [
                    "total_token_usage": [
                        "input_tokens": 100,
                        "cached_input_tokens": 20,
                        "output_tokens": 10,
                    ],
                ],
            ],
        ]

        let fileURL = try env.writeCodexSessionFile(
            day: day,
            filename: "priority-session.jsonl",
            contents: env.jsonl([turnContext, taskStarted, firstTokenCount]))
        let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)

        let first = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range)
        #expect(first.lastCodexTurnID == turnID)
        #expect(first.rows.map(\.turnID) == [turnID])

        let secondTokenCount: [String: Any] = [
            "type": "event_msg",
            "timestamp": iso3,
            "payload": [
                "type": "token_count",
                "info": [
                    "total_token_usage": [
                        "input_tokens": 160,
                        "cached_input_tokens": 40,
                        "output_tokens": 16,
                    ],
                ],
            ],
        ]
        try env.jsonl([turnContext, taskStarted, firstTokenCount, secondTokenCount])
            .write(to: fileURL, atomically: true, encoding: .utf8)

        let delta = CostUsageScanner.parseCodexFile(
            fileURL: fileURL,
            range: range,
            startOffset: first.parsedBytes,
            initialModel: first.lastModel,
            initialTotals: first.lastTotals,
            initialCodexTurnID: first.lastCodexTurnID)

        #expect(delta.lastCodexTurnID == turnID)
        #expect(delta.rows.map(\.turnID) == [turnID])
        #expect(delta.rows.first?.input == 60)
        #expect(delta.rows.first?.cached == 20)
        #expect(delta.rows.first?.output == 6)
    }

    @Test
    func `codex fast parser does not trap on overflowing token integers`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10)
        let iso = env.isoString(for: day)
        let hugeInteger = String(repeating: "9", count: 100)
        let line = """
        {"type":"event_msg","timestamp":"\(
            iso)","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":\(
            hugeInteger),"cached_input_tokens":0,"output_tokens":5},"model":"openai/gpt-5.5"}}}
        """
        let fileURL = try env.writeCodexSessionFile(day: day, filename: "overflow.jsonl", contents: line + "\n")
        let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)

        let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range)
        let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day)
        let packed = parsed.days[dayKey]?["gpt-5.5"] ?? []

        #expect(packed.count >= 3)
        #expect(packed[0] == 0)
        #expect(packed[1] == 0)
        #expect(packed[2] == 5)
    }

    @Test
    func `claude incremental parsing reads appended lines only`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
        let iso0 = env.isoString(for: day)
        let iso1 = env.isoString(for: day.addingTimeInterval(1))

        let model = "claude-sonnet-4-20250514"
        let normalized = CostUsagePricing.normalizeClaudeModel(model)
        let first: [String: Any] = [
            "type": "assistant",
            "timestamp": iso0,
            "message": [
                "model": model,
                "usage": [
                    "input_tokens": 200,
                    "cache_creation_input_tokens": 50,
                    "cache_read_input_tokens": 25,
                    "output_tokens": 80,
                ],
            ],
        ]
        let fileURL = try env.writeClaudeProjectFile(
            relativePath: "project-a/session-a.jsonl",
            contents: env.jsonl([first]))

        let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)
        let firstParse = CostUsageScanner.parseClaudeFile(
            fileURL: fileURL,
            range: range,
            providerFilter: .all)
        #expect(firstParse.parsedBytes > 0)

        let second: [String: Any] = [
            "type": "assistant",
            "timestamp": iso1,
            "message": [
                "model": model,
                "usage": [
                    "input_tokens": 40,
                    "cache_creation_input_tokens": 10,
                    "cache_read_input_tokens": 5,
                    "output_tokens": 20,
                ],
            ],
        ]
        try env.jsonl([first, second]).write(to: fileURL, atomically: true, encoding: .utf8)

        let delta = CostUsageScanner.parseClaudeFile(
            fileURL: fileURL,
            range: range,
            providerFilter: .all,
            startOffset: firstParse.parsedBytes)
        let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day)
        let packed = delta.days[dayKey]?[normalized] ?? []
        #expect(packed.count >= 4)
        #expect(packed[0] == 40)
        #expect(packed[1] == 5)
        #expect(packed[2] == 10)
        #expect(packed[3] == 20)
    }

    @Test
    func `day key from timestamp matches ISO parsing`() {
        let timestamps = [
            "2025-12-20T23:59:59Z",
            "2025-12-20T23:59:59+02:00",
        ]

        for ts in timestamps {
            let expected = CostUsageScanner.dayKeyFromParsedISO(ts)
            let fast = CostUsageScanner.dayKeyFromTimestamp(ts)
            #expect(fast == expected)
        }
    }

    @Test
    func `claude deduplicates streaming chunks`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
        let iso0 = env.isoString(for: day)
        let iso1 = env.isoString(for: day.addingTimeInterval(1))
        let iso2 = env.isoString(for: day.addingTimeInterval(2))

        let model = "claude-sonnet-4-20250514"
        let messageId = "msg_01ABC123"
        let requestId = "req_01XYZ789"

        // Streaming emits multiple chunks with same message.id + requestId.
        // Each chunk has cumulative usage, not delta.
        let chunk1: [String: Any] = [
            "type": "assistant",
            "timestamp": iso0,
            "requestId": requestId,
            "message": [
                "id": messageId,
                "model": model,
                "usage": [
                    "input_tokens": 100,
                    "cache_creation_input_tokens": 50,
                    "cache_read_input_tokens": 25,
                    "output_tokens": 10,
                ],
            ],
        ]
        let chunk2: [String: Any] = [
            "type": "assistant",
            "timestamp": iso1,
            "requestId": requestId,
            "message": [
                "id": messageId,
                "model": model,
                "usage": [
                    "input_tokens": 100,
                    "cache_creation_input_tokens": 50,
                    "cache_read_input_tokens": 25,
                    "output_tokens": 10,
                ],
            ],
        ]
        let chunk3: [String: Any] = [
            "type": "assistant",
            "timestamp": iso2,
            "requestId": requestId,
            "message": [
                "id": messageId,
                "model": model,
                "usage": [
                    "input_tokens": 100,
                    "cache_creation_input_tokens": 50,
                    "cache_read_input_tokens": 25,
                    "output_tokens": 10,
                ],
            ],
        ]

        _ = try env.writeClaudeProjectFile(
            relativePath: "project-a/session-a.jsonl",
            contents: env.jsonl([chunk1, chunk2, chunk3]))

        var options = CostUsageScanner.Options(
            codexSessionsRoot: nil,
            claudeProjectsRoots: [env.claudeProjectsRoot],
            cacheRoot: env.cacheRoot)
        options.refreshMinIntervalSeconds = 0

        let report = CostUsageScanner.loadDailyReport(
            provider: .claude,
            since: day,
            until: day,
            now: day,
            options: options)

        // Should only count once, not 3x.
        #expect(report.data.count == 1)
        #expect(report.data[0].inputTokens == 100)
        #expect(report.data[0].cacheCreationTokens == 50)
        #expect(report.data[0].cacheReadTokens == 25)
        #expect(report.data[0].outputTokens == 10)
        #expect(report.data[0].totalTokens == 185)
    }

    @Test
    func `claude counts entries without ids as separate`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
        let iso0 = env.isoString(for: day)
        let iso1 = env.isoString(for: day.addingTimeInterval(1))

        let model = "claude-sonnet-4-20250514"

        // Entries without message.id or requestId should still be counted
        // (fallback for older log formats).
        let entry1: [String: Any] = [
            "type": "assistant",
            "timestamp": iso0,
            "message": [
                "model": model,
                "usage": [
                    "input_tokens": 100,
                    "cache_creation_input_tokens": 0,
                    "cache_read_input_tokens": 0,
                    "output_tokens": 50,
                ],
            ],
        ]
        let entry2: [String: Any] = [
            "type": "assistant",
            "timestamp": iso1,
            "message": [
                "model": model,
                "usage": [
                    "input_tokens": 200,
                    "cache_creation_input_tokens": 0,
                    "cache_read_input_tokens": 0,
                    "output_tokens": 100,
                ],
            ],
        ]

        _ = try env.writeClaudeProjectFile(
            relativePath: "project-a/session-a.jsonl",
            contents: env.jsonl([entry1, entry2]))

        var options = CostUsageScanner.Options(
            codexSessionsRoot: nil,
            claudeProjectsRoots: [env.claudeProjectsRoot],
            cacheRoot: env.cacheRoot)
        options.refreshMinIntervalSeconds = 0

        let report = CostUsageScanner.loadDailyReport(
            provider: .claude,
            since: day,
            until: day,
            now: day,
            options: options)

        // Both entries should be counted since no IDs to dedupe.
        #expect(report.data.count == 1)
        #expect(report.data[0].inputTokens == 300)
        #expect(report.data[0].outputTokens == 150)
        #expect(report.data[0].totalTokens == 450)
    }

    @Test
    func `claude counts different request ids separately`() throws {
        let env = try CostUsageTestEnvironment()
        defer { env.cleanup() }

        let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20)
        let iso0 = env.isoString(for: day)
        let iso1 = env.isoString(for: day.addingTimeInterval(1))

        let model = "claude-sonnet-4-20250514"
        let messageId = "msg_01ABC123"

        let entry1: [String: Any] = [
            "type": "assistant",
            "timestamp": iso0,
            "requestId": "req_01AAA",
            "message": [
                "id": messageId,
                "model": model,
                "usage": [
                    "input_tokens": 10,
                    "cache_creation_input_tokens": 0,
                    "cache_read_input_tokens": 0,
                    "output_tokens": 5,
                ],
            ],
        ]
        let entry2: [String: Any] = [
            "type": "assistant",
            "timestamp": iso1,
            "requestId": "req_01BBB",
            "message": [
                "id": messageId,
                "model": model,
                "usage": [
                    "input_tokens": 20,
                    "cache_creation_input_tokens": 0,
                    "cache_read_input_tokens": 0,
                    "output_tokens": 10,
                ],
            ],
        ]

        _ = try env.writeClaudeProjectFile(
            relativePath: "project-a/session-a.jsonl",
            contents: env.jsonl([entry1, entry2]))

        var options = CostUsageScanner.Options(
            codexSessionsRoot: nil,
            claudeProjectsRoots: [env.claudeProjectsRoot],
            cacheRoot: env.cacheRoot)
        options.refreshMinIntervalSeconds = 0

        let report = CostUsageScanner.loadDailyReport(
            provider: .claude,
            since: day,
            until: day,
            now: day,
            options: options)

        #expect(report.data.count == 1)
        #expect(report.data[0].inputTokens == 30)
        #expect(report.data[0].outputTokens == 15)
        #expect(report.data[0].totalTokens == 45)
    }
}

struct CostUsageTestEnvironment {
    let root: URL
    let cacheRoot: URL
    let codexHomeRoot: URL
    let codexSessionsRoot: URL
    let codexArchivedSessionsRoot: URL
    let claudeProjectsRoot: URL
    let piSessionsRoot: URL

    init() throws {
        let root = FileManager.default.temporaryDirectory.appendingPathComponent(
            "codexbar-cost-usage-\(UUID().uuidString)",
            isDirectory: true)
        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        self.root = root
        self.cacheRoot = root.appendingPathComponent("cache", isDirectory: true)
        self.codexHomeRoot = root.appendingPathComponent("codex-home", isDirectory: true)
        self.codexSessionsRoot = self.codexHomeRoot.appendingPathComponent("sessions", isDirectory: true)
        self.codexArchivedSessionsRoot = self.codexHomeRoot
            .appendingPathComponent("archived_sessions", isDirectory: true)
        self.claudeProjectsRoot = root.appendingPathComponent("claude-projects", isDirectory: true)
        self.piSessionsRoot = root.appendingPathComponent("pi-sessions", isDirectory: true)
        try FileManager.default.createDirectory(at: self.cacheRoot, withIntermediateDirectories: true)
        try FileManager.default.createDirectory(at: self.codexSessionsRoot, withIntermediateDirectories: true)
        try FileManager.default.createDirectory(at: self.codexArchivedSessionsRoot, withIntermediateDirectories: true)
        try FileManager.default.createDirectory(at: self.claudeProjectsRoot, withIntermediateDirectories: true)
        try FileManager.default.createDirectory(at: self.piSessionsRoot, withIntermediateDirectories: true)
    }

    func cleanup() {
        try? FileManager.default.removeItem(at: self.root)
    }

    func makeLocalNoon(year: Int, month: Int, day: Int) throws -> Date {
        var comps = DateComponents()
        comps.calendar = Calendar.current
        comps.timeZone = TimeZone.current
        comps.year = year
        comps.month = month
        comps.day = day
        comps.hour = 12
        comps.minute = 0
        comps.second = 0
        guard let date = comps.date else { throw NSError(domain: "CostUsageTestEnvironment", code: 1) }
        return date
    }

    func isoString(for date: Date) -> String {
        let fmt = ISO8601DateFormatter()
        fmt.formatOptions = [.withInternetDateTime]
        return fmt.string(from: date)
    }

    func writeCodexSessionFile(day: Date, filename: String, contents: String) throws -> URL {
        let comps = Calendar.current.dateComponents([.year, .month, .day], from: day)
        let y = String(format: "%04d", comps.year ?? 1970)
        let m = String(format: "%02d", comps.month ?? 1)
        let d = String(format: "%02d", comps.day ?? 1)

        let dir = self.codexSessionsRoot
            .appendingPathComponent(y, isDirectory: true)
            .appendingPathComponent(m, isDirectory: true)
            .appendingPathComponent(d, isDirectory: true)
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)

        let url = dir.appendingPathComponent(filename, isDirectory: false)
        try contents.write(to: url, atomically: true, encoding: .utf8)
        return url
    }

    func writeClaudeProjectFile(relativePath: String, contents: String) throws -> URL {
        let url = self.claudeProjectsRoot.appendingPathComponent(relativePath, isDirectory: false)
        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try contents.write(to: url, atomically: true, encoding: .utf8)
        return url
    }

    func writeClaudeDesktopLocalAgentFile(relativePath: String, contents: String) throws -> URL {
        let localAgentRoot = self.root
            .appendingPathComponent("Library", isDirectory: true)
            .appendingPathComponent("Application Support", isDirectory: true)
            .appendingPathComponent("Claude", isDirectory: true)
            .appendingPathComponent("local-agent-mode-sessions", isDirectory: true)
            .appendingPathComponent("workspace-id", isDirectory: true)
            .appendingPathComponent("session-id", isDirectory: true)
            .appendingPathComponent("local_agent", isDirectory: true)
        let url = localAgentRoot.appendingPathComponent(relativePath, isDirectory: false)
        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try contents.write(to: url, atomically: true, encoding: .utf8)
        return url
    }

    func writeClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL {
        try self.writeClaudeDesktopLocalAgentFile(
            relativePath: ".claude/projects/\(relativePath)",
            contents: contents)
    }

    func writeClaudeDesktopCodeSessionProjectFile(relativePath: String, contents: String) throws -> URL {
        let projectsRoot = self.root
            .appendingPathComponent("Library", isDirectory: true)
            .appendingPathComponent("Application Support", isDirectory: true)
            .appendingPathComponent("Claude", isDirectory: true)
            .appendingPathComponent("claude-code-sessions", isDirectory: true)
            .appendingPathComponent("account-id", isDirectory: true)
            .appendingPathComponent("org-id", isDirectory: true)
            .appendingPathComponent(".claude", isDirectory: true)
            .appendingPathComponent("projects", isDirectory: true)
        let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false)
        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try contents.write(to: url, atomically: true, encoding: .utf8)
        return url
    }

    func writeClaudeDesktopSharedProjectFile(relativePath: String, contents: String) throws -> URL {
        let projectsRoot = self.root
            .appendingPathComponent(".claude", isDirectory: true)
            .appendingPathComponent("projects", isDirectory: true)
        let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false)
        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try contents.write(to: url, atomically: true, encoding: .utf8)
        return url
    }

    func writeNestedClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL {
        let projectsRoot = self.root
            .appendingPathComponent("Library", isDirectory: true)
            .appendingPathComponent("Application Support", isDirectory: true)
            .appendingPathComponent("Claude", isDirectory: true)
            .appendingPathComponent("local-agent-mode-sessions", isDirectory: true)
            .appendingPathComponent("workspace-id", isDirectory: true)
            .appendingPathComponent("session-id", isDirectory: true)
            .appendingPathComponent("agent", isDirectory: true)
            .appendingPathComponent("local_agent", isDirectory: true)
            .appendingPathComponent(".claude", isDirectory: true)
            .appendingPathComponent("projects", isDirectory: true)
        let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false)
        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try contents.write(to: url, atomically: true, encoding: .utf8)
        return url
    }

    func writeCodexArchivedSessionFile(filename: String, contents: String) throws -> URL {
        let url = self.codexArchivedSessionsRoot.appendingPathComponent(filename, isDirectory: false)
        try contents.write(to: url, atomically: true, encoding: .utf8)
        return url
    }

    func writePiSessionFile(relativePath: String, contents: String) throws -> URL {
        let url = self.piSessionsRoot.appendingPathComponent(relativePath, isDirectory: false)
        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try contents.write(to: url, atomically: true, encoding: .utf8)
        return url
    }

    func jsonl(_ objects: [Any]) throws -> String {
        let lines = try objects.map { obj in
            let data = try JSONSerialization.data(withJSONObject: obj)
            guard let text = String(bytes: data, encoding: .utf8) else {
                throw NSError(domain: "CostUsageTestEnvironment", code: 2)
            }
            return text
        }
        return lines.joined(separator: "\n") + "\n"
    }
}
