import Foundation
import Testing

@testable import NucleicCore
import NucleicProtocol

/// CARBON_SHARDING §17.9 (ledger half): fold determinism (same chain ⇒ same table on every
/// member), compaction-snapshot equivalence, stale-generation events ignored by the fold,
/// and the append discipline (single writer, generation-fenced).
@Suite("Carbon lock ledger", .serialized)
struct CarbonLockLedgerTests {
    private let projectUUID = UUID(uuidString: "0A1B2C3D-4E5F-4671-8899-AABBCCDDEEFF")!
    private let key = try! CarbonCrypto.ProjectKey(bytes: Data((0..<32).map { UInt8($0 &+ 7) }))
    private var project: CarbonManifest.ProjectRef { .init(uuid: projectUUID) }

    private func makeLedger(root: URL, name: String) -> (store: CarbonShardStore, ledger: CarbonLockLedger) {
        let store = CarbonShardStore(
            root: root.appendingPathComponent(name, isDirectory: true), keyProvider: { [key] _ in key })
        return (store, CarbonLockLedger(store: store, keyProvider: { [key] _ in key }))
    }

    private func tempRoot() throws -> URL {
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent("carbon-lock-ledger-\(UUID().uuidString)", isDirectory: true)
        try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
        return url
    }

    private func head(_ seq: UInt64) -> CarbonHead {
        CarbonHead(
            key: CarbonStreamKey(projectUUID: projectUUID, stream: .history, sessionID: nil),
            manifestID: "m-\(seq)", ownerGeneration: 1, snapshotSeq: seq)
    }

    // MARK: - Pure fold

    @Test func foldTracksAcquireQueueRelease() {
        let entries: [CarbonLockLedgerEntry] = [
            .init(generation: 1, event: .init(
                kind: .acquired, domainRef: "dev", sessionID: "A", deviceID: "host1",
                paths: ["x", "y"], ledgerSeq: 1)),
            .init(generation: 1, event: .init(
                kind: .queued, domainRef: "dev", sessionID: "B", deviceID: "host2",
                paths: ["x"], ledgerSeq: 2)),
            .init(generation: 1, event: .init(
                kind: .released, domainRef: "dev", sessionID: "A", deviceID: "host1",
                paths: ["y"], ledgerSeq: 3)),
        ]
        let fold = CarbonLockLedger.fold(entries)
        let dev = fold.domains["dev"]
        #expect(dev?.held["x"]?.sessionID == "A")
        #expect(dev?.held["y"] == nil)  // unfenced release frees everywhere at once
        #expect(dev?.releasing.isEmpty == true)
        #expect(dev?.waiters == [.init(sessionID: "B", deviceID: "host2", paths: ["x"])])
        #expect(fold.ledgerSeq == 3)
    }

    /// A fenced release moves the path to `releasing` (blocked until the observer holds the
    /// heads); the next acquire supersedes the fence (§17.4 soundness — the authority granted
    /// it only past the fence).
    @Test func fencedReleaseBlocksUntilObservedThenAcquireClears() {
        let fence = [head(4)]
        var entries: [CarbonLockLedgerEntry] = [
            .init(generation: 1, event: .init(
                kind: .acquired, domainRef: "dev", sessionID: "A", deviceID: "host1",
                paths: ["x"], ledgerSeq: 1)),
            .init(generation: 1, event: .init(
                kind: .released, domainRef: "dev", sessionID: "A", deviceID: "host1",
                paths: ["x"], requiredHeads: fence, ledgerSeq: 2)),
        ]
        let published = CarbonLockLedger.fold(entries)
        #expect(published.domains["dev"]?.held.isEmpty == true)
        #expect(published.domains["dev"]?.releasing["x"]?.requiredHeads == fence)
        // Observation is per-device (§17.4 stage 2).
        #expect(published.observedBlockedPaths(domainRef: "dev", fenceSatisfied: { _ in false }) == ["x"])
        #expect(published.observedBlockedPaths(domainRef: "dev", fenceSatisfied: { _ in true }).isEmpty)

        entries.append(.init(generation: 1, event: .init(
            kind: .acquired, domainRef: "dev", sessionID: "B", deviceID: "host2",
            paths: ["x"], ledgerSeq: 3)))
        let reacquired = CarbonLockLedger.fold(entries)
        #expect(reacquired.domains["dev"]?.releasing.isEmpty == true)
        #expect(reacquired.domains["dev"]?.held["x"]?.sessionID == "B")
    }

