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

/// **What a marquee drag sample costs the renderer** — the regression suite for the selection fix
/// (2026-08-07), grown out of the investigation that found it.
///
/// `MarqueeControl.gesture`'s `onChanged` calls `store.select(ids, …)` on EVERY mouse sample,
/// whether or not the swept set changed, and `TransientBoardState.select` writes its `@Observable`
/// properties unconditionally — Observation notifies on every set, equal or not. So a band drag is a
/// stream of selection changes at pointer rate, and whatever one selection change costs the board,
/// the band pays it sixty times a second.
///
/// The investigation measured that cost at **180 card bodies and ~85 ms per sample** on the 6×30
/// fixture below (515 bodies, ~233 ms on a real 515-card board, debug): every face on the board,
/// every sample, because `CardFaceView.body` read `store.selection` for its own `isSelected` and
/// Observation tracks whole properties. `.equatable()` could not help — a direct Observation
/// invalidation never consults the gate (RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card
/// bodies).
///
/// The fix made selected-ness a **compared parameter**: `LaneView` and `TrashLaneView` hoist one
/// selection read per body and hand each face `isSelected`/`selectedCount`, so a selection change
/// re-runs the lane bodies that were subscribed anyway plus the faces whose flag actually flipped.
/// These tests pin that shape — a growing band pays per *crossing*, not per card on the board — and
/// the redundant streams pin the other half: a sample that changes nothing costs nothing.
///
/// The prints stay: a budget says whether the shape held, and the numbers beside it say by how much.

// MARK: - Fixture (BoardRenderPerformanceTests' shape)

private let laneCount = 6
private let cardsPerLane = 30

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: "Marquee Cost Board")
    for lane in 0..<laneCount {
        try fixture.lane(laneName(lane), order: "\((lane + 1) * 1024)", title: "Lane \(lane)")
        for card in 0..<cardsPerLane {
            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)

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 let scratch: URL
    private let preferencesDomain: String

    init(store: BoardStore, scratch: URL) {
        self.store = store
        self.scratch = scratch
        preferencesDomain = "dev.rzen.indie.Kanban.marquee-cost.\(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
        let root = ZoomedBoard(
            store: store,
            window: { [weak window] in window },
            confirmations: TrashConfirmations(),
            openCard: { _ in },
            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)
        settle()
    }

    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()
        }
    }

    /// One drag-sample's worth of settling: a single runloop turn and display pass, which is what
    /// the real event stream gets between two mouseDragged deliveries.
    func settleOnce() {
        RunLoop.main.run(until: Date().addingTimeInterval(0.001))
        view.layoutSubtreeIfNeeded()
        window.displayIfNeeded()
    }
}

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

// MARK: - The measurements

@MainActor
@Suite("Marquee drag-sample render cost", .serialized)
struct MarqueeRenderCostTests {

    @Test("A stream of redundant selects — the marquee's steady state between card crossings")
    func redundantSelectStream() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let board = try host(fixture)
        let store = board.store

        // The band is sweeping ten cards and the cursor is moving inside the same footprint —
        // every sample recomputes the same set, exactly as MarqueeControl.gesture does today.
        let swept = Set((0..<10).map { ItemID(rawValue: cardName(1, $0)) })

