import Foundation
import os

// MARK: - GitHistoryProvider

/// **Pro's undo substrate: the commit trail itself** (06-history-undo.md; 12-editions.md ▸ The
/// provider seam) — the second implementation of `HistoryProviding`, and the one the seam was
/// designed around.
///
/// ### The stack is not a stack
///
/// "The stack **is** HEAD's first-parent ancestry, live" (06 ▸ Rules). Nothing here records a step
/// when the board is written to; `register(_:)` is a deliberate no-op, because on a git board an undo
/// step is a *commit* and commits are made by the auto-committer, by an agent, or by a terminal. What
/// this object holds is a **pointer into that ancestry** — which commit ⌘Z would cross next — plus a
/// redo list of the commits already crossed in this session. Both are caches over a repository that
/// remains the only truth, which is what makes "no sidecar state, nothing ever lost" (14 ▸ C8) a
/// property of the shape rather than a discipline.
///
/// ### Four rules, and where each one lives
///
/// - **Forward only.** A crossing writes an older state as a *new commit* — `GitRestoreOperation`,
///   which cannot reset because it never resolves a reset symbol. Old commits stay reachable; refs
///   only move forward.
/// - **Exactly one commit per ⌘Z.** The pre-flight sync (`syncToHEAD`) re-reads HEAD before every
///   crossing, so agents' self-commits landed since the last operation become the new top and ⌘Z
///   steps back over *them* rather than silently reverting twenty minutes of their work.
/// - **Any arrival clears redo.** From the pre-flight sync for commits made outside the app, and from
///   `noteLanded(_:)` for the ones this app's committer made — with the one exception the heal rule
///   requires (below).
/// - **In-session and post-relaunch are one rule.** `reseed()` is the same ancestry walk from
///   scratch, so a relaunch, a branch switch and a foreign arrival all take the same path.
///
/// ### Heal transparency, and its honest limit
///
/// Heal-class commits never become steps: the pointer passes over them, and a restore excludes the
/// paths whose divergence is heal work — so a ⌘Z run never reverts a repair and never re-arms the
/// healer (06 ▸ Rules ▸ Heal commits are transparent to undo, in-session). Both halves are learned
/// from `GitAutoCommitter.reportLanded`, which fires while the Writer's heal-marked receipts still
/// exist. **In-session is the whole of it, deliberately**: the reseed is sidecar-free, so after a
/// relaunch old heal commits reappear as ordinary steps — the accepted one-bounce residual, named in
/// 06 and not worked around here.
///
/// ### Asynchrony
///
/// `HistoryProviding.undo()` is synchronous because a menu item is; a restore is a settle step, a
/// libgit2 diff, a set of writes and a commit. So the protocol methods start a `Task` and return, and
/// `cross(_:)` is the awaitable one a test drives. Enablement never waits on any of it: `canUndo`,
/// `canRedo` and both action names answer from the cached ancestry, so menu validation costs nothing.
@MainActor
@Observable
public final class GitHistoryProvider: HistoryProviding {

    // MARK: - Identity

    /// The board this is the history of — in git mode, the repository's working-tree root.
    public let boardRoot: URL

    // MARK: - Seams

    /// **The pending auto-commit, flushed before a restore commits** (06 ▸ Rules ▸ Flush-before-
    /// overwrite, applied here by the card's own rule: settled tree first, then one more commit).
    ///
    /// Without it a ⌘Z would commit an older state on top of edits that never got a commit of their
    /// own — the forward trail would be missing the very version the undo is stepping back from.
    @ObservationIgnored
    public var flushPendingCommit: (@MainActor () async -> Void)?

    /// Whether the git surface is **held** — a detached HEAD or an in-progress merge/rebase
    /// (06 ▸ Rules ▸ Abnormal repo states: "Undo/Redo and the branch controls disable"). Reads
    /// `GitAutoCommitter.pause`, which is in-memory state, so enablement stays free.
    @ObservationIgnored
    public var isHeld: (@MainActor () -> Bool)?

    /// Stops and restarts the auto-commit debounce around a restore, so its own writes cannot be
    /// half-committed by a timer that fires mid-materialization.
    @ObservationIgnored
    public var suspendCommitting: (@MainActor () -> Void)?

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

