import AppKit
import Foundation
import Testing
@testable import Kanban

/// **The stationary modifier flip** (04-interactions.md ▸ Drag and drop: "⌥ always forces copy and
/// ⌘ always forces move … the badge tracks the effective operation live").
///
/// The effective operation is sampled inside `DragSession.resolveOperation`, which is reached from
/// `dropUpdated` and from the commit — and `DropDelegate` callbacks arrive only while the mouse
/// **moves**. So a modifier pressed against a perfectly still pointer used to change nothing until
/// the next twitch: the source board's originals stayed lifted (`hiddenMembers` follows the
/// operation since the copy-redraw ruling), the trash column kept refusing a copy it would now take,
/// and the shadows stood in an index space the release no longer counted in.
///
/// The fix is a `.flagsChanged` watch whose lifetime is the drag's, and whose only output is a
/// counter; the board window under the cursor turns that counter back into **the one shared
/// retarget** (`BoardDropContext.retargetAfterModifierFlip`) — the third client of the seam
/// DRAG-REORDER.md § Edge autoscroll opened for the autoscroll driver.
///
/// Two halves, tested at the two seams they live at:
///
/// - the **watch's lifetime** and what counts as a flip, on `DragSession`, through a scripted
///   source — the shipping one is a local `NSEvent` monitor, which needs a live application event
///   stream a test bundle cannot feed (`DragAutoScrollDriverTests`' scripted tick source exactly);
/// - the **funnel**, on `BoardDropContext`, through the modifier seam `resolveOperation` already
///   established: the flags are a defaulted parameter, so the whole re-proposal is checkable
///   without a keyboard.
///
/// What is *not* here, because no unit can hold it: whether the system's own drag badge tracks
/// modifiers without our help (`NSDraggingSource` ANDs modifier-driven operations into the mask
/// unless `ignoreModifierKeysWhileDragging`), and whether a local monitor is delivered inside
/// AppKit's drag-tracking loop at all. Both are live-verify items; the source is a seam precisely so
/// the second one has somewhere to be answered.

// MARK: - The scripted source

/// A stand-in for `LocalModifierFlipSource` whose flags the test presses, and which records the one
/// thing the real source must do when a drag ends: it removes the monitor.
@MainActor
private final class ScriptedFlips: ModifierFlipSource {

    /// How many monitors this source has installed, and how many have been retired — the pair every
    /// leak claim below is made of.
    private(set) var installs = 0
    private(set) var removals = 0

    private var handler: (@MainActor (NSEvent.ModifierFlags) -> Void)?
    private var current: Watch?

    /// Whether a monitor is installed right now.
    var isWatching: Bool { handler != nil }

    func watch(
        _ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void
    ) -> any ModifierFlipWatch {
        installs += 1
        handler = onFlip
        let watch = Watch(source: self)
        current = watch
        return watch
    }

    /// A `.flagsChanged` arriving with this flag state. The event reports the **whole** set rather
    /// than a delta, which is what the session's own comparison assumes.
    func press(_ flags: NSEvent.ModifierFlags) {
        handler?(flags)
    }

    fileprivate func retire(_ watch: Watch) {
        guard current === watch else { return }
        removals += 1
        handler = nil
        current = nil
    }

    /// Stopping twice is not two removals — the session's teardown calls `stop()` on every path
    /// without asking whether a watch is installed.
    @MainActor
    fileprivate final class Watch: ModifierFlipWatch {
        private unowned let source: ScriptedFlips
        private var stopped = false

        init(source: ScriptedFlips) { self.source = source }

        func stop() {
            guard !stopped else { return }
            stopped = true
            source.retire(self)
        }
    }
}

// MARK: - Fixtures

/// One lane with one card — enough for a session to pick up, a lane to propose into, and a trash
/// column to refuse.
@MainActor
private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    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"))
    return fixture
}

/// A drop context with no window, which is exactly what the flip path can be asked about: every
/// cursor-dependent retarget holds (`globalCursor` answers `nil`), so what is left is the operation,
/// the origin dispatch and the trash's modifier-sensitive refusal — the three things a flip decides.
@MainActor
private func makeDrops(
    store: BoardStore,
    session: DragSession,
    registry: LaneDropRegistry
) -> BoardDropContext {
    BoardDropContext(
        store: store,
        session: session,
        registry: registry,
        gap: 12,
        window: { nil },
        stripFrame: { .zero },
        standard: { 260 }
    )
}

@MainActor
private func pickUpCard(_ session: DragSession, from store: BoardStore) {
    session.beginCards(
        [ItemID(rawValue: Ident.card1)],
        folders: [store.rootURL
            .appendingPathComponent(Ident.lane1, isDirectory: true)
            .appendingPathComponent(Ident.card1, isDirectory: true)],
        heights: [44],
        container: .board,
        source: store
    )
}

