import Foundation

/// The Mac ↔ iPhone application message set (SYNC_PROTOCOL §5). A projection of the
/// backend `AgentEvent` stream + approval round-trip: the host is the single authority,
/// the phone subscribes and answers. Identifiers (`SessionID`, `ApprovalID`, `Decision`,
/// `AgentEvent`) are the exact backend types — no parallel model.
///
/// Transport-agnostic: identical over LAN (v1) and the future Cloudflare relay; only the
/// bytes-moving layer swaps. Encoded as CBOR inside an encrypted `Frame`.

public enum SyncProtocol {
    /// Bumped on any breaking change to the message set; `hello`/`welcome` exchange it.
    public static let version = 1
}

/// What a paired device may do (SYNC §1.5). v1 iPhone is granted `approve`. Ordered so the
/// host can check "scope ≥ approve".
public enum DeviceScope: String, Sendable, Codable, Comparable {
    case view      // read sessions + transcripts + diffs
    case approve   // + answer approvals, send follow-up input
    case control   // + start/merge/discard/interrupt (later)

    private var rank: Int {
        switch self {
        case .view: 0
        case .approve: 1
        case .control: 2
        }
    }
    public static func < (lhs: DeviceScope, rhs: DeviceScope) -> Bool { lhs.rank < rhs.rank }
}

/// Per-subscriber firehose level (SYNC §5.5). The host coalesces before encryption, per
/// client, so each gets what it asked for without affecting others or the transcript.
public enum Verbosity: String, Sendable, Codable {
    case statusOnly   // only sessionUpdated / approvalRequested / runFinished
    case coalesced    // partial deltas buffered + flushed on block boundary (default on cell/relay)
    case full         // every AgentEvent including partial deltas (good on LAN)
}

// MARK: - Handshake payloads

public struct Hello: Sendable, Codable, Equatable {
    public let protocolVersion: Int
    public let deviceID: String
    public let deviceLabel: String
    public let scopeClaim: DeviceScope
    /// APNs token for background wake (SYNC §6); nil until push ships (M5).
    public let pushToken: String?
    /// The client's APNs topic — its own bundle id (`Bundle.main.bundleIdentifier`). The relay
    /// needs this to address pushes: per-channel builds have suffixed bundle ids (…`.beta` /
    /// …`.canary`), and APNs rejects any push whose `apns-topic` isn't the target app's bundle id
    /// with `BadTopic`. The host forwards it to the relay so wake + Live Activity pushes carry the
    /// right topic for *this* phone's build (not a single hardcoded one). Optional on the wire — a
    /// remote that predates it decodes as `nil` and the relay falls back to its configured topic.
    public let pushTopic: String?
    /// The build channel this remote was cut from. The host rejects a `hello` whose channel
    /// is incompatible with its own (`ReleaseChannel.isCompatible`). Optional on the wire so a
    /// host talking to a remote that predates the field decodes it as `nil` and skips the gate.
    public let channel: ReleaseChannel?
    /// What kind of device this is (mesh P4): `SyncTransportHint`-style raw string ("mac" /
    /// "iphone" / "cloud"). Optional on the wire — absent means a pre-mesh iPhone.
    public let deviceKind: String?
    /// What the connecting client advertises about itself (mesh feature level + host/agent
    /// capabilities). Optional; absent means pre-mesh (mesh level 0).
    public let clientCaps: WireClientCapabilities?
    /// Where this client can itself be dialed (mesh P4) — meaningful for a mac/cloud peer
    /// that also hosts. The host persists these so a session transfer can dial back later.
    /// Optional; a phone (or a pre-mesh client) omits it.
    public let addresses: PeerAddresses?

    public init(
        protocolVersion: Int = SyncProtocol.version,
        deviceID: String, deviceLabel: String,
        scopeClaim: DeviceScope = .approve, pushToken: String? = nil,
        pushTopic: String? = nil,
        channel: ReleaseChannel? = nil,
        deviceKind: String? = nil, clientCaps: WireClientCapabilities? = nil,
        addresses: PeerAddresses? = nil
    ) {
        self.protocolVersion = protocolVersion
        self.deviceID = deviceID
        self.deviceLabel = deviceLabel
        self.scopeClaim = scopeClaim
        self.pushToken = pushToken
        self.pushTopic = pushTopic
        self.channel = channel
        self.deviceKind = deviceKind
        self.clientCaps = clientCaps
        self.addresses = addresses
    }

    private enum CodingKeys: String, CodingKey {
        case protocolVersion, deviceID, deviceLabel, scopeClaim, pushToken, pushTopic, channel
        case deviceKind, clientCaps, addresses
    }

    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.protocolVersion = try c.decode(Int.self, forKey: .protocolVersion)
        self.deviceID = try c.decode(String.self, forKey: .deviceID)
        self.deviceLabel = try c.decode(String.self, forKey: .deviceLabel)
        self.scopeClaim = try c.decode(DeviceScope.self, forKey: .scopeClaim)
        self.pushToken = try c.decodeIfPresent(String.self, forKey: .pushToken)
        self.pushTopic = try c.decodeIfPresent(String.self, forKey: .pushTopic)
        // Tolerate a remote that predates the channel gate.
        self.channel = try c.decodeIfPresent(ReleaseChannel.self, forKey: .channel)
        // Mesh P4 — absent on a pre-mesh iPhone.
        self.deviceKind = try c.decodeIfPresent(String.self, forKey: .deviceKind)
        self.clientCaps = try c.decodeIfPresent(WireClientCapabilities.self, forKey: .clientCaps)
        self.addresses = try c.decodeIfPresent(PeerAddresses.self, forKey: .addresses)
    }

    /// The device kind, defaulting to iPhone for pre-mesh clients.
    public var kind: PeerKind { deviceKind.map(PeerKind.init(rawValue:)) ?? .iphone }
}