    /// **The save-or-discard step** (06 ▸ Rules ▸ Undo restore vs open Edit sessions). `nil` is a
    /// board with no card windows to settle — every storeless test, and a session composed before any
    /// window opened.
    @ObservationIgnored
    public var settleSessions: (@MainActor (Set<String>) async -> SessionSettleOutcome)?

    /// Runs the restore inside the store's wholesale bracket — watcher suspended, one full reload at
    /// the end, the board locked if that reload fails (02-architecture.md; `BoardStore.performWholesale`).
    /// `nil` runs the work bare, which is what a repository-level test wants.
    @ObservationIgnored
    public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?

    /// A genuine restore failure — surfaced as 02's one-shot banner by whoever wires it.
    ///
    /// **The direction travels with the failure** (02-architecture.md ▸ The banner surface, settled
    /// 2026-07-31): the one-shot failure class's second shape names the operation in the user's
    /// words — "Undo failed", "Redo failed" — and this object is the only one that knows which key
    /// was pressed. Everything past that boundary is the banner's: the closure receives the
    /// direction and libgit2's own message, never a sentence composed here.
    @ObservationIgnored
    public var reportFailure: (@MainActor (HistoryDirection, GitOperationFailure) -> Void)?

    // MARK: - The cached stack

    /// HEAD's first-parent ancestry as of the last sync, newest first. The *stack*, cached.
    public private(set) var ancestry: [GitCommitRecord] = []

    /// The oid of the commit ⌘Z would cross next, or `nil` before the first seed. Not always
    /// `ancestry.first`: after an undo the pointer sits below the restore commit the undo just made,
    /// which is the whole mechanism behind "the undo-menu labels are the *crossed* commit's subject,
    /// so labels never nest" (06 ▸ Commit messages).
    public private(set) var pointerOID: String?

    /// Commits crossed by ⌘Z in this session, oldest crossed first — ⇧⌘Z restores the state *at* the
    /// last of them. Empty on every seed: "redo starts empty" (06 ▸ Rules ▸ Undo survives relaunch).
    public private(set) var redoCommits: [GitCommitRecord] = []

    /// The HEAD this cache was built against — the pre-flight sync's comparison.
    private var knownHead: String?

    /// Heal-class commits landed **in this session**, which the pointer passes over.
    private var healOIDs: Set<String> = []

    /// Paths committed as heal work in this session, which a restore never materializes.
    private var healPaths: Set<String> = []

    /// Whether a crossing is in flight — a second ⌘Z during a restore must not start a second one.
    public private(set) var isCrossing = false

    /// How many restores this provider has landed — the trail's own testimony, so a test need not
    /// infer a crossing from a commit walk.
    public private(set) var restoreCount = 0

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

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

    // MARK: - Seeding

    /// **Reseeds the stack from HEAD's first-parent ancestry, with an empty redo** — the API a board
    /// open, a relaunch, and a **branch switch** all enter through (06 ▸ Rules ▸ Undo survives
    /// relaunch; ▸ 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").
    ///
    /// Synchronous and off the main actor is not an option — the walk is libgit2 — so this is the
    /// awaitable seed and `seed()` is the fire-and-forget one an open path can call.
    public func reseed() async {
        let root = boardRoot
        let records = await Task.detached(priority: .userInitiated) {
            GitHistoryWalk.ancestry(at: root)
        }.value
        adopt(records)
    }

    /// `reseed()` without waiting — what a board open and an add-git flip call.
    public func seed() {
        Task { await reseed() }
    }

    /// The reseed's main-actor half, split out so the sync path can reuse it.
    private func adopt(_ records: [GitCommitRecord]) {
        ancestry = records
        knownHead = records.first?.oid
        pointerOID = records.first?.oid
        redoCommits = []
    }

    /// **The pre-flight sync** (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry, live):
    /// "The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the
    /// app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly
    /// **one** commit."
    ///
    /// One reference read when nothing moved, a full reseed when something did. A reseed here is the
    /// same reseed a relaunch does, which is the point: "In-session and post-relaunch behavior are
    /// thereby one rule."
    private func syncToHEAD() async {
        let root = boardRoot
        let head = await Task.detached(priority: .userInitiated) {
            GitHistoryWalk.headOID(at: root)
        }.value
        guard head != knownHead else { return }
        Self.logger.debug("undo stack re-syncing: HEAD moved outside the stack's knowledge")
        await reseed()
    }