    /// §10 fencing in the fold: a stale authority's events never mutate the table.
    @Test func staleGenerationEventsAreIgnoredByTheFold() {
        let entries: [CarbonLockLedgerEntry] = [
            .init(generation: 2, event: .init(
                kind: .acquired, domainRef: "dev", sessionID: "A", deviceID: "host1",
                paths: ["x"], ledgerSeq: 1)),
            .init(generation: 1, event: .init(
                kind: .released, domainRef: "dev", sessionID: "A", deviceID: "stale",
                paths: ["x"], ledgerSeq: 99)),
        ]
        let fold = CarbonLockLedger.fold(entries)
        #expect(fold.domains["dev"]?.held["x"]?.sessionID == "A")  // stale release ignored
        #expect(fold.generation == 2)
    }

    /// §17.3: the old authority's waiter continuations died with its host — a generation bump
    /// clears waiters; held locks and pending fences persist.
    @Test func authorityChangeClearsWaitersKeepsHeldAndFences() {
        let entries: [CarbonLockLedgerEntry] = [
            .init(generation: 1, event: .init(
                kind: .acquired, domainRef: "dev", sessionID: "A", deviceID: "host1",
                paths: ["x"], ledgerSeq: 1)),
            .init(generation: 1, event: .init(
                kind: .queued, domainRef: "dev", sessionID: "B", deviceID: "host2",
                paths: ["x"], ledgerSeq: 2)),
            .init(generation: 1, event: .init(
                kind: .released, domainRef: "dev", sessionID: "A", deviceID: "host1",
                paths: ["x"], requiredHeads: [head(9)], ledgerSeq: 3)),
            .init(generation: 2, event: .init(
                kind: .authorityChanged, deviceID: "host3", ledgerSeq: 1)),
        ]
        let fold = CarbonLockLedger.fold(entries)
        #expect(fold.domains["dev"]?.waiters.isEmpty == true)
        #expect(fold.domains["dev"]?.releasing["x"] != nil)
        #expect(fold.generation == 2)
    }

    // MARK: - Store-backed append + fold

    @Test func appendFoldRoundTripMatchesAcrossMembers() async throws {
        let root = try tempRoot()
        defer { try? FileManager.default.removeItem(at: root) }
        let owner = makeLedger(root: root, name: "owner")
        _ = try await owner.ledger.append(
            CarbonLockEvent(kind: .authorityChanged, deviceID: "host1"),
            project: project, ownerDeviceID: "host1", generation: 1)
        _ = try await owner.ledger.append(
            CarbonLockEvent(
                kind: .acquired, domainRef: "dev", sessionID: "A", deviceID: "host1",
                paths: ["src/x.swift"]),
            project: project, ownerDeviceID: "host1", generation: 1)
        let ledgerHead = try await owner.ledger.append(
            CarbonLockEvent(
                kind: .released, domainRef: "dev", sessionID: "A", deviceID: "host1",
                paths: ["src/x.swift"], requiredHeads: [head(3)]),
            project: project, ownerDeviceID: "host1", generation: 1)
        #expect(ledgerHead.key.stream == .lock)
        #expect(ledgerHead.snapshotSeq == 3)

        // A mirror that syncs the chain folds the identical table (fold determinism §17.9).
        let mirror = makeLedger(root: root, name: "mirror")
        let mesh = MeshStores(["owner": owner.store])
        let coordinator = CarbonSyncCoordinator(
            store: mirror.store, keyProvider: { [key] _ in key }, transport: mesh.transport())
        await coordinator.observeHeadAdvanced(ledgerHead, from: "owner")

        let ownerFold = try await owner.ledger.fold(projectUUID: projectUUID)
        let mirrorFold = try await mirror.ledger.fold(projectUUID: projectUUID)
        #expect(ownerFold == mirrorFold)
        #expect(mirrorFold.domains["dev"]?.releasing["src/x.swift"]?.requiredHeads == [head(3)])
    }

