import Foundation
import NucleicProtocol

/// Shared vocabulary between the accepting host (`ConnectionHandler`) and the joining client
/// (`PeerClient`) for the Mac-pairing ceremony.
public enum MeshPairing {
    /// The exact `WireError.message` the host seals when its user declines a Mac pairing. The
    /// joining side matches on it to tell a real decline apart from a pre-session transport
    /// close (which can't be sealed and looks like a generic "handshake closed").
    public static let declinedMessage = "pairing declined on the host"
    /// How long the dialing side waits for a peer to answer the handshake + hello before giving
    /// up on an endpoint, so a silent host (a stale port now owned by another service) can't
    /// wedge the manage loop forever — the only stage `SyncClient` has no deadline for.
    public static let handshakeTimeout: Duration = .seconds(10)
    /// The pairing-side deadline. Longer than `handshakeTimeout` because pairing additionally
    /// waits on the accepting Mac's user to answer the confirm dialog (its own 120 s fail-closed
    /// timeout) — this backstops only a peer that accepts TCP but never speaks the protocol.
    public static let pairTimeout: Duration = .seconds(130)
}

/// Why a Mac↔Mac pairing attempt failed, in words the pairing sheet can show.
public enum PeerPairError: Error, LocalizedError, Equatable {
    /// The pasted text isn't a `nucleic://pair` code.
    case invalidCode
    /// The code names only transports this build can't dial (e.g. relay before P2 deploys).
    case unsupportedTransport
    /// The code carries no dialable connection hint at all.
    case noEndpoints
    /// Every hinted endpoint failed to connect or complete the handshake. A wrong or expired
    /// code fails the Noise handshake *silently* (the host can't seal an error pre-session),
    /// so this is also what an expired pairing window looks like.
    case unreachable
    /// The other Mac rejected the pairing (its user declined, or the build channels clash).
    case declined(String)
    /// The pasted code is this Mac's own pairing code.
    case selfPairing
    /// This Mac's mesh isn't running (remote access is off).
    case meshOffline

    public var errorDescription: String? {
        switch self {
        case .invalidCode:
            "That isn't a Nucleic pairing code."
        case .unsupportedTransport:
            "This pairing code needs a connection method this build doesn't support yet."
        case .noEndpoints:
            "This pairing code has no connection details. Make sure remote access is on for the other Mac."
        case .unreachable:
            "Couldn't reach the other Mac. Check it's reachable and its pairing window is still open."
        case .declined(let reason):
            reason
        case .selfPairing:
            "That's this Mac's own pairing code — paste it on the other Mac instead."
        case .meshOffline:
            "Turn on remote access first."
        }
    }
}

