import Foundation
import Testing
import NucleicProtocol

@testable import NucleicCore

/// Mesh P4 remainder: the Mac-side `PeerClient` against a real `SyncHost` over in-memory
/// pipes — pair via a pasted link, IK reconnect, presence, `listPeers` gating, unpair.
@Suite struct PeerClientTests {
    /// One "Mac" end: a SyncHost + scriptable bridge + push-on-demand listener.
    private struct HostEnd {
        let identity = DeviceIdentity()
        let bridge: FakeSyncBridge
        let store = InMemoryPairedDeviceStore()
        let listener = ManualListener()
        let host: SyncHost

        init(bridge: FakeSyncBridge) async throws {
            self.bridge = bridge
            self.host = SyncHost(
                identity: identity, bridge: bridge, store: store,
                dedupGrace: .milliseconds(50))
            try await host.start(listener: listener)
        }
    }

    private func makePeerClient(
        dialer: LoopbackPeerDialer, identity: DeviceIdentity = DeviceIdentity(),
        store: any PairedDeviceStore = InMemoryPairedDeviceStore(), label: String = "Mac B",
        discovery: (any PeerLANBrowsing)? = nil
    ) -> (client: PeerClient, identity: DeviceIdentity, store: any PairedDeviceStore) {
        let client = PeerClient(
            identity: identity, store: store, dialer: dialer,
            config: PeerClient.Config(
                deviceID: identity.hostID, deviceLabel: label,
                maxReconnectDelay: .milliseconds(50), idleRecheckDelay: .milliseconds(50)),
            discovery: discovery)
        return (client, identity, store)
    }

    /// A peer already known to us but with no dialable direct address (its LAN port churned and no
    /// tailnet) still reconnects over the relay room, using the membership token it issued us. This
    /// is the port-independent healing path for a mesh that no live LAN connection could bootstrap.
    @Test func dialsPeerOverRelayWhenDirectAddressIsStale() async throws {
        let bridge = FakeSyncBridge(localAddresses: nil)
        let a = try await HostEnd(bridge: bridge)
        let bIdentity = DeviceIdentity()
        // A must already pin B for B's IK reconnect to be accepted.
        await a.store.upsert(PairedDevice(
            deviceID: bIdentity.hostID, label: "Mac B", staticPublicKey: bIdentity.staticPublicKey,
            scope: .control, pairedAt: Date(), kind: .mac, capabilities: .hostAndAgents))
        // B knows A only by its relay membership — no LAN hint, no tailnet.
        let bStore = InMemoryPairedDeviceStore()
        await bStore.upsert(PairedDevice(
            deviceID: a.identity.hostID, label: "Mac A", staticPublicKey: a.identity.staticPublicKey,
            scope: .control, pairedAt: Date(), kind: .mac, capabilities: .hostAndAgents,
            relayMembershipToken: "relay-tok-a"))
        let dialer = LoopbackPeerDialer()
        dialer.route(host: "relay-tok-a", to: a.listener)  // loopback routes relay by its token
        let (b, _, _) = makePeerClient(dialer: dialer, identity: bIdentity, store: bStore)
        _ = await b.start()

        let connected = await poll {
            (await b.presences()).filter { $0.connected && $0.transport == .relay }
        }
        #expect(connected.first?.deviceID == a.identity.hostID)
        #expect(dialer.dialed.contains(.relay(baseURLString: nil, membershipToken: "relay-tok-a")))

        await b.stop()
        await a.host.stop()
    }