public struct HostInfo: Sendable, Codable, Equatable {
    public let hostID: String
    public let hostName: String

    public init(hostID: String, hostName: String) {
        self.hostID = hostID
        self.hostName = hostName
    }
}

/// The capability bits the phone UI reads to mirror the Mac's affordances (e.g. show
/// "modify-and-allow" only when the backend supports it — UX_IOS §5.2).
public struct WireCapabilities: Sendable, Codable, Equatable {
    public let canModifyToolInput: Bool
    public let allowAlwaysScopes: [AlwaysScope]
    /// Whether the host answers `ClientMsg.fetchDiff` with a full patch. The phone only
    /// offers the full-diff view when set; an older host that omits the key decodes as
    /// `false` and the phone keeps its summary fallback.
    public let canFetchDiff: Bool
    /// Whether the host answers `ClientMsg.listPeers` with its mesh peer list (mesh P4). A
    /// client must not send `listPeers` unless this is set — an older host would reject the
    /// unknown tag. Omitted ⇒ `false`.
    public let canListPeers: Bool
    /// Whether the host accepts `ClientMsg.addressUpdate` (mesh P4) — a client refreshing the
    /// addresses the host has on file for it mid-connection. Same gating contract as
    /// `canListPeers`. Omitted ⇒ `false`.
    public let canUpdateAddresses: Bool
    /// Whether the host accepts an inbound session transfer (mesh P5) — the `transferOffer`/
    /// `transferChunk`/`transferCommit` verbs. A source Mac must not send them unless this is
    /// set; an older host would reject the unknown tag. Omitted ⇒ `false`.
    public let canReceiveSessionTransfer: Bool
    /// Whether the host speaks mesh roster gossip (mesh "join") — it answers with and accepts
    /// `meshRoster` pushes so scanning any one member joins the scanner to the whole group. A
    /// client must not send `ClientMsg.meshRoster` unless this is set (same gating contract as
    /// `canListPeers`); an older host falls back to the display-only `listPeers` path. Omitted ⇒
    /// `false`.
    public let canSyncRoster: Bool
    /// Whether this peer answers `ClientMsg.fetchTranscript` with a session's full transcript —
    /// from a live controller OR an on-disk transcript it holds (owned or mirrored). A requester
    /// must not send `fetchTranscript` unless this is set; an older peer throws on the unknown
    /// tag (same gating contract as `canReceiveSessionTransfer`). Omitted ⇒ `false`.
    public let canSyncTranscripts: Bool
    /// Whether the host pushes Live Activity updates over APNs to keep the phone's lock-screen
    /// glance fresh while it's backgrounded (UX_IOS §5.3). The phone must not send
    /// `ClientMsg.registerLiveActivity` unless this is set; an older host throws on the unknown
    /// tag (same gating contract as `canSyncRoster`). Omitted ⇒ `false`.
    public let canPushLiveActivity: Bool
    /// Whether the host can **push-to-start** a Live Activity (iOS 17.2+) — create the lock-screen
    /// glance over APNs when work starts while the app is closed, so it appears without the user
    /// opening the app first (UX_IOS §5.3). A *separate* bit from `canPushLiveActivity` on purpose:
    /// a host that predates push-to-start can advertise `canPushLiveActivity` but would throw on the
    /// unknown `registerPushToStartToken` tag, so the phone must gate that message on *this* bit, not
    /// on the older one. Omitted ⇒ `false`.
    public let canPushToStartLiveActivity: Bool
    /// Whether the host will mint a pairing code on demand for a client to relay — the phone's
    /// "add a device to this mesh" button asks a connected Mac to open a pairing window and
    /// returns its QR/join code (`ClientMsg.requestPairingCode` → `HostMsg.pairingCode`). A
    /// client must not send `requestPairingCode`/`cancelPairingCode` unless this is set; an older
    /// host throws on the unknown tag (same gating contract as `canPushLiveActivity`). Omitted ⇒
    /// `false`.
    public let canMintPairingCode: Bool
    /// Whether the host understands `ClientMsg.setForeground` — the phone reporting whether its app is
    /// foreground (creating its own Live Activity over the socket) or backgrounded (unable to, so the
    /// host should push-to-start the glance instead — UX_IOS §5.3). A *separate* bit from
    /// `canPushToStartLiveActivity` on purpose: an already-deployed host advertises push-to-start yet
    /// predates this tag and would throw on it, so the phone must gate `setForeground` on *this* bit,
    /// not the older one. Omitted ⇒ `false`.
    public let canReceiveForegroundState: Bool
    /// Whether this host delegates intelligence work (docs/COVALENCE_RUNNER.md §5) — it may push
    /// `HostMsg.intelligenceRequest` to clients advertising
    /// `WireClientCapabilities.canProvideIntelligence`, and it understands the
    /// `ClientMsg.intelligenceResult` answer. A client must not send `intelligenceResult` unless
    /// this is set — an older host throws on the unknown tag. Omitted ⇒ `false`.
    public let canDelegateIntelligence: Bool
    /// Whether this host accepts the credential-mesh verbs (docs/COVALENCE_RUNNER.md §6) —
    /// `ClientMsg.credentialManifest` (descriptors + refresh leases, no secrets) and
    /// `ClientMsg.credentialProvision` (records sealed to the host's sealing key, answering
    /// `HostMsg.credentialNeeded`). A client must not send either unless this is set; an older
    /// host throws on the unknown tag. Omitted ⇒ `false`.
    public let canReceiveSealedCredentials: Bool
    /// Whether this host accepts `ClientMsg.createProject` — cloning a git URL and registering
    /// it as a project (CLOUD_RUNTIME §4.3), the verb that seeds work onto a fresh runner. A
    /// client must not send it unless this is set; an older host throws on the unknown tag
    /// (same gating contract as `canReceiveSealedCredentials`). Omitted ⇒ `false`.
    public let canCreateProjects: Bool
    /// Whether this host serves mesh casts (`CastMessages.swift`) — it accepts
    /// `ClientMsg.castSubscribe` and pushes `HostMsg.casts`/`castCatchUp` to subscribers. A
    /// client must not send `castSubscribe` unless this is set; an older host throws on the
    /// unknown tag (same gating contract as `canSyncTranscripts`). Omitted ⇒ `false`.
    public let canCast: Bool
    /// Whether this host relays live composer typing — it accepts `ClientMsg.composerTyping`
    /// and republishes it on the `composer.typing` cast channel (mesh composer streaming). A
    /// client must not send `composerTyping` unless this is set; an older host throws on the
    /// unknown tag (same gating contract as `canCast`). Omitted ⇒ `false`.
    public let canStreamComposer: Bool
    /// Whether this host acknowledges a `startChat` that carries a `requestID` with
    /// `HostMsg.chatStarted` (idempotent per id) — the mesh-dispatch contract. A dispatcher only
    /// treats hosts advertising this as Mesh candidates; an older host stays manually
    /// targetable but is never auto-routed to. Omitted ⇒ `false`.
    public let canAcknowledgeDispatch: Bool
    /// Whether this host syncs account-level settings (`SyncedSettings`) — it stamps the current
    /// value into `Welcome.settings` and understands `ClientMsg.updateSettings`, broadcasting the
    /// result as `HostMsg.settings`. A client must not send `updateSettings` unless this is set;
    /// an older host throws on the unknown tag (same gating contract as `canAcknowledgeDispatch`).
    /// Omitted ⇒ `false`.
    public let canSyncSettings: Bool
    /// Whether this host upgrades relay sessions to a hole-punched direct path (Covalence
    /// direct, SYNC_PROTOCOL §3.3) — it accepts `ClientMsg.directOffer`/`directSelect` and
    /// answers `HostMsg.directAnswer`/`directGo`/`directDecline`. A client must not send either
    /// verb unless this is set; an older host throws on the unknown tag (same gating contract as
    /// `canSyncSettings`). Omitted ⇒ `false`.
    public let canDirectConnect: Bool
    /// Whether this host brokers remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md) — it accepts
    /// `ClientMsg.agentLoginBegin`/`agentLoginCallback`/`agentLoginCancel` and answers with
    /// `HostMsg.agentLoginChallenge`/`agentLoginResult` (+ the `agentAuthStatus` push). A client
    /// must not send any of the three unless this is set; an older host throws on the unknown
    /// tag (same gating contract as `canDirectConnect`). *Which providers* it can broker rides
    /// per-provider in `WireProviderAuthStatus.canBrokerLogin`, not here. Omitted ⇒ `false`.
    public let canBrokerAgentLogin: Bool
    /// Whether this host accepts `ClientMsg.credentialRevoke` — a device deleting a credential
    /// kind mesh-wide (the tombstone counterpart of `credentialProvision`). The host clears its
    /// local copy, records the tombstone, and fans it out (`HostMsg.credentialRevoked` + peer
    /// re-gossip). A client must not send it unless this is set; an older host throws on the
    /// unknown tag (same gating contract as `canBrokerAgentLogin`). Omitted ⇒ `false`.
    public let canRevokeCredentials: Bool
    /// Whether this host accepts `ClientMsg.resolveProcessStall` — a remote surface resolving a
    /// live "host command looks hung" alert (Kill tears down the process tree, Keep-waiting
    /// dismisses it), the wire counterpart of the Mac's in-transcript Kill/Keep-waiting buttons. A
    /// client must not send it unless this is set; an older host throws on the unknown tag (same
    /// gating contract as `canRevokeCredentials`). Omitted ⇒ `false`.
    public let canResolveProcessStall: Bool
    /// Whether this host honors `ClientMsg.requestSessionTransfer` — moving one of *its* sessions
    /// to a third device on request, so "Transfer…" works from a Mac that doesn't own the chat
    /// (including "bring it here"). A client must not send it unless this is set; an older host
    /// throws on the unknown tag (same gating contract as `canResolveProcessStall`). Omitted ⇒
    /// `false`.
    public let canTransferOnRequest: Bool
    /// Whether this peer serves Carbon shard replication (docs/CARBON_SHARDING.md §8.1) — it
    /// answers `fetchCarbonHeads`/`fetchCarbonManifests`/`fetchCarbonShards` (+ the ack-window
    /// verbs). A mirror must not send any of the four unless this is set; an older peer throws
    /// on the unknown tag (same gating contract as `canTransferOnRequest`). Omitted ⇒ `false`.
    public let canServeCarbon: Bool
    /// Whether this host accepts `ClientMsg.markSessionDone` — a remote surface hand-marking a chat
    /// "Done" (its last turn's disposition flips to `.completed`), the wire counterpart of the Mac
    /// sidebar's "Mark Done". A client must not send it unless this is set; an older host throws on
    /// the unknown tag (same gating contract as `canServeCarbon`). Omitted ⇒ `false`.
    public let canMarkSessionDone: Bool

