import Foundation
import os

// MARK: - GitBranchSwitcher

/// **The branch switch, in the order 06-history-undo.md ▸ Branch switching fixes it** — settle the
/// editors, flush the pending commit, stamp the intent, check out, reseed undo — with every step that
/// needs a window, a store, or a banner arriving as a seam.
///
/// ### Why the sequence is an object rather than a method
///
/// Because five of its six steps belong to somebody else. Settling editors is the card windows'
/// (`SessionSettleGate`), flushing is the committer's, bracketing is the store's, reseeding is the
/// undo provider's, and the in-progress row is the banner strip's — and 06 fixes the *order* they run
/// in, which is the one thing none of them can hold. `GitHistoryProvider` is the same shape for the
/// same reason, and its seams are wired from the same place (`AppModel.wireGitUndo`).
///
/// A `nil` seam is always the honest degenerate case rather than a disabled feature: a board with no
/// card windows has nothing to settle, a repository-level test has no store to bracket with, and a
/// board whose popover is closed has no spinner to update. The sequence runs the same way through all
/// of them.
///
/// ### What it deliberately does not do
///
/// Nothing remote. Tracking, ahead/behind, Pull, Push and push-on-commit follow the current branch
/// (06 ▸ Branch switching) and are 07-sync-collab.md's own card; this object moves HEAD and tells the
/// undo stack, and the remote half will join by reading the same `didSwitch` seam.
@MainActor
@Observable
public final class GitBranchSwitcher {

    /// The board this switches branches on — in git mode, the repository's working-tree root.
    public let boardRoot: URL

    // MARK: - Seams

    /// **The save-or-discard step, over every open session** (06 ▸ Branch switching: "if any open card
    /// window has one … the switch presents a save-or-discard step").
    ///
    /// Unlike the undo restore's, this gate is **not** narrowed by a diff. A restore materializes only
    /// the paths it changes, so a session the diff never touches is genuinely unaffected; a branch
    /// switch moves the whole tree out from under every session at once, and the raw-source hazard 06
    /// names — "its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new
    /// branch's card" — does not care whether the checkout touched that card at all. So the seam takes
    /// no paths, and `SessionSettleGate.settleAll()` is what production passes.
    @ObservationIgnored
    public var settleSessions: (@MainActor () async -> SessionSettleOutcome)?

    /// The pending auto-commit, flushed once the sessions are settled — "with sessions settled, the
    /// pending auto-commit flushes (flush-before-overwrite) and checkout runs on a truly settled tree:
    /// it cannot fail dirty".
    @ObservationIgnored
    public var flushPendingCommit: (@MainActor () async -> Void)?

    /// Stops and restarts the auto-commit debounce around the checkout, so a timer cannot fire
    /// mid-materialization. `GitHistoryProvider`'s pair, for its reason.
    @ObservationIgnored
    public var suspendCommitting: (@MainActor () -> Void)?

    @ObservationIgnored
    public var resumeCommitting: (@MainActor () -> Void)?

    /// **The undo/redo reseed** (06 ▸ Branch switching: "The undo/redo stack does not survive a
    /// switch. It is discarded and reseeded from the new HEAD's first-parent ancestry … redo starts
    /// empty") — `GitHistoryProvider.reseed`, which is already exactly that.
    @ObservationIgnored
    public var reseedUndo: (@MainActor () async -> Void)?

    /// The store's wholesale bracket: watcher suspended, one full reload at the end, the board locked
    /// read-only if that reload fails (02-architecture.md; `BoardStore.performWholesale(announcing:awaiting:)`).
    @ObservationIgnored
    public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?

    /// The in-progress banner row: begin, relabel (the lock's waiting state), end.
    ///
    /// Three seams rather than one object because the banner is the *store's*, and this type is
    /// composed on boards that have none. Relabelling is its own call because a held lock must change
    /// what the row says without replacing the row: "contention outlasting the brief retry surfaces as
    /// a *waiting* state in the operation's in-progress banner row" — the same operation, still
    /// running, now explaining itself.
    @ObservationIgnored
    public var beginProgress: (@MainActor (String) -> UUID)?

