import Foundation
import Testing
@testable import Oppi

/// Cross-platform protocol contract tests.
///
/// Decodes canonical ServerMessage JSON generated by the server test suite
/// (`server/tests/protocol-snapshots.test.ts`). If any message fails to decode,
/// the server and iOS are out of sync.
///
/// To regenerate the snapshot file:
///   cd server && npx vitest run tests/protocol-snapshots.test.ts
@Suite("ProtocolSnapshot")
struct ProtocolSnapshotTests {

    // MARK: - Snapshot Loading

    /// Path to the shared protocol snapshot file.
    private var snapshotURL: URL {
        // #filePath → .../clients/apple/OppiTests/Protocol/ProtocolSnapshotTests.swift
        // Navigate up to repo root, then into protocol/
        let testFile = URL(fileURLWithPath: #filePath)
        let repoRoot = testFile
            .deletingLastPathComponent()  // ProtocolSnapshotTests.swift
            .deletingLastPathComponent()  // Protocol/
            .deletingLastPathComponent()  // OppiTests/
            .deletingLastPathComponent()  // apple/
            .deletingLastPathComponent()  // clients/
        return repoRoot.appendingPathComponent("protocol/server-messages.json")
    }

    private func loadSnapshot() throws -> [String: Any] {
        let data = try Data(contentsOf: snapshotURL)
        let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
        return json["messages"] as? [String: Any] ?? [:]
    }

    /// Decode a single message from the snapshot by key.
    private func decodeMessage(_ key: String) throws -> ServerMessage {
        let messages = try loadSnapshot()
        guard let value = messages[key] else {
            throw NSError(domain: "ProtocolSnapshot", code: 1, userInfo: [
                NSLocalizedDescriptionKey: "No snapshot for key '\(key)'"
            ])
        }
        let data = try JSONSerialization.data(withJSONObject: value)
        return try JSONDecoder().decode(ServerMessage.self, from: data)
    }

    // MARK: - Decode Every Message Type

    @Test func snapshotFileExists() throws {
        #expect(
            FileManager.default.fileExists(atPath: snapshotURL.path),
            "Protocol snapshot not found at \(snapshotURL.path). Run: cd server && npx vitest run tests/protocol-snapshots.test.ts"
        )
    }

    @Test func decodeAllServerMessages() throws {
        let messages = try loadSnapshot()

        var failures: [String] = []

        for (key, value) in messages {
            let messageJSON = try JSONSerialization.data(withJSONObject: value)
            do {
                let decoded = try JSONDecoder().decode(ServerMessage.self, from: messageJSON)

                // Verify we didn't fall through to .unknown
                switch decoded {
                case .unknown(let type):
                    failures.append("\(key): decoded as .unknown(\(type))")
                default:
                    break
                }
            } catch {
                failures.append("\(key): \(error.localizedDescription)")
            }
        }

        #expect(failures.isEmpty, "Failed to decode \(failures.count) message(s):\n\(failures.joined(separator: "\n"))")
    }

    // MARK: - Specific Type Assertions

    @Test func connectedMessage() throws {
        let msg = try decodeMessage("connected")

        guard case .connected(let session) = msg else {
            Issue.record("Expected .connected, got \(msg.typeLabel)")
            return
        }

        #expect(session.id == "test-session-1")
        #expect(session.status == .ready)
        #expect(session.workspaceId == "ws-1")
        #expect(session.workspaceName == "My Workspace")
        #expect(session.name == "Test Session")
        #expect(session.model == "anthropic/claude-sonnet-4-20250514")
        #expect(session.messageCount == 5)
        #expect(session.tokens.input == 1500)
        #expect(session.tokens.output == 800)
        #expect(session.cost == 0.012)
        #expect(session.contextTokens == 2300)
        #expect(session.contextWindow == 200000)
        #expect(session.thinkingLevel == "high")
        #expect(session.launch?.agentId == "agent-reviewer")
        #expect(session.launch?.agentIcon == .symbol("checkmark.shield"))
    }

    @Test func sessionSummaryCarriesAgentPresentationSnapshot() throws {
        let msg = try decodeMessage("session_summary")
        guard case .sessionSummary(let summary) = msg else {
            Issue.record("Expected .sessionSummary")
            return
        }

        #expect(summary.agentId == "agent-reviewer")
        #expect(summary.agentIcon == .symbol("checkmark.shield"))
        #expect(summary.session.launch?.agentIcon == .symbol("checkmark.shield"))
    }

    @Test func parentServerMessagesPreserveIconFallbackAcrossEveryTaggedAndFutureCase() throws {
        let assetId = "ia_" + String(repeating: "A", count: 43)
        let cases: [(String, IconChoice)] = [
            ("state_icon_default", .defaultValue),
            ("state_icon_emoji", .emoji("🧘")),
            ("state_icon_genmoji", .genmoji(
                assetId: assetId,
                contentDescription: "A smiling fox"
            )),
            ("state_icon_malformed", .defaultValue),
            ("state_icon_future", .defaultValue),
        ]

        for (key, expected) in cases {
            let message = try decodeMessage(key)
            guard case .state(let session) = message else {
                Issue.record("Expected .state for \(key), got \(message.typeLabel)")
                continue
            }
            #expect(session.launch?.agentIcon == expected)
        }
    }

