import Foundation
import NucleicProtocol

/// The authority seam between the sync server and the running host (RUNTIME §1; the phone is
/// "another renderer of the same authority"). `SyncHost` depends only on this, so it's
/// testable with a fake and the real `AppStore` conformance stays thin.
///
/// All methods hop to wherever the authority lives (the `AppStore` conformance is
/// `@MainActor`); callers `await` them off the connection tasks.
public protocol SyncHostBridge: Sendable {
    var hostInfo: HostInfo { get async }
    var capabilities: WireCapabilities { get async }
    /// What kind of host this is (`PeerKind`), stamped into `Welcome.hostKind` so a device
    /// pairing directly with it pins the right kind. Defaulted `.mac`; a runner's bridge returns
    /// `.cloud` (docs/COVALENCE_RUNNER.md §3).
    var hostKind: PeerKind { get async }
    /// The model/effort catalog the phone's pickers mirror (SYNC §5.2), sent in `Welcome`.
    var modelCatalog: WireModelCatalog { get async }
    /// This host's build channel. `ConnectionHandler` rejects a `hello` whose channel is
    /// incompatible with it (`ReleaseChannel.isCompatible`) and echoes it in `Welcome`.
    /// `nil` disables the gate — the default for minimal/test hosts that don't declare one.
    var releaseChannel: ReleaseChannel? { get async }

    /// Current session list, newest-activity first (the phone sorts attention-first itself).
    func sessionSummaries() async -> [WireSessionSummary]

    /// Snapshot for a cold/warm subscribe: status + pending approvals + a tail (cold) or
    /// the events after `sinceSeq` (warm). Returns nil if the session is unknown.
    func snapshot(_ id: SessionID, sinceSeq: UInt64?) async -> SessionSnapshot?

    /// First-responder-wins approval (SYNC §5.4). Throws `WireError(.alreadyResolved)` if the
    /// race was lost, `.unknownSession` if gone. `responder` is the `ResponderLabel` of the
    /// remote device answering (recorded as `ApprovalResolved.decidedBy`).
    func respondToApproval(_ id: ApprovalID, _ decision: Decision, by responder: String) async throws

    /// Queue a follow-up user turn (scope ≥ approve).
    func sendInput(_ id: SessionID, _ input: AgentInput) async throws

    /// Cooperative interrupt (scope ≥ control).
    func interrupt(_ id: SessionID) async throws