    public init(
        canModifyToolInput: Bool, allowAlwaysScopes: [AlwaysScope],
        canFetchDiff: Bool = false, canListPeers: Bool = false,
        canUpdateAddresses: Bool = false, canReceiveSessionTransfer: Bool = false,
        canSyncRoster: Bool = false, canSyncTranscripts: Bool = false,
        canPushLiveActivity: Bool = false, canPushToStartLiveActivity: Bool = false,
        canMintPairingCode: Bool = false, canReceiveForegroundState: Bool = false,
        canDelegateIntelligence: Bool = false, canReceiveSealedCredentials: Bool = false,
        canCreateProjects: Bool = false, canCast: Bool = false,
        canStreamComposer: Bool = false,
        canAcknowledgeDispatch: Bool = false,
        canSyncSettings: Bool = false,
        canDirectConnect: Bool = false,
        canBrokerAgentLogin: Bool = false,
        canRevokeCredentials: Bool = false,
        canResolveProcessStall: Bool = false,
        canTransferOnRequest: Bool = false,
        canServeCarbon: Bool = false,
        canMarkSessionDone: Bool = false
    ) {
        self.canModifyToolInput = canModifyToolInput
        self.allowAlwaysScopes = allowAlwaysScopes
        self.canFetchDiff = canFetchDiff
        self.canListPeers = canListPeers
        self.canUpdateAddresses = canUpdateAddresses
        self.canReceiveSessionTransfer = canReceiveSessionTransfer
        self.canSyncRoster = canSyncRoster
        self.canSyncTranscripts = canSyncTranscripts
        self.canPushLiveActivity = canPushLiveActivity
        self.canPushToStartLiveActivity = canPushToStartLiveActivity
        self.canMintPairingCode = canMintPairingCode
        self.canReceiveForegroundState = canReceiveForegroundState
        self.canDelegateIntelligence = canDelegateIntelligence
        self.canReceiveSealedCredentials = canReceiveSealedCredentials
        self.canCreateProjects = canCreateProjects
        self.canCast = canCast
        self.canStreamComposer = canStreamComposer
        self.canAcknowledgeDispatch = canAcknowledgeDispatch
        self.canSyncSettings = canSyncSettings
        self.canDirectConnect = canDirectConnect
        self.canBrokerAgentLogin = canBrokerAgentLogin
        self.canRevokeCredentials = canRevokeCredentials
        self.canResolveProcessStall = canResolveProcessStall
        self.canTransferOnRequest = canTransferOnRequest
        self.canServeCarbon = canServeCarbon
        self.canMarkSessionDone = canMarkSessionDone
    }

