import Foundation

// MARK: - Worktree value types (WORKTREE_MANAGER §7)

public struct Worktree: Sendable, Codable, Equatable {
    public let sessionID: SessionID
    public let path: String
    public let branch: String
    /// Diff anchor for the session's lifetime (WORKTREE_MANAGER §4).
    public let baseSHA: String
    public let createdAt: Date

    public init(sessionID: SessionID, path: String, branch: String, baseSHA: String, createdAt: Date) {
        self.sessionID = sessionID
        self.path = path
        self.branch = branch
        self.baseSHA = baseSHA
        self.createdAt = createdAt
    }
}

// DiffStat moved to NucleicProtocol/CoreIdentifiers.swift (shared with the iOS client).

public struct WorktreeStatus: Sendable, Codable, Equatable {
    public let isDirty: Bool
    public let ahead: Int
    public let behind: Int
    public let diffStat: DiffStat
    /// Non-empty ⇒ the worktree is mid-conflict (state `conflicted`).
    public let conflicts: [String]

    public init(isDirty: Bool, ahead: Int, behind: Int, diffStat: DiffStat, conflicts: [String]) {
        self.isDirty = isDirty
        self.ahead = ahead
        self.behind = behind
        self.diffStat = diffStat
        self.conflicts = conflicts
    }
}

public struct DiffOptions: Sendable {
    public var includeUntracked: Bool
    public init(includeUntracked: Bool = true) {
        self.includeUntracked = includeUntracked
    }
}

public struct FileDiff: Sendable, Codable, Equatable {
    public enum Status: String, Sendable, Codable {
        case added, modified, deleted, renamed, copied, untracked
    }
    public let path: String
    public let oldPath: String?
    public let status: Status
    public let added: Int
    public let removed: Int

    public init(path: String, oldPath: String?, status: Status, added: Int, removed: Int) {
        self.path = path
        self.oldPath = oldPath
        self.status = status
        self.added = added
        self.removed = removed
    }
}

public struct Diff: Sendable, Equatable {
    public let stat: DiffStat
    public let files: [FileDiff]
    /// Unified patch text (tracked-vs-base plus synthesized adds for untracked files).
    public let patch: String

    public init(stat: DiffStat, files: [FileDiff], patch: String) {
        self.stat = stat
        self.files = files
        self.patch = patch
    }
}

/// How to handle uncommitted work before integrating (WORKTREE_MANAGER §6.1).
public enum CommitOption: Sendable, Equatable {
    /// Stage all + commit a snapshot so the branch is clean. Default.
    case auto(message: String)
    /// Require a committed tree; refuse to proceed if dirty.
    case manual
    /// Leave as-is (only valid for discard).
    case none
}

public enum IntegrationStrategy: Sendable, Equatable {
    case merge
    case rebase
    case squash
}

public struct ConflictedPath: Sendable, Codable, Equatable {
    public let path: String
    public init(path: String) { self.path = path }
}

/// Outcome of update-on-grant (LOCKING §4.5). `conflicts` non-empty ⇒ a real merge conflict: the
/// merge was aborted and the worktree left intact, so the session pauses holding its lock. Otherwise
/// the merge was clean *or* refused on a dirty tree (the worktree may or may not have been
/// refreshed). `changedFiles` lists the requested files the parent moved relative to the worktree's
/// pre-merge `HEAD` — derived from a **commit-to-commit** diff, so it is reliable even when the
/// working-tree merge refused — and `diff` is the bounded, scoped patch for those files (empty when
/// there are none, or when the patch was too large to inline; the caller then instructs a re-read).
public struct ParentUpdate: Sendable, Equatable {
    public let conflicts: [ConflictedPath]
    public let changedFiles: [String]
    public let diff: String
    public init(conflicts: [ConflictedPath], changedFiles: [String], diff: String) {
        self.conflicts = conflicts
        self.changedFiles = changedFiles
        self.diff = diff
    }
    /// Nothing to surface — up to date, dirty-refused with no requested file moved, or worktree-less.
    public static let clean = ParentUpdate(conflicts: [], changedFiles: [], diff: "")
}

public enum IntegrationResult: Sendable, Equatable {
    case clean(mergedInto: GitRef, commit: String)
    case conflicted([ConflictedPath])
}

/// A divergence between the DB's expected worktrees and `git worktree list`
/// (WORKTREE_MANAGER §8).
public struct Orphan: Sendable, Equatable {
    public enum Kind: Sendable, Equatable {
        /// Present in git, not tracked by us — offer cleanup.
        case inGitNotDB
        /// Tracked by us, gone from git — stale record.
        case inDBNotGit
    }
    public let kind: Kind
    public let path: String?
    public let branch: String?

    public init(kind: Kind, path: String?, branch: String?) {
        self.kind = kind
        self.path = path
        self.branch = branch
    }
}

// MARK: - Protocol (Swift surface, WORKTREE_MANAGER §7)
//
// Deviates from the doc's signature in one way: methods that act on the *repo*
// rather than the worktree (`integrate`, `discard`, `createPR`, `reconcile`) take
// the owning `Project` so the manager stays repo-agnostic and never has to guess
// the main checkout's path. `reconcile` also takes the DB's expected worktrees
// (the GRDB store is built later in M1).