    /// Cancel one queued (not-yet-sent) follow-up by id (scope ≥ control).
    func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) async throws

    /// Resolve a live "host command looks hung" alert (`ClientMsg.resolveProcessStall`, scope ≥
    /// control): `kill == true` tears down that command's whole process tree, `false` dismisses the
    /// alert and lets it run on. `stallID` is the `ProcessStallNote.id`. Defaulted no-op so bridges
    /// that don't run commands keep compiling — they just shouldn't advertise
    /// `canResolveProcessStall`.
    func resolveProcessStall(_ id: SessionID, stallID: String, kill: Bool) async throws

    // Control scope: the same actions the Mac can take (UX_IOS §10, lifted to control).
    func dashboardSnapshot() async -> DashboardSnapshot
    func startChat(_ request: StartChatRequest) async throws
    func captureTodo(_ request: CaptureTodoRequest) async throws
    func dispatchTodo(_ id: TodoID, in projectID: ProjectID) async throws
    func remoteSetTodoStatus(_ id: TodoID, _ status: TodoStatus) async throws
    func remoteDeleteTodo(_ id: TodoID) async throws
    func renameSession(_ id: SessionID, to title: String) async throws
    func setFavorite(_ id: SessionID, _ favorite: Bool) async throws
    func setArchived(_ id: SessionID, _ archived: Bool) async throws
    /// Hand-mark a chat "Done" (`ClientMsg.markSessionDone`, scope ≥ control): flip its last turn's
    /// disposition to `.completed` so a chat stuck reading "Awaiting Input" drops out of "needs
    /// you". A no-op unless the chat is idle at `.awaitingInput`. Defaulted no-op so bridges that
    /// don't classify dispositions keep compiling — they just shouldn't advertise
    /// `canMarkSessionDone`.
    func markSessionDoneRemote(_ id: SessionID) async throws
    func deleteSessionRemote(_ id: SessionID) async throws
    func integrate(_ id: SessionID, _ mode: IntegrationMode) async throws
    func discard(_ id: SessionID) async throws
    // Mid-session model / reasoning / automation changes (the Mac header's affordances).
    func setSessionModel(_ id: SessionID, _ model: String?) async throws
    func setSessionEffort(_ id: SessionID, _ effort: String?) async throws
    func setSessionAuto(_ id: SessionID, _ auto: Bool) async throws
    func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) async throws
    func setSessionShipBranch(_ id: SessionID, _ branch: String?) async throws

    /// The session's full worktree diff for the phone's Diff tab (`ClientMsg.fetchDiff`).
    /// Nil for an unknown session or one whose diff can't be computed. Defaulted so
    /// bridges that don't serve diffs (fakes, minimal hosts) keep compiling — they just
    /// shouldn't advertise `canFetchDiff`.
    func sessionDiff(_ id: SessionID) async -> WireSessionDiff?

    /// A session's transcript events after `afterSeq` (0 ⇒ the whole transcript, with its header
    /// so a requester can build a faithful mirror file), for full-transcript mesh sync
    /// (`ClientMsg.fetchTranscript`). Served from a live controller OR an on-disk transcript this
    /// device holds — owned, or mirrored from another device — so a peer that doesn't *run* the
    /// session can still serve its history. Returns nil when this device holds no transcript for
    /// `id` (⇒ `transcriptUnavailable(notHeld)`). `revertEpoch` is this holder's revert epoch for
    /// the session (`SessionSummary.revertEpoch`) — rides `TranscriptFetchComplete` so the
    /// requester can verify the serve matches the owner's advertised transcript generation before
    /// committing; nil when unknown (a pre-epoch transcript). Defaulted nil so bridges that don't
    /// serve transcripts keep compiling — they just shouldn't advertise `canSyncTranscripts`.
    func transcriptEvents(_ id: SessionID, afterSeq: UInt64) async
        -> (header: SessionHeader?, revertEpoch: UInt64?, events: [AgentEvent])?

    // Carbon shard replication serving (docs/CARBON_SHARDING.md §8.1) — this device's
    // `CarbonShardStore`, read-only. Blobs are ciphertext, verified by the store before being
    // served (§7.2: a rotted blob answers as absent, never poisons the mesh). All three
    // defaulted so bridges without a Carbon store keep compiling — they just shouldn't
    // advertise `canServeCarbon`.
    /// This member's Carbon head state (`nil` project = all enrolled projects).
    func carbonHeads(projectUUID: UUID?) async -> [CarbonHead]
    /// A held manifest ciphertext, or nil (⇒ `carbonManifestUnavailable(notHeld)`).
    func carbonManifestBlob(id: String, projectUUID: UUID) async -> Data?
    /// A held shard ciphertext, or nil (⇒ `carbonShardUnavailable(notHeld)`).
    func carbonShardBlob(id: String, projectUUID: UUID) async -> Data?

    // Carbon lock arbitration (CARBON_SHARDING §17.5) — served only by the domain's lock
    // authority (the host running the project's control container, §17.3). Both defaulted so
    // bridges without a lock authority keep compiling; a non-authority answers `cancelled`.
    /// Whether a `carbonLockAcquire` would park (⇒ send an interim `queued` decision first).
    func carbonLockWouldBlock(_ request: CarbonLockRequest, from deviceID: String) async -> Bool
    /// Arbitrate a remote session's acquire. Suspends until granted/cancelled — the caller
    /// runs it off the message loop. The agent-side contract (LOCKING §4.6) is unchanged;
    /// the suspension just spans the wire.
    func carbonLockAcquire(_ request: CarbonLockRequest, from deviceID: String) async
        -> CarbonLockDecisionMsg.Outcome
    /// Release a remote session's locks (`paths == nil` = all).
    func carbonLockRelease(_ request: CarbonLockRequest, from deviceID: String) async

    /// Live host-side changes to fan out to subscribed clients. One shared stream; `SyncHost`
    /// multiplexes per-connection filtering/verbosity on top.
    func broadcasts() async -> AsyncStream<HostBroadcast>

    /// The mesh peers this host knows (mesh P4) — for the device list and the transfer
    /// destination picker (`ClientMsg.listPeers`). Defaulted to empty so bridges that don't
    /// participate in the mesh keep compiling; they just shouldn't advertise `canListPeers`.
    func peerSummaries() async -> [PeerSummary]

    /// Where this host can currently be dialed (mesh P4) — sent in `Welcome.addresses` so
    /// clients keep fresh reconnect hints. Defaulted nil for bridges that don't mesh.
    var localAddresses: PeerAddresses? { get async }

    /// Whether to accept a *Mac* peer's first pairing (mesh P4). Consulted only on the TOFU
    /// path when the hello claims a device kind that can own sessions — pairing a Mac grants
    /// it control of this one, so the plan requires an explicit confirm on the accepting Mac
    /// on top of QR-secret possession. Phones keep the existing pure-TOFU flow. Defaulted to
    /// accept, so test/minimal bridges pair without UI.
    func approveMacPairing(deviceID: String, label: String) async -> Bool

    /// The mesh peer table or presence just changed (a device paired, connected, disconnected,
    /// or refreshed its addresses). A hook for the host app to refresh its published peer
    /// state; defaulted no-op.
    func meshPeersChanged() async

    // MARK: Mesh "join" — roster gossip

    /// This host's current view of the group (live members + revocation tombstones), pushed to a
    /// connecting client that advertised `canSyncRoster` so joining via any member converges the
    /// whole mesh. Defaulted empty for bridges that don't participate (they shouldn't advertise
    /// `canSyncRoster`).
    func meshRoster() async -> MeshRosterPush

    /// Merge a roster pushed by `deviceID`: auto-pin unknown mac members (auto-trusting the
    /// introducer), apply revocation tombstones, reconcile dial loops, and re-gossip onward. The
    /// receiving side of `ClientMsg.meshRoster` / `HostMsg.meshRoster`. Defaulted no-op.
    func mergeMeshRoster(_ push: MeshRosterPush, from deviceID: String) async

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

    /// Open a pairing window on this host and return its join code (`nucleic://pair?d=…`), for a
    /// control-scope client relaying it ("add a device"). Nil when the host can't mint one right
    /// now (e.g. the sync server isn't running). Defaulted nil so bridges that don't host pairing
    /// keep compiling — they just shouldn't advertise `canMintPairingCode`.
    func mintPairingCode() async -> String?
    /// Close a pairing window opened by `mintPairingCode` (the relaying client dismissed its
    /// sheet) so the one-time secret doesn't stay armed. Defaulted no-op.
    func cancelPairingCode() async

    /// Answer a Mac-pair confirm forwarded to a control client (`ClientMsg.respondMacPair`) — the
    /// phone that shared the join code approving/denying the joining Mac. Applies only when
    /// `deviceID` matches the pending request (a stale answer is ignored). Defaulted no-op.
    func respondToMacPairing(deviceID: String, approve: Bool) async

    // MARK: Session transfer — destination importer surface (mesh P5)
    // The source drives a transfer as our client; `ConnectionHandler` forwards each verb here
    // after gating on `.control` + mac peer. Each returns the `HostMsg`s to send back, in order
    // (usually one; a final chunk returns both its ack AND `transferReady`). Defaulted to reject
    // so bridges that don't accept transfers keep compiling — they just shouldn't advertise
    // `canReceiveSessionTransfer`.

    /// Evaluate an offer and reply `transferAccept` or `transferReject`.
    func receiveTransferOffer(_ offer: TransferOffer, from deviceID: String) async -> [HostMsg]
    /// Persist a chunk and reply `transferChunkAck`; the last chunk of the last item also replies
    /// `transferReady` once the session is staged (or `transferReject` on a hash/git failure).
    func receiveTransferChunk(_ chunk: TransferChunk) async -> [HostMsg]
    /// Phase-2 commit: activate the staged session and reply `transferCommitted` (idempotent).
    func receiveTransferCommit(_ transferID: String) async -> [HostMsg]
    /// The source abandoned the transfer; discard any staged data.
    func receiveTransferCancel(_ transferID: String) async

    /// Move a session *this* host owns to `destinationDeviceID`, on a peer's request
    /// (`ClientMsg.requestSessionTransfer`) — the sending peer may name itself ("bring it here")
    /// or any other device it can see. Throws the same `SessionTransferError`s the local menu
    /// surfaces, so the requester gets a `WireError` instead of silence. Defaulted to throw so
    /// bridges that can't move sessions keep compiling — they just shouldn't advertise
    /// `canTransferOnRequest`.
    func transferSession(_ id: SessionID, to destinationDeviceID: String) async throws

    // MARK: Covalence runner — intelligence delegation + credential mesh
    // (docs/COVALENCE_RUNNER.md §5–6.) All defaulted no-op: a Mac host today neither delegates
    // intelligence nor ingests mesh credentials — it just doesn't advertise
    // `canDelegateIntelligence` / `canReceiveSealedCredentials`. A runner host's bridge overrides
    // these.

    /// The answer to a delegated intelligence call this host pushed
    /// (`HostMsg.intelligenceRequest`). Correlate by `result.id`; drop stale/unknown ids.
    func receiveIntelligenceResult(_ result: WireIntelligenceResult, from deviceID: String) async

    /// A connected device advertised `canProvideIntelligence` — enroll it as a worker in this
    /// host's mesh AFM queue (COVALENCE_RUNNER §5). `connectionID` scopes the registration to
    /// one connection so a superseded socket's teardown can't unregister its replacement;
    /// `send` pushes one `HostMsg.intelligenceRequest` at the device. Defaulted no-op — a host
    /// that never delegates just ignores the offer.
    func intelligenceWorkerConnected(
        deviceID: String, connectionID: String, profile: IntelligenceWorkerProfile,
        send: @escaping @Sendable (WireIntelligenceRequest) async -> Void
    ) async
    /// The registering connection closed — withdraw the worker (matched on `connectionID`).
    /// Defaulted no-op.
    func intelligenceWorkerDisconnected(deviceID: String, connectionID: String) async
    /// A device's credential manifest — descriptors + refresh leases, never secret bytes. Merge
    /// per kind on `updatedAt`; arbitrate leases with `CredentialRefreshLease.merged`.
    func receiveCredentialManifest(_ manifest: CredentialManifest, from deviceID: String) async
    /// Credential records sealed to this host's sealing key (answering
    /// `HostMsg.credentialNeeded`, or a proactive push after a device-side refresh).
    func receiveCredentialProvision(_ envelope: SealedCredentialEnvelope, from deviceID: String) async

    /// What this host wants from connected devices (`HostMsg.credentialNeeded`, runner §6):
    /// the missing credential kinds + the X25519 key to seal them to. Pushed post-hello to
    /// approve-scope peers. Defaulted nil — a Mac host lacks nothing over the mesh.
    func credentialNeed() async -> WireCredentialNeed?

    /// A device deleted credential kinds mesh-wide (`ClientMsg.credentialRevoke`): clear the
    /// local copies, record the tombstones, and fan them out — to this host's other clients
    /// (`HostMsg.credentialRevoked`) and peer-ward. Defaulted no-op so bridges that don't
    /// revoke keep compiling — they just shouldn't advertise `canRevokeCredentials`.
    func receiveCredentialRevocation(_ tombstones: [CredentialTombstone], from deviceID: String) async

    /// Every credential tombstone this host has recorded, pushed post-hello
    /// (`HostMsg.credentialRevoked`) so a device that was offline during a deletion converges
    /// on connect. Defaulted empty.
    func credentialTombstones() async -> [CredentialTombstone]

    /// The runner-pool credential this device holds (COVALENCE_RUNNER §0.2 item 4), pushed
    /// post-hello to control-scope peers the way relay memberships are — so every trusted mesh
    /// device manages the same pool instead of PoP-enrolling its own (which rotates the secret
    /// out from under the sharer). Defaulted nil for bridges that hold none.
    func runnerPoolCredential() async -> WireRunnerPoolCredential?

    /// Clone a git repository and register it as a project (`ClientMsg.createProject`,
    /// CLOUD_RUNTIME §4.3) — how a device seeds work onto a fresh runner. Returns the outcome
    /// (echoing the request's `requestID`) for the requester's `HostMsg.projectCreated`; throws
    /// a `WireError` on failure, which `ConnectionHandler` folds into a failed outcome. The full
    /// project row reaches every client through the dashboard broadcast. Defaulted to reject so
    /// bridges that don't create projects keep compiling — they just shouldn't advertise
    /// `canCreateProjects`.
    func createProject(_ request: WireCreateProjectRequest) async throws -> WireProjectCreated

    // MARK: Mesh casting (CastMessages.swift)
    // Both defaulted empty so bridges that don't cast keep compiling — they just shouldn't
    // advertise `canCast`.

    /// Every (origin, channel) this host holds casts for — owned publishes AND mirrored peers'
    /// casts — so `ConnectionHandler` can serve a `castSubscribe` catch-up pass over all of them.
    func castTips() async -> [CastTip]

    /// The catch-up reply for one (origin, channel) at the given cursor, pre-chunked into
    /// ordered `CastCatchUp` batches (≤ `CastCatchUp.maxCastsPerBatch` each, last one
    /// `complete: true`). State channels return the compacted snapshot (`reset: true`); stream
    /// channels the gap `(afterSeq, tip]`, or a reset when the cursor fell below the retention
    /// floor.
    func castCatchUp(origin: String, channel: String, afterSeq: UInt64) async -> [CastCatchUp]

    /// A client's live composer report (`ClientMsg.composerTyping`, mesh composer streaming):
    /// republish it on the `composer.typing` cast channel stamped with the sender's identity,
    /// or tombstone the session's entry when `active` is false. Defaulted no-op so bridges that
    /// don't relay keep compiling — they just shouldn't advertise `canStreamComposer`.
    func receiveComposerTyping(_ typing: WireComposerTyping, from deviceID: String) async

    /// Mesh dispatch: start a chat and answer with a correlated, definitive outcome —
    /// idempotent per `requestID` (a repeated id acks the already-created session and starts
    /// nothing, so the dispatcher's timeout-retry can never double-start). Defaulted to a
    /// failure outcome so bridges that don't dispatch keep compiling — they just shouldn't
    /// advertise `canAcknowledgeDispatch`.
    func startChatAcknowledged(_ request: StartChatRequest, requestID: String) async -> WireChatStarted

    // MARK: Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md)
    // All defaulted so bridges that don't broker sign-in keep compiling — they just shouldn't
    // advertise `canBrokerAgentLogin`.

    /// Start one remote sign-in attempt (`ClientMsg.agentLoginBegin`): run the provider's OAuth
    /// flow host-side, pushing the consent challenge — and eventually the result — back at the
    /// initiating connection through `send`. MUST return promptly (kick the flow off in a task):
    /// the caller is the connection's serial dispatch loop, and the flow blocks for minutes
    /// awaiting the browser round-trip — including the `agentLoginCallback` that arrives on this
    /// same loop. Defaulted to an immediate failed result.
    func beginAgentLogin(
        _ request: WireAgentLoginBegin, from deviceID: String,
        send: @escaping @Sendable (HostMsg) async -> Void
    ) async
    /// The captured redirect for an in-flight attempt (`ClientMsg.agentLoginCallback`) — resume
    /// the flow awaiting it. Correlated by `requestID`; stale/unknown ids are dropped. Defaulted
    /// no-op.
    func deliverAgentLoginCallback(_ callback: WireAgentLoginCallback) async
    /// The device abandoned an attempt (`ClientMsg.agentLoginCancel`) — discard its PKCE state;
    /// the flow answers with a failed result. Defaulted no-op.
    func cancelAgentLogin(requestID: String) async
    /// The host's per-provider install/auth state for the `HostMsg.agentAuthStatus` push (sent
    /// post-hello and re-broadcast on change). Empty ⇒ nothing pushed. Defaulted empty.
    func providerAuthStatuses() async -> [WireProviderAuthStatus]

    // MARK: Account-level synced settings (SyncedSettings)

    /// The account-level synced settings this host currently holds — stamped into
    /// `Welcome.settings` on connect so a device starts from the host's truth. Defaulted to
    /// `.default` for bridges that don't sync settings (they shouldn't advertise
    /// `canSyncSettings`).
    var syncedSettings: SyncedSettings { get async }

    /// Apply a device's requested settings change (`ClientMsg.updateSettings`): persist it,
    /// enforce any dependent host state (e.g. force the Covalence method on so the
    /// relayed lock-screen approval can actually reach this Mac), then broadcast the authoritative
    /// result as `HostMsg.settings` so every device converges. Defaulted no-op so bridges that
    /// don't sync settings keep compiling — they just shouldn't advertise `canSyncSettings`.
    func updateSyncedSettings(_ settings: SyncedSettings) async
}

