import Foundation

/// Client → Host messages (SYNC_PROTOCOL §5.1). The phone subscribes, approves, and — at
/// `control` scope — drives the same actions the Mac can (start chats, manage to-dos, manage
/// and integrate sessions).
public enum ClientMsg: Sendable, Equatable {
    case hello(Hello)
    case listSessions
    case listDashboard                        // request Home/Projects/To-Dos state
    case subscribe(Subscribe)
    case unsubscribe(SessionID)
    case approvalRespond(ApprovalID, Decision)
    case sendInput(SessionID, AgentInput)     // scope ≥ approve
    case interrupt(SessionID)                 // scope ≥ control
    case ping
    // Control scope:
    case startChat(StartChatRequest)
    case captureTodo(CaptureTodoRequest)
    case dispatchTodo(TodoID, ProjectID)
    case setTodoStatus(TodoID, TodoStatus)
    case deleteTodo(TodoID)
    case renameSession(SessionID, String)
    case setFavorite(SessionID, Bool)
    case setArchived(SessionID, Bool)
    case deleteSession(SessionID)
    case integrate(SessionID, IntegrationMode)
    case discard(SessionID)
    // Control scope: change a session's model / reasoning / automation mid-stream (the same
    // affordances the Mac header exposes). `nil` model/effort resets to the host/app default.
    case setSessionModel(SessionID, String?)
    case setSessionEffort(SessionID, String?)
    case setSessionAuto(SessionID, Bool)
    case setSessionAutoShip(SessionID, Bool)
    case setSessionShipBranch(SessionID, String?)
    /// Cancel one queued (not-yet-sent) follow-up by id — matches the Mac composer's per-message
    /// ✕. The `UUID` is the `QueuedMessage.id` from the session summary.
    case cancelQueuedMessage(SessionID, UUID)
    /// Ask for the session's full worktree diff (`HostMsg.sessionDiff`) — the Mac Diff tab's
    /// patch, on demand. Read-only (scope ≥ view). Send only when
    /// `WireCapabilities.canFetchDiff`; an older host treats it as an undecodable frame.
    case fetchDiff(SessionID)
    /// Ask the host for its mesh peer list (`HostMsg.peerList`) — the other devices it knows,
    /// for the mesh device list and the transfer destination picker (mesh P4). Read-only. Send
    /// only when `WireCapabilities.canListPeers`; an older host rejects the unknown tag.
    case listPeers
    /// Refresh the addresses the host has on file for *this* device (mesh P4) — a mac peer
    /// whose LAN endpoint or tailnet IP changed mid-connection. The host updates the sender's
    /// own record only. Send only when `WireCapabilities.canUpdateAddresses`; an older host
    /// rejects the unknown tag.
    case addressUpdate(PeerAddresses)
    /// Push this device's mesh roster (live members + revocation tombstones) to the host it's
    /// talking to (mesh "join"). The host merges it, so a device that scanned a *different* member
    /// still learns everyone. Send only when the host advertised `WireCapabilities.canSyncRoster`;
    /// an older host rejects the unknown tag.
    case meshRoster(MeshRosterPush)

    // Session transfer (mesh P5) — the source acts as a client of the destination. All gated
    // on `WireCapabilities.canReceiveSessionTransfer` + `.control` scope + a mac peer.
    /// Open a transfer with the manifest; the destination replies `transferAccept`/`transferReject`.
    case transferOffer(TransferOffer)
    /// One ordered slice of a transfer item; the destination replies `transferChunkAck`.
    case transferChunk(TransferChunk)
    /// Phase-2 commit (idempotent): the source has durably tombstoned; activate the staged session.
    case transferCommit(String)
    /// The source is abandoning the transfer (user cancelled / error); discard any staged data.
    case transferCancel(String)

    /// Ask a peer for a session's full transcript after a seq cursor (full-transcript mesh sync).
    /// The requester acts as a client of the holder; the holder answers with `transcriptChunk`
    /// batches then `transcriptFetchComplete`, or `transcriptUnavailable`. Read-only (scope ≥
    /// view). Send only when the holder advertised `WireCapabilities.canSyncTranscripts`; an
    /// older peer throws on the unknown tag.
    case fetchTranscript(TranscriptFetch)