    // MARK: - What the committer tells it

    /// **A flush landed** — `GitAutoCommitter.reportLanded`.
    ///
    /// Two behaviours, and the split between them is the heal rule:
    ///
    /// - A window of **nothing but heal commits** leaves the pointer and the redo list exactly where
    ///   they were, and only records what was healed. That is what keeps an undo run from being
    ///   trapped on an ever-renewing top: "the fresh heal commit is in-session, transparent, and the
    ///   undo run continues past it" (06).
    /// - **Anything else is an arrival**, and "any commit arriving from anywhere clears the redo
    ///   stack (classic behavior)" — with the new commit becoming the top of the stack, so the next
    ///   ⌘Z crosses what just happened.
    public func noteLanded(_ window: GitLandedWindow) {
        healOIDs.formUnion(window.healOIDs)
        healPaths.formUnion(window.healPaths)
        guard !window.commits.isEmpty else { return }

        // The walk is libgit2 work and the committer reports from a synchronous outcome handler, so
        // the cache catches up on its own turn. `settled()` is how anything that must not race it
        // waits — `cross(_:)` first of all.
        refresh = Task { [weak self] in
            guard let self else { return }
            if window.isEntirelyHeal {
                // The ancestry gained a commit the pointer must be able to walk past; the pointer and
                // the redo list are untouched.
                await self.refreshAncestryKeepingPointer()
            } else {
                await self.reseed()
            }
        }
    }

    /// The cache catch-up started by the last `noteLanded(_:)`, if it is still running.
    @ObservationIgnored
    private var refresh: Task<Void, Never>?

    /// **Waits for the cache to have heard about the last commit** — so "⌘Z now crosses what just
    /// landed" is a fact to await rather than a race.
    ///
    /// Every crossing awaits it, which is the production caller; a test awaits it to assert on
    /// enablement the instant a flush returns, where a menu would simply be validated a turn later.
    public func settled() async {
        await refresh?.value
        refresh = nil
    }

    /// Re-reads the ancestry without disturbing the pointer or the redo list — the heal window's
    /// path, and the one every successful restore takes.
    private func refreshAncestryKeepingPointer() async {
        let root = boardRoot
        let records = await Task.detached(priority: .userInitiated) {
            GitHistoryWalk.ancestry(at: root)
        }.value
        ancestry = records
        knownHead = records.first?.oid
        if let pointerOID, !records.contains(where: { $0.oid == pointerOID }) {
            // The pointer's commit is no longer in HEAD's first-parent ancestry — a rebase remapped
            // it (07-sync-collab.md's pull). The honest answer is the seed's: start again from the
            // top, redo empty.
            adopt(records)
        }
    }

    // MARK: - HistoryProviding

    /// **Deliberately nothing — except the one thing a dropped step is owed.** On a git board an undo
    /// step is a commit, and the Writer boundary's inverse operations are the *free* tier's substrate
    /// (13-native-undo.md). `BoardStore` registers against whatever provider the session bound, and
    /// this one has a repository to read instead — so the registrations arrive and are dropped, which
    /// is exactly what "the commit trail itself is the substrate" (14 ▸ C1) means in code.
    ///
    /// Dropping a step means **retiring** it (`HistoryStep.Retirement`), and that is what keeps the
    /// tier split in 13's purge rule structural rather than conditional: "on Pro the substrate is
    /// history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge
    /// rides the close flush there as before" (13 ▸ Interaction with the trash). A card window's close
    /// step registered here is retired on arrival, so its deferred `comments/.trash/` purge runs
    /// immediately — at the close flush, exactly where it ran before this milestone — with no call
    /// site anywhere asking which substrate it is talking to.
    public func register(_ step: HistoryStep) {
        step.retirement?.run()
    }

    public var canUndo: Bool {
        guard !isCrossing, isHeld?() != true else { return false }
        return crossableIndex() != nil
    }

