import Foundation
import Testing
import NucleicProtocol

@testable import NucleicCore

/// Mesh messaging (the "messages" cast channel): host-stamped provenance, topic delivery as an
/// injected user turn, and the Settings gate.
@MainActor
@Suite("Mesh messaging", .serialized, .isolatedContainerSettings)
struct MeshMessagingTests {
    private func makeStore(repo: GitTestRepo) -> AppStore {
        let now: @Sendable () -> Date = { Date(timeIntervalSince1970: 1_700_000_000) }
        let database = try! GRDBMetadataStore(path: nil)
        let worktrees = GitWorktreeManager(now: now)
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        return AppStore(
            database: database, worktrees: worktrees, transcriptsDir: transcriptsDir, now: now
        ) { _ in
            ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be-msg", model: "m", cwd: "", toolNames: [])))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
    }

    private func waitForAsync(_ condition: @escaping () async -> Bool, timeoutMs: Int = 3000) async {
        var elapsed = 0
        while await !condition() && elapsed < timeoutMs {
            try? await Task.sleep(for: .milliseconds(10))
            elapsed += 10
        }
    }

    @Test func topicMessageStampsProvenanceAndInjectsAsUserTurn() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        MeshMessagingSettings.enabled = true
        defer { UserDefaults.standard.removeObject(forKey: MeshMessagingSettings.enabledKey) }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
        let projectID = try #require(project?.id)

        let receiver = try await store.createSession(
            in: store.project(projectID)!, title: "receiver", prompt: "go")
        let sender = try await store.createSession(
            in: store.project(projectID)!, title: "builder bee", prompt: "go")
        // Let both opening turns finish so the injected turn is a fresh run, not queued.
        await waitForAsync {
            let receiverIdle = await store.liveSnapshot(receiver)?.session.status == .awaitingInput
            let senderIdle = await store.liveSnapshot(sender)?.session.status == .awaitingInput
            return receiverIdle && senderIdle
        }

        _ = await store.subscribeMeshTopic(session: receiver, topic: "builds")
        let result = await store.sendMeshMessage(
            from: sender,
            request: MeshSendRequest(to: nil, sessionID: nil, topic: "builds", body: "artifacts ready"))
        #expect(result.contains("Broadcast"))

        // The cast landed durably on the messages channel with HOST-stamped provenance —
        // the sending session's real title, regardless of what tool args could have claimed.
        let tips = await store.castTips()
        let tip = try #require(tips.first { $0.channel == MeshCastChannel.messages })
        let batches = await store.castCatchUp(
            origin: tip.originHostID, channel: tip.channel, afterSeq: 0)
        let message = try #require(
            batches.flatMap(\.casts).compactMap(MeshMessage.decode).first)
        #expect(message.fromSessionID == sender)
        #expect(message.fromSessionTitle == "builder bee")
        #expect(message.topic == "builds")

        // Delivery: the subscribed session received it as a visible user turn with the
        // provenance header; the sender did NOT receive its own broadcast.
        await waitForAsync {
            let events = await store.liveSnapshot(receiver) != nil
                ? await store.liveTranscript(receiver) : []
            return events.contains {
                if case .userText(let chunk) = $0.kind {
                    return chunk.text.contains("[Mesh message from \"builder bee\"")
                        && chunk.text.contains("artifacts ready")
                }
                return false
            }
        }
        let receiverEvents = await store.liveTranscript(receiver)
        #expect(receiverEvents.contains {
            if case .userText(let chunk) = $0.kind {
                return chunk.text.contains("artifacts ready")
            }
            return false
        })
        let senderEvents = await store.liveTranscript(sender)
        #expect(!senderEvents.contains {
            if case .userText(let chunk) = $0.kind {
                return chunk.text.contains("[Mesh message")
            }
            return false
        })
    }

    @Test func waitForMessageReturnsTheMessageInsteadOfInjecting() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        MeshMessagingSettings.enabled = true
        defer { UserDefaults.standard.removeObject(forKey: MeshMessagingSettings.enabledKey) }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
        let projectID = try #require(project?.id)
        let waiter = try await store.createSession(
            in: store.project(projectID)!, title: "waiter", prompt: "go")
        let sender = try await store.createSession(
            in: store.project(projectID)!, title: "sender bee", prompt: "go")
        await waitForAsync {
            let a = await store.liveSnapshot(waiter)?.session.status == .awaitingInput
            let b = await store.liveSnapshot(sender)?.session.status == .awaitingInput
            return a && b
        }

        // Park the waiter on a topic (subscribes it), then broadcast from the sender.
        async let parked = store.waitForMeshMessage(
            session: waiter, topic: "handoff", timeoutSeconds: 30)
        // Give the park a beat to register before broadcasting.
        try await Task.sleep(for: .milliseconds(50))
        _ = await store.sendMeshMessage(
            from: sender,
            request: MeshSendRequest(to: nil, sessionID: nil, topic: "handoff", body: "take it"))

        let result = await parked
        #expect(result.contains("take it"))
        #expect(result.contains("sender bee"))
        // It was returned to the waiter, NOT injected as a user turn.
        let waiterEvents = await store.liveTranscript(waiter)
        #expect(!waiterEvents.contains {
            if case .userText(let chunk) = $0.kind {
                return chunk.text.contains("[Mesh message")
            }
            return false
        })
    }

    @Test func waitForMessageTimesOut() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        MeshMessagingSettings.enabled = true
        defer { UserDefaults.standard.removeObject(forKey: MeshMessagingSettings.enabledKey) }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
        let projectID = try #require(project?.id)
        let session = try await store.createSession(
            in: store.project(projectID)!, title: "lonely", prompt: "go")
        // A 1-second timeout with nobody sending returns {timedOut:true}.
        let result = await store.waitForMeshMessage(session: session, topic: nil, timeoutSeconds: 1)
        #expect(result.contains("timedOut"))
    }

    @Test func messagingIsInertWhenDisabled() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        UserDefaults.standard.removeObject(forKey: MeshMessagingSettings.enabledKey)
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
        let projectID = try #require(project?.id)
        let session = try await store.createSession(
            in: store.project(projectID)!, title: "quiet", prompt: "go")

        // No tool bridge is built, and a direct send refuses.
        #expect(store.meshMessagingHandlers(for: session) == nil)
        let result = await store.sendMeshMessage(
            from: session,
            request: MeshSendRequest(to: nil, sessionID: nil, topic: "t", body: "x"))
        #expect(result.contains("disabled"))
    }

    @Test func sendValidatesDestinationAndSize() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        MeshMessagingSettings.enabled = true
        defer { UserDefaults.standard.removeObject(forKey: MeshMessagingSettings.enabledKey) }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
        let projectID = try #require(project?.id)
        let session = try await store.createSession(
            in: store.project(projectID)!, title: "s", prompt: "go")

        // No destination at all.
        #expect(await store.sendMeshMessage(
            from: session, request: MeshSendRequest(to: nil, sessionID: nil, topic: nil, body: "x"))
            .contains("destination"))
        // Unknown host.
        #expect(await store.sendMeshMessage(
            from: session,
            request: MeshSendRequest(to: "Nonexistent Mac", sessionID: nil, topic: nil, body: "x"))
            .contains("No mesh host"))
        // Oversized body.
        let huge = String(repeating: "a", count: MeshMessage.maxBodyBytes + 1)
        #expect(await store.sendMeshMessage(
            from: session, request: MeshSendRequest(to: nil, sessionID: nil, topic: "t", body: huge))
            .contains("too large"))
    }
}
