import SwiftUI
import Testing
@testable import Kanban

/// The motion language's testable half (03-board-ui.md § Motion, 10-accessibility.md ▸ Reduce
/// Motion). Animation itself is not unit-testable — nothing here renders — so what these pin is the
/// *contract* the surface makes to its call sites:
///
/// 1. **Which reloads perform.** `Motion.reloadAnimates` is the whole of "user-initiated structural
///    changes animate; foreign changes snap" in Lanework's one-way flow, and it is a pure function
///    of an origin and a flag precisely so it can be pinned here rather than watched for.
/// 2. **Which variant Reduce Motion gets.** 10-accessibility.md's commitment is "crossfade or
///    instant", and both halves are assertable: an animation's reduced variant is `nil`, a
///    transition's is the opacity-only crossfade (`Motion.Appearance`, which exists so this claim is
///    testable at all — `AnyTransition` is opaque).
/// 3. **The named durations.** 03 fixes five figures by name; a silent drift in one of them would be
///    invisible in every other test in the suite.
/// 4. **Which changes are not motion at all.** The create handoff has to "read as one arrival"
///    (02-architecture.md ▸ TransientBoardState ▸ overlays), and it achieves that by *removing* an
///    animation rather than adding one — the placeholder adopts the arriving card's `ForEach`
///    identity, so no transition has anything to fire on. The claim is a pure function of a
///    placeholder phase (`LaneSlot.identity(ofPlaceholderIn:)`), so it is pinned here rather than
///    watched for.

// MARK: - Which reloads perform

/// The store seam's decision, over every origin the watcher can deliver.
struct ReloadVoiceTests {

    /// The rule's positive half: an app-mediated reload is the tail of something the user did, and
    /// it is the *only* origin that performs.
    @Test func anAppMediatedReloadAnimates() {
        #expect(Motion.reloadAnimates(origin: .appMediated, endsBracketedOperation: false))
    }

    /// "Changes arriving through the watcher — agent edits, hand edits, sync, external git — apply
    /// instantly with no transition: live-reload is the board becoming what's on disk, not an event
    /// to perform."
    @Test func aForeignReloadSnaps() {
        #expect(!Motion.reloadAnimates(origin: .foreign, endsBracketedOperation: false))
    }

    /// A reconciling sweep — wake, activation, a missed-events flag — makes no claim to be anyone's
    /// gesture. Usually it lands a value-equal snapshot and nothing moves at all; when it does not,
    /// what it found is a change that already happened, not one to perform.
    @Test func aReconcilingReloadSnaps() {
        #expect(!Motion.reloadAnimates(origin: .reconciling, endsBracketedOperation: false))
    }

    /// A bracketed wholesale operation's closing reload snaps **whatever its origin** — a pull, a
    /// branch switch, a wholesale rewrite can leave the board a different tree, and 10 gives those
    /// one announcement at completion rather than a performance of their churn.
    @Test func aBracketedWholesaleReloadSnapsWhateverItsOrigin() {
        for origin in [WatchOrigin.appMediated, .foreign, .reconciling] {
            #expect(
                !Motion.reloadAnimates(origin: origin, endsBracketedOperation: true),
                "a bracketed wholesale reload from \(origin.rawValue) should snap"
            )
        }
    }

    /// **The merged-origin case, pinned because it is not obvious.** Origins coalesce by precedence
    /// and the merge is lossy: a foreign edit folding into an app-mediated span yields
    /// `.appMediated`, so a window that mixes both is indistinguishable from a pure echo and
    /// animates. This test exists to make that a decision rather than a surprise — if the merge rule
    /// ever grows a "mixed" case, this is what should fail.
    @Test func aWindowMixingForeignIntoAnAppMediatedSpanStillAnimates() {
        let merged = WatchOrigin.merged(.appMediated, .foreign)
        #expect(merged == .appMediated)
        #expect(Motion.reloadAnimates(origin: merged, endsBracketedOperation: false))
    }

    /// The other merge, the other way: reconciling outranks app-mediated, so a sweep folding over an
    /// echo snaps. The stronger claim wins, and the stronger claim here is "assume nothing".
    @Test func aReconcilingSweepFoldingOverAnEchoSnaps() {
        let merged = WatchOrigin.merged(.appMediated, .reconciling)
        #expect(merged == .reconciling)
        #expect(!Motion.reloadAnimates(origin: merged, endsBracketedOperation: false))
    }

    /// The wrapper the store actually calls: the same decision, plus the voice and the Reduce Motion
    /// variant. Every snapping path is `nil`, and so is the reduced form of the animating one — which
    /// is what makes `withAnimation` at the seam need no branch of its own.
    @Test func theStoreSeamsWrapperYieldsTheStructuralVoiceOnlyWhereItPerforms() {
        #expect(Motion.reloadAnimation(origin: .appMediated, endsBracketedOperation: false, reduced: false)
                == Motion.structural(reduced: false))
        #expect(Motion.reloadAnimation(origin: .appMediated, endsBracketedOperation: false, reduced: true) == nil)
        #expect(Motion.reloadAnimation(origin: .foreign, endsBracketedOperation: false, reduced: false) == nil)
        #expect(Motion.reloadAnimation(origin: .reconciling, endsBracketedOperation: false, reduced: false) == nil)
        #expect(Motion.reloadAnimation(origin: .appMediated, endsBracketedOperation: true, reduced: false) == nil)
    }
}

