import Foundation
import Testing
import NucleicProtocol

@testable import NucleicCore

/// Mesh dispatch over the real loopback pipe: an acknowledged `startChat(requestID:)` answers
/// `chatStarted`, and a RETRIED requestID acks the same session with no second start — the
/// double-start killer the dispatcher's timeout-retry relies on.
@Suite struct MeshDispatchSyncTests {
    @Test func acknowledgedStartChatIsCorrelatedAndIdempotent() async throws {
        let bridge = FakeSyncBridge(sessions: [])
        let host = SyncHost(
            identity: DeviceIdentity(), bridge: bridge, store: InMemoryPairedDeviceStore())
        let (clientCh, serverCh) = MemoryChannel.pair()
        try await host.start(listener: OneShotListener(serverCh))
        let payload = await host.beginPairing()
        let client = SyncClient(
            channel: clientCh, identity: DeviceIdentity(), hostStaticKey: payload.hostStaticKey,
            mode: .pair(secret: payload.pairingSecret), deviceID: "mac-dispatcher",
            deviceLabel: "Dispatcher", deviceKind: "mac",
            clientCaps: WireClientCapabilities(mesh: 1, canHost: true, canRunAgents: true))
        let recorder = EventRecorder()
        await recorder.consume(client.start())
        _ = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }

        let request = StartChatRequest(
            projectID: ProjectID(rawValue: "p1"), message: "go", requestID: "req-1")
        await client.send(.startChat(request))
        let first = await recorder.waitFor {
            if case .chatStarted = $0 { return true } else { return false }
        }
        guard case .chatStarted(let ack) = first else { Issue.record("no ack"); return }
        #expect(ack.requestID == "req-1")
        let sessionID = try #require(ack.sessionID)

        // The retry (as after a dispatcher timeout): same requestID ⇒ same session, and the
        // bridge started exactly ONE chat.
        await client.send(.startChat(request))
        let second = await recorder.waitFor {
            if case .chatStarted = $0 { return true } else { return false }
        }
        guard case .chatStarted(let retryAck) = second else { Issue.record("no retry ack"); return }
        #expect(retryAck.sessionID == sessionID)
        let started = await poll { await bridge.startedChats }
        #expect(started.count == 1)
        let acked = await poll { await bridge.acknowledgedDispatchIDs }
        #expect(acked == ["req-1", "req-1"])

        // A request WITHOUT a requestID stays on the legacy fire-and-forget path (no ack).
        await client.send(.startChat(StartChatRequest(
            projectID: ProjectID(rawValue: "p1"), message: "plain")))
        await client.send(.listSessions)
        _ = await recorder.waitFor { if case .sessionList = $0 { return true } else { return false } }
        let bufferedAck = await recorder.hasBuffered { (event: SyncClient.Event) in
            if case .chatStarted = event { return true } else { return false }
        }
        #expect(!bufferedAck)
        await host.stop()
    }

    @Test func dispatchWireTypesRoundTripWithLegacyTolerance() throws {
        func roundTrip<T: Codable & Equatable>(_ value: T) throws -> T {
            try CBORDecoder().decode(T.self, from: CBOREncoder().encode(value))
        }
        let request = StartChatRequest(
            projectID: ProjectID(rawValue: "p1"), message: "go", requestID: "req-9")
        #expect(try roundTrip(request).requestID == "req-9")
        // A legacy request (no requestID key) decodes nil — the fire-and-forget path.
        let legacy = try JSONDecoder().decode(StartChatRequest.self, from: Data("""
            {"projectID": "p1", "message": "go", "useWorktree": true}
            """.utf8))
        #expect(legacy.requestID == nil)

        let ack = WireChatStarted(requestID: "req-9", sessionID: SessionID(rawValue: "s1"))
        #expect(try roundTrip(HostMsg.chatStarted(ack)) == .chatStarted(ack))

        // A pre-dispatch peer's capabilities decode the flag false.
        let caps = try JSONDecoder().decode(WireCapabilities.self, from: Data("""
            {"canModifyToolInput": true, "allowAlwaysScopes": []}
            """.utf8))
        #expect(caps.canAcknowledgeDispatch == false)
    }
}