public protocol WorktreeManaging: Sendable {
    func create(for session: SessionID, in project: Project, base: GitRef, slug: String) async throws -> Worktree
    func status(of worktree: Worktree) async throws -> WorktreeStatus
    func diff(of worktree: Worktree, _ options: DiffOptions) async throws -> Diff
    func unmergedFiles(of worktree: Worktree, into target: GitRef) async throws -> [String]
    func updateFromParent(_ worktree: Worktree, parentRef: GitRef, requestedFiles: [String]) async throws -> ParentUpdate
    func finalize(_ worktree: Worktree, commit: CommitOption, onCommit: (@Sendable () async -> Void)?) async throws
    func integrate(_ worktree: Worktree, in project: Project, into target: GitRef, strategy: IntegrationStrategy, message: String?, onStash: (@Sendable () async -> Void)?, onSync: (@Sendable () async -> Void)?, onSigned: (@Sendable (String) async -> Void)?) async throws -> IntegrationResult
    func createPR(_ worktree: Worktree, in project: Project, base: GitRef, title: String, body: String) async throws -> URL
    func discard(_ worktree: Worktree, in project: Project, force: Bool) async throws
    func reconcile(_ project: Project, expected: [Worktree]) async throws -> [Orphan]
    func reclaim(_ worktree: Worktree, in project: Project) async throws
    func restore(_ worktree: Worktree, in project: Project) async throws
}

// MARK: - Implementation