    @ObservationIgnored
    public var updateProgress: (@MainActor (UUID, String) -> Void)?

    @ObservationIgnored
    public var endProgress: (@MainActor (UUID) -> Void)?

    /// A clean failure — "surfaces as a one-shot banner failure naming the operation and the error,
    /// the tree left as it was" (06 ▸ Interaction with external writers).
    ///
    /// The banner rather than an inline caption, deliberately, and 06 draws the line: the
    /// form-anchored answer is for operations that answer *at the form* (add-git, verify-remote —
    /// forms that live in the board settings sheet since the 2026-07-31 popover/sheet split),
    /// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any
    /// one surface" — which a branch switch is by construction, since its bracket locks the board and
    /// its completion is announced.
    ///
    /// **Which row that is, settled 2026-07-31** (02-architecture.md ▸ The banner surface): the
    /// one-shot failure class's message-carrying git shape — error tone, failure rank, dismissable
    /// and untimed. What travels is the operation and the underlying message; the sentence
    /// ("Couldn't switch branches — …") is `BannerCenter`'s, which is why nothing here composes one.
    @ObservationIgnored
    public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?

    /// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`).
    ///
    /// **A warning-tone loss row, not a failure** (02 ▸ The banner surface, settled 2026-07-31):
    /// "recovery notices report a success, not a failure, and stay warning-tone" — the abort put the
    /// previous state back, and the row exists so the user learns that it happened.
    @ObservationIgnored
    public var reportRecovery: (@MainActor (String) -> Void)?

    /// The per-board registry's stamp — read at open, written before the repository is touched, and
    /// cleared when the operation is over (`GitOperationStamp`).
    @ObservationIgnored
    public var readStamp: (@MainActor () -> GitOperationStamp?)?

    @ObservationIgnored
    public var writeStamp: (@MainActor (GitOperationStamp?) -> Void)?

    /// Whether the git surface is held (`GitAutoCommitter.pause != nil`). The controls disable on it,
    /// and this is the pre-flight that keeps a click that raced the render from presenting a modal
    /// step for an operation the repository is about to refuse.
    @ObservationIgnored
    public var isHeld: (@MainActor () -> Bool)?

    /// HEAD moved — what refreshes the popover's branch line (`HistoryStore.refreshBranch`). Called
    /// after a successful switch and after a successful abort, and by nothing else.
    @ObservationIgnored
    public var didSwitch: (@MainActor () async -> Void)?

    // MARK: - Observable state

    /// Every local branch, as of the last refresh — the picker's contents.
    public private(set) var branches: [String] = []

    /// Whether a switch is in flight: the controls' disabled state, and the guard that keeps a second
    /// click from starting a second checkout.
    public private(set) var isSwitching = false

    /// The last clean failure, or `nil`. Held beside the banner it is also posted to, so the popover
    /// can show what happened while it was open without the banner having to be its only witness.
    public private(set) var lastFailure: GitOperationFailure?

    /// Folders whose card session the settle step's **Discard** branch just abandoned — reverted to
    /// HEAD before anything else happens (see `perform`). Filled through `noteDiscarded(cardFolderName:)`,
    /// which is how `AppModel`'s gate reports each one.
    @ObservationIgnored
    private var discardedFolders: Set<String> = []

    /// **A settle step discarded this card's session.** "Discard reverts buffers and uncommitted saves
    /// to HEAD" — the window reverted the buffer, and this is the switch remembering to revert the
    /// saves.
    public func noteDiscarded(cardFolderName: String) {
        discardedFolders.insert(cardFolderName)
    }

    // MARK: - Tunables