// MARK: - The watch's lifetime

/// **The monitor cannot outlive the drag**, and the reason it cannot is structural rather than a
/// list of call sites: it is armed at `begin` and stopped at `end`, and `end` is where every way a
/// drag can finish already funnels — a delegate's cancel, the button-up belt, the watchdog, the
/// hold's hand-off, the hold's timeout.
@MainActor
@Suite("Modifier flips ▸ the watch's lifetime")
struct ModifierFlipLifetimeTests {

    @Test("A drag arms the watch, and ending it retires the monitor")
    func armedAtBeginRetiredAtEnd() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let flips = ScriptedFlips()
        let session = DragSession(flipSource: flips)

        #expect(!flips.isWatching, "an idle session watches nothing")

        pickUpCard(session, from: store)
        #expect(flips.isWatching)
        #expect(flips.installs == 1)

        session.end()
        #expect(!flips.isWatching)
        #expect(flips.removals == 1, "the token must be handed back, or the block fires forever")
    }

    @Test("A second drag never leaves the first one's monitor installed")
    func aSecondBeginRetiresTheFirstWatch() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let flips = ScriptedFlips()
        let session = DragSession(flipSource: flips)

        pickUpCard(session, from: store)
        // `begin` without an intervening `end` — the lifecycle trap the watchdog exists for, where a
        // finished session's events arrive after the next drag has started.
        pickUpCard(session, from: store)

        #expect(flips.installs == 2)
        #expect(flips.removals == 1, "arming replaces the watch rather than stacking a second one")
        #expect(flips.isWatching)

        session.end()
        #expect(flips.removals == 2)
    }

    /// The path no delegate can see: a drag macOS never reports the end of. The watchdog polls the
    /// physical button and clears the session, and clearing the session is what retires the monitor.
    @Test("The watchdog's own teardown retires the monitor")
    func theWatchdogRetiresTheWatch() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let flips = ScriptedFlips()
        let session = DragSession(flipSource: flips)

        pickUpCard(session, from: store)
        #expect(flips.isWatching)

        // The button is not down in a test bundle, so the watchdog's poll plus its grace period is
        // the whole wait. Bounded, so a watchdog that never fires fails rather than hangs.
        var cleared = false
        for _ in 0..<200 where !cleared {
            try? await Task.sleep(for: .milliseconds(10))
            cleared = !session.isActive
        }

        #expect(cleared, "the watchdog is the guaranteed termination path")
        #expect(!flips.isWatching)
        #expect(flips.removals == 1)
    }

    /// A Finder file session arms none of this object's own state, so it arms no watch either — and
    /// it needs none: a file drop is always a copy (`fileDropProposal`), whatever is held down.
    @Test("An external file session arms no watch")
    func fileSessionsAreNotWatched() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let flips = ScriptedFlips()
        let session = DragSession(flipSource: flips)

        session.proposeFile(FileDropTarget(
            boardRoot: store.rootKey,
            landing: .create(laneID: ItemID(rawValue: Ident.lane1), index: 0),
            fileCount: 1
        ))

        #expect(!flips.isWatching)
        #expect(flips.installs == 0)
        session.proposeFile(nil)
    }
}

// MARK: - What counts as a flip

/// The nudge itself: one counter, bumped only when something the *operation* is a function of
/// actually moved (`DragLocality.operation` reads ⌥ and ⌘, and nothing else).
@MainActor
@Suite("Modifier flips ▸ the nudge")
struct ModifierFlipNudgeTests {

    @Test("Pressing ⌥ and releasing it are two flips")
    func pressAndReleaseBothNudge() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let flips = ScriptedFlips()
        let session = DragSession(flipSource: flips)

        pickUpCard(session, from: store)
        let base = session.modifierGeneration

        flips.press([.option])
        #expect(session.modifierGeneration == base + 1)