    /// Register (or refresh) this device's Live Activity push token so the host can keep the
    /// lock-screen glance fresh over APNs while the phone is backgrounded and its socket is
    /// suspended (UX_IOS §5.3). Carries the per-activity update token (hex) and the activity's id.
    /// Read-only (scope ≥ view — it only enables the host to push the same aggregate the phone
    /// already shows). Send only when the host advertised `WireCapabilities.canPushLiveActivity`;
    /// an older host throws on the unknown tag.
    case registerLiveActivity(String, String)
    /// The phone's Live Activity ended (dismissed / all idle) — stop pushing to it. Carries the
    /// activity id. Same gating as `registerLiveActivity`.
    case endLiveActivity(String)
    /// Register (or refresh) this device's **push-to-start** token (iOS 17.2+) — the device-scoped
    /// token that lets the host *create* the Live Activity over APNs when a session starts while the
    /// app is closed/backgrounded, without the user first having to open it (UX_IOS §5.3). Distinct
    /// from `registerLiveActivity`'s per-activity update token: this one exists before any Activity
    /// does and survives app termination. Read-only (scope ≥ view) and same gating as
    /// `registerLiveActivity` — send only when the host advertised `WireCapabilities.canPushLiveActivity`.
    case registerPushToStartToken(String)
    /// Report whether this device's app is currently **foreground** (`true`) or backgrounded
    /// (`false`). The host uses it to pick the Live Activity path (UX_IOS §5.3): a foreground phone
    /// creates the glance itself over the socket, so the host defers; a backgrounded phone can't
    /// reliably start one — even while its socket lingers — so the host push-to-starts it instead.
    /// Sent on connect and on every foreground/background transition. Read-only (scope ≥ view). Send
    /// only when the host advertised `WireCapabilities.canReceiveForegroundState`; an older host
    /// throws on the unknown tag.
    case setForeground(Bool)

    /// Ask a connected Mac to open a pairing window and hand back its join code so the phone can
    /// display it — the "add a device to this mesh" button. The host answers with
    /// `HostMsg.pairingCode`. `.control` scope; send only when the host advertised
    /// `WireCapabilities.canMintPairingCode` (an older host throws on the unknown tag).
    case requestPairingCode
    /// Close the pairing window opened by `requestPairingCode` (the phone dismissed the sheet) so
    /// the one-time secret doesn't stay armed. Same gating as `requestPairingCode`.
    case cancelPairingCode
    /// Approve or deny a Mac trying to join, forwarded here as `HostMsg.macPairRequested` — so the
    /// phone that shared the join code can allow the join without walking to the Mac. `deviceID`
    /// identifies the pending request; first responder wins (the accepting Mac can still answer on
    /// its own screen). `.control` scope; send only when the host advertised
    /// `WireCapabilities.canMintPairingCode`.
    case respondMacPair(String, Bool)
}

/// Host → Client messages (SYNC_PROTOCOL §5.2). Forward-compatible: unknown tags decode to
/// `.unknown` rather than throwing, so a newer host can add cases without breaking older
/// clients (SYNC §9).
public enum HostMsg: Sendable, Equatable {
    case welcome(Welcome)
    case sessionList([SessionSummary])
    case snapshot(SessionSnapshot)
    case events(EventBatch)
    case approvalRequested(ApprovalRequest)
    case approvalResolved(ApprovalResolved)
    case sessionUpdated(SessionSummary)
    case dashboard(DashboardSnapshot)
    /// The full worktree diff a `fetchDiff` asked for.
    case sessionDiff(WireSessionDiff)
    /// The host's mesh peer list, answering `ClientMsg.listPeers` (mesh P4).
    case peerList([PeerSummary])
    /// The host's mesh roster (live members + revocation tombstones), pushed to a client that
    /// advertised `WireClientCapabilities.canSyncRoster` (mesh "join"). The client merges it —
    /// auto-pinning mac members it didn't know and dropping tombstoned ones — so joining via any
    /// member converges the whole group. Safe as a new tag (`.unknown` fallback), but the client
    /// must also have a `SyncClient.Event.meshRoster` case + `messageLoop` branch to act on it.
    case meshRoster(MeshRosterPush)