    /// The brief, silent backoff: "pull, push, branch switch, and undo restore meeting a held lock
    /// wait and retry briefly, silently" (06 ▸ Interaction with external writers). The committer's own
    /// numbers, for the committer's reason.
    @ObservationIgnored
    public var lockRetryDelay: Duration = .milliseconds(120)

    @ObservationIgnored
    public var lockRetryAttempts = 3

    /// The cadence the waiting state retries on, once the brief backoff is spent.
    @ObservationIgnored
    public var lockWaitInterval: Duration = .seconds(1)

    /// How long a wait runs before the row names the lock path — "a wait that persists implausibly
    /// long names the lock path (a crashed writer's leftover is the user's to clear)".
    @ObservationIgnored
    public var lockPathNamingDelay: Duration = .seconds(5)

    /// **The bound on the wait, recorded as a judgment call.** 06 describes a waiting state that
    /// retries on its cadence and never becomes an error dialog; it does not say when — or whether —
    /// it gives up. An unbounded wait would hold the board's wholesale bracket, and with it the
    /// read-only lock, for as long as a crashed writer's `index.lock` sits on disk, with no way out
    /// but quitting. So the wait ends, generously, at a clean failure that names the lock path — the
    /// tree untouched, the branch unchanged, the banner explaining exactly what to clear. Never a
    /// dialog, never a hammer, and never a board wedged by another process's litter.
    @ObservationIgnored
    public var lockWaitLimit: Duration = .seconds(30)

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

    public init(boardRoot: URL) {
        self.boardRoot = boardRoot
    }

    // MARK: - Phrases

    /// The in-progress row while the checkout runs (02-architecture.md ▸ The banner surface:
    /// "Switching to 'main'…").
    public static func progressLabel(target: String) -> String {
        "Switching to '\(target)'…"
    }

    /// The bracket's completion announcement (10-accessibility.md ▸ Live board announcements:
    /// "bracketed operations announce once, at completion").
    public static func completionAnnouncement(target: String) -> String {
        "Switched to branch '\(target)'"
    }

    /// The waiting state, and the same sentence once the wait is long enough to name what is holding
    /// the lock.
    public static let waitingLabel = "Waiting for another writer's git lock"

    public static func waitingLabel(path: String) -> String {
        "\(waitingLabel) (\(path))"
    }

    // MARK: - Reads

    /// Reloads the branch list — what the popover's `.task` calls when it appears, and what every
    /// completed operation calls for itself.
    public func refreshBranches() async {
        let root = boardRoot
        branches = await Task.detached(priority: .userInitiated) {
            GitBranchOperation.localBranches(at: root)
        }.value
    }

    // MARK: - The two operations

    /// **Switches to an existing local branch.** Answers whether HEAD actually moved.
    @discardableResult
    public func switchTo(_ branch: String) async -> Bool {
        await perform(target: branch, creating: false)
    }

    /// **Creates a branch at the current HEAD and switches to it.**
    ///
    /// The full sequence runs — settle step included — and that is a judgment call, recorded. The card
    /// this was built for allows skipping the settle "only if you can prove the tree cannot change",
    /// and the proof does not hold: the new branch is created at whatever HEAD is *at that moment*,
    /// and this app shares its repository with self-committing agents by design (06 ▸ "Two writers,
    /// one repository"), so a commit landing between the flush and the create leaves a working tree
    /// that the new branch does not describe. Two smaller reasons point the same way — the flush puts
    /// pending work on the branch it was made on rather than on the branch that did not exist when it
    /// was made, and one sequence is one thing to reason about. The step costs nothing when nothing is
    /// dirty: the gate never appears unless a session is actually holding unsaved state.
    @discardableResult
    public func createAndSwitch(to branch: String) async -> Bool {
        await perform(target: branch.trimmingCharacters(in: .whitespacesAndNewlines), creating: true)
    }