    /// Bonjour rediscovery beats a stale stored hint: a peer advertising on the LAN is dialed at its
    /// live endpoint (resolved by fingerprint) *before* the stored `lanHint`, so a Mac that
    /// relaunched onto a new OS-assigned port reconnects without waiting for any hint refresh.
    @Test func prefersBonjourEndpointOverStoredHint() async throws {
        let bridge = FakeSyncBridge(localAddresses: nil)
        let a = try await HostEnd(bridge: bridge)
        let bIdentity = DeviceIdentity()
        await a.store.upsert(PairedDevice(
            deviceID: bIdentity.hostID, label: "Mac B", staticPublicKey: bIdentity.staticPublicKey,
            scope: .control, pairedAt: Date(), kind: .mac, capabilities: .hostAndAgents))
        // B's stored hint points at A's dead old port; only Bonjour reaches A's live listener.
        let bStore = InMemoryPairedDeviceStore()
        await bStore.upsert(PairedDevice(
            deviceID: a.identity.hostID, label: "Mac A", staticPublicKey: a.identity.staticPublicKey,
            scope: .control, pairedAt: Date(), kind: .mac, capabilities: .hostAndAgents,
            addresses: PeerAddresses(lanHint: "mac-a-old.local:40000")))
        let dialer = LoopbackPeerDialer()
        dialer.route(host: "mac-a-live", to: a.listener)  // the Bonjour service name resolves here
        let fingerprint = DeviceIdentity.fingerprint(ofStaticKey: a.identity.staticPublicKey)
        let discovery = StubLANBrowser(byFingerprint: [
            fingerprint: .bonjour(serviceName: "mac-a-live", domain: "local.")])
        let (b, _, _) = makePeerClient(
            dialer: dialer, identity: bIdentity, store: bStore, discovery: discovery)
        _ = await b.start()

        let connected = await poll { (await b.presences()).filter(\.connected) }
        #expect(connected.first?.deviceID == a.identity.hostID)
        // Bonjour was tried first (inserted ahead of the stale stored hint).
        #expect(dialer.dialed.first == .bonjour(serviceName: "mac-a-live", domain: "local."))

        await b.stop()
        await a.host.stop()
    }

    /// Pair Mac B to host A via the pasted link, then watch the manage loop reconnect over
    /// IK — and check both sides pinned each other (the symmetric mesh invariant P5 needs).
    @Test func pairsViaPastedLinkAndPinsBothSides() async throws {
        let hostAddresses = PeerAddresses(lanHint: "mac-a.local:41000")
        let bridge = FakeSyncBridge(localAddresses: hostAddresses)
        let a = try await HostEnd(bridge: bridge)
        let dialer = LoopbackPeerDialer()
        dialer.route(host: "mac-a.local", to: a.listener)
        let (b, bIdentity, bStore) = makePeerClient(dialer: dialer)
        _ = await b.start()
        let myAddresses = PeerAddresses(lanHint: "mac-b.local:42000")
        await b.setLocalAddresses(myAddresses)

        let payload = await a.host.beginPairing(lanHost: "mac-a.local", lanPort: 41000)
        try await b.pair(with: payload.qrString())

        // Host A pinned B as a control-scope mac peer, with B's addresses from its hello.
        let pinnedOnA = await poll { await a.store.all() }
        #expect(pinnedOnA.first?.deviceID == bIdentity.hostID)
        #expect(pinnedOnA.first?.kind == .mac)
        #expect(pinnedOnA.first?.capabilities == .hostAndAgents)
        #expect(pinnedOnA.first?.scope == .control)
        #expect(pinnedOnA.first?.addresses == myAddresses)
        // The accepting side's confirm ran.
        #expect(await bridge.macPairingRequests.first?.deviceID == bIdentity.hostID)

        // B pinned A symmetrically: A's hostID, the Noise-verified key, A's welcome addresses.
        let pinnedOnB = await poll { await bStore.all() }
        #expect(pinnedOnB.first?.deviceID == a.identity.hostID)
        #expect(pinnedOnB.first?.staticPublicKey == a.identity.staticPublicKey)
        #expect(pinnedOnB.first?.kind == .mac)
        #expect(pinnedOnB.first?.addresses == hostAddresses)

        // The manage loop re-dials over IK and reports a live LAN connection.
        let connected = await poll {
            (await b.presences()).filter { $0.connected && $0.transport == .lan }
        }
        #expect(connected.first?.deviceID == a.identity.hostID)
        #expect(connected.first?.grantedScope == .control)
        #expect(await a.host.connectedDeviceIDs() == [bIdentity.hostID])

        await b.stop()
        await a.host.stop()
    }

