import Foundation
import Testing
@testable import Kanban

/// **The pre-snapshot loading state** (02-architecture.md § Launch and window lifecycle, ruled
/// 2026-07-29): the board window appears immediately, wearing the registry record's cached name,
/// and its content area stays empty until a short grace has passed — "so ordinary fast opens never
/// flash it".
///
/// Both halves are rules about *state*, not about rendering, and both are extracted so they can be
/// asked without a window: `BoardLoadingIndicator` is the grace's state machine and
/// `BoardWindowHost.loadingTitle` is the title rule. What SwiftUI does with either — a `ProgressView`
/// in a `ZStack`, a `navigationTitle` — is one line each and is not what could go quietly wrong.
///
/// **No test here waits the real grace out.** The figure is injectable for exactly that reason
/// (`DragSession.holdTimeout`'s precedent), and the "after" half is also pinned directly through the
/// body the clock runs, so the rule is checkable with no clock at all.

// MARK: - Helpers

/// Polls until `condition` holds or the deadline passes — the file's only wait, and it waits for a
/// *fact* (the spinner arrived) rather than for an interval.
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(5), _ condition: () -> Bool) async {
    let start = ContinuousClock.now
    while ContinuousClock.now - start < deadline {
        if condition() { return }
        try? await Task.sleep(for: .milliseconds(5))
    }
}

@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"))
    return fixture
}

/// A registry file in temp — app-side state, never inside a board folder.
@MainActor
private func makeRegistry() throws -> (registry: BoardRegistry, tearDown: () -> Void) {
    let folder = FileManager.default.temporaryDirectory
        .appendingPathComponent("BoardLoadingTests-\(UUID().uuidString)", isDirectory: true)
    try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
    let registry = BoardRegistry(storageURL: folder.appendingPathComponent("board-registry.json"))
    return (registry, { try? FileManager.default.removeItem(at: folder) })
}

// MARK: - Tests

@MainActor
@Suite("Board loading state")
struct BoardLoadingTests {

    // MARK: The grace

    @Test("Nothing shows before the grace elapses")
    func theSurfaceIsEmptyDuringTheGrace() async {
        let indicator = BoardLoadingIndicator()
        indicator.grace = .seconds(30)

        #expect(!indicator.showsSpinner, "at rest")
        indicator.begin()
        #expect(!indicator.showsSpinner, "the grace has been armed, not elapsed")
    }

    @Test("The spinner appears once the grace elapses")
    func theSpinnerArrivesAfterTheGrace() async {
        let indicator = BoardLoadingIndicator()
        indicator.grace = .milliseconds(20)

        indicator.begin()
        await waitUntil { indicator.showsSpinner }
        #expect(indicator.showsSpinner)
    }

    @Test("The grace's body is the whole of the spinner's arrival")
    func graceElapsedIsThePinnableHalf() {
        // The clock-free half of the rule above: whatever the duration, *this* is what the sleep
        // ends in, so a suite can assert the "after" state without a clock (`DragSession.expire`'s
        // precedent).
        let indicator = BoardLoadingIndicator()
        indicator.graceElapsed()
        #expect(indicator.showsSpinner)
    }

    @Test("A board that lands inside the grace never flashes the spinner")
    func fastOpenNeverFlashes() async {
        let indicator = BoardLoadingIndicator()
        indicator.grace = .milliseconds(20)

        // The ordinary open: the snapshot arrives before the grace is up.
        indicator.begin()
        indicator.end()
        #expect(!indicator.showsSpinner)

        // And it stays away — the disarmed grace must not fire into a window that has moved on.
        try? await Task.sleep(for: .milliseconds(60))
        #expect(!indicator.showsSpinner)
    }

    @Test("Ending the surface clears a spinner that had already appeared")
    func endClearsTheSpinner() async {
        let indicator = BoardLoadingIndicator()
        indicator.grace = .milliseconds(20)

        indicator.begin()
        await waitUntil { indicator.showsSpinner }
        indicator.end()
        #expect(!indicator.showsSpinner, "the snapshot replaced the surface in place")
    }

    @Test("Arming twice does not restart the clock")
    func beginIsIdempotent() async {
        let indicator = BoardLoadingIndicator()
        indicator.grace = .milliseconds(20)

        indicator.begin()
        // A body that evaluates again, or a host that configures itself twice, must not push the
        // spinner back by another grace.
        indicator.begin()
        await waitUntil { indicator.showsSpinner }
        #expect(indicator.showsSpinner)
    }

    // MARK: The loading window's title

    @Test("A first-ever open wears the record's provisional folder name")
    func loadingTitleIsTheFolderNameOnAFirstOpen() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let (registry, tearDown) = try makeRegistry()
        defer { tearDown() }

        // Exactly what `BoardWindowHost.start()` does before the walk: record, then read the record
        // back for the title. The folder name arrives as the record's own provisional display name,
        // not as a second rule the window applies for itself.
        let recordID = registry.recordOpen(of: fixture.root)

        #expect(
            BoardWindowHost.loadingTitle(record: registry.record(id: recordID), url: fixture.root)
                == AppModel.folderDisplayName(of: fixture.root)
        )
    }

    @Test("A board that has opened before wears its cached title")
    func loadingTitleIsTheCachedTitle() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let (registry, tearDown) = try makeRegistry()
        defer { tearDown() }

        // The previous session's successful load, which is what stamps the cached title.
        let first = registry.recordOpen(of: fixture.root)
        registry.syncDisplayState(id: first, title: "Roadmap", icon: nil, iconColor: nil)

        // This session's open: the record is found again by file identity, and its cached title is
        // what the window is called while it walks — never the folder name it happens to sit in.
        let recordID = registry.recordOpen(of: fixture.root)
        #expect(recordID == first, "the same board must find the record it already has")
        #expect(BoardWindowHost.loadingTitle(record: registry.record(id: recordID), url: fixture.root) == "Roadmap")
    }

    @Test("With no record yet the title is still a no-scan name")
    func loadingTitleFallsBackToTheFolderName() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        // The one body evaluation that precedes `recordOpen`. It must not be blank, and it must not
        // cost a look inside the board.
        #expect(
            BoardWindowHost.loadingTitle(record: nil, url: fixture.root)
                == AppModel.folderDisplayName(of: fixture.root)
        )
    }
}
