import Foundation
import Testing
import NucleicProtocol

@testable import NucleicCore

/// The cast table (mesh casting, GRDB v27) — seq assignment, compaction, retention, and the
/// idempotent-ingest changed-guard that terminates re-forward gossip loops.
@Suite("Cast persistence — in-memory SQLite")
struct CastStoreTests {
    let origin = String(repeating: "aa", count: 32)
    let peer = String(repeating: "bb", count: 32)
    let now = Date(timeIntervalSince1970: 1_700_000_000)

    func makeStore() throws -> GRDBMetadataStore {
        try GRDBMetadataStore(path: nil)
    }

    func streamCast(_ seq: UInt64, origin: String? = nil, channel: String = "activity") -> WireCast {
        WireCast(
            originHostID: origin ?? self.origin, channel: channel, castSeq: seq, mode: .stream,
            typeID: "t.v1", payload: Data([UInt8(truncatingIfNeeded: seq)]), sentAt: now)
    }

    @Test func publishAssignsMonotonicSeqPerChannel() async throws {
        let store = try makeStore()
        let first = try await store.publishCast(
            origin: origin, channel: "activity", mode: .stream, key: nil,
            typeID: "t.v1", payload: Data(), deleted: false, sentAt: now)
        let second = try await store.publishCast(
            origin: origin, channel: "activity", mode: .stream, key: nil,
            typeID: "t.v1", payload: Data(), deleted: false, sentAt: now)
        let other = try await store.publishCast(
            origin: origin, channel: "messages", mode: .stream, key: nil,
            typeID: "t.v1", payload: Data(), deleted: false, sentAt: now)
        #expect(first.castSeq == 1)
        #expect(second.castSeq == 2)
        #expect(other.castSeq == 1)  // independent counter per channel

        let tips = try await store.castTips().sorted { $0.channel < $1.channel }
        #expect(tips.map(\.channel) == ["activity", "messages"])
        #expect(tips.first?.tipSeq == 2)
        #expect(tips.first?.floorSeq == 1)
    }

    @Test func stateChannelCompactsToLatestPerKey() async throws {
        let store = try makeStore()
        for i in 1...3 {
            _ = try await store.publishCast(
                origin: origin, channel: "runner.presence", mode: .state, key: "hostA",
                typeID: "p.v1", payload: Data([UInt8(i)]), deleted: false, sentAt: now)
        }
        _ = try await store.publishCast(
            origin: origin, channel: "runner.presence", mode: .state, key: "hostB",
            typeID: "p.v1", payload: Data([9]), deleted: false, sentAt: now)

        let snapshot = try await store.castStateSnapshot(origin: origin, channel: "runner.presence")
        #expect(snapshot.count == 2)  // exactly one row per key
        #expect(snapshot.first(where: { $0.key == "hostA" })?.payload == Data([3]))

        // A `deleted` tombstone becomes the surviving latest value for its key.
        _ = try await store.publishCast(
            origin: origin, channel: "runner.presence", mode: .state, key: "hostA",
            typeID: "p.v1", payload: Data(), deleted: true, sentAt: now)
        let after = try await store.castStateSnapshot(origin: origin, channel: "runner.presence")
        #expect(after.first(where: { $0.key == "hostA" })?.deleted == true)
        #expect(after.count == 2)
    }

    @Test func streamRetentionKeepsNewestMaxEvents() async throws {
        let store = try makeStore()
        // "messages" policy caps at 200 — publish past the cap.
        for _ in 1...205 {
            _ = try await store.publishCast(
                origin: origin, channel: "messages", mode: .stream, key: nil,
                typeID: "m.v1", payload: Data(), deleted: false, sentAt: now)
        }
        let tips = try await store.castTips()
        let tip = try #require(tips.first(where: { $0.channel == "messages" }))
        #expect(tip.tipSeq == 205)
        #expect(tip.floorSeq == 6)  // 200 newest retained; 1...5 swept
    }

    @Test func ingestIsIdempotentAndReturnsOnlyFreshRows() async throws {
        let store = try makeStore()
        let casts = [streamCast(1, origin: peer), streamCast(2, origin: peer)]
        let firstPass = try await store.ingestCasts(casts)
        #expect(firstPass.map(\.castSeq) == [1, 2])
        // The changed-guard: a second delivery inserts nothing ⇒ re-broadcasts nothing.
        let secondPass = try await store.ingestCasts(casts)
        #expect(secondPass.isEmpty)
        // A mixed batch returns only the new row.
        let thirdPass = try await store.ingestCasts([streamCast(2, origin: peer), streamCast(3, origin: peer)])
        #expect(thirdPass.map(\.castSeq) == [3])
    }

    @Test func replaceCastsRebaselinesOneOriginChannel() async throws {
        let store = try makeStore()
        _ = try await store.ingestCasts([streamCast(1, origin: peer), streamCast(2, origin: peer)])
        _ = try await store.ingestCasts([streamCast(1, origin: peer, channel: "messages")])
        try await store.replaceCasts(
            origin: peer, channel: "activity", with: [streamCast(9, origin: peer)])
        let activity = try await store.castsAfter(origin: peer, channel: "activity", afterSeq: 0)
        #expect(activity.map(\.castSeq) == [9])
        // The other channel is untouched.
        let messages = try await store.castsAfter(origin: peer, channel: "messages", afterSeq: 0)
        #expect(messages.map(\.castSeq) == [1])
    }

    @Test func removeCastsDropsOneOriginOnly() async throws {
        let store = try makeStore()
        _ = try await store.ingestCasts([streamCast(1, origin: peer)])
        _ = try await store.ingestCasts([streamCast(1)])
        try await store.removeCasts(origin: peer)
        #expect(try await store.castsAfter(origin: peer, channel: "activity", afterSeq: 0).isEmpty)
        #expect(try await store.castsAfter(origin: origin, channel: "activity", afterSeq: 0).count == 1)
    }

    @Test func castsAfterServesTheGapInOrder() async throws {
        let store = try makeStore()
        _ = try await store.ingestCasts((1...5).map { streamCast($0, origin: peer) })
        let gap = try await store.castsAfter(origin: peer, channel: "activity", afterSeq: 3)
        #expect(gap.map(\.castSeq) == [4, 5])
    }
}
