import Foundation
import NucleicProtocol

/// The host-side Carbon orchestrator `AppStore` owns (docs/CARBON_SHARDING.md §15 phase 3):
/// one store + key custody + producer + (when the mesh is up) a sync coordinator, behind a
/// small surface so `AppStore`'s own footprint stays glue-sized. Carbon is **mandatory**
/// (D17): the producer runs whenever the app runs — a mesh of one still snapshots locally
/// (that alone powers the restore browser); replication activates when peers exist.
///
/// `AppStore` resolves *what* to snapshot (live sessions, their projects) each pass and hands
/// it in as plain inputs; this service owns cadence, enrollment gating, key custody, and the
/// produce → head-advance pipeline. Enrollment is per-repo-shape *inability*, never a
/// preference (§17.6): shallow/partial/LFS repos surface `blocked(reason)` and are skipped.
@MainActor
public final class CarbonService {
    // MARK: - Inputs (resolved by AppStore per pass)

    public struct ProjectInput: Sendable {
        public let uuid: UUID
        public let rootPath: String
        public let defaultBranch: String

        public init(uuid: UUID, rootPath: String, defaultBranch: String) {
            self.uuid = uuid
            self.rootPath = rootPath
            self.defaultBranch = defaultBranch
        }
    }

    public struct SessionInput: Sendable {
        public let sessionID: String
        public let worktreePath: String
        public let branch: String
        public let baseSHA: String?
        public let project: ProjectInput
        /// The agent CLI's resume file + its backend session id, when the session has one —
        /// the `nativeTranscript` stream's source (§6.5).
        public let nativeTranscriptURL: URL?
        public let backendSessionID: String?

        public init(
            sessionID: String, worktreePath: String, branch: String, baseSHA: String?,
            project: ProjectInput, nativeTranscriptURL: URL? = nil,
            backendSessionID: String? = nil
        ) {
            self.sessionID = sessionID
            self.worktreePath = worktreePath
            self.branch = branch
            self.baseSHA = baseSHA
            self.project = project
            self.nativeTranscriptURL = nativeTranscriptURL
            self.backendSessionID = backendSessionID
        }
    }

    public enum Enrollment: Sendable, Equatable {
        case enrolled
        /// An honest inability (repo shape), not an opt-out — surfaced, never silent (§13).
        case blocked(reason: String)
    }

    // MARK: - State

    public let store: CarbonShardStore
    public let custody: CarbonKeyCustody
    private let snapshotter: CarbonSnapshotter
    private let git: GitRunner
    private let ownerDeviceID: @MainActor () -> String
    /// The resuscitation-fence stamp (spec §10). Constant 1 until the fence ships (phase 4),
    /// which will thread the real per-session generation through here.
    private let ownerGeneration: UInt64 = 1

    private var cadences: [String: CarbonCadence] = [:]
    private var lastTicked: [String: Date] = [:]
    private var enrollment: [UUID: Enrollment] = [:]
    /// The project-identity triple per enrolled project (§6.3 `project`), probed once —
    /// `normalizedRemote == nil` is what marks a project `localOnly` (D9).
    private var projectRefs: [UUID: CarbonManifest.ProjectRef] = [:]
    /// The mesh replication half — non-nil only while the peer client is up.
    private(set) var coordinator: CarbonSyncCoordinator?
    /// Fired on every locally produced manifest so `AppStore` can push `carbonHeadAdvanced`
    /// to connected mirrors (§8.2 step 1).
    public var onHeadAdvanced: (@MainActor (CarbonHead) -> Void)?
    /// Fired on every lock-ledger append so `AppStore` can push `carbonLockLedgerAdvanced`
    /// to connected mirrors (§17.5).
    public var onLockLedgerAdvanced: (@MainActor (CarbonHead) -> Void)?
    /// The per-project lock ledger (§17.2) — this host appends as lock authority (§17.3).
    public private(set) lazy var lockLedger = CarbonLockLedger(
        store: store, keyProvider: { [custody] in custody.key(for: $0) })
    /// The lock authority's fence stamp (§17.3). Rides the resuscitation generation once the
    /// phase 4 fence ships; constant 1 until then, same as `ownerGeneration`.
    private let authorityGeneration: UInt64 = 1
    /// Projects whose ledger chain this launch has verified/bootstrapped (an
    /// `authorityChanged` event opens a chain the first time a project locks, §17.3).
    private var lockAuthorityBootstrapped: Set<UUID> = []