// MARK: - The named curves

/// The five durations 03-board-ui.md § Motion fixes, and the two voices they are spoken in.
struct MotionCurveTests {

    @Test func theStructuralVoiceIsTheSnappySpringAtItsNamedDurations() {
        #expect(Motion.structural(reduced: false) == .snappy(duration: 0.2))
        #expect(Motion.dragReflow(reduced: false) == .snappy(duration: 0.18))
        #expect(Motion.delete(reduced: false) == .snappy(duration: 0.25))
        #expect(Motion.laneResize(reduced: false) == .snappy(duration: 0.2))
    }

    /// The content-reflow voice is a *different preset*, not the structural one slowed down — that
    /// is the whole of "two curves, semantically split", and it is why search filtering and an undo
    /// restore read alike and neither reads like a drop.
    @Test func theContentReflowVoiceIsTheSmoothSpring() {
        #expect(Motion.contentReflow(reduced: false) == .smooth(duration: 0.28))
        #expect(Motion.contentReflow(reduced: false) != Motion.structural(reduced: false))
    }

    /// The window's half of the lane resize has to be the same figure as the units' half, or the
    /// window edge and the lanes inside it stop travelling as one (`LaneResizeSession`).
    @Test func theWindowResizeMatchesTheLaneResizeDuration() {
        #expect(Motion.laneResizeWindowDuration == 0.2)
        #expect(Motion.laneResize(reduced: false) == .snappy(duration: Motion.laneResizeWindowDuration))
    }
}

// MARK: - Reduce Motion

/// 10-accessibility.md's "crossfade or instant", by kind: an animation has no crossfade available,
/// so its reduced variant is instant; a transition does, so its reduced variant is the crossfade.
struct ReduceMotionVariantTests {

    @Test func everyAnimationsReducedVariantIsInstant() {
        #expect(Motion.structural(reduced: true) == nil)
        #expect(Motion.dragReflow(reduced: true) == nil)
        #expect(Motion.delete(reduced: true) == nil)
        #expect(Motion.laneResize(reduced: true) == nil)
        #expect(Motion.contentReflow(reduced: true) == nil)
    }

    /// "Appear/disappear is scale + fade (cards scale from ~0.8, lanes ~0.9, combined with opacity)."
    @Test func appearanceIsScaleAndFadeAtTheTwoNamedScales() {
        #expect(Motion.cardAppearance(reduced: false) == .scaleAndFade(from: 0.8))
        #expect(Motion.laneAppearance(reduced: false) == .scaleAndFade(from: 0.9))
    }

    /// The scale is what drops under Reduce Motion; the fade is what stays. A reduced user still
    /// sees the item leave — it just does not travel to do it.
    @Test func everyTransitionsReducedVariantIsTheCrossfade() {
        #expect(Motion.cardAppearance(reduced: true) == .crossfade)
        #expect(Motion.laneAppearance(reduced: true) == .crossfade)
    }

    /// The card window's sidebar slides from the trailing edge it lives on — View ▸ Show Sidebar's
    /// one visible consequence — and, like every slide-and-fade here, crossfades instead under
    /// Reduce Motion.
    @Test func theSidebarSlidesFromTheTrailingEdgeItLivesOn() {
        #expect(Motion.cardSidebarAppearance(reduced: false) == .slideAndFade(from: .trailing))
        #expect(Motion.cardSidebarAppearance(reduced: true) == .crossfade)
    }
}

// MARK: - The create handoff