    /// A dropped connection is redialed: presence goes down, then comes back on its own.
    @Test func reconnectsAfterTheChannelDrops() async throws {
        let bridge = FakeSyncBridge(localAddresses: PeerAddresses(lanHint: "mac-a.local:41000"))
        let a = try await HostEnd(bridge: bridge)
        let dialer = LoopbackPeerDialer()
        dialer.route(host: "mac-a.local", to: a.listener)
        let (b, _, _) = makePeerClient(dialer: dialer)
        _ = await b.start()

        let payload = await a.host.beginPairing(lanHost: "mac-a.local", lanPort: 41000)
        try await b.pair(with: payload.qrString())
        _ = await poll { (await b.presences()).filter(\.connected) }

        // Sever the live pipe; the manage loop should dial a fresh one after backoff.
        let before = dialer.dialed.count
        dialer.lastChannel?.close()
        let reconnected = await poll(timeout: .seconds(5)) {
            (await b.presences()).filter { $0.connected && dialer.dialed.count > before }
        }
        #expect(!reconnected.isEmpty)

        await b.stop()
        await a.host.stop()
    }

    /// Reconnection after a relaunch onto a fresh LAN port: once a Mac's dialable address
    /// changes (its `.any` listener binds a new ephemeral port every launch), the app refreshes
    /// the peer client's local addresses, and every *connected* sibling must relearn them — the
    /// only way it can dial this Mac back. Regression for the mesh bug where a Mac that quit and
    /// reopened kept being dialed at its dead old port and never reconnected.
    @Test func connectedHostRelearnsRefreshedLocalAddress() async throws {
        // The host must advertise `canUpdateAddresses` for the mid-connection refresh push.
        let bridge = FakeSyncBridge(
            capabilities: WireCapabilities(
                canModifyToolInput: true, allowAlwaysScopes: [], canUpdateAddresses: true),
            localAddresses: PeerAddresses(lanHint: "mac-a.local:41000"))
        let a = try await HostEnd(bridge: bridge)
        let dialer = LoopbackPeerDialer()
        dialer.route(host: "mac-a.local", to: a.listener)
        let (b, bIdentity, _) = makePeerClient(dialer: dialer)
        _ = await b.start()
        await b.setLocalAddresses(PeerAddresses(lanHint: "mac-b.local:42000"))

        let payload = await a.host.beginPairing(lanHost: "mac-a.local", lanPort: 41000)
        try await b.pair(with: payload.qrString())
        _ = await poll { (await b.presences()).filter(\.connected) }

        // The host pinned B at the port it first advertised.
        let firstPin = await poll {
            (await a.store.all()).filter {
                $0.deviceID == bIdentity.hostID && $0.addresses?.lanHint == "mac-b.local:42000"
            }
        }
        #expect(!firstPin.isEmpty)

        // B's LAN port changes (as on a relaunch) and the app pushes the refreshed address —
        // the connected host must converge to it so a redial lands on the live port.
        await b.setLocalAddresses(PeerAddresses(lanHint: "mac-b.local:49999"))
        let relearned = await poll {
            (await a.store.all()).filter {
                $0.deviceID == bIdentity.hostID && $0.addresses?.lanHint == "mac-b.local:49999"
            }
        }
        #expect(!relearned.isEmpty)

        await b.stop()
        await a.host.stop()
    }

