import CoreGraphics
import Foundation
import Testing
@testable import Kanban

/// **The lane-resize release hold** (`LaneWidthHold`, `LaneResizeSession.end`): the bridge across
/// the one-way flow's round trip between a release and the reload that carries its write.
///
/// The bug these pin is a visible two-step. The drag grows the *window* one standard-plus-gap per
/// tick and freezes the strip's standard so the siblings never move; the release then writes the new
/// unit count, which takes a Writer → disk → watcher → reload round trip to reach the snapshot. A
/// session that cleared at the release handed the strip back to
/// `LaneLayoutMath.standardWidth(stripWidth:totalUnits:gap:)` for that whole stretch — the *grown*
/// window divided by the *stale* unit total — so every lane took a proportionally wrong width and
/// the board jumped a second time when the echo landed.
///
/// Fixture geometry is `LaneLayoutMathTests`': a frozen standard of 100 and a gap of 12, so the slot
/// widths are 1× = 100 · 2× = 212 · 3× = 324, and the tick-down thresholds are 214 (3→2) and 102
/// (2→1). Every drag here **shrinks**, which keeps the arithmetic the frozen standard's throughout:
/// with no `NSWindow` to measure, the on-screen fit is the count the drag started from
/// (`LaneResizeSession.fittingMaxUnits`), so growth would land in the re-divide regime — that is the
/// suite at the foot of this file, and direction is nothing to the hold's state machine, which is
/// what is under test here.

private let standard: CGFloat = 100
private let gap: CGFloat = 12

/// The fixture strip's whole divide — the 1× lane plus the 3× one, trash hidden.
private let boardUnits = 4

/// Enough leftward translation from a 3× start to land on 2×, and on 1×: 324 − 120 = 204, under the
/// 214 threshold and over the 102 one; 324 − 240 rubber-bands to 96, under both.
private let toTwoUnits: CGFloat = -120
private let toOneUnit: CGFloat = -240

private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)

