import Foundation
import os

/// nvrsion lifecycle tracing (NVRSION). Every trunk ensure / land / reground is logged so a
/// stuck edit can be diagnosed from `log stream --predicate 'subsystem == "com.nucleic"'`
/// (category "nvrsion"). Paths + session ids are `.public` (a local project's own data).
let nvrsionLog = Logger(subsystem: "com.nucleic", category: "nvrsion")

/// Outcome of landing one edit into the trunk (NVRSION §3).
public enum NvrLandResult: Sendable, Equatable {
    /// Committed — `sha` is the new trunk HEAD.
    case landed(sha: String)
    /// Nothing to commit (the edit left the file identical to HEAD) — treated as success.
    case noop
    /// The pre-land hook rejected the edit (NVRSION §5); `stderr` is fed back to the agent.
    /// (Wired in Phase D; defined here so the contract is stable.)
    case rejected(String)
    /// Git failed (after retries) — the edit stays on disk uncommitted; lock kept.
    case failed(String)
}

/// Outcome of promoting the trunk into the real base branch (NVRSION §6).
public enum NvrPromoteResult: Sendable, Equatable {
    /// The trunk's accumulated work was squashed into `base` as one commit (`sha`).
    case promoted(sha: String)
    /// The trunk already matches `base` — nothing to promote.
    case nothingToPromote
    /// The squash into `base` conflicted (e.g. the base was edited outside the trunk); `files` list it.
    case conflicted([String])
    case failed(String)
}