    private enum CodingKeys: String, CodingKey {
        case canModifyToolInput, allowAlwaysScopes, canFetchDiff, canListPeers
        case canUpdateAddresses, canReceiveSessionTransfer, canSyncRoster, canSyncTranscripts
        case canPushLiveActivity, canPushToStartLiveActivity, canMintPairingCode
        case canReceiveForegroundState
        case canDelegateIntelligence, canReceiveSealedCredentials
        case canCreateProjects
        case canCast, canStreamComposer, canAcknowledgeDispatch, canSyncSettings
        case canDirectConnect
        case canBrokerAgentLogin
        case canRevokeCredentials
        case canResolveProcessStall
        case canTransferOnRequest
        case canServeCarbon
        case canMarkSessionDone
    }

    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.canModifyToolInput = try c.decode(Bool.self, forKey: .canModifyToolInput)
        self.allowAlwaysScopes = try c.decode([AlwaysScope].self, forKey: .allowAlwaysScopes)
        // Tolerate a host that predates on-demand diff fetch / peer listing / address updates /
        // session transfer / roster gossip / Live Activity push.
        self.canFetchDiff = try c.decodeIfPresent(Bool.self, forKey: .canFetchDiff) ?? false
        self.canListPeers = try c.decodeIfPresent(Bool.self, forKey: .canListPeers) ?? false
        self.canUpdateAddresses = try c.decodeIfPresent(Bool.self, forKey: .canUpdateAddresses) ?? false
        self.canReceiveSessionTransfer =
            try c.decodeIfPresent(Bool.self, forKey: .canReceiveSessionTransfer) ?? false
        self.canSyncRoster = try c.decodeIfPresent(Bool.self, forKey: .canSyncRoster) ?? false
        self.canSyncTranscripts =
            try c.decodeIfPresent(Bool.self, forKey: .canSyncTranscripts) ?? false
        self.canPushLiveActivity =
            try c.decodeIfPresent(Bool.self, forKey: .canPushLiveActivity) ?? false
        self.canPushToStartLiveActivity =
            try c.decodeIfPresent(Bool.self, forKey: .canPushToStartLiveActivity) ?? false
        self.canMintPairingCode =
            try c.decodeIfPresent(Bool.self, forKey: .canMintPairingCode) ?? false
        self.canReceiveForegroundState =
            try c.decodeIfPresent(Bool.self, forKey: .canReceiveForegroundState) ?? false
        self.canDelegateIntelligence =
            try c.decodeIfPresent(Bool.self, forKey: .canDelegateIntelligence) ?? false
        self.canReceiveSealedCredentials =
            try c.decodeIfPresent(Bool.self, forKey: .canReceiveSealedCredentials) ?? false
        self.canCreateProjects =
            try c.decodeIfPresent(Bool.self, forKey: .canCreateProjects) ?? false
        self.canCast = try c.decodeIfPresent(Bool.self, forKey: .canCast) ?? false
        self.canStreamComposer =
            try c.decodeIfPresent(Bool.self, forKey: .canStreamComposer) ?? false
        self.canAcknowledgeDispatch =
            try c.decodeIfPresent(Bool.self, forKey: .canAcknowledgeDispatch) ?? false
        self.canSyncSettings =
            try c.decodeIfPresent(Bool.self, forKey: .canSyncSettings) ?? false
        self.canDirectConnect =
            try c.decodeIfPresent(Bool.self, forKey: .canDirectConnect) ?? false
        self.canBrokerAgentLogin =
            try c.decodeIfPresent(Bool.self, forKey: .canBrokerAgentLogin) ?? false
        self.canRevokeCredentials =
            try c.decodeIfPresent(Bool.self, forKey: .canRevokeCredentials) ?? false
        self.canResolveProcessStall =
            try c.decodeIfPresent(Bool.self, forKey: .canResolveProcessStall) ?? false
        self.canTransferOnRequest =
            try c.decodeIfPresent(Bool.self, forKey: .canTransferOnRequest) ?? false
        self.canServeCarbon =
            try c.decodeIfPresent(Bool.self, forKey: .canServeCarbon) ?? false
        self.canMarkSessionDone =
            try c.decodeIfPresent(Bool.self, forKey: .canMarkSessionDone) ?? false
    }
}