extension SyncHostBridge {
    /// Default host kind: a Mac. A runner host overrides this to `.cloud`.
    public var hostKind: PeerKind { get async { .mac } }
    public func resolveProcessStall(_ id: SessionID, stallID: String, kill: Bool) async throws {}
    public func markSessionDoneRemote(_ id: SessionID) async throws {}
    public func sessionDiff(_ id: SessionID) async -> WireSessionDiff? { nil }
    public func transcriptEvents(_ id: SessionID, afterSeq: UInt64) async
        -> (header: SessionHeader?, revertEpoch: UInt64?, events: [AgentEvent])? { nil }
    public func carbonHeads(projectUUID: UUID?) async -> [CarbonHead] { [] }
    public func carbonManifestBlob(id: String, projectUUID: UUID) async -> Data? { nil }
    public func carbonShardBlob(id: String, projectUUID: UUID) async -> Data? { nil }
    public func carbonLockWouldBlock(_ request: CarbonLockRequest, from deviceID: String) async -> Bool { false }
    public func carbonLockAcquire(_ request: CarbonLockRequest, from deviceID: String) async
        -> CarbonLockDecisionMsg.Outcome
    { .cancelled }
    public func carbonLockRelease(_ request: CarbonLockRequest, from deviceID: String) async {}
    public func peerSummaries() async -> [PeerSummary] { [] }
    /// Default: no declared channel, so the channel gate is a no-op (test/minimal hosts).
    public var releaseChannel: ReleaseChannel? { get async { nil } }
    public var localAddresses: PeerAddresses? { get async { nil } }
    public func approveMacPairing(deviceID: String, label: String) async -> Bool { true }
    public func meshPeersChanged() async {}
    public func meshRoster() async -> MeshRosterPush { MeshRosterPush() }
    public func mergeMeshRoster(_ push: MeshRosterPush, from deviceID: String) async {}
    public func mintPairingCode() async -> String? { nil }
    public func cancelPairingCode() async {}
    public func respondToMacPairing(deviceID: String, approve: Bool) async {}