    public init(
        root: URL, git: GitRunner = GitRunner(),
        escrow: (@Sendable (CarbonCrypto.ProjectKey, UUID) throws -> Void)? = nil,
        recover: (@Sendable (UUID) throws -> CarbonCrypto.ProjectKey?)? = nil,
        ownerDeviceID: @escaping @MainActor () -> String
    ) {
        let custody = CarbonKeyCustody(root: root, escrow: escrow, recover: recover)
        self.custody = custody
        self.git = git
        self.ownerDeviceID = ownerDeviceID
        self.store = CarbonShardStore(root: root, keyProvider: { custody.key(for: $0) })
        self.snapshotter = CarbonSnapshotter(
            git: git, store: store, keyProvider: { custody.key(for: $0) })
    }

    // MARK: - Enrollment (§3 gate; §17.6 honesty)

    /// Cached per launch; the shape of a repo doesn't change under us in ways worth polling.
    public func enrollmentState(for project: ProjectInput) async -> Enrollment {
        if let cached = enrollment[project.uuid] { return cached }
        let state: Enrollment
        do {
            let probe = TransferGitProbe(git: git)
            let shape = try await probe.repoShape(rootPath: project.rootPath)
            if shape.isShallow {
                state = .blocked(reason: "shallow clone")
            } else if shape.isPartialClone {
                state = .blocked(reason: "partial clone")
            } else if shape.usesLFS {
                state = .blocked(reason: "Git LFS")
            } else {
                state = .enrolled
            }
        } catch {
            state = .blocked(reason: "repo probe failed")
        }
        enrollment[project.uuid] = state
        return state
    }

    /// The manifest `project` block (identity triple + localOnly), probed once per project.
    private func projectRef(for project: ProjectInput) async -> CarbonManifest.ProjectRef {
        if let cached = projectRefs[project.uuid] { return cached }
        let identity = try? await TransferGitProbe(git: git).repoIdentity(
            rootPath: project.rootPath)
        let ref = CarbonManifest.ProjectRef(
            uuid: project.uuid, rootCommitSHA: identity?.rootCommitSHA,
            normalizedRemote: identity?.normalizedRemote,
            localOnly: identity?.normalizedRemote == nil)
        projectRefs[project.uuid] = ref
        return ref
    }

    // MARK: - Producer driving (§6.4 cadence, owner-side only)

    /// A turn boundary / user input / file event for `sessionID` — reset its cadence so the
    /// next `tick` captures immediately (D8: turn boundaries always snapshot).
    public func noteActivity(sessionID: String) {
        cadences[sessionID, default: CarbonCadence()].recordActivity()
        lastTicked[sessionID] = nil
    }

    /// One producer pass: snapshot every due session (worktree + history + native streams)
    /// and extend each involved project's base-branch history chain. `AppStore` calls this on
    /// its ticker and after turn boundaries; sessions mid-transfer/integrate are excluded by
    /// the caller (§6 quiesce). Enrollment-blocked projects are skipped (honestly, cached).
    public func tick(sessions: [SessionInput], now: Date = Date()) async {
        var touchedProjects: [UUID: ProjectInput] = [:]
        for session in sessions {
            guard await enrollmentState(for: session.project) == .enrolled else { continue }
            guard custodyKeyEnsured(for: session.project) else { continue }
            touchedProjects[session.project.uuid] = session.project

            let cadence = cadences[session.sessionID, default: CarbonCadence()]
            if let last = lastTicked[session.sessionID],
                now.timeIntervalSince(last) < cadence.interval
            {
                continue
            }
            lastTicked[session.sessionID] = now
            await produce(for: session)
        }
        for project in touchedProjects.values {
            await produceProjectBaseHistory(project)
        }
    }

    /// Immediate single-session capture (turn boundary — always snapshots, D8).
    public func tickSession(_ session: SessionInput) async {
        guard await enrollmentState(for: session.project) == .enrolled,
            custodyKeyEnsured(for: session.project)
        else { return }
        noteActivity(sessionID: session.sessionID)
        lastTicked[session.sessionID] = Date()
        await produce(for: session)
        await produceProjectBaseHistory(session.project)
    }