/// A Mac mid-pairing awaiting the user's allow/deny (mesh P4), forwarded to a control-scope client
/// so the pairing can be approved from the phone that relayed the join code — not only on the
/// accepting Mac's own screen (`HostMsg.macPairRequested`). `deviceID` keys the response
/// (`ClientMsg.respondMacPair`) and the withdrawal (`HostMsg.macPairResolved`).
public struct WireMacPairRequest: Sendable, Codable, Equatable {
    public let deviceID: String
    public let label: String

    public init(deviceID: String, label: String) {
        self.deviceID = deviceID
        self.label = label
    }
}

public struct Welcome: Sendable, Codable, Equatable {
    public let protocolVersion: Int
    public let grantedScope: DeviceScope
    public let host: HostInfo
    public let capabilities: WireCapabilities
    /// The model/effort catalog the phone's pickers render (SYNC §5.2). Optional on the wire so
    /// a phone talking to a host that predates the field decodes it as an empty catalog and falls
    /// back to its built-in effort list.
    public let modelCatalog: WireModelCatalog
    /// The host's build channel, so the remote can confirm compatibility from its side too
    /// (`ReleaseChannel.isCompatible`). Optional on the wire so a remote talking to a host that
    /// predates the field decodes it as `nil` and skips the gate.
    public let channel: ReleaseChannel?
    /// Where this host can currently be dialed (mesh P4). Clients persist these per host so
    /// reconnect hints stay fresh without a new pairing QR. Optional; a pre-mesh host omits it.
    public let addresses: PeerAddresses?
    /// What kind of host this is (`PeerKind` raw value: "mac"/"cloud"/…), so a device pairing
    /// directly with it pins the right kind rather than assuming a Mac (docs/COVALENCE_RUNNER.md
    /// §3 — a runner is a `.cloud` host). Optional/raw-string: a host that predates the field
    /// decodes as `nil` and the peer keeps its `.mac` default; an unknown future kind round-trips
    /// as its raw value.
    public let hostKind: String?
    /// The account-level synced settings the host currently holds (`SyncedSettings`), stamped in
    /// so a device that connects mid-session starts from the host's truth without waiting for a
    /// `HostMsg.settings` broadcast. Optional/decode-if-present: a host that predates the field
    /// (or one that doesn't advertise `canSyncSettings`) omits it and the device keeps
    /// `SyncedSettings.default`.
    public let settings: SyncedSettings?

    public init(
        protocolVersion: Int = SyncProtocol.version,
        grantedScope: DeviceScope, host: HostInfo, capabilities: WireCapabilities,
        modelCatalog: WireModelCatalog = .empty, channel: ReleaseChannel? = nil,
        addresses: PeerAddresses? = nil, hostKind: String? = nil,
        settings: SyncedSettings? = nil
    ) {
        self.protocolVersion = protocolVersion
        self.grantedScope = grantedScope
        self.host = host
        self.capabilities = capabilities
        self.modelCatalog = modelCatalog
        self.channel = channel
        self.addresses = addresses
        self.hostKind = hostKind
        self.settings = settings
    }

    private enum CodingKeys: String, CodingKey {
        case protocolVersion, grantedScope, host, capabilities, modelCatalog, channel, addresses
        case hostKind, settings
    }

    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.protocolVersion = try c.decode(Int.self, forKey: .protocolVersion)
        self.grantedScope = try c.decode(DeviceScope.self, forKey: .grantedScope)
        self.host = try c.decode(HostInfo.self, forKey: .host)
        self.capabilities = try c.decode(WireCapabilities.self, forKey: .capabilities)
        self.modelCatalog = try c.decodeIfPresent(WireModelCatalog.self, forKey: .modelCatalog) ?? .empty
        self.channel = try c.decodeIfPresent(ReleaseChannel.self, forKey: .channel)
        self.addresses = try c.decodeIfPresent(PeerAddresses.self, forKey: .addresses)
        self.hostKind = try c.decodeIfPresent(String.self, forKey: .hostKind)
        self.settings = try c.decodeIfPresent(SyncedSettings.self, forKey: .settings)
    }

    /// The host's kind, defaulting to `.mac` for a host that predates the field.
    public var kind: PeerKind { hostKind.map(PeerKind.init(rawValue:)) ?? .mac }
}

/// The relay credential the host issues a device (mesh P2): the roomID it belongs to and a
/// long-lived membership token it later trades for connection tokens (`RelayAPI`). Delivered
/// two ways — inside the pairing QR (so pairing itself can ride the relay) and pushed as
/// `HostMsg.relayMembership` after every hello, which is what lets a device paired *before*
/// the relay existed adopt it, and refreshes the ~90-day token long before it lapses.
/// Content stays out of reach either way: the token only authorizes room admission; every
/// frame is Noise-E2EE.
public struct WireRelayMembership: Sendable, Codable, Equatable {
    /// The host's relay room — the full SHA-256 hex of its static key (= `HostID`).
    public let roomID: String
    /// The long-lived membership token this device presents to `POST /v1/relay/connect`.
    public let token: String
    /// Override of the built-in relay base URL (dev/self-host); nil = production relay.
    public let url: String?
    /// Unix seconds when `token` expires. Informational — the host re-issues on every connect.
    public let exp: Double?

    public init(roomID: String, token: String, url: String? = nil, exp: Double? = nil) {
        self.roomID = roomID
        self.token = token
        self.url = url
        self.exp = exp
    }
}