    // Session transfer replies (mesh P5), dest→source. Safe as new tags (`.unknown` fallback),
    // but each ALSO needs a `SyncClient.Event` case + `messageLoop` branch, or the source —
    // which receives these as a client — silently drops them.
    case transferAccept(TransferAccept)
    case transferReject(TransferReject)
    /// The destination staged the session non-runnable and verified every item hash.
    case transferReady(String)
    /// The destination activated the staged session (answers `transferCommit`, idempotent).
    case transferCommitted(String)
    /// Flow-control ack for one persisted chunk.
    case transferChunkAck(TransferChunkAck)

    // Full-transcript mesh sync replies (holder→requester). Safe as new tags (`.unknown`
    // fallback), but each ALSO needs a `SyncClient.Event` case + `messageLoop` branch, or a
    // requester — which receives these as a client — silently drops them.
    /// One ordered batch of the requested transcript events.
    case transcriptChunk(TranscriptChunk)
    /// Terminal success: every batch was sent (carries the tip seq and, on a full fetch, header).
    case transcriptFetchComplete(TranscriptFetchComplete)
    /// Terminal failure: this holder can't serve the fetch (e.g. `notHeld` while probing peers).
    case transcriptUnavailable(TranscriptUnavailable)

    /// The current collapsed Bash summary lines for a subscribed session (mesh session sync),
    /// pushed to peers viewing it as the owner (re)computes them. Safe as a new tag (`.unknown`
    /// fallback), but a client that acts on it ALSO needs a `SyncClient.Event.toolSummaries`
    /// case + `messageLoop` branch, or it silently drops them.
    case toolSummaries(ToolSummaryBatch)

    /// This device's relay credential (mesh P2), pushed after every hello when the host has
    /// the Nucleic Private Relay method enabled. Devices persist it and use the relay as a
    /// dial fallback; devices paired before the relay adopt it from this push alone.
    case relayMembership(WireRelayMembership)

    /// A pairing/join code a client asked this host to mint (`ClientMsg.requestPairingCode`) — the
    /// `nucleic://pair?d=…` string the phone renders as a QR and offers to copy. `nil` when the
    /// host has meshing on but couldn't mint one right now (e.g. the sync server isn't running).
    /// Safe as a new tag (`.unknown` fallback), but a client that acts on it ALSO needs a
    /// `SyncClient.Event.pairingCode` case + `messageLoop` branch.
    case pairingCode(String?)

    /// A Mac is trying to join the group and needs the user's allow/deny (mesh P4) — forwarded to
    /// control-scope clients so the phone that shared the join code can approve it, in parallel
    /// with the accepting Mac's own dialog (first responder wins). Safe as a new tag (`.unknown`
    /// fallback), but a client that acts on it ALSO needs a `SyncClient.Event.macPairRequested`
    /// case + `messageLoop` branch.
    case macPairRequested(WireMacPairRequest)
    /// The pending Mac-pair (identified by `deviceID`) was answered or withdrawn — `approved`
    /// carries the outcome. Clients dismiss the forwarded prompt by id. Same forward-compat notes
    /// as `macPairRequested`.
    case macPairResolved(String, Bool)

    case error(WireError)
    case pong
    /// An unrecognized future message tag (forward-compat); the client ignores it.
    case unknown(String)
}

// MARK: - Codable (tagged: {"t": <tag>, ...payload})

private enum EnvelopeKey: String, CodingKey {
    case t, sessionID, approvalID, decision, input, tag
    case request, todoID, projectID, todoStatus, title, flag, mode
    case model, effort, branch, messageID, addresses
    // Session transfer (mesh P5).
    case transferID, offer, chunk, accept, reject, ack
    // Full-transcript mesh sync.
    case fetch, transcriptChunk, transcriptComplete, transcriptUnavailable
    // Collapsed tool-call summaries (mesh session sync).
    case toolSummaries
    // Relay membership push (mesh P2).
    case membership
    // Mesh roster gossip (mesh "join").
    case roster
    // Live Activity push registration (UX_IOS §5.3).
    case pushToken, activityID, pushToStartToken
    // Remote pairing-code mint ("add a device to this mesh").
    case qr
    // Forwarded Mac-pair approval (approve a Mac join from the phone).
    case macPairRequest, deviceID, approved
}