/// The Mac-side mesh dialer (mesh P4): keeps an outbound connection to every paired sibling
/// Mac, using the same platform-neutral `SyncClient` the iPhone uses — this Mac is a host to
/// its phones *and* a `.control`-scope client of its peer Macs, over the one wire protocol.
///
/// Peers live in the same `PairedDeviceStore` the host side pins inbound devices into: a
/// record with a kind that can own sessions plus `canHost` is dialable (the pinned static key
/// is the IK target; `addresses` say where). Pairing is symmetric — `pair(with:)` pins the
/// remote Mac here, and the remote Mac pins this one via the ordinary hello path — so one
/// pasted code makes both directions dialable, which session transfer (P5) relies on.
public actor PeerClient {
    public struct Config: Sendable {
        /// This Mac's device id on peers — its `HostID` (SHA-256 hex of the static key), so
        /// records about this Mac are keyed identically everywhere.
        public var deviceID: String
        public var deviceLabel: String
        public var releaseChannel: ReleaseChannel?
        /// This device's own mesh kind, advertised in the `Hello.deviceKind` it sends when
        /// dialing sibling peers — `.mac` for a Mac, `.cloud` for a runner
        /// (docs/ANTIMATTER_RUNNER.md §3). Defaults to `.mac` so every existing caller is
        /// unchanged; a peer pins this device with the kind it claims here.
        public var selfKind: PeerKind
        /// Reconnect backoff cap. Attempts grow 1.5 s per consecutive failure up to this.
        public var maxReconnectDelay: Duration
        /// How long a peer with no known addresses waits before re-checking the store.
        public var idleRecheckDelay: Duration

        public init(
            deviceID: String, deviceLabel: String, releaseChannel: ReleaseChannel? = nil,
            selfKind: PeerKind = .mac,
            maxReconnectDelay: Duration = .seconds(30),
            idleRecheckDelay: Duration = .seconds(30)
        ) {
            self.deviceID = deviceID
            self.deviceLabel = deviceLabel
            self.releaseChannel = releaseChannel
            self.selfKind = selfKind
            self.maxReconnectDelay = maxReconnectDelay
            self.idleRecheckDelay = idleRecheckDelay
        }
    }

    /// One peer Mac as the UI sees it: the stored record plus live connection truth.
    public struct PeerPresence: Sendable, Equatable, Identifiable {
        public var id: String { deviceID }
        public let deviceID: String
        public var label: String
        public var kind: PeerKind
        public var connected: Bool
        /// Which method the live connection went over (nil while disconnected).
        public var transport: SyncTransportHint?
        /// What the peer granted us in its welcome (nil until first connect).
        public var grantedScope: DeviceScope?
        /// The peer's own mesh peer list, when it advertises `canListPeers`.
        public var remotePeers: [PeerSummary]
        /// The peer Mac's live session list (mesh session sync): its sidebar's sessions, mirrored
        /// here so this Mac can render the union. Seeded from the peer's `.sessionList` and kept
        /// current by its `.sessionUpdated` broadcasts; cleared the moment the connection drops, so
        /// a Mac that leaves the mesh takes its sessions with it.
        public var sessions: [WireSessionSummary]
        /// The peer Mac's dashboard projection (mesh session sync) — its Home counts / projects /
        /// to-dos / activity, so this Mac can fold them into a union "stats" view. `nil` until the
        /// peer's `.dashboard` arrives; cleared on disconnect.
        public var dashboard: DashboardSnapshot?
        /// Whether this peer can receive a session transfer (mesh P5) — the "Move to <Mac>"
        /// picker filters on it. Learned from the peer's welcome capabilities.
        public var canReceiveSessionTransfer: Bool
        public var lastError: String?

        public init(
            deviceID: String, label: String, kind: PeerKind = .mac,
            connected: Bool = false, transport: SyncTransportHint? = nil,
            grantedScope: DeviceScope? = nil, remotePeers: [PeerSummary] = [],
            sessions: [WireSessionSummary] = [], dashboard: DashboardSnapshot? = nil,
            canReceiveSessionTransfer: Bool = false,
            lastError: String? = nil
        ) {
            self.deviceID = deviceID
            self.label = label
            self.kind = kind
            self.connected = connected
            self.transport = transport
            self.grantedScope = grantedScope
            self.remotePeers = remotePeers
            self.sessions = sessions
            self.dashboard = dashboard
            self.canReceiveSessionTransfer = canReceiveSessionTransfer
            self.lastError = lastError
        }
    }

    /// One update in an open remote session's live stream (mesh session sync) — the wire events a
    /// viewer needs to render + interact with a peer Mac's chat, as they arrive over its connection.
    public enum RemoteSessionUpdate: Sendable {
        /// The initial tail-window snapshot (transcript + pending approvals) on subscribe.
        case snapshot(SessionSnapshot)
        /// Newer transcript events.
        case events([AgentEvent])
        /// A new approval request in this session.
        case approvalRequested(ApprovalRequest)
        /// An approval was resolved (anywhere).
        case approvalResolved(ApprovalResolved)
        /// The owner's current collapsed Bash summary lines for this session (mesh session sync) —
        /// the viewer drops each onto its matching card, resolving the loading placeholder.
        case toolSummaries([ToolSummaryLine])
        /// The peer Mac dropped — the open remote session is no longer live.
        case disconnected
    }

    /// One reply in a full-transcript fetch's stream (mesh sync). The coordinator drains this to
    /// write a mirror: `chunk`s carry events, `complete` is terminal-success (with the tip seq
    /// and, on a full fetch, the header), `unavailable`/`disconnected` are terminal-failure.
    public enum TranscriptFetchUpdate: Sendable {
        case chunk(TranscriptChunk)
        case complete(TranscriptFetchComplete)
        case unavailable(TranscriptUnavailable)
        case disconnected
    }

    private let identity: DeviceIdentity
    private let store: any PairedDeviceStore
    private let dialer: any PeerDialer
    /// Bonjour rediscovery (mesh LAN reconnect): resolves a peer's *current* LAN endpoint by its
    /// static-key fingerprint, so `manage` can prefer it over a stored hint gone stale on relaunch.
    /// Nil in tests / when LAN is off → the loop falls back to stored addresses only.
    private let discovery: (any PeerLANBrowsing)?
    private let config: Config

    private var running = false
    private var localAddresses: PeerAddresses?
    private var loops: [String: Task<Void, Never>] = [:]
    private var liveClients: [String: SyncClient] = [:]
    private var peerCapabilities: [String: WireCapabilities] = [:]
    private var presence: [String: PeerPresence] = [:]
    private var emit: AsyncStream<[PeerPresence]>.Continuation?
    /// Active session-transfer reply sinks (mesh P5), keyed by destination deviceID. A transfer
    /// coordinator drives a `PeerClientTransferChannel` whose replies flow through here.
    private var transferSinks: [String: AsyncStream<TransferReply>.Continuation] = [:]
    /// The one open remote-session subscription (mesh session sync): the app opens at most one peer
    /// session at a time, streaming its transcript/approvals here to render + interact with it.
    private var openSessionSub: (deviceID: String, sessionID: SessionID,
                                 continuation: AsyncStream<RemoteSessionUpdate>.Continuation)?
    /// In-flight full-transcript fetches (mesh sync), keyed by `requestID` — the coordinator runs
    /// several concurrently (one per session it's backfilling), and a holder could even serve the
    /// same session to two callers at different cursors, so replies are correlated by request id,
    /// not by session. Each entry remembers its `deviceID` so a peer's disconnect can fail all of
    /// its open fetches at once.
    private var fetchSinks: [String: (deviceID: String,
                                      continuation: AsyncStream<TranscriptFetchUpdate>.Continuation)] = [:]
    /// Where a roster a peer Mac gossips is merged (mesh "join"). Set by the app to route into its
    /// single merge path (`AppStore.mergeMeshRoster`), which mutates the shared store, reconciles,
    /// and re-gossips. Nil in tests/minimal hosts → a received roster is dropped.
    private var mergeRoster: (@Sendable (MeshRosterPush, String) async -> Void)?

    public init(
        identity: DeviceIdentity, store: any PairedDeviceStore,
        dialer: any PeerDialer, config: Config,
        discovery: (any PeerLANBrowsing)? = nil
    ) {
        self.identity = identity
        self.store = store
        self.dialer = dialer
        self.discovery = discovery
        self.config = config
    }

    // MARK: - Lifecycle

    /// Start dialing every stored peer Mac; the stream publishes the full presence list on
    /// every change (the app mirrors it into observable state).
    public func start() -> AsyncStream<[PeerPresence]> {
        running = true
        let stream = AsyncStream<[PeerPresence]> { continuation in
            self.emit = continuation
        }
        Task { await self.reconcile() }
        return stream
    }

    public func stop() {
        running = false
        for task in loops.values { task.cancel() }
        loops.removeAll()
        for client in liveClients.values {
            Task { await client.disconnect() }
        }
        liveClients.removeAll()
        peerCapabilities.removeAll()
        for sink in transferSinks.values { sink.yield(.disconnected("mesh stopped")); sink.finish() }
        transferSinks.removeAll()
        for sink in fetchSinks.values { sink.continuation.yield(.disconnected); sink.continuation.finish() }
        fetchSinks.removeAll()
        openSessionSub?.continuation.yield(.disconnected)
        openSessionSub?.continuation.finish()
        openSessionSub = nil
        for id in presence.keys {
            presence[id]?.connected = false
            presence[id]?.transport = nil
        }
        publish()
        emit?.finish()
        emit = nil
    }

    /// Current presence list (pull companion to the `start()` stream).
    public func presences() -> [PeerPresence] { sortedPresence() }

    /// Route rosters gossiped by peer Macs into the app's single merge path (mesh "join").
    public func setMeshRosterHandler(_ handler: @escaping @Sendable (MeshRosterPush, String) async -> Void) {
        mergeRoster = handler
    }

    /// Push a fresh roster to every connected peer Mac that speaks roster gossip (mesh "join").
    /// Called by the app when membership changed so still-connected peers converge immediately.
    public func pushRoster(_ push: MeshRosterPush) async {
        for (deviceID, client) in liveClients
        where peerCapabilities[deviceID]?.canSyncRoster == true {
            await client.send(.meshRoster(push))
        }
    }

    /// This Mac's current group view (live members + tombstones) built from the shared store, for
    /// gossiping to a peer on connect.
    private func localMeshRoster() async -> MeshRosterPush {
        MeshRosterPush(
            members: await store.all().map(MeshMember.init(pinned:)),
            tombstones: await store.tombstones())
    }

    /// This Mac's own dialable addresses — sent in every hello, and pushed to connected peers
    /// that accept `addressUpdate` when they change (e.g. the LAN listener moved ports).
    public func setLocalAddresses(_ addresses: PeerAddresses?) {
        localAddresses = addresses
        guard let addresses else { return }
        for (deviceID, client) in liveClients
        where peerCapabilities[deviceID]?.canUpdateAddresses == true {
            Task { await client.send(.addressUpdate(addresses)) }
        }
    }

    /// React to a network path change (Wi-Fi ⇄ cellular ⇄ wired) by dropping any live connection
    /// that can't survive it, so its `manage` loop redials at once over a still-valid path instead
    /// of blocking on a half-open socket. This is the missing proactive signal behind "a Mac stays
    /// stuck on LAN after leaving Wi-Fi": a dead LAN `NWConnection` typically only goes `.waiting`
    /// (never `.failed`), so its `FrameChannel` stream never ends and `consume` waits forever —
    /// TCP keepalive is the sole backstop, and up to a minute away.
    ///
    /// A LAN (or relay) connection is pinned to the interface it opened on, so leaving that network
    /// makes it a guaranteed stall — drop it. A tailnet connection rides tsnet's userspace WireGuard,
    /// which re-homes onto the new interface itself, so it's left intact (dropping a working tunnel
    /// would only cost a needless reconnect). Disconnecting ends the channel's stream, which returns
    /// `consume` and lets `manage` rebuild its candidate list — LAN first, then the tailnet fallback.
    public func networkPathChanged() async {
        for (deviceID, client) in liveClients {
            switch presence[deviceID]?.transport {
            case .lan, .relay:
                await client.disconnect()
            case .tailnet, .none:
                break
            }
        }
    }

    // MARK: - Session transfer channel (mesh P5)

    /// Whether a peer is connected and advertised that it can receive a session transfer.
    public func canTransfer(to deviceID: String) -> Bool {
        (presence[deviceID]?.connected ?? false) && (presence[deviceID]?.canReceiveSessionTransfer ?? false)
    }

    /// Open a transfer channel over the live connection to `deviceID` (mesh P5). Returns nil if
    /// that peer isn't connected. The returned channel sends transfer verbs over the peer's
    /// `SyncClient` and surfaces its replies; `closeTransferChannel` when the transfer ends.
    public func openTransferChannel(to deviceID: String) -> PeerClientTransferChannel? {
        guard liveClients[deviceID] != nil else { return nil }
        transferSinks[deviceID]?.finish()  // supersede any stale sink
        let (stream, continuation) = AsyncStream<TransferReply>.makeStream()
        transferSinks[deviceID] = continuation
        return PeerClientTransferChannel(peerClient: self, deviceID: deviceID, stream: stream)
    }

    /// Send one transfer verb to a peer's live client (called by `PeerClientTransferChannel`).
    func sendTransfer(_ msg: ClientMsg, to deviceID: String) async {
        guard let client = liveClients[deviceID] else {
            transferSinks[deviceID]?.yield(.disconnected("the other Mac is offline"))
            return
        }
        await client.send(msg)
    }

    /// Tear down a peer's transfer channel when the transfer completes or aborts.
    public func closeTransferChannel(to deviceID: String) {
        transferSinks[deviceID]?.finish()
        transferSinks[deviceID] = nil
    }

    // MARK: - Open remote session (mesh session sync)

    /// Subscribe to a peer Mac's session and stream its transcript/approvals here so the app can
    /// render + interact with it. Supersedes any previously open remote session (one at a time).
    /// Returns an empty, immediately-finished stream if the peer isn't connected.
    /// `sinceSeq` resumes the subscription from a cursor: nil ⇒ the host's cold 200-event tail;
    /// a value (incl. 0) ⇒ the host replays every event after it (full-transcript mesh sync), so
    /// a viewer seeded from a local mirror fills only the gap — no cap, no hole.
    public func openRemoteSession(
        _ sessionID: SessionID, on deviceID: String, sinceSeq: UInt64? = nil
    ) -> AsyncStream<RemoteSessionUpdate> {
        openSessionSub?.continuation.finish()
        let (stream, continuation) = AsyncStream<RemoteSessionUpdate>.makeStream()
        guard let client = liveClients[deviceID] else {
            continuation.finish()
            openSessionSub = nil
            return stream
        }
        openSessionSub = (deviceID, sessionID, continuation)
        Task { await client.send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: sinceSeq, verbosity: .full))) }
        return stream
    }

    /// Fetch a session's full transcript after `afterSeq` from a peer (full-transcript mesh sync),
    /// streaming the holder's `transcriptChunk`/`complete`/`unavailable` replies here. `afterSeq
    /// == 0` pulls the whole transcript (with header); a positive cursor tops up an existing
    /// mirror. Returns an immediately-`disconnected` stream if the peer is offline or predates the
    /// `canSyncTranscripts` capability. Unlike `openRemoteSession`, several of these can run at
    /// once (keyed by request id), and none disturb the one open remote-session view.
    public func fetchTranscript(
        _ sessionID: SessionID, afterSeq: UInt64, from deviceID: String
    ) -> AsyncStream<TranscriptFetchUpdate> {
        let (stream, continuation) = AsyncStream<TranscriptFetchUpdate>.makeStream()
        guard let client = liveClients[deviceID],
              peerCapabilities[deviceID]?.canSyncTranscripts == true else {
            continuation.yield(.disconnected)
            continuation.finish()
            return stream
        }
        let requestID = UUID().uuidString
        fetchSinks[requestID] = (deviceID, continuation)
        Task {
            await client.send(.fetchTranscript(
                TranscriptFetch(requestID: requestID, sessionID: sessionID, afterSeq: afterSeq)))
        }
        return stream
    }

    /// Finish and forget a fetch sink once its stream reaches a terminal reply (or the caller
    /// stops draining) — called from the reply routing below.
    private func finishFetch(_ requestID: String) {
        fetchSinks[requestID]?.continuation.finish()
        fetchSinks[requestID] = nil
    }

    /// Stop viewing the open remote session: unsubscribe on the peer + end the stream.
    public func closeRemoteSession() {
        if let sub = openSessionSub, let client = liveClients[sub.deviceID] {
            Task { await client.send(.unsubscribe(sub.sessionID)) }
        }
        openSessionSub?.continuation.finish()
        openSessionSub = nil
    }

    /// Send user input to a session on a peer Mac (its owner runs it).
    public func sendRemoteInput(_ input: AgentInput, to sessionID: SessionID, on deviceID: String) async {
        await liveClients[deviceID]?.send(.sendInput(sessionID, input))
    }

    /// Answer an approval in a peer Mac's session.
    public func respondRemoteApproval(_ id: ApprovalID, _ decision: Decision, on deviceID: String) async {
        await liveClients[deviceID]?.send(.approvalRespond(id, decision))
    }

    /// Send any control-scope verb to a peer Mac (mesh session parity): favorite / archive /
    /// delete / interrupt / rename / model / effort / start-chat — the same wire verbs a phone
    /// uses, and mesh Macs grant each other control scope. The owner's `sessionUpdated` /
    /// `sessionList` / `dashboard` broadcasts carry the result back into this Mac's mirrored
    /// presence. Returns false when the peer isn't connected, so the caller can surface the
    /// failure instead of pretending the verb landed.
    @discardableResult
    public func sendCommand(_ msg: ClientMsg, to deviceID: String) async -> Bool {
        guard let client = liveClients[deviceID] else { return false }
        await client.send(msg)
        return true
    }

    /// Re-read the store and start/stop per-peer dial loops to match it. Call after anything
    /// that changes the peer table (pairing, revoking, an inbound mac pairing to our host).
    public func reconcile() async {
        guard running else { return }
        let records = await store.all()
        var dialable: Set<String> = []
        for record in records where record.kind.canOwnSessions && record.capabilities.canHost {
            guard record.deviceID != config.deviceID else { continue }  // never dial ourselves
            dialable.insert(record.deviceID)
            if presence[record.deviceID] == nil {
                presence[record.deviceID] = PeerPresence(
                    deviceID: record.deviceID, label: record.label, kind: record.kind)
            }
            if loops[record.deviceID] == nil {
                let id = record.deviceID
                loops[id] = Task { await self.manage(deviceID: id) }
            }
        }
        for (id, task) in loops where !dialable.contains(id) {
            task.cancel()
            loops[id] = nil
            presence[id] = nil
        }
        publish()
    }

    // MARK: - Pairing (the joining Mac's side of "Pair another Mac…")

    /// Pair this Mac to a sibling by its pasted `nucleic://pair?d=…` code (the same payload
    /// the QR encodes). Dials the code's hints, runs the XXpsk0 handshake, waits for the
    /// accepting Mac's user to confirm, pins the peer, and starts managing the connection.
    @discardableResult
    public func pair(with pastedCode: String) async throws -> PeerPresence {
        let trimmed = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines)
        guard let payload = try? PairingPayload(qrString: trimmed) else {
            throw PeerPairError.invalidCode
        }
        guard payload.hostStaticKey != identity.staticPublicKey else {
            throw PeerPairError.selfPairing
        }
        let endpoints = Self.endpoints(fromPayload: payload)
        guard !endpoints.isEmpty else {
            // Distinguish "no hints at all" from "hints we can't dial yet" (relay-only).
            if payload.transportHint == nil || payload.relayRoomID != nil {
                throw PeerPairError.unsupportedTransport
            }
            throw PeerPairError.noEndpoints
        }

        // A `.wireError` before a welcome is terminal for this whole pairing, not a per-endpoint
        // failure: the host either sealed a rejection (its user declined / channels clash) or the
        // handshake died pre-session. Redialing the next hint just re-runs the ceremony — and for
        // a decline, re-prompts the accepting user. So we stop after the first one.
        var terminalError: WireError?
        dialLoop: for endpoint in endpoints {
            guard let channel = try? await dialer.dial(endpoint) else { continue }
            let client = SyncClient(
                channel: channel, identity: identity,
                hostStaticKey: payload.hostStaticKey,
                mode: .pair(secret: payload.pairingSecret),
                deviceID: config.deviceID, deviceLabel: config.deviceLabel,
                scopeClaim: .control,
                releaseChannel: config.releaseChannel,
                deviceKind: config.selfKind.rawValue,
                clientCaps: WireClientCapabilities(
                    mesh: 1, canHost: true, canRunAgents: true, canSyncRoster: true),
                addresses: localAddresses)
            let events = await client.start()
            // Bound the handshake+confirm wait (a silent host, or the sheet cancelled while the
            // `for await` is parked between events) — `disconnect` ends the stream so this loop
            // observes the cancellation / falls through, instead of hanging headless.
            let deadline = Task { [weak client] in
                try? await Task.sleep(for: MeshPairing.pairTimeout)
                if !Task.isCancelled { await client?.disconnect() }
            }
            defer { deadline.cancel() }
            for await event in events {
                if Task.isCancelled {
                    await client.disconnect()
                    throw CancellationError()
                }
                switch event {
                case .ready(let welcome):
                    let record = await pin(welcome: welcome, client: client, payload: payload)
                    // Drop the pairing socket and let the manage loop reconnect over IK —
                    // one code path for live connections, and the pin is validated end-to-end
                    // right away.
                    await client.disconnect()
                    await reconcile()
                    return presence[record.deviceID]
                        ?? PeerPresence(deviceID: record.deviceID, label: record.label)
                case .wireError(let wire):
                    terminalError = wire
                    await client.disconnect()
                    break dialLoop
                default:
                    break
                }
            }
            // Stream ended without a welcome or a wireError — a bare transport drop; try the
            // next hinted endpoint.
        }
        if let terminalError {
            // A sealed host decline / channel clash is a definitive "no"; a client-synthesized
            // pre-session close ("handshake closed") means the window is gone or the peer is
            // unreachable — surface each as the fitting, actionable error.
            if terminalError.message == MeshPairing.declinedMessage
                || terminalError.code == .channelMismatch {
                throw PeerPairError.declined(terminalError.message)
            }
            throw PeerPairError.unreachable
        }
        throw PeerPairError.unreachable
    }

    /// Forget a peer Mac: stop dialing it and drop its pin. (Its record of *us* survives on
    /// the other side; its dials will fail our host's pin check once we also revoke there.)
    public func unpair(_ deviceID: String) async {
        await store.remove(deviceID)
        await reconcile()
    }

    /// Persist the freshly-authenticated peer from a successful pairing welcome.
    private func pin(
        welcome: Welcome, client: SyncClient, payload: PairingPayload
    ) async -> PairedDevice {
        let pinnedKey = await client.hostKey() ?? payload.hostStaticKey
        let deviceID = DeviceIdentity.hostID(ofStaticKey: pinnedKey)
        let now = Date()
        var addresses = welcome.addresses ?? Self.addresses(fromPayload: payload)
        if addresses.isEmpty { addresses = Self.addresses(fromPayload: payload) }
        // `scope` is what *we* grant the peer when it dials us back (mutual control between
        // Macs); what it granted us rides in presence, not the store.
        let record = PairedDevice(
            deviceID: deviceID, label: welcome.host.hostName, staticPublicKey: pinnedKey,
            scope: .control, pairedAt: now, lastSeenAt: now,
            // Pin the kind the host declared (`.cloud` for a runner); an older host that omits
            // `hostKind` decodes as `.mac`, preserving today's behavior.
            kind: welcome.kind, capabilities: .hostAndAgents, addresses: addresses)
        await store.upsert(record)
        peerCapabilities[deviceID] = welcome.capabilities
        var current = presence[deviceID] ?? PeerPresence(deviceID: deviceID, label: record.label)
        current.label = record.label
        current.grantedScope = welcome.grantedScope
        presence[deviceID] = current
        publish()
        return record
    }

    // MARK: - Per-peer connection management

    /// One peer's dial-forever loop: serial candidates (LAN, then tailnet) with a connect
    /// timeout each, then consume the session until it drops, then back off and redial. The
    /// same serial-with-reorder-later shape the iPhone uses — no racing (cut from v1).
    private func manage(deviceID: String) async {
        var consecutiveFailures = 0
        while running, !Task.isCancelled {
            guard let record = await store.device(deviceID),
                  record.kind.canOwnSessions
            else { return }

            var endpoints = Self.endpoints(for: record.addresses)
            // Bonjour rediscovery first: if the peer is advertising on this LAN right now, its live
            // endpoint beats any stored `lanHint`, which goes stale the moment the peer relaunches
            // onto a new OS-assigned port. Matched on the peer's static-key fingerprint.
            if let discovery {
                let fingerprint = DeviceIdentity.fingerprint(ofStaticKey: record.staticPublicKey)
                if let bonjour = await discovery.endpoint(forFingerprint: fingerprint) {
                    endpoints.insert(bonjour, at: 0)
                }
            }
            // The relay room is a stable, port-independent last resort: try it after LAN + tailnet
            // so a sibling whose LAN port churned on relaunch still reconnects (and, once up, its
            // `addressUpdate` refreshes the LAN hint for future direct dials).
            if let token = record.relayMembershipToken {
                endpoints.append(.relay(baseURLString: record.relayURL, membershipToken: token))
            }
            guard !endpoints.isEmpty else {
                setPresence(deviceID) {
                    $0.connected = false
                    $0.transport = nil
                    $0.lastError = "No known addresses yet — connect it to this Mac once, or re-pair."
                }
                try? await Task.sleep(for: config.idleRecheckDelay)
                continue
            }

            var sawSession = false
            for endpoint in endpoints {
                guard let channel = try? await dialer.dial(endpoint) else { continue }
                let client = SyncClient(
                    channel: channel, identity: identity,
                    hostStaticKey: record.staticPublicKey,
                    mode: .reconnect,
                    deviceID: config.deviceID, deviceLabel: config.deviceLabel,
                    scopeClaim: .control,
                    releaseChannel: config.releaseChannel,
                    deviceKind: config.selfKind.rawValue,
                    clientCaps: WireClientCapabilities(
                    mesh: 1, canHost: true, canRunAgents: true, canSyncRoster: true),
                    addresses: localAddresses)
                if await consume(client: client, deviceID: deviceID, transport: endpoint.transport) {
                    sawSession = true
                    break  // session lived and died; restart the candidate list fresh
                }
            }

            // Check cancellation *before* touching presence — a cancelled loop (unpair,
            // stop) must not resurrect the entry reconcile() just removed.
            if Task.isCancelled || !running { return }
            setPresence(deviceID) {
                $0.connected = false
                $0.transport = nil
                $0.sessions = []       // a disconnected peer's sessions leave the mesh view
                $0.dashboard = nil     // …and its stats drop out of the union
            }
            consecutiveFailures = sawSession ? 0 : consecutiveFailures + 1
            let delay = min(
                Duration.seconds(Double(max(consecutiveFailures, 1)) * 1.5),
                config.maxReconnectDelay)
            try? await Task.sleep(for: delay)
        }
    }

    /// Run one established channel to completion. Returns whether a welcome ever arrived —
    /// a false return means this endpoint never yielded a session (dead port, revoked pin).
    private func consume(
        client: SyncClient, deviceID: String, transport: SyncTransportHint
    ) async -> Bool {
        var becameReady = false
        let events = await client.start()
        // `SyncClient` has no handshake/hello deadline — a connected-but-silent host would block
        // `for await` forever. Disconnect if no welcome lands in time; the stream then ends and
        // the manage loop backs off and redials (or tries the next candidate).
        let deadline = Task { [weak client] in
            try? await Task.sleep(for: MeshPairing.handshakeTimeout)
            if !Task.isCancelled { await client?.disconnect() }
        }
        for await event in events {
            if Task.isCancelled { await client.disconnect() }
            switch event {
            case .ready(let welcome):
                deadline.cancel()
                becameReady = true
                liveClients[deviceID] = client
                peerCapabilities[deviceID] = welcome.capabilities
                await refreshRecord(deviceID: deviceID, welcome: welcome)
                setPresence(deviceID) {
                    $0.label = welcome.host.hostName
                    $0.connected = true
                    $0.transport = transport
                    $0.grantedScope = welcome.grantedScope
                    $0.canReceiveSessionTransfer = welcome.capabilities.canReceiveSessionTransfer
                    $0.lastError = nil
                }
                // Re-advertise where *we* can be dialed now the session is live: our hello may
                // have carried a stale snapshot (the LAN port binds after startup, so a just-
                // relaunched Mac dials out before it knows its own new port). This is the peer's
                // authoritative source for redialing us, so a fresh address here is what lets it
                // reconnect to us after we restart onto a new ephemeral port.
                if welcome.capabilities.canUpdateAddresses, let addresses = localAddresses {
                    await client.send(.addressUpdate(addresses))
                }
                if welcome.capabilities.canListPeers {
                    await client.send(.listPeers)
                }
                // Mesh "join": push our roster so the peer (and, transitively, everyone) learns the
                // members we know. The peer merges it and pushes its own back (handled below).
                if welcome.capabilities.canSyncRoster {
                    await client.send(.meshRoster(await localMeshRoster()))
                }
                // Mesh session sync: pull the peer's session list + dashboard. Their `.sessionUpdated`
                // / `.dashboard` broadcasts then keep both live (the host fans them out to every
                // connected client, incl. peers).
                await client.send(.listSessions)
                await client.send(.listDashboard)
            case .relayMembership(let membership):
                // The peer issued us admission to its relay room — persist it so a later reconnect
                // can dial that stable room when the peer's LAN port has gone stale (mesh healing).
                await persistRelayMembership(membership, for: deviceID)
            case .peerList(let peers):
                setPresence(deviceID) { $0.remotePeers = peers }
            case .meshRoster(let push):
                // The peer Mac gossiped its group view (mesh "join") — route it into the app's
                // single merge path, which pins new mac members, applies revocations, reconciles
                // (so we start dialing newcomers), and re-gossips onward.
                await mergeRoster?(push, deviceID)
            case .sessionList(let list):
                setPresence(deviceID) { $0.sessions = list }
            case .sessionUpdated(let summary):
                setPresence(deviceID) { p in
                    if let i = p.sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) {
                        p.sessions[i] = summary
                    } else {
                        p.sessions.append(summary)
                    }
                }
            case .dashboard(let snapshot):
                setPresence(deviceID) { $0.dashboard = snapshot }
            // Open remote session (mesh session sync): stream the viewed peer session's transcript +
            // approvals to the app. Only when this peer is the one being viewed and the id matches.
            case .snapshot(let snap):
                if let sub = openSessionSub, sub.deviceID == deviceID, sub.sessionID == snap.summary.sessionID {
                    sub.continuation.yield(.snapshot(snap))
                }
            case .events(let batch):
                if let sub = openSessionSub, sub.deviceID == deviceID, sub.sessionID == batch.sessionID {
                    sub.continuation.yield(.events(batch.events))
                }
            case .approvalRequested(let req):
                if let sub = openSessionSub, sub.deviceID == deviceID, sub.sessionID == req.sessionID {
                    sub.continuation.yield(.approvalRequested(req))
                }
            case .approvalResolved(let res):
                if let sub = openSessionSub, sub.deviceID == deviceID {
                    sub.continuation.yield(.approvalResolved(res))
                }
            case .toolSummaries(let batch):
                if let sub = openSessionSub, sub.deviceID == deviceID, sub.sessionID == batch.sessionID {
                    sub.continuation.yield(.toolSummaries(batch.summaries))
                }
            // Session transfer replies (mesh P5) — route to the active transfer channel for this
            // peer, if one is open. Dropped otherwise (no transfer in flight).
            case .transferAccept(let a): transferSinks[deviceID]?.yield(.accept(a))
            case .transferReject(let r): transferSinks[deviceID]?.yield(.reject(r))
            case .transferReady(let id): transferSinks[deviceID]?.yield(.ready(id))
            case .transferCommitted(let id): transferSinks[deviceID]?.yield(.committed(id))
            case .transferChunkAck(let ack): transferSinks[deviceID]?.yield(.chunkAck(ack))
            // Full-transcript fetch replies (mesh sync) — route by requestID to the matching
            // sink. `complete`/`unavailable` are terminal, so finish the sink after yielding.
            case .transcriptChunk(let chunk):
                fetchSinks[chunk.requestID]?.continuation.yield(.chunk(chunk))
            case .transcriptFetchComplete(let done):
                fetchSinks[done.requestID]?.continuation.yield(.complete(done))
                finishFetch(done.requestID)
            case .transcriptUnavailable(let un):
                fetchSinks[un.requestID]?.continuation.yield(.unavailable(un))
                finishFetch(un.requestID)
            case .wireError(let wire):
                // Pre-session failures ("handshake closed" from a one-sided revoke or a host
                // mid-restart) arrive here too, not just as `.failed` — gate them the same way
                // so a redialing-but-offline peer reads as a quiet gray dot, not a cryptic
                // orange error. Only a fault on a *live* session is worth surfacing.
                if becameReady {
                    setPresence(deviceID) { $0.lastError = wire.message }
                }
            case .failed(let reason):
                // Routine dial fallback shouldn't read as an error; only surface post-ready
                // failures (a live session dying mid-flight).
                if becameReady {
                    setPresence(deviceID) { $0.lastError = reason }
                }
            case .closed:
                break
            default:
                break
            }
        }
        deadline.cancel()
        // The event stream ends on disconnect *or* on our own task's cancellation — make
        // sure the channel is really down in the second case.
        await client.disconnect()
        if liveClients[deviceID] === client { liveClients[deviceID] = nil }
        // A transfer in flight over this connection can't survive its drop (mesh P5) — tell the
        // coordinator so it fails cleanly instead of hanging on the next reply.
        transferSinks[deviceID]?.yield(.disconnected("connection to the other Mac dropped"))
        // Any in-flight transcript fetches over this connection (mesh sync) die with it — fail
        // them so the coordinator moves on to the next holder instead of hanging.
        for (requestID, sink) in fetchSinks where sink.deviceID == deviceID {
            sink.continuation.yield(.disconnected)
            sink.continuation.finish()
            fetchSinks[requestID] = nil
        }
        // An open remote session on this peer likewise dies with the connection.
        if let sub = openSessionSub, sub.deviceID == deviceID {
            sub.continuation.yield(.disconnected)
            sub.continuation.finish()
            openSessionSub = nil
        }
        return becameReady
    }

    /// Persist a relay membership a peer pushed (`HostMsg.relayMembership`) so a later reconnect can
    /// dial its stable relay room. Idempotent — skips the store write when the token is unchanged.
    private func persistRelayMembership(_ membership: WireRelayMembership, for deviceID: String) async {
        guard var record = await store.device(deviceID) else { return }
        guard record.relayMembershipToken != membership.token || record.relayURL != membership.url
        else { return }
        record.relayMembershipToken = membership.token
        record.relayURL = membership.url
        await store.upsert(record)
    }

    /// Refresh the stored record from a live welcome: newest name + addresses win.
    private func refreshRecord(deviceID: String, welcome: Welcome) async {
        guard var record = await store.device(deviceID) else { return }
        record.label = welcome.host.hostName
        record.lastSeenAt = Date()
        if let fresh = welcome.addresses, !fresh.isEmpty {
            // Merge per-field, don't replace: a welcome sent while the peer's tailnet node is still
            // coming up carries a LAN-only snapshot, and a blob-replace would wipe the tailnet IP we
            // need to reach it once we're off its LAN. See `PeerAddresses.merged(with:)`.
            record.addresses = record.addresses?.merged(with: fresh) ?? fresh
        }
        await store.upsert(record)
    }

    // MARK: - Presence plumbing

    private func setPresence(_ deviceID: String, _ mutate: (inout PeerPresence) -> Void) {
        // Never resurrect a peer whose dial loop is gone. After unpair/revoke, `reconcile`
        // cancels the manage task and drops `loops[deviceID]` + `presence[deviceID]` — but a
        // `.ready`/`.wireError` event the cancelled task's `for await` already dequeued still
        // runs and would re-create a permanent "connected" zombie via the `?? PeerPresence(…)`
        // fallback. The loop's presence is authoritative only while its entry exists.
        guard loops[deviceID] != nil else { return }
        var entry = presence[deviceID] ?? PeerPresence(deviceID: deviceID, label: deviceID)
        mutate(&entry)
        presence[deviceID] = entry
        publish()
    }

    private func sortedPresence() -> [PeerPresence] {
        presence.values.sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending }
    }

    private func publish() {
        emit?.yield(sortedPresence())
    }

    // MARK: - Endpoint derivation

    /// Dial candidates from a stored peer record: LAN first (cheap, same network), then
    /// tailnet (fixed sync port — the IP alone is enough).
    static func endpoints(for addresses: PeerAddresses?) -> [PeerEndpoint] {
        guard let addresses else { return [] }
        var result: [PeerEndpoint] = []
        if let lan = addresses.lanHint, let endpoint = parseHostPort(lan) {
            result.append(.lan(host: endpoint.host, port: endpoint.port))
        }
        if let tailnet = addresses.tailnet, !tailnet.isEmpty {
            result.append(.tailnet(host: tailnet, port: SyncTransportSetting.tailnetPort))
        }
        return result
    }

    /// Dial candidates from a pairing payload's explicit hints (ports included).
    static func endpoints(fromPayload payload: PairingPayload) -> [PeerEndpoint] {
        var result: [PeerEndpoint] = []
        if let host = payload.lanHost, let port = payload.lanPort {
            result.append(.lan(host: host, port: port))
        }
        if let host = payload.tailnetHost, let port = payload.tailnetPort {
            result.append(.tailnet(host: host, port: port))
        }
        return result
    }

    /// Seed addresses for a fresh pin when the welcome didn't carry any.
    static func addresses(fromPayload payload: PairingPayload) -> PeerAddresses {
        var lanHint: String?
        if let host = payload.lanHost, let port = payload.lanPort {
            lanHint = "\(host):\(port)"
        }
        return PeerAddresses(
            lanHint: lanHint, tailnet: payload.tailnetHost,
            relayRoomID: payload.relayRoomID, updatedAt: Date())
    }

    /// "host:port" → parts. Splits on the *last* colon so a bracketed IPv6 host survives.
    static func parseHostPort(_ hint: String) -> (host: String, port: UInt16)? {
        guard let colon = hint.lastIndex(of: ":"),
              let port = UInt16(hint[hint.index(after: colon)...])
        else { return nil }
        var host = String(hint[..<colon])
        if host.hasPrefix("["), host.hasSuffix("]") {
            host = String(host.dropFirst().dropLast())
        }
        guard !host.isEmpty else { return nil }
        return (host, port)
    }
}

/// A `TransferChannel` bound to the live `SyncClient` `PeerClient` holds for one destination Mac
/// (mesh P5): sends transfer verbs over that client and surfaces its replies. The source
/// coordinator drives it; `PeerClient.closeTransferChannel` ends it.
public struct PeerClientTransferChannel: TransferChannel {
    private let peerClient: PeerClient
    private let deviceID: String
    private let stream: AsyncStream<TransferReply>

    init(peerClient: PeerClient, deviceID: String, stream: AsyncStream<TransferReply>) {
        self.peerClient = peerClient
        self.deviceID = deviceID
        self.stream = stream
    }

    public func send(_ msg: ClientMsg) async { await peerClient.sendTransfer(msg, to: deviceID) }
    public func replies() -> AsyncStream<TransferReply> { stream }
}