    public var canRedo: Bool {
        guard !isCrossing, isHeld?() != true else { return false }
        return !redoCommits.isEmpty
    }

    /// **The crossed commit's own subject** (06 ▸ Commit messages: "the undo-menu labels are the
    /// *crossed* commit's subject, so labels never nest") — so the Edit menu reads "Undo Move card
    /// 'Fix login' to Doing", never "Undo Undo: …" for a restore this session made.
    public var undoActionName: String? {
        guard canUndo, let index = crossableIndex() else { return nil }
        return ancestry[index].subject
    }

    public var redoActionName: String? {
        guard canRedo else { return nil }
        return redoCommits.last?.subject
    }

    public func undo() {
        Task { await cross(.undo) }
    }

    public func redo() {
        Task { await cross(.redo) }
    }

    /// Drops the cache. The session's teardown, and nothing else — the *repository* is untouched, so
    /// a board reopened a second later has exactly the same trail.
    public func clear() {
        ancestry = []
        pointerOID = nil
        redoCommits = []
        knownHead = nil
        healOIDs = []
        healPaths = []
    }

    // MARK: - The crossing

    /// One ⌘Z or ⇧⌘Z, awaitable — the whole restore, in the order the rules fix it.
    public func cross(_ direction: HistoryDirection) async {
        guard !isCrossing, isHeld?() != true else { return }
        isCrossing = true
        defer { isCrossing = false }

        // **The settled tree first** (06 ▸ Rules ▸ Flush-before-overwrite, and this card's own rule):
        // whatever the debounce is still holding becomes a commit of its own before a restore lands
        // on top of it, so both states exist in the trail.
        await flushPendingCommit?()
        await settled()
        await syncToHEAD()

        switch direction {
        case .undo:
            guard let index = crossableIndex() else { return }
            let crossed = ancestry[index]
            guard let target = crossed.parentOID else { return }
            let landed = await restore(
                .undo,
                to: target,
                message: Self.restoreSubject(.undo, crossing: crossed.subject)
            )
            guard landed else { return }
            redoCommits.append(crossed)
            pointerOID = target
            await refreshAncestryKeepingPointer()

        case .redo:
            guard let target = redoCommits.last else { return }
            let landed = await restore(
                .redo,
                to: target.oid,
                message: Self.restoreSubject(.redo, crossing: target.subject)
            )
            guard landed else { return }
            redoCommits.removeLast()
            // The commit just restored *to* is the one the next ⌘Z crosses again — the classic dance,
            // with the pointer where the undo found it.
            pointerOID = target.oid
            await refreshAncestryKeepingPointer()
        }
    }