    /// `listPeers` is sent only when the welcome advertised `canListPeers` — the capability
    /// gate that keeps a new verb off old hosts.
    @Test func listPeersFollowsTheAdvertisedCapability() async throws {
        let peers = [PeerSummary(deviceID: "iphone-9", label: "iPhone", kind: .iphone)]

        // A host that advertises the capability gets asked, and presence carries the answer.
        let advertising = FakeSyncBridge(
            capabilities: WireCapabilities(
                canModifyToolInput: true, allowAlwaysScopes: [], canListPeers: true),
            peers: peers,
            localAddresses: PeerAddresses(lanHint: "mac-a.local:41000"))
        let a = try await HostEnd(bridge: advertising)
        let dialerA = LoopbackPeerDialer()
        dialerA.route(host: "mac-a.local", to: a.listener)
        let (clientA, _, _) = makePeerClient(dialer: dialerA)
        _ = await clientA.start()
        try await clientA.pair(with: (await a.host.beginPairing(lanHost: "mac-a.local", lanPort: 41000)).qrString())
        let withPeers = await poll { (await clientA.presences()).flatMap(\.remotePeers) }
        #expect(withPeers == peers)
        await clientA.stop()
        await a.host.stop()

        // A host that does NOT advertise it is never asked.
        let silent = FakeSyncBridge(
            peers: peers,  // it would have an answer — but must never be asked
            localAddresses: PeerAddresses(lanHint: "mac-c.local:41000"))
        let c = try await HostEnd(bridge: silent)
        let dialerC = LoopbackPeerDialer()
        dialerC.route(host: "mac-c.local", to: c.listener)
        let (clientC, _, _) = makePeerClient(dialer: dialerC)
        _ = await clientC.start()
        try await clientC.pair(with: (await c.host.beginPairing(lanHost: "mac-c.local", lanPort: 41000)).qrString())
        _ = await poll { (await clientC.presences()).filter(\.connected) }
        try await Task.sleep(for: .milliseconds(150))
        #expect(await silent.peerSummariesCalls == 0)
        #expect(await clientC.presences().flatMap(\.remotePeers).isEmpty)
        await clientC.stop()
        await c.host.stop()
    }

    /// Mesh "join": on connect, both ends exchange rosters over the wire when both advertise
    /// `canSyncRoster` — the host pushes its group view to the peer (so a device that scanned a
    /// *different* member learns everyone), and the peer pushes its own back (so the host merges it).
    @Test func exchangesRosterOnConnectWhenBothAdvertise() async throws {
        actor RosterSink {
            private(set) var pushes: [MeshRosterPush] = []
            func record(_ p: MeshRosterPush) { pushes.append(p) }
        }
        // A serves a roster naming a third Mac C — as if A↔C were already a group.
        let cIdentity = DeviceIdentity()
        let memberC = MeshMember(
            deviceID: cIdentity.hostID, label: "Mac C", kind: .mac, capabilities: .hostAndAgents,
            staticPublicKey: cIdentity.staticPublicKey,
            addresses: PeerAddresses(lanHint: "c.local:7000"),
            pairedAt: Date(timeIntervalSince1970: 100))
        let bridge = FakeSyncBridge(
            capabilities: WireCapabilities(
                canModifyToolInput: true, allowAlwaysScopes: [], canSyncRoster: true),
            localAddresses: PeerAddresses(lanHint: "mac-a.local:41000"))
        await bridge.setRosterToServe(MeshRosterPush(members: [memberC]))
        let a = try await HostEnd(bridge: bridge)
        let dialer = LoopbackPeerDialer()
        dialer.route(host: "mac-a.local", to: a.listener)
        let (b, _, _) = makePeerClient(dialer: dialer)
        let sink = RosterSink()
        await b.setMeshRosterHandler { push, _ in await sink.record(push) }
        _ = await b.start()
        try await b.pair(with: (await a.host.beginPairing(lanHost: "mac-a.local", lanPort: 41000)).qrString())

        // B received A's roster naming C.
        let sawC = await poll {
            (await sink.pushes).flatMap(\.members).contains { $0.deviceID == cIdentity.hostID } ? [true] : []
        }
        #expect(!sawC.isEmpty)
        // A received B's roster back (B's store holds A after pairing).
        let hostGotRoster = await poll { (await bridge.receivedRosters).isEmpty ? [] : [true] }
        #expect(!hostGotRoster.isEmpty)

        await b.stop()
        await a.host.stop()
    }