    private func perform(target: String, creating: Bool) async -> Bool {
        guard !isSwitching, !target.isEmpty else { return false }
        // A held repository disables the controls; this is the click that raced the render.
        guard isHeld?() != true else { return false }

        isSwitching = true
        defer { isSwitching = false }
        lastFailure = nil
        discardedFolders = []

        // **a. Settle the editors first — explicitly, never silently** (06 ▸ Branch switching). Before
        // the bracket, because the step is modal and a modal inside a suspended watcher would hold the
        // board read-only for as long as the user took to read it.
        if let settleSessions {
            switch await settleSessions() {
            case .cancelled, .failed:
                // "Cancel keeps the current branch and the sessions", and a raw buffer that will not
                // validate "cancels the whole switch with focus on the offending window, nothing
                // half-switched".
                discardedFolders = []
                return false
            case .proceed:
                break
            }
        }

        let root = boardRoot

        // **a′. Discard's second half**: the windows reverted their buffers, and the *uncommitted
        // saves* those sessions left on disk go back to HEAD here — before the flush, which would
        // otherwise commit them the instant the ended session stopped being staged around
        // (`GitRestoreOperation.revertToHead`).
        let discarded = discardedFolders
        discardedFolders = []
        if !discarded.isEmpty {
            let reverted = await Task.detached(priority: .userInitiated) {
                GitRestoreOperation.revertToHead(folderNames: discarded, at: root)
            }.value
            guard reverted else {
                fail(GitOperationFailure(
                    operation: GitBranchOperation.operationName,
                    message: "this board's repository could not be read"
                ))
                return false
            }
        }

        // **b. Flush the pending auto-commit** — the tree is settled from here on.
        await flushPendingCommit?()

        // **c. Stamp the intent, before the repository is touched** (06 ▸ Rules ▸ Abnormal repo
        // states). Everything above this line is app-side; everything below can be interrupted.
        let head = await Task.detached(priority: .userInitiated) {
            GitHistoryWalk.headOID(at: root)
        }.value
        let current = await Task.detached(priority: .userInitiated) {
            GitRepository.branchName(at: root)
        }.value
        writeStamp?(GitOperationStamp(
            fromBranch: current ?? "",
            toBranch: target,
            headOID: head
        ))

        // **d. The checkout, bracketed** — watcher suspended, one full reload at the end, the board
        // locked read-only if that reload fails.
        let progress = beginProgress?(Self.progressLabel(target: target))
        var landed = false
        let work: @MainActor () async -> Void = { [weak self] in
            guard let self else { return }
            self.suspendCommitting?()
            defer { self.resumeCommitting?() }

            switch await self.runWaitingOutLocks(target: target, creating: creating, progress: progress) {
            case .switched:
                landed = true
                // **e. Reseed undo/redo from the new HEAD**, inside the bracket: the stack must never
                // be readable in a state where it describes the branch that is no longer checked out.
                await self.reseedUndo?()
            case let .failed(failure):
                self.fail(failure)
            case let .held(pause):
                self.fail(GitOperationFailure(
                    operation: GitBranchOperation.operationName,
                    message: pause.explanation
                ))
            case let .locked(path):
                self.fail(GitOperationFailure(
                    operation: GitBranchOperation.operationName,
                    message: "another program is still using this repository's index (\(path))"
                ))
            }
        }

        if let runBracketed {
            await runBracketed(Self.completionAnnouncement(target: target), work)
        } else {
            await work()
        }

        // The operation is over, whichever way it went: a clean failure left the tree exactly as it
        // was, so there is nothing for a later open to abort.
        writeStamp?(nil)
        if let progress { endProgress?(progress) }
        await didSwitch?()
        await refreshBranches()
        return landed
    }