    @Test func sessionChangeStats() throws {
        let msg = try decodeMessage("connected")

        guard case .connected(let session) = msg else {
            Issue.record("Expected .connected")
            return
        }

        let stats = session.changeStats
        #expect(stats != nil)
        #expect(stats?.mutatingToolCalls == 3)
        #expect(stats?.filesChanged == 6)
        #expect(stats?.changedFiles == ["src/main.ts", "README.md"])
        #expect(stats?.changedFilesOverflow == 4)
        #expect(stats?.addedLines == 45)
        #expect(stats?.removedLines == 12)
    }

    @Test func extensionUIRequest() throws {
        let msg = try decodeMessage("extension_ui_request")

        guard case .extensionUIRequest(let req) = msg else {
            Issue.record("Expected .extensionUIRequest, got \(msg.typeLabel)")
            return
        }

        #expect(req.id == "ui-001")
        #expect(req.sessionId == "test-session-1")
        #expect(req.method == "select")
        #expect(req.title == "Choose a model")
        #expect(req.options == ["claude-sonnet", "claude-opus"])
        #expect(req.extensionScopeId == "npm:review-helper")
        #expect(req.extensionDisplayName == "Review Helper")
    }

    @Test func extensionUINotification() throws {
        let msg = try decodeMessage("extension_ui_notification")

        guard case .extensionUINotification(let notification) = msg else {
            Issue.record("Expected .extensionUINotification, got \(msg.typeLabel)")
            return
        }

        #expect(notification.method == "setWidget")
        #expect(notification.widgetKey == "review")
        #expect(notification.extensionScopeId == "npm:review-helper")
        #expect(notification.extensionDisplayName == "Review Helper")
    }

    @Test func extensionUISettled() throws {
        let msg = try decodeMessage("extension_ui_settled")

        guard case .extensionUISettled(let id, let sessionId) = msg else {
            Issue.record("Expected .extensionUISettled, got \(msg.typeLabel)")
            return
        }

        #expect(id == "ui-001")
        #expect(sessionId == "test-session-1")
    }

    @Test func turnAck() throws {
        let msg = try decodeMessage("turn_ack")

        guard case .turnAck(let command, let clientTurnId, let stage, let requestId, let duplicate) = msg else {
            Issue.record("Expected .turnAck, got \(msg.typeLabel)")
            return
        }

        #expect(command == "prompt")
        #expect(clientTurnId == "turn-abc-123")
        #expect(stage == .accepted)
        #expect(requestId == "req-001")
        #expect(duplicate == false)
    }

    @Test func toolExecution() throws {
        // tool_start
        let startMsg = try decodeMessage("tool_start")
        guard case .toolStart(let tool, _, let toolCallId, _) = startMsg else {
            Issue.record("Expected .toolStart")
            return
        }
        #expect(tool == "bash")
        #expect(toolCallId == "tc-001")

        // tool_update
        let updateMsg = try decodeMessage("tool_update")
        guard case .toolUpdate(let updateTool, _, let updateToolCallId, _) = updateMsg else {
            Issue.record("Expected .toolUpdate")
            return
        }
        #expect(updateTool == "write")
        #expect(updateToolCallId == "tc-update-001")

        // tool_output
        let outputMsg = try decodeMessage("tool_output")
        guard case .toolOutput(let output, let isError, _, _, _, _, _) = outputMsg else {
            Issue.record("Expected .toolOutput")
            return
        }
        #expect(output == "All 42 tests passed")
        #expect(isError == false)

        // tool_output_preview (replace mode)
        let previewMsg = try decodeMessage("tool_output_preview")
        guard case .toolOutput(let previewOutput, _, _, let mode, let truncated, let totalBytes, _) = previewMsg else {
            Issue.record("Expected .toolOutput (preview)")
            return
        }
        #expect(previewOutput.contains("file-180"))
        #expect(mode == .replace)
        #expect(truncated)
        #expect(totalBytes == 32768)

        // tool_end
        let endMsg = try decodeMessage("tool_end")
        guard case .toolEnd(let endTool, _, _, _, _) = endMsg else {
            Issue.record("Expected .toolEnd")
            return
        }
        #expect(endTool == "bash")

        // tool_end_with_details
        let detailsMsg = try decodeMessage("tool_end_with_details")
        guard case .toolEnd(let detTool, _, let details, let isError, let resultSegs) = detailsMsg else {
            Issue.record("Expected .toolEnd with details")
            return
        }
        #expect(detTool == "remember")
        #expect(isError == false)
        if case .object(let dict) = details {
            #expect(dict["file"] == .string("2026-02-18.md"))
            #expect(dict["redacted"] == .bool(false))
        } else {
            Issue.record("Expected object details")
        }
        // Result segments from server mobile renderer
        #expect(resultSegs != nil)
        #expect(resultSegs?.count == 2)
        #expect(resultSegs?[0].text == "✓ Saved")
        #expect(resultSegs?[0].style == .success)
        #expect(resultSegs?[1].style == .muted)

        // tool_start_with_segments
        let segMsg = try decodeMessage("tool_start_with_segments")
        guard case .toolStart(let segTool, let segArgs, _, let callSegs) = segMsg else {
            Issue.record("Expected .toolStart with callSegments")
            return
        }
        #expect(segTool == "read")
        #expect(segArgs["path"] == .string("src/main.ts"))
        #expect(callSegs != nil)
        #expect(callSegs?.count == 3)
        #expect(callSegs?[0].text == "read ")
        #expect(callSegs?[0].style == .bold)
        #expect(callSegs?[1].text == "src/main.ts")
        #expect(callSegs?[1].style == .accent)
        #expect(callSegs?[2].text == ":1-50")
        #expect(callSegs?[2].style == .warning)
    }