    private func produce(for session: SessionInput) async {
        let ref = await projectRef(for: session.project)
        let context = CarbonSnapshotter.SessionContext(
            sessionID: session.sessionID, worktreePath: session.worktreePath,
            branch: session.branch, baseSHA: session.baseSHA, project: ref,
            ownerDeviceID: ownerDeviceID(), ownerGeneration: ownerGeneration)

        // Session-branch history first (the D1 coupling: snapshot packs assume the tip's
        // objects are independently sharded).
        let originSafe = await originSafeSHA(
            project: session.project, ref: ref, baseSHA: session.baseSHA,
            repoPath: session.worktreePath)
        if let id = try? await snapshotter.snapshotHistory(
            project: ref, sessionID: session.sessionID,
            repoPath: session.worktreePath, ref: "refs/heads/\(session.branch)",
            baseSHA: session.baseSHA, originSafeSHA: originSafe,
            ownerDeviceID: ownerDeviceID(), ownerGeneration: ownerGeneration), id != nil
        {
            await announceHead(
                stream: .history, sessionID: session.sessionID, project: session.project.uuid)
        }

        var cadence = cadences[session.sessionID, default: CarbonCadence()]
        switch (try? await snapshotter.snapshotWorktree(context)) ?? .clean {
        case .snapshotted:
            cadence.recordSnapshot()
            await announceHead(
                stream: .worktreeSnapshot, sessionID: session.sessionID,
                project: session.project.uuid)
        case .clean, .identical:
            cadence.recordNoOp()
        case .abortedTipMoved:
            cadence.recordActivity()  // a ship raced us — come back hot
        }
        cadences[session.sessionID] = cadence

        if let fileURL = session.nativeTranscriptURL, let backendID = session.backendSessionID {
            if let id = try? await snapshotter.snapshotNativeTranscript(
                context: context, fileURL: fileURL, backendSessionID: backendID), id != nil
            {
                await announceHead(
                    stream: .nativeTranscript, sessionID: session.sessionID,
                    project: session.project.uuid)
            }
        }
    }

    /// The per-project base chain (§3.2 as amended): the local default branch's delta over
    /// origin — what the §17.4 release fence rides. localOnly projects shard everything.
    private func produceProjectBaseHistory(_ project: ProjectInput) async {
        _ = await produceBaseHistoryNow(project)
    }

    /// Landing-site production (§6.6 as amended): extend the per-project base chain for
    /// `branch` (default: the project's default branch; nvrsion callers pass the trunk)
    /// **synchronously**, returning the chain head that now covers the branch tip — the
    /// `requiredHeads` input for a fenced lock release (§17.4). Must run *before* the release
    /// is published. Returns the current chain head even when the tip was already covered
    /// (no new manifest ⇒ the existing head is the fence).
    public func produceBaseHistoryNow(
        _ project: ProjectInput, branch: String? = nil
    ) async -> CarbonHead? {
        guard await enrollmentState(for: project) == .enrolled,
            custodyKeyEnsured(for: project)
        else { return nil }
        let ref = await projectRef(for: project)
        let originSafe = ref.localOnly ? nil : await lastKnownOriginDefault(project)
        let produced = try? await snapshotter.snapshotHistory(
            project: ref, sessionID: nil, repoPath: project.rootPath,
            ref: "refs/heads/\(branch ?? project.defaultBranch)", originSafeSHA: originSafe,
            ownerDeviceID: ownerDeviceID(), ownerGeneration: ownerGeneration)
        if produced != nil {
            await announceHead(stream: .history, sessionID: nil, project: project.uuid)
        }
        let key = CarbonStreamKey(projectUUID: project.uuid, stream: .history, sessionID: nil)
        return await store.heads(projectUUID: project.uuid)[key]
    }

    /// Landing-site production of a **session-branch** history head (§17.4: a nested child's
    /// release also fences on its parent session's branch content). Same synchronous
    /// discipline as `produceBaseHistoryNow`.
    public func produceSessionHistoryNow(_ session: SessionInput) async -> CarbonHead? {
        guard await enrollmentState(for: session.project) == .enrolled,
            custodyKeyEnsured(for: session.project)
        else { return nil }
        let ref = await projectRef(for: session.project)
        let originSafe = await originSafeSHA(
            project: session.project, ref: ref, baseSHA: session.baseSHA,
            repoPath: session.worktreePath)
        let produced = try? await snapshotter.snapshotHistory(
            project: ref, sessionID: session.sessionID, repoPath: session.worktreePath,
            ref: "refs/heads/\(session.branch)", baseSHA: session.baseSHA,
            originSafeSHA: originSafe, ownerDeviceID: ownerDeviceID(),
            ownerGeneration: ownerGeneration)
        if produced != nil {
            await announceHead(
                stream: .history, sessionID: session.sessionID, project: session.project.uuid)
        }
        let key = CarbonStreamKey(
            projectUUID: session.project.uuid, stream: .history, sessionID: session.sessionID)
        return await store.heads(projectUUID: session.project.uuid)[key]
    }

