import Foundation
import Testing
import NucleicProtocol

@testable import NucleicCore

/// The unified local/remote sidebar model (mesh session sync): peer Macs' projects and
/// sessions map into the same `ProjectSummary` / `SessionSummary` shapes the local sidebar
/// renders, interleave through the same list/lookup APIs, and dispatch the same verbs — so
/// the interface represents both origins identically, differentiated only by origin fields.
@MainActor
@Suite("Mesh unified sidebar", .serialized, .isolatedContainerSettings)
struct MeshUnifiedSidebarTests {
    private let t0 = Date(timeIntervalSince1970: 1_700_000_000)

    private func makeStore(repo: GitTestRepo) -> AppStore {
        let database = try! GRDBMetadataStore(path: nil)
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        return AppStore(
            database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { _ in
            ScriptedBackend { e, _ in
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
    }

    private func wireSession(
        _ id: String, project: String = "remote-p1", projectName: String = "Remote Project",
        title: String = "remote chat", favorite: Bool = false, archived: Bool = false,
        updatedAt: Date? = nil, lastUserMessageAt: Date? = nil
    ) -> WireSessionSummary {
        WireSessionSummary(
            sessionID: SessionID(rawValue: id), projectID: project, projectName: projectName,
            backend: .claudeCode, status: .running, disposition: nil, title: title,
            branch: "nucleic/x", lastSeq: 3, diffStat: DiffStat(filesChanged: 1, added: 2, removed: 1),
            pendingApprovalCount: 1, favorite: favorite, archived: archived,
            model: "claude-x", effort: "high", auto: true,
            updatedAt: updatedAt ?? t0, lastUserMessageAt: lastUserMessageAt)
    }

    private func peer(
        _ deviceID: String = "peer-A", label: String = "Studio",
        sessions: [WireSessionSummary] = [], projects: [WireProject] = []
    ) -> PeerClient.PeerPresence {
        PeerClient.PeerPresence(
            deviceID: deviceID, label: label, connected: true,
            sessions: sessions,
            dashboard: projects.isEmpty ? nil : DashboardSnapshot(
                counts: DashboardCounts(
                    projects: projects.count, chats: sessions.count, activeChats: 0,
                    messages: 0, activeDays: 0, tokens: 0),
                activity: [], projects: projects, todos: []))
    }

    // MARK: Wire → local summary mapping

    @Test func wireSummaryMapsIntoLocalShapeWithOrigin() {
        let wire = wireSession("s-1", favorite: true, lastUserMessageAt: t0.addingTimeInterval(-30))
        let summary = SessionSummary(wire: wire, hostID: "peer-A")
        // The last-user-message stamp is carried on the wire, so the sidebar orders remote rows
        // on the last turn rather than reshuffling on every bit of agent activity.
        #expect(summary.lastUserMessageAt == wire.lastUserMessageAt)
        #expect(summary.id.rawValue == "s-1")
        #expect(summary.projectID.rawValue == "remote-p1")
        #expect(summary.title == "remote chat")
        #expect(summary.status == .running)
        #expect(summary.favorite)
        #expect(summary.auto)
        #expect(summary.effort == "high")
        #expect(summary.pendingApprovalCount == 1)
        #expect(summary.hostID == "peer-A")
        // Owner-tracked view-state the wire doesn't carry stays quiet, not guessed.
        #expect(!summary.unseenCompletion)
        #expect(!summary.autoShipFailed)
        // Recency comparators fall back to updatedAt so remote rows sort like local ones.
        #expect(summary.createdAt == wire.updatedAt)
        // Row identity is origin-qualified (tombstone/live-twin collision guard).
        #expect(summary.sidebarRowID == "peer-A-s-1")
    }

    // MARK: Unified project list + lookup

    @Test func sidebarProjectsInterleavesLocalAndRemoteByName() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        _ = await store.addProject(name: "bravo", rootPath: repo.root, defaultBranch: "main")

        store.setMeshPeersForTesting([peer(
            sessions: [wireSession("s-1", project: "remote-p1", projectName: "alpha")],
            projects: [
                WireProject(id: ProjectID(rawValue: "remote-p1"), name: "alpha",
                            defaultBranch: "main", sessionCount: 1, activeCount: 1,
                            isNucleicControlled: true),
                // A peer project with no sessions still gets a header, like an empty local one.
                WireProject(id: ProjectID(rawValue: "remote-p2"), name: "zulu",
                            defaultBranch: "main", sessionCount: 0, activeCount: 0),
            ])])

        let projects = store.sidebarProjects
        #expect(projects.map(\.name) == ["alpha", "bravo", "zulu"])
        let alpha = try #require(projects.first { $0.name == "alpha" })
        #expect(alpha.isRemote)
        #expect(alpha.hostID == "peer-A")
        #expect(alpha.hostLabel == "Studio")
        #expect(alpha.isNucleicControlled)   // carried from the wire, not path-derived
        let bravo = try #require(projects.first { $0.name == "bravo" })
        #expect(!bravo.isRemote)

        // Unified lookup resolves both origins.
        #expect(store.projectSummary(alpha.id)?.hostID == "peer-A")
        #expect(store.projectSummary(bravo.id)?.hostID == nil)

        // A disconnected peer takes its projects with it.
        store.setMeshPeersForTesting([])
        #expect(store.sidebarProjects.map(\.name) == ["bravo"])
    }

    @Test func projectHeaderSynthesizedFromSessionsUntilDashboardArrives() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.setMeshPeersForTesting([peer(
            sessions: [wireSession("s-1", project: "remote-p1", projectName: "alpha")])])
        let alpha = try #require(store.sidebarProjects.first { $0.name == "alpha" })
        #expect(alpha.isRemote)
        #expect(!alpha.isNucleicControlled)   // unknown until the dashboard lands
    }

    // MARK: Unified session lists

    @Test func summariesServeRemoteProjectsWithLocalOrdering() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.setMeshPeersForTesting([peer(sessions: [
            wireSession("s-old", title: "old", updatedAt: t0.addingTimeInterval(-100)),
            wireSession("s-new", title: "new", updatedAt: t0),
            wireSession("s-fav", title: "fav", favorite: true, updatedAt: t0.addingTimeInterval(-500)),
            wireSession("s-arch", title: "gone", archived: true),
        ])])

        let project = try #require(store.projectSummary(ProjectID(rawValue: "remote-p1")))
        let live = store.summaries(for: project)
        // Favorites pin first (like local), then recency; archived rows stay out.
        #expect(live.map(\.title) == ["fav", "new", "old"])
        #expect(live.allSatisfy { $0.hostID == "peer-A" })

        let archived = store.archivedSummaries(for: project)
        #expect(archived.map(\.title) == ["gone"])
        #expect(archived.first?.hostID == "peer-A")
    }