/// **"The handoff must read as one arrival"** (02-architecture.md ▸ TransientBoardState ▸ overlays,
/// settled 2026-07-28): the new-card placeholder "is handed off by discarding itself the moment the
/// created card's UUID appears in a snapshot", and "the placeholder renders at the arriving card's
/// exact geometry/chrome".
///
/// The mechanism is `LaneSlot`'s identity function and nothing else — no `matchedGeometryEffect`, no
/// second transaction, no suppression flag. A committed placeholder is keyed by the *arriving card's*
/// slot id, so when the echo reload replaces the pseudo-card with the real one the `ForEach` element
/// is neither inserted nor removed: only its content changes, and `Motion.cardTransition` — attached
/// per slot in the lane's masonry — has nothing to run on. Two scale-and-fades become none.
///
/// That is the whole of what is unit-testable about it. The masonry is a `Layout`, the transition is
/// an opaque `AnyTransition`, and neither renders here — but the key derivation those two depend on
/// is a pure function of a phase, and it is the line that would have to break for the handoff to go
/// back to being two events.
struct CreateHandoffIdentityTests {

    private let arriving = ItemID(rawValue: "6C1D4B4E-0000-4000-8000-00000000A001")

    /// **The claim, stated as an equality.** The committed placeholder's key *is* the arriving card's
    /// key — not a parallel spelling of it, the same function's answer — so nothing can drift the two
    /// apart without failing here.
    @Test func aCommittedPlaceholderIsKeyedAsTheArrivingCard() {
        #expect(
            LaneSlot.identity(ofPlaceholderIn: .awaitingArrival(arriving))
                == LaneSlot.identity(of: arriving)
        )
        #expect(LaneSlot.placeholder(.awaitingArrival(arriving)).id == LaneSlot.identity(of: arriving))
    }

    /// The other half of the handoff, from the slot the real card builds: the two `LaneSlot` cases
    /// that stand for one card at two moments answer with one id, which is what makes them one
    /// element to `ForEach` rather than two.
    @Test func theArrivingCardAndItsPlaceholderAreOneElement() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))

        let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
        let card = try #require(snapshot.lanes.flatMap(\.cards).first)

        #expect(LaneSlot.card(card).id == LaneSlot.placeholder(.awaitingArrival(card.id)).id)
    }

    /// **The editing phase keeps the constant key**, and that is not an oversight the handoff missed:
    /// an open editor has no identity to adopt yet, and a key that changed while the user typed would
    /// tear the field down mid-word and take its keyboard focus with it. The key changes exactly once,
    /// at commit.
    @Test func anOpenEditorKeepsItsConstantKey() {
        #expect(LaneSlot.identity(ofPlaceholderIn: .editing) == LaneSlot.editingPlaceholderIdentity)
        #expect(LaneSlot.placeholder(.editing).id == LaneSlot.editingPlaceholderIdentity)
        // And it is emphatically *not* any card's key — that is the whole point of the one change.
        #expect(LaneSlot.identity(ofPlaceholderIn: .editing) != LaneSlot.identity(of: arriving))
    }

    /// The keys stay disjoint by construction — a shadow is never mistaken for the card arriving into
    /// the slot it opened, and the editing placeholder is never mistaken for either.
    @Test func theSlotNamespacesDoNotCollide() {
        let keys: Set<String> = [
            LaneSlot.identity(of: arriving),
            LaneSlot.editingPlaceholderIdentity,
            LaneSlot.shadow(index: 0, height: 44).id,
            LaneSlot.shadow(index: 1, height: 44).id,
        ]
        #expect(keys.count == 4)
    }

    /// **Reduce Motion needs no branch of its own here.** Identity continuity introduces no motion to
    /// reduce: the key is the same key in both variants, so the reduced path reads as one arrival for
    /// exactly the reason the standard path does — a transition that never fires has no variant to
    /// choose between. Asserted as the absence of a parameter, which is the shape the claim takes.
    @Test func theHandoffIsIdenticalUnderReduceMotion() {
        // The derivation takes no `reduced:` flag at all, and the transition it defeats has a reduced
        // variant that is equally irrelevant while the element persists.
        #expect(LaneSlot.identity(ofPlaceholderIn: .awaitingArrival(arriving)) == LaneSlot.identity(of: arriving))
        #expect(Motion.cardAppearance(reduced: true) == .crossfade)
        #expect(Motion.cardAppearance(reduced: false) == .scaleAndFade(from: 0.8))
    }
}