    // MARK: - Lock ledger (§17.2–17.5)

    /// Append one lock-table mutation to the owning project's ledger — the
    /// `CarbonLockManager.setLedgerAppend` collaborator, resolved per domain by `AppStore`.
    /// Bootstraps the chain with an `authorityChanged` event the first time a project locks
    /// this launch (§17.3). Pushes `onLockLedgerAdvanced` for mesh fan-out.
    public func appendLockEvent(
        _ record: CarbonLockLedgerRecord, project: ProjectInput, sessionDeviceID: String? = nil
    ) async {
        guard await enrollmentState(for: project) == .enrolled,
            custodyKeyEnsured(for: project)
        else { return }
        let ref = await projectRef(for: project)
        let device = ownerDeviceID()
        if !lockAuthorityBootstrapped.contains(project.uuid) {
            lockAuthorityBootstrapped.insert(project.uuid)
            let chainKey = CarbonStreamKey(
                projectUUID: project.uuid, stream: .lock, sessionID: nil)
            if await store.heads(projectUUID: project.uuid)[chainKey] == nil {
                _ = try? await lockLedger.append(
                    CarbonLockEvent(kind: .authorityChanged, deviceID: device),
                    project: ref, ownerDeviceID: device, generation: authorityGeneration)
            }
        }
        let event = CarbonLockEvent(
            kind: record.kind, domainRef: record.domain.ref,
            sessionID: record.session?.rawValue, deviceID: sessionDeviceID ?? device,
            paths: record.paths,
            requiredHeads: record.requiredHeads.isEmpty ? nil : record.requiredHeads)
        guard var head = try? await lockLedger.append(
            event, project: ref, ownerDeviceID: device, generation: authorityGeneration)
        else { return }
        // §17.2 compaction: every N appends, fold the chain into a `snapshot` event so replay
        // (and a joining member's backfill walk) stays bounded.
        if head.snapshotSeq.isMultiple(of: 64),
            let compacted = try? await lockLedger.compact(
                project: ref, ownerDeviceID: device, generation: authorityGeneration)
        {
            head = compacted
        }
        onLockLedgerAdvanced?(head)
    }

    /// The locally-held ledger's fold (§17.2) — the lock viewer's mesh-side source and the
    /// remote-member observed view. nil when the chain is unreadable (no key) or absent.
    public func lockFold(projectUUID: UUID) async -> CarbonLockFold? {
        try? await lockLedger.fold(projectUUID: projectUUID)
    }

    /// Build the §17.4 observation fence for `CarbonLockManager.setFenceCheck`. `repoPath`
    /// resolves a project's local clone root (nil ⇒ store-level check only).
    public func makeLockFence(
        repoPath: @escaping @Sendable (UUID) -> String?
    ) -> CarbonLockFence {
        CarbonLockFence(
            git: git, store: store, keyProvider: { [custody] in custody.key(for: $0) },
            repoPath: repoPath)
    }

    /// §6.6 `originSafeSHA` for a session chain: the fork base when origin provably has it,
    /// else origin's last-known default tip, else nothing (ship everything reachable).
    private func originSafeSHA(
        project: ProjectInput, ref: CarbonManifest.ProjectRef, baseSHA: String?, repoPath: String
    ) async -> String? {
        if ref.localOnly { return nil }
        guard let originDefault = await lastKnownOriginDefault(project) else { return nil }
        if let baseSHA {
            let isAncestor = try? await git.run(
                ["merge-base", "--is-ancestor", baseSHA, originDefault], in: repoPath)
            if isAncestor?.status == 0 { return baseSHA }
        }
        return originDefault
    }

    /// The remote-tracking ref as of the last real fetch/clone/push — never fetched here
    /// (§6.6: no new network ops on the producer path).
    private func lastKnownOriginDefault(_ project: ProjectInput) async -> String? {
        let result = try? await git.run(
            ["rev-parse", "--verify", "--quiet", "refs/remotes/origin/\(project.defaultBranch)"],
            in: project.rootPath)
        guard let result, result.ok else { return nil }
        let sha = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
        return sha.isEmpty ? nil : sha
    }

