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):
                    // Server-only message types iOS intentionally ignores.
                    let allowedUnknown: Set<String> = ["stream_connected"]
                    if !allowedUnknown.contains(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")
    }

    @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 permissionRequest() throws {
        let msg = try decodeMessage("permission_request")

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

        #expect(perm.id == "perm-001")
        #expect(perm.sessionId == "test-session-1")
        #expect(perm.tool == "bash")
        #expect(perm.displaySummary == "Run: rm -rf node_modules")
        #expect(perm.reason == "Destructive file operation")
    }

    @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"])
    }

    @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_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 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()

        // All type discriminators that iOS handles (excluding stream_connected which is mux-only)
        let expectedTypes: Set<String> = [
            "connected", "state", "session_ended", "session_deleted",
            "stop_requested", "stop_confirmed", "stop_failed", "error",
            "agent_start", "agent_end", "message_end",
            "text_delta", "thinking_delta",
            "tool_start", "tool_output", "tool_end",
            "turn_ack", "command_result",
            "compaction_start", "compaction_end",
            "retry_start", "retry_end",
            "permission_request", "permission_expired", "permission_cancelled",
            "extension_ui_request", "extension_ui_notification",
        ]

        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())")
    }
}