    /// Materializes one target state as a new commit. Answers whether the crossing may advance.
    ///
    /// `message` is both the commit's subject and the bracket's completion announcement
    /// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
    /// completion") — one sentence, so the trail and the speech cannot disagree about what happened.
    ///
    /// `direction` is carried for one reason: a failure here is the banner's git-operation shape,
    /// and it is named by the key the user pressed rather than by the subject the restore would have
    /// carried (`reportFailure`).
    private func restore(_ direction: HistoryDirection, to target: String, message: String) async -> Bool {
        let root = boardRoot
        let excluded = healPaths

        // The **preliminary** plan: what the restore would write, which is the only thing that can
        // say whether any open session is in its way.
        guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
            GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
        }).value else {
            report(direction, "this board's repository could not be read")
            return false
        }

        var reconciling: Set<String> = []
        if !preliminary.isEmpty, let settleSessions {
            switch await settleSessions(Set(preliminary.paths)) {
            case .cancelled, .failed:
                // "Cancel keeps everything" — and a raw buffer that would not validate cancels the
                // whole restore, focused on the offender (06 ▸ Branch switching).
                return false
            case .proceed:
                reconciling = discardedFolders
                discardedFolders = []
            }
            if reconciling.isEmpty {
                // **Save All ended sessions, which commits them**: the tree moved, so the plan is
                // recomputed below against the HEAD that now exists rather than the one it was
                // drafted against.
                //
                // **Discard deliberately does not flush.** Ending a session un-stages-around its
                // folder, so a flush here would commit exactly the uncommitted saves the user just
                // asked to lose — a Discard that wrote them into history forever. Nothing is left
                // behind by skipping it: everything else pending was already flushed at the top of
                // the crossing, and the discarded folder is reconciled against the working tree by
                // the plan itself.
                await flushPendingCommit?()
                await settled()
            }
        }

        // Bound before the closure that crosses actors reads it — the settle step is over, and what
        // it decided is a value from here on.
        let folders = reconciling
        var landed = 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, operation: {
                guard let plan = GitRestoreOperation.plan(
                    at: root,
                    target: target,
                    excluding: excluded,
                    reconciling: folders
                ) else {
                    return GitCommitOutcome.failed(GitOperationFailure(
                        operation: GitRestoreOperation.operationName,
                        message: "this board's repository could not be read"
                    ))
                }
                return GitRestoreOperation.apply(plan, at: root, message: message)
            }).value

            switch outcome {
            case .committed:
                self.restoreCount += 1
                landed = true
            case .nothingToCommit:
                // The step was crossed and needed no bytes — every path its diff would have written
                // was heal work, or the two states are byte-identical. The pointer still advances:
                // a step that changed nothing is still a step the user asked to walk past.
                landed = true
            case .locked:
                // "Contention outlasting the brief retry surfaces as a *waiting* state" (06); the
                // in-progress banner row is the branch card's surface. Here the honest answer is to
                // leave the stack where it is so ⌘Z can simply be pressed again.
                Self.logger.debug("restore found index.lock held — the stack is unchanged")
            case let .held(pause):
                Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
            case let .failed(failure):
                self.reportFailure?(direction, failure)
            }
        }

        if let runBracketed {
            await runBracketed(message, work)
        } else {
            await work()
        }
        return landed
    }

    /// Folders the settle step's Discard branch left for the plan to reconcile against the working
    /// tree. Filled by the gate's wiring through `noteDiscarded(_:)`.
    private var discardedFolders: Set<String> = []

    /// **A settle step discarded this card's session** — its folder is compared against the working
    /// tree rather than against HEAD, so the uncommitted saves the user just chose to lose are
    /// reverted by the restore itself rather than by a second pass that could disagree with it
    /// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
    public func noteDiscarded(cardFolderName: String) {
        discardedFolders.insert(cardFolderName)
    }

    // MARK: - The pointer

    /// The index in `ancestry` of the commit ⌘Z would cross, or `nil` when there is none.
    ///
    /// Two commits are never steps:
    ///
    /// - **Heal commits**, which the pointer passes over (06 ▸ Rules ▸ Heal commits are transparent).
    /// - **The root commit** — a judgment call, recorded. It has no parent, so "the state before it"
    ///   is the empty tree: crossing it would delete every file the board has ever had, in one
    ///   keystroke, on a board whose entire history is that one commit. 06 says an unborn repository's
    ///   "undo trail simply starts empty"; a repository with exactly one commit is that repository one
    ///   commit later, and the honest reading is that the board's existence is not a step. (Nothing is
    ///   lost either way: the commit stays reachable in any git client.)
    private func crossableIndex() -> Int? {
        guard !ancestry.isEmpty else { return nil }
        let start = pointerOID.flatMap { oid in ancestry.firstIndex { $0.oid == oid } } ?? 0
        for index in start..<ancestry.count {
            let commit = ancestry[index]
            guard !healOIDs.contains(commit.oid) else { continue }
            guard commit.parentOID != nil else { return nil }
            return index
        }
        return nil
    }

    private func report(_ direction: HistoryDirection, _ message: String) {
        reportFailure?(direction, GitOperationFailure(
            operation: GitRestoreOperation.operationName,
            message: message
        ))
    }

    // MARK: - The restore subject

    /// **What a restore commit is called** — a pure function of the crossed subject and the
    /// direction, so the rule can be read (and pinned) without a repository.
    ///
    /// The base rule is 06's oldest: a crossing commits the state it restored as "Undo: ⟨subject⟩"
    /// or "Redo: ⟨subject⟩". **Subjects don't nest** (06 ▸ Commit messages, settled 2026-07-31):
    /// when the crossed subject already carries a restore prefix — the post-relaunch case, where the
    /// reseed has made old restore commits ordinary steps — the composer "emits the *inverse* label
    /// instead of stacking: crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
    /// 'Undo: S'", which "caps prefixes at one across any number of relaunches".
    ///
    /// ### Why the two directions read the crossed subject differently
    ///
    /// The label states what the new commit's tree *does* to the base subject S: "Undo: S" is the
    /// state where S is out, "Redo: S" the state where S is in. An undo restores the crossed
    /// commit's **parent** — the state before it — so it emits that commit's inverse; a redo
    /// restores the target commit **itself**, so it emits that commit's own reading. That is what
    /// makes 06's sentence true ("undoing the restore that undid a move *re-applies* the move") and
    /// its mirror true with it: ⇧⌘Z back across an "Undo: S" step lands on the tree where S is out,
    /// and says "Undo: S" — the truer label, rather than the "Redo: S" the ⌘Z that crossed it
    /// already used for the opposite tree.
    ///
    /// ### The legacy double prefix
    ///
    /// "Undo: Undo: S" exists in the wild — the shipped nesting build made them — and the honest
    /// reading is this same one applied twice: the inner "Undo:" took S out, the outer one took
    /// *that* back, so the commit's tree is the one where S is in. Undoing across it therefore emits
    /// **"Undo: S"** — the tree it restores is the one without S, and saying "Redo: S" there would be
    /// exactly the euphemism 06 rules out ("This is the truer label, not a euphemism"), while
    /// "Redo: Undo: S" would keep the nesting the ruling caps at one. So each "Undo: " prefix flips
    /// the reading, each "Redo: " prefix leaves it, and what comes out carries exactly one.
    ///
    /// The sniff is on the subject string, deliberately (06), so "a foreign commit that happens to
    /// open with a prefix gets the inverse label too; that's cosmetic — the restore itself is
    /// unaffected".
    public nonisolated static func restoreSubject(
        _ direction: HistoryDirection,
        crossing subject: String
    ) -> String {
        let reading = RestoreSubjectReading(of: subject)
        let emitted = switch direction {
        case .undo: reading.polarity.inverse
        case .redo: reading.polarity
        }
        return "\(emitted.label): \(reading.base)"
    }
}