    @Test func cacheMiss() throws {
        let msg = try decodeMessage("cache_miss")
        guard case .cacheMiss(let id, let message) = msg else {
            Issue.record("Expected .cacheMiss")
            return
        }
        #expect(id == "cache-miss:1739750460000:anthropic/claude-sonnet-4-20250514")
        #expect(message == "Cache miss after 5m idle: 69k tokens re-billed (~$0.79)")
    }

    @Test func compaction() throws {
        let startMsg = try decodeMessage("compaction_start")
        guard case .compactionStart(let reason) = startMsg else {
            Issue.record("Expected .compactionStart")
            return
        }
        #expect(reason == "Context window 85% full")

        let endMsg = try decodeMessage("compaction_end")
        guard case .compactionEnd(let aborted, let willRetry, let summary, let tokensBefore) = endMsg else {
            Issue.record("Expected .compactionEnd")
            return
        }
        #expect(aborted == false)
        #expect(willRetry == false)
        #expect(summary == "Compacted 15k tokens to 8k tokens")
        #expect(tokensBefore == 15000)
    }

    @Test func retry() throws {
        let startMsg = try decodeMessage("retry_start")
        guard case .retryStart(let attempt, let maxAttempts, let delayMs, let errorMessage) = startMsg else {
            Issue.record("Expected .retryStart")
            return
        }
        #expect(attempt == 1)
        #expect(maxAttempts == 3)
        #expect(delayMs == 5000)
        #expect(errorMessage == "API overloaded")

        let endMsg = try decodeMessage("retry_end")
        guard case .retryEnd(let success, let endAttempt, _) = endMsg else {
            Issue.record("Expected .retryEnd")
            return
        }
        #expect(success == true)
        #expect(endAttempt == 2)
    }

    @Test func stopLifecycle() throws {
        let reqMsg = try decodeMessage("stop_requested")
        guard case .stopRequested(let source, _) = reqMsg else {
            Issue.record("Expected .stopRequested")
            return
        }
        #expect(source == .user)

        let confMsg = try decodeMessage("stop_confirmed")
        guard case .stopConfirmed = confMsg else {
            Issue.record("Expected .stopConfirmed")
            return
        }

        let failMsg = try decodeMessage("stop_failed")
        guard case .stopFailed(let failSource, let reason) = failMsg else {
            Issue.record("Expected .stopFailed")
            return
        }
        #expect(failSource == .timeout)
        #expect(reason.contains("SIGTERM"))
    }

    @Test func errorMessage() throws {
        let msg = try decodeMessage("error")

        guard case .error(let message, let code, let fatal) = msg else {
            Issue.record("Expected .error")
            return
        }
        #expect(message == "Model API rate limit exceeded")
        #expect(code == "rate_limit")
        #expect(fatal == false)
    }

    // MARK: - Message Count Validation

    @Test func snapshotCoversAllExpectedTypes() throws {
        let messages = try loadSnapshot()

        let expectedTypes: Set<String> = [
            "connected", "stream_connected", "state", "session_summary",
            "session_ended", "session_deleted",
            "stop_requested", "stop_confirmed", "stop_failed", "error",
            "agent_start", "agent_end", "message_end", "cache_miss",
            "text_delta", "thinking_delta", "audio_stream",
            "tool_start", "tool_update", "tool_output", "tool_end",
            "queue_state", "queue_item_started",
            "turn_ack", "command_result",
            "compaction_start", "compaction_end",
            "retry_start", "retry_end",
            "extension_ui_request", "extension_ui_notification", "extension_ui_settled", "git_status",
            "dictation_ready", "dictation_result", "dictation_final", "dictation_error",
        ]

        let messageTypes = Set(messages.values.compactMap { value -> String? in
            guard let dict = value as? [String: Any] else { return nil }
            return dict["type"] as? String
        })

        let missing = expectedTypes.subtracting(messageTypes)
        #expect(missing.isEmpty, "Missing snapshots for types: \(missing.sorted())")
    }
}