    /// §17.2 compaction: replay from the snapshot equals replay of the full history.
    @Test func compactionSnapshotIsEquivalent() async throws {
        let root = try tempRoot()
        defer { try? FileManager.default.removeItem(at: root) }
        let a = makeLedger(root: root, name: "a")      // compacts mid-stream
        let b = makeLedger(root: root, name: "b")      // never compacts
        func appendBoth(_ event: CarbonLockEvent) async throws {
            _ = try await a.ledger.append(event, project: project, ownerDeviceID: "h", generation: 1)
            _ = try await b.ledger.append(event, project: project, ownerDeviceID: "h", generation: 1)
        }
        try await appendBoth(.init(kind: .acquired, domainRef: "dev", sessionID: "A", deviceID: "h", paths: ["x", "y"]))
        try await appendBoth(.init(kind: .queued, domainRef: "dev", sessionID: "B", deviceID: "h", paths: ["y"]))
        try await appendBoth(.init(kind: .released, domainRef: "dev", sessionID: "A", deviceID: "h", paths: ["x"], requiredHeads: [head(2)]))
        _ = try await a.ledger.compact(project: project, ownerDeviceID: "h", generation: 1)
        try await appendBoth(.init(kind: .acquired, domainRef: "main", sessionID: "C", deviceID: "h", paths: ["z"]))

        let foldA = try await a.ledger.fold(projectUUID: projectUUID)
        let foldB = try await b.ledger.fold(projectUUID: projectUUID)
        #expect(foldA.domains == foldB.domains)  // seq counters differ (the snapshot consumed one)
    }

    /// A stale authority (lower generation than the chain head) can never append (§10).
    @Test func staleAuthorityAppendThrows() async throws {
        let root = try tempRoot()
        defer { try? FileManager.default.removeItem(at: root) }
        let owner = makeLedger(root: root, name: "owner")
        _ = try await owner.ledger.append(
            CarbonLockEvent(kind: .authorityChanged, deviceID: "h2"),
            project: project, ownerDeviceID: "h2", generation: 2)
        await #expect(throws: CarbonStoreError.self) {
            _ = try await owner.ledger.append(
                CarbonLockEvent(
                    kind: .acquired, domainRef: "dev", sessionID: "A", deviceID: "h1",
                    paths: ["x"]),
                project: project, ownerDeviceID: "h1", generation: 1)
        }
    }
}

/// Minimal in-memory mesh for chain-sync in these tests: every named store is reachable.
struct MeshStores: Sendable {
    let stores: [String: CarbonShardStore]
    init(_ stores: [String: CarbonShardStore]) { self.stores = stores }

    func transport() -> CarbonSyncCoordinator.PeerTransport {
        let stores = self.stores
        return CarbonSyncCoordinator.PeerTransport(
            fetchHeads: { peer, project in
                guard let store = stores[peer], let project else { return nil }
                return Array(await store.heads(projectUUID: project).values)
            },
            fetchManifests: { peer, project, ids in
                guard let store = stores[peer] else { return [:] }
                var out: [String: Data] = [:]
                for id in ids {
                    if let blob = await store.manifestBlob(id: id, projectUUID: project) { out[id] = blob }
                }
                return out
            },
            fetchShards: { peer, project, ids in
                guard let store = stores[peer] else { return [:] }
                var out: [String: Data] = [:]
                for id in ids {
                    if let blob = await store.shard(id: id, projectUUID: project) { out[id] = blob }
                }
                return out
            })
    }
}
