import AppKit
import Foundation
import SwiftUI
import Testing
@testable import Kanban

/// **Regression pins for the 2026-08-06 click-latency fix**, measured on a real hosted `BoardView`
/// with synthetic pointer events.
///
/// The defect: `LaneView`'s empty-space double-click was a second `.onTapGesture(count: 2)` stacked
/// over the single tap, and a sequential two-tap recogniser holds every single click on that
/// container — its own empty space and every card face it wraps — hostage to the system
/// double-click interval (500 ms here) while it disambiguates. Measured before the fix: ~475 ms
/// from click to selection, and ~464 ms from right-click to menu when the right-click followed a
/// left click. After: both a handful of milliseconds.
///
/// The fix moved the empty-space surfaces to a background layer behind the masonry (cards no
/// longer share a gesture path with any lane recogniser) and replaced the two-tap recogniser with
/// one `.onTapGesture` branching on `PointerClick.count`. The layer carries **no drag source** —
/// an `.onDrag` there, even empty-provider, claims drags outright and kills the rubber band
/// (measured on real events, 2026-08-07; see `LaneView`).
///
/// **That choice puts the lane's empty space beyond this harness's reach.** In this suite's
/// sterile queue macOS holds a dragless surface's primary clicks pending multi-click
/// disambiguation and never releases them — successive synthetic clicks do not release a held
/// predecessor the way real ones do, and injecting synthetic micro-motion deadlocks AppKit's
/// mouse-tracking loop (it blocks on `nextEvent` for hardware that isn't there). Worse, every
/// held-and-orphaned click poisons the app-global held-event machinery for the *rest of the
/// process* — a prior revision of this suite probed empty space first and watched the right-click
/// pin fail downstream. So empty-space clicks are deliberately absent here. Their truth on a real
/// event stream is established and re-checkable with the CGEvent driver (2026-08-07 session:
/// taps ~1–3 ms, `clickCount=2` reaches the create branch, the marquee sweeps end to end) — real
/// streams have nothing to disambiguate on a count-1 tap.
///
/// What this suite pins is the original defect's surface, which is also the one it can see:
///  - a card click selects **instantly** — the ~475 ms tripwire; a container-level multi-click
///    recogniser regressing would re-hold every card click (card faces are drag-sourced, so
///    their clicks are hold-free in both stream kinds),
///  - a card double-click opens the card window and creates **no** placeholder (the empty-space
///    layer is not the card's ancestor, so its create can never fire for a card's clicks),
///  - a right-click on the heels of a left click reaches its menu instantly.
///
/// Events go through `NSApp.postEvent` and are drained via `NSApp.nextEvent` rather than
/// `window.sendEvent`, because `PointerClick` reads `NSApp.currentEvent` — which only the real
/// dequeue path populates — and because the dequeue path is the one real clicks take through the
/// hold-and-release machinery this suite exists to pin.

// MARK: - Fixture

private let laneCount = 6
private let cardsPerLane = 30
/// Lane 0 stays short so it has visible empty space to double-click.
private let shortLaneCards = 3

private func laneName(_ lane: Int) -> String {
    String(format: "1%07d-1111-4111-8111-111111111111", lane)
}

private func cardName(_ lane: Int, _ card: Int) -> String {
    String(format: "2%03d%04d-2222-4222-8222-222222222222", lane, card)
}

@MainActor
private func makeFixture() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.board(title: "Latency Board")
    for lane in 0..<laneCount {
        try fixture.lane(laneName(lane), order: "\((lane + 1) * 1024)", title: "Lane \(lane)")
        let cards = lane == 0 ? shortLaneCards : cardsPerLane
        for card in 0..<cards {
            try fixture.card(
                cardName(lane, card),
                in: laneName(lane),
                order: "\((card + 1) * 1024)",
                title: "Card \(lane)-\(card)",
                body: "Body text for card \(lane)-\(card)."
            )
        }
    }
    return fixture
}

// MARK: - Hosting (BoardRenderPerformanceTests' harness, with an openCard capture)

private struct ZoomedBoard: View {
    let store: BoardStore
    let window: @MainActor () -> NSWindow?
    let confirmations: TrashConfirmations
    let openCard: @MainActor (ItemID) -> Void
    let search: BoardSearchPresentation

    @Environment(AppModel.self) private var appModel

    var body: some View {
        BoardView(
            store: store,
            window: window,
            confirmations: confirmations,
            openCard: openCard,
            search: search
        )
        .environment(\.boardZoom, appModel.zoom.context)
    }
}

@MainActor
private final class HostedBoard {
    let store: BoardStore
    let appModel: AppModel
    let window: NSWindow
    let view: NSView
    private(set) var openedCards: [ItemID] = []
    private let scratch: URL
    private let preferencesDomain: String