    private func custodyKeyEnsured(for project: ProjectInput) -> Bool {
        _ = custody.ensureKey(for: project.uuid)
        return custody.key(for: project.uuid) != nil
    }

    private func announceHead(stream: CarbonStreamKind, sessionID: String?, project: UUID) async {
        let key = CarbonStreamKey(projectUUID: project, stream: stream, sessionID: sessionID)
        guard let head = await store.heads(projectUUID: project)[key] else { return }
        onHeadAdvanced?(head)
    }

    // MARK: - Replication (mesh-up wiring; §8.2)

    /// Bind the sync coordinator to live peer transport (called when the peer client starts;
    /// torn down with it). Idempotent per activation.
    public func activateReplication(transport: CarbonSyncCoordinator.PeerTransport) {
        coordinator = CarbonSyncCoordinator(
            store: store, keyProvider: { [custody] in custody.key(for: $0) },
            transport: transport)
    }

    public func deactivateReplication() {
        coordinator = nil
    }

    /// A peer pushed a head (via `PeerClient`'s observer) — chain-sync it, falling through to
    /// the other reachable members.
    public func observePeerHead(_ head: CarbonHead, from deviceID: String, candidates: [String]) async {
        await coordinator?.observeHeadAdvanced(head, from: deviceID, candidates: candidates)
    }

    /// Reconcile all chains against `peers` (connect/presence-change/ticker — §8.2 step 4).
    public func reconcile(peers: [String]) async {
        guard let coordinator else { return }
        await coordinator.reconcile(projectUUID: nil, peers: peers)
    }

    // MARK: - Viewer snapshot (§12 — read-only)

    /// Assemble ``CarbonStoreSnapshot`` for `peers` (the mesh members that can hold Copies).
    public func storeSnapshot(peers: [String]) async -> CarbonStoreSnapshot {
        var projects: [CarbonStoreSnapshot.ProjectHoldings] = []
        var localHeads: [CarbonStreamKey: CarbonHead] = [:]
        for uuid in await store.knownProjects() {
            let heads = await store.heads(projectUUID: uuid)
            localHeads.merge(heads) { a, _ in a }
            projects.append(CarbonStoreSnapshot.ProjectHoldings(
                uuid: uuid,
                usage: await store.usage(project: uuid),
                heads: heads.values.sorted {
                    ($0.key.stream.rawValue, $0.key.sessionID ?? "")
                        < ($1.key.stream.rawValue, $1.key.sessionID ?? "")
                }))
        }

        let gossip = await coordinator?.peerHeadState() ?? [:]
        let peerRows = peers.map { deviceID -> CarbonStoreSnapshot.PeerHoldings in
            let theirs = gossip[deviceID]
            var caughtUp = 0
            for (key, ours) in localHeads
            where CarbonStoreSnapshot.isAtOrPast(theirs?[key], ours) { caughtUp += 1 }
            return CarbonStoreSnapshot.PeerHoldings(
                deviceID: deviceID, reported: theirs != nil, caughtUp: caughtUp,
                behind: localHeads.count - caughtUp)
        }

        return CarbonStoreSnapshot(
            projects: projects.sorted { $0.uuid.uuidString < $1.uuid.uuidString },
            blocked: enrollment.compactMap { uuid, state in
                guard case .blocked(let reason) = state else { return nil }
                return CarbonStoreSnapshot.BlockedProject(uuid: uuid, reason: reason)
            }.sorted { $0.uuid.uuidString < $1.uuid.uuidString },
            peers: peerRows.sorted { $0.deviceID < $1.deviceID })
    }

    // MARK: - Serving (the SyncHostBridge surface)

    /// A stable UUID for a legacy non-UUID project id: the first 16 bytes of its SHA-256,
    /// version/variant-stamped — every member derives the same key for the same project.
    public nonisolated static func derivedUUID(fromLegacyID id: String) -> UUID {
        let hex = Array(CarbonCrypto.blobID(Data(id.utf8)).utf8)  // 64 lowercase hex chars
        func nibble(_ c: UInt8) -> UInt8 { c >= 0x61 ? c - 0x57 : c - 0x30 }
        var bytes = [UInt8](repeating: 0, count: 16)
        for i in 0..<16 { bytes[i] = nibble(hex[i * 2]) << 4 | nibble(hex[i * 2 + 1]) }
        bytes[6] = (bytes[6] & 0x0F) | 0x40  // version 4
        bytes[8] = (bytes[8] & 0x3F) | 0x80  // RFC 4122 variant
        return UUID(uuid: (
            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
            bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]))
    }

    public func headsSnapshot(projectUUID: UUID?) async -> [CarbonHead] {
        if let projectUUID {
            return Array(await store.heads(projectUUID: projectUUID).values)
        }
        var all: [CarbonHead] = []
        for uuid in await store.knownProjects() {
            all.append(contentsOf: await store.heads(projectUUID: uuid).values)
        }
        return all
    }
}

