import AppKit
import QuartzCore
import SwiftUI

/// The app's one animation surface — every curve, duration and appear/disappear transition it uses
/// is named here (03-board-ui.md § Motion: "The rewrite gives the vocabulary the one thing the
/// pathfinder lacked: a single named home (one animation-constants surface), so curves and durations
/// stop being per-site literals").
///
/// **The rule this type exists to enforce: no call site anywhere spells a duration, a spring, a
/// timing function, or a transition of its own.** A site that needs motion asks for it by *meaning*
/// — `Motion.laneResize`, `Motion.cardTransition` — and the meaning is defined once, here.
///
/// ### Two voices, semantically split
///
/// - **Structural** (snappy springs) is the positional voice: drag reflow (~0.18 s), drop commit,
///   paste, delete (~0.25 s), keyboard nudges, scroll-into-view, lane resize (~0.2 s). It is what
///   the board wears when *things move*.
/// - **Content reflow** (a smooth ~0.28 s spring) is the filtering voice: search filtering and
///   undo/redo restore, "deliberately paired so a restore reads like the search filter".
///
/// Named system presets only (`.snappy`, `.smooth`); 03 rules out hand-tuned spring parameters, so
/// there is not a `response:` or a `dampingFraction:` anywhere below.
///
/// ### Which changes animate at all
///
/// "User-initiated structural changes animate; foreign changes snap." Lanework's one-way flow means
/// a user's own action arrives back as a watcher reload like any other, so the split cannot live at
/// the call sites the pathfinder put it at — it lives at the *reload*, whose origin the store
/// already classifies. `reloadAnimates(origin:endsBracketedOperation:)` is that decision, kept pure
/// so it can be tested without a filesystem, and `BoardStore.land` is its one caller.
///
/// ### Reduce Motion
///
/// Every accessor comes in a Reduce Motion-aware form, because 10-accessibility.md makes the reduced
/// variants an obligation rather than an inheritance ("the pathfinder ships zero reduced variants").
/// The mapping is 10's own "crossfade or instant", applied by kind:
///
/// - an **animation** has no crossfade available — a reflow either eases or it does not — so its
///   reduced variant is `nil`, which `withAnimation` and `.animation(_:value:)` both read as
///   "apply instantly";
/// - a **transition** does, so its reduced variant is the opacity-only crossfade, with the scale
///   dropped.
///
/// Views read `@Environment(\.accessibilityReduceMotion)` and pass it in. Code with no environment
/// to read — the store's reload seam, the lane-resize session's window animation, a menu command's
/// action — reads `prefersReducedMotion` instead, which asks AppKit the same question.
enum Motion {

    // MARK: - The numbers

    /// The durations 03-board-ui.md § Motion fixes by name. Private: the whole point of this type is
    /// that a duration is never read as a number except by the accessors below.
    ///
    /// `structural` and `laneResize` are the same 0.2 s today and are still two entries, because 03
    /// names them separately — one is the voice's general figure and the other is one operation's.
    /// Collapsing them would make a later "lane resize is a touch slower than the rest" a change to
    /// every structural site.
    private enum Duration {
        static let structural: TimeInterval = 0.2
        static let dragReflow: TimeInterval = 0.18
        static let delete: TimeInterval = 0.25
        static let laneResize: TimeInterval = 0.2
        static let contentReflow: TimeInterval = 0.28
    }

    /// "Appear/disappear is scale + fade (cards scale from ~0.8, lanes ~0.9, combined with opacity)."
    private enum AppearScale {
        static let card: CGFloat = 0.8
        static let lane: CGFloat = 0.9
    }

    // MARK: - The structural voice

    /// The general snappy spring — the voice every positional change wears unless 03 gives that
    /// operation a figure of its own. The store's reload seam and the trash re-divide are its call
    /// sites today.
    static func structural(reduced: Bool) -> Animation? {
        reduced ? nil : .snappy(duration: Duration.structural)
    }

