import Foundation
import Testing

@testable import NucleicCore
import NucleicProtocol

/// CARBON_SHARDING §15 phase 3 (service half): enrollment as an honest inability, the
/// produce-on-tick pipeline (worktree + session history + project-base history), cadence
/// gating, head announcements, and key custody with injectable escrow.
@Suite("Carbon service", .serialized)
struct CarbonServiceTests {
    private let projectUUID = UUID(uuidString: "0AC0FFEE-1234-4567-89AB-CDEF01234567")!

    private struct Fixture {
        let git = GitRunner()
        let root: URL
        var repo: String { root.appendingPathComponent("repo").path }
        var sessionWT: String { root.appendingPathComponent("wt").path }
        var carbonRoot: URL { root.appendingPathComponent("carbon-root") }

        @discardableResult
        func sh(_ args: [String], in path: String) async throws -> String {
            try await git.checked(args, in: path)
        }

        func write(_ name: String, _ contents: String, in dir: String) throws {
            try Data(contents.utf8).write(
                to: URL(fileURLWithPath: dir).appendingPathComponent(name))
        }
    }

    private func makeFixture() async throws -> Fixture {
        let root = FileManager.default.temporaryDirectory
            .appendingPathComponent("carbon-service-\(UUID().uuidString)", isDirectory: true)
        let f = Fixture(root: root)
        try FileManager.default.createDirectory(
            at: URL(fileURLWithPath: f.repo), withIntermediateDirectories: true)
        try await f.sh(["init", "-b", "main"], in: f.repo)
        try await f.sh(["config", "user.name", "Fixture"], in: f.repo)
        try await f.sh(["config", "user.email", "fixture@test.local"], in: f.repo)
        try f.write("README.md", "carbon service fixture\n", in: f.repo)
        try await f.sh(["add", "-A"], in: f.repo)
        try await f.sh(["commit", "-m", "init"], in: f.repo)
        return f
    }

    @MainActor
    private func makeService(_ f: Fixture) -> CarbonService {
        CarbonService(root: f.carbonRoot, ownerDeviceID: { "host-A" })
    }

    @MainActor
    private func makeSession(_ f: Fixture) async throws -> CarbonService.SessionInput {
        try await f.sh(["worktree", "add", f.sessionWT, "-b", "nucleic/svc-session", "main"], in: f.repo)
        try f.write("work.txt", "uncommitted work\n", in: f.sessionWT)
        return CarbonService.SessionInput(
            sessionID: "svc-1", worktreePath: f.sessionWT, branch: "nucleic/svc-session",
            baseSHA: try await f.sh(["rev-parse", "main"], in: f.repo),
            project: CarbonService.ProjectInput(
                uuid: projectUUID, rootPath: f.repo, defaultBranch: "main"))
    }

    // MARK: - Produce pipeline

    @MainActor
    @Test func tickProducesAllStreamsAndAnnouncesHeads() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let service = makeService(f)
        let session = try await makeSession(f)

        var announced: [CarbonStreamKey] = []
        service.onHeadAdvanced = { announced.append($0.key) }
        await service.tick(sessions: [session])