    public func receiveTransferOffer(_ offer: TransferOffer, from deviceID: String) async -> [HostMsg] {
        [.transferReject(TransferReject(
            transferID: offer.transferID, reason: .internalError,
            message: "This device can't receive session transfers."))]
    }
    public func receiveTransferChunk(_ chunk: TransferChunk) async -> [HostMsg] { [] }
    public func receiveTransferCommit(_ transferID: String) async -> [HostMsg] {
        [.transferReject(TransferReject(transferID: transferID, reason: .internalError))]
    }
    public func receiveTransferCancel(_ transferID: String) async {}
    public func transferSession(_ id: SessionID, to destinationDeviceID: String) async throws {
        throw SessionTransferError.rejected(
            .internalError, message: "This device can't move sessions.")
    }

    public func receiveIntelligenceResult(
        _ result: WireIntelligenceResult, from deviceID: String
    ) async {}
    public func intelligenceWorkerConnected(
        deviceID: String, connectionID: String, profile: IntelligenceWorkerProfile,
        send: @escaping @Sendable (WireIntelligenceRequest) async -> Void
    ) async {}
    public func intelligenceWorkerDisconnected(deviceID: String, connectionID: String) async {}
    public func receiveCredentialManifest(
        _ manifest: CredentialManifest, from deviceID: String
    ) async {}
    public func receiveCredentialProvision(
        _ envelope: SealedCredentialEnvelope, from deviceID: String
    ) async {}