        // First select: the legitimate change. Not measured here.
        store.select(swept, in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
        board.settle()

        let samples = 30
        BoardRenderMetrics.reset()
        let t0 = CACurrentMediaTime()
        for _ in 0..<samples {
            store.select(swept, in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
            board.settleOnce()
        }
        let elapsed = (CACurrentMediaTime() - t0) * 1000

        let cards = BoardRenderMetrics.cardBodyEvaluations
        let containers = BoardRenderMetrics.containerBodyEvaluations
        let strips = BoardRenderMetrics.stripBodyEvaluations
        print(String(
            format: "── %d redundant selects — %d card bodies, %d containers, %d strips, %.1f ms total (%.2f ms/sample)",
            samples, cards, containers, strips, elapsed, elapsed / Double(samples)
        ))

        // **Zero, measured — and the fix must not regress it.** A redundant select assigns the same
        // value, so every face's `isSelected`/`selectedCount` comes back identical and the gate
        // suppresses the lot; the lane bodies re-run (they are subscribed to the selection) and stop
        // there. This was already 0 before the change, for a different reason — the faces were
        // invalidated directly but SwiftUI found their output unchanged — so it is a tripwire on the
        // steady state rather than a new win.
        #expect(cards == 0, "a stream of redundant selects re-ran \(cards) card bodies")
    }

    @Test("A stream of growing selects — the band crossing one card per sample")
    func growingSelectStream() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let board = try host(fixture)
        let store = board.store

        let samples = 20
        var swept: Set<ItemID> = [ItemID(rawValue: cardName(1, 0))]
        store.select(swept, in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
        board.settle()

        BoardRenderMetrics.reset()
        let t0 = CACurrentMediaTime()
        for i in 1...samples {
            swept.insert(ItemID(rawValue: cardName(1, i % cardsPerLane)))
            store.select(swept, in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
            board.settleOnce()
        }
        let elapsed = (CACurrentMediaTime() - t0) * 1000

        let cards = BoardRenderMetrics.cardBodyEvaluations
        print(String(
            format: "── %d growing selects — %d card bodies, %.1f ms total (%.2f ms/sample)",
            samples, cards, elapsed, elapsed / Double(samples)
        ))

        // Something has to repaint: each sample adds one card to the band, and that card's face has
        // to grow an accent ring.
        #expect(cards > 0, "a growing band repainted nothing")

        // **The regression pin, and what it is a budget *of*.** A sample re-runs the faces whose
        // parameters moved, and a growing band moves two things: the newcomer's `isSelected`, and
        // `selectedCount` for everyone already in the band — the drag replica's fan and count badge
        // are drawn from it, so a card that now travels with five others is genuinely a different
        // face than one that travelled with four (`CardFaceView.dragReplica`). So the floor is the
        // **selection's own running size, summed over the stream** — 2 members after the first
        // sample, \(samples + 1) after the last — and not one flip per sample.
        //
        // That is the shape the fix bought: the cost follows what the user has selected, not what the
        // board holds. Before it, every sample re-ran all \(laneCount * cardsPerLane) faces on the
        // board whatever the band had swept — 3,600 bodies over this stream, five times the budget
        // below and independent of the selection entirely.
        //
        // The ×3 is `BoardRenderPerformanceTests`' slack rationale: SwiftUI evaluates a body more
        // than once per update, so a changed face is worth more than one count. Measured at exactly
        // the floor today.
        let selectionWork = (2...(samples + 1)).reduce(0, +)
        #expect(cards <= selectionWork * 3,
                "\(samples) growing selects cost \(cards) card bodies — floor \(selectionWork), budget \(selectionWork * 3)")
    }

    @Test("A stream of redundant clears — the band sweeping empty space")
    func redundantClearStream() throws {
        let fixture = try makeFixture()
        defer { fixture.tearDown() }
        let board = try host(fixture)
        let store = board.store

        store.clearSelection()
        board.settle()

        let samples = 30
        BoardRenderMetrics.reset()
        let t0 = CACurrentMediaTime()
        for _ in 0..<samples {
            store.clearSelection()
            board.settleOnce()
        }
        let elapsed = (CACurrentMediaTime() - t0) * 1000

        let cards = BoardRenderMetrics.cardBodyEvaluations
        print(String(
            format: "── %d redundant clears — %d card bodies, %.1f ms total (%.2f ms/sample)",
            samples, cards, elapsed, elapsed / Double(samples)
        ))

        // Zero, measured — `redundantSelectStream`'s rule over an empty selection, which is the band
        // sweeping the gutter between two lanes.
        #expect(cards == 0, "a stream of redundant clears re-ran \(cards) card bodies")
    }
}