private func laneText(order: String, title: String, width: String) -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    width: \(width)
    created: 2026-01-01T09:00:00Z
    ---
    \(title) body.

    """
}

/// One plain lane (no `width`, so 1×) and one at 3× — the one every drag below shrinks.
@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.lane2, laneText(order: "2048", title: "Doing", width: "3"))
    return fixture
}

@MainActor
@Suite("The lane-resize release hold")
struct LaneResizeHoldTests {

    // MARK: - Harness

    /// One walk through the store's inbound door — the echo, as the watcher delivers it.
    private func reload(_ store: BoardStore, _ origin: WatchOrigin = .appMediated) async {
        store.handleWatcherEvent(.treeChanged(origin))
        await store.awaitQuiescence()
    }

    /// A session mid-drag on lane two, `translation` points to the left of its 3× start.
    private func dragging(_ translation: CGFloat) -> LaneResizeSession {
        let session = LaneResizeSession()
        session.begin(laneID: lane2, units: 3, standard: standard, gap: gap,
                      totalUnits: boardUnits, window: nil)
        session.update(translation: translation)
        return session
    }

    /// A session that has released onto `store` and is holding the width it wrote.
    private func settled(on store: BoardStore, translation: CGFloat = toTwoUnits) -> LaneResizeSession {
        let session = dragging(translation)
        session.end { id, units in store.setLaneWidth(id, units: units) }
        return session
    }

    private func lane(_ id: String, in store: BoardStore) throws -> Lane {
        try #require(store.snapshot.lanes.first { $0.id.rawValue == id })
    }

    /// Polls for `condition`, because the deadline's dissolve is a `Task` on this very actor: the
    /// test has to yield for it to run at all. Bounded, so a dissolve that never comes fails rather
    /// than hangs.
    private func settles(_ condition: () -> Bool) async -> Bool {
        for _ in 0..<200 {
            if condition() { return true }
            try? await Task.sleep(for: .milliseconds(5))
        }
        return condition()
    }

    // MARK: - Entering the hold

    @Test("The release holds instead of clearing — the frozen standard and the written units keep governing")
    func releaseEntersTheHold() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        let session = settled(on: store)

        #expect(session.isSettled)
        #expect(session.hold == LaneWidthHold(laneID: lane2, units: 2))
        // Still governing — which is the whole fix. `isActive` is what `BoardView.standardWidth`
        // reads to keep the frozen standard in force, and `governs` is what the lane slot reads.
        #expect(session.isActive)
        #expect(session.governs(lane2))
        #expect(session.standard == standard)
        // …but no longer *dragging*: no mouse is holding this.
        #expect(!session.isDragging)
        #expect(!session.isDragging(lane2))

        // The live width comes to rest on the written count's slot, measured against the frozen
        // standard: the last sub-tick of overflow animates away and nothing else moves.
        #expect(session.liveWidth == LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap))

        // The snapshot is a round trip behind, and this is precisely the window in which reading it
        // would draw the pre-drag layout.
        let stale = try lane(Ident.lane2, in: store)
        #expect(LaneLayoutMath.displayUnits(of: stale) == 3)
        #expect(session.displayUnits(of: stale) == 2, "the written count answers, not the stale one")
        #expect(session.displayUnits(of: try lane(Ident.lane1, in: store)) == 1,
                "a lane the session does not govern still reads from the snapshot")
    }

    @Test("A drag that ends where it started writes nothing and holds nothing")
    func anUnchangedReleaseNeverArms() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // 3× released at 3×: `setLaneWidth`'s unchanged-units guard refuses it, so there is no write,
        // no reload and therefore no echo — an overlay armed here would only ever time out.
        let session = dragging(0)
        session.end { id, units in store.setLaneWidth(id, units: units) }

        #expect(!session.isSettled)
        #expect(!session.isActive)
        #expect(session.liveWidth == 0)
    }

    @Test("A refused write dissolves the session outright — the hold must not outlive it")
    func aRefusedWriteNeverArms() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.enterVanishedRootLock()

        let session = settled(on: store)

        // Nothing reached disk, so nothing is coming back: the snapshot takes the layout straight
        // back and the lane returns to its pre-drag width, which is the truth (and the banner row is
        // already saying why).
        #expect(!session.isSettled)
        #expect(!session.isActive)
        #expect(try lane(Ident.lane2, in: store).width == .valid(3))
    }

    // MARK: - The hand-off

    @Test("Only a snapshot carrying the written width releases the hold")
    func theEchoReleasesIt() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = settled(on: store)

        session.handOff(against: store.snapshot.lanes)
        #expect(session.isSettled, "a snapshot that still says the old width is not the echo")

        await reload(store)
        #expect(try lane(Ident.lane2, in: store).width == .valid(2))
        #expect(store.snapshotGeneration == 1)

        session.handOff(against: store.snapshot.lanes)
        #expect(!session.isSettled)
        #expect(!session.isActive)
        #expect(session.liveWidth == 0)
    }

    @Test("A reload that does not carry the written width leaves the hold standing, whatever else moved")
    func anUnrelatedReloadDoesNotRelease() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = settled(on: store)

        // Somebody else got there first: another lane renamed, and this lane's width put back where
        // it was. The walk lands and moves the generation — which is why this hold is retired by a
        // *value* and not by a counter, unlike the drag's `CommittedHold`. Handing the strip back on
        // the bump alone would divide the already-grown window by the unit total the board still
        // has, which is the two-step the hold exists to remove.
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Renamed"))
        try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3"))
        await reload(store, .foreign)
        #expect(store.snapshotGeneration == 1)
        #expect(try lane(Ident.lane2, in: store).width == .valid(3))

        session.handOff(against: store.snapshot.lanes)
        #expect(session.isSettled)
        #expect(session.governs(lane2))

        // And the deadline is what ends it, since the width it named is never coming.
        session.expire(try #require(session.hold))
        #expect(!session.isActive)
    }

    @Test("A landed walk that assigns nothing still releases a hold the current snapshot satisfies")
    func aValueEqualWalkReleasesIt() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = settled(on: store)

        // The echo lands and is applied, but nobody asks the hold about it yet; then a second walk
        // lands over the same tree. That one comes back value-equal, so it skips the assignment and
        // moves no `snapshotGeneration` — a hold watching for a *bump* would sit out its whole
        // deadline over a snapshot that already agrees with it.
        await reload(store)
        await reload(store)
        #expect(store.landedReloads == 2)
        #expect(store.snapshotGeneration == 1, "the second walk had nothing to assign")

        session.handOff(against: store.snapshot.lanes)
        #expect(!session.isSettled)
        #expect(!session.isActive)
    }

    @Test("A width landing on one is echoed by an ABSENT key, and that releases the hold")
    func theOneUnitEchoIsAnAbsentKey() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        let session = settled(on: store, translation: toOneUnit)
        #expect(session.hold == LaneWidthHold(laneID: lane2, units: 1))

        await reload(store)
        // The remove-at-default rule: there is no `width: 1` on disk to compare against, so the
        // condition can only ever be the *display* reading of an absent key.
        #expect(try lane(Ident.lane2, in: store).width.isMissing)

        session.handOff(against: store.snapshot.lanes)
        #expect(!session.isSettled)
        #expect(!session.isActive)
    }

    @Test("A lane that vanished under the gesture retires the hold — the snapshot is the authority")
    func aVanishedLaneRetiresIt() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = settled(on: store)

        session.handOff(against: store.snapshot.lanes.filter { $0.id != lane2 })

        #expect(!session.isSettled)
        #expect(!session.isActive)
    }

    /// The condition as a value, away from the session — both traps in one place.
    @Test("The hold's condition reads the width exactly as the board does")
    func theConditionIsTheDisplayReading() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let atThree = try lane(Ident.lane2, in: store)
        let keyless = try lane(Ident.lane1, in: store)

        #expect(!LaneWidthHold(laneID: lane2, units: 2).isRetired(by: [atThree]))
        #expect(LaneWidthHold(laneID: lane2, units: 3).isRetired(by: [atThree]))
        // A lane with no `width` key at all displays one unit, which is what a 1× write leaves
        // behind — and is therefore the echo of one.
        #expect(LaneWidthHold(laneID: lane1, units: 1).isRetired(by: [keyless]))
        #expect(!LaneWidthHold(laneID: lane1, units: 2).isRetired(by: [keyless]))
        #expect(LaneWidthHold(laneID: lane2, units: 2).isRetired(by: []))
    }

    // MARK: - The deadline

    @Test("A hold with no echo coming times out, and the snapshot takes the layout back")
    func theDeadlineDissolvesIt() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        let session = dragging(toTwoUnits)
        // The seam: the real figure is `LaneWidthHold.timeout`, and waiting it out would be 1.5 s of
        // wall clock in the suite for a claim about the dissolve rather than about the clock.
        session.holdTimeout = .milliseconds(20)
        session.end { id, units in store.setLaneWidth(id, units: units) }
        #expect(session.isSettled)

        #expect(await settles { !session.isSettled }, "a hold with no hand-off coming must dissolve")
        #expect(!session.isActive)
        #expect(session.liveWidth == 0)
    }

    @Test("The dissolve ends the hold it was armed for, and no other")
    func theDissolveEndsOnlyItsOwnHold() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = settled(on: store)
        let hold = try #require(session.hold)

        session.expire(LaneWidthHold(laneID: lane1, units: 9))
        #expect(session.isSettled, "a hold this session is not holding is not this session's to end")

        session.expire(hold)
        #expect(!session.isSettled)
        #expect(!session.isActive)
    }

    @Test("A second drag supersedes a standing hold, and the retired deadline never reaches it")
    func aSecondDragSupersedesTheHold() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        let session = dragging(toTwoUnits)
        session.holdTimeout = .milliseconds(20)
        session.end { id, units in store.setLaneWidth(id, units: units) }

        // The user grabs the same edge again before the echo. The anchor is what is on screen — the
        // width the release wrote — not the snapshot's, which is still a round trip behind.
        let onScreen = session.displayUnits(of: try lane(Ident.lane2, in: store))
        #expect(onScreen == 2)
        session.begin(laneID: lane2, units: onScreen, standard: standard, gap: gap,
                      totalUnits: boardUnits, window: nil)

        #expect(!session.isSettled)
        #expect(session.isDragging(lane2))
        #expect(session.units == 2)

        try? await Task.sleep(for: .milliseconds(80))
        #expect(session.isDragging(lane2), "the retired hold's deadline must not end the drag that followed it")
    }

    @Test("A hold ignores drag updates — no mouse is driving it")
    func theHoldTakesNoTranslations() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let session = settled(on: store)

        session.update(translation: toOneUnit)

        #expect(session.units == 2)
        #expect(session.liveWidth == LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap))
    }
}

// MARK: - The drag past the screen

/// **The re-divide at the session level** (03-board-ui.md § Lane, settled 2026-08-08). A window with
/// no headroom — one already flush against the screen's edge, or full screen, and in a fixture one
/// with no `NSWindow` at all — has an on-screen fit equal to the count the drag started from, so its
/// very first tick is a re-divide: the strip's width is pinned, the dragged lane takes one more unit
/// of it, and the siblings compress. The clamp that used to sit at the fit is what made a lane on a
/// maximised window refuse to widen at all.
///
/// Same fixture as above — the 1× lane, the 3× lane and no trash, so a four-unit strip 460 points
/// wide (100·4 + 12·5) — dragged from **one** unit, which with no window makes the fit 1× and puts
/// every tick in the second regime.
@MainActor
@Suite("The lane resize past the screen's edge")
struct LaneResizePastTheScreenTests {

    /// A drag of the 1× lane, `translation` points to the right of its start.
    private func dragging(_ translation: CGFloat) -> LaneResizeSession {
        let session = LaneResizeSession()
        session.begin(laneID: lane1, units: 1, standard: standard, gap: gap,
                      totalUnits: boardUnits, window: nil)
        session.update(translation: translation)
        return session
    }

    @Test("With no window to grow, the drag re-divides instead of refusing to tick")
    func aPinnedWindowRedividesFromTheFirstTick() {
        let session = dragging(200)

        // The 460pt strip re-divided: 2× puts the lane's edge at 167.2, 3× at 212, … and 7× at
        // 301.6, whose trailing gap is the first threshold the 300pt live edge has not cleared.
        #expect(session.units == 7, "the fit is 1× here, and the old clamp stopped the tick dead")
        #expect(session.liveWidth == 300, "well inside the strip's capacity, so no resistance")
        // The siblings compress, which is what a re-divide IS: the standard every other lane is
        // drawn at comes down as the dragged one takes more units of the same strip.
        #expect(abs(session.standard - 32.8) < 0.0001)   // (460 − 12·11) / 10
        #expect(session.standard < standard)
    }

    @Test("The tick still stops — at the strip's capacity, where the re-divide runs out of strip")
    func theRedivideStopsAtTheStripsCapacity() {
        // floor((460 − 12) / 13) = 34 whole units the strip can still divide into, of which this
        // lane contributes 31. Past that `standardWidth`'s 1pt floor would break the exact fill.
        let session = dragging(10_000)

        #expect(session.units == 31)
        #expect(session.liveWidth > LaneLayoutMath.slotWidth(units: 31, standard: session.standard, gap: gap),
                "the rubber band gives past the true end of travel, and the tick does not follow")
    }

    @Test("A release past the fit settles on the re-divided slot, not the frozen one")
    func theSettleUsesTheRedividedStandard() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        let session = dragging(200)
        session.end { id, units in store.setLaneWidth(id, units: units) }

        #expect(session.isSettled)
        #expect(session.hold == LaneWidthHold(laneID: lane1, units: 7))
        // The width the release wrote, measured against the standard THAT count implies — the one
        // still governing the strip while the hold stands.
        #expect(session.liveWidth
                == LaneLayoutMath.slotWidth(units: 7, standard: session.standard, gap: gap))
        #expect(abs(session.liveWidth - 301.6) < 0.0001)
    }
}