// MARK: - Helpers

/// What a subject says about its own base subject: is that change *in* the tree the subject
/// describes, or has it been taken back out? Every restore label is one of these two readings, which
/// is why the composer can invert rather than stack (`GitHistoryProvider.restoreSubject(_:crossing:)`).
private enum RestorePolarity {
    /// The base subject's change is in the tree — every ordinary commit, and every "Redo: S".
    case applied
    /// The base subject's change has been taken back out — "Undo: S".
    case reverted

    var inverse: RestorePolarity { self == .applied ? .reverted : .applied }

    /// The word that states this reading in a subject.
    var label: String { self == .applied ? "Redo" : "Undo" }

    /// The same word as a prefix — the only two this composer emits, and the only two it reads, so
    /// that reading and writing can never drift apart.
    var prefix: String { "\(label): " }
}

/// One subject read as "a base subject, plus what its restore prefixes say about it".
///
/// Stripping is greedy because the legacy nesting build's subjects are (`restoreSubject`), and a
/// prefix only counts while something is left for it to be *about*: a bare "Undo: " is somebody's
/// subject, not a label with nothing after it.
private struct RestoreSubjectReading {
    let base: String
    let polarity: RestorePolarity

    init(of subject: String) {
        var base = subject
        var polarity = RestorePolarity.applied
        while true {
            let read: RestorePolarity
            if base.hasPrefix(RestorePolarity.reverted.prefix) {
                read = .reverted
            } else if base.hasPrefix(RestorePolarity.applied.prefix) {
                read = .applied
            } else {
                break
            }
            let rest = String(base.dropFirst(read.prefix.count))
            guard !rest.isEmpty else { break }
            base = rest
            // "Undo: " flips what the rest of the subject was saying; "Redo: " restates it.
            if read == .reverted { polarity = polarity.inverse }
        }
        self.base = base
        self.polarity = polarity
    }
}