    /// Mesh session sync: a peer's session list mirrors into presence on connect, stays live via the
    /// host's `.sessionUpdated` broadcasts, and leaves the view when the peer is forgotten.
    @Test func mirrorsPeerSessionsAndClearsOnLeave() async throws {
        func summary(_ id: String, status: SessionStatus = .running) -> WireSessionSummary {
            WireSessionSummary(
                sessionID: SessionID(rawValue: id), projectID: "p1", projectName: "ProjA",
                backend: .claudeCode, status: status, title: "chat-\(id)", branch: "nucleic/\(id)",
                lastSeq: 5, diffStat: nil, updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
        }
        let bridge = FakeSyncBridge(
            sessions: [summary("s1"), summary("s2")],
            localAddresses: PeerAddresses(lanHint: "mac-a.local:41000"))
        await bridge.setDashboard(DashboardSnapshot(
            counts: DashboardCounts(projects: 2, chats: 5, activeChats: 1, messages: 20, activeDays: 3, tokens: 1000),
            activity: [], projects: [], todos: [], usage: nil, statusFeeds: []))
        let a = try await HostEnd(bridge: bridge)
        let dialer = LoopbackPeerDialer()
        dialer.route(host: "mac-a.local", to: a.listener)
        let (b, _, _) = makePeerClient(dialer: dialer)
        _ = await b.start()
        try await b.pair(with: (await a.host.beginPairing(lanHost: "mac-a.local", lanPort: 41000)).qrString())

        // The peer's session list mirrors into presence.
        let synced = await poll { (await b.presences()).filter { $0.connected && $0.sessions.count == 2 } }
        #expect(Set(synced.first?.sessions.map(\.sessionID.rawValue) ?? []) == ["s1", "s2"])
        // …and so does its dashboard (for the union stats).
        let dashed = await poll { (await b.presences()).filter { $0.dashboard?.counts.chats == 5 } }
        #expect(dashed.first?.dashboard?.counts.tokens == 1000)

        // A live broadcast from the host adds a session (the host fans it to every client).
        await bridge.push(.sessionUpdated(summary("s3", status: .awaitingApproval)))
        let grew = await poll { (await b.presences()).filter { $0.sessions.count == 3 } }
        #expect(grew.first?.sessions.contains { $0.sessionID.rawValue == "s3" } == true)

        // Leaving (forgetting the peer) removes it — and its sessions — from the mesh view.
        await b.unpair(a.identity.hostID)
        let gone = await poll { (await b.presences()).isEmpty ? ["gone"] : [] }
        #expect(gone == ["gone"])

        await b.stop()
        await a.host.stop()
    }

    /// Opening a peer Mac's session subscribes over the mesh and streams its transcript snapshot back
    /// — the foundation of viewing/controlling a remote chat.
    @Test func opensAndStreamsARemoteSession() async throws {
        let sid = SessionID(rawValue: "s1")
        func summary() -> WireSessionSummary {
            WireSessionSummary(
                sessionID: sid, projectID: "p1", projectName: "ProjA", backend: .claudeCode,
                status: .running, title: "auth", branch: "nucleic/auth", lastSeq: 1, diffStat: nil,
                updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
        }
        let snap = SessionSnapshot(
            summary: summary(),
            recentEvents: [AgentEvent(
                sessionID: sid, seq: 1, at: Date(timeIntervalSince1970: 1_700_000_000),
                backend: .claudeCode, nativeType: nil,
                kind: .userText(TextChunk(messageID: "u1", text: "hi", isPartial: false)))],
            pendingApprovals: [], cursor: 1)
        let bridge = FakeSyncBridge(
            sessions: [summary()], snapshots: [sid: snap],
            localAddresses: PeerAddresses(lanHint: "mac-a.local:41000"))
        let a = try await HostEnd(bridge: bridge)
        let dialer = LoopbackPeerDialer()
        dialer.route(host: "mac-a.local", to: a.listener)
        let (b, _, _) = makePeerClient(dialer: dialer)
        _ = await b.start()
        try await b.pair(with: (await a.host.beginPairing(lanHost: "mac-a.local", lanPort: 41000)).qrString())
        _ = await poll { (await b.presences()).filter(\.connected) }

        let stream = await b.openRemoteSession(sid, on: a.identity.hostID)
        // Race the first snapshot against a timeout so a miss fails fast instead of hanging.
        let gotSnapshot = await withTaskGroup(of: Bool.self) { group in
            group.addTask {
                for await update in stream {
                    if case .snapshot(let s) = update, s.summary.sessionID == sid { return true }
                }
                return false
            }
            group.addTask { try? await Task.sleep(for: .seconds(3)); return false }
            let result = await group.next() ?? false
            group.cancelAll()
            return result
        }
        #expect(gotSnapshot)

        await b.closeRemoteSession()
        await b.stop()
        await a.host.stop()
    }

    /// Unpair stops dialing, drops the pin, and the host sees the connection go away.
    @Test func unpairForgetsThePeerAndDisconnects() async throws {
        let bridge = FakeSyncBridge(localAddresses: PeerAddresses(lanHint: "mac-a.local:41000"))
        let a = try await HostEnd(bridge: bridge)
        let dialer = LoopbackPeerDialer()
        dialer.route(host: "mac-a.local", to: a.listener)
        let (b, _, bStore) = makePeerClient(dialer: dialer)
        _ = await b.start()
        try await b.pair(with: (await a.host.beginPairing(lanHost: "mac-a.local", lanPort: 41000)).qrString())
        _ = await poll { (await b.presences()).filter(\.connected) }

        await b.unpair(a.identity.hostID)
        #expect(await bStore.all().isEmpty)
        let gone = await poll(timeout: .seconds(5)) { () -> [String] in
            let ids = await a.host.connectedDeviceIDs()
            return ids.isEmpty ? ["gone"] : []
        }
        #expect(gone == ["gone"])
        #expect(await b.presences().isEmpty)

        await b.stop()
        await a.host.stop()
    }

    /// The accepting Mac's user declining the confirm surfaces as a typed, readable error.
    @Test func hostDeclineSurfacesAsPairError() async throws {
        let bridge = FakeSyncBridge(localAddresses: PeerAddresses(lanHint: "mac-a.local:41000"))
        await bridge.setApproveMacPairing(false)
        let a = try await HostEnd(bridge: bridge)
        let dialer = LoopbackPeerDialer()
        dialer.route(host: "mac-a.local", to: a.listener)
        let (b, _, bStore) = makePeerClient(dialer: dialer)
        _ = await b.start()

        let payload = await a.host.beginPairing(lanHost: "mac-a.local", lanPort: 41000)
        await #expect(throws: PeerPairError.declined("pairing declined on the host")) {
            try await b.pair(with: payload.qrString())
        }
        #expect(await bStore.all().isEmpty)
        #expect(await a.store.all().isEmpty)

        await b.stop()
        await a.host.stop()
    }

    @Test func pastingYourOwnCodeIsRejected() async throws {
        let dialer = LoopbackPeerDialer()
        let identity = DeviceIdentity()
        let (b, _, _) = makePeerClient(dialer: dialer, identity: identity)
        let ownPayload = PairingPayload(
            hostName: "Mac B", hostStaticKey: identity.staticPublicKey,
            pairingSecret: PairingPayload.freshSecret(), lanHost: "mac-b.local", lanPort: 1)
        await #expect(throws: PeerPairError.selfPairing) {
            try await b.pair(with: ownPayload.qrString())
        }
    }