    init(store: BoardStore, scratch: URL) {
        self.store = store
        self.scratch = scratch
        preferencesDomain = "dev.rzen.indie.Kanban.pointer-latency.\(UUID().uuidString)"
        appModel = AppModel(
            registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
            clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true),
            preferences: UserDefaults(suiteName: preferencesDomain)!
        )
        let window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
            styleMask: [.titled], backing: .buffered, defer: false
        )
        self.window = window
        var recordOpen: (ItemID) -> Void = { _ in }
        let root = ZoomedBoard(
            store: store,
            window: { [weak window] in window },
            confirmations: TrashConfirmations(),
            openCard: { recordOpen($0) },
            search: BoardSearchPresentation()
        )
        .environment(appModel)
        let hosting = NSHostingView(rootView: root)
        hosting.frame = NSRect(x: 0, y: 0, width: 1600, height: 1000)
        view = hosting
        window.contentView = hosting
        window.orderBack(nil)
        window.makeKey()
        recordOpen = { [weak self] id in
            MainActor.assumeIsolated { self?.openedCards.append(id) }
        }
        // A generous settle, and not politeness: the masonry places cards only after measurement
        // round-trips, and until those land a click at `cardPoint` hits the *empty-space layer*
        // instead of a card — which the dragless layer holds forever in this sterile queue,
        // poisoning the process's held-event machinery (see the suite note above). Six turns
        // sufficed on an idle machine; under parallel-build load it reproducibly did not
        // (2026-08-08). The turns are cheap; the misfire is not.
        settle(turns: 60)
    }

    deinit {
        window.orderOut(nil)
        window.contentView = nil
        try? FileManager.default.removeItem(at: scratch)
        UserDefaults.standard.removePersistentDomain(forName: preferencesDomain)
    }

    func settle(turns: Int = 6) {
        for _ in 0..<turns {
            RunLoop.main.run(until: Date().addingTimeInterval(0.02))
            view.layoutSubtreeIfNeeded()
            window.displayIfNeeded()
        }
    }
}

@MainActor
private func host(_ fixture: WriterFixture) throws -> HostedBoard {
    let scratch = FileManager.default.temporaryDirectory
        .appendingPathComponent("PointerLatency-\(UUID().uuidString)", isDirectory: true)
    try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
    return HostedBoard(store: try BoardStore(rootURL: fixture.root), scratch: scratch)
}

// MARK: - Event synthesis

/// Posts one mouse event into the application's queue — the queue, not `window.sendEvent`, so the
/// dequeue below stamps it as `NSApp.currentEvent` the way a real click is.
@MainActor
private func post(_ type: NSEvent.EventType, at p: NSPoint, in window: NSWindow, clicks: Int) {
    let event = NSEvent.mouseEvent(
        with: type, location: p, modifierFlags: [],
        timestamp: ProcessInfo.processInfo.systemUptime,
        windowNumber: window.windowNumber, context: nil,
        eventNumber: Int.random(in: 1...999_999), clickCount: clicks, pressure: 1
    )!
    NSApp.postEvent(event, atStart: false)
}

/// Drains the queue through the real dequeue-and-dispatch path, then gives SwiftUI a turn.
@MainActor
private func pump(_ seconds: TimeInterval) {
    let deadline = Date().addingTimeInterval(seconds)
    repeat {
        while let event = NSApp.nextEvent(matching: .any, until: .distantPast, inMode: .default, dequeue: true) {
            NSApp.sendEvent(event)
        }
        RunLoop.main.run(until: min(Date().addingTimeInterval(0.004), deadline))
    } while Date() < deadline
}

/// One full click — down then up, drained after each half.
@MainActor
private func click(at p: NSPoint, in window: NSWindow, clicks: Int = 1) {
    post(.leftMouseDown, at: p, in: window, clicks: clicks)
    pump(0.02)
    post(.leftMouseUp, at: p, in: window, clicks: clicks)
    pump(0.02)
}

/// Pumps until `condition` holds; returns elapsed ms, or nil on timeout.
@MainActor
private func waitFor(_ timeout: TimeInterval, condition: () -> Bool) -> Double? {
    let t0 = CACurrentMediaTime()
    while CACurrentMediaTime() - t0 < timeout {
        pump(0.004)
        if condition() { return (CACurrentMediaTime() - t0) * 1000 }
    }
    return nil
}

/// The menu-tracking observer's mailbox — statics because the notification closure is @Sendable.
private enum MenuProbe {
    nonisolated(unsafe) static var beganAt: CFTimeInterval?
}

// MARK: - Probe points

/// Lane 0's first card sits near the strip's top-leading corner; found empirically by the
/// investigation probe and pinned by `#require` on the selection it produces.
private let cardPoint = NSPoint(x: 90, y: 1000 - 90)