    /// Remote rows order on the last *user* message, not on `updatedAt`. `updatedAt` bumps on any
    /// agent activity (output, diff refresh, autoship notes), which reshuffled the list every few
    /// seconds; the last-turn stamp keeps a chat's position steady until the user speaks again.
    @Test func remoteSessionsOrderByLastUserMessageNotActivity() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        // "chatty" has the newest activity but the oldest user message; "quiet" is the reverse.
        // Ordering by activity would put chatty first; ordering by last turn puts quiet first.
        store.setMeshPeersForTesting([peer(sessions: [
            wireSession("s-chatty", title: "chatty",
                        updatedAt: t0, lastUserMessageAt: t0.addingTimeInterval(-500)),
            wireSession("s-quiet", title: "quiet",
                        updatedAt: t0.addingTimeInterval(-400), lastUserMessageAt: t0.addingTimeInterval(-100)),
        ])])

        let project = try #require(store.projectSummary(ProjectID(rawValue: "remote-p1")))
        #expect(store.summaries(for: project).map(\.title) == ["quiet", "chatty"])
    }

    /// Two Macs holding the same project is the normal case, not a collision: its section lists
    /// both Macs' chats, the peer's marked with the owner's label. Asking for the *peer's* copy by
    /// origin still answers with only that peer's rows — the origin-aware overloads stay keyed by
    /// the summary's own origin, which is what routing and selection rely on.
    @Test func sameProjectOnTwoOriginsSharesOneSection() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let localID = try await store.createSession(in: project, title: "local chat", prompt: "")
        store.setMeshPeersForTesting([peer(sessions: [
            wireSession("s-remote", project: project.id.rawValue, projectName: "demo"),
        ])])

        let localSummary = ProjectSummary(project)
        let remoteSummary = ProjectSummary(id: project.id, name: "demo", hostID: "peer-A")
        // One section, both chats — and the peer's row says where it runs.
        let pooled = store.summaries(for: localSummary)
        #expect(Set(pooled.map(\.id)) == [localID, SessionID(rawValue: "s-remote")])
        #expect(pooled.first { $0.id.rawValue == "s-remote" }?.foldedHostLabel == "Studio")
        #expect(pooled.first { $0.id == localID }?.foldedHostLabel == nil)
        // The peer's own copy of the project resolves to just its rows.
        #expect(store.summaries(for: remoteSummary).map(\.title) == ["remote chat"])
        // …and the sidebar shows the project once.
        #expect(store.sidebarProjects.map(\.name) == ["demo"])
    }

    @Test func recentsUnionTheMeshAndPreferLocalTwins() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let localID = try await store.createSession(in: project, title: "local chat", prompt: "")

        store.setMeshPeersForTesting([peer(sessions: [
            wireSession("s-remote", title: "remote chat"),
            // The same id as a local record (a moved session mirrored back): local row wins.
            wireSession(localID.rawValue, title: "shadowed twin"),
        ])])

        let recents = store.recentSummaries()
        #expect(recents.contains { $0.title == "remote chat" && $0.hostID == "peer-A" })
        #expect(recents.contains { $0.id == localID && $0.hostID == nil })
        #expect(!recents.contains { $0.title == "shadowed twin" })
        #expect(store.recentCount(now: t0.addingTimeInterval(60)) == 2)
    }

    // MARK: Selection routing

    @Test func openSessionRoutesRemoteAndLocalRecordsWin() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let localID = try await store.createSession(in: project, title: "mine", prompt: "")

        store.setMeshPeersForTesting([peer(sessions: [wireSession("s-remote")])])

        // The one selection path: assigning the id routes remotely on its own.
        store.openSessionID = SessionID(rawValue: "s-remote")
        #expect(store.openHostID == "peer-A")
        #expect(store.openSession?.title == "remote chat")
        #expect(store.openSession?.worktreePath == nil)

        // The summary-shaped opener does the same.
        store.openSessionID = nil
        let remoteProject = try #require(store.projectSummary(ProjectID(rawValue: "remote-p1")))
        let summary = try #require(store.summaries(for: remoteProject).first)
        store.openSession(summary)
        #expect(store.openSessionID?.rawValue == "s-remote")
        #expect(store.openHostID == "peer-A")

        // A local record with the same id shadows the remote copy (tombstone guard).
        store.setMeshPeersForTesting([peer(sessions: [wireSession(localID.rawValue, title: "imposter")])])
        store.openSessionID = localID
        #expect(store.openHostID == nil)
        #expect(store.remoteSessionLocation(localID) == nil)
    }

    /// After a transfer between two OTHER Macs, both peers mirror the same SessionID — the
    /// old owner as a moved-away tombstone, the new owner live. Routing must bind to the
    /// live copy regardless of peer order, and an explicit open of the live row must honor
    /// the caller's host even when the roster lookup would pick the tombstone's.
    @Test func routingPrefersLiveCopyOverPeerTombstone() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        var tombstone = wireSession("s-x", title: "moved away")
        tombstone = WireSessionSummary(
            sessionID: tombstone.sessionID, projectID: tombstone.projectID,
            projectName: tombstone.projectName, backend: tombstone.backend,
            status: tombstone.status, title: tombstone.title, branch: tombstone.branch,
            lastSeq: tombstone.lastSeq, diffStat: nil, archived: true, updatedAt: t0,
            movedTo: MovedDestination(deviceID: "peer-B", deviceName: "Studio"))
        // "Air" sorts before "Studio", so a naive first-match lookup would pick the tombstone.
        store.setMeshPeersForTesting([
            peer("peer-A", label: "Air", sessions: [tombstone]),
            peer("peer-B", label: "Studio", sessions: [wireSession("s-x", title: "live twin")]),
        ])

        let location = try #require(store.remoteSessionLocation(SessionID(rawValue: "s-x")))
        #expect(location.deviceID == "peer-B")
        #expect(location.summary.movedTo == nil)

        // Explicit open of the live row binds to the clicked host.
        let studioProject = ProjectSummary(
            id: ProjectID(rawValue: "remote-p1"), name: "Remote Project", hostID: "peer-B")
        let live = try #require(store.summaries(for: studioProject).first)
        store.openSession(live)
        #expect(store.openHostID == "peer-B")
    }

    /// A remote row whose owner isn't in the live roster must still open. The sidebar renders
    /// Covalence rows from the persisted mirror at launch — before any peer presence lands, and
    /// forever if that runner never comes back — and clicking one used to return without touching
    /// any state, so the row didn't even highlight (selection is origin-qualified on `openHostID`).
    @Test func openSessionOnOfflineOwnerStillSelectsTheRow() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.setMeshPeersForTesting([])  // mesh not yet reconnected after a relaunch

        // No cached wire summary: the row itself carries enough to open read-only.
        let row = SessionSummary(wire: wireSession("s-cached", title: "cloud chat"), hostID: "runner-1")
        store.openSession(row)
        #expect(store.openSessionID?.rawValue == "s-cached")
        #expect(store.openHostID == "runner-1")
        #expect(store.openSession?.title == "cloud chat")
        // Announced, not silent — and benign, since the mirrored copy is still readable.
        #expect(store.lastError != nil)
        #expect(store.lastErrorIsBenign)

        // With a cached wire summary, the owner's own fields open instead of the row's.
        store.openSessionID = nil
        store.covalenceMirrors[SessionID(rawValue: "s-cached")] = CovalenceSessionMirror(
            sessionID: SessionID(rawValue: "s-cached"),
            originProjectID: ProjectID(rawValue: "local-p1"), hostID: "runner-1",
            summary: wireSession("s-cached", title: "cloud chat (cached)"), updatedAt: t0)
        store.openSession(row)
        #expect(store.openHostID == "runner-1")
        #expect(store.openSession?.title == "cloud chat (cached)")
        #expect(store.openSession?.branch == "nucleic/x")  // wire-only field the row can't carry
    }

    // MARK: Owner-side broadcasts that keep viewers' mirrors fresh

    @Test func favoriteAndDeleteBroadcastToViewers() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let sessionID = try await store.createSession(in: project, title: "watched", prompt: "")

        let collected = Collector()
        let stream = await store.broadcasts()
        let consumer = Task { for await b in stream { await collected.add(b) } }
        defer { consumer.cancel() }

        // A local favorite toggle (a mutation that emits no transcript event) reaches viewers.
        await store.setSessionFavorite(sessionID, true)
        let updated = await collected.waitFor { broadcasts in
            broadcasts.contains {
                if case .sessionUpdated(let s) = $0 { return s.sessionID == sessionID && s.favorite }
                return false
            }
        }
        #expect(updated)

        // A deletion pushes the whole refreshed list, so viewers drop the row immediately.
        await store.deleteSession(sessionID)
        let removed = await collected.waitFor { broadcasts in
            broadcasts.contains {
                if case .sessionList(let list) = $0 { return !list.contains { $0.sessionID == sessionID } }
                return false
            }
        }
        #expect(removed)
    }

    // MARK: Transfer destinations for a chat this Mac doesn't own

    /// "Transfer…" has to work from either end: a chat a peer owns can be pulled here ("this Mac")
    /// or pushed on to a third device the *owner* can see, which this Mac merely brokers.
    @Test func remoteSessionOffersThisMacAndTheOwnersOtherPeers() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.syncHostName = "Laptop"
        let me = store.castOriginID
        var owner = peer("peer-A", label: "Studio", sessions: [wireSession("s-1")])
        owner.canTransferOnRequest = true
        owner.remotePeers = [
            PeerSummary(deviceID: me, label: "Laptop", kind: .mac, online: true),
            PeerSummary(deviceID: "runner-1", label: "Cloud", kind: .cloud, online: true),
            // Offline, and a phone: neither can hold a session.
            PeerSummary(deviceID: "peer-C", label: "Mini", kind: .mac, online: false),
            PeerSummary(deviceID: "phone-1", label: "iPhone", kind: .iphone, online: true),
        ]
        store.setMeshPeersForTesting([owner])

        let remote = SessionSummary(wire: wireSession("s-1"), hostID: "peer-A")
        let destinations = store.transferDestinations(for: remote)
        #expect(destinations.map(\.deviceID) == [me, "runner-1"])
        #expect(destinations.first?.isThisMac == true)
        #expect(destinations.first?.label == "Laptop (this Mac)")

        // An owner that predates the verb gets no menu at all, rather than a move it would reject.
        var legacy = owner
        legacy.canTransferOnRequest = false
        store.setMeshPeersForTesting([legacy])
        #expect(store.transferDestinations(for: remote).isEmpty)

        // A moved-away tombstone is already gone — nothing left here to transfer.
        var tombstone = SessionSummary(wire: wireSession("s-2"), hostID: "peer-A")
        tombstone.hostID = nil
        tombstone.movedTo = MovedDestination(deviceID: "peer-A", deviceName: "Studio")
        store.setMeshPeersForTesting([owner])
        #expect(store.transferDestinations(for: tombstone).isEmpty)
    }

    // MARK: Transfer to a Covalence Cloud runner

    /// Moving a chat to a runner ("Transfer…" in the sidebar menu) has to leave the same fold record
    /// a runner *dispatch* does — the backstop for a runner clone whose repo identity doesn't line
    /// up with ours (here: a differently-named clone with no presence card), which would otherwise
    /// surface as a second, globe-badged section next to the local one.
    @Test func transferToCloudRunnerFoldsUnderTheOriginProject() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let local = try #require(
            await store.addProject(name: "bravo", rootPath: repo.root, defaultBranch: "main"))
        store.runnerPresenceByHost["runner-1"] = RunnerPresence(
            hostName: "Cloud", kind: .cloud, canRunAgents: true, acceptsMeshDispatch: true,
            activeSessionCount: 1, backends: [.claudeCode], projects: [], updatedAt: t0)
        // The arrived chat, as the runner now reports it: same session id, the runner's own copy of
        // the project — a distinct ProjectID under a name that doesn't match ours either.
        store.setMeshPeersForTesting([peer(
            "runner-1", label: "Cloud",
            sessions: [wireSession("s-moved", project: "runner-p1", projectName: "bravo-clone")])])

        // Nothing ties the two together yet, so the runner's copy is its own section.
        #expect(store.sidebarProjects.map(\.name) == ["bravo", "bravo-clone"])

        await store.noteTransferLanding(
            SessionID(rawValue: "s-moved"), on: "runner-1", originProjectID: local.id)

        let projects = store.sidebarProjects
        #expect(projects.map(\.name) == ["bravo"])
        let bravo = try #require(projects.first)
        #expect(!bravo.isRemote)
        // …and the moved chat rides under it, globe-badged with the runner's label.
        let rows = store.summaries(for: bravo)
        #expect(rows.map(\.id.rawValue) == ["s-moved"])
        #expect(rows.first?.foldedHostLabel == "Cloud")
    }

    // MARK: One project per repo, whatever device it's on

    /// Every device in the mesh holds the same projects, so a peer's copy of one is never a second
    /// section — its chats fold under ours, wearing a globe. Matching is by repo identity from the
    /// peer's presence card, falling back to the project name before any card has been published.
    @Test func peerCopyOfALocalProjectFoldsIntoOneSection() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        _ = await store.addProject(name: "bravo", rootPath: repo.root, defaultBranch: "main")
        // The peer registered the same repo separately, so it carries its own ProjectID.
        store.setMeshPeersForTesting([peer(
            sessions: [wireSession("s-peer", project: "peer-p1", projectName: "bravo")],
            projects: [WireProject(id: ProjectID(rawValue: "peer-p1"), name: "bravo",
                                   defaultBranch: "main", sessionCount: 1, activeCount: 1)])])

        let projects = store.sidebarProjects
        #expect(projects.map(\.name) == ["bravo"])
        let bravo = try #require(projects.first)
        #expect(!bravo.isRemote)   // the shared project is ours; the row underneath says where it runs
        let rows = store.summaries(for: bravo)
        #expect(rows.map(\.id.rawValue) == ["s-peer"])
        #expect(rows.first?.hostID == "peer-A")        // verbs still dispatch to its owner
        #expect(rows.first?.foldedHostLabel == "Studio")

        // A project the peer holds and we genuinely don't keeps its own header, so its chats
        // aren't lost.
        store.setMeshPeersForTesting([peer(
            sessions: [wireSession("s-other", project: "peer-p2", projectName: "zulu")])])
        #expect(store.sidebarProjects.map(\.name) == ["bravo", "zulu"])
    }

    /// Same fold, driven by repo identity rather than the name — the peer renamed its copy, and its
    /// presence card carries the root commit both sides share.
    @Test func peerProjectFoldsByRepoIdentityWhenNamesDiffer() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let local = try #require(
            await store.addProject(name: "bravo", rootPath: repo.root, defaultBranch: "main"))
        // Warm the identity cache the presence publisher fills, then have the peer advertise the
        // same repo under its own id and a different name.
        _ = await store.projectDescriptors()
        let identity = try #require(store.projectDescriptorCache[local.id]?.rootCommitSHA)
        store.runnerPresenceByHost["peer-A"] = RunnerPresence(
            hostName: "Studio", kind: .mac, canRunAgents: true, acceptsMeshDispatch: true,
            activeSessionCount: 0, backends: [.claudeCode],
            projects: [ProjectDescriptor(
                projectID: ProjectID(rawValue: "peer-p1"), name: "bravo-on-studio",
                rootCommitSHA: identity, normalizedRemote: nil, defaultBranch: "main")],
            updatedAt: t0)
        store.setMeshPeersForTesting([peer(
            sessions: [wireSession("s-peer", project: "peer-p1", projectName: "bravo-on-studio")])])

        #expect(store.sidebarProjects.map(\.name) == ["bravo"])
        let bravo = try #require(store.sidebarProjects.first)
        #expect(store.summaries(for: bravo).map(\.id.rawValue) == ["s-peer"])
    }
}

/// Collects host broadcasts off the main actor so tests can await a matching one.
private actor Collector {
    private var broadcasts: [HostBroadcast] = []
    func add(_ b: HostBroadcast) { broadcasts.append(b) }
    func waitFor(
        _ predicate: @Sendable ([HostBroadcast]) -> Bool, tries: Int = 200
    ) async -> Bool {
        for _ in 0..<tries {
            if predicate(broadcasts) { return true }
            try? await Task.sleep(for: .milliseconds(10))
        }
        return predicate(broadcasts)
    }
}