extension ClientMsg: Codable {
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: EnvelopeKey.self)
        switch try c.decode(String.self, forKey: .t) {
        case "hello": self = .hello(try Hello(from: decoder))
        case "listSessions": self = .listSessions
        case "subscribe": self = .subscribe(try Subscribe(from: decoder))
        case "unsubscribe": self = .unsubscribe(try c.decode(SessionID.self, forKey: .sessionID))
        case "approvalRespond":
            self = .approvalRespond(
                try c.decode(ApprovalID.self, forKey: .approvalID),
                try c.decode(Decision.self, forKey: .decision))
        case "sendInput":
            self = .sendInput(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(AgentInput.self, forKey: .input))
        case "interrupt": self = .interrupt(try c.decode(SessionID.self, forKey: .sessionID))
        case "ping": self = .ping
        case "listDashboard": self = .listDashboard
        case "startChat": self = .startChat(try c.decode(StartChatRequest.self, forKey: .request))
        case "captureTodo": self = .captureTodo(try c.decode(CaptureTodoRequest.self, forKey: .request))
        case "dispatchTodo":
            self = .dispatchTodo(
                try c.decode(TodoID.self, forKey: .todoID),
                try c.decode(ProjectID.self, forKey: .projectID))
        case "setTodoStatus":
            self = .setTodoStatus(
                try c.decode(TodoID.self, forKey: .todoID),
                try c.decode(TodoStatus.self, forKey: .todoStatus))
        case "deleteTodo": self = .deleteTodo(try c.decode(TodoID.self, forKey: .todoID))
        case "renameSession":
            self = .renameSession(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(String.self, forKey: .title))
        case "setFavorite":
            self = .setFavorite(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(Bool.self, forKey: .flag))
        case "setArchived":
            self = .setArchived(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(Bool.self, forKey: .flag))
        case "deleteSession": self = .deleteSession(try c.decode(SessionID.self, forKey: .sessionID))
        case "integrate":
            self = .integrate(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(IntegrationMode.self, forKey: .mode))
        case "discard": self = .discard(try c.decode(SessionID.self, forKey: .sessionID))
        case "setSessionModel":
            self = .setSessionModel(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decodeIfPresent(String.self, forKey: .model))
        case "setSessionEffort":
            self = .setSessionEffort(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decodeIfPresent(String.self, forKey: .effort))
        case "setSessionAuto":
            self = .setSessionAuto(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(Bool.self, forKey: .flag))
        case "setSessionAutoShip":
            self = .setSessionAutoShip(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(Bool.self, forKey: .flag))
        case "setSessionShipBranch":
            self = .setSessionShipBranch(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decodeIfPresent(String.self, forKey: .branch))
        case "cancelQueuedMessage":
            self = .cancelQueuedMessage(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(UUID.self, forKey: .messageID))
        case "fetchDiff":
            self = .fetchDiff(try c.decode(SessionID.self, forKey: .sessionID))
        case "listPeers": self = .listPeers
        case "addressUpdate":
            self = .addressUpdate(try c.decode(PeerAddresses.self, forKey: .addresses))
        case "meshRoster":
            self = .meshRoster(try c.decode(MeshRosterPush.self, forKey: .roster))
        case "transferOffer":
            self = .transferOffer(try c.decode(TransferOffer.self, forKey: .offer))
        case "transferChunk":
            self = .transferChunk(try c.decode(TransferChunk.self, forKey: .chunk))
        case "transferCommit":
            self = .transferCommit(try c.decode(String.self, forKey: .transferID))
        case "transferCancel":
            self = .transferCancel(try c.decode(String.self, forKey: .transferID))
        case "fetchTranscript":
            self = .fetchTranscript(try c.decode(TranscriptFetch.self, forKey: .fetch))
        case "registerLiveActivity":
            self = .registerLiveActivity(
                try c.decode(String.self, forKey: .pushToken),
                try c.decode(String.self, forKey: .activityID))
        case "endLiveActivity":
            self = .endLiveActivity(try c.decode(String.self, forKey: .activityID))
        case "registerPushToStartToken":
            self = .registerPushToStartToken(try c.decode(String.self, forKey: .pushToStartToken))
        case "setForeground": self = .setForeground(try c.decode(Bool.self, forKey: .flag))
        case "requestPairingCode": self = .requestPairingCode
        case "cancelPairingCode": self = .cancelPairingCode
        case "respondMacPair":
            self = .respondMacPair(
                try c.decode(String.self, forKey: .deviceID),
                try c.decode(Bool.self, forKey: .approved))
        case let other:
            throw DecodingError.dataCorruptedError(
                forKey: .t, in: c, debugDescription: "Unknown ClientMsg \(other)")
        }
    }

    public func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: EnvelopeKey.self)
        switch self {
        case .hello(let h):
            try c.encode("hello", forKey: .t); try h.encode(to: encoder)
        case .listSessions:
            try c.encode("listSessions", forKey: .t)
        case .subscribe(let s):
            try c.encode("subscribe", forKey: .t); try s.encode(to: encoder)
        case .unsubscribe(let id):
            try c.encode("unsubscribe", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .approvalRespond(let id, let decision):
            try c.encode("approvalRespond", forKey: .t)
            try c.encode(id, forKey: .approvalID)
            try c.encode(decision, forKey: .decision)
        case .sendInput(let id, let input):
            try c.encode("sendInput", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(input, forKey: .input)
        case .interrupt(let id):
            try c.encode("interrupt", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .ping:
            try c.encode("ping", forKey: .t)
        case .listDashboard:
            try c.encode("listDashboard", forKey: .t)
        case .startChat(let r):
            try c.encode("startChat", forKey: .t); try c.encode(r, forKey: .request)
        case .captureTodo(let r):
            try c.encode("captureTodo", forKey: .t); try c.encode(r, forKey: .request)
        case .dispatchTodo(let todo, let project):
            try c.encode("dispatchTodo", forKey: .t)
            try c.encode(todo, forKey: .todoID)
            try c.encode(project, forKey: .projectID)
        case .setTodoStatus(let todo, let status):
            try c.encode("setTodoStatus", forKey: .t)
            try c.encode(todo, forKey: .todoID)
            try c.encode(status, forKey: .todoStatus)
        case .deleteTodo(let todo):
            try c.encode("deleteTodo", forKey: .t); try c.encode(todo, forKey: .todoID)
        case .renameSession(let id, let title):
            try c.encode("renameSession", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(title, forKey: .title)
        case .setFavorite(let id, let flag):
            try c.encode("setFavorite", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(flag, forKey: .flag)
        case .setArchived(let id, let flag):
            try c.encode("setArchived", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(flag, forKey: .flag)
        case .deleteSession(let id):
            try c.encode("deleteSession", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .integrate(let id, let mode):
            try c.encode("integrate", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(mode, forKey: .mode)
        case .discard(let id):
            try c.encode("discard", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .setSessionModel(let id, let model):
            try c.encode("setSessionModel", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encodeIfPresent(model, forKey: .model)
        case .setSessionEffort(let id, let effort):
            try c.encode("setSessionEffort", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encodeIfPresent(effort, forKey: .effort)
        case .setSessionAuto(let id, let flag):
            try c.encode("setSessionAuto", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(flag, forKey: .flag)
        case .setSessionAutoShip(let id, let flag):
            try c.encode("setSessionAutoShip", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(flag, forKey: .flag)
        case .setSessionShipBranch(let id, let branch):
            try c.encode("setSessionShipBranch", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encodeIfPresent(branch, forKey: .branch)
        case .cancelQueuedMessage(let id, let messageID):
            try c.encode("cancelQueuedMessage", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(messageID, forKey: .messageID)
        case .fetchDiff(let id):
            try c.encode("fetchDiff", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .listPeers:
            try c.encode("listPeers", forKey: .t)
        case .addressUpdate(let addresses):
            try c.encode("addressUpdate", forKey: .t)
            try c.encode(addresses, forKey: .addresses)
        case .meshRoster(let roster):
            try c.encode("meshRoster", forKey: .t)
            try c.encode(roster, forKey: .roster)
        case .transferOffer(let offer):
            try c.encode("transferOffer", forKey: .t); try c.encode(offer, forKey: .offer)
        case .transferChunk(let chunk):
            try c.encode("transferChunk", forKey: .t); try c.encode(chunk, forKey: .chunk)
        case .transferCommit(let id):
            try c.encode("transferCommit", forKey: .t); try c.encode(id, forKey: .transferID)
        case .transferCancel(let id):
            try c.encode("transferCancel", forKey: .t); try c.encode(id, forKey: .transferID)
        case .fetchTranscript(let f):
            try c.encode("fetchTranscript", forKey: .t); try c.encode(f, forKey: .fetch)
        case .registerLiveActivity(let token, let activityID):
            try c.encode("registerLiveActivity", forKey: .t)
            try c.encode(token, forKey: .pushToken)
            try c.encode(activityID, forKey: .activityID)
        case .endLiveActivity(let activityID):
            try c.encode("endLiveActivity", forKey: .t)
            try c.encode(activityID, forKey: .activityID)
        case .registerPushToStartToken(let token):
            try c.encode("registerPushToStartToken", forKey: .t)
            try c.encode(token, forKey: .pushToStartToken)
        case .setForeground(let foreground):
            try c.encode("setForeground", forKey: .t)
            try c.encode(foreground, forKey: .flag)
        case .requestPairingCode:
            try c.encode("requestPairingCode", forKey: .t)
        case .cancelPairingCode:
            try c.encode("cancelPairingCode", forKey: .t)
        case .respondMacPair(let deviceID, let approve):
            try c.encode("respondMacPair", forKey: .t)
            try c.encode(deviceID, forKey: .deviceID)
            try c.encode(approve, forKey: .approved)
        }
    }
}

extension HostMsg: Codable {
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: EnvelopeKey.self)
        let tag = try c.decode(String.self, forKey: .t)
        switch tag {
        case "welcome": self = .welcome(try Welcome(from: decoder))
        case "sessionList": self = .sessionList(try c.decode([SessionSummary].self, forKey: .input))
        case "snapshot": self = .snapshot(try SessionSnapshot(from: decoder))
        case "events": self = .events(try EventBatch(from: decoder))
        case "approvalRequested": self = .approvalRequested(try ApprovalRequest(from: decoder))
        case "approvalResolved": self = .approvalResolved(try ApprovalResolved(from: decoder))
        case "sessionUpdated": self = .sessionUpdated(try SessionSummary(from: decoder))
        case "dashboard": self = .dashboard(try DashboardSnapshot(from: decoder))
        case "sessionDiff": self = .sessionDiff(try WireSessionDiff(from: decoder))
        case "peerList": self = .peerList(try c.decode([PeerSummary].self, forKey: .input))
        case "meshRoster": self = .meshRoster(try c.decode(MeshRosterPush.self, forKey: .roster))
        case "transferAccept": self = .transferAccept(try c.decode(TransferAccept.self, forKey: .accept))
        case "transferReject": self = .transferReject(try c.decode(TransferReject.self, forKey: .reject))
        case "transferReady": self = .transferReady(try c.decode(String.self, forKey: .transferID))
        case "transferCommitted": self = .transferCommitted(try c.decode(String.self, forKey: .transferID))
        case "transferChunkAck": self = .transferChunkAck(try c.decode(TransferChunkAck.self, forKey: .ack))
        case "transcriptChunk":
            self = .transcriptChunk(try c.decode(TranscriptChunk.self, forKey: .transcriptChunk))
        case "transcriptComplete":
            self = .transcriptFetchComplete(
                try c.decode(TranscriptFetchComplete.self, forKey: .transcriptComplete))
        case "transcriptUnavailable":
            self = .transcriptUnavailable(
                try c.decode(TranscriptUnavailable.self, forKey: .transcriptUnavailable))
        case "toolSummaries":
            self = .toolSummaries(try c.decode(ToolSummaryBatch.self, forKey: .toolSummaries))
        case "relayMembership":
            self = .relayMembership(try c.decode(WireRelayMembership.self, forKey: .membership))
        case "pairingCode":
            self = .pairingCode(try c.decodeIfPresent(String.self, forKey: .qr))
        case "macPairRequested":
            self = .macPairRequested(try c.decode(WireMacPairRequest.self, forKey: .macPairRequest))
        case "macPairResolved":
            self = .macPairResolved(
                try c.decode(String.self, forKey: .deviceID),
                try c.decode(Bool.self, forKey: .approved))
        case "error": self = .error(try WireError(from: decoder))
        case "pong": self = .pong
        default: self = .unknown(tag)  // forward-compatible
        }
    }

    public func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: EnvelopeKey.self)
        switch self {
        case .welcome(let w):
            try c.encode("welcome", forKey: .t); try w.encode(to: encoder)
        case .sessionList(let list):
            try c.encode("sessionList", forKey: .t); try c.encode(list, forKey: .input)
        case .snapshot(let s):
            try c.encode("snapshot", forKey: .t); try s.encode(to: encoder)
        case .events(let b):
            try c.encode("events", forKey: .t); try b.encode(to: encoder)
        case .approvalRequested(let r):
            try c.encode("approvalRequested", forKey: .t); try r.encode(to: encoder)
        case .approvalResolved(let r):
            try c.encode("approvalResolved", forKey: .t); try r.encode(to: encoder)
        case .sessionUpdated(let s):
            try c.encode("sessionUpdated", forKey: .t); try s.encode(to: encoder)
        case .dashboard(let d):
            try c.encode("dashboard", forKey: .t); try d.encode(to: encoder)
        case .sessionDiff(let d):
            try c.encode("sessionDiff", forKey: .t); try d.encode(to: encoder)
        case .peerList(let peers):
            try c.encode("peerList", forKey: .t); try c.encode(peers, forKey: .input)
        case .meshRoster(let roster):
            try c.encode("meshRoster", forKey: .t); try c.encode(roster, forKey: .roster)
        case .transferAccept(let a):
            try c.encode("transferAccept", forKey: .t); try c.encode(a, forKey: .accept)
        case .transferReject(let r):
            try c.encode("transferReject", forKey: .t); try c.encode(r, forKey: .reject)
        case .transferReady(let id):
            try c.encode("transferReady", forKey: .t); try c.encode(id, forKey: .transferID)
        case .transferCommitted(let id):
            try c.encode("transferCommitted", forKey: .t); try c.encode(id, forKey: .transferID)
        case .transferChunkAck(let ack):
            try c.encode("transferChunkAck", forKey: .t); try c.encode(ack, forKey: .ack)
        case .transcriptChunk(let chunk):
            try c.encode("transcriptChunk", forKey: .t)
            try c.encode(chunk, forKey: .transcriptChunk)
        case .transcriptFetchComplete(let done):
            try c.encode("transcriptComplete", forKey: .t)
            try c.encode(done, forKey: .transcriptComplete)
        case .transcriptUnavailable(let un):
            try c.encode("transcriptUnavailable", forKey: .t)
            try c.encode(un, forKey: .transcriptUnavailable)
        case .toolSummaries(let batch):
            try c.encode("toolSummaries", forKey: .t)
            try c.encode(batch, forKey: .toolSummaries)
        case .relayMembership(let m):
            try c.encode("relayMembership", forKey: .t); try c.encode(m, forKey: .membership)
        case .pairingCode(let qr):
            try c.encode("pairingCode", forKey: .t); try c.encodeIfPresent(qr, forKey: .qr)
        case .macPairRequested(let req):
            try c.encode("macPairRequested", forKey: .t); try c.encode(req, forKey: .macPairRequest)
        case .macPairResolved(let deviceID, let approved):
            try c.encode("macPairResolved", forKey: .t)
            try c.encode(deviceID, forKey: .deviceID)
            try c.encode(approved, forKey: .approved)
        case .error(let e):
            try c.encode("error", forKey: .t); try e.encode(to: encoder)
        case .pong:
            try c.encode("pong", forKey: .t)
        case .unknown(let tag):
            try c.encode(tag, forKey: .t)
        }
    }
}