/// Owns worktree lifecycle, diffing, and integration over the `git` CLI.
///
/// An `actor`, so all of its structural ops are serialized — satisfying
/// WORKTREE_MANAGER invariant #3 (structural git ops serialized per repo) with a
/// single global lock for M1's one-repo MVP. (M2 refines this to per-repo locks so
/// read-only `status`/`diff` across many repos run in parallel.)
public actor GitWorktreeManager: WorktreeManaging {
    private let git: GitRunner
    private let now: @Sendable () -> Date

    public init(git: GitRunner = GitRunner(), now: @escaping @Sendable () -> Date = { Date() }) {
        self.git = git
        self.now = now
    }

    // MARK: create

    public func create(
        for session: SessionID, in project: Project, base: GitRef, slug rawSlug: String
    ) async throws -> Worktree {
        let root = project.rootPath
        let topLevel = try await git.run(["rev-parse", "--show-toplevel"], in: root)
        if !topLevel.ok {
            // The directory isn't a git repo yet (e.g. a brand-new project folder).
            // Rather than fail the session with `.notAGitRepo`, initialize one on the
            // chosen branch with an empty seed commit so there's a base to branch the
            // worktree from.
            try await initializeRepo(at: root, branch: base.value)
        }

        var baseResult = try await git.run(["rev-parse", "--verify", "\(base.value)^{commit}"], in: root)
        if !baseResult.ok, !base.value.contains("/"),
           let remoteRef = await remoteTrackingRef(for: base.value, in: root) {
            // The base exists only as a remote-tracking branch — a fresh clone (e.g. a Nucleic
            // Control repo) has just its checked-out default branch as a local ref; every other
            // branch is only `origin/<name>`. Materialize a local branch tracking the remote so
            // this worktree's base resolves AND a later merge/autoship into the same branch
            // (also a local-ref op) works. Best-effort: re-resolve regardless of the result, so
            // a concurrent create that already made the branch still proceeds.
            _ = try? await git.run(["branch", "--track", base.value, remoteRef], in: root)
            baseResult = try await git.run(["rev-parse", "--verify", "\(base.value)^{commit}"], in: root)
        }
        guard baseResult.ok else {
            throw GitError.baseResolutionFailed(ref: base.value, stderr: baseResult.stderr)
        }
        let baseSHA = baseResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)

        let worktreeBase = project.resolvedWorktreeBase
        try FileManager.default.createDirectory(atPath: worktreeBase, withIntermediateDirectories: true)
        // The default base lives inside the repo under `.nucleic/`; exclude it locally so
        // the user's primary checkout never reports the worktrees as untracked.
        try? await excludeNucleicDir(inRepoRoot: root, worktreeBase: worktreeBase)

        let (path, branch) = try await allocate(slug: Slug.sanitize(rawSlug), base: worktreeBase, root: root)

        let add = try await git.run(["worktree", "add", path, "-b", branch, baseSHA], in: root)
        if !add.ok {
            // Roll back any half-made artifacts so a failed create lands in `failed`
            // cleanly rather than leaking a worktree/branch (WORKTREE_MANAGER §3).
            _ = try? await git.run(["worktree", "remove", "--force", path], in: root)
            _ = try? await git.run(["branch", "-D", branch], in: root)
            throw GitError.commandFailed(
                command: "git worktree add \(path)", status: add.status, stderr: add.stderr)
        }
        return Worktree(sessionID: session, path: path, branch: branch, baseSHA: baseSHA, createdAt: now())
    }

    /// The remote-tracking ref (e.g. `origin/dev`) for a bare branch `name`, or nil if no remote
    /// has it. Tries `origin` first (the usual case for a clone), then any other remote. Used to
    /// recover a base branch that exists only remotely in a fresh clone.
    private func remoteTrackingRef(for name: String, in root: String) async -> String? {
        var remotes = ["origin"]
        if let listed = try? await git.run(["remote"], in: root), listed.ok {
            for r in listed.stdout.split(whereSeparator: \.isNewline).map(String.init)
            where !r.isEmpty && !remotes.contains(r) {
                remotes.append(r)
            }
        }
        for remote in remotes {
            let ref = "\(remote)/\(name)"
            if (try? await git.run(["rev-parse", "--verify", "--quiet", "\(ref)^{commit}"], in: root))?.ok == true {
                return ref
            }
        }
        return nil
    }

    /// Initializes a fresh git repo at `root` on `branch` and seeds an empty commit so
    /// `worktree add` has a base to branch from. Called from `create` when the target
    /// directory isn't a git repo yet, instead of failing with `.notAGitRepo`.
    private func initializeRepo(at root: String, branch: String) async throws {
        let initResult = try await git.run(["init", "-b", branch], in: root)
        guard initResult.ok else {
            throw GitError.commandFailed(
                command: "git init -b \(branch)", status: initResult.status, stderr: initResult.stderr)
        }

        // Seed an empty commit so the new branch has a base. Author it with the host's
        // configured identity, falling back to a Nucleic placeholder so init can't fail
        // on a machine with no global git identity; force gpgsign off so a configured-
        // but-unusable signing key can't block the commit either.
        var commit = ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "Initial commit"]
        let haveName = try await git.run(["config", "user.name"], in: root).ok
        let haveEmail = try await git.run(["config", "user.email"], in: root).ok
        if !haveName || !haveEmail {
            commit = ["-c", "user.name=Nucleic", "-c", "user.email=nucleic@blakeslee.xyz"] + commit
        }
        let commitResult = try await git.run(commit, in: root)
        guard commitResult.ok else {
            throw GitError.commandFailed(
                command: "git commit --allow-empty -m 'Initial commit'",
                status: commitResult.status, stderr: commitResult.stderr)
        }
    }

    /// Ensures `.nucleic/` is in the repo's local exclude file when the worktree base
    /// lives inside the working tree (the default `<repoRoot>/.nucleic/worktrees`). Uses
    /// `.git/info/exclude` rather than the tracked `.gitignore` so the user's committed
    /// files are never touched. A no-op when the base is outside the repo (custom override).
    private func excludeNucleicDir(inRepoRoot root: String, worktreeBase: String) async throws {
        let nucleicDir = (root as NSString).appendingPathComponent(".nucleic")
        guard worktreeBase == nucleicDir || worktreeBase.hasPrefix(nucleicDir + "/") else { return }

        let gitDir = try await git.run(["rev-parse", "--git-common-dir"], in: root)
        guard gitDir.ok else { return }
        var common = gitDir.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
        if !common.hasPrefix("/") {
            common = (root as NSString).appendingPathComponent(common)
        }
        let infoDir = (common as NSString).appendingPathComponent("info")
        let excludePath = (infoDir as NSString).appendingPathComponent("exclude")

        let entry = ".nucleic/"
        let existing = (try? String(contentsOfFile: excludePath, encoding: .utf8)) ?? ""
        let alreadyExcluded = existing
            .split(whereSeparator: \.isNewline)
            .contains { $0.trimmingCharacters(in: .whitespaces) == entry }
        if alreadyExcluded { return }

        try FileManager.default.createDirectory(atPath: infoDir, withIntermediateDirectories: true)
        var updated = existing
        if !updated.isEmpty && !updated.hasSuffix("\n") { updated += "\n" }
        updated += entry + "\n"
        try updated.write(toFile: excludePath, atomically: true, encoding: .utf8)
    }

    /// Finds a free `(dir, branch)` pair, deduping with a numeric suffix on collision.
    /// Serialized by the actor, so there's no TOCTOU between the check and `worktree add`.
    private func allocate(slug desired: String, base: String, root: String) async throws -> (String, String) {
        var n = 1
        while n <= 1000 {
            let candidate = n == 1 ? desired : "\(desired)-\(n)"
            let branch = "nucleic/\(candidate)"
            let path = (base as NSString).appendingPathComponent(candidate)
            let branchExists = try await git.run(
                ["rev-parse", "--verify", "--quiet", "refs/heads/\(branch)"], in: root).ok
            if !branchExists && !FileManager.default.fileExists(atPath: path) {
                return (path, branch)
            }
            n += 1
        }
        throw GitError.worktreeExists(path: (base as NSString).appendingPathComponent(desired))
    }

    // MARK: status & diff

    public func status(of worktree: Worktree) async throws -> WorktreeStatus {
        let porcelain = try await git.checked(
            ["status", "--porcelain=v2", "--untracked-files=all"], in: worktree.path)
        let isDirty = porcelain.split(separator: "\n").contains { line in
            guard let first = line.first else { return false }
            return first == "1" || first == "2" || first == "u" || first == "?"
        }
        let conflicts = try await conflictedPaths(in: worktree.path)
        let stat = try await diffStat(of: worktree)
        let (behind, ahead) = try await aheadBehind(of: worktree)
        return WorktreeStatus(
            isDirty: isDirty, ahead: ahead, behind: behind, diffStat: stat, conflicts: conflicts)
    }

    public func diff(of worktree: Worktree, _ options: DiffOptions = DiffOptions()) async throws -> Diff {
        var files: [FileDiff] = []

        let numstat = try await git.checked(["diff", "--numstat", worktree.baseSHA, "--"], in: worktree.path)
        let addRem = Self.parseNumstat(numstat)
        let nameStatus = try await git.checked(
            ["-c", "core.quotePath=false", "diff", "--name-status", "--find-renames", worktree.baseSHA, "--"],
            in: worktree.path)
        var patch = try await git.checked(["diff", worktree.baseSHA, "--"], in: worktree.path)

        for entry in Self.parseNameStatus(nameStatus) {
            let ar = addRem[entry.path] ?? (0, 0)
            files.append(
                FileDiff(path: entry.path, oldPath: entry.oldPath, status: entry.status,
                         added: ar.added, removed: ar.removed))
        }

        if options.includeUntracked {
            for path in try await untrackedPaths(in: worktree.path) {
                let added = try await untrackedAddedLines(path: path, in: worktree.path)
                let r = try await git.run(["diff", "--no-index", "--", "/dev/null", path], in: worktree.path)
                patch += r.stdout
                files.append(FileDiff(path: path, oldPath: nil, status: .untracked, added: added, removed: 0))
            }
        }

        let stat = DiffStat(
            filesChanged: files.count,
            added: files.reduce(0) { $0 + $1.added },
            removed: files.reduce(0) { $0 + $1.removed })
        return Diff(stat: stat, files: files.sorted { $0.path < $1.path }, patch: patch)
    }

    /// Repo-relative files this session has changed that are **not yet present in
    /// `target`** — its `check_conflict` footprint (the files it has "borrowed").
    ///
    /// The footprint intersects "what the session itself contributed, not yet in `target`"
    /// with "what still differs from `target`'s tip": a file is held only while the session
    /// has its own unmerged content for it. Once the work lands, those files drop out and
    /// the lock is released.
    ///
    /// "What the session contributed" is anchored at the *current* merge base of `target`
    /// and `HEAD` (the three-dot `target...HEAD` diff), **not** the fixed fork point
    /// `worktree.baseSHA`. This is what releases the lock after a merge: once the session's
    /// commits land in `target`, the merge base advances to the session tip and the
    /// three-dot diff empties — even if `target` later moves *past* those same files (other
    /// sessions, or the user, editing them after this session merged). A fixed-`baseSHA`
    /// anchor would keep re-locking such files forever, since they'd still differ from a
    /// since-advanced `target`. Uncommitted working-tree edits (`git diff HEAD`) and
    /// untracked files are unioned in so in-flight work still counts as held. Robust across
    /// merge/squash/rebase: the content gate (`differing`) releases squashes/rebases at the
    /// moment they land, and the moving merge base releases ancestry-preserving merges even
    /// after `target` advances.
    public func unmergedFiles(of worktree: Worktree, into target: GitRef) async throws -> [String] {
        let sinceMergeBase = try await git.checked(
            ["-c", "core.quotePath=false", "diff", "--name-only", "--find-renames",
             "\(target.value)...HEAD", "--"], in: worktree.path)
        let uncommitted = try await git.checked(
            ["-c", "core.quotePath=false", "diff", "--name-only", "--find-renames", "HEAD", "--"],
            in: worktree.path)
        let untracked = Set(try await untrackedPaths(in: worktree.path))
        var changed = Set(sinceMergeBase.split(separator: "\n").map(String.init))
        changed.formUnion(uncommitted.split(separator: "\n").map(String.init))
        changed.formUnion(untracked)
        guard !changed.isEmpty else { return [] }

        let vsTarget = try await git.checked(
            ["-c", "core.quotePath=false", "diff", "--name-only", "--find-renames", target.value, "--"],
            in: worktree.path)
        var differing = Set(vsTarget.split(separator: "\n").map(String.init))
        differing.formUnion(untracked)  // untracked files have no counterpart in target

        return changed.intersection(differing).sorted()
    }

    private func diffStat(of worktree: Worktree) async throws -> DiffStat {
        var files = Set<String>()
        var added = 0
        var removed = 0
        let numstat = try await git.checked(["diff", "--numstat", worktree.baseSHA, "--"], in: worktree.path)
        for (path, ar) in Self.parseNumstat(numstat) {
            files.insert(path)
            added += ar.added
            removed += ar.removed
        }
        for path in try await untrackedPaths(in: worktree.path) {
            files.insert(path)
            added += try await untrackedAddedLines(path: path, in: worktree.path)
        }
        return DiffStat(filesChanged: files.count, added: added, removed: removed)
    }

    private func aheadBehind(of worktree: Worktree) async throws -> (behind: Int, ahead: Int) {
        let out = try await git.checked(
            ["rev-list", "--left-right", "--count", "\(worktree.baseSHA)...HEAD"], in: worktree.path)
        let parts = out.split { $0 == "\t" || $0 == " " }.compactMap { Int($0) }
        guard parts.count == 2 else { return (0, 0) }
        return (parts[0], parts[1])
    }

    // MARK: finalize

    public func finalize(
        _ worktree: Worktree, commit: CommitOption,
        onCommit: (@Sendable () async -> Void)? = nil
    ) async throws {
        switch commit {
        case .auto(let message):
            try await git.checked(["add", "-A"], in: worktree.path)
            let staged = try await git.run(["diff", "--cached", "--quiet"], in: worktree.path)
            if staged.status != 0 {  // 1 ⇒ there are staged changes to commit
                try await git.checked(["commit", "-m", message], in: worktree.path)
                await onCommit?()
            }
        case .manual:
            let porcelain = try await git.checked(["status", "--porcelain"], in: worktree.path)
            if !porcelain.isEmpty { throw GitError.dirtyTree(path: worktree.path) }
        case .none:
            break
        }
    }

    // MARK: integrate

    public func integrate(
        _ worktree: Worktree, in project: Project, into target: GitRef,
        strategy: IntegrationStrategy, message: String? = nil,
        onStash: (@Sendable () async -> Void)? = nil,
        onSync: (@Sendable () async -> Void)? = nil,
        onSigned: (@Sendable (String) async -> Void)? = nil
    ) async throws -> IntegrationResult {
        let root = project.rootPath
        let commitMessage = message ?? "Merge \(worktree.branch) into \(target.value)"

        // Integration lands in the *target's checkout context* (WORKTREE_MANAGER §6.2).
        // Doing it in a real checkout (rather than via a detached worktree + ref bump)
        // keeps that checkout's HEAD, index, and working tree in sync; a ref bump on a
        // checked-out branch would desync it and make it look dirty.
        //
        // `target` is usually the main working copy's branch, but it may live in a
        // *separate* worktree — e.g. a gated dev→staging→rc→main flow where the main
        // checkout sits on `staging` while `dev` is checked out elsewhere. `git checkout
        // target` in a tree where target is already checked out by another worktree fails
        // ("'dev' is already checked out at ..."), so resolve target's actual checkout and
        // merge there; only fall back to checking out target in root when no worktree holds it.
        let listing = try await git.checked(["worktree", "list", "--porcelain"], in: root)
        let targetCheckout = Self.parseWorktreeBranches(listing)[target.value]
        let mergeDir = targetCheckout ?? root

        // Precondition: the merge must run on a *clean* target tree, or it could clobber
        // uncommitted work there. A dirty target checkout used to abort the integration
        // outright (`GitError.dirtyTree`) — and since the target is shared, a single stray
        // untracked file in the root `dev` checkout blocked autoship for *every* session at
        // once. Instead of failing, set the dirt aside in a stash, merge on the now-clean
        // tree, then restore it. A stash is fully recoverable, so nothing the user had is
        // ever lost (WORKTREE_MANAGER §6.2).
        let stashedDirt = try await stashDirt(in: mergeDir, label: target.value)
        if stashedDirt { await onStash?() }

        // Restore the set-aside dirt — or, if it collides with what we merged, retain it
        // safely in the stash. Called on exactly one exit path per run (clean merge, conflict,
        // or thrown error) so the target tree never silently loses the user's local changes.
        func restoreDirt() async {
            guard stashedDirt else { return }
            let pop = try? await git.run(["stash", "pop"], in: mergeDir)
            if pop?.ok != true {
                // The set-aside changes overlap what we just merged (e.g. a stray untracked
                // file sharing a merged file's name). A conflicted pop would leave markers in
                // the target; reset to the clean merged tip and keep the changes safe in the
                // stash (recover with `git stash list` / `git stash pop`) instead.
                _ = try? await git.run(["reset", "--hard", "HEAD"], in: mergeDir)
            }
        }

        do {
            // Sign the integration commit(s) with the configured Managed Git key when "Sign commits
            // with this SSH key" is on. Without this, the merge/squash/rebase commits run with no
            // credential env and fall back to the host's ambient gitconfig — signing with the user's
            // *personal* key (or not at all), never the configured Managed Git key. Empty flags + env
            // when signing is off, so the commands run exactly as before. (`provisionHost`, used for
            // the push below, carries the same signing config but reaches no commit — only transport.)
            let (signFlags, signEnv, signCleanup) = GitHubCredentialProvisioner.commitSigning()
            defer { signCleanup() }

            // When a worktree already holds `target`, mergeDir is on target by construction;
            // only when falling back to root do we need to switch it onto target (safe — clean).
            if targetCheckout == nil {
                let currentBranch = try await git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], in: mergeDir)
                let onTarget = currentBranch.ok
                    && currentBranch.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == target.value
                if !onTarget {
                    try await git.checked(["checkout", target.value], in: mergeDir)
                }
            }

            func conflicted(in cwd: String, abort: [String]) async throws -> IntegrationResult {
                let conflicts = try await conflictedPaths(in: cwd)
                _ = try? await git.run(abort, in: cwd)
                await restoreDirt()
                return .conflicted(conflicts.map(ConflictedPath.init))
            }

            switch strategy {
            case .merge:
                let merge = try await git.run(
                    signFlags + ["merge", "--no-ff", "-m", commitMessage, worktree.branch],
                    in: mergeDir, env: signEnv)
                if !merge.ok { return try await conflicted(in: mergeDir, abort: ["merge", "--abort"]) }

            case .squash:
                let merge = try await git.run(["merge", "--squash", worktree.branch], in: mergeDir)
                // `--squash` sets no MERGE_HEAD, so `git merge --abort` can't undo a conflicted
                // squash — it leaves conflict markers in the target checkout. Reset hard to HEAD
                // instead (the true inverse), so a conflict never dirties the target tree.
                if !merge.ok { return try await conflicted(in: mergeDir, abort: ["reset", "--hard", "HEAD"]) }
                // --squash stages without committing; commit unless the branch was a no-op.
                let staged = try await git.run(["diff", "--cached", "--quiet"], in: mergeDir)
                if staged.status != 0 {
                    try await git.checked(
                        signFlags + ["commit", "-m", commitMessage], in: mergeDir, env: signEnv)
                }

            case .rebase:
                // Replay the session branch onto target (linear history), then
                // fast-forward the (clean, checked-out) target to it. The replayed commits are
                // re-created here, so the signing flags make them carry the Managed Git signature
                // (git's default merge-backend rebase honors commit.gpgsign).
                let rebase = try await git.run(
                    signFlags + ["rebase", target.value], in: worktree.path, env: signEnv)
                if !rebase.ok { return try await conflicted(in: worktree.path, abort: ["rebase", "--abort"]) }
                let ff = try await git.run(["merge", "--ff-only", worktree.branch], in: mergeDir)
                guard ff.ok else {
                    throw GitError.commandFailed(
                        command: "git merge --ff-only \(worktree.branch)", status: ff.status, stderr: ff.stderr)
                }
            }

            let head = try await git.checked(["rev-parse", "HEAD"], in: mergeDir)
            // Surface commit signing as a transcript note: when the host has signing configured
            // (`commit.gpgsign` + a usable GPG/SSH key) git records a signature on the commit this
            // integration just created, so callers can report that the shipped commit was signed.
            if let onSigned, await commitIsSigned(head, in: mergeDir) { await onSigned(head) }
            await restoreDirt()
            // The session's work now lives in `target`, but the session's own branch still
            // forks from the pre-merge base — so its next turn would start out of date with
            // (and behind) what it just shipped. Pull the freshly-updated target back into the
            // session branch in its own worktree so it stays in sync for future edits.
            await resyncBranch(worktree, with: target, onSync: onSync)
            return .clean(mergedInto: target, commit: head)
        } catch {
            await restoreDirt()
            throw error
        }
    }

    /// Whether the commit at `ref` carries a recorded signature — detected from the presence of a
    /// `gpgsig` header on the raw commit object (`git cat-file commit`). This is deliberately *not*
    /// git's `%G?`/`%GG` verification placeholders: those report the *verification* result, which
    /// for an SSH signature collapses to "no signature" (`%G?` → "N", `%GG` → empty) whenever no
    /// `gpg.ssh.allowedSignersFile` is configured — the common case on a dev host — so a genuinely
    /// signed commit gets misreported as unsigned. Reading the stored signature header instead
    /// depends on no verifier the host may not have set up. Best-effort — a failed query reports
    /// "unsigned" rather than throwing, so a missing signing note can't fail a good integration.
    private func commitIsSigned(_ ref: String, in cwd: String) async -> Bool {
        guard let result = try? await git.run(["cat-file", "commit", ref], in: cwd),
              result.ok else { return false }
        // The commit object's headers precede the first blank line; a signed commit carries a
        // `gpgsig` header there (an SSH signature is stored under `gpgsig`, just like GPG).
        for line in result.stdout.split(separator: "\n", omittingEmptySubsequences: false) {
            if line.isEmpty { break }  // end of headers, start of the message
            if line.hasPrefix("gpgsig ") { return true }
        }
        return false
    }

    /// Sets aside any uncommitted changes (tracked edits *and* untracked files) in `cwd`
    /// so an integration can run on a clean tree, returning whether anything was stashed.
    /// The stash carries a descriptive label so a retained (un-poppable) stash is easy to
    /// find with `git stash list`.
    private func stashDirt(in cwd: String, label: String) async throws -> Bool {
        let status = try await git.checked(["status", "--porcelain"], in: cwd)
        guard !status.isEmpty else { return false }
        try await git.checked(
            ["stash", "push", "--include-untracked",
             "-m", "nucleic-autoship: set aside \(label) changes before merge"],
            in: cwd)
        return true
    }

    /// After a clean integration, merge the freshly-updated `target` back into the session's
    /// own branch *in its worktree* so the session stays current for future edits — otherwise
    /// its branch keeps forking from the old base and immediately reads as behind what it just
    /// shipped (and any sibling work that landed in target meanwhile).
    ///
    /// Best-effort: the ship has already succeeded, so this must never fail it. If the resync
    /// conflicts (a sibling session landed overlapping work in target between this session's
    /// fork point and now), abort it and leave the branch exactly as it was for the user to
    /// reconcile on the next turn. `onSync` fires only when the resync actually advanced the
    /// branch, so a no-op (already up to date — e.g. after a `.rebase`/`.merge` integration)
    /// emits no note. The worktree is clean here: `finalize` committed its work and the squash
    /// merge ran in the target's checkout, not this one.
    private func resyncBranch(
        _ worktree: Worktree, with target: GitRef,
        onSync: (@Sendable () async -> Void)?
    ) async {
        let before = try? await git.run(["rev-parse", "HEAD"], in: worktree.path)
        let merge = try? await git.run(["merge", "--no-edit", target.value], in: worktree.path)
        guard merge?.ok == true else {
            _ = try? await git.run(["merge", "--abort"], in: worktree.path)
            return
        }
        let after = try? await git.run(["rev-parse", "HEAD"], in: worktree.path)
        if let before, let after, before.stdout != after.stdout { await onSync?() }
    }

    // MARK: PR

    public func createPR(
        _ worktree: Worktree, in project: Project, base: GitRef, title: String, body: String
    ) async throws -> URL {
        // Pin the push to the app's configured Managed Git credential (e.g. the Nucleic-managed key)
        // so the host's ssh uses it instead of falling back to — or prompting the keychain for — the
        // user's personal ~/.ssh keys. Applied to **every** origin, not just github.com: Managed Git
        // is the one git identity. A no-op when no Managed Git credential is configured.
        let (pushEnv, cleanup) = GitHubCredentialProvisioner.provisionHost()
        defer { cleanup() }
        try await git.checked(["push", "-u", "origin", worktree.branch], in: worktree.path, env: pushEnv)
        let gh = GitRunner(executable: "gh")
        // `gh pr create` runs its own git/SSH against the remote (it can re-push the head, and queries
        // the remote), so it must inherit the same managed-credential env as the push above. Without
        // it, gh falls back to the host's ssh — the user's ssh-agent / personal `~/.ssh` keys — which
        // prompts (e.g. 1Password) and authenticates as the user's own key, NOT the Managed Git key:
        // the key ends up used only for signing, never transport. `GIT_SSH_COMMAND` pins the transport
        // here too. Drop only `GH_CONFIG_DIR`: that dir carries `git_protocol: ssh` but no gh auth, and
        // forcing it would break gh's *API* auth when no token is configured (gh resolves auth from
        // `GH_TOKEN` or the host gh login instead) — the remote is already SSH, so pr-create needs
        // nothing from it. `pushEnv` is empty when no Managed Git credential is set, so this no-ops then.
        var ghEnv = pushEnv
        ghEnv.removeValue(forKey: "GH_CONFIG_DIR")
        let out = try await gh.checked(
            ["pr", "create", "--base", base.value, "--head", worktree.branch,
             "--title", title, "--body", body],
            in: worktree.path, env: ghEnv)
        guard let url = out.split(separator: "\n").compactMap({ URL(string: String($0)) }).last else {
            throw GitError.commandFailed(command: "gh pr create", status: 0, stderr: "no URL in gh output: \(out)")
        }
        return url
    }

    /// Cap a re-ground diff: skip the inline patch above this many files…
    private static let maxRegroundDiffFiles = 5
    /// …or this many bytes. Over either, the caller lists the changed paths and asks for a re-read.
    private static let maxRegroundDiffBytes = 8 * 1024

    /// Update-on-grant (LOCKING §4.5): bring a just-granted session's worktree up to `parentRef` and
    /// report which of the files it is about to edit (`requestedFiles`) the parent moved underneath
    /// it, so the agent can re-ground before editing (it `Read` them before the lock).
    ///
    /// Detection is **decoupled from the merge**: the agent's edits are uncommitted mid-turn (commits
    /// are deferred to ship time), so a working-tree `git merge` frequently refuses on a dirty tree —
    /// precisely when divergence is highest. So "did a requested file move?" is a commit-to-commit
    /// diff (`preHEAD..parentRef`), immune to the dirty tree, and the actual merge is best-effort:
    /// - **Up to date** (`parentRef` already an ancestor of `HEAD`) → `.clean`, no merge.
    /// - **Clean merge** → worktree refreshed on disk.
    /// - **Real conflict** → abort, return `conflicts` (lock kept, session paused).
    /// - **Non-conflict refusal** (dirty tree) → leave the worktree; the session proceeds on its
    ///   current base, still re-grounded by `changedFiles`/`diff` from the detection step.
    ///
    /// **Local-only by invariant — no `git fetch`.** `parentRef` is always a *local* branch (a fork
    /// base / cascade parent / ship target — all of which Nucleic checks out and merges into locally,
    /// never an `origin/*` remote-tracking ref), so this reads the freshest committed state without a
    /// network round-trip. update-on-grant runs on the lock-grant hot path, so a fetch here would add
    /// latency and a hang risk to every edit, and it would be redundant: sibling coordination is
    /// entirely local (all Nucleic sessions share this repo and land work in local branches via
    /// `integrate`). Reconciling a remote that advanced out-of-band (`origin/<name>` moved) is the
    /// explicit sync path's job, not update-on-grant's.
    public func updateFromParent(
        _ worktree: Worktree, parentRef: GitRef, requestedFiles: [String]
    ) async throws -> ParentUpdate {
        // Efficiency guard: exit 0 ⇒ parent already contained in HEAD ⇒ nothing to pull. Exit 1 ⇒
        // parent advanced ⇒ proceed. Exit ≥2 (bad/missing ref, e.g. an orphaned cascade child) ⇒
        // fail open: skip rather than attempt a doomed merge.
        let ancestor = try await git.run(
            ["merge-base", "--is-ancestor", parentRef.value, "HEAD"], in: worktree.path)
        guard ancestor.status == 1 else { return .clean }

        let preHEAD = (try await git.run(["rev-parse", "HEAD"], in: worktree.path))
            .stdout.trimmingCharacters(in: .whitespacesAndNewlines)

        // Detection (commit-to-commit, dirty-tree-immune): which requested files did the parent move
        // relative to our pre-merge HEAD? Scoped to the requested set via a pathspec.
        let requested = Self.normalizedSet(requestedFiles)
        var changedFiles: [String] = []
        if !preHEAD.isEmpty, !requested.isEmpty {
            let names = try await git.run(
                ["-c", "core.quotePath=false", "diff", "--name-only", preHEAD, parentRef.value, "--"]
                    + requested.sorted(), in: worktree.path)
            if names.ok {
                changedFiles = names.stdout.split(separator: "\n")
                    .map { ConflictDetector.normalize(String($0)) }
                    .filter { requested.contains($0) }
                    .sorted()
            }
        }

        // Best-effort worktree refresh.
        let merge = try await git.run(["merge", "--no-edit", parentRef.value], in: worktree.path)
        if !merge.ok {
            let conflicts = try await conflictedPaths(in: worktree.path)
            if !conflicts.isEmpty {
                _ = try? await git.run(["merge", "--abort"], in: worktree.path)
                return ParentUpdate(
                    conflicts: conflicts.map(ConflictedPath.init), changedFiles: [], diff: "")
            }
            // Non-conflict refusal (dirty tree): fall through with the detection results.
        }

        guard !changedFiles.isEmpty else { return .clean }
        var diff = ""
        if changedFiles.count <= Self.maxRegroundDiffFiles {
            let patch = try await git.run(
                ["-c", "core.quotePath=false", "diff", preHEAD, parentRef.value, "--"] + changedFiles,
                in: worktree.path)
            if patch.ok, patch.stdout.utf8.count <= Self.maxRegroundDiffBytes { diff = patch.stdout }
        }
        return ParentUpdate(conflicts: [], changedFiles: changedFiles, diff: diff)
    }

    private static func normalizedSet(_ files: [String]) -> Set<String> {
        Set(files.map(ConflictDetector.normalize).filter { !$0.isEmpty })
    }

    // MARK: discard

    public func discard(_ worktree: Worktree, in project: Project, force: Bool) async throws {
        let root = project.rootPath
        var rmArgs = ["worktree", "remove"]
        if force { rmArgs.append("--force") }
        rmArgs.append(worktree.path)
        let rm = try await git.run(rmArgs, in: root)
        if !rm.ok && force {
            // The dir may already be gone; prune metadata and continue to branch delete.
            _ = try? await git.run(["worktree", "prune"], in: root)
        } else if !rm.ok {
            throw GitError.commandFailed(command: "git worktree remove", status: rm.status, stderr: rm.stderr)
        }
        let del = try await git.run(["branch", force ? "-D" : "-d", worktree.branch], in: root)
        if !del.ok && !force {
            throw GitError.commandFailed(
                command: "git branch -d \(worktree.branch)", status: del.status, stderr: del.stderr)
        }
    }

    // MARK: reclaim / restore

    /// Reclaim the disk a worktree occupies **while keeping its branch**, so an archived
    /// session that's been idle past the cleanup interval stops holding a full checkout but
    /// can be re-created on demand (see `AppStore.sweepArchivedWorktrees` / `restore`).
    ///
    /// Unlike `discard` (which deletes the branch and is irreversible), this preserves
    /// everything: any uncommitted/untracked work is committed to the branch as a WIP commit
    /// first — so removing the checkout can't lose it — then `git worktree remove` deletes
    /// only the working-tree directory. The branch and its history stay, and `restore`
    /// re-checks-it-out at the same path with the identical tree.
    public func reclaim(_ worktree: Worktree, in project: Project) async throws {
        let root = project.rootPath
        // Already gone (e.g. user deleted it manually) — just prune git's stale registration
        // so a later `restore` isn't blocked, and leave the branch be.
        guard FileManager.default.fileExists(atPath: worktree.path) else {
            _ = try? await git.run(["worktree", "prune"], in: root)
            return
        }
        // Commit any WIP onto the branch so the removal is non-destructive. A clean tree
        // commits nothing; `finalize(.auto)` stages everything (tracked + untracked) first.
        try await finalize(worktree, commit: .auto(message: "WIP (archived worktree cleanup)"))
        var rm = try await git.run(["worktree", "remove", worktree.path], in: root)
        if !rm.ok {
            // WIP is committed, so only ignored files (build artifacts) can still block the
            // plain remove — force past them; nothing the user authored is at risk.
            rm = try await git.run(["worktree", "remove", "--force", worktree.path], in: root)
        }
        if !rm.ok { _ = try? await git.run(["worktree", "prune"], in: root) }
    }

    /// Re-create a worktree previously reclaimed by `reclaim`: check its (preserved) branch
    /// back out at the original path. The branch — including any WIP commit `reclaim` made —
    /// is intact, so the working tree returns exactly as it was. A no-op if the checkout is
    /// already present.
    public func restore(_ worktree: Worktree, in project: Project) async throws {
        guard !FileManager.default.fileExists(atPath: worktree.path) else { return }
        let root = project.rootPath
        // Clear any stale "<path> is a missing but registered worktree" record left behind so
        // `worktree add` doesn't refuse the path.
        _ = try? await git.run(["worktree", "prune"], in: root)
        try FileManager.default.createDirectory(
            atPath: (worktree.path as NSString).deletingLastPathComponent,
            withIntermediateDirectories: true)
        let add = try await git.run(["worktree", "add", worktree.path, worktree.branch], in: root)
        guard add.ok else {
            throw GitError.commandFailed(
                command: "git worktree add \(worktree.path) \(worktree.branch)",
                status: add.status, stderr: add.stderr)
        }
    }

    // MARK: reconcile

    public func reconcile(_ project: Project, expected: [Worktree]) async throws -> [Orphan] {
        let root = project.rootPath
        let listing = try await git.checked(["worktree", "list", "--porcelain"], in: root)
        let actual = Self.parseWorktreeList(listing, excluding: Self.canonical(root))
        let expectedByPath = Dictionary(
            expected.map { (Self.canonical($0.path), $0) }, uniquingKeysWith: { a, _ in a })

        var orphans: [Orphan] = []
        for path in actual where expectedByPath[path] == nil {
            orphans.append(Orphan(kind: .inGitNotDB, path: path, branch: nil))
        }
        for worktree in expected where !actual.contains(Self.canonical(worktree.path)) {
            orphans.append(Orphan(kind: .inDBNotGit, path: worktree.path, branch: worktree.branch))
        }
        _ = try? await git.run(["worktree", "prune"], in: root)
        return orphans
    }

    // MARK: - git helpers

    private func conflictedPaths(in cwd: String) async throws -> [String] {
        let out = try await git.checked(
            ["-c", "core.quotePath=false", "diff", "--name-only", "--diff-filter=U"], in: cwd)
        return out.split(separator: "\n").map(String.init)
    }

    private func untrackedPaths(in cwd: String) async throws -> [String] {
        let out = try await git.checked(
            ["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], in: cwd)
        return out.split(separator: "\n").map(String.init)
    }

    private func untrackedAddedLines(path: String, in cwd: String) async throws -> Int {
        // `git diff --no-index` exits 1 when the files differ — expected for a new file.
        let r = try await git.run(["diff", "--numstat", "--no-index", "--", "/dev/null", path], in: cwd)
        var added = 0
        for line in r.stdout.split(separator: "\n") {
            let cols = line.split(separator: "\t", maxSplits: 2)
            if cols.count == 3 { added += Int(cols[0]) ?? 0 }
        }
        return added
    }

    // MARK: - parsing (static, pure — unit-testable)

    static func parseNumstat(_ text: String) -> [String: (added: Int, removed: Int)] {
        var result: [String: (Int, Int)] = [:]
        for line in text.split(separator: "\n") {
            let cols = line.split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false)
            guard cols.count == 3 else { continue }
            let added = Int(cols[0]) ?? 0  // "-" (binary) → 0
            let removed = Int(cols[1]) ?? 0
            result[String(cols[2])] = (added, removed)
        }
        return result
    }

    struct NameStatusEntry { let status: FileDiff.Status; let path: String; let oldPath: String? }

    static func parseNameStatus(_ text: String) -> [NameStatusEntry] {
        var entries: [NameStatusEntry] = []
        for line in text.split(separator: "\n") {
            let cols = line.split(separator: "\t").map(String.init)
            guard let code = cols.first?.first else { continue }
            switch code {
            case "A": if cols.count >= 2 { entries.append(.init(status: .added, path: cols[1], oldPath: nil)) }
            case "M": if cols.count >= 2 { entries.append(.init(status: .modified, path: cols[1], oldPath: nil)) }
            case "D": if cols.count >= 2 { entries.append(.init(status: .deleted, path: cols[1], oldPath: nil)) }
            case "R": if cols.count >= 3 { entries.append(.init(status: .renamed, path: cols[2], oldPath: cols[1])) }
            case "C": if cols.count >= 3 { entries.append(.init(status: .copied, path: cols[2], oldPath: cols[1])) }
            default: break
            }
        }
        return entries
    }

    /// Paths from `git worktree list --porcelain`, excluding the main checkout.
    static func parseWorktreeList(_ text: String, excluding mainRoot: String) -> Set<String> {
        var paths: Set<String> = []
        for line in text.split(separator: "\n") {
            guard line.hasPrefix("worktree ") else { continue }
            let path = canonical(String(line.dropFirst("worktree ".count)))
            if path != mainRoot { paths.insert(path) }
        }
        return paths
    }

    /// Maps each checked-out local branch to the worktree path holding it, from
    /// `git worktree list --porcelain`. Detached worktrees (no `branch` line) are skipped.
    static func parseWorktreeBranches(_ text: String) -> [String: String] {
        var result: [String: String] = [:]
        var currentPath: String?
        for line in text.split(separator: "\n", omittingEmptySubsequences: false) {
            if line.hasPrefix("worktree ") {
                currentPath = String(line.dropFirst("worktree ".count))
            } else if line.hasPrefix("branch "), let path = currentPath {
                let ref = String(line.dropFirst("branch ".count))
                let branch = ref.hasPrefix("refs/heads/") ? String(ref.dropFirst("refs/heads/".count)) : ref
                result[branch] = path
            } else if line.isEmpty {
                currentPath = nil
            }
        }
        return result
    }

    static func canonical(_ path: String) -> String {
        URL(fileURLWithPath: path).resolvingSymlinksInPath().path
    }
}
