import Foundation
import Testing

@testable import NucleicProtocol

@Suite struct WireMessageTests {
    private func roundTrip<T: Codable & Equatable>(_ value: T) throws -> T {
        try CBORDecoder().decode(T.self, from: CBOREncoder().encode(value))
    }

    private let summary = SessionSummary(
        sessionID: SessionID(rawValue: "s1"), projectID: "p1", projectName: "ProjA",
        backend: .claudeCode, status: .awaitingApproval, disposition: .completed,
        title: "auth-refactor", branch: "nucleic/auth", lastSeq: 120,
        diffStat: DiffStat(filesChanged: 3, added: 312, removed: 40),
        pendingApprovalCount: 1, favorite: true, archived: false,
        updatedAt: Date(timeIntervalSince1970: 1_700_000_000))

    @Test func clientMessagesRoundTrip() throws {
        let approval = ApprovalID(rawValue: "a1")
        let sid = SessionID(rawValue: "s1")
        let msgs: [ClientMsg] = [
            .hello(Hello(deviceID: "dev", deviceLabel: "Andrew's iPhone")),
            .listSessions,
            .subscribe(Subscribe(sessionID: sid, sinceSeq: 42, verbosity: .full)),
            .subscribe(Subscribe(sessionID: sid, sinceSeq: nil, verbosity: .coalesced)),
            .unsubscribe(sid),
            .approvalRespond(approval, .allow(updatedInput: ["command": "ls"])),
            .approvalRespond(approval, .deny(reason: "no")),
            .approvalRespond(approval, .allowAlways(.toolName)),
            .sendInput(sid, AgentInput(text: "keep going")),
            .interrupt(sid),
            .ping,
            // Control scope:
            .listDashboard,
            .startChat(StartChatRequest(projectID: ProjectID(rawValue: "p1"), message: "go", auto: true)),
            .captureTodo(CaptureTodoRequest(text: "fix the thing", projectID: ProjectID(rawValue: "p1"))),
            .dispatchTodo(TodoID(rawValue: "t1"), ProjectID(rawValue: "p1")),
            .setTodoStatus(TodoID(rawValue: "t1"), .done),
            .deleteTodo(TodoID(rawValue: "t1")),
            .renameSession(sid, "new title"),
            .setFavorite(sid, true),
            .setArchived(sid, false),
            .deleteSession(sid),
            .integrate(sid, .squash),
            .discard(sid),
            // Mid-session controls — exercise both the value and the nil (reset) variants.
            .setSessionModel(sid, "claude-sonnet-4-6"),
            .setSessionModel(sid, nil),
            .setSessionEffort(sid, "xhigh"),
            .setSessionEffort(sid, nil),
            .setSessionAuto(sid, true),
            .setSessionAutoShip(sid, false),
            .setSessionShipBranch(sid, "main"),
            .setSessionShipBranch(sid, nil),
        ]
        for msg in msgs { #expect(try roundTrip(msg) == msg) }
    }

    private let catalog = WireModelCatalog(
        groups: [
            [WireModelCatalog.Model(
                sku: "claude-opus-4-8", displayName: "Opus 4.8", backend: .claudeCode,
                contextBadge: "256K", contextWindow: 256_000,
                efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
            [WireModelCatalog.Model(
                sku: "grok-build", displayName: "Grok Build", backend: .grok,
                contextBadge: nil, contextWindow: 256_000, efforts: ["auto"], effortNoun: "Reasoning")],
        ],
        effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
        orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
        fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")

    @Test func modelCatalogRoundTripsAndLooksUp() throws {
        #expect(try roundTrip(catalog) == catalog)
        // The phone reads everything it needs straight off the projection — no backend logic.
        #expect(catalog.models(for: .grok).map(\.sku) == ["grok-build"])
        #expect(catalog.efforts(forModel: "grok-build") == ["auto"])
        #expect(catalog.efforts(forModel: "claude-opus-4-8").last == "max")
        #expect(catalog.contextWindow("claude-opus-4-8") == 256_000)
        #expect(catalog.effortNoun(forModel: "grok-build") == "Reasoning")
        #expect(catalog.effortDisplayName("auto") == "Auto")
        #expect(catalog.isOrchestra("orchestra"))
        #expect(!catalog.isOrchestra("high"))
    }

    @Test func welcomeCarriesCatalog() throws {
        let welcome = Welcome(
            grantedScope: .control, host: HostInfo(hostID: "h", hostName: "Andrew's Mac"),
            capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session]),
            modelCatalog: catalog)
        #expect(try roundTrip(HostMsg.welcome(welcome)) == .welcome(welcome))
    }

    @Test func sessionSummaryNewFieldsRoundTrip() throws {
        let rich = SessionSummary(
            sessionID: SessionID(rawValue: "s1"), projectID: "p1", projectName: "ProjA",
            backend: .claudeCode, status: .running, title: "t", branch: "b", lastSeq: 5,
            diffStat: nil, model: "claude-sonnet-4-6", effort: "xhigh", auto: true,
            autoShip: true, shipBranch: "main", contextInputTokens: 84_000,
            updatedAt: Date(timeIntervalSince1970: 1))
        let back = try roundTrip(rich)
        #expect(back.model == "claude-sonnet-4-6")
        #expect(back.effort == "xhigh")
        #expect(back.auto == true)
        #expect(back.autoShip == true)
        #expect(back.shipBranch == "main")
        #expect(back.contextInputTokens == 84_000)
    }

    @Test func sessionSummaryToleratesMissingNewFields() throws {
        // A summary encoded by a host that predates the model/effort/auto/autoShip fields:
        // it must decode with safe defaults rather than throwing (SYNC §9 forward-compat).
        struct LegacySummary: Encodable {
            let sessionID = SessionID(rawValue: "s1")
            let projectID = "p1"
            let projectName = "ProjA"
            let backend = BackendID.claudeCode
            let status = SessionStatus.running
            let title = "t"
            let branch = "b"
            let lastSeq: UInt64 = 5
            let updatedAt = Date(timeIntervalSince1970: 1)
        }
        let data = try CBOREncoder().encode(LegacySummary())
        let decoded = try CBORDecoder().decode(SessionSummary.self, from: data)
        #expect(decoded.model == nil)
        #expect(decoded.effort == nil)
        #expect(decoded.auto == false)
        #expect(decoded.autoShip == false)
        #expect(decoded.shipBranch == nil)
        #expect(decoded.contextInputTokens == nil)
        // Pre-existing non-optional fields also fall back rather than throwing.
        #expect(decoded.favorite == false)
        #expect(decoded.pendingApprovalCount == 0)
    }

    @Test func dashboardRoundTrips() throws {
        let snapshot = DashboardSnapshot(
            counts: DashboardCounts(projects: 2, chats: 7, activeChats: 3, messages: 140, activeDays: 5, tokens: 98_765),
            activity: [ActivityDay(day: Date(timeIntervalSince1970: 1_700_000_000), count: 4, tokens: 12_345)],
            projects: [WireProject(id: ProjectID(rawValue: "p1"), name: "ProjA", defaultBranch: "main", sessionCount: 3, activeCount: 1)],
            todos: [WireTodo(
                id: TodoID(rawValue: "t1"), text: "ship it", summary: "ship",
                projectID: ProjectID(rawValue: "p1"), projectName: "ProjA", status: .open,
                dispatchedSessionID: nil, updatedAt: Date(timeIntervalSince1970: 1))])
        #expect(try roundTrip(HostMsg.dashboard(snapshot)) == .dashboard(snapshot))
    }

    @Test func hostMessagesRoundTrip() throws {
        let snapshot = SessionSnapshot(
            summary: summary,
            recentEvents: [AgentEvent(
                sessionID: summary.sessionID, seq: 119, at: Date(timeIntervalSince1970: 1),
                backend: .claudeCode, nativeType: nil,
                kind: .assistantText(TextChunk(messageID: "m", text: "hi", isPartial: false)))],
            pendingApprovals: [ApprovalRequest(
                id: ApprovalID(rawValue: "a1"), sessionID: summary.sessionID, toolCallID: "t",
                toolName: "Bash", input: ["command": "rm -rf build/"], title: "Run command",
                risk: .destructive, createdAt: Date(timeIntervalSince1970: 2))],
            cursor: 120)
        let msgs: [HostMsg] = [
            .welcome(Welcome(
                grantedScope: .approve,
                host: HostInfo(hostID: "h", hostName: "Andrew's Mac"),
                capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName]))),
            .sessionList([summary]),
            .snapshot(snapshot),
            .events(EventBatch(sessionID: summary.sessionID, events: snapshot.recentEvents)),
            .approvalRequested(snapshot.pendingApprovals[0]),
            .approvalResolved(ApprovalResolved(
                id: ApprovalID(rawValue: "a1"), decision: .deny(reason: nil),
                decidedBy: "iphone:dev", decidedAt: Date(timeIntervalSince1970: 3))),
            .sessionUpdated(summary),
            .error(WireError(code: .alreadyResolved, message: "lost the race", sessionID: summary.sessionID)),
            .pong,
        ]
        for msg in msgs { #expect(try roundTrip(msg) == msg) }
    }

    @Test func unknownHostTagIsForwardCompatible() throws {
        // Simulate a newer host sending a tag this client doesn't know.
        let future: [String: String] = ["t": "futureThing"]
        let data = try CBOREncoder().encode(future)
        let decoded = try CBORDecoder().decode(HostMsg.self, from: data)
        #expect(decoded == .unknown("futureThing"))
    }

    @Test func deviceScopeOrders() {
        #expect(DeviceScope.view < DeviceScope.approve)
        #expect(DeviceScope.approve < DeviceScope.control)
        #expect(DeviceScope.approve >= DeviceScope.approve)
    }
}