    /// The drag's reflow-to-make-room — the siblings opening a slot under a drop proposal
    /// (03 § Motion: "on the drag's **drop proposal** (the reflow-to-make-room above animates under
    /// it, ~0.18 s)"). The lane reorder's sibling reflow is its call site today; m5's card drag joins
    /// it, and the replica's own tracking deliberately never comes near it.
    static func dragReflow(reduced: Bool) -> Animation? {
        reduced ? nil : .snappy(duration: Duration.dragReflow)
    }

    /// Delete's slightly longer settle — the survivors' reflow as an item leaves.
    ///
    /// **No call site yet, and that is a known gap rather than an oversight.** Delete lands through
    /// the Writer and comes back as an app-mediated reload, and the reload seam cannot know *which*
    /// operation echoed — see `reloadAnimates(origin:endsBracketedOperation:)`. It is named here
    /// because 03 fixes the figure and because the alternative is a literal at whichever site first
    /// needs it.
    static func delete(reduced: Bool) -> Animation? {
        reduced ? nil : .snappy(duration: Duration.delete)
    }

    /// The lane resize's snap tick — the SwiftUI half. The window's half is
    /// `laneResizeWindowDuration`, and the two must stay on matching curves or the window edge and
    /// the lanes to its right stop travelling as one (`LaneResizeSession`).
    static func laneResize(reduced: Bool) -> Animation? {
        reduced ? nil : .snappy(duration: Duration.laneResize)
    }

    /// The content-reflow voice: search filtering and undo/redo restore, "deliberately paired so a
    /// restore reads like the search filter — leavers and arrivers run their transition, survivors
    /// reflow under one gentle spring".
    ///
    /// **The search filter is its call site** (`BoardView.laneStrip`), where it wraps a transaction
    /// keyed on the query and nothing else — 03's own narrow key for this reflow. The leavers and
    /// arrivers it talks about are `cardTransition`, already attached to every card slot and trash
    /// row, so the two halves of the sentence are two modifiers rather than one bespoke animation.
    ///
    // m7-undo: the restore is the other half of the pair. It arrives as a bracketed wholesale
    // reload, so it reaches this voice through `reloadAnimation` rather than through a transaction
    // of its own — see `reloadAnimates(origin:endsBracketedOperation:)`.
    static func contentReflow(reduced: Bool) -> Animation? {
        reduced ? nil : .smooth(duration: Duration.contentReflow)
    }

    // MARK: - Appear and disappear

    /// The shape an appear/disappear transition takes — the *testable* half of `cardTransition` and
    /// `laneTransition`.
    ///
    /// It exists because `AnyTransition` is opaque and not `Equatable`: without this, "the reduced
    /// variant is a crossfade" would be a claim no test could make, and the one thing
    /// 10-accessibility.md actually commits to here is which variant a reduced-motion user gets.
    enum Appearance: Equatable {
        /// Scales up from `from` while fading in (and the reverse on the way out).
        case scaleAndFade(from: CGFloat)
        /// Slides in from `edge` while fading in, and leaves the way it came.
        case slideAndFade(from: Edge)
        /// Opacity only — 10-accessibility.md's "crossfade" variant.
        case crossfade

        var transition: AnyTransition {
            switch self {
            case let .scaleAndFade(scale): .scale(scale: scale).combined(with: .opacity)
            case let .slideAndFade(edge): .move(edge: edge).combined(with: .opacity)
            case .crossfade: .opacity
            }
        }
    }

    static func cardAppearance(reduced: Bool) -> Appearance {
        reduced ? .crossfade : .scaleAndFade(from: AppearScale.card)
    }

    static func laneAppearance(reduced: Bool) -> Appearance {
        reduced ? .crossfade : .scaleAndFade(from: AppearScale.lane)
    }

