import CoreGraphics
import Foundation
import SwiftUI
import Testing
@testable import Kanban

/// A zoom store on a scratch defaults domain — the level is app-wide and persisted, so a suite that
/// used `.standard` would zoom the developer's own boards.
@MainActor
private func makeStore(seeding stored: Double? = nil) -> (BoardZoomStore, UserDefaults, () -> Void) {
    let name = "dev.rzen.indie.Kanban.zoom-tests.\(UUID().uuidString)"
    let defaults = UserDefaults(suiteName: name)!
    if let stored { defaults.set(stored, forKey: AppPreferences.boardZoomLevelKey) }
    return (BoardZoomStore(defaults: defaults), defaults,
            { UserDefaults.standard.removePersistentDomain(forName: name) })
}

/// The standard system body size. Written down rather than read, so every claim below is a fact about
/// the arithmetic and not about the machine the suite happens to run on.
private let standardBody: CGFloat = 13

// MARK: - The ladder

@Suite("Zoom ▸ the ladder")
struct BoardZoomLadderTests {

    @Test("The rungs ascend, and 100% is one of them")
    func rungsAscend() {
        #expect(BoardZoom.levels == BoardZoom.levels.sorted())
        #expect(Set(BoardZoom.levels).count == BoardZoom.levels.count, "no rung appears twice")
        #expect(BoardZoom.levels.contains(BoardZoom.actualSize), "Actual Size must be reachable by stepping")
        #expect(BoardZoom.levels.allSatisfy { $0 > 0 })
    }

    /// A held ⌘+ must settle rather than cycle, so both ends are fixed points.
    @Test("Stepping walks the ladder and stops at each end")
    func steppingTerminates() {
        var level = BoardZoom.levels.first!
        for expected in BoardZoom.levels.dropFirst() {
            level = BoardZoom.stepIn(from: level)
            #expect(level == expected)
        }
        #expect(BoardZoom.stepIn(from: level) == level, "the top rung is a fixed point")

        for expected in BoardZoom.levels.dropLast().reversed() {
            level = BoardZoom.stepOut(from: level)
            #expect(level == expected)
        }
        #expect(BoardZoom.stepOut(from: level) == level, "the bottom rung is a fixed point")
    }

    /// ⌘+ then ⌘− lands back exactly where it started — the whole reason the ladder is discrete
    /// rather than a percentage field.
    @Test("In then out is a round trip from every interior rung")
    func inThenOutRoundTrips() {
        for level in BoardZoom.levels.dropFirst().dropLast() {
            #expect(BoardZoom.stepOut(from: BoardZoom.stepIn(from: level)) == level)
            #expect(BoardZoom.stepIn(from: BoardZoom.stepOut(from: level)) == level)
        }
    }

    @Test("The can-step predicates agree with what stepping actually does")
    func predicatesMatchStepping() {
        for level in BoardZoom.levels {
            #expect(BoardZoom.canZoomIn(level) == (BoardZoom.stepIn(from: level) != level))
            #expect(BoardZoom.canZoomOut(level) == (BoardZoom.stepOut(from: level) != level))
        }
        #expect(!BoardZoom.canZoomIn(BoardZoom.levels.last!))
        #expect(!BoardZoom.canZoomOut(BoardZoom.levels.first!))
        #expect(BoardZoom.isActualSize(BoardZoom.actualSize))
    }
}

// MARK: - Reading a stored level

/// `normalize` is the one gate every persisted level passes through, and the reason it exists is that
/// the preference is a plain `UserDefaults` scalar a user can `defaults write` to anything.
@Suite("Zoom ▸ normalising a stored level")
struct BoardZoomNormalizeTests {

    @Test("Every rung survives a round trip")
    func rungsAreFixedPoints() {
        for level in BoardZoom.levels {
            #expect(BoardZoom.normalize(Double(level)) == level)
        }
    }

    /// The trap this exists for: `double(forKey:)` answers 0 for a key nobody ever set, and an
    /// unfiltered 0 would drive every `BoardMetrics.em` multiple to its 1pt floor.
    @Test("Zero is not a level, and never becomes one")
    func zeroIsClamped() {
        let level = BoardZoom.normalize(0)
        #expect(BoardZoom.levels.contains(level))
        #expect(level == BoardZoom.levels.first!)
        #expect(BoardZoom.bodyPointSize(system: standardBody, level: level) > 1)
    }