// MARK: - The pins

@MainActor
@Suite("Pointer latency on a hosted board", .serialized)
struct PointerLatencyTests {

    @Test("A click on a card selects it without the double-click wait")
    func cardClickSelectsWithoutTheDoubleClickWait() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let board = try host(fixture)
        let store = board.store

        post(.leftMouseDown, at: cardPoint, in: board.window, clicks: 1)
        pump(0.02)
        post(.leftMouseUp, at: cardPoint, in: board.window, clicks: 1)
        let latency = try #require(
            waitFor(2.0) { !store.selection.isEmpty },
            "the click never selected anything — did the layout move under the probe point?"
        )
        // Which card the point lands on is the masonry's business (column-major, two columns at
        // standard width) — what matters is that it is *a card*, instantly. Fixture card ids all
        // start with "2", lanes with "1".
        let selected = try #require(store.selection.ids.first)
        #expect(selected.rawValue.hasPrefix("2"),
                "the probe point should land on a card, selected \(store.selection.ids)")

        // The defect measured ~475 ms here — the system double-click interval leaking into every
        // single click. The bound is generous headroom over the healthy ~5 ms, far under the
        // interval it must never re-approach.
        #expect(latency < 250, "click → selection took \(Int(latency)) ms")
        print(String(format: "── click → selection: %.0f ms", latency))
    }

    @Test("A card double-click opens the card window and creates no placeholder")
    func cardDoubleClickOpensTheWindow() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let board = try host(fixture)
        let store = board.store

        click(at: cardPoint, in: board.window, clicks: 1)
        _ = waitFor(1.0) { !store.selection.isEmpty }
        let target = try #require(store.selection.ids.first,
                                  "the pair's first click should select the card under the point")
        click(at: cardPoint, in: board.window, clicks: 2)
        _ = waitFor(1.0) { !board.openedCards.isEmpty }
        board.settle(turns: 2)

        #expect(board.openedCards == [target],
                "the double-click should open exactly the clicked card, opened \(board.openedCards)")
        // The empty-space layer is a background sibling of the masonry, not the card's ancestor —
        // structurally, a card's clicks can never reach its create. This pins that structure.
        #expect(store.transient.newCardPlaceholder == nil,
                "a double-click on a card must not open the lane's placeholder")
        #expect(store.selection.ids.contains(target))
    }

    @Test("A right-click on the heels of a left click reaches its menu without the wait")
    func rightClickMenuAfterAClick() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let board = try host(fixture)
        let store = board.store

        let observer = NotificationCenter.default.addObserver(
            forName: NSMenu.didBeginTrackingNotification, object: nil, queue: nil
        ) { note in
            if MenuProbe.beganAt == nil { MenuProbe.beganAt = CACurrentMediaTime() }
            guard let menu = note.object as? NSMenu else { return }
            nonisolated(unsafe) let pending = menu
            // .common fires during menu tracking, which is what ends it — without this the
            // dispatch below never returns.
            let timer = Timer(timeInterval: 0.1, repeats: false) { _ in
                pending.cancelTrackingWithoutAnimation()
            }
            RunLoop.main.add(timer, forMode: .common)
        }
        defer { NotificationCenter.default.removeObserver(observer) }

        func rightClick(_ label: String) -> Double? {
            MenuProbe.beganAt = nil
            let armed = CACurrentMediaTime()
            post(.rightMouseDown, at: cardPoint, in: board.window, clicks: 1)
            pump(0.02)
            post(.rightMouseUp, at: cardPoint, in: board.window, clicks: 1)
            _ = waitFor(2.0) { MenuProbe.beganAt != nil }
            let latency = MenuProbe.beganAt.map { ($0 - armed) * 1000 }
            print(latency.map { String(format: "── right-click → menu (%@): %.0f ms", label, $0) }
                  ?? "── right-click → menu (\(label)): NEVER")
            return latency
        }

        // The first menu open in a process pays a one-time AppKit warmup (~350 ms) — spent here,
        // unasserted, so the pinned figures below measure the steady state users live in.
        _ = rightClick("cold, first menu in the process")
        pump(1.5)
        for gap in [0.1, 0.4, 0.8] {
            click(at: cardPoint, in: board.window, clicks: 1)
            _ = waitFor(1.0) { !store.selection.isEmpty }
            pump(gap)
            let label = String(format: "%.1f s after a left click", gap)
            let latency = try #require(rightClick(label), "the context menu never began tracking")
            // ~464 ms before the fix: pending click disambiguation deferred the menu too.
            #expect(latency < 250, "right-click → menu (\(label)) took \(Int(latency)) ms")
            store.clearSelection()
            pump(1.2)
        }
    }
}