        let heads = await service.store.heads(projectUUID: projectUUID)
        // The worktree snapshot, the session-branch history chain, and the per-project base
        // chain (the future release fence's carrier) all advanced.
        #expect(heads[CarbonStreamKey(
            projectUUID: projectUUID, stream: .worktreeSnapshot, sessionID: "svc-1")] != nil)
        #expect(heads[CarbonStreamKey(
            projectUUID: projectUUID, stream: .history, sessionID: "svc-1")] != nil)
        #expect(heads[CarbonStreamKey(
            projectUUID: projectUUID, stream: .history, sessionID: nil)] != nil)
        #expect(Set(announced) == Set(heads.keys))

        // The serve-all snapshot covers this project (the bridge's nil-project answer).
        #expect(await service.headsSnapshot(projectUUID: nil).count == heads.count)
    }

    @MainActor
    @Test func cadenceGatesRepeatTicksButTurnBoundaryForces() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let service = makeService(f)
        let session = try await makeSession(f)

        await service.tick(sessions: [session])
        let key = CarbonStreamKey(
            projectUUID: projectUUID, stream: .worktreeSnapshot, sessionID: "svc-1")
        let first = await service.store.heads(projectUUID: projectUUID)[key]

        // A new edit, but the cadence window hasn't elapsed ⇒ the plain tick skips it.
        try f.write("work.txt", "edited again\n", in: f.sessionWT)
        await service.tick(sessions: [session])
        #expect(await service.store.heads(projectUUID: projectUUID)[key] == first)

        // A turn boundary always snapshots (D8) — tickSession forces the capture.
        await service.tickSession(session)
        let second = await service.store.heads(projectUUID: projectUUID)[key]
        #expect(second != first)
        #expect(second?.snapshotSeq == (first?.snapshotSeq ?? 0) + 1)
    }

    @MainActor
    @Test func localOnlyProjectDetectedAndProducesWholeGraphChain() async throws {
        let f = try await makeFixture()  // no origin remote ⇒ localOnly (D9)
        defer { try? FileManager.default.removeItem(at: f.root) }
        let service = makeService(f)
        let session = try await makeSession(f)
        await service.tick(sessions: [session])

        let baseKey = CarbonStreamKey(projectUUID: projectUUID, stream: .history, sessionID: nil)
        let head = try #require(await service.store.heads(projectUUID: projectUUID)[baseKey])
        let manifest = try #require(
            try await service.store.manifest(id: head.manifestID, projectUUID: projectUUID))
        #expect(manifest.project.localOnly == true)
        // No origin negative ⇒ the pack carries the whole reachable graph (non-empty).
        #expect(!manifest.shards.isEmpty)
    }

    // MARK: - Enrollment (honest inability, §17.6)

    @MainActor
    @Test func shallowCloneIsBlockedNotEnrolled() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        // A shallow clone of the fixture repo — the §3 gate must refuse it, with a reason.
        let shallowPath = f.root.appendingPathComponent("shallow").path
        try await f.sh(
            ["clone", "--quiet", "--depth", "1", "file://\(f.repo)", shallowPath],
            in: f.root.path)

        let service = makeService(f)
        let blocked = CarbonService.ProjectInput(
            uuid: UUID(), rootPath: shallowPath, defaultBranch: "main")
        #expect(await service.enrollmentState(for: blocked) == .blocked(reason: "shallow clone"))
        let fine = CarbonService.ProjectInput(
            uuid: projectUUID, rootPath: f.repo, defaultBranch: "main")
        #expect(await service.enrollmentState(for: fine) == .enrolled)

        // A blocked project produces nothing on tick.
        let session = CarbonService.SessionInput(
            sessionID: "blocked-1", worktreePath: shallowPath, branch: "main", baseSHA: nil,
            project: blocked)
        await service.tick(sessions: [session])
        #expect(await service.store.heads(projectUUID: blocked.uuid).isEmpty)
    }

    // MARK: - Key custody

    @Test func custodyPersistsAndRecoversViaEscrow() throws {
        let root = FileManager.default.temporaryDirectory
            .appendingPathComponent("carbon-custody-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: root) }
        let uuid = UUID()

        // Generate + persist; a fresh custody over the same root rereads the same key.
        let custody = CarbonKeyCustody(root: root)
        let key = custody.ensureKey(for: uuid)
        #expect(CarbonKeyCustody(root: root).key(for: uuid) == key)
        // A mirror never mints: absent key + no escrow ⇒ nil.
        #expect(custody.key(for: UUID()) == nil)

        // Total-device-loss: a custody with no file but a recover closure (the iCloud
        // Keychain stand-in) recovers and re-caches to disk.
        let lostRoot = root.appendingPathComponent("replacement", isDirectory: true)
        let recovered = CarbonKeyCustody(
            root: lostRoot, recover: { asked in asked == uuid ? key : nil })
        #expect(recovered.key(for: uuid) == key)
        #expect(CarbonKeyCustody(root: lostRoot).key(for: uuid) == key)  // re-cached
    }

    @Test func escrowFailureSurfacesAndRetries() {
        let root = FileManager.default.temporaryDirectory
            .appendingPathComponent("carbon-custody-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: root) }
        let uuid = UUID()
        final class EscrowBox: @unchecked Sendable {
            var fail = true
            var attempts = 0
        }
        let box = EscrowBox()
        struct EscrowDown: Error {}
        let custody = CarbonKeyCustody(
            root: root,
            escrow: { _, _ in
                box.attempts += 1
                if box.fail { throw EscrowDown() }
            })

        _ = custody.ensureKey(for: uuid)
        #expect(custody.escrowPending(for: uuid))  // enrolled anyway; chip says not escrowed
        box.fail = false
        _ = custody.ensureKey(for: uuid)  // retried on next ensure
        #expect(!custody.escrowPending(for: uuid))
        #expect(box.attempts == 2)
    }

    // MARK: - Viewer snapshot (§12)

    /// The Carbon Viewer reads `storeSnapshot`, not the raw store — so a real produce pass must
    /// show up in it. This is the regression guard for the viewer having reported "no Copies
    /// stored on this Mac" while the store held thousands of shards.
    @MainActor
    @Test func storeSnapshotReportsWhatWasActuallyProduced() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let service = makeService(f)
        let session = try await makeSession(f)
        await service.tickSession(session)

        let snapshot = await service.storeSnapshot(peers: [])
        let project = try #require(snapshot.projects.first { $0.uuid == projectUUID })
        #expect(snapshot.projects.count == 1)
        #expect(!project.usage.isEmpty)
        #expect(project.usage.shardCount > 0)
        #expect(project.usage.manifestCount > 0)
        #expect(project.usage.bytes > 0)
        #expect(!project.keyMissing)
        #expect(snapshot.totalShards == project.usage.shardCount)
        #expect(snapshot.totalBytes == project.usage.bytes)
        // A tick produces the worktree + history streams, so heads back the usage numbers.
        #expect(!project.heads.isEmpty)
        #expect(project.heads.contains { $0.key.stream == .worktreeSnapshot })
    }

    /// An empty store is the *honest* empty state — distinguishable from the old hardcoded one
    /// only because it now moves when the store does (covered above).
    @MainActor
    @Test func storeSnapshotOfEmptyStoreIsEmpty() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let snapshot = await makeService(f).storeSnapshot(peers: [])
        #expect(snapshot.projects.isEmpty)
        #expect(snapshot.blocked.isEmpty)
        #expect(snapshot.totalShards == 0)
        #expect(snapshot.totalBytes == 0)
    }

    /// A repo Carbon can't shard is surfaced in the snapshot rather than silently missing —
    /// otherwise the viewer shows an unexplained gap where a project should be (§17.6).
    @MainActor
    @Test func storeSnapshotSurfacesBlockedProjects() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let shallowPath = f.root.appendingPathComponent("shallow").path
        try await f.sh(
            ["clone", "--quiet", "--depth", "1", "file://\(f.repo)", shallowPath],
            in: f.root.path)

        let service = makeService(f)
        let blockedUUID = UUID()
        _ = await service.enrollmentState(
            for: CarbonService.ProjectInput(
                uuid: blockedUUID, rootPath: shallowPath, defaultBranch: "main"))

        let snapshot = await service.storeSnapshot(peers: [])
        #expect(snapshot.blocked.map(\.uuid) == [blockedUUID])
        #expect(snapshot.blocked.first?.reason == "shallow clone")
    }

    /// Peer rows come from gossip, so a peer that has told us nothing must read as *unknown*
    /// (`reported == false`) rather than as a peer we've confirmed holds nothing — the viewer
    /// keys its "No Copies reported yet" wording (and its neutral tint) off exactly this.
    @MainActor
    @Test func storeSnapshotMarksSilentPeersUnreported() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let service = makeService(f)
        let session = try await makeSession(f)
        await service.tickSession(session)

        let snapshot = await service.storeSnapshot(peers: ["host-B"])
        let peer = try #require(snapshot.peers.first)
        #expect(peer.deviceID == "host-B")
        #expect(!peer.reported)
        #expect(peer.caughtUp == 0)
        #expect(peer.behind > 0)  // it covers none of the chains we hold
    }

    @Test func derivedLegacyUUIDIsStableAndWellFormed() {
        let a = CarbonService.derivedUUID(fromLegacyID: "legacy-project-id")
        let b = CarbonService.derivedUUID(fromLegacyID: "legacy-project-id")
        let c = CarbonService.derivedUUID(fromLegacyID: "other-project")
        #expect(a == b)
        #expect(a != c)
        // RFC 4122 version/variant bits so the value round-trips as a real UUID everywhere.
        #expect(a.uuidString.split(separator: "-")[2].first == "4")
    }
}