    @Test("Garbage lands on a legal rung")
    func garbageIsClamped() {
        #expect(BoardZoom.levels.contains(BoardZoom.normalize(-4)))
        #expect(BoardZoom.levels.contains(BoardZoom.normalize(99)))
        #expect(BoardZoom.normalize(99) == BoardZoom.levels.last!)
        #expect(BoardZoom.normalize(-4) == BoardZoom.levels.first!)
    }

    /// Not a number has no honest nearest rung, so it resolves to the one answer that cannot
    /// surprise.
    @Test("Non-finite input resolves to Actual Size")
    func nonFiniteIsActualSize() {
        #expect(BoardZoom.normalize(.nan) == BoardZoom.actualSize)
        #expect(BoardZoom.normalize(.infinity) == BoardZoom.actualSize)
        #expect(BoardZoom.normalize(-.infinity) == BoardZoom.actualSize)
    }

    /// A build that shortened the ladder under a value an older one wrote — snapped, not refused.
    @Test("An off-ladder value snaps to its nearest rung")
    func offLadderSnaps() {
        #expect(BoardZoom.normalize(1.07) == 1.0)
        #expect(BoardZoom.normalize(1.12) == 1.15)
        #expect(BoardZoom.normalize(1.9) == 2.0)
    }
}

// MARK: - What a level means

@Suite("Zoom ▸ what a level means")
struct BoardZoomMeaningTests {

    /// The founding guarantee: Actual Size draws pixel-for-pixel what the board drew before zoom
    /// existed.
    @Test("Actual Size is the system's own ruler, exactly")
    func actualSizeIsTheSystemRuler() {
        #expect(BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.actualSize) == standardBody)
        #expect(BoardZoom.bodyPointSize(system: 18, level: BoardZoom.actualSize) == 18)
    }

    /// The other half of that guarantee: a relative text style stays a relative text style, carrying
    /// the system's own leading and traits, until a deliberate zoom trades it away.
    @Test("Actual Size hands back the relative style itself")
    @MainActor
    func actualSizeKeepsRelativeStyles() {
        #expect(BoardZoom.font(.body, level: BoardZoom.actualSize) == .body)
        #expect(BoardZoom.font(.caption, level: BoardZoom.actualSize) == .caption)
        #expect(BoardZoom.font(.headline, level: BoardZoom.actualSize) == .headline)
        #expect(BoardZoom.font(.caption2, level: BoardZoom.actualSize) == .caption2)
    }

    @Test("Off the default rung, the style is scaled rather than kept")
    @MainActor
    func zoomedStylesAreScaled() {
        #expect(BoardZoom.font(.body, level: 2.0) != .body)
        #expect(BoardZoom.font(.caption, level: 0.75) != .caption)
    }

    @Test("The level scales the ruler monotonically")
    func levelScalesTheRuler() {
        let sizes = BoardZoom.levels.map { BoardZoom.bodyPointSize(system: standardBody, level: $0) }
        #expect(sizes == sizes.sorted())
        #expect(sizes.first! < standardBody)
        #expect(sizes.last! > standardBody)
    }

    /// Deliberately unrounded: `BoardMetrics.em` already rounds every figure it produces, and
    /// rounding here would round twice on two different rulers.
    @Test("The ruler is not pre-rounded")
    func rulerIsNotRounded() {
        #expect(BoardZoom.bodyPointSize(system: 13, level: 1.15) == 13 * 1.15)
    }

    /// A level cannot be trusted to be positive until `normalize` has seen it, and no proposal
    /// downstream may be zero or negative.
    @Test("The ruler is floored at one point")
    func rulerIsFloored() {
        #expect(BoardZoom.bodyPointSize(system: 13, level: 0) == 1)
        #expect(BoardZoom.bodyPointSize(system: 13, level: -2) == 1)
    }

    @Test("The percent label is the announcement's value")
    func percentLabel() {
        #expect(BoardZoom.percentLabel(1.0) == "100%")
        #expect(BoardZoom.percentLabel(1.15) == "115%")
        #expect(BoardZoom.percentLabel(0.75) == "75%")
        #expect(BoardZoom.percentLabel(2.0) == "200%")
        #expect(AccessibilityPhrases.zoomLevel(BoardZoom.percentLabel(1.3)) == "Zoom 130%")
    }
}

// MARK: - Every board metric follows the ruler

/// The point of the whole feature: `BoardMetrics` is already parameterised on the body point size, so
/// moving the level moves every figure the strip draws. Asserted over the table rather than per
/// figure, so a metric added later is covered the day it lands.
@Suite("Zoom ▸ the metrics follow the level")
struct BoardZoomMetricsTests {

