import Foundation
import libgit2
import os

// MARK: - Outcome

/// **How a branch operation ended** — the four answers 06-history-undo.md gives every app-initiated
/// git operation, in the shape `GitCommitOutcome` already gives the committer's.
///
/// The kinship is deliberate: contention is never an error, a paused repository is a hold rather than
/// a failure, and everything else is a clean failure carrying libgit2's own message ("An operation
/// that fails *cleanly* — disk error, refused checkout … surfaces as a one-shot banner failure naming
/// the operation and the error, the tree left as it was").
public enum GitBranchOutcome: Sendable, Equatable {

    /// HEAD now names this branch and the working tree is its state.
    case switched(String)

    /// `index.lock` was held. The payload is the lock file's path — what an implausibly long wait
    /// names (06 ▸ Interaction with external writers: "a wait that persists implausibly long names
    /// the lock path").
    case locked(path: String)

    /// The repository is in a state the app does not write in (`GitRepositoryPause`). Branch controls
    /// disable in that state, so this is the race — a terminal started a merge between the popover
    /// rendering and the click landing.
    case held(GitRepositoryPause)

    /// A clean failure: a refused checkout, an unwritable object store, a name that is not a branch.
    /// **The tree is untouched** — libgit2's safe checkout either applies wholly or refuses.
    case failed(GitOperationFailure)
}

// MARK: - GitBranchOperation

/// **Branch switching and create-and-switch, over the bundled libgit2** (06-history-undo.md ▸ Branch
/// switching) — the repository half of the operation, with nothing in it that knows about editors,
/// banners, or the undo stack.
///
/// ### The checkout is `SAFE`, and that is the whole safety story
///
/// `git_checkout_tree` with `GIT_CHECKOUT_SAFE` "allows safe updates that cannot overwrite
/// uncommitted data": a working tree carrying changes that conflict with the target refuses the
/// checkout wholesale (`GIT_ECONFLICT`) and leaves every byte where it was. Nothing here ever passes
/// `GIT_CHECKOUT_FORCE` — not on the switch, not on the create-and-switch, and not on the
/// own-leftovers abort, which is the one path that could plausibly want it. That is what makes "a
/// refused checkout is a clean one-shot failure, tree untouched" a property of the call rather than a
/// promise, and it is checkable by grepping this file for `FORCE`.
///
/// The caller's contract is the other half: the switch runs on a settled tree — open Edit sessions
/// settled explicitly, the pending auto-commit flushed — so in practice `SAFE` has nothing to refuse
/// ("checkout runs on a truly settled tree: it cannot fail dirty").
///
/// ### Isolation
///
/// `GitCommitOperation`'s rule, unchanged and for its reason: every function is `nonisolated`, opens
/// its own `git_repository`, and frees it in the same synchronous scope. No handle crosses an
/// `await`, a `Task`, or a stored property.
enum GitBranchOperation {

    /// What a failure calls itself on the banner — in the user's words, not libgit2's.
    static let operationName = "Switching branches"

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")

    /// libgit2's global state — `GitCommitOperation.startUp`'s twin, and for its reason.
    private static let startUp: Bool = {
        git_libgit2_init() >= 0
    }()

    // MARK: - Reads

    /// **Every local branch**, sorted the way a menu should list them.
    ///
    /// Local only: remote-tracking branches are 07-sync-collab.md's, and a picker that offered
    /// `origin/main` would be offering a detached HEAD — precisely the state 06 pauses the whole git
    /// surface for.
    ///
    /// An unborn HEAD answers with an empty list, which is honest: `git init` has created no branch
    /// yet, only a symbolic ref naming the one the first commit will make.
    nonisolated static func localBranches(at boardRoot: URL) -> [String] {
        _ = startUp
        guard let repository = open(boardRoot) else { return [] }
        defer { git_repository_free(repository) }

        var iterator: OpaquePointer?
        guard git_branch_iterator_new(&iterator, repository, GIT_BRANCH_LOCAL) == 0, let iterator else {
            return []
        }
        defer { git_branch_iterator_free(iterator) }

        var names: [String] = []
        var reference: OpaquePointer?
        var kind = GIT_BRANCH_LOCAL
        while git_branch_next(&reference, &kind, iterator) == 0 {
            defer {
                reference.map(git_reference_free)
                reference = nil
            }
            var name: UnsafePointer<CChar>?
            guard git_branch_name(&name, reference) == 0, let name else { continue }
            names.append(String(cString: name))
        }
        return names.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
    }