/// Host-side coordinator for the shared **nvrsion trunk** (NVRSION §2–3). Owns the single
/// `nucleic/trunk` checkout for a project and lands each *completed* edit into it as a
/// **path-scoped** commit. The host performs every commit (the agent only writes the file, which
/// the virtiofs mount reflects on the host), so lock release is **host-certain** — there is no
/// landing-detection poll, and "committed ≠ landed" can't strand a lock (NVRSION §4).
///
/// Although an `actor`, git work suspends at `await`, and Swift actors are *reentrant* — two
/// `land`s would otherwise interleave their `add`+`commit` on the one shared `.git/index`. So an
/// explicit serial gate (`withGate`) makes the add→commit sequence atomic against any other trunk
/// op; the agent's own in-container git can still race the index, handled by bounded retry.
public actor NvrsionTrunk {
    private let git: GitRunner

    public init(git: GitRunner = GitRunner()) { self.git = git }

    /// Trailer crediting Nucleic as a co-author on every trunk commit. The commit's **author and
    /// committer stay the human's own git identity** (inherited from the repo's `user.name`/`.email`,
    /// NOT forced to "Nucleic") — autoship work is attributed to the person, with Nucleic recorded
    /// alongside. GitHub recognizes `Co-authored-by:` and shows Nucleic as a co-author of the commit.
    static let coauthorTrailer = "Co-authored-by: Nucleic <nucleic@blakeslee.xyz>"

    // MARK: Serial gate (reentrancy-safe actor mutex)

    private var busy = false
    private var waiters: [CheckedContinuation<Void, Never>] = []

    private func acquireGate() async {
        if !busy { busy = true; return }
        await withCheckedContinuation { waiters.append($0) }
    }
    private func releaseGate() {
        if waiters.isEmpty { busy = false } else { waiters.removeFirst().resume() }
    }
    /// Run `body` with exclusive access to this trunk's git index (serializes add+commit etc.).
    private func withGate<T>(_ body: () async throws -> T) async rethrows -> T {
        await acquireGate()
        do { let r = try await body(); releaseGate(); return r }
        catch { releaseGate(); throw error }
    }

    // MARK: State

    /// Trunk checkouts already ensured this run (idempotence), keyed by trunk path.
    private var ensured: Set<String> = []
    /// `observed[session][path]` = the trunk blob sha the session last had for `path` — drives the
    /// on-grant re-ground decision (NVRSION §3.2). Absent path → `""`.
    private var observed: [SessionID: [String: String]] = [:]

    // MARK: Ensure the trunk worktree exists (NVRSION §2)

    /// Idempotently ensure `<repo>/.nucleic/trunk` is a worktree checked out on `trunkBranch`,
    /// forking it from `base` when the branch is new. Safe to call on every nvrsion session create.
    public func ensureTrunk(root: String, trunkPath: String, trunkBranch: String, base: String) async throws {
        if ensured.contains(trunkPath) { return }
        try await withGate {
            if ensured.contains(trunkPath) { return }
            let dotGit = (trunkPath as NSString).appendingPathComponent(".git")
            if FileManager.default.fileExists(atPath: dotGit) {
                ensured.insert(trunkPath)
                nvrsionLog.info("nvrsion ensureTrunk reuse path=\(trunkPath, privacy: .public)")
                return
            }
            let parent = (trunkPath as NSString).deletingLastPathComponent
            try? FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
            // Drop any stale worktree registration for this path before re-adding.
            _ = try? await git.run(["worktree", "prune"], in: root)
            let branchExists =
                (try? await git.run(["rev-parse", "--verify", "--quiet", "\(trunkBranch)^{commit}"], in: root))?.ok == true
            let args = branchExists
                ? ["worktree", "add", trunkPath, trunkBranch]
                : ["worktree", "add", trunkPath, "-b", trunkBranch, base]
            let res = try await git.run(args, in: root)
            guard res.ok else {
                nvrsionLog.error("nvrsion ensureTrunk FAILED path=\(trunkPath, privacy: .public) stderr=\(res.stderr, privacy: .public)")
                throw GitError.commandFailed(
                    command: "git " + args.joined(separator: " "), status: res.status, stderr: res.stderr)
            }
            ensured.insert(trunkPath)
            nvrsionLog.notice("nvrsion ensureTrunk created path=\(trunkPath, privacy: .public) branch=\(trunkBranch, privacy: .public) base=\(base, privacy: .public)")
        }
    }

    // MARK: Land one edit (NVRSION §3)

    /// Commit exactly `paths` (repo-relative) into the trunk as one path-scoped commit, attributed
    /// to `session`. Serialized; retries a few times if the agent's own git holds `.git/index.lock`.
    /// Returns `.landed`/`.noop` on success (lock may release), or `.failed` (lock kept).
    ///
    /// `prelandHook` (NVRSION §5), when set, runs on the edited files *before* the commit; a non-zero
    /// exit returns `.rejected` so the edit does **not** land (its content stays on disk, lock kept).
    /// The hook runs OUTSIDE the index gate (it only reads files), so a slow hook can't wedge other
    /// sessions' lands — only its own edit waits.
    public func land(
        trunkPath: String, session: SessionID, paths: [String], message: String,
        prelandHook: String? = nil
    ) async -> NvrLandResult {
        let specs = paths.filter { !$0.isEmpty }
        guard !specs.isEmpty else { return .noop }
        if let prelandHook {
            let (passed, output) = await runPrelandHook(prelandHook, trunkPath: trunkPath, paths: specs)
            if !passed {
                nvrsionLog.notice("nvrsion preland REJECT session=\(session.rawValue, privacy: .public) paths=\(specs.joined(separator: ","), privacy: .public)")
                return .rejected(output.isEmpty ? "pre-land check failed" : output)
            }
        }
        return await withGate {
            for attempt in 0..<3 {
                // Stage adds/mods/deletes scoped to these paths only — never another session's
                // in-flight file (the per-edit isolation that keeps trunk consistent).
                let add = try? await git.run(["add", "-A", "--"] + specs, in: trunkPath)
                let commit = try? await git.run(
                    ["-c", "commit.gpgsign=false", "commit", "-m", message,
                     "--trailer", "Nucleic-Session: \(session.rawValue)",
                     "--trailer", Self.coauthorTrailer, "--"] + specs,
                    in: trunkPath)
                guard let commit else {
                    if attempt < 2 { try? await Task.sleep(nanoseconds: 60_000_000); continue }
                    return .failed("git did not run")
                }
                if commit.ok {
                    let head = (try? await git.run(["rev-parse", "HEAD"], in: trunkPath))?
                        .stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
                    await recordObserved(session: session, trunkPath: trunkPath, paths: specs)
                    nvrsionLog.notice("nvrsion land session=\(session.rawValue, privacy: .public) paths=\(specs.joined(separator: ","), privacy: .public) sha=\(head, privacy: .public)")
                    return .landed(sha: head)
                }
                let blob = commit.stdout + commit.stderr
                if blob.contains("nothing to commit") || blob.contains("no changes added") {
                    // The edit produced no net change vs HEAD (identical content) — fine.
                    await recordObserved(session: session, trunkPath: trunkPath, paths: specs)
                    return .noop
                }
                if blob.contains("index.lock") && attempt < 2 {
                    try? await Task.sleep(nanoseconds: 60_000_000); continue  // agent's git holds it; retry
                }
                _ = add
                nvrsionLog.error("nvrsion land FAILED session=\(session.rawValue, privacy: .public) paths=\(specs.joined(separator: ","), privacy: .public) stderr=\(commit.stderr, privacy: .public)")
                return .failed(commit.stderr)
            }
            return .failed("exhausted retries")
        }
    }

    // MARK: Launch crash-recovery (NVRSION §8)

    /// Ensure the trunk exists, then commit any uncommitted residue a crash mid-edit may have left
    /// on it (the agent wrote a file but the app died before the `.fileChange` landed it), so the
    /// trunk starts each run clean and nothing is silently lost. Best-effort — failures only log.
    public func recover(root: String, trunkPath: String, trunkBranch: String, base: String) async {
        do {
            try await ensureTrunk(root: root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: base)
        } catch {
            nvrsionLog.error("nvrsion recover ensure-failed path=\(trunkPath, privacy: .public)")
            return
        }
        await withGate {
            // Clear any stray in-progress merge first. A crash mid-promote (or the pre-fix wedge)
            // can leave the trunk with MERGE_HEAD + conflict markers; without this, the `add -A` +
            // full `commit` below would bake those markers into trunk history. `merge --abort`
            // restores the working tree to HEAD (no commits touched) — the half-merge is recoverable
            // from {trunk HEAD, base} on the next promote's resync — and is a harmless no-op when no
            // merge is in progress. Genuine uncommitted residue is then re-landed normally below.
            _ = try? await git.run(["merge", "--abort"], in: trunkPath)
            let status = (try? await git.run(["status", "--porcelain"], in: trunkPath))?
                .stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            guard !status.isEmpty else { return }
            _ = try? await git.run(["add", "-A"], in: trunkPath)
            _ = try? await git.run(
                ["-c", "commit.gpgsign=false", "commit",
                 "-m", "nvrsion: recovered uncommitted work after restart",
                 "--trailer", "Nucleic-Recovery: 1",
                 "--trailer", Self.coauthorTrailer],
                in: trunkPath)
            nvrsionLog.notice("nvrsion recovered dirty trunk path=\(trunkPath, privacy: .public)")
        }
    }

    // MARK: Re-ground on grant (NVRSION §3.2)

    /// The nvrsion update-on-grant: did any of `files` move in the trunk since `session` last had
    /// them? `.clean` on first acquaintance (matching LOCKING §4.5: re-ground only fires when a file
    /// the session *already had* advanced), else `.changed` so the host tells the agent to re-read.
    public func regroundOnGrant(
        trunkPath: String, session: SessionID, files: [String]
    ) async -> LockUpdateResult {
        let specs = files.filter { !$0.isEmpty }
        guard !specs.isEmpty else { return .clean }
        return await withGate {
            var moved: [String] = []
            for f in specs {
                let cur = await blobSha(trunkPath: trunkPath, path: f)
                if let last = observed[session]?[f], last != cur { moved.append(f) }
                observed[session, default: [:]][f] = cur  // now the session has seen the current content
            }
            if moved.isEmpty { return .clean }
            nvrsionLog.notice("nvrsion reground session=\(session.rawValue, privacy: .public) files=\(moved.joined(separator: ","), privacy: .public)")
            return .changed(files: moved, diff: "")  // empty diff → the host's notice says "re-read"
        }
    }

    /// Forget a finished session's observed-sha state (called on release-all / lifecycle).
    public func forget(_ session: SessionID) {
        observed[session] = nil
    }

    // MARK: Promotion checkout (NVRSION §6) — make promotion independent of the primary checkout

    /// Where a promotion's git runs and how it finalizes onto `base`. A promotion has to land a commit
    /// on `base`, which needs a worktree holding `base`'s content. Historically that was *always* the
    /// project **root** checkout — which silently required the user to have left the root parked on
    /// `base`. If the root sat on a feature branch (or the trunk, or a detached HEAD), the squash /
    /// `checkout … --` would build the promotion on the wrong branch, so promotion just failed.
    /// `PromoteCheckout` decouples the two: use the root only when it's *already* on `base` (unchanged
    /// behavior — the commit advances `base` itself), otherwise run in a dedicated, Nucleic-managed
    /// worktree **detached at `base`** and advance the real `base` ref ourselves. So where the user
    /// parks the primary checkout no longer matters, and a detached worktree never *holds* the `base`
    /// branch — the user stays free to `git checkout <base>` in the primary.
    private struct PromoteCheckout {
        /// Directory to run the promotion's git in.
        let dir: String
        /// True when `dir` is the dedicated worktree (detached at `base`): the commit lands on a
        /// detached HEAD, so `base` is advanced explicitly via `advanceBase`. False when `dir` is the
        /// root already on `base`, where committing moves `base` on its own.
        let detached: Bool
        /// `base`'s tip when the checkout was resolved — the compare-and-swap old value for the
        /// detached `update-ref` (guards against `base` moving under us). Unused on the root path.
        let baseBefore: String
    }

    /// Outcome of resolving a promotion checkout: a ready `PromoteCheckout`, or a reason `base`
    /// couldn't be prepared (surfaced as the promote's `.failed`).
    private enum PromoteCheckoutResolution {
        case ready(PromoteCheckout)
        case unavailable(String)
    }

    /// The dedicated promote worktree path: `<repo>/.nucleic/promote`, a sibling of the trunk.
    private func promoteWorktreePath(trunkPath: String) -> String {
        ((trunkPath as NSString).deletingLastPathComponent as NSString)
            .appendingPathComponent("promote")
    }

    // MARK: Running change summary (NVRSION §6)

    /// Sidecar file holding the trunk's running change summary: `<repo>/.nucleic/trunk-summary`, a
    /// plain-text sibling of the trunk/promote worktrees (so it persists across restarts and is
    /// never itself committed into the trunk). One per project — there is one trunk per project.
    private func summaryFilePath(trunkPath: String) -> String {
        ((trunkPath as NSString).deletingLastPathComponent as NSString)
            .appendingPathComponent("trunk-summary")
    }

    /// The trunk's running summary of changes accumulated since its last promotion (NVRSION §6),
    /// or "" when none has been recorded yet. Read at promote time for the commit message, and
    /// before each fold so the next increment lays over the current text. A plain file read — no
    /// git, no index gate.
    public func changeSummary(trunkPath: String) -> String {
        let text = (try? String(contentsOfFile: summaryFilePath(trunkPath: trunkPath), encoding: .utf8)) ?? ""
        return text.trimmingCharacters(in: .whitespacesAndNewlines)
    }

    /// Persist the trunk's running change summary, replacing any prior text; an empty/blank
    /// `summary` clears it (deletes the sidecar). Folds are serialized per project by the caller
    /// (`AppStore`), so this needn't take the index gate — it touches only the sidecar file.
    public func recordChangeSummary(trunkPath: String, _ summary: String) {
        let path = summaryFilePath(trunkPath: trunkPath)
        let trimmed = summary.trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed.isEmpty {
            try? FileManager.default.removeItem(atPath: path)
        } else {
            try? trimmed.write(toFile: path, atomically: true, encoding: .utf8)
        }
    }

    /// Resolve the `PromoteCheckout` for `root`/`base`. Root-on-`base` ⇒ promote in the root (legacy
    /// path, unchanged). Otherwise ensure `<repo>/.nucleic/promote` is a clean worktree detached at
    /// `base`'s tip and use that — so promotion never depends on, nor disturbs, the primary checkout.
    /// `.failure` if `base` is missing or the worktree can't be prepared. Call inside `withGate`.
    private func resolvePromotionCheckout(
        root: String, trunkPath: String, base: String
    ) async -> PromoteCheckoutResolution {
        let baseTip = (try? await git.run(["rev-parse", "--verify", "--quiet", "refs/heads/\(base)"], in: root))?
            .stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        guard !baseTip.isEmpty else { return .unavailable("base branch \(base) does not exist") }

        let onBranch = (try? await git.run(["rev-parse", "--abbrev-ref", "HEAD"], in: root))?
            .stdout.trimmingCharacters(in: .whitespacesAndNewlines)
        if onBranch == base {
            return .ready(PromoteCheckout(dir: root, detached: false, baseBefore: baseTip))
        }

        // Root is parked off `base`, so `base` is checked out nowhere — we can stand up a private
        // worktree on it. Detached, so it never holds the `base` branch and advancing `base` (below)
        // can't desync any working tree.
        let promotePath = promoteWorktreePath(trunkPath: trunkPath)
        let dotGit = (promotePath as NSString).appendingPathComponent(".git")
        if FileManager.default.fileExists(atPath: dotGit) {
            // Reuse: force it back to a clean checkout detached at the current base tip, dropping any
            // residue an aborted prior promote may have left.
            let det = try? await git.run(
                ["checkout", "--force", "--detach", "refs/heads/\(base)"], in: promotePath)
            guard det?.ok == true else {
                return .unavailable("could not ground promote worktree at \(base): \(det?.stderr ?? "git did not run")")
            }
            _ = try? await git.run(["clean", "-ffd"], in: promotePath)
        } else {
            let parent = (promotePath as NSString).deletingLastPathComponent
            try? FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
            _ = try? await git.run(["worktree", "prune"], in: root)
            let add = try? await git.run(
                ["worktree", "add", "--detach", promotePath, "refs/heads/\(base)"], in: root)
            guard add?.ok == true else {
                return .unavailable("could not create promote worktree at \(promotePath): \(add?.stderr ?? "git did not run")")
            }
        }
        nvrsionLog.notice("nvrsion promote via detached worktree path=\(promotePath, privacy: .public) base=\(base, privacy: .public)")
        return .ready(PromoteCheckout(dir: promotePath, detached: true, baseBefore: baseTip))
    }

    /// After a promotion commit on the detached promote worktree, fast-forward the real `base` ref to
    /// it (compare-and-swap on the tip we grounded at, so a concurrent `base` move is caught rather
    /// than clobbered). No-op on the root path — committing there already moved `base`. Returns an
    /// error string on failure, else `nil`.
    private func advanceBase(_ checkout: PromoteCheckout, base: String, to head: String) async -> String? {
        guard checkout.detached else { return nil }
        let upd = try? await git.run(
            ["update-ref", "refs/heads/\(base)", head, checkout.baseBefore], in: checkout.dir)
        guard upd?.ok == true else {
            return "could not advance \(base) to \(head): \(upd?.stderr ?? "update-ref did not run")"
        }
        return nil
    }

    // MARK: Promotion — trunk → base (NVRSION §6)

    /// Squash the trunk's accumulated work into the real `base` branch as one clean commit. Promotion
    /// runs wherever `base` can be committed onto **without depending on where the user parked the
    /// primary checkout** (`PromoteCheckout`): in the root when it's already on `base`, else in a
    /// private worktree detached at `base`. Then best-effort merge `base` back into the trunk so the
    /// next promotion is incremental. Conflicts (e.g. `base` edited outside the trunk) leave both
    /// branches untouched and are reported.
    public func promote(
        root: String, trunkPath: String, trunkBranch: String, base: String, message: String
    ) async -> NvrPromoteResult {
        await withGate {
            // The squash merges trunk into a checkout of `base`. Resolve one that doesn't depend on
            // where the primary checkout is parked (root if it's on `base`, else a detached worktree).
            let checkout: PromoteCheckout
            switch await resolvePromotionCheckout(root: root, trunkPath: trunkPath, base: base) {
            case .ready(let c): checkout = c
            case .unavailable(let why): return .failed(why)
            }
            let dir = checkout.dir
            // Nothing to do when the trunk's content already equals base.
            if let diff = try? await git.run(["diff", "--quiet", base, trunkBranch], in: dir), diff.status == 0 {
                return .nothingToPromote
            }
            let merge = try? await git.run(["merge", "--squash", trunkBranch], in: dir)
            guard let merge else { return .failed("git did not run") }
            if !merge.ok {
                // Capture the conflicted paths, then reset (── --squash sets no MERGE_HEAD, so only a
                // hard reset is the true inverse, mirroring WorktreeManager.integrate).
                let conflicts = (try? await git.run(
                    ["diff", "--name-only", "--diff-filter=U"], in: dir))?
                    .stdout.split(separator: "\n").map(String.init) ?? []
                _ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
                nvrsionLog.error("nvrsion promote CONFLICT base=\(base, privacy: .public) files=\(conflicts.joined(separator: ","), privacy: .public)")
                return .conflicted(conflicts.isEmpty ? ["<unknown>"] : conflicts)
            }
            // --squash stages without committing; commit unless the squash was a no-op.
            if let staged = try? await git.run(["diff", "--cached", "--quiet"], in: dir), staged.status == 0 {
                return .nothingToPromote
            }
            // Sign the promotion with the configured Managed Git key when "Sign commits with this
            // SSH key" is on; otherwise keep `commit.gpgsign=false` so the user's ambient signing
            // config can't break/slow this commit (the prior always-off behavior). NVRSION promotions
            // become real base-branch history, so they're in scope for signing (per-edit trunk
            // snapshots aren't — those stay unsigned for speed).
            let (signFlags, signEnv, signCleanup) = GitHubCredentialProvisioner.commitSigning()
            defer { signCleanup() }
            let gpgArgs = signFlags.isEmpty ? ["-c", "commit.gpgsign=false"] : signFlags
            let commit = try? await git.run(
                gpgArgs + ["commit", "-m", message,
                 "--trailer", "Nucleic-Promote: 1",
                 "--trailer", Self.coauthorTrailer],
                in: dir, env: signEnv)
            guard let commit, commit.ok else {
                _ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
                return .failed(commit?.stderr ?? "commit failed")
            }
            let head = (try? await git.run(["rev-parse", "HEAD"], in: dir))?
                .stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            // On the detached-worktree path the commit landed on a detached HEAD — advance the real
            // base ref to it (the root path already moved base by committing on it).
            if let err = await advanceBase(checkout, base: base, to: head) {
                _ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
                return .failed(err)
            }
            // Resync the trunk onto the new base so the next promotion squashes only NEW work
            // (advances the merge-base). Transactional: a conflicting resync would leave MERGE_HEAD
            // + markers in the trunk, and then every later per-edit `land` — a path-scoped (partial)
            // commit — fails with "cannot do a partial commit during a merge", silently wedging the
            // trunk (lands keep failing, locks never release). So on any non-clean result, abort back
            // to the trunk's own HEAD. No data is lost: the trunk keeps all its commits and base is
            // untouched, the merge state is fully derived from {trunk HEAD, base}, and the only cost
            // is the merge-base not advancing this round — the next promote re-squashes (idempotent,
            // content-based), shipping nothing for already-shipped files. The clean-merge path is
            // unchanged, so base→trunk propagation still happens whenever the resync merges cleanly.
            let resync = try? await git.run(["merge", "--no-edit", base], in: trunkPath)
            if resync?.ok != true {
                // `merge --abort` restores the working tree + index to pre-merge HEAD (no commits
                // touched); a no-op "no merge to abort" when the merge was merely refused (dirty tree).
                _ = try? await git.run(["merge", "--abort"], in: trunkPath)
                nvrsionLog.notice("nvrsion promote resync skipped (trunk left clean at HEAD) base=\(base, privacy: .public)")
            }
            // The trunk's accumulated work just shipped, so its running change summary is consumed —
            // clear it so the next promotion's message describes only the work landed after this one.
            recordChangeSummary(trunkPath: trunkPath, "")
            nvrsionLog.notice("nvrsion promote base=\(base, privacy: .public) sha=\(head, privacy: .public)")
            return .promoted(sha: head)
        }
    }

    // MARK: Per-session promotion — ship one chat's work (NVRSION §6)

    /// Promote a **single session's** landed work from the trunk into the real `base` branch, so a
    /// finished chat can ship without waiting for the rest of the project's chats to fall quiet
    /// (NVRSION §6 per-session promotion; the deferred autoship-on-completion, now implemented).
    ///
    /// Runs in a checkout of `base` chosen so it never depends on where the primary checkout is parked
    /// (`PromoteCheckout` — the root if it's on `base`, else a private detached worktree) plus the
    /// trunk *branch ref* — it **never touches the trunk working copy**, so every other session's
    /// locks, warm files, and re-ground state are left completely intact (the property the user relies on: siblings stay
    /// aware of each other's work, NVRSION §4, §7). The model is nvrsion-native: because the trunk
    /// is always conflict-free composed content (serialized per-file writes + re-ground, NVRSION
    /// §0.3), promoting a chat = lifting the **current trunk content of the files that chat is the
    /// latest author of** onto base — a direct content overwrite, never a 3-way replay, so it can't
    /// produce a merge conflict and is **idempotent** (a second promote finds the files already in
    /// base and ships nothing).
    ///
    /// - "Files the chat is latest author of": of the files where trunk differs from base, the ones
    ///   whose most-recent trunk commit carries this session's trailer. A file a *later* sibling
    ///   re-touched isn't this chat's to ship (that sibling will ship it); a file composed of this
    ///   chat's edit *over* an earlier sibling's already-landed edit ships whole — safe, because the
    ///   lock invariant means every earlier edit to it was already complete before this chat took it.
    /// - The one refusal is `.conflicted`: a file whose `base` side was edited **outside** the trunk
    ///   since the fork (a hand commit on the real branch). Overwriting it would clobber that edit, so
    ///   — exactly like the whole-trunk `promote`'s conflict guard — we decline and report it.
    public func promoteSession(
        root: String, trunkPath: String, trunkBranch: String, base: String,
        session: SessionID, message: String
    ) async -> NvrPromoteResult {
        await withGate {
            // We commit a chat's content onto `base`. Resolve a checkout for that which doesn't depend
            // on where the primary checkout is parked (root if it's on `base`, else a detached worktree).
            let checkout: PromoteCheckout
            switch await resolvePromotionCheckout(root: root, trunkPath: trunkPath, base: base) {
            case .ready(let c): checkout = c
            case .unavailable(let why): return .failed(why)
            }
            let dir = checkout.dir
            // No trunk branch yet (no session has landed) ⇒ nothing this chat could have shipped.
            let exists = (try? await git.run(
                ["rev-parse", "--verify", "--quiet", "\(trunkBranch)^{commit}"], in: dir))?.ok == true
            guard exists else { return .nothingToPromote }

            // The unshipped files: where the trunk's tree differs from base. (`--no-renames` keeps a
            // rename as delete+add so per-file attribution stays simple.) None ⇒ trunk already shipped.
            let diff = try? await git.run(
                ["diff", "--name-only", "--no-renames", base, trunkBranch], in: dir)
            guard let diff else { return .failed("git did not run") }
            let unshipped = diff.stdout.split(separator: "\n").map(String.init).filter { !$0.isEmpty }
            guard !unshipped.isEmpty else { return .nothingToPromote }

            // Of those, the files this chat is the *latest* trunk author of — its files to ship.
            var mine: [String] = []
            for f in unshipped {
                let owner = (try? await git.run(
                    ["log", "-1", "--format=%(trailers:key=Nucleic-Session,valueonly,separator=%x2C)",
                     trunkBranch, "--", f], in: dir))?
                    .stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
                let owners = owner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
                if owners.contains(session.rawValue) { mine.append(f) }
            }
            guard !mine.isEmpty else { return .nothingToPromote }

            // Base-divergence guard: if any of this chat's files was edited on `base` *outside* the
            // trunk since the fork, a content overwrite would clobber that edit — decline and report
            // it, exactly like the whole-trunk promote's conflict path. Common case (root untouched
            // by agents, NVRSION §6): no divergence, so this is empty and we ship cleanly.
            let mergeBase = (try? await git.run(["merge-base", base, trunkBranch], in: dir))?
                .stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? base
            var conflicted: [String] = []
            for f in mine {
                if let d = try? await git.run(["diff", "--quiet", mergeBase, base, "--", f], in: dir),
                   d.status != 0 { conflicted.append(f) }
            }
            if !conflicted.isEmpty {
                nvrsionLog.error("nvrsion promoteSession CONFLICT session=\(session.rawValue, privacy: .public) files=\(conflicted.joined(separator: ","), privacy: .public)")
                return .conflicted(conflicted.sorted())
            }

            // Stage each of this chat's files at the trunk's current content: a file present in the
            // trunk tree is checked out (add/modify); one the chat deleted (absent in trunk) is removed.
            var present: [String] = [], absent: [String] = []
            for f in mine {
                let inTrunk = (try? await git.run(["cat-file", "-e", "\(trunkBranch):\(f)"], in: dir))?.ok == true
                if inTrunk { present.append(f) } else { absent.append(f) }
            }
            if !present.isEmpty {
                let co = try? await git.run(["checkout", trunkBranch, "--"] + present, in: dir)
                if co?.ok != true {
                    _ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
                    return .failed(co?.stderr ?? "could not stage trunk content")
                }
            }
            for f in absent { _ = try? await git.run(["rm", "-q", "--ignore-unmatch", "--", f], in: dir) }

            // Nothing actually staged (base already matched the trunk for this chat's files) ⇒ no-op.
            if let staged = try? await git.run(["diff", "--cached", "--quiet"], in: dir), staged.status == 0 {
                _ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
                return .nothingToPromote
            }
            // Sign the per-session promotion with the Managed Git key when signing is enabled (it
            // lands on the real base branch); otherwise keep `commit.gpgsign=false`. See `promote`.
            let (signFlags, signEnv, signCleanup) = GitHubCredentialProvisioner.commitSigning()
            defer { signCleanup() }
            let gpgArgs = signFlags.isEmpty ? ["-c", "commit.gpgsign=false"] : signFlags
            let commit = try? await git.run(
                gpgArgs + ["commit", "-m", message,
                 "--trailer", "Nucleic-Promote: 1", "--trailer", "Nucleic-Session: \(session.rawValue)",
                 "--trailer", Self.coauthorTrailer],
                in: dir, env: signEnv)
            guard let commit, commit.ok else {
                _ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
                return .failed(commit?.stderr ?? "commit failed")
            }
            let head = (try? await git.run(["rev-parse", "HEAD"], in: dir))?
                .stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            // On the detached-worktree path the commit landed on a detached HEAD — advance the real
            // base ref to it (the root path already moved base by committing on it).
            if let err = await advanceBase(checkout, base: base, to: head) {
                _ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
                return .failed(err)
            }
            // No trunk resync needed: idempotence comes from the content comparison above (a re-promote
            // finds these files already in base), and the trunk is never touched — so this can't
            // disturb any sibling session's in-flight work.
            nvrsionLog.notice("nvrsion promoteSession session=\(session.rawValue, privacy: .public) base=\(base, privacy: .public) files=\(mine.joined(separator: ","), privacy: .public) sha=\(head, privacy: .public)")
            return .promoted(sha: head)
        }
    }

    // MARK: Integration status — does the trunk hold work to promote? (NVRSION §6)

    /// Whether the trunk carries work not yet in `base` — i.e. the explicit Integrate action would
    /// have something to promote. Mirrors `promote`'s fast-path gate (`git diff --quiet base trunk`,
    /// run in `root`): a non-zero exit means the trees differ, so there's work to integrate. Drives
    /// the sidebar's Integrate affordance — an up-arrow while there's work, a checkmark once the
    /// trunk is back in sync. Read-only (a ref-to-ref diff that never touches the index), so it
    /// needn't take the gate and can't stall a land. Best-effort: if the trunk branch doesn't exist
    /// yet — or git won't run — it reports `false` (nothing landed ⇒ nothing to integrate).
    public func hasPendingWork(root: String, trunkBranch: String, base: String) async -> Bool {
        let exists = (try? await git.run(
            ["rev-parse", "--verify", "--quiet", "\(trunkBranch)^{commit}"], in: root))?.ok == true
        guard exists else { return false }
        guard let diff = try? await git.run(["diff", "--quiet", base, trunkBranch], in: root)
        else { return false }
        return diff.status != 0  // non-zero ⇒ trunk differs from base ⇒ there's work to integrate
    }

    // MARK: Per-session change count (NVRSION §6)

    /// The diffstat of a **single chat's** unshipped trunk work — the files this `session` is the
    /// latest author of (the identical selection `promoteSession` ships), measured `base`→trunk.
    /// Drives the sidebar's change-count badge for nvrsion sessions, which have no worktree to
    /// `WorktreeManaging.status` against (they share the trunk, NVRSION §2). Runs entirely against
    /// refs in the project `root` checkout — never the trunk working copy or index — so it's
    /// read-only and needn't take the gate; it can't stall a land. Returns `.empty` when the trunk
    /// branch doesn't exist yet, nothing is unshipped, or this chat owns none of the unshipped files.
    public func sessionDiffStat(
        root: String, trunkBranch: String, base: String, session: SessionID
    ) async -> DiffStat {
        // No trunk branch yet (nothing has landed) ⇒ this chat has nothing to show.
        let exists = (try? await git.run(
            ["rev-parse", "--verify", "--quiet", "\(trunkBranch)^{commit}"], in: root))?.ok == true
        guard exists else { return .empty }

        // The unshipped files: where the trunk's tree differs from base. (`--no-renames` keeps a
        // rename as delete+add so per-file attribution stays simple, mirroring `promoteSession`.)
        let names = try? await git.run(
            ["diff", "--name-only", "--no-renames", base, trunkBranch], in: root)
        let unshipped = (names?.stdout.split(separator: "\n").map(String.init) ?? [])
            .filter { !$0.isEmpty }
        guard !unshipped.isEmpty else { return .empty }

        // Of those, the files this chat is the *latest* trunk author of — its files to ship. The same
        // attribution query `promoteSession` uses, so the badge previews exactly what would ship.
        var mine: [String] = []
        for f in unshipped {
            let owner = (try? await git.run(
                ["log", "-1", "--format=%(trailers:key=Nucleic-Session,valueonly,separator=%x2C)",
                 trunkBranch, "--", f], in: root))?
                .stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            let owners = owner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
            if owners.contains(session.rawValue) { mine.append(f) }
        }
        guard !mine.isEmpty else { return .empty }

        // Added/removed line counts for exactly this chat's files, base→trunk (one numstat scoped to
        // `mine`). Each line is "added\tremoved\tpath"; a "-" count (binary file) reads as 0.
        let numstat = (try? await git.run(
            ["diff", "--numstat", "--no-renames", base, trunkBranch, "--"] + mine, in: root))?
            .stdout ?? ""
        var added = 0, removed = 0
        for line in numstat.split(separator: "\n") {
            let cols = line.split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false)
            guard cols.count == 3 else { continue }
            added += Int(cols[0]) ?? 0
            removed += Int(cols[1]) ?? 0
        }
        return DiffStat(filesChanged: mine.count, added: added, removed: removed)
    }

    // MARK: Pre-land hook (NVRSION §5)

    /// How long a pre-land hook may run before it's killed and the edit rejected.
    public static let prelandTimeoutSeconds: TimeInterval = 10

    private enum HookOutcome: Sendable { case exited(Int32); case spawnFailed; case timedOut }

    /// Run `command` via `/bin/sh -c` on the host in the trunk dir, with the edited paths in
    /// `NUCLEIC_NVR_PATHS`. Returns whether it passed (exit 0 within the timeout) and its combined
    /// output (stderr+stdout) to feed back. A timeout or spawn failure counts as a failure.
    private func runPrelandHook(
        _ command: String, trunkPath: String, paths: [String]
    ) async -> (passed: Bool, output: String) {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/bin/sh")
        process.arguments = ["-c", command]
        process.currentDirectoryURL = URL(fileURLWithPath: trunkPath)
        var env = ProcessInfo.processInfo.environment
        env["NUCLEIC_NVR_PATHS"] = paths.joined(separator: " ")
        // Pin any git (every remote) the hook runs to the Managed Git key (never the user's
        // ssh-agent/personal keys). GIT_SSH_COMMAND governs only git, so a standalone `ssh` is
        // unaffected. No-op when unset. Shredded after the hook exits (the func awaits it).
        let (gitEnv, gitCleanup) = GitHubCredentialProvisioner.provisionHost()
        defer { gitCleanup() }
        for (k, v) in gitEnv where k != "GH_CONFIG_DIR" { env[k] = v }
        process.environment = env
        let outPipe = Pipe(), errPipe = Pipe()
        process.standardOutput = outPipe
        process.standardError = errPipe
        process.standardInput = FileHandle.nullDevice

        async let outData = Self.readToEnd(outPipe.fileHandleForReading)
        async let errData = Self.readToEnd(errPipe.fileHandleForReading)

        // Wait for exit (handler set *before* run, GitRunner-style, so a fast exit isn't missed).
        let exitWait = Task { () -> HookOutcome in
            await withCheckedContinuation { (c: CheckedContinuation<HookOutcome, Never>) in
                process.terminationHandler = { c.resume(returning: .exited($0.terminationStatus)) }
                do { try process.run() } catch {
                    process.terminationHandler = nil
                    c.resume(returning: .spawnFailed)
                }
            }
        }
        let outcome = await withTaskGroup(of: HookOutcome.self) { group -> HookOutcome in
            group.addTask { await exitWait.value }
            group.addTask {
                try? await Task.sleep(for: .seconds(Self.prelandTimeoutSeconds)); return .timedOut
            }
            let first = await group.next() ?? .timedOut
            group.cancelAll()
            return first
        }
        let out = (String(decoding: await errData, as: UTF8.self)
            + String(decoding: await outData, as: UTF8.self))
            .trimmingCharacters(in: .whitespacesAndNewlines)
        switch outcome {
        case .timedOut:
            process.terminate()
            return (false, "pre-land hook timed out after \(Int(Self.prelandTimeoutSeconds))s"
                + (out.isEmpty ? "" : "\n\(out)"))
        case .spawnFailed:
            return (false, "could not start pre-land hook")
        case .exited(let status):
            return (status == 0, out)
        }
    }

    private static func readToEnd(_ handle: FileHandle) async -> Data {
        await withCheckedContinuation { c in
            DispatchQueue.global().async { c.resume(returning: (try? handle.readToEnd()) ?? Data()) }
        }
    }

    // MARK: Helpers

    private func recordObserved(session: SessionID, trunkPath: String, paths: [String]) async {
        for p in paths { observed[session, default: [:]][p] = await blobSha(trunkPath: trunkPath, path: p) }
    }

    /// The trunk's current blob sha for `path` (`""` if absent — deleted/untracked).
    private func blobSha(trunkPath: String, path: String) async -> String {
        let res = try? await git.run(["rev-parse", "--verify", "--quiet", "HEAD:\(path)"], in: trunkPath)
        guard let res, res.ok else { return "" }
        return res.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
    }
}