    /// Every em-derived figure on the board, by name — `VisualAccommodationsTests`' own inventory
    /// shape, which is how a new metric gets caught by this suite without anyone remembering to add
    /// it to two lists.
    private var figures: [(String, (CGFloat) -> CGFloat)] { [
        ("stripGap", { BoardMetrics.stripGap(bodyPointSize: $0) }),
        ("laneCornerRadius", { BoardMetrics.laneCornerRadius(bodyPointSize: $0) }),
        ("lanePlatePadding", { BoardMetrics.lanePlatePadding(bodyPointSize: $0) }),
        ("laneStackSpacing", { BoardMetrics.laneStackSpacing(bodyPointSize: $0) }),
        ("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
        ("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
        ("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }),
        ("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }),
        ("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
        ("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
        ("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
        ("cardHeroHeight", { BoardMetrics.cardHeroHeight(bodyPointSize: $0) }),
        ("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
        ("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
        ("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(bodyPointSize: $0) }),
        ("trashHatchSpacing", { BoardMetrics.trashHatchSpacing(bodyPointSize: $0) }),
    ] }

    @Test("Zooming in grows every figure; zooming out shrinks every figure")
    func figuresFollowTheLevel() {
        let resting = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.actualSize)
        let zoomedIn = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.levels.last!)
        let zoomedOut = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.levels.first!)

        for (name, figure) in figures {
            #expect(figure(zoomedIn) > figure(resting), "\(name) must grow when the board zooms in")
            #expect(figure(zoomedOut) < figure(resting), "\(name) must shrink when the board zooms out")
        }
    }

    @Test("Every figure stays a whole point, and at least one, at every rung")
    func figuresStayLegalAtEveryRung() {
        for level in BoardZoom.levels {
            let size = BoardZoom.bodyPointSize(system: standardBody, level: level)
            for (name, figure) in figures {
                let value = figure(size)
                #expect(value >= 1, "\(name) collapsed at \(BoardZoom.percentLabel(level))")
                #expect(value == value.rounded(), "\(name) is not a whole point at \(BoardZoom.percentLabel(level))")
            }
        }
    }

    /// **The no-horizontal-scroll invariant survives every rung** (03-board-ui.md ▸ Layout). Zoom
    /// moves the gap, so the lanes narrow; what it must never do is make the strip want more room
    /// than the window has.
    @Test("The strip still exactly fills its window at every rung")
    func stripStillFillsAtEveryRung() {
        let stripWidth: CGFloat = 1400
        for level in BoardZoom.levels {
            let gap = BoardMetrics.stripGap(
                bodyPointSize: BoardZoom.bodyPointSize(system: standardBody, level: level))
            for units in 1...8 {
                let standard = LaneLayoutMath.standardWidth(
                    stripWidth: stripWidth, totalUnits: units, gap: gap)
                #expect(standard > 0)
                let drawn = standard * CGFloat(units) + gap * CGFloat(units + 1)
                #expect(abs(drawn - stripWidth) < 0.001,
                        "\(units) units at \(BoardZoom.percentLabel(level)) does not fill the strip")
            }
        }
    }

    /// Zoom in and the lanes narrow — the honest consequence of a board that refuses horizontal
    /// scroll, and the one thing about this feature a user could be surprised by. Pinned so it stays
    /// a decision rather than becoming a bug report.
    @Test("Zooming in narrows the lanes rather than widening the strip")
    func zoomingInNarrowsLanes() {
        let stripWidth: CGFloat = 1400
        let resting = LaneLayoutMath.standardWidth(
            stripWidth: stripWidth, totalUnits: 4,
            gap: BoardMetrics.stripGap(bodyPointSize: standardBody))
        let zoomed = LaneLayoutMath.standardWidth(
            stripWidth: stripWidth, totalUnits: 4,
            gap: BoardMetrics.stripGap(
                bodyPointSize: BoardZoom.bodyPointSize(system: standardBody, level: 2.0)))

        #expect(zoomed < resting)
        // …but only by the gap's growth, which is a few percent — not by the level.
        #expect(zoomed > resting * 0.9, "the narrowing is the gap's, not the level's")
    }
}

// MARK: - The persisted level

@Suite("Zoom ▸ the persisted level")
@MainActor
struct BoardZoomStoreTests {

    @Test("A fresh domain opens at Actual Size")
    func freshDomainIsActualSize() {
        let (store, _, tearDown) = makeStore()
        defer { tearDown() }

        #expect(store.level == BoardZoom.actualSize)
        #expect(store.isActualSize)
    }

    @Test("A level survives the trip through defaults")
    func levelPersists() {
        let (store, defaults, tearDown) = makeStore()
        defer { tearDown() }

        store.zoomIn()
        let level = store.level
        #expect(level != BoardZoom.actualSize)

        let reopened = BoardZoomStore(defaults: defaults)
        #expect(reopened.level == level)
    }

    /// The stored scalar is a plain preference a user can hand-edit; whatever is in there, the store
    /// opens on a rung.
    @Test("A hand-edited preference still opens on a rung")
    func handEditedPreferenceIsNormalised() {
        for stored in [0, -1, 1.07, 42] as [Double] {
            let (store, _, tearDown) = makeStore(seeding: stored)
            defer { tearDown() }
            #expect(BoardZoom.levels.contains(store.level), "\(stored) opened off the ladder")
        }
    }

    @Test("The three moves walk the ladder and hold at its ends")
    func movesWalkTheLadder() {
        let (store, _, tearDown) = makeStore()
        defer { tearDown() }

        for _ in BoardZoom.levels { store.zoomIn() }
        #expect(store.level == BoardZoom.levels.last!)
        #expect(!store.canZoomIn)

        for _ in BoardZoom.levels { store.zoomOut() }
        #expect(store.level == BoardZoom.levels.first!)
        #expect(!store.canZoomOut)

        store.actualSize()
        #expect(store.level == BoardZoom.actualSize)
        #expect(store.isActualSize)
    }

    /// The single write path the menu rows and the toolbar buttons share, so the two faces of one
    /// command can never drift (`BoardStore.setTrashVisible`'s rule).
    @Test("The shared step is the same walk")
    func sharedStepIsTheSameWalk() {
        let (store, _, tearDown) = makeStore()
        defer { tearDown() }

        store.step(.in)
        #expect(store.level == BoardZoom.stepIn(from: BoardZoom.actualSize))
        store.step(.out)
        #expect(store.level == BoardZoom.actualSize)
        store.step(.in)
        store.step(.actualSize)
        #expect(store.level == BoardZoom.actualSize)
    }

    @Test("The context carries the level to the strip")
    func contextCarriesTheLevel() {
        let (store, _, tearDown) = makeStore()
        defer { tearDown() }

        store.zoomIn()
        #expect(store.context.level == store.level)
        #expect(store.context == BoardZoomContext(level: store.level))
        #expect(BoardZoomContext.actualSize.level == BoardZoom.actualSize)
    }
}

// MARK: - The commands' scope

@Suite("Zoom ▸ the commands' scope")
@MainActor
struct ZoomCommandScopeTests {

    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
    }

    /// The rows are board-scoped: the level is app-wide, but only board windows draw with it, so a
    /// ⌘+ over a card window or the welcome screen would be a command that silently missed.
    @Test("No board window, no zoom")
    func withoutABoardTheRowsAreDead() {
        #expect(!ZoomCommands.isEnabled(store: nil, session: DragSession()))
    }

    @Test("A board window in front, and nothing in flight, is live")
    func withABoardTheRowsAreLive() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        #expect(ZoomCommands.isEnabled(store: store, session: DragSession()))
    }

    /// A drag freezes geometry the level feeds — the run's heights, and `RestingLayoutCache`, whose
    /// entry key does not include the point size. The guard states that rather than relying on the
    /// AppKit drag loop happening to swallow the chord.
    @Test("A drag in flight holds all three rows shut")
    func aDragHoldsTheRowsShut() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true)

        let cards = DragSession()
        cards.beginCards([ItemID(rawValue: Ident.card1)], folders: [folder], heights: [44],
                         container: .board, source: store)
        #expect(!ZoomCommands.isEnabled(store: store, session: cards))

        let lanes = DragSession()
        lanes.beginLanes([ItemID(rawValue: Ident.lane1)], folders: [folder], units: [1], source: store)
        #expect(!ZoomCommands.isEnabled(store: store, session: lanes))
    }

    /// Zooming is a view change, not a mutation — `ShowTrashCommand`'s rule. So the rows are not
    /// gated on `acceptsBoardMutations`, which an open inline editor is enough to falsify: a user
    /// naming a new card can still make the board bigger to read it.
    @Test("A board that refuses mutations still zooms")
    func mutationRefusalDoesNotCloseTheRows() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: ItemID(rawValue: Ident.lane1))
        #expect(!store.acceptsBoardMutations, "the fixture must actually be refusing mutations")
        #expect(ZoomCommands.isEnabled(store: store, session: DragSession()))
    }
}