// MARK: - Viewer snapshot (spec §12)

/// Everything the Carbon Viewer renders, in one pass: what this member actually holds per
/// project, which projects are enrollment-blocked, and what gossip says each peer holds.
/// Purely a read — assembling it never produces a snapshot or touches the wire.
///
/// Top-level rather than nested in ``CarbonService`` so these stay outside the service's
/// `@MainActor` isolation: `Identifiable`'s `id` is a nonisolated requirement, which a
/// global-actor-inferred property can't satisfy.
public struct CarbonStoreSnapshot: Sendable {
    /// One project's holdings on this member.
    public struct ProjectHoldings: Sendable, Identifiable {
        public let uuid: UUID
        public let usage: CarbonStoreUsage
        /// This member's head per stream, ordered by stream then session for stable rows.
        public let heads: [CarbonHead]

        public init(uuid: UUID, usage: CarbonStoreUsage, heads: [CarbonHead]) {
            self.uuid = uuid
            self.usage = usage
            self.heads = heads
        }

        /// Set when this member holds the chain but not the project key (§4.3): it can mirror,
        /// not restore. Surfaced rather than silently shown as healthy.
        public var keyMissing: Bool { heads.contains(where: \.keyMissing) }
        public var id: UUID { uuid }
    }

    /// A project Carbon can't protect because of its repo shape (§17.6) — never an opt-out,
    /// always an honest inability, so the viewer explains the absence instead of hiding it.
    public struct BlockedProject: Sendable, Identifiable {
        public let uuid: UUID
        public let reason: String

        public init(uuid: UUID, reason: String) {
            self.uuid = uuid
            self.reason = reason
        }

        public var id: UUID { uuid }
    }

    /// One mesh member's holdings *as gossip reports them* (§8.2: heads are state). A member
    /// that has never gossiped reports nothing here — "unknown", not "holds nothing".
    public struct PeerHoldings: Sendable, Identifiable {
        public let deviceID: String
        /// Whether this peer has gossiped *any* head to us. False means we know nothing about
        /// its holdings — which is not the same as knowing it holds nothing, and must not be
        /// rendered as a lag figure.
        public let reported: Bool
        /// Streams where this peer's gossiped head is at or past ours.
        public let caughtUp: Int
        /// Streams we hold a head for that this peer's gossip does not cover at our level.
        public let behind: Int

        public init(deviceID: String, reported: Bool, caughtUp: Int, behind: Int) {
            self.deviceID = deviceID
            self.reported = reported
            self.caughtUp = caughtUp
            self.behind = behind
        }

        public var id: String { deviceID }
    }

    public let projects: [ProjectHoldings]
    public let blocked: [BlockedProject]
    public let peers: [PeerHoldings]

    public init(
        projects: [ProjectHoldings], blocked: [BlockedProject], peers: [PeerHoldings]
    ) {
        self.projects = projects
        self.blocked = blocked
        self.peers = peers
    }

    public var totalShards: Int { projects.reduce(0) { $0 + $1.usage.shardCount } }
    public var totalManifests: Int { projects.reduce(0) { $0 + $1.usage.manifestCount } }
    public var totalBytes: UInt64 { projects.reduce(0) { $0 + $1.usage.bytes } }
    public var totalQuarantined: Int { projects.reduce(0) { $0 + $1.usage.quarantinedCount } }

    /// The §10 fencing comparison, reused for read-only lag accounting: is `candidate` at or
    /// past `ours`? A missing candidate head is never caught up.
    static func isAtOrPast(_ candidate: CarbonHead?, _ ours: CarbonHead) -> Bool {
        guard let candidate else { return false }
        return candidate.ownerGeneration > ours.ownerGeneration
            || (candidate.ownerGeneration == ours.ownerGeneration
                && candidate.snapshotSeq >= ours.snapshotSeq)
    }
}
