import Foundation
import Testing

@testable import NucleicCore
import NucleicProtocol

/// CARBON_SHARDING §15 phase 2 acceptance: a killed worktree restores byte- and
/// stage-identical (porcelain=v2 equality) from local shards, thin packs complete on a
/// receiver holding only the origin objects, and the producer provably never mutates
/// user-visible git state.
@Suite("Carbon snapshot + restore", .serialized)
struct CarbonSnapshotRestoreTests {
    private let projectUUID = UUID(uuidString: "D1E2F3A4-B5C6-4789-8ABC-DEF012345678")!
    private let key = try! CarbonCrypto.ProjectKey(bytes: Data((0..<32).map { UInt8($0) }))

    // MARK: - Fixture

    /// A hermetic origin repo + session worktree + Carbon store rooted in one temp dir.
    private struct Fixture {
        let git = GitRunner()
        let root: URL
        var repoA: String { root.appendingPathComponent("repoA").path }
        var sessionWT: String { root.appendingPathComponent("wt-session").path }
        var repoB: String { root.appendingPathComponent("repoB").path }
        var restoredWT: String { root.appendingPathComponent("wt-restored").path }
        var storeRoot: URL { root.appendingPathComponent("carbon-store") }

        @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))
        }

        /// The full user-visible state fingerprint the producer must never change and the
        /// restore must reproduce: porcelain=v2 (hashes, modes, staged/unstaged split) + tip.
        func fingerprint(of worktree: String, branch: String) async throws -> String {
            let status = try await git.checked(
                ["status", "--porcelain=v2", "--no-renames", "--untracked-files=all"],
                in: worktree)
            let tip = try await git.checked(["rev-parse", "refs/heads/\(branch)"], in: worktree)
            return status + "\n@tip:" + tip
        }
    }

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

    private func makeStore(_ f: Fixture) -> CarbonShardStore {
        CarbonShardStore(root: f.storeRoot, keyProvider: { [key] _ in key })
    }

    private func projectRef() -> CarbonManifest.ProjectRef {
        .init(uuid: projectUUID, localOnly: false)
    }

    private func sessionContext(_ f: Fixture, baseSHA: String) -> CarbonSnapshotter.SessionContext {
        .init(
            sessionID: "sess-1", worktreePath: f.sessionWT, branch: "nucleic/test-session",
            baseSHA: baseSHA, project: projectRef(), ownerDeviceID: "host-A", ownerGeneration: 1)
    }

    /// Session worktree forked from main, one committed change, then a dirty spread: staged
    /// new file, unstaged modification, untracked file, tracked deletion.
    private func populateSession(_ f: Fixture) async throws -> (baseSHA: String, ctx: CarbonSnapshotter.SessionContext) {
        let baseSHA = try await f.sh(["rev-parse", "main"], in: f.repoA)
        try await f.sh(
            ["worktree", "add", f.sessionWT, "-b", "nucleic/test-session", "main"], in: f.repoA)
        try f.write("committed.txt", "landed on the session branch\n", in: f.sessionWT)
        try await f.sh(["add", "committed.txt"], in: f.sessionWT)
        try await f.sh(["commit", "-m", "session work"], in: f.sessionWT)

        try f.write("staged.txt", "staged but uncommitted\n", in: f.sessionWT)
        try await f.sh(["add", "staged.txt"], in: f.sessionWT)
        try f.write("README.md", "hello carbon — modified, unstaged\n", in: f.sessionWT)
        try f.write("untracked.txt", "never added\n", in: f.sessionWT)
        try FileManager.default.removeItem(
            at: URL(fileURLWithPath: f.sessionWT).appendingPathComponent("doomed.txt"))
        return (baseSHA, sessionContext(f, baseSHA: baseSHA))
    }

    /// Produce the session's history + snapshot manifests (the D1 pair a restore needs).
    private func produce(
        _ f: Fixture, _ ctx: CarbonSnapshotter.SessionContext, baseSHA: String,
        snapshotter: CarbonSnapshotter
    ) async throws -> String {
        _ = try await snapshotter.snapshotHistory(
            project: projectRef(), sessionID: ctx.sessionID, repoPath: f.sessionWT,
            ref: "refs/heads/\(ctx.branch)", baseSHA: baseSHA, originSafeSHA: baseSHA,
            ownerDeviceID: "host-A", ownerGeneration: 1)
        let outcome = try await snapshotter.snapshotWorktree(ctx)
        guard case .snapshotted(let id) = outcome else {
            Issue.record("expected snapshot, got \(outcome)")
            throw CarbonRestoreError.noSnapshotForSession(ctx.sessionID)
        }
        return id
    }

    /// A receiver that holds ONLY origin's objects (single-branch clone of main) — restoring
    /// here is the honest device-died scenario AND proves the thin packs complete (§16).
    private func cloneReceiver(_ f: Fixture) async throws {
        try await f.sh(
            ["clone", "--quiet", "--single-branch", "--branch", "main", f.repoA, f.repoB],
            in: f.root.path)
        try await f.sh(["config", "user.name", "Receiver"], in: f.repoB)
        try await f.sh(["config", "user.email", "receiver@test.local"], in: f.repoB)
    }

    // MARK: - The headline round-trip

    @Test func killedWorktreeRestoresStageIdenticalOnFreshClone() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let store = makeStore(f)
        let snapshotter = CarbonSnapshotter(store: store, keyProvider: { [key] _ in key })
        let (baseSHA, ctx) = try await populateSession(f)

        let before = try await f.fingerprint(of: f.sessionWT, branch: ctx.branch)
        let readmeBefore = try Data(
            contentsOf: URL(fileURLWithPath: f.sessionWT).appendingPathComponent("README.md"))

        // Produce — and prove the producer changed nothing the user can see.
        _ = try await produce(f, ctx, baseSHA: baseSHA, snapshotter: snapshotter)
        let after = try await f.fingerprint(of: f.sessionWT, branch: ctx.branch)
        #expect(before == after)

        // The device dies; a fresh clone (origin objects only) restores from shards.
        try await cloneReceiver(f)
        let restore = CarbonRestore(store: store, keyProvider: { [key] _ in key })
        let restored = try await restore.materializeWorktree(
            projectRepoPath: f.repoB, projectUUID: projectUUID, sessionID: ctx.sessionID,
            at: f.restoredWT)

        #expect(restored.branch == ctx.branch)
        #expect(restored.baseSHA == baseSHA)
        #expect(restored.indexUnavailable == false)

        // Stage- and byte-identical: porcelain=v2 (staged hashes, unstaged split, untracked,
        // deletions) plus tip must match the dead worktree exactly.
        let restoredPrint = try await f.fingerprint(of: f.restoredWT, branch: ctx.branch)
        #expect(restoredPrint == before)
        let readmeRestored = try Data(
            contentsOf: URL(fileURLWithPath: f.restoredWT).appendingPathComponent("README.md"))
        #expect(readmeRestored == readmeBefore)
        #expect(
            try Data(
                contentsOf: URL(fileURLWithPath: f.restoredWT)
                    .appendingPathComponent("untracked.txt"))
                == Data("never added\n".utf8))
        #expect(
            !FileManager.default.fileExists(
                atPath: URL(fileURLWithPath: f.restoredWT).appendingPathComponent("doomed.txt")
                    .path))
    }

    // MARK: - Producer behavior

    @Test func cleanAndIdenticalTicksNoOp() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let store = makeStore(f)
        let snapshotter = CarbonSnapshotter(store: store, keyProvider: { [key] _ in key })
        let (baseSHA, ctx) = try await populateSession(f)

        _ = try await produce(f, ctx, baseSHA: baseSHA, snapshotter: snapshotter)
        // Same trees again ⇒ .identical, no new manifest.
        #expect(try await snapshotter.snapshotWorktree(ctx) == .identical)

        // A further edit produces seq 2 chained to seq 1.
        try f.write("README.md", "hello carbon — edited again\n", in: f.sessionWT)
        guard case .snapshotted(let second) = try await snapshotter.snapshotWorktree(ctx) else {
            Issue.record("expected second snapshot")
            return
        }
        let m2 = try await store.manifest(id: second, projectUUID: projectUUID)
        #expect(m2?.snapshotSeq == 2)
        #expect(m2?.parentManifest != nil)

        // Fully committed + clean ⇒ .clean.
        try await f.sh(["add", "-A"], in: f.sessionWT)
        try await f.sh(["commit", "-m", "land everything"], in: f.sessionWT)
        #expect(try await snapshotter.snapshotWorktree(ctx) == .clean)
    }

    @Test func hiddenAnchorRefsSurviveGC() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let store = makeStore(f)
        let snapshotter = CarbonSnapshotter(store: store, keyProvider: { [key] _ in key })
        let (baseSHA, ctx) = try await populateSession(f)
        let id = try await produce(f, ctx, baseSHA: baseSHA, snapshotter: snapshotter)
        let manifest = try #require(try await store.manifest(id: id, projectUUID: projectUUID))
        let wtCommit = try #require(manifest.wtCommitSHA)

        try await f.sh(["gc", "--prune=now", "--quiet"], in: f.repoA)
        // The anchor ref kept the snapshot objects alive; porcelain still shows nothing.
        try await f.sh(["cat-file", "-e", wtCommit], in: f.repoA)
        let porcelain = try await f.sh(["status", "--porcelain"], in: f.repoA)
        #expect(porcelain.isEmpty)
    }

    @Test func unmergedIndexFallsBackToSingleTree() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let store = makeStore(f)
        let snapshotter = CarbonSnapshotter(store: store, keyProvider: { [key] _ in key })
        let (baseSHA, ctx) = try await populateSession(f)
        // Land the dirty state so a conflicting merge is possible, then manufacture a
        // conflict: main and the session branch both rewrite README.
        try await f.sh(["add", "-A"], in: f.sessionWT)
        try await f.sh(["commit", "-m", "land"], in: f.sessionWT)
        try f.write("README.md", "main's divergent take\n", in: f.repoA)
        try await f.sh(["commit", "-am", "diverge main"], in: f.repoA)
        _ = try await f.git.run(["merge", "main"], in: f.sessionWT)  // exits 1: conflict

        guard case .snapshotted(let id) = try await snapshotter.snapshotWorktree(ctx) else {
            Issue.record("expected snapshot despite unmerged index")
            return
        }
        let manifest = try await store.manifest(id: id, projectUUID: projectUUID)
        #expect(manifest?.indexUnavailable == true)
        #expect(manifest?.indexTreeSHA == nil)

        // Restore on a fresh clone: working files (conflict markers included) come back;
        // everything is unstaged; the honest flag rides through.
        _ = try await snapshotter.snapshotHistory(
            project: projectRef(), sessionID: ctx.sessionID, repoPath: f.sessionWT,
            ref: "refs/heads/\(ctx.branch)", baseSHA: baseSHA, originSafeSHA: baseSHA,
            ownerDeviceID: "host-A", ownerGeneration: 1)
        try await cloneReceiver(f)
        let restore = CarbonRestore(store: store, keyProvider: { [key] _ in key })
        let restored = try await restore.materializeWorktree(
            projectRepoPath: f.repoB, projectUUID: projectUUID, sessionID: ctx.sessionID,
            at: f.restoredWT)
        #expect(restored.indexUnavailable == true)
        let readme = try String(
            contentsOf: URL(fileURLWithPath: f.restoredWT).appendingPathComponent("README.md"),
            encoding: .utf8)
        #expect(readme.contains("<<<<<<<"))
    }

    @Test func gitignoredFilesAreNotCaptured() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let store = makeStore(f)
        let snapshotter = CarbonSnapshotter(store: store, keyProvider: { [key] _ in key })
        let (baseSHA, ctx) = try await populateSession(f)
        try f.write(".gitignore", "ignored.log\n", in: f.sessionWT)
        try f.write("ignored.log", "gigabytes of build noise\n", in: f.sessionWT)

        _ = try await produce(f, ctx, baseSHA: baseSHA, snapshotter: snapshotter)
        try await cloneReceiver(f)
        let restore = CarbonRestore(store: store, keyProvider: { [key] _ in key })
        _ = try await restore.materializeWorktree(
            projectRepoPath: f.repoB, projectUUID: projectUUID, sessionID: ctx.sessionID,
            at: f.restoredWT)
        // .gitignore itself (untracked-unignored) travels; the ignored file does not.
        #expect(
            FileManager.default.fileExists(
                atPath: URL(fileURLWithPath: f.restoredWT).appendingPathComponent(".gitignore")
                    .path))
        #expect(
            !FileManager.default.fileExists(
                atPath: URL(fileURLWithPath: f.restoredWT).appendingPathComponent("ignored.log")
                    .path))
    }

    // MARK: - History chain

    @Test func historyChainExtendsPerTipMoveAndSkipsCovered() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let store = makeStore(f)
        let snapshotter = CarbonSnapshotter(store: store, keyProvider: { [key] _ in key })
        let (baseSHA, ctx) = try await populateSession(f)

        let first = try await snapshotter.snapshotHistory(
            project: projectRef(), sessionID: ctx.sessionID, repoPath: f.sessionWT,
            ref: "refs/heads/\(ctx.branch)", baseSHA: baseSHA, originSafeSHA: baseSHA,
            ownerDeviceID: "host-A", ownerGeneration: 1)
        #expect(first != nil)
        // Tip unchanged ⇒ covered ⇒ no manifest.
        let repeated = try await snapshotter.snapshotHistory(
            project: projectRef(), sessionID: ctx.sessionID, repoPath: f.sessionWT,
            ref: "refs/heads/\(ctx.branch)", baseSHA: baseSHA, originSafeSHA: baseSHA,
            ownerDeviceID: "host-A", ownerGeneration: 1)
        #expect(repeated == nil)

        // Another commit ⇒ chain extends with only the delta.
        try await f.sh(["add", "-A"], in: f.sessionWT)
        try await f.sh(["commit", "-m", "more work"], in: f.sessionWT)
        let second = try await snapshotter.snapshotHistory(
            project: projectRef(), sessionID: ctx.sessionID, repoPath: f.sessionWT,
            ref: "refs/heads/\(ctx.branch)", baseSHA: baseSHA, originSafeSHA: baseSHA,
            ownerDeviceID: "host-A", ownerGeneration: 1)
        let m2 = try await store.manifest(id: try #require(second), projectUUID: projectUUID)
        #expect(m2?.snapshotSeq == 2)
        #expect(m2?.parentManifest == first)
    }

    // MARK: - Native transcript chain

    @Test func nativeTranscriptSegmentsAndRebaseRoundTrip() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let store = makeStore(f)
        let snapshotter = CarbonSnapshotter(store: store, keyProvider: { [key] _ in key })
        let (baseSHA, _) = try await populateSession(f)
        let ctx = sessionContext(f, baseSHA: baseSHA)
        let native = f.root.appendingPathComponent("native.jsonl")
        let restoredFile = f.root.appendingPathComponent("native-restored.jsonl")
        let restore = CarbonRestore(store: store, keyProvider: { [key] _ in key })

        // Segment 1 + growth segment 2 ⇒ concatenation restores the full file.
        try Data("line-1\n".utf8).write(to: native)
        _ = try await snapshotter.snapshotNativeTranscript(
            context: ctx, fileURL: native, backendSessionID: "backend-1")
        try Data("line-1\nline-2\n".utf8).write(to: native)
        _ = try await snapshotter.snapshotNativeTranscript(
            context: ctx, fileURL: native, backendSessionID: "backend-1")
        // Unchanged ⇒ no-op.
        let unchanged = try await snapshotter.snapshotNativeTranscript(
            context: ctx, fileURL: native, backendSessionID: "backend-1")
        #expect(unchanged == nil)

        var backendID = try await restore.restoreNativeTranscript(
            projectUUID: projectUUID, sessionID: ctx.sessionID, to: restoredFile)
        #expect(backendID == "backend-1")
        #expect(try Data(contentsOf: restoredFile) == Data("line-1\nline-2\n".utf8))

        // The CLI compacts the file (shrinks) ⇒ rebase manifest restarts the chain.
        try Data("compacted\n".utf8).write(to: native)
        _ = try await snapshotter.snapshotNativeTranscript(
            context: ctx, fileURL: native, backendSessionID: "backend-1")
        backendID = try await restore.restoreNativeTranscript(
            projectUUID: projectUUID, sessionID: ctx.sessionID, to: restoredFile)
        #expect(backendID == "backend-1")
        #expect(try Data(contentsOf: restoredFile) == Data("compacted\n".utf8))
    }

    // MARK: - Restore guards

    @Test func branchCollisionAborts() async throws {
        let f = try await makeFixture()
        defer { try? FileManager.default.removeItem(at: f.root) }
        let store = makeStore(f)
        let snapshotter = CarbonSnapshotter(store: store, keyProvider: { [key] _ in key })
        let (baseSHA, ctx) = try await populateSession(f)
        _ = try await produce(f, ctx, baseSHA: baseSHA, snapshotter: snapshotter)

        try await cloneReceiver(f)
        // The receiver already has a DIFFERENT branch of the same name — another live copy
        // may exist; restore must refuse rather than force.
        try await f.sh(["branch", ctx.branch, "main"], in: f.repoB)
        let restore = CarbonRestore(store: store, keyProvider: { [key] _ in key })
        await #expect(throws: (any Error).self) {
            _ = try await restore.materializeWorktree(
                projectRepoPath: f.repoB, projectUUID: projectUUID, sessionID: ctx.sessionID,
                at: f.restoredWT)
        }
    }
}