        flips.press([])
        #expect(session.modifierGeneration == base + 2)
    }

    @Test("A flags event that moves neither ⌥ nor ⌘ is not a flip")
    func irrelevantFlagsAreIgnored() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let flips = ScriptedFlips()
        let session = DragSession(flipSource: flips)

        pickUpCard(session, from: store)
        let base = session.modifierGeneration

        // ⇧ has its own meaning in the click grammar and none at all in the drag's operation.
        flips.press([.shift])
        flips.press([.control])
        flips.press([.capsLock])
        #expect(session.modifierGeneration == base)

        // And ⌥ *with* one of them along for the ride is still exactly one flip.
        flips.press([.shift, .option])
        #expect(session.modifierGeneration == base + 1)
        flips.press([.control, .option])
        #expect(session.modifierGeneration == base + 1, "⌥ never moved")
    }

    /// The freeze, at its earliest point: a settled release is past retargeting, so a ⌥ released
    /// between the drop and the echo must not even cost a board window a body pass.
    @Test("A flip under a committed hold is not a nudge")
    func theHoldSwallowsFlips() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let flips = ScriptedFlips()
        let session = DragSession(flipSource: flips)

        pickUpCard(session, from: store)
        session.propose(DropTarget(
            boardRoot: store.rootKey,
            container: .lane(ItemID(rawValue: Ident.lane1)),
            index: 0
        ))
        session.commit(into: store)
        let base = session.modifierGeneration

        flips.press([.option])
        flips.press([])
        #expect(session.modifierGeneration == base)
    }

    @Test("A flip after the drag is over reaches nothing at all")
    func aFinishedDragHearsNothing() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let flips = ScriptedFlips()
        let session = DragSession(flipSource: flips)

        pickUpCard(session, from: store)
        session.end()
        let base = session.modifierGeneration

        flips.press([.option])
        #expect(session.modifierGeneration == base, "the monitor is gone; this is the belt")
    }
}

// MARK: - The funnel: a flip runs the one shared retarget

/// **The third client of the shared retarget.** A flip resolves nothing of its own: it re-runs the
/// retarget that resolved the standing proposal, at the surface that resolved it, so a keystroke and
/// a mouse sample land in exactly the same place by construction.
@MainActor
@Suite("Modifier flips ▸ the shared retarget")
struct ModifierFlipRetargetTests {

    private static let lane1 = ItemID(rawValue: Ident.lane1)
    private static let card1 = ItemID(rawValue: Ident.card1)

    // MARK: The address a flip replays at

    @Test("Each retarget leaves its own address behind, even when it goes on to hold")
    func everyRetargetRecordsItsSurface() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let registry = LaneDropRegistry()
        let session = DragSession(flipSource: ScriptedFlips())
        let drops = makeDrops(store: store, session: session, registry: registry)

        #expect(session.retargetOrigin == nil, "a fresh session has nowhere to replay")

        pickUpCard(session, from: store)
        drops.retargetCards(inLane: Self.lane1)
        #expect(session.retargetOrigin?.surface == .lane(Self.lane1))
        #expect(session.retargetOrigin?.registry === registry)

        drops.retargetTrash(modifiers: [])
        #expect(session.retargetOrigin?.surface == .trash)

        // The lane level: the strip's own zones.
        session.beginLanes(
            [Self.lane1],
            folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
            units: [1],
            source: store
        )
        #expect(session.retargetOrigin == nil, "a new drag starts with no address")
        drops.retargetLanes()
        #expect(session.retargetOrigin?.surface == .strip)

