import Foundation
import Testing
@testable import Kanban

/// The close flush is an *order*, and an order is only worth stating if something checks it. 02
/// § Windows fixes it: the board's card windows and their sessions first, then pending debounced work
/// with editor saves before the board-level flush, then the registry stamp, then teardown — and
/// "nothing about this is conditional".
///
/// So every test here is an assertion about a list. The coordinator is driven with fakes that append
/// to one event log, which is the only way to see an ordering that in production is spread across
/// SwiftUI teardowns, a store's pipeline, and a JSON file.

// MARK: - Fixtures

@MainActor
private final class FlushLog {
    private(set) var events: [String] = []

    func record(_ event: String) {
        events.append(event)
    }
}

/// A card window's session as the coordinator sees it: something that ends, once, in order.
@MainActor
private final class FakeCardSession: CardSessionFlushing {
    private let name: String
    private let log: FlushLog

    init(name: String, log: FlushLog) {
        self.name = name
        self.log = log
    }

    func endSession() async {
        log.record("card-session \(name)")
    }
}

/// One board's worth of seams, wired to a shared log.
///
/// `dismissDrains` is the interesting knob: normally a dismissed card window unregisters and the
/// ref goes, which is what the coordinator waits for. Turning it off simulates a window that never
/// tears down — the case the drain deadline exists for.
@MainActor
private final class FakeBoard {

    let name: String
    private let log: FlushLog
    private var cardRefs: [CardWindowRef]
    private var sessions: [CardWindowRef: FakeCardSession] = [:]
    private let dismissDrains: Bool
    private let drainDeadline: Duration

    init(
        name: String,
        cards: [String],
        log: FlushLog,
        dismissDrains: Bool = true,
        drainDeadline: Duration = .seconds(2)
    ) {
        self.name = name
        self.log = log
        self.dismissDrains = dismissDrains
        self.drainDeadline = drainDeadline
        cardRefs = cards.map { CardWindowRef(boardPath: "/boards/\(name)", cardID: $0) }
        for ref in cardRefs {
            sessions[ref] = FakeCardSession(name: "\(name)/\(ref.cardID)", log: log)
        }
    }

    var coordinator: CloseFlushCoordinator {
        CloseFlushCoordinator(
            openCardRefs: { [self] in cardRefs },
            endCardSession: { [self] ref in await sessions[ref]?.endSession() },
            dismissCardWindow: { [self] ref in
                log.record("dismiss \(name)/\(ref.cardID)")
                guard dismissDrains else { return }
                cardRefs.removeAll { $0 == ref }
            },
            cardDrainDeadline: drainDeadline,
            storeFlush: { [self] in log.record("store-flush \(name)") },
            editorFlush: { [self] in log.record("editor-flush \(name)") },
            committerFlush: { [self] in log.record("committer-flush \(name)") },
            recordClose: { [self] in log.record("record-close \(name)") },
            clearOpenNow: { [self] in log.record("clear-open-now \(name)") },
            tearDown: { [self] in log.record("teardown \(name)") }
        )
    }
}

// MARK: - Tests

@MainActor
@Suite("Close flush")
struct CloseFlushCoordinatorTests {

    @Test("A user close runs every step, in the one order the design fixes")
    func userCloseOrdering() async {
        let log = FlushLog()
        let board = FakeBoard(name: "work", cards: ["card-a", "card-b"], log: log)

        await board.coordinator.run(cause: .userClose)

        #expect(log.events == [
            // Every session ends before any window is dismissed: commits are not interleaved with
            // teardowns.
            "card-session work/card-a",
            "card-session work/card-b",
            "dismiss work/card-a",
            "dismiss work/card-b",
            // Pending debounced work, editor saves before the board-level flush.
            "store-flush work",
            "editor-flush work",
            "committer-flush work",
            // The record, then the store.
            "record-close work",
            "clear-open-now work",
            "teardown work",
        ])
    }

    @Test("Quit runs the identical sequence but leaves the open-now flag standing")
    func quitDoesNotClearTheOpenNowFlag() async {
        let log = FlushLog()
        let board = FakeBoard(name: "work", cards: ["card-a"], log: log)

        await board.coordinator.run(cause: .quit)

        // The single difference between the two causes, and the whole restoration mechanism: the
        // boards open at quit are by definition the ones the next launch reopens.
        #expect(!log.events.contains("clear-open-now work"))
        #expect(log.events == [
            "card-session work/card-a",
            "dismiss work/card-a",
            "store-flush work",
            "editor-flush work",
            "committer-flush work",
            "record-close work",
            "teardown work",
        ])
    }

    @Test("A board with no card windows still runs the rest of the sequence")
    func noCardWindowsIsNotASpecialCase() async {
        let log = FlushLog()
        let board = FakeBoard(name: "solo", cards: [], log: log)

        await board.coordinator.run(cause: .userClose)

        // "Nothing about this is conditional" — the card step is empty, not skipped-with-a-branch,
        // and everything downstream is unchanged.
        #expect(log.events == [
            "store-flush solo",
            "editor-flush solo",
            "committer-flush solo",
            "record-close solo",
            "clear-open-now solo",
            "teardown solo",
        ])
    }

    @Test("The seams that are still nil are simply absent from the sequence")
    func absentSeamsAreSkipped() async {
        let log = FlushLog()
        let coordinator = CloseFlushCoordinator(
            openCardRefs: { [] },
            endCardSession: { _ in },
            dismissCardWindow: { _ in },
            storeFlush: { log.record("store-flush") },
            recordClose: { log.record("record-close") },
            clearOpenNow: { log.record("clear-open-now") },
            tearDown: { log.record("teardown") }
        )

        // m4's real shape: no editor and no committer exist yet, and their absence must not change
        // the order of anything around them.
        await coordinator.run(cause: .userClose)
        #expect(log.events == ["store-flush", "record-close", "clear-open-now", "teardown"])
    }

    @Test("Quitting with several boards runs each board's sequence whole, one after another")
    func quitPreservesPerBoardOrdering() async {
        let log = FlushLog()
        let first = FakeBoard(name: "alpha", cards: ["card-a"], log: log)
        let second = FakeBoard(name: "beta", cards: ["card-b"], log: log)

        // Sequential, as `AppModel.flushAllBoardsForQuit()` runs them: a board's steps are never
        // interleaved with another's, so each board's ordering guarantee survives a multi-board quit.
        await first.coordinator.run(cause: .quit)
        await second.coordinator.run(cause: .quit)

        #expect(log.events == [
            "card-session alpha/card-a",
            "dismiss alpha/card-a",
            "store-flush alpha",
            "editor-flush alpha",
            "committer-flush alpha",
            "record-close alpha",
            "teardown alpha",
            "card-session beta/card-b",
            "dismiss beta/card-b",
            "store-flush beta",
            "editor-flush beta",
            "committer-flush beta",
            "record-close beta",
            "teardown beta",
        ])
    }

    @Test("A card window that never unregisters cannot wedge the close")
    func theDrainIsBounded() async {
        let log = FlushLog()
        let board = FakeBoard(
            name: "stuck",
            cards: ["card-a"],
            log: log,
            dismissDrains: false,
            drainDeadline: .milliseconds(50)
        )

        // The quit path runs inside `applicationShouldTerminate`'s deferred reply, so an unbounded
        // wait here would be an app that cannot be quit. The sessions have already ended by this
        // point, so expiring the deadline costs tidiness and no data.
        await board.coordinator.run(cause: .quit)

        #expect(log.events.first == "card-session stuck/card-a")
        #expect(log.events.last == "teardown stuck")
        #expect(log.events.contains("record-close stuck"))
    }
}