public struct Subscribe: Sendable, Codable, Equatable {
    public let sessionID: SessionID
    /// nil → host sends a snapshot + tail, not full history (SYNC §5.3).
    public let sinceSeq: UInt64?
    public let verbosity: Verbosity

    public init(sessionID: SessionID, sinceSeq: UInt64?, verbosity: Verbosity) {
        self.sessionID = sessionID
        self.sinceSeq = sinceSeq
        self.verbosity = verbosity
    }
}

// MARK: - Session projections

/// Where a session went when it was moved to another Mac (mesh P5). Present only on a
/// tombstoned summary (`archived == true`); `nil` on a live session. Lets any viewer render
/// "Moved to <deviceName>" instead of the session silently vanishing from the list.
public struct MovedDestination: Sendable, Codable, Equatable {
    /// The destination Mac's `HostID` (full SHA-256 hex of its static key).
    public let deviceID: String
    /// The destination Mac's friendly name, resolved from the paired-device store when the
    /// summary is built (falls back to a generic label when unknown), so a phone that can't
    /// resolve the id itself still renders a name.
    public let deviceName: String

    public init(deviceID: String, deviceName: String) {
        self.deviceID = deviceID
        self.deviceName = deviceName
    }
}

/// Where a session came from when it was transferred here (mesh P5) — the mirror of
/// `MovedDestination`. Present on a session that arrived from another Mac; `nil` for one created
/// here. Lets a viewer render a subtle "Arrived from <deviceName>" note.
public struct ArrivedFrom: Sendable, Codable, Equatable {
    /// The source Mac's `HostID` (full SHA-256 hex of its static key).
    public let deviceID: String
    /// The source Mac's friendly name, resolved from the paired-device store when the summary is
    /// built (falls back to a generic label when unknown).
    public let deviceName: String

    public init(deviceID: String, deviceName: String) {
        self.deviceID = deviceID
        self.deviceName = deviceName
    }
}

public struct SessionSummary: Sendable, Codable, Equatable {
    public let sessionID: SessionID
    public let projectID: String
    public let projectName: String
    public let backend: BackendID
    public let status: SessionStatus
    /// Refines `.awaitingInput` into "done" vs "needs you" (matches the Mac sidebar).
    public let disposition: TurnDisposition?
    public let title: String
    public let branch: String
    public let lastSeq: UInt64
    /// Monotonic count of transcript reverts (undo) this session has performed. A revert resets
    /// the seq counter, so `(sessionID, seq)` only identifies content *within* one epoch — a
    /// receiver holding events under a different epoch than the owner advertises must drop and
    /// refetch them (its copy may hold pre-revert content at reused seqs). 0 = never reverted;
    /// `nil` = a host that predates the field (no comparison possible — legacy behavior).
    public let revertEpoch: UInt64?
    public let diffStat: DiffStat?
    public let pendingApprovalCount: Int
    /// When the active pending block is an `AskUserQuestion`, the number of questions it
    /// asks; `nil` when the block is a plain approval or there's none. Lets a remote name
    /// that block "Question(s) Asked" instead of the generic "Awaiting approval", matching
    /// the host sidebar. (Optional so summaries from a host that predates the field decode
    /// as "not a question".)
    public let pendingQuestionCount: Int?
    /// The id of this session's single most-urgent pending approval (its oldest), so a remote surface
    /// that resolves an approval without opening the transcript — the aggregate Live Activity's inline
    /// Allow/Deny — knows which one to act on. Just the id, never the tool input: the glance stays
    /// content-free. `nil` when nothing's pending (or a host that predates the field).
    public let firstApprovalID: ApprovalID?
    /// Whether that approval is high-risk (destructive / network / host-exec). A surface hides inline
    /// Allow for a high-risk request and offers only Deny / open-the-app (§3.3). `nil` when there's
    /// none, or unknown (a host that predates the field) — treated as "no inline Allow".
    public let firstApprovalIsHighRisk: Bool?
    public let favorite: Bool
    public let archived: Bool
    /// A follow-up the user submitted while the agent was mid-turn, held to send the moment
    /// the current turn finishes. `nil` = nothing queued. (Optional so older hosts/clients
    /// that omit the key decode it as "no queued message".) A newline-joined summary of
    /// `queuedMessages`, kept for backward compatibility.
    public let queuedMessage: String?
    /// The queued follow-ups as discrete items (each with its text and attachments), so the
    /// phone can show and cancel each one individually — matching the Mac composer. Empty when
    /// nothing is queued. (Tolerates summaries from a host that predates the field.)
    public let queuedMessages: [QueuedMessage]
    /// The session's current model SKU / effort level — what the phone's header pickers show as
    /// selected and mutate via `setSessionModel` / `setSessionEffort`. `nil` = host/app default.
    public let model: String?
    public let effort: String?
    /// Auto-approval and autoship state (mirrors the Mac header toggles). Default `false` so
    /// summaries from a host that predates these fields decode cleanly.
    public let auto: Bool
    public let autoShip: Bool
    /// Per-session autoship destination override; `nil` inherits the project default.
    public let shipBranch: String?
    /// The latest turn's context-window occupancy (input tokens of the final model call), so the
    /// phone can show a context-usage badge without scanning the transcript. `nil` if unknown.
    public let contextInputTokens: Int?
    public let updatedAt: Date
    /// When the user last submitted a message in this session. Drives the sidebar's recency
    /// ordering so rows settle on the last *turn* rather than jumping on every bit of agent
    /// activity (`updatedAt` bumps on output/diff refresh/autoship notes). `nil` = no user
    /// message yet, or a summary from a host that predates the field — the sidebar then falls
    /// back to recency of any activity, matching the local comparator.
    public let lastUserMessageAt: Date?
    /// Set once this session was moved to another Mac (mesh P5) — the summary is then a
    /// tombstone (`archived == true`) and this names where it went. `nil` for a live session.
    /// (Optional so summaries from a host that predates the field decode as "not moved".)
    public let movedTo: MovedDestination?
    /// Set on a session that arrived here from another Mac (mesh P5) — names where it came from,
    /// for a subtle "Arrived from <Mac>" note. `nil` for a session created here. (Optional so
    /// summaries from a host that predates the field decode as "not transferred in".)
    public let arrivedFrom: ArrivedFrom?