    /// The checkout, with 06's lock posture around it: brief silent retries, then a waiting state in
    /// the operation's own row, then — at `lockWaitLimit` — a clean failure naming the lock path.
    private func runWaitingOutLocks(
        target: String,
        creating: Bool,
        progress: UUID?
    ) async -> GitBranchOutcome {
        let root = boardRoot
        let startedWaiting = ContinuousClock.now
        var attempt = 0
        var announced = false
        var named = false

        while true {
            let outcome = await Task.detached(priority: .userInitiated) {
                creating
                    ? GitBranchOperation.createAndSwitch(target, at: root)
                    : GitBranchOperation.checkout(target, at: root)
            }.value

            guard case let .locked(path) = outcome else { return outcome }

            attempt += 1
            if attempt <= max(0, lockRetryAttempts) {
                // Brief and silent: "a held lock is another writer doing its job".
                try? await Task.sleep(for: lockRetryDelay)
                continue
            }

            let waited = ContinuousClock.now - startedWaiting
            guard waited < lockWaitLimit else {
                return .locked(path: path)
            }
            if !announced, let progress {
                updateProgress?(progress, Self.waitingLabel)
                announced = true
            }
            if !named, waited >= lockPathNamingDelay, let progress {
                updateProgress?(progress, Self.waitingLabel(path: path))
                named = true
            }
            try? await Task.sleep(for: lockWaitInterval)
        }
    }

    // MARK: - The app's own leftovers

    /// **Recovers an interrupted app-run switch, at board open** (06 ▸ Rules ▸ Abnormal repo states).
    ///
    /// Called once per session, beside the committer's start — which is where the pause it is looking
    /// for is first knowable, and before any of it reaches a user. Three outcomes, all of
    /// `GitOperationRecovery`'s: nothing to do, a stale stamp dropped silently, or the app's own
    /// leftover aborted with a banner.
    ///
    /// **A failed abort keeps the stamp**, which is this file's second judgment call. 06 says the app
    /// "aborts its own unfinished operation … then clears the stamp"; that sentence describes the
    /// abort that worked. An abort refused by a conflicting working tree has restored nothing, and
    /// clearing the stamp would demote the leftover to somebody else's on the next open — the app
    /// would then defer forever to an operation only it ever started. So the stamp stands, the failure
    /// is surfaced, and the next open tries again.
    public func recoverInterruptedOperation() async {
        guard let stamp = readStamp?() else { return }
        let root = boardRoot
        let pause = await Task.detached(priority: .userInitiated) {
            GitCommitOperation.reading(at: root).pause
        }.value

        switch GitOperationRecovery.decide(stamp: stamp, pause: pause) {
        case .nothingToDo:
            return

        case .clearStamp:
            writeStamp?(nil)

        case let .abort(stamp):
            Self.logger.notice("aborting this app's own interrupted branch switch")
            var restored = false
            let work: @MainActor () async -> Void = { [weak self] in
                guard let self else { return }
                self.suspendCommitting?()
                defer { self.resumeCommitting?() }
                let outcome = await Task.detached(priority: .userInitiated) {
                    GitBranchOperation.abort(stamp, at: root)
                }.value
                switch outcome {
                case .switched:
                    restored = true
                    await self.reseedUndo?()
                case let .failed(failure):
                    self.fail(failure)
                case let .held(pause):
                    self.fail(GitOperationFailure(
                        operation: GitBranchOperation.operationName,
                        message: pause.explanation
                    ))
                case let .locked(path):
                    self.fail(GitOperationFailure(
                        operation: GitBranchOperation.operationName,
                        message: "another program is using this repository's index (\(path))"
                    ))
                }
            }
            if let runBracketed {
                await runBracketed(GitOperationStamp.interruptionMessage, work)
            } else {
                await work()
            }
            guard restored else { return }
            writeStamp?(nil)
            reportRecovery?(GitOperationStamp.interruptionMessage)
            await didSwitch?()
        }

        await refreshBranches()
    }

    // MARK: - Failure

    private func fail(_ failure: GitOperationFailure) {
        lastFailure = failure
        Self.logger.error("branch operation failed: \(failure.description, privacy: .public)")
        reportFailure?(failure)
    }
}