    /// A card arriving or leaving — a create, a delete, a restore, and the search filter's leavers
    /// and arrivers ("Search-hiding rides the same structural transition — hiding is removal, not a
    /// special fade"). The trash's rows wear it too: they are cards, and 10 requires the trash
    /// animations to have a reduced variant like everything else.
    static func cardTransition(reduced: Bool) -> AnyTransition {
        cardAppearance(reduced: reduced).transition
    }

    /// A lane arriving or leaving — and the trash column joining or leaving the width division,
    /// which is lane-shaped and reads as one.
    static func laneTransition(reduced: Bool) -> AnyTransition {
        laneAppearance(reduced: reduced).transition
    }

    /// The transient search bar arriving and leaving — ⌘F's fallback when the search field has been
    /// removed from the toolbar (03-board-ui.md ▸ Toolbar: "with the field removed from the toolbar,
    /// invoking it surfaces the field transiently until the search clears").
    ///
    /// It comes from the top edge because that is where the field lives when it is installed: the
    /// bar is the toolbar's item arriving one row lower, not a new kind of surface.
    static func transientSearchAppearance(reduced: Bool) -> Appearance {
        reduced ? .crossfade : .slideAndFade(from: .top)
    }

    static func transientSearchTransition(reduced: Bool) -> AnyTransition {
        transientSearchAppearance(reduced: reduced).transition
    }

    /// The card window's attributes sidebar, arriving or leaving — View ▸ Show Sidebar's one visible
    /// consequence (05-card-window.md ▸ Composition ▸ The attributes sidebar). Slides from the
    /// trailing edge, where the sidebar itself sits — `transientSearchAppearance`'s own reasoning
    /// (the surface arrives from the edge it lives on) applied to this one's edge instead of the
    /// toolbar's.
    static func cardSidebarAppearance(reduced: Bool) -> Appearance {
        reduced ? .crossfade : .slideAndFade(from: .trailing)
    }

    static func cardSidebarTransition(reduced: Bool) -> AnyTransition {
        cardSidebarAppearance(reduced: reduced).transition
    }

    // MARK: - The AppKit face

    /// The lane-resize window animation's duration, for `NSAnimationContext` — which takes a number
    /// of seconds and not an `Animation`, and so cannot be handed `laneResize(reduced:)`.
    ///
    /// No `reduced:` parameter, deliberately: the reduced variant of a window resize is **no
    /// animation group at all** (`LaneResizeSession.tick`), not a zero-duration one. Core Animation
    /// treats a zero duration as "use the transaction's default" often enough that trusting it to
    /// mean *instant* would make 10-accessibility.md's lane-resize commitment a coin toss.
    static var laneResizeWindowDuration: TimeInterval { Duration.laneResize }

    /// The window animation's timing function. `.easeOut` is the closest AppKit curve to the snappy
    /// spring the units tick on — the two are travelling the same distance in the same time, and a
    /// visible disagreement between the window's right edge and the lanes inside it is exactly what
    /// `LaneResizeSession`'s frozen standard exists to prevent.
    static var laneResizeWindowTiming: CAMediaTimingFunction {
        CAMediaTimingFunction(name: .easeOut)
    }

    /// Reduce Motion, asked of AppKit rather than of the SwiftUI environment.
    ///
    /// For the three kinds of caller that have no environment to read: the store's reload seam (a
    /// model type), `LaneResizeSession` (which animates an `NSWindow`), and the menu commands (whose
    /// content is built outside any rendered hierarchy, so the environment's accessibility values are
    /// not reliably populated there). It is the same system setting SwiftUI's
    /// `accessibilityReduceMotion` reports, asked at the other end.
    @MainActor
    static var prefersReducedMotion: Bool {
        NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
    }

    // MARK: - Which reloads perform