    public func runnerPoolCredential() async -> WireRunnerPoolCredential? { nil }

    public func credentialNeed() async -> WireCredentialNeed? { nil }

    public func receiveCredentialRevocation(
        _ tombstones: [CredentialTombstone], from deviceID: String
    ) async {}

    public func credentialTombstones() async -> [CredentialTombstone] { [] }

    public func createProject(_ request: WireCreateProjectRequest) async throws -> WireProjectCreated {
        throw WireError(code: .unsupported, message: "This host can't create projects.")
    }

    public func castTips() async -> [CastTip] { [] }
    public func castCatchUp(origin: String, channel: String, afterSeq: UInt64) async -> [CastCatchUp] { [] }

    public func receiveComposerTyping(_ typing: WireComposerTyping, from deviceID: String) async {}

    public func startChatAcknowledged(
        _ request: StartChatRequest, requestID: String
    ) async -> WireChatStarted {
        WireChatStarted(requestID: requestID, error: "This host can't accept mesh dispatches.")
    }

    public var syncedSettings: SyncedSettings { get async { .default } }
    public func updateSyncedSettings(_ settings: SyncedSettings) async {}

    public func beginAgentLogin(
        _ request: WireAgentLoginBegin, from deviceID: String,
        send: @escaping @Sendable (HostMsg) async -> Void
    ) async {
        await send(.agentLoginResult(WireAgentLoginResult(
            requestID: request.requestID, error: "This host can't broker agent sign-in.")))
    }
    public func deliverAgentLoginCallback(_ callback: WireAgentLoginCallback) async {}
    public func cancelAgentLogin(requestID: String) async {}
    public func providerAuthStatuses() async -> [WireProviderAuthStatus] { [] }
}

