import Foundation
import Testing

@testable import NucleicCore
import NucleicProtocol

/// CARBON_SHARDING §16 "Store": idempotent put, verify-on-write/read + quarantine, head
/// fencing, GC that never collects anything reachable, eviction as a directory drop.
@Suite("Carbon shard store")
struct CarbonShardStoreTests {
    private let projectUUID = UUID(uuidString: "0B1C2D3E-4F50-6172-8394-A5B6C7D8E9F0")!
    private let key = try! CarbonCrypto.ProjectKey(bytes: Data((0..<32).map { UInt8($0) }))
    private var crypto: CarbonCrypto { CarbonCrypto(projectKey: key) }

    private func makeStore(root: URL, keyKnown: Bool = true) -> CarbonShardStore {
        let held = keyKnown ? key : nil
        return CarbonShardStore(root: root, keyProvider: { _ in held })
    }

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

    /// Seal + put a payload as one shard; returns its ref.
    private func putShard(
        _ store: CarbonShardStore, payload: String
    ) async throws -> CarbonManifest.ShardRef {
        let sealed = try crypto.seal(Data(payload.utf8))
        try await store.putShard(
            ciphertext: sealed.ciphertext, expectedID: sealed.id, projectUUID: projectUUID)
        return .init(id: sealed.id, byteCount: UInt64(sealed.ciphertext.count))
    }

    /// Build, seal, and put a manifest; returns (id, manifest).
    @discardableResult
    private func putManifest(
        _ store: CarbonShardStore, stream: CarbonStreamKind, sessionID: String?,
        gen: UInt64, seq: UInt64, parent: String? = nil, shards: [CarbonManifest.ShardRef]
    ) async throws -> (id: String, manifest: CarbonManifest) {
        let manifest = CarbonManifest(
            project: .init(uuid: projectUUID), stream: stream, sessionID: sessionID,
            ownerDeviceID: "host-A", ownerGeneration: gen, snapshotSeq: seq,
            parentManifest: parent, payloadKind: .gitPack, shards: shards,
            createdAtMs: 1_752_000_000_000 + seq)
        let sealed = try manifest.sealed(with: crypto)
        try await store.putManifest(
            ciphertext: sealed.ciphertext, expectedID: sealed.id, projectUUID: projectUUID)
        return (sealed.id, manifest)
    }

    // MARK: - Blob IO

    @Test func putAndGetRoundTrip() async throws {
        let store = makeStore(root: try tempRoot())
        let ref = try await putShard(store, payload: "hello shards")
        let blob = await store.shard(id: ref.id, projectUUID: projectUUID)
        #expect(blob != nil)
        #expect(try crypto.open(blob!) == Data("hello shards".utf8))
        #expect(await store.hasShard(id: ref.id, projectUUID: projectUUID))
    }

    @Test func putIsIdempotent() async throws {
        let store = makeStore(root: try tempRoot())
        let sealed = try crypto.seal(Data("twice".utf8))
        try await store.putShard(
            ciphertext: sealed.ciphertext, expectedID: sealed.id, projectUUID: projectUUID)
        // Second put of the same ID: no throw, still readable.
        try await store.putShard(
            ciphertext: sealed.ciphertext, expectedID: sealed.id, projectUUID: projectUUID)
        #expect(await store.shard(id: sealed.id, projectUUID: projectUUID) != nil)
    }