        session.end()
        #expect(session.retargetOrigin == nil)
    }

    // MARK: The re-admission at home

    /// The confirmed half of the card, end to end: ⌥ pressed with the mouse perfectly still flips
    /// the operation to a copy, and a copy leaves its originals standing in the source board's
    /// resting layout (`hiddenMembers`) — which used to wait for the next mouse sample.
    @Test("A stationary ⌥ re-admits the originals at home")
    func aStationaryOptionRedrawsTheSource() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let registry = LaneDropRegistry()
        let session = DragSession(flipSource: ScriptedFlips())
        let drops = makeDrops(store: store, session: session, registry: registry)

        pickUpCard(session, from: store)
        drops.retargetCards(inLane: Self.lane1)
        #expect(session.operation == .move)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])

        drops.retargetAfterModifierFlip(modifiers: [.option])

        #expect(session.operation == .copy)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty,
                "a copy's originals stay exactly where they are")

        // And releasing ⌥ lifts them again, with the pointer still untouched.
        drops.retargetAfterModifierFlip(modifiers: [])
        #expect(session.operation == .move)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
    }

    /// The one carve-out that outranks the modifier: a within-board lane drag is a reorder and ⌥ is
    /// simply ignored there (`DragLocality.operation`). The flip runs; the answer does not move.
    @Test("A within-board lane drag ignores the flip, exactly as it ignores the modifier")
    func laneDragsIgnoreTheFlip() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let registry = LaneDropRegistry()
        let session = DragSession(flipSource: ScriptedFlips())
        let drops = makeDrops(store: store, session: session, registry: registry)

        session.beginLanes(
            [Self.lane1],
            folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
            units: [1],
            source: store
        )
        drops.retargetLanes()

        drops.retargetAfterModifierFlip(modifiers: [.option])
        #expect(session.operation == .move)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.lane1])
    }

    // MARK: The operation-dependent refusal

    /// **The trash refuses copies** (04-interactions.md ▸ The trash): "copying into the trash is not
    /// a thing". The gate is a function of the operation, so it is a function of the modifiers — and
    /// until now it was re-asked only when the mouse moved, which is why a ⌥ released over the
    /// column left it inert under a pointer sitting right on it.
    @Test("Releasing ⌥ over the trash proposes the delete, with the pointer perfectly still")
    func theTrashReadmitsTheDropOnRelease() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.transient.isTrashVisible = true
        let registry = LaneDropRegistry()
        let session = DragSession(flipSource: ScriptedFlips())
        let drops = makeDrops(store: store, session: session, registry: registry)

        pickUpCard(session, from: store)

        // The cursor arrives over the column with ⌥ down: the copy is refused, and the column
        // declines to be a target rather than cancelling the drag.
        drops.retargetTrash(modifiers: [.option])
        #expect(session.operation == .copy)
        #expect(session.trashProposal(onBoardRooted: store.rootKey) == nil)

        // ⌥ comes up. No mouse event follows — this is the whole point.
        drops.retargetAfterModifierFlip(modifiers: [])

        #expect(session.operation == .move)
        #expect(session.trashProposal(onBoardRooted: store.rootKey) == TrashDrop.landingIndex)
    }

    // MARK: Rule 2, on the flip's path too

    /// The `.lane` replay is the lane delegate's own pair — revalidate, then the masonry's zones —
    /// so a proposal whose lane vanished in a reload withdraws on a flip exactly as it would on the
    /// next mouse sample.
    @Test("A flip revalidates before it retargets: a vanished lane's proposal withdraws")
    func theFlipAppliesRuleTwo() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let registry = LaneDropRegistry()
        let session = DragSession(flipSource: ScriptedFlips())
        let drops = makeDrops(store: store, session: session, registry: registry)

        pickUpCard(session, from: store)
        drops.retargetCards(inLane: Self.lane1)
        // A proposal naming a lane this board does not have — the reload that deleted it landed
        // while the pointer sat still.
        session.propose(DropTarget(
            boardRoot: store.rootKey,
            container: .lane(ItemID(rawValue: Ident.lane2)),
            index: 0
        ))

        drops.retargetAfterModifierFlip(modifiers: [.option])

        #expect(session.proposal == nil, "deleted lanes are never drop targets")
    }

    // MARK: Who answers

    /// The origin names a `LaneDropRegistry`, which is one board **window**'s — so the window under
    /// the cursor answers a flip and no other does. A root comparison would have a second window on
    /// the same board retargeting against a cursor nowhere near its lanes; a cross-board drag would
    /// have the *source* board answering for the destination.
    @Test("Only the window that resolved the proposal answers the flip")
    func anotherWindowDoesNotAnswer() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession(flipSource: ScriptedFlips())
        let hovered = LaneDropRegistry()
        let elsewhere = LaneDropRegistry()
        let hoveredDrops = makeDrops(store: store, session: session, registry: hovered)
        let otherDrops = makeDrops(store: store, session: session, registry: elsewhere)

        pickUpCard(session, from: store)
        hoveredDrops.retargetCards(inLane: Self.lane1)

        otherDrops.retargetAfterModifierFlip(modifiers: [.option])
        #expect(session.operation == .move, "this window is not where the proposal lives")
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])

        hoveredDrops.retargetAfterModifierFlip(modifiers: [.option])
        #expect(session.operation == .copy)
    }

    @Test("A flip with no address recorded resolves nothing")
    func noOriginNoAnswer() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession(flipSource: ScriptedFlips())
        let drops = makeDrops(store: store, session: session, registry: LaneDropRegistry())

        pickUpCard(session, from: store)
        drops.retargetAfterModifierFlip(modifiers: [.option])

        #expect(session.operation == .move, "no surface has resolved a proposal yet")
    }

    // MARK: The freeze

    /// The same freeze `propose` and `resolveOperation` wear, asked once more at the funnel: the
    /// write named an operation and the overlay draws *that* operation until the echo lands, so a ⌥
    /// released between the drop and the reload must not re-lift originals the write is leaving in
    /// place.
    @Test("A settled release is past retargeting — a flip changes nothing")
    func theHoldFreezesTheFlip() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.transient.isTrashVisible = true
        let registry = LaneDropRegistry()
        let session = DragSession(flipSource: ScriptedFlips())
        let drops = makeDrops(store: store, session: session, registry: registry)

        pickUpCard(session, from: store)
        drops.retargetTrash(modifiers: [])
        #expect(session.trashProposal(onBoardRooted: store.rootKey) == TrashDrop.landingIndex)

        session.commit(into: store)
        drops.retargetAfterModifierFlip(modifiers: [.option])

        #expect(session.operation == .move, "the write named the move")
        #expect(session.trashProposal(onBoardRooted: store.rootKey) == TrashDrop.landingIndex)
        #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
    }
}