    /// Whether libgit2 would accept `name` as a branch name — `git check-ref-format --branch`'s
    /// answer, asked before anything is created so the failure names the input rather than a ref.
    nonisolated static func isValidBranchName(_ name: String) -> Bool {
        _ = startUp
        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return false }
        var valid: Int32 = 0
        guard git_branch_name_is_valid(&valid, trimmed) == 0 else { return false }
        return valid == 1
    }

    /// Whether a local branch by this name already exists — the create path's own refusal, phrased
    /// against the name the user typed instead of against libgit2's `GIT_EEXISTS`.
    nonisolated static func branchExists(_ name: String, at boardRoot: URL) -> Bool {
        localBranches(at: boardRoot).contains(name)
    }

    /// `.git/index.lock`'s path, for the waiting state that names it.
    nonisolated static func indexLockPath(at boardRoot: URL) -> String {
        _ = startUp
        guard let repository = open(boardRoot) else {
            return boardRoot.appendingPathComponent(".git/index.lock").path
        }
        defer { git_repository_free(repository) }
        return gitDirectory(of: repository).appendingPathComponent("index.lock").path
    }

    // MARK: - The switch

    /// **The checkout itself** (06 ▸ Branch switching): materialize the branch's tree with the safe
    /// strategy, then move HEAD's symbolic ref onto it.
    ///
    /// The order is libgit2's own recommended one and it matters: the checkout's baseline is the
    /// *current* HEAD, so the tree is updated against what is actually checked out, and HEAD moves
    /// only once the bytes are there. An interruption between the two leaves a tree that matches the
    /// target under a HEAD that does not — which is exactly the leftover `GitOperationStamp` exists to
    /// recognize as the app's own.
    ///
    /// - Parameter allowingPause: whether to proceed against a repository in a pause state. `false`
    ///   everywhere except the own-leftovers abort, which is 06's one exemption from "the app never
    ///   mutates repo state it didn't create" — see `abort(_:at:)`.
    nonisolated static func checkout(
        _ branch: String,
        at boardRoot: URL,
        allowingPause: Bool = false
    ) -> GitBranchOutcome {
        _ = startUp

        // The state check runs immediately before the write, never from a caller's earlier read: 06's
        // rule is that it runs "again before every flush", and a terminal can start a merge between a
        // popover rendering and a click landing.
        let reading = GitCommitOperation.reading(at: boardRoot)
        if let pause = reading.pause, !allowingPause { return .held(pause) }
        if reading.isIndexLocked { return .locked(path: indexLockPath(at: boardRoot)) }

        guard let repository = open(boardRoot) else {
            return .failed(GitOperationFailure(
                operation: operationName,
                message: "this board's repository could not be opened"
            ))
        }
        defer { git_repository_free(repository) }

        let fullName = "refs/heads/" + branch
        var reference: OpaquePointer?
        guard git_reference_lookup(&reference, repository, fullName) == 0, let reference else {
            return .failed(GitOperationFailure(
                operation: operationName,
                message: "there is no local branch named '\(branch)'"
            ))
        }
        defer { git_reference_free(reference) }

        var target: OpaquePointer?
        guard git_reference_peel(&target, reference, GIT_OBJECT_COMMIT) == 0, let target else {
            return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
        }
        defer { git_object_free(target) }

        var options = git_checkout_options()
        guard git_checkout_options_init(&options, UInt32(GIT_CHECKOUT_OPTIONS_VERSION)) == 0 else {
            return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
        }
        // **`SAFE`, never `FORCE`** — see the type's note. The value is libgit2's zero, spelled out
        // rather than left implicit so the strategy is visible at the point it is chosen.
        options.checkout_strategy = GIT_CHECKOUT_SAFE.rawValue

        let checked = git_checkout_tree(repository, target, &options)
        guard checked == 0 else { return classify(checked, at: boardRoot) }

        guard git_repository_set_head(repository, fullName) == 0 else {
            return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
        }
        logger.notice("checked out branch \(branch, privacy: .public)")
        return .switched(branch)
    }

    /// **Create-and-switch** (06 ▸ Branch switching, and 03-board-ui.md ▸ Board popover: "branch
    /// switching and creation"): a new branch at the current HEAD, then the ordinary switch onto it.
    ///
    /// **The checkout is not skipped**, though the new branch's tree is HEAD's by construction and the
    /// working tree therefore cannot change. The reason is a race the app shares its repository with
    /// by design (06 ▸ "Two writers, one repository"): an agent's self-commit landing between the
    /// branch's creation and the switch moves HEAD, and a `set_head` with no checkout would then leave
    /// the working tree describing a commit the new branch does not point at. Running the same
    /// checkout every switch runs costs one no-op index write in the ordinary case and is correct in
    /// the racing one.
    ///
    /// **An unborn HEAD creates nothing and only moves the symbolic ref** — which is exactly what
    /// `git checkout -b` does on a repository with no commits: there is no commit to branch from, and
    /// the name HEAD points at is the branch the first commit will make (06 ▸ Rules ▸ Abnormal repo
    /// states: "an unborn HEAD … is normal git mode").
    nonisolated static func createAndSwitch(_ branch: String, at boardRoot: URL) -> GitBranchOutcome {
        _ = startUp
        let name = branch.trimmingCharacters(in: .whitespacesAndNewlines)

        guard isValidBranchName(name) else {
            return .failed(GitOperationFailure(
                operation: operationName,
                message: "'\(branch)' is not a valid branch name"
            ))
        }
        guard !branchExists(name, at: boardRoot) else {
            return .failed(GitOperationFailure(
                operation: operationName,
                message: "a branch named '\(name)' already exists"
            ))
        }

        let reading = GitCommitOperation.reading(at: boardRoot)
        if let pause = reading.pause { return .held(pause) }
        if reading.isIndexLocked { return .locked(path: indexLockPath(at: boardRoot)) }

        guard let repository = open(boardRoot) else {
            return .failed(GitOperationFailure(
                operation: operationName,
                message: "this board's repository could not be opened"
            ))
        }

        if reading.isUnborn {
            defer { git_repository_free(repository) }
            guard git_repository_set_head(repository, "refs/heads/" + name) == 0 else {
                return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
            }
            return .switched(name)
        }

        var created: OpaquePointer?
        let outcome: GitBranchOutcome? = {
            defer { git_repository_free(repository) }
            guard let head = headCommit(of: repository) else {
                return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
            }
            defer { git_commit_free(head) }
            guard git_branch_create(&created, repository, name, head, 0) == 0 else {
                return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
            }
            created.map(git_reference_free)
            return nil
        }()
        if let outcome { return outcome }

        return checkout(name, at: boardRoot)
    }

    // MARK: - The app's own leftovers

    /// **Aborts an interrupted app-run switch** (06 ▸ Rules ▸ Abnormal repo states: "The one exemption
    /// is the app's own leftovers … finding a pause state with a matching stamp, the app **aborts its
    /// own unfinished operation** to restore the pre-operation state").
    ///
    /// The abort *is* a checkout back to the branch the stamp recorded — the interrupted operation ran
    /// forwards, so undoing it is running the same operation backwards. It carries `allowingPause`
    /// because the leftover it is clearing is precisely a state that would otherwise refuse; that
    /// exemption is the stamp's whole purpose, and it is why nothing else in the app passes the flag.
    ///
    /// **Still `SAFE`, still never `FORCE`.** An abort that overwrote uncommitted work to tidy up
    /// would be the app losing the user's bytes on its own initiative — and "abort discards nothing"
    /// is the design's own promise about it. A refused abort therefore stays refused and says so.
    ///
    /// It deliberately does **not** call `git_repository_state_cleanup`: a branch switch never creates
    /// `MERGE_HEAD` or a rebase directory, so a leftover of *that* shape is not this operation's even
    /// when a stamp is standing, and removing it would be the never-mutate rule broken in the one
    /// place the exemption does not reach. (Recorded as a judgment call; the rebase that can leave one
    /// is 07-sync-collab.md's pull, whose own abort will own it.)
    nonisolated static func abort(_ stamp: GitOperationStamp, at boardRoot: URL) -> GitBranchOutcome {
        guard !stamp.fromBranch.isEmpty else {
            return .failed(GitOperationFailure(
                operation: operationName,
                message: "the interrupted operation recorded no branch to return to"
            ))
        }
        return checkout(stamp.fromBranch, at: boardRoot, allowingPause: true)
    }

    // MARK: - Private plumbing

    private static func open(_ boardRoot: URL) -> OpaquePointer? {
        guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
        var repository: OpaquePointer?
        guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
        return repository
    }

    private static func gitDirectory(of repository: OpaquePointer) -> URL {
        URL(fileURLWithPath: string(git_repository_path(repository)) ?? "", isDirectory: true)
    }

    private static func headCommit(of repository: OpaquePointer) -> OpaquePointer? {
        var reference: OpaquePointer?
        guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
        defer { git_reference_free(reference) }
        var object: OpaquePointer?
        guard git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0 else { return nil }
        return object
    }

    private static func string(_ pointer: UnsafePointer<CChar>?) -> String? {
        pointer.map { String(cString: $0) }
    }

    private static func lastErrorMessage() -> String {
        guard let error = git_error_last(), let message = error.pointee.message else {
            return "libgit2 reported no reason"
        }
        return String(cString: message)
    }

    /// Turns a libgit2 status into the outcome 06 gives it — contention apart from failure, exactly as
    /// `GitCommitOperation.classify` does for a commit.
    private static func classify(_ status: Int32, at boardRoot: URL) -> GitBranchOutcome {
        if status == GIT_ELOCKED.rawValue { return .locked(path: indexLockPath(at: boardRoot)) }
        return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
    }
}