    public init(
        sessionID: SessionID, projectID: String, projectName: String, backend: BackendID,
        status: SessionStatus, disposition: TurnDisposition? = nil, title: String, branch: String,
        lastSeq: UInt64, revertEpoch: UInt64? = nil, diffStat: DiffStat?,
        pendingApprovalCount: Int = 0,
        pendingQuestionCount: Int? = nil,
        firstApprovalID: ApprovalID? = nil, firstApprovalIsHighRisk: Bool? = nil,
        favorite: Bool = false, archived: Bool = false, queuedMessage: String? = nil,
        queuedMessages: [QueuedMessage] = [],
        model: String? = nil, effort: String? = nil, auto: Bool = false, autoShip: Bool = false,
        shipBranch: String? = nil, contextInputTokens: Int? = nil,
        updatedAt: Date, lastUserMessageAt: Date? = nil,
        movedTo: MovedDestination? = nil, arrivedFrom: ArrivedFrom? = nil
    ) {
        self.sessionID = sessionID
        self.projectID = projectID
        self.projectName = projectName
        self.backend = backend
        self.status = status
        self.disposition = disposition
        self.title = title
        self.branch = branch
        self.lastSeq = lastSeq
        self.revertEpoch = revertEpoch
        self.diffStat = diffStat
        self.pendingApprovalCount = pendingApprovalCount
        self.pendingQuestionCount = pendingQuestionCount
        self.firstApprovalID = firstApprovalID
        self.firstApprovalIsHighRisk = firstApprovalIsHighRisk
        self.favorite = favorite
        self.archived = archived
        self.queuedMessage = queuedMessage
        self.queuedMessages = queuedMessages
        self.model = model
        self.effort = effort
        self.auto = auto
        self.autoShip = autoShip
        self.shipBranch = shipBranch
        self.contextInputTokens = contextInputTokens
        self.updatedAt = updatedAt
        self.lastUserMessageAt = lastUserMessageAt
        self.movedTo = movedTo
        self.arrivedFrom = arrivedFrom
    }

    private enum CodingKeys: String, CodingKey {
        case sessionID, projectID, projectName, backend, status, disposition, title, branch
        case lastSeq, revertEpoch, diffStat, pendingApprovalCount, pendingQuestionCount
        case firstApprovalID, firstApprovalIsHighRisk, favorite, archived, queuedMessage
        case queuedMessages
        case model, effort, auto, autoShip, shipBranch, contextInputTokens, updatedAt
        case lastUserMessageAt
        case movedTo, arrivedFrom
    }

    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.sessionID = try c.decode(SessionID.self, forKey: .sessionID)
        self.projectID = try c.decode(String.self, forKey: .projectID)
        self.projectName = try c.decode(String.self, forKey: .projectName)
        self.backend = try c.decode(BackendID.self, forKey: .backend)
        self.status = try c.decode(SessionStatus.self, forKey: .status)
        self.disposition = try c.decodeIfPresent(TurnDisposition.self, forKey: .disposition)
        self.title = try c.decode(String.self, forKey: .title)
        self.branch = try c.decode(String.self, forKey: .branch)
        self.lastSeq = try c.decode(UInt64.self, forKey: .lastSeq)
        self.revertEpoch = try c.decodeIfPresent(UInt64.self, forKey: .revertEpoch)
        self.diffStat = try c.decodeIfPresent(DiffStat.self, forKey: .diffStat)
        self.pendingApprovalCount = try c.decodeIfPresent(Int.self, forKey: .pendingApprovalCount) ?? 0
        self.pendingQuestionCount = try c.decodeIfPresent(Int.self, forKey: .pendingQuestionCount)
        self.firstApprovalID = try c.decodeIfPresent(ApprovalID.self, forKey: .firstApprovalID)
        self.firstApprovalIsHighRisk = try c.decodeIfPresent(Bool.self, forKey: .firstApprovalIsHighRisk)
        self.favorite = try c.decodeIfPresent(Bool.self, forKey: .favorite) ?? false
        self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
        self.queuedMessage = try c.decodeIfPresent(String.self, forKey: .queuedMessage)
        self.queuedMessages = try c.decodeIfPresent([QueuedMessage].self, forKey: .queuedMessages) ?? []
        // New fields — tolerate summaries from a host that predates them.
        self.model = try c.decodeIfPresent(String.self, forKey: .model)
        self.effort = try c.decodeIfPresent(String.self, forKey: .effort)
        self.auto = try c.decodeIfPresent(Bool.self, forKey: .auto) ?? false
        self.autoShip = try c.decodeIfPresent(Bool.self, forKey: .autoShip) ?? false
        self.shipBranch = try c.decodeIfPresent(String.self, forKey: .shipBranch)
        self.contextInputTokens = try c.decodeIfPresent(Int.self, forKey: .contextInputTokens)
        self.updatedAt = try c.decode(Date.self, forKey: .updatedAt)
        self.lastUserMessageAt = try c.decodeIfPresent(Date.self, forKey: .lastUserMessageAt)
        self.movedTo = try c.decodeIfPresent(MovedDestination.self, forKey: .movedTo)
        self.arrivedFrom = try c.decodeIfPresent(ArrivedFrom.self, forKey: .arrivedFrom)
    }
}

