import Foundation
import NucleicProtocol

/// The owner-side Carbon producer (docs/CARBON_SHARDING.md §6): captures a live session
/// worktree's uncommitted state (two trees, D10), the branch history origin doesn't have, and
/// the agent CLI's native transcript — as thin incremental packs / byte segments, sharded into
/// the local `CarbonShardStore`. Mirrors NEVER produce; one instance per host drives all
/// enrolled projects.
///
/// Chain state (previous anchor commits, sequence numbers) is derived from the store's heads
/// and manifests rather than a parallel table, so producer restarts can't desync from what was
/// actually persisted.
///
/// Every git op here is read-only against *user-visible* state: the session's real index is
/// only read (`write-tree`), the worktree tree is built through a throwaway `GIT_INDEX_FILE`,
/// and the anchor commits live under hidden `refs/nucleic/carbon/*` refs that porcelain never
/// shows. The §16 producer test pins this (porcelain + HEAD + index checksum unchanged).
public actor CarbonSnapshotter {
    /// What the producer needs to know about one enrolled session — resolved by the caller
    /// (`AppStore`), which owns session/project state; the snapshotter never reaches into it.
    public struct SessionContext: Sendable {
        public let sessionID: String
        public let worktreePath: String
        public let branch: String
        /// The session's fork anchor, preserved verbatim into manifests.
        public let baseSHA: String?
        public let project: CarbonManifest.ProjectRef
        public let ownerDeviceID: String
        /// The resuscitation-fence stamp (spec §10); bumped by ownership transfer, never here.
        public let ownerGeneration: UInt64

        public init(
            sessionID: String, worktreePath: String, branch: String, baseSHA: String?,
            project: CarbonManifest.ProjectRef, ownerDeviceID: String, ownerGeneration: UInt64
        ) {
            self.sessionID = sessionID
            self.worktreePath = worktreePath
            self.branch = branch
            self.baseSHA = baseSHA
            self.project = project
            self.ownerDeviceID = ownerDeviceID
            self.ownerGeneration = ownerGeneration
        }
    }

    public enum Outcome: Sendable, Equatable {
        /// Nothing dirty — record a no-op, apply cadence backoff.
        case clean
        /// Dirty-check fired but both trees match the previous snapshot — backoff.
        case identical
        /// The branch tip moved mid-snapshot (a ship raced us) — abort; the tip-move trigger
        /// (§6.6) re-enters.
        case abortedTipMoved
        case snapshotted(manifestID: String)
    }

    private let git: GitRunner
    private let store: CarbonShardStore
    private let keyProvider: CarbonShardStore.KeyProvider

    /// Fixed plumbing identity (§6.1): snapshot commits are never shown, never pushed, and
    /// must not ride the user's identity or the Managed-Git signing path.
    private static let identityEnv: [String: String] = [
        "GIT_AUTHOR_NAME": "Nucleic Carbon",
        "GIT_AUTHOR_EMAIL": "carbon@nucleic.local",
        "GIT_COMMITTER_NAME": "Nucleic Carbon",
        "GIT_COMMITTER_EMAIL": "carbon@nucleic.local",
    ]

    public init(
        git: GitRunner = GitRunner(), store: CarbonShardStore,
        keyProvider: @escaping CarbonShardStore.KeyProvider
    ) {
        self.git = git
        self.store = store
        self.keyProvider = keyProvider
    }

    // MARK: - worktreeSnapshot (§6.1)

    /// One producer tick for one session worktree. The caller gates scheduling (cadence,
    /// transfer/integrate quiesce); this performs the capture pipeline unconditionally.
    public func snapshotWorktree(_ context: SessionContext) async throws -> Outcome {
        let wt = context.worktreePath
        let projectUUID = context.project.uuid
        guard let key = keyProvider(projectUUID) else {
            throw CarbonStoreError.keyMissing(projectUUID)
        }
        let crypto = CarbonCrypto(projectKey: key)

        // 1. Dirty check — cheap, always first.
        let porcelain = try await git.checked(
            ["status", "--porcelain", "--no-renames"], in: wt)
        if porcelain.isEmpty { return .clean }

        // Previous snapshot in this chain (nil at chain start / after a generation bump).
        let previous = try await latestManifest(
            key: CarbonStreamKey(
                projectUUID: projectUUID, stream: .worktreeSnapshot, sessionID: context.sessionID),
            generation: context.ownerGeneration)

        // 2. Index tree from the session's REAL index (read-only; may refresh cache-tree).
        var indexUnavailable = false
        var indexTree: String?
        let wtResult = try await git.run(["write-tree"], in: wt)
        if wtResult.ok {
            indexTree = wtResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
        } else if wtResult.stderr.contains("unmerged") {
            indexUnavailable = true  // capture working files anyway; restore leaves all unstaged
        } else {
            throw GitError.commandFailed(
                command: "git write-tree", status: wtResult.status, stderr: wtResult.stderr)
        }

        // 3. Worktree tree via a throwaway index — never touch the session's real index.
        let branchTip = try await git.checked(
            ["rev-parse", "refs/heads/\(context.branch)"], in: wt)
        let tmpIndex = FileManager.default.temporaryDirectory
            .appendingPathComponent("carbon-index-\(UUID().uuidString)").path
        defer { try? FileManager.default.removeItem(atPath: tmpIndex) }
        let scratchEnv = ["GIT_INDEX_FILE": tmpIndex]
        try await git.checked(["read-tree", branchTip], in: wt, env: scratchEnv)
        try await git.checked(["add", "-A"], in: wt, env: scratchEnv)
        let worktreeTree = try await git.checked(["write-tree"], in: wt, env: scratchEnv)

        // Abort if a ship raced us and moved the tip mid-capture.
        let tipNow = try await git.checked(["rev-parse", "refs/heads/\(context.branch)"], in: wt)
        guard tipNow == branchTip else { return .abortedTipMoved }

        // 4. Skip identical trees.
        if let previous, previous.manifest.worktreeTreeSHA == worktreeTree,
            previous.manifest.indexTreeSHA == indexTree
        {
            return .identical
        }

        // 5. Hidden anchor commits + refs (objects stay reachable across repo `git gc`;
        //    stable negatives for thin packs; invisible to porcelain).
        let seq = (previous?.manifest.snapshotSeq ?? 0) + 1
        let env = Self.identityEnv
        var wtArgs = ["-c", "commit.gpgsign=false", "commit-tree", worktreeTree]
        if let prev = previous?.manifest.wtCommitSHA { wtArgs += ["-p", prev] }
        wtArgs += ["-p", branchTip, "-m", "carbon wt snapshot seq=\(seq)"]
        let wtCommit = try await git.checked(wtArgs, in: wt, env: env)

        var idxCommit: String?
        if let indexTree {
            var idxArgs = ["-c", "commit.gpgsign=false", "commit-tree", indexTree]
            if let prev = previous?.manifest.idxCommitSHA { idxArgs += ["-p", prev] }
            idxArgs += ["-m", "carbon idx snapshot seq=\(seq)"]
            idxCommit = try await git.checked(idxArgs, in: wt, env: env)
        }
        try await git.checked(
            ["update-ref", "refs/nucleic/carbon/\(context.sessionID)/wt", wtCommit], in: wt)
        if let idxCommit {
            try await git.checked(
                ["update-ref", "refs/nucleic/carbon/\(context.sessionID)/idx", idxCommit], in: wt)
        }

        // 6. Thin incremental pack: exactly the new objects. The `^branchTip` negative is safe
        //    because the history stream (§6.6) independently shards the tip's objects — the D1
        //    coupling that makes both streams mandatory.
        var revs = [wtCommit]
        if let idxCommit { revs.append(idxCommit) }
        if let prev = previous?.manifest.wtCommitSHA { revs.append("^\(prev)") }
        if let prev = previous?.manifest.idxCommitSHA { revs.append("^\(prev)") }
        revs.append("^\(branchTip)")
        let pack = try await packObjects(revs: revs, in: wt)

        // 7. Shard + manifest + head.
        let shards = try await CarbonPayload.shard(
            pack, projectUUID: projectUUID, crypto: crypto, store: store)
        let manifest = CarbonManifest(
            project: context.project, stream: .worktreeSnapshot, sessionID: context.sessionID,
            branch: context.branch, baseSHA: context.baseSHA,
            ownerDeviceID: context.ownerDeviceID, ownerGeneration: context.ownerGeneration,
            snapshotSeq: seq, parentManifest: previous?.id, branchTipSHA: branchTip,
            wtCommitSHA: wtCommit, worktreeTreeSHA: worktreeTree, idxCommitSHA: idxCommit,
            indexTreeSHA: indexTree, indexUnavailable: indexUnavailable, payloadKind: .gitPack,
            shards: shards, createdAtMs: Self.nowMs())
        let manifestID = try await persist(manifest, crypto: crypto)
        return .snapshotted(manifestID: manifestID)
    }

    // MARK: - history (§6.6)

    /// Produce a history manifest when a branch tip moved. `ref` is a session branch (with
    /// `sessionID` set) or a project base branch / nvrsion trunk (`sessionID == nil` — the
    /// per-project chain, which the §17.4 release fence rides). `originSafeSHA` is the §6.6
    /// negative the caller resolved (nil in paranoid mode / for `localOnly` projects, where
    /// everything reachable ships). Returns nil when the tip is already covered by the chain.
    public func snapshotHistory(
        project: CarbonManifest.ProjectRef, sessionID: String?, repoPath: String, ref: String,
        baseSHA: String? = nil, originSafeSHA: String?, ownerDeviceID: String,
        ownerGeneration: UInt64
    ) async throws -> String? {
        let projectUUID = project.uuid
        guard let key = keyProvider(projectUUID) else {
            throw CarbonStoreError.keyMissing(projectUUID)
        }
        let crypto = CarbonCrypto(projectKey: key)
        let chainKey = CarbonStreamKey(
            projectUUID: projectUUID, stream: .history, sessionID: sessionID)
        let previous = try await latestManifest(key: chainKey, generation: ownerGeneration)

        let newTip = try await git.checked(["rev-parse", ref], in: repoPath)
        // Dedup against the newest manifest covering THIS ref: the per-project chain
        // (`sessionID == nil`) interleaves branches — base branches plus the nvrsion trunk
        // (§3.2) — so the immediate previous may be another branch's manifest whose tip
        // legitimately differs while `ref` hasn't moved at all.
        let previousForRef = try await latestManifest(
            forRef: ref, from: previous, projectUUID: projectUUID)
        if previousForRef?.manifest.branchTipSHA == newTip { return nil }

        var revs = [newTip]
        if let prevTip = previous?.manifest.branchTipSHA { revs.append("^\(prevTip)") }
        if let sameRefTip = previousForRef?.manifest.branchTipSHA,
            sameRefTip != previous?.manifest.branchTipSHA
        {
            revs.append("^\(sameRefTip)")  // held by any chain holder; shrinks the pack
        }
        if let originSafeSHA { revs.append("^\(originSafeSHA)") }
        let pack = try await packObjects(revs: revs, in: repoPath)

        let shards = try await CarbonPayload.shard(
            pack, projectUUID: projectUUID, crypto: crypto, store: store)
        let manifest = CarbonManifest(
            project: project, stream: .history, sessionID: sessionID, branch: ref,
            baseSHA: baseSHA, ownerDeviceID: ownerDeviceID, ownerGeneration: ownerGeneration,
            snapshotSeq: (previous?.manifest.snapshotSeq ?? 0) + 1,
            parentManifest: previous?.id, branchTipSHA: newTip, payloadKind: .gitPack,
            shards: shards, createdAtMs: Self.nowMs())
        return try await persist(manifest, crypto: crypto)
    }

    // MARK: - nativeTranscript (§6.5)

    /// Ship the native resume file's growth since the last manifest as a byte segment; a
    /// shrunken file (the CLI rewrote/compacted it) emits a rebase manifest — offset 0, whole
    /// file, chain restart. Returns nil when the file hasn't grown.
    public func snapshotNativeTranscript(
        context: SessionContext, fileURL: URL, backendSessionID: String
    ) async throws -> String? {
        let projectUUID = context.project.uuid
        guard let key = keyProvider(projectUUID) else {
            throw CarbonStoreError.keyMissing(projectUUID)
        }
        let crypto = CarbonCrypto(projectKey: key)
        let chainKey = CarbonStreamKey(
            projectUUID: projectUUID, stream: .nativeTranscript, sessionID: context.sessionID)
        let previous = try await latestManifest(key: chainKey, generation: context.ownerGeneration)

        guard let data = try? Data(contentsOf: fileURL) else { return nil }
        let size = UInt64(data.count)
        // The next segment starts where the previous one ended: its offset + plaintext length
        // (recovered from ciphertext sizes — each shard carries a 28-byte nonce+tag envelope).
        let lastOffset = previous.map { prev in
            (prev.manifest.segmentOffset ?? 0) + prev.manifest.shards.reduce(UInt64(0)) {
                $0 + $1.byteCount - 28
            }
        } ?? 0

        let rebase = size < lastOffset
        if !rebase && size == lastOffset { return nil }
        let segmentStart = rebase ? 0 : lastOffset
        let segment = data.suffix(from: Int(segmentStart))

        let shards = try await CarbonPayload.shard(
            Data(segment), projectUUID: projectUUID, crypto: crypto, store: store)
        let manifest = CarbonManifest(
            project: context.project, stream: .nativeTranscript, sessionID: context.sessionID,
            ownerDeviceID: context.ownerDeviceID, ownerGeneration: context.ownerGeneration,
            snapshotSeq: (previous?.manifest.snapshotSeq ?? 0) + 1,
            parentManifest: rebase ? nil : previous?.id, payloadKind: .jsonlSegment,
            segmentOffset: segmentStart, backendSessionID: backendSessionID, shards: shards,
            createdAtMs: Self.nowMs())
        return try await persist(manifest, crypto: crypto)
    }

    // MARK: - Producer-side GC hook (§7.3 rule 6)

    /// On session archive, drop the hidden anchor refs so the repo's own `git gc` can reclaim
    /// snapshot objects.
    public func dropAnchorRefs(sessionID: String, repoPath: String) async {
        for leaf in ["wt", "idx"] {
            _ = try? await git.run(
                ["update-ref", "-d", "refs/nucleic/carbon/\(sessionID)/\(leaf)"], in: repoPath)
        }
    }

    // MARK: - Plumbing

    private func packObjects(revs: [String], in repoPath: String) async throws -> Data {
        let tmpDir = FileManager.default.temporaryDirectory
        let revsFile = tmpDir.appendingPathComponent("carbon-revs-\(UUID().uuidString)")
        let packFile = tmpDir.appendingPathComponent("carbon-pack-\(UUID().uuidString)")
        defer {
            try? FileManager.default.removeItem(at: revsFile)
            try? FileManager.default.removeItem(at: packFile)
        }
        try Data((revs.joined(separator: "\n") + "\n").utf8).write(to: revsFile)
        let result = try await git.runRedirected(
            ["pack-objects", "--revs", "--thin", "--delta-base-offset", "--stdout"],
            in: repoPath, stdinFile: revsFile, stdoutFile: packFile)
        guard result.ok else {
            throw GitError.commandFailed(
                command: "git pack-objects", status: result.status, stderr: result.stderr)
        }
        return try Data(contentsOf: packFile)
    }

    /// The newest manifest of a chain **at this generation** — a lower-generation head means a
    /// prior owner's chain; this owner starts a fresh chain (seq 1) rather than extending a
    /// chain it cannot fence (§10).
    private func latestManifest(
        key: CarbonStreamKey, generation: UInt64
    ) async throws -> (id: String, manifest: CarbonManifest)? {
        guard let head = await store.heads(projectUUID: key.projectUUID)[key],
            head.ownerGeneration == generation,
            let manifest = try await store.manifest(
                id: head.manifestID, projectUUID: key.projectUUID)
        else { return nil }
        return (head.manifestID, manifest)
    }

    /// Walk back from `start` to the newest chain manifest whose `branch` equals `ref` —
    /// the dedup anchor for interleaved per-project chains (§3.2). Bounded: an interleaved
    /// chain rarely runs more than a few manifests deep between touches of one ref.
    private func latestManifest(
        forRef ref: String, from start: (id: String, manifest: CarbonManifest)?,
        projectUUID: UUID
    ) async throws -> (id: String, manifest: CarbonManifest)? {
        var cursor = start
        var hops = 0
        while let entry = cursor, hops < 32 {
            if entry.manifest.branch == ref { return entry }
            guard let parentID = entry.manifest.parentManifest,
                let parent = try await store.manifest(id: parentID, projectUUID: projectUUID)
            else { return nil }
            cursor = (parentID, parent)
            hops += 1
        }
        return cursor?.manifest.branch == ref ? cursor : nil
    }

    private func persist(_ manifest: CarbonManifest, crypto: CarbonCrypto) async throws -> String {
        let sealed = try manifest.sealed(with: crypto)
        try await store.putManifest(
            ciphertext: sealed.ciphertext, expectedID: sealed.id,
            projectUUID: manifest.project.uuid)
        try await store.advanceHead(manifest.head(manifestID: sealed.id))
        return sealed.id
    }

    private static func nowMs() -> UInt64 {
        UInt64(Date().timeIntervalSince1970 * 1000)
    }
}