    /// **The two-voice split, as one pure decision**: does the snapshot this reload landed get
    /// applied in an animated transaction, or does it simply appear?
    ///
    /// 03-board-ui.md § Motion states the rule at the level of user intent — "User-initiated
    /// structural changes animate; foreign changes snap" — and notes the pathfinder enforced it by
    /// routing every user operation through animated store methods. Lanework cannot: the one-way
    /// flow means a delete the user just asked for arrives back through the watcher exactly like an
    /// agent's edit would. What the app has instead is the reload's **origin**, which the watcher
    /// already classifies, and that is enough:
    ///
    /// - `.appMediated` — the tail of an operation this app ran. The user did something and this is
    ///   it landing, so it performs.
    /// - `.foreign` — an editor, an agent, `git` in a terminal. It snaps: "live-reload is the board
    ///   becoming what's on disk, not an event to perform."
    /// - `.reconciling` — a sweep after a wake, an activation, or a missed-events flag. It makes no
    ///   claim to be anyone's gesture, so it snaps too; a board that animated every lane on wake
    ///   would be performing the *absence* of a change.
    ///
    /// **A bracketed wholesale operation snaps whatever its origin.** Its reload is a pull-rebase, a
    /// branch switch, a wholesale rewrite — the board can be a different tree afterwards, and
    /// animating every lane and card out and back in would be theatre over a change the user asked
    /// for in one gesture and 10-accessibility.md announces in one sentence ("Bracketed operations
    /// announce once, at completion").
    ///
    /// ### The merged-origin case, stated because it is not what it looks like
    ///
    /// Origins coalesce by precedence (`reconciling > appMediated > foreign`, `WatchOrigin.merged`),
    /// and that merge is **lossy on purpose**: a foreign edit landing inside an app-mediated span
    /// does not downgrade it, so the store is handed `.appMediated` and cannot tell a pure echo from
    /// a mixed one. The mixed window therefore animates. That is the honest reading rather than a
    /// concession — the merged label is defined as "the strongest claim either half made", the app's
    /// own operation *is* in that window, and it is the thing the user is waiting to see land. The
    /// alternative would be to split deliveries to keep origins pure, which `FolderWatcher.schedule`
    /// rejects for the coalescing it would cost.
    ///
    // m7-undo: undo/redo restore is 03's "one deliberate crossover" — app-initiated, so it animates,
    // but in the *content-reflow* voice rather than the structural one ("a git checkout, but ours").
    // It arrives as a bracketed wholesale reload, so it lands on the `endsBracketedOperation` branch
    // below: m7 gives this function a way to know the operation was a restore (an origin case, or a
    // restore flag threaded through `performWholesale`) and returns `contentReflow` for it instead of
    // snapping. Everything else on that branch keeps snapping.
    static func reloadAnimates(origin: WatchOrigin, endsBracketedOperation: Bool) -> Bool {
        guard !endsBracketedOperation else { return false }
        switch origin {
        case .appMediated: return true
        case .foreign, .reconciling: return false
        }
    }

    /// The thin wrapper `BoardStore.land` hands to `withAnimation`: the voice a landing snapshot is
    /// applied in, or `nil` for the reloads that snap.
    ///
    // m5-drag: the voice is the *general* structural spring for every app-mediated reload, because
    // the reload seam knows an operation echoed but not which one — 03 gives delete 0.25 s and a
    // drop commit its own dialect, and neither is reachable from an origin tag. The per-operation
    // figures (`delete`, `dragReflow`) become reachable when the operations that own them run their
    // own transactions around the gesture; this seam stays the floor under them.
    //
    // The search filter needed none of that and never reaches here: a query change is not a reload
    // at all — it is transient state, so its transaction is wrapped where it happens
    // (`BoardView.laneStrip`, `contentReflow`).
    static func reloadAnimation(origin: WatchOrigin, endsBracketedOperation: Bool, reduced: Bool) -> Animation? {
        guard reloadAnimates(origin: origin, endsBracketedOperation: endsBracketedOperation) else { return nil }
        return structural(reduced: reduced)
    }
}
