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 releaseChannelCompatibility() {
        // Same channel always matches.
        for ch: ReleaseChannel in [.local, .canary, .beta, .releaseCandidate, .release] {
            #expect(ch.isCompatible(with: ch))
        }
        // The one exception: canary and local are interchangeable, in both directions.
        #expect(ReleaseChannel.canary.isCompatible(with: .local))
        #expect(ReleaseChannel.local.isCompatible(with: .canary))
        // Everything else is incompatible.
        #expect(!ReleaseChannel.beta.isCompatible(with: .canary))
        #expect(!ReleaseChannel.canary.isCompatible(with: .beta))
        #expect(!ReleaseChannel.release.isCompatible(with: .local))
        #expect(!ReleaseChannel.local.isCompatible(with: .beta))
        #expect(!ReleaseChannel.releaseCandidate.isCompatible(with: .release))
    }

    @Test func helloAndWelcomeCarryChannel() throws {
        let hello = Hello(deviceID: "dev", deviceLabel: "iPhone", channel: .canary)
        #expect(try roundTrip(hello).channel == .canary)
        let welcome = Welcome(
            grantedScope: .control, host: HostInfo(hostID: "h", hostName: "Mac"),
            capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: []),
            channel: .beta)
        #expect(try roundTrip(welcome).channel == .beta)
        // A peer that predates the field decodes as `nil` (gate skipped).
        #expect(try roundTrip(Hello(deviceID: "d", deviceLabel: "i")).channel == nil)
    }

    @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),
            .cancelQueuedMessage(sid, UUID(uuidString: "11111111-1111-1111-1111-111111111111")!),
            .fetchDiff(sid),
            .listPeers,  // mesh P4
            .addressUpdate(PeerAddresses(lanHint: "10.0.0.4:51000", tailnet: "100.64.0.9")),  // mesh P4
        ]
        for msg in msgs { #expect(try roundTrip(msg) == msg) }
    }

    // MARK: Mesh P4 — peer model, deviceKind, listPeers/peerList

    @Test func helloCarriesDeviceKindAndClientCaps() throws {
        let hello = Hello(
            deviceID: "mac-b", deviceLabel: "Work Mac",
            scopeClaim: .control, deviceKind: PeerKind.mac.rawValue,
            clientCaps: WireClientCapabilities(mesh: 1, canHost: true, canRunAgents: true))
        let decoded = try roundTrip(hello)
        #expect(decoded.deviceKind == "mac")
        #expect(decoded.kind == .mac)
        #expect(decoded.clientCaps?.mesh == 1)
        #expect(decoded.clientCaps?.canHost == true)
    }

    @Test func helloWithoutMeshFieldsDefaultsToIPhone() throws {
        // A pre-mesh iPhone omits deviceKind/clientCaps.
        let decoded = try roundTrip(Hello(deviceID: "phone", deviceLabel: "iPhone"))
        #expect(decoded.deviceKind == nil)
        #expect(decoded.kind == .iphone)
        #expect(decoded.clientCaps == nil)
    }

    @Test func capabilitiesCarryCanListPeers() throws {
        let caps = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [], canListPeers: true)
        #expect(try roundTrip(caps).canListPeers == true)
    }

    /// A host that predates `canListPeers` decodes it as false (client won't send `listPeers`).
    @Test func capabilitiesTolerateMissingCanListPeers() throws {
        struct LegacyCaps: Codable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
        let data = try CBOREncoder().encode(LegacyCaps())
        let decoded = try CBORDecoder().decode(WireCapabilities.self, from: data)
        #expect(decoded.canListPeers == false)
        #expect(decoded.canFetchDiff == false)
    }

    // MARK: Remote pairing-code mint ("add a device to this mesh")

    @Test func capabilitiesCarryCanMintPairingCode() throws {
        let caps = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [], canMintPairingCode: true)
        #expect(try roundTrip(caps).canMintPairingCode == true)
    }

    /// A host that predates `canMintPairingCode` decodes it as false (the phone hides the
    /// "add a device" button and never sends `requestPairingCode`).
    @Test func capabilitiesTolerateMissingCanMintPairingCode() throws {
        struct LegacyCaps: Codable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
        let data = try CBOREncoder().encode(LegacyCaps())
        let decoded = try CBORDecoder().decode(WireCapabilities.self, from: data)
        #expect(decoded.canMintPairingCode == false)
    }

    @Test func pairingCodeVerbsRoundTrip() throws {
        #expect(try roundTrip(ClientMsg.requestPairingCode) == .requestPairingCode)
        #expect(try roundTrip(ClientMsg.cancelPairingCode) == .cancelPairingCode)
        // The host reply carries the join string on success and nil when it couldn't mint.
        #expect(try roundTrip(HostMsg.pairingCode("nucleic://pair?d=abc")) == .pairingCode("nucleic://pair?d=abc"))
        #expect(try roundTrip(HostMsg.pairingCode(nil)) == .pairingCode(nil))
    }

    @Test func macPairApprovalForwardingRoundTrips() throws {
        // Host forwards the confirm to a control client, which answers, and the host withdraws it.
        let req = WireMacPairRequest(deviceID: "mac-c", label: "Studio")
        #expect(try roundTrip(req) == req)
        #expect(try roundTrip(HostMsg.macPairRequested(req)) == .macPairRequested(req))
        #expect(try roundTrip(HostMsg.macPairResolved("mac-c", true)) == .macPairResolved("mac-c", true))
        #expect(try roundTrip(ClientMsg.respondMacPair("mac-c", false)) == .respondMacPair("mac-c", false))
    }

    @Test func peerListRoundTrips() throws {
        let peers = [
            PeerSummary(deviceID: "mac-b", label: "Work Mac", kind: .mac,
                        capabilities: .hostAndAgents, online: true,
                        lastSeenAt: Date(timeIntervalSince1970: 1_700_000_000)),
            PeerSummary(deviceID: "phone", label: "iPhone", kind: .iphone),
        ]
        let decoded = try roundTrip(HostMsg.peerList(peers))
        #expect(decoded == .peerList(peers))
    }

    @Test func peerKindIsForwardCompatible() throws {
        // An unknown future kind decodes to its raw value, not a throw.
        let future = PeerSummary(deviceID: "x", label: "Watch", kind: PeerKind(rawValue: "watch"))
        #expect(try roundTrip(future).kind.rawValue == "watch")
    }

    // MARK: Mesh "join" — roster gossip (MeshMember / MeshTombstone / MeshRosterPush)

    private let member = MeshMember(
        deviceID: "mac-b", label: "Work Mac", kind: .mac, capabilities: .hostAndAgents,
        staticPublicKey: Data(repeating: 0xAB, count: 32),
        addresses: PeerAddresses(lanHint: "192.168.1.20:52341", tailnet: "100.64.0.7"),
        pairedAt: Date(timeIntervalSince1970: 1_700_000_000),
        lastSeenAt: Date(timeIntervalSince1970: 1_700_000_500))

    @Test func meshRosterRoundTripsBothDirections() throws {
        let push = MeshRosterPush(
            members: [
                member,
                MeshMember(deviceID: "phone", label: "iPhone", kind: .iphone,
                           staticPublicKey: Data(repeating: 0x01, count: 32),
                           pairedAt: Date(timeIntervalSince1970: 1_700_000_100)),
            ],
            tombstones: [MeshTombstone(deviceID: "old-mac", revokedAt: Date(timeIntervalSince1970: 1_699_000_000))])
        // Gossip flows both ways: a Mac is host *and* client.
        #expect(try roundTrip(ClientMsg.meshRoster(push)) == .meshRoster(push))
        #expect(try roundTrip(HostMsg.meshRoster(push)) == .meshRoster(push))
        #expect(try roundTrip(member) == member)
    }

    /// A `MeshMember` from a build that predates a field (e.g. `pairedAt`) decodes with a floor
    /// default rather than throwing — the same decode-defaulted tolerance as `PeerSummary`.
    @Test func meshMemberToleratesMissingFields() throws {
        struct LegacyMember: Encodable {
            let deviceID = "mac-b"
            let label = "Work Mac"
            let staticPublicKey = Data(repeating: 0xAB, count: 32)
        }
        let decoded = try CBORDecoder().decode(MeshMember.self, from: CBOREncoder().encode(LegacyMember()))
        #expect(decoded.deviceID == "mac-b")
        #expect(decoded.kind == .iphone)              // default when kind is absent
        #expect(decoded.addresses == nil)
        #expect(decoded.pairedAt == Date(timeIntervalSince1970: 0))
    }

    @Test func capabilitiesCarryCanSyncRoster() throws {
        let caps = WireCapabilities(
            canModifyToolInput: true, allowAlwaysScopes: [], canSyncRoster: true)
        #expect(try roundTrip(caps).canSyncRoster == true)
        // A client advertises it in its Hello caps too.
        let clientCaps = WireClientCapabilities(mesh: 2, canHost: true, canSyncRoster: true)
        #expect(try roundTrip(clientCaps).canSyncRoster == true)
    }

    /// A host/client that predates `canSyncRoster` decodes it as false → falls back to the
    /// display-only `listPeers` path, never sending/expecting `meshRoster`.
    @Test func capabilitiesTolerateMissingCanSyncRoster() throws {
        struct LegacyCaps: Codable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
        let decoded = try CBORDecoder().decode(WireCapabilities.self, from: CBOREncoder().encode(LegacyCaps()))
        #expect(decoded.canSyncRoster == false)
        struct LegacyClientCaps: Codable { let mesh = 1 }
        let cc = try CBORDecoder().decode(
            WireClientCapabilities.self, from: CBOREncoder().encode(LegacyClientCaps()))
        #expect(cc.canSyncRoster == false)
    }

    // MARK: Mesh P4 remainder — PeerAddresses, Hello/Welcome addresses, addressUpdate

    private let addresses = PeerAddresses(
        lanHint: "192.168.1.20:52341", tailnet: "100.64.0.7",
        relayRoomID: nil, updatedAt: Date(timeIntervalSince1970: 1_700_000_000))

    @Test func peerAddressesRoundTrip() throws {
        let back = try roundTrip(addresses)
        #expect(back == addresses)
        #expect(!back.isEmpty)
        #expect(PeerAddresses().isEmpty)
    }

    @Test func helloCarriesAddresses() throws {
        let hello = Hello(
            deviceID: "mac-b", deviceLabel: "Work Mac",
            deviceKind: PeerKind.mac.rawValue, addresses: addresses)
        #expect(try roundTrip(hello).addresses == addresses)
        // A client that predates the field omits it.
        #expect(try roundTrip(Hello(deviceID: "d", deviceLabel: "i")).addresses == nil)
    }

    @Test func welcomeCarriesAddresses() throws {
        let welcome = Welcome(
            grantedScope: .control, host: HostInfo(hostID: "h", hostName: "Mac"),
            capabilities: WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: []),
            addresses: addresses)
        #expect(try roundTrip(welcome).addresses == addresses)
    }

    /// A hello/welcome from a build that predates `addresses` decodes with nil (never throws).
    @Test func helloAndWelcomeTolerateMissingAddresses() throws {
        struct LegacyHello: Encodable {
            let protocolVersion = SyncProtocol.version
            let deviceID = "d"
            let deviceLabel = "iPhone"
            let scopeClaim = DeviceScope.approve
        }
        let hello = try CBORDecoder().decode(Hello.self, from: CBOREncoder().encode(LegacyHello()))
        #expect(hello.addresses == nil)

        struct LegacyWelcome: Encodable {
            let protocolVersion = SyncProtocol.version
            let grantedScope = DeviceScope.control
            let host = HostInfo(hostID: "h", hostName: "Mac")
            let capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [])
        }
        let welcome = try CBORDecoder().decode(Welcome.self, from: CBOREncoder().encode(LegacyWelcome()))
        #expect(welcome.addresses == nil)
    }

    @Test func addressUpdateRoundTrips() throws {
        let msg = ClientMsg.addressUpdate(addresses)
        #expect(try roundTrip(msg) == msg)
    }

    @Test func capabilitiesCarryCanUpdateAddresses() throws {
        let caps = WireCapabilities(
            canModifyToolInput: true, allowAlwaysScopes: [], canUpdateAddresses: true)
        #expect(try roundTrip(caps).canUpdateAddresses == true)
    }

    /// A host that predates `canUpdateAddresses` decodes it as false (client never sends it).
    @Test func capabilitiesTolerateMissingCanUpdateAddresses() throws {
        struct LegacyCaps: Codable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
        let decoded = try CBORDecoder().decode(
            WireCapabilities.self, from: CBOREncoder().encode(LegacyCaps()))
        #expect(decoded.canUpdateAddresses == false)
    }

    // MARK: Mesh P5 — session transfer

    private var transferOffer: TransferOffer {
        TransferOffer(
            transferID: "xfer-1",
            record: SessionTransferRecord(
                sessionID: SessionID(rawValue: "s1"), backend: .claudeCode,
                backendSessionID: "native-abc", title: "auth refactor",
                branch: "nucleic/auth", baseSHA: "deadbeef", model: "claude-opus-4-8",
                effort: "high", lastSeq: 42, createdAt: Date(timeIntervalSince1970: 1_700_000_000)),
            project: ProjectDescriptor(
                projectID: ProjectID(rawValue: "p1"), name: "ProjA",
                rootCommitSHA: "root123", normalizedRemote: "github.com/me/proj",
                defaultBranch: "main"),
            transcriptHeaderVersion: 1,
            availableBaseSHAs: ["deadbeef", "cafef00d"],
            knownItems: [
                TransferItemDescriptor(kind: .transcript, byteCount: 4096, sha256: "abc", chunkCount: 1),
                TransferItemDescriptor(kind: .nativeTranscript, byteCount: 8192, sha256: "def", chunkCount: 1),
            ])
    }

    @Test func transferClientMessagesRoundTrip() throws {
        let chunk = TransferChunk(
            transferID: "xfer-1", kind: .bundle, chunkIndex: 0, chunkCount: 2,
            sha256: "bundlesha", data: Data([0x1, 0x2, 0x3]))
        let msgs: [ClientMsg] = [
            .transferOffer(transferOffer),
            .transferChunk(chunk),
            .transferCommit("xfer-1"),
            .transferCancel("xfer-1"),
        ]
        for msg in msgs { #expect(try roundTrip(msg) == msg) }
    }

    @Test func transferHostMessagesRoundTrip() throws {
        let msgs: [HostMsg] = [
            .transferAccept(TransferAccept(
                transferID: "xfer-1", resolvedProjectID: ProjectID(rawValue: "p-local"),
                haveSHAs: ["deadbeef"], resumeFrom: [.bundle: 3, .transcript: 0])),
            .transferReject(TransferReject(transferID: "xfer-1", reason: .projectNotFound)),
            .transferReady("xfer-1"),
            .transferCommitted("xfer-1"),
            .transferChunkAck(TransferChunkAck(transferID: "xfer-1", kind: .bundle, chunkIndex: 0)),
        ]
        for msg in msgs { #expect(try roundTrip(msg) == msg) }
    }

    @Test func transferRejectReasonIsForwardCompatible() throws {
        // An unknown future reason decodes to its raw value, not a throw.
        let reject = TransferReject(
            transferID: "x", reason: TransferRejectReason(rawValue: "someFutureReason"),
            message: "who knows")
        #expect(try roundTrip(HostMsg.transferReject(reject)) == .transferReject(reject))
    }

    @Test func capabilitiesCarryCanReceiveSessionTransfer() throws {
        let caps = WireCapabilities(
            canModifyToolInput: true, allowAlwaysScopes: [], canReceiveSessionTransfer: true)
        #expect(try roundTrip(caps).canReceiveSessionTransfer == true)
    }

    /// A host that predates the transfer capability decodes it false — a source never offers.
    @Test func capabilitiesTolerateMissingCanReceiveSessionTransfer() throws {
        struct LegacyCaps: Codable { let canModifyToolInput = true; let allowAlwaysScopes: [AlwaysScope] = [] }
        let decoded = try CBORDecoder().decode(
            WireCapabilities.self, from: CBOREncoder().encode(LegacyCaps()))
        #expect(decoded.canReceiveSessionTransfer == false)
    }

    /// A transfer reply from a newer host is still forward-compatible at the raw HostMsg layer
    /// (`.unknown`), but the whole point of P5 is that SyncClient maps it to an Event — the
    /// codec must at least round-trip it so an up-to-date source sees it.
    @Test func transferReplyKeepsUnknownFallbackForOldClients() throws {
        // An old client that predates transferReady sees it as .unknown, not a decode failure.
        let data = try CBOREncoder().encode(["t": "transferReady", "transferID": "x"])
        // (Old clients don't have the case; new clients decode it properly.)
        let decoded = try CBORDecoder().decode(HostMsg.self, from: data)
        #expect(decoded == .transferReady("x"))
    }

    @Test func projectDescriptorMatchesByIdentity() {
        let a = ProjectDescriptor(projectID: ProjectID(rawValue: "p1"), name: "A",
                                  rootCommitSHA: "root", normalizedRemote: nil, defaultBranch: "main")
        let sameRoot = ProjectDescriptor(projectID: ProjectID(rawValue: "p2"), name: "A",
                                         rootCommitSHA: "root", normalizedRemote: nil, defaultBranch: "main")
        let sameRemote = ProjectDescriptor(projectID: ProjectID(rawValue: "p3"), name: "A",
                                           rootCommitSHA: nil, normalizedRemote: "github.com/me/a", defaultBranch: "main")
        let sameRemoteOther = ProjectDescriptor(projectID: ProjectID(rawValue: "p4"), name: "A",
                                                rootCommitSHA: nil, normalizedRemote: "github.com/me/a", defaultBranch: "main")
        let different = ProjectDescriptor(projectID: ProjectID(rawValue: "p5"), name: "B",
                                          rootCommitSHA: "other", normalizedRemote: "github.com/me/b", defaultBranch: "main")
        #expect(a.matches(sameRoot))            // same root commit
        #expect(sameRemote.matches(sameRemoteOther))  // same normalized remote
        #expect(a.matches(a))                   // same UUID
        #expect(!a.matches(different))
    }

    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,
            queuedMessages: [QueuedMessage(
                id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!,
                text: "look at this",
                attachments: [QueuedAttachment(
                    id: UUID(uuidString: "33333333-3333-3333-3333-333333333333")!,
                    filename: "shot.png", relativePath: ".nucleic/attachments/ab-shot.png")])],
            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)
        #expect(back.queuedMessages.count == 1)
        #expect(back.queuedMessages.first?.text == "look at this")
        #expect(back.queuedMessages.first?.attachments.first?.filename == "shot.png")
    }

    @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)
        #expect(decoded.queuedMessages.isEmpty)
        // Pre-existing non-optional fields also fall back rather than throwing.
        #expect(decoded.favorite == false)
        #expect(decoded.pendingApprovalCount == 0)
        // A summary that predates the moved-to field decodes as "not moved" (mesh P5).
        #expect(decoded.movedTo == nil)
        #expect(decoded.arrivedFrom == nil)
    }

    @Test func sessionSummaryCarriesMovedTo() throws {
        // A live session leaves `movedTo` nil; a tombstoned one names where it went, so a phone
        // renders "Moved to <deviceName>" (mesh P5). Round-trips through CBOR.
        #expect(try roundTrip(summary).movedTo == nil)
        let moved = SessionSummary(
            sessionID: SessionID(rawValue: "s1"), projectID: "p1", projectName: "ProjA",
            backend: .claudeCode, status: .awaitingInput, title: "auth-refactor",
            branch: "nucleic/auth", lastSeq: 120, diffStat: nil, archived: true,
            updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
            movedTo: MovedDestination(deviceID: "abc123", deviceName: "Studio Mac"))
        let back = try roundTrip(moved)
        #expect(back.archived == true)
        #expect(back.movedTo == MovedDestination(deviceID: "abc123", deviceName: "Studio Mac"))
        #expect(back.movedTo?.deviceName == "Studio Mac")
    }

    @Test func sessionSummaryCarriesArrivedFrom() throws {
        // A session created here leaves `arrivedFrom` nil; one transferred in names its origin so the
        // destination renders "Arrived from <deviceName>" (mesh P5). Round-trips through CBOR.
        #expect(try roundTrip(summary).arrivedFrom == nil)
        let arrived = SessionSummary(
            sessionID: SessionID(rawValue: "s2"), projectID: "p1", projectName: "ProjA",
            backend: .claudeCode, status: .awaitingInput, title: "ported-work",
            branch: "nucleic/ported", lastSeq: 3, diffStat: nil,
            updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
            arrivedFrom: ArrivedFrom(deviceID: "laptop-1", deviceName: "Andrew's Laptop"))
        let back = try roundTrip(arrived)
        #expect(back.movedTo == nil)
        #expect(back.arrivedFrom == ArrivedFrom(deviceID: "laptop-1", deviceName: "Andrew's Laptop"))
        #expect(back.arrivedFrom?.deviceName == "Andrew's Laptop")
    }

    @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, triage: "critical", updatedAt: Date(timeIntervalSince1970: 1))],
            usage: WireSubscriptionUsage(
                fiveHour: WireUsageWindow(utilization: 42.5, resetsAt: Date(timeIntervalSince1970: 2)),
                sevenDay: WireUsageWindow(utilization: 78, resetsAt: nil),
                sevenDayOpus: nil,
                sevenDaySonnet: WireUsageWindow(utilization: 12, resetsAt: nil)),
            statusFeeds: [WireStatusFeed(
                provider: "openai", providerName: "OpenAI",
                incidents: [WireStatusIncident(
                    id: "i1", title: "Elevated errors on Codex", url: URL(string: "https://status.openai.com"),
                    updatedAt: Date(timeIntervalSince1970: 3), state: "Monitoring",
                    isResolved: false, components: ["Codex", "APIs"])])])
        let back = try roundTrip(HostMsg.dashboard(snapshot))
        #expect(back == .dashboard(snapshot))
        guard case .dashboard(let decoded) = back else { Issue.record("not a dashboard"); return }
        #expect(decoded.todos.first?.triage == "critical")
        #expect(decoded.usage?.fiveHour?.utilization == 42.5)
        #expect(decoded.usage?.peakUtilization == 78)
        #expect(decoded.statusFeeds.first?.hasActiveIncident == true)
    }

    @Test func dashboardToleratesMissingUsageAndStatus() throws {
        // A dashboard from a host that predates usage/status projection and todo triage —
        // decodes with safe defaults rather than throwing (SYNC §9).
        struct LegacyTodo: Encodable {
            let id = TodoID(rawValue: "t1")
            let text = "ship it"
            let status = TodoStatus.open
            let updatedAt = Date(timeIntervalSince1970: 1)
        }
        struct LegacySnapshot: Encodable {
            let counts = DashboardCounts.empty
            let activity: [ActivityDay] = []
            let projects: [WireProject] = []
            let todos = [LegacyTodo()]
        }
        let data = try CBOREncoder().encode(LegacySnapshot())
        let decoded = try CBORDecoder().decode(DashboardSnapshot.self, from: data)
        #expect(decoded.usage == nil)
        #expect(decoded.statusFeeds.isEmpty)
        #expect(decoded.todos.first?.triage == nil)
    }

    @Test func capabilitiesTolerateMissingCanFetchDiff() throws {
        // A welcome from a host that predates on-demand diff fetch.
        struct LegacyCapabilities: Encodable {
            let canModifyToolInput = true
            let allowAlwaysScopes = [AlwaysScope.session]
        }
        let data = try CBOREncoder().encode(LegacyCapabilities())
        let decoded = try CBORDecoder().decode(WireCapabilities.self, from: data)
        #expect(decoded.canFetchDiff == false)
        #expect(decoded.canModifyToolInput == true)
    }

    @Test func sessionDiffRoundTrips() throws {
        let diff = WireSessionDiff(
            sessionID: SessionID(rawValue: "s1"),
            stat: DiffStat(filesChanged: 2, added: 10, removed: 3),
            files: [
                WireFileDiff(path: "a/b.swift", oldPath: nil, status: "modified", added: 8, removed: 3),
                WireFileDiff(path: "c/new name.swift", oldPath: "c/old.swift", status: "renamed", added: 2, removed: 0),
            ],
            patch: "diff --git a/a/b.swift b/a/b.swift\n+let x = 1\n-let x = 0\n",
            truncated: true)
        #expect(try roundTrip(HostMsg.sessionDiff(diff)) == .sessionDiff(diff))
        // An unknown future file status stays a plain string on the wire.
        guard case .sessionDiff(let back) = try roundTrip(HostMsg.sessionDiff(diff)) else {
            Issue.record("not a sessionDiff"); return
        }
        #expect(back.files.map(\.status) == ["modified", "renamed"])
        #expect(back.truncated == true)
    }

    @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 toolSummariesRoundTrip() throws {
        let batch = ToolSummaryBatch(
            sessionID: summary.sessionID,
            summaries: [
                ToolSummaryLine(toolCallID: "call-1", line: "Ran the test suite"),
                ToolSummaryLine(toolCallID: "call-2", line: "Built the `nucleic-local` app"),
            ])
        #expect(try roundTrip(HostMsg.toolSummaries(batch)) == .toolSummaries(batch))
    }

    /// A snapshot carries its seeded Bash summaries; and the empty case omits the key on the wire
    /// (so an owner that predates the field is byte-identical), decoding back to an empty set
    /// rather than failing — the mesh-session-sync back-compat guarantee.
    @Test func snapshotToolSummariesAreWireOptional() throws {
        let seeded = SessionSnapshot(
            summary: summary, recentEvents: [], pendingApprovals: [], cursor: 5,
            toolSummaries: [ToolSummaryLine(toolCallID: "b1", line: "Searched the codebase")])
        #expect(try roundTrip(seeded).toolSummaries == seeded.toolSummaries)

        // No summaries → the `toolSummaries` key is omitted entirely; the decoder's
        // `decodeIfPresent ?? []` is what turns a legacy owner's absent key back into [].
        let empty = SessionSnapshot(
            summary: summary, recentEvents: [], pendingApprovals: [], cursor: 5)
        #expect(try roundTrip(empty).toolSummaries.isEmpty)
    }

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