    @Test func putRejectsIDMismatchAndStoresNothing() async throws {
        let root = try tempRoot()
        let store = makeStore(root: root)
        let sealed = try crypto.seal(Data("mismatch".utf8))
        let wrongID = String(repeating: "0", count: 64)
        await #expect(throws: (any Error).self) {
            try await store.putShard(
                ciphertext: sealed.ciphertext, expectedID: wrongID, projectUUID: projectUUID)
        }
        #expect(await store.shard(id: wrongID, projectUUID: projectUUID) == nil)
        #expect(await store.shard(id: sealed.id, projectUUID: projectUUID) == nil)
    }

    @Test func corruptBlobIsQuarantinedOnRead() async throws {
        let root = try tempRoot()
        let store = makeStore(root: root)
        let ref = try await putShard(store, payload: "will rot on disk")
        // Corrupt the stored file directly — a bit-rotted mirror.
        let file = root
            .appendingPathComponent(projectUUID.uuidString, isDirectory: true)
            .appendingPathComponent("objects", isDirectory: true)
            .appendingPathComponent(String(ref.id.prefix(2)), isDirectory: true)
            .appendingPathComponent(ref.id)
        var bytes = try Data(contentsOf: file)
        bytes[bytes.count / 2] ^= 0xFF
        try bytes.write(to: file)

        // Read degrades to "doesn't have it" and moves the blob aside.
        #expect(await store.shard(id: ref.id, projectUUID: projectUUID) == nil)
        #expect(!FileManager.default.fileExists(atPath: file.path))
        let quarantined = root
            .appendingPathComponent(projectUUID.uuidString, isDirectory: true)
            .appendingPathComponent("objects", isDirectory: true)
            .appendingPathComponent("quarantine", isDirectory: true)
            .appendingPathComponent(ref.id)
        #expect(FileManager.default.fileExists(atPath: quarantined.path))
    }

    // MARK: - Heads

    @Test func headAdvanceEnforcesFencingAndPersists() async throws {
        let root = try tempRoot()
        let store = makeStore(root: root)
        let streamKey = CarbonStreamKey(
            projectUUID: projectUUID, stream: .worktreeSnapshot, sessionID: "s1")
        let head = { (gen: UInt64, seq: UInt64) in
            CarbonHead(key: streamKey, manifestID: "m-\(gen)-\(seq)", ownerGeneration: gen, snapshotSeq: seq)
        }
        try await store.advanceHead(head(1, 1))
        try await store.advanceHead(head(1, 2))  // same gen, higher seq: ok
        try await store.advanceHead(head(2, 1))  // higher gen, seq resets: ok

        // §10 rule — no exceptions: lower gen, equal (gen, seq), lower seq all refuse.
        for stale in [head(1, 99), head(2, 1), head(2, 0)] {
            await #expect(throws: (any Error).self) { try await store.advanceHead(stale) }
        }
        #expect(await store.heads(projectUUID: projectUUID)[streamKey]?.manifestID == "m-2-1")

        // Heads survive a store re-init (heads.json).
        let reopened = makeStore(root: root)
        #expect(await reopened.heads(projectUUID: projectUUID)[streamKey]?.snapshotSeq == 1)
        #expect(await reopened.heads(projectUUID: projectUUID)[streamKey]?.ownerGeneration == 2)
    }

    @Test func missingShardsListsExactlyTheAbsent() async throws {
        let store = makeStore(root: try tempRoot())
        let held = try await putShard(store, payload: "held")
        let absent = CarbonManifest.ShardRef(id: String(repeating: "a", count: 64), byteCount: 9)
        let manifest = CarbonManifest(
            project: .init(uuid: projectUUID), stream: .history, ownerDeviceID: "h",
            ownerGeneration: 1, snapshotSeq: 1, payloadKind: .gitPack,
            shards: [held, absent], createdAtMs: 0)
        #expect(await store.missingShards(for: manifest) == [absent.id])
    }

    @Test func manifestDecodeRequiresKey() async throws {
        let root = try tempRoot()
        let writer = makeStore(root: root)
        let shard = try await putShard(writer, payload: "payload")
        let (id, original) = try await putManifest(
            writer, stream: .history, sessionID: nil, gen: 1, seq: 1, shards: [shard])

        // Keyed store decodes; keyless store serves the blob but throws on decode.
        #expect(try await writer.manifest(id: id, projectUUID: projectUUID) == original)
        let keyless = makeStore(root: root, keyKnown: false)
        #expect(await keyless.manifestBlob(id: id, projectUUID: projectUUID) != nil)
        await #expect(throws: (any Error).self) {
            _ = try await keyless.manifest(id: id, projectUUID: projectUUID)
        }
    }

    // MARK: - GC

    /// The §16 property: GC never collects anything reachable from a retained manifest or a
    /// live head — exercised over a 12-deep snapshot chain (K=8), a history chain, and a
    /// shard shared between a collected and a retained manifest (dedup must not double-free).
    @Test func gcRetainsReachableAndSweepsTheRest() async throws {
        let store = makeStore(root: try tempRoot())
        let shared = try await putShard(store, payload: "shared-across-manifests")

        var snapshotIDs: [String] = []
        var perManifestShards: [CarbonManifest.ShardRef] = []
        var parent: String?
        for seq in 1...12 {
            let own = try await putShard(store, payload: "snapshot-\(seq)")
            perManifestShards.append(own)
            let (id, manifest) = try await putManifest(
                store, stream: .worktreeSnapshot, sessionID: "s1", gen: 1, seq: UInt64(seq),
                parent: parent, shards: [own, shared])
            snapshotIDs.append(id)
            parent = id
            if seq == 12 { try await store.advanceHead(manifest.head(manifestID: id)) }
        }
        let historyShard = try await putShard(store, payload: "history-pack")
        let (historyID, historyManifest) = try await putManifest(
            store, stream: .history, sessionID: "s1", gen: 1, seq: 1, shards: [historyShard])
        try await store.advanceHead(historyManifest.head(manifestID: historyID))

        let report = try await store.collectGarbage(
            project: projectUUID,
            policy: CarbonRetention(snapshotManifestsPerChain: 8, sweepMinAge: 0))

        // The 4 oldest snapshot manifests and their exclusive shards are gone…
        #expect(report.manifestsRemoved == 4)
        #expect(report.shardsRemoved == 4)
        for id in snapshotIDs.prefix(4) {
            #expect(await store.manifestBlob(id: id, projectUUID: projectUUID) == nil)
        }
        for ref in perManifestShards.prefix(4) {
            #expect(await store.shard(id: ref.id, projectUUID: projectUUID) == nil)
        }
        // …and everything reachable from retained manifests/heads is intact.
        for id in snapshotIDs.suffix(8) + [historyID] {
            #expect(await store.manifestBlob(id: id, projectUUID: projectUUID) != nil)
        }
        for ref in perManifestShards.suffix(8) + [shared, historyShard] {
            #expect(await store.shard(id: ref.id, projectUUID: projectUUID) != nil)
        }
    }

    @Test func gcWithoutKeyRetainsEverything() async throws {
        let root = try tempRoot()
        let writer = makeStore(root: root)
        let shard = try await putShard(writer, payload: "opaque")
        try await putManifest(writer, stream: .history, sessionID: "s1", gen: 1, seq: 1, shards: [shard])

        let keyless = makeStore(root: root, keyKnown: false)
        let report = try await keyless.collectGarbage(
            project: projectUUID, policy: CarbonRetention(sweepMinAge: 0))
        #expect(report == CarbonGCReport())
        #expect(await keyless.shard(id: shard.id, projectUUID: projectUUID) != nil)
    }

    @Test func gcDropsEndedSessionChains() async throws {
        let store = makeStore(root: try tempRoot())
        let ended = try await putShard(store, payload: "ended-session")
        try await putManifest(store, stream: .history, sessionID: "gone", gen: 1, seq: 1, shards: [ended])
        let live = try await putShard(store, payload: "live-session")
        try await putManifest(store, stream: .history, sessionID: "live", gen: 1, seq: 1, shards: [live])

        let report = try await store.collectGarbage(
            project: projectUUID,
            policy: CarbonRetention(droppedSessionIDs: ["gone"], sweepMinAge: 0))
        #expect(report.manifestsRemoved == 1)
        #expect(await store.shard(id: ended.id, projectUUID: projectUUID) == nil)
        #expect(await store.shard(id: live.id, projectUUID: projectUUID) != nil)
    }

    @Test func gcAgeFloorProtectsYoungBlobs() async throws {
        let store = makeStore(root: try tempRoot())
        // An orphan shard (no manifest references it) — but younger than the floor.
        let orphan = try await putShard(store, payload: "landed before its manifest")
        let report = try await store.collectGarbage(
            project: projectUUID, policy: CarbonRetention(sweepMinAge: 48 * 3600))
        #expect(report.shardsRemoved == 0)
        #expect(await store.shard(id: orphan.id, projectUUID: projectUUID) != nil)
    }

    @Test func gcUnretainedProjectSweepsAll() async throws {
        let store = makeStore(root: try tempRoot())
        let shard = try await putShard(store, payload: "ex-replica data")
        try await putManifest(store, stream: .history, sessionID: "s1", gen: 1, seq: 1, shards: [shard])
        let report = try await store.collectGarbage(
            project: projectUUID,
            policy: CarbonRetention(projectUnretained: true, sweepMinAge: 0))
        #expect(report.manifestsRemoved == 1)
        #expect(report.shardsRemoved == 1)
    }

    // MARK: - Usage (§12 — what the Carbon Viewer reports)

    @Test func usageCountsHeldBlobsAndBytes() async throws {
        let store = makeStore(root: try tempRoot())
        let a = try await putShard(store, payload: "shard one")
        let b = try await putShard(store, payload: "shard two")
        try await putManifest(
            store, stream: .history, sessionID: "s1", gen: 1, seq: 1, shards: [a, b])

        let usage = await store.usage(project: projectUUID)
        #expect(usage.shardCount == 2)
        #expect(usage.manifestCount == 1)
        #expect(usage.quarantinedCount == 0)
        #expect(!usage.isEmpty)
        // Bytes cover shards + manifest, and exclude `heads.json` (bookkeeping, not content).
        #expect(usage.bytes >= a.byteCount + b.byteCount)
    }

    /// The tally is rate-limited so a polling viewer can't rewalk tens of thousands of blobs
    /// every tick — but each refresh is a fresh walk, so the numbers can't drift from disk.
    @Test func usageIsRateLimitedButNeverDrifts() async throws {
        let store = makeStore(root: try tempRoot())
        let t0 = Date()
        _ = try await putShard(store, payload: "first")
        #expect(await store.usage(project: projectUUID, now: t0).shardCount == 1)

        _ = try await putShard(store, payload: "second")
        // Inside the TTL: the cached tally is served, so the new shard isn't visible yet.
        #expect(await store.usage(project: projectUUID, now: t0).shardCount == 1)
        // Past it: rewalked from the filesystem, which is the only source of truth.
        let later = t0.addingTimeInterval(CarbonShardStore.usageCacheTTL + 1)
        #expect(await store.usage(project: projectUUID, now: later).shardCount == 2)
    }

    @Test func evictClearsTheUsageTally() async throws {
        let store = makeStore(root: try tempRoot())
        let t0 = Date()
        _ = try await putShard(store, payload: "evict me")
        #expect(await store.usage(project: projectUUID, now: t0).shardCount == 1)
        try await store.evict(project: projectUUID)
        // Same instant, so only invalidation (not the TTL) can make this honest.
        #expect(await store.usage(project: projectUUID, now: t0).isEmpty)
    }

    @Test func usageOfUnknownProjectIsEmpty() async throws {
        let store = makeStore(root: try tempRoot())
        let usage = await store.usage(project: UUID())
        #expect(usage.isEmpty)
        #expect(usage.bytes == 0)
    }

    /// A quarantined blob is still bytes on disk, but this holder has stopped claiming it —
    /// it must not inflate `shardCount` (§7.2), or the viewer would report copies that the
    /// store will honestly answer `notHeld` for.
    @Test func usageSplitsQuarantinedFromHeld() async throws {
        let root = try tempRoot()
        let store = makeStore(root: root)
        let ref = try await putShard(store, payload: "rots on disk")
        let file = root
            .appendingPathComponent(projectUUID.uuidString, isDirectory: true)
            .appendingPathComponent("objects", isDirectory: true)
            .appendingPathComponent(String(ref.id.prefix(2)), isDirectory: true)
            .appendingPathComponent(ref.id)
        try Data("corrupted".utf8).write(to: file)
        _ = await store.shard(id: ref.id, projectUUID: projectUUID)  // verify-on-read quarantines

        let usage = await store.usage(project: projectUUID)
        #expect(usage.shardCount == 0)
        #expect(usage.quarantinedCount == 1)
        #expect(usage.bytes > 0)
    }

    @Test func evictIsADirectoryDrop() async throws {
        let root = try tempRoot()
        let store = makeStore(root: root)
        let ref = try await putShard(store, payload: "evict me")
        try await store.evict(project: projectUUID)
        #expect(await store.shard(id: ref.id, projectUUID: projectUUID) == nil)
        #expect(
            !FileManager.default.fileExists(
                atPath: root.appendingPathComponent(projectUUID.uuidString).path))
    }
}