extension SessionSummary {
    /// Whether this session's turn *ended* — a completed/finished chat with nothing required from
    /// the user. Mirrors the Mac sidebar's "Done" (`AppStore.SessionSummary.isCompleted`): a run
    /// that finished normally, or an interactive turn classified `.completed`. A session asking a
    /// question, blocked on an approval, or not yet classified is not done.
    public var isTurnDone: Bool {
        switch status {
        case .finished: return true
        case .awaitingInput: return disposition == .completed
        default: return false
        }
    }

    /// Whether the session is Done *and* quiet — nothing left for it to do: its turn ended and no
    /// queued follow-up is waiting to dispatch when it next runs. A cloud runner sums the
    /// complement of this across its sessions into the heartbeat's `activeSessions`, so the pool
    /// can sleep a runner once every session it holds is quiescent (docs/COVALENCE_RUNNER.md §7).
    /// A moved-away tombstone (`archived`) is inert and counts as quiescent.
    public var isQuiescent: Bool {
        if archived { return true }
        return isTurnDone && queuedMessages.isEmpty
    }
}

/// How the phone asks the host to land a session's branch (control scope). Mirrors the Mac's
/// integrate strategies.
public enum IntegrationMode: String, Sendable, Codable {
    case merge, squash, rebase
}

public struct SessionSnapshot: Sendable, Codable, Equatable {
    public let summary: SessionSummary
    /// Tail window, oldest→newest (not the whole transcript — SYNC §5.3).
    public let recentEvents: [AgentEvent]
    public let pendingApprovals: [ApprovalRequest]
    /// Client continues (`subscribe sinceSeq`) from here.
    public let cursor: UInt64
    /// The session's current collapsed Bash summary lines (mesh session sync), so a peer
    /// subscribing mid-session seeds every already-computed card without waiting for the next
    /// live `HostMsg.toolSummaries`. Non-empty only when this snapshot's owner has the session
    /// open (that's the only session it summarizes); empty otherwise. Wire-optional, defaulted
    /// `[]`, so an older owner that omits it still decodes.
    public let toolSummaries: [ToolSummaryLine]

    public init(
        summary: SessionSummary, recentEvents: [AgentEvent],
        pendingApprovals: [ApprovalRequest], cursor: UInt64,
        toolSummaries: [ToolSummaryLine] = []
    ) {
        self.summary = summary
        self.recentEvents = recentEvents
        self.pendingApprovals = pendingApprovals
        self.cursor = cursor
        self.toolSummaries = toolSummaries
    }

    private enum CodingKeys: String, CodingKey {
        case summary, recentEvents, pendingApprovals, cursor, toolSummaries
    }

    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.summary = try c.decode(SessionSummary.self, forKey: .summary)
        self.recentEvents = try c.decode([AgentEvent].self, forKey: .recentEvents)
        self.pendingApprovals = try c.decode([ApprovalRequest].self, forKey: .pendingApprovals)
        self.cursor = try c.decode(UInt64.self, forKey: .cursor)
        self.toolSummaries =
            try c.decodeIfPresent([ToolSummaryLine].self, forKey: .toolSummaries) ?? []
    }

    public func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(summary, forKey: .summary)
        try c.encode(recentEvents, forKey: .recentEvents)
        try c.encode(pendingApprovals, forKey: .pendingApprovals)
        try c.encode(cursor, forKey: .cursor)
        // Omit an empty set so the common no-summaries snapshot stays byte-for-byte as before.
        if !toolSummaries.isEmpty { try c.encode(toolSummaries, forKey: .toolSummaries) }
    }
}

/// An ordered batch of transcript events for one session (`HostMsg.events`). Carries its
/// seq range implicitly via the events; the client dedupes on `(sessionID, seq)`.
public struct EventBatch: Sendable, Codable, Equatable {
    public let sessionID: SessionID
    public let events: [AgentEvent]

    public init(sessionID: SessionID, events: [AgentEvent]) {
        self.sessionID = sessionID
        self.events = events
    }
}

/// A transcript was truncated in place — the user reverted (undid) the chat to just before a
/// prior message, dropping that message and everything after it. `throughSeq` is the transcript's
/// new tip: a receiver drops every event with `seq > throughSeq` from the session (0 ⇒ the whole
/// transcript was cleared). Unlike `EventBatch`, which only ever *grows* a mirrored transcript,
/// this is the one wire message that *shrinks* it — so a revert converges across the mesh the way
/// a new message does, instead of leaving receivers on a stale transcript until a manual refetch.
public struct TranscriptReverted: Sendable, Codable, Equatable {
    public let sessionID: SessionID
    public let throughSeq: UInt64
    /// The session's revert epoch *after* this revert (see `SessionSummary.revertEpoch`). A
    /// receiver that truncates to `throughSeq` records this as its held epoch, so the next
    /// reconcile pass doesn't mistake its (now converged) copy for one that missed the revert.
    /// `nil` from a host that predates the field.
    public let revertEpoch: UInt64?

    public init(sessionID: SessionID, throughSeq: UInt64, revertEpoch: UInt64? = nil) {
        self.sessionID = sessionID
        self.throughSeq = throughSeq
        self.revertEpoch = revertEpoch
    }
}

// MARK: - Errors (SYNC §8)

public struct WireError: Sendable, Codable, Equatable, Error {
    public enum Code: String, Sendable, Codable {
        case unauthorized        // bad/revoked device or insufficient scope
        case unknownSession
        case alreadyResolved     // lost the approval race
        case unsupported         // e.g. interrupt on a non-control scope
        case backpressure        // host overloaded; client should back off
        case protocolVersion     // version mismatch; client must upgrade
        case channelMismatch     // remote/host built from incompatible release channels
        case malformed           // undecodable frame
    }

    public let code: Code
    public let message: String
    public let sessionID: SessionID?

    public init(code: Code, message: String, sessionID: SessionID? = nil) {
        self.code = code
        self.message = message
        self.sessionID = sessionID
    }
}