    @Test func garbageCodeIsRejectedWithoutDialing() async throws {
        let dialer = LoopbackPeerDialer()
        let (b, _, _) = makePeerClient(dialer: dialer)
        await #expect(throws: PeerPairError.invalidCode) {
            try await b.pair(with: "not a pairing link")
        }
        #expect(dialer.dialed.isEmpty)
    }

    /// Endpoint derivation: lanHint parses host:port (incl. bracketed IPv6); tailnet rides
    /// the fixed sync port; empty addresses yield nothing.
    @Test func endpointDerivation() {
        let both = PeerClient.endpoints(for: PeerAddresses(lanHint: "10.0.0.4:51000", tailnet: "100.64.0.9"))
        #expect(both == [
            .lan(host: "10.0.0.4", port: 51000),
            .tailnet(host: "100.64.0.9", port: SyncTransportSetting.tailnetPort),
        ])
        let v6 = PeerClient.endpoints(for: PeerAddresses(lanHint: "[fe80::1]:51000"))
        #expect(v6 == [.lan(host: "fe80::1", port: 51000)])
        #expect(PeerClient.endpoints(for: nil).isEmpty)
        #expect(PeerClient.endpoints(for: PeerAddresses()).isEmpty)
        #expect(PeerClient.endpoints(for: PeerAddresses(lanHint: "no-port")).isEmpty)
    }
}