/// The wire `SessionSummary` lives in NucleicProtocol but collides by name with the app's
/// own view-model `SessionSummary`; alias it so host code is unambiguous.
public typealias WireSessionSummary = NucleicProtocol.SessionSummary

/// A host-side change worth pushing to subscribers. `SyncHost` translates these into
/// `HostMsg`s, filtered by each connection's subscriptions and verbosity.
public enum HostBroadcast: Sendable {
    case events(SessionID, [AgentEvent])
    case approvalRequested(ApprovalRequest)
    case approvalResolved(ApprovalResolved)
    case sessionUpdated(WireSessionSummary)
    /// The session *list* changed in a way `sessionUpdated` can't express — a deletion.
    /// Clients replace their mirrored list wholesale (the same `HostMsg.sessionList` they
    /// seed from on connect), so a removed row disappears without waiting for a reconnect.
    case sessionList([WireSessionSummary])
    case dashboard(DashboardSnapshot)
    /// The current collapsed Bash summary lines for a session changed (mesh session sync) — the
    /// owner (re)computed them for the session it has open. `SyncHost` fans this out to that
    /// session's subscribers as `HostMsg.toolSummaries` so peer viewers can resolve the loading
    /// placeholder on each card without recomputing the model work themselves.
    case toolSummaries(SessionID, [ToolSummaryLine])
    /// A session's transcript was truncated in place — the owner reverted (undid) the chat to just
    /// before a prior message (undo). `SyncHost` fans this out to *every* connected device as
    /// `HostMsg.transcriptReverted` — not just this session's live subscribers — so any device that
    /// holds a stored copy (a peer Mac's proactive mirror, a phone's offline cache) *shrinks* it to
    /// the new tip instead of resurrecting the reverted tail on its next open. `throughSeq` is the
    /// transcript's new tip (0 ⇒ the whole transcript was cleared). `revertEpoch` is the session's
    /// revert count *after* this revert — receivers record it as their held epoch so the next
    /// reconcile pass knows their truncated copy converged (SessionSummary.revertEpoch).
    case transcriptReverted(SessionID, throughSeq: UInt64, revertEpoch: UInt64?)
    /// A Mac is mid-pairing and needs allow/deny (mesh P4) — fanned out to control-scope clients so
    /// the phone that shared the join code can approve it (`HostMsg.macPairRequested`).
    case macPairRequested(WireMacPairRequest)
    /// The pending Mac-pair was answered/withdrawn — clients dismiss the forwarded prompt.
    case macPairResolved(deviceID: String, approved: Bool)
    /// A batch of mesh casts to fan out (mesh casting) — a local publish, or the freshly
    /// inserted subset of a peer's casts being re-forwarded. Per-connection filtering (client
    /// opt-in, channel scope, echo suppression) happens in `ConnectionHandler.deliver`.
    case casts([WireCast])
    /// The account-level synced settings changed (`SyncedSettings`) — fanned out to every
    /// connected device as `HostMsg.settings` so the mesh converges on one truth (the value
    /// also rides `Welcome.settings` at connect time).
    case settings(SyncedSettings)
    /// This host produced a new Carbon manifest (CARBON_SHARDING §8.2 step 1) — fanned out as
    /// `HostMsg.carbonHeadAdvanced` to clients that advertised `canMirrorCarbon` (Macs and
    /// runners; never phones, D13). Mirrors pull-fetch what they lack; a missed push is healed
    /// by the reconcile pass (heads are state).
    case carbonHeadAdvanced(CarbonHead)
    /// This host — as a lock authority — appended to a project's lock ledger (CARBON_SHARDING
    /// §17.5): fanned out as `HostMsg.carbonLockLedgerAdvanced` to `canMirrorCarbon` clients,
    /// which chain-sync the `lock` stream and re-fold. A missed push heals on reconcile.
    case carbonLockLedgerAdvanced(CarbonHead)
}
