import Foundation
import os

// MARK: - Vocabulary

/// Why a board is closing — the one bit the flush sequence branches on.
///
/// The distinction *is* the restoration mechanism (02-architecture.md § Launch and window
/// lifecycle): a user close clears the record's open-now flag, a quit deliberately leaves it
/// standing so the next launch reopens what was on screen. Everything else about the two paths is
/// identical, which is why this is an enum consulted at one step rather than two sequences.
public enum BoardCloseCause: Sendable, Equatable {
    /// ⌘W, the red button, File ▸ Close — the user said this board is done.
    case userClose
    /// App quit. The boards were open at quit by definition, so their flags stay set.
    case quit
}

/// The end-of-session hook a card window runs before it goes away.
///
/// **A seam, not a feature, in m4.** The real work is 05-card-window.md's: "each open Edit session
/// ends with its normal session commit" (06-history-undo.md's granularity), which needs an editor
/// and a dirty buffer that do not exist yet. The default implementation is therefore a no-op, and
/// what this milestone actually pins is the *ordering* — that the hook runs, for every open card
/// window, before any of the board's pending work is flushed and long before the store is released.
/// The card-window milestone supplies a body; nothing above it has to change.
///
/// `AnyObject` because a card window's session is a live object with a buffer in it, and because the
/// coordinator holds it across an `await`.
@MainActor
public protocol CardSessionFlushing: AnyObject {
    func endSession() async

    /// Whether this window is holding content the file does not have — a dirty Edit buffer, or an
    /// open raw-source outlet that has been typed in.
    ///
    /// Beside `endSession()` because it is the same fact from the other end: this is what the flush
    /// *would* write, asked before running it. Its one caller is File ▸ Save as Template's
    /// validation, which under the unwritable-location read-only lock stays live only "unless an
    /// open Edit or raw-source session holds unsaved content the suspended saves can't flush"
    /// (02-architecture.md ▸ Live-reload resilience; 09-templates.md ▸ Save as Template) — a
    /// template that silently missed those keystrokes would break 09's never-misses-keystrokes
    /// guarantee, which outranks the item's availability.
    var holdsUnsavedContent: Bool { get }

    /// **This window's answers to the save-or-discard step** (06-history-undo.md ▸ Rules ▸ Undo
    /// restore vs open Edit sessions; ▸ Branch switching), or `nil` for a window with nothing a
    /// wholesale tree operation could disturb.
    ///
    /// Beside `holdsUnsavedContent` and deliberately not folded into it: that property answers "would
    /// a *template copy* miss keystrokes", which is a read; this one carries the two writes — Save
    /// All and Discard — that a restore or a branch switch needs the window to perform before it can
    /// run on a settled tree. Same window, two different questions, and collapsing them would give
    /// the settle step the draft-composer exemption `holdsUnsavedContent` deliberately makes.
    var settlement: CardSessionSettlement? { get }
}

public extension CardSessionFlushing {
    func endSession() async {}
    var holdsUnsavedContent: Bool { false }
    var settlement: CardSessionSettlement? { nil }
}

// MARK: - CloseFlushCoordinator

/// The close-flush sequence, in order, for one board — the whole of 02-architecture.md § Windows'
/// "Close flushes" bullet.
///
/// > closing a board window (and app quit) first closes the board's card windows — each open Edit
/// > session ends with its normal session commit — then flushes pending debounced work, editor saves
/// > before the pending auto-commit, before the store tears down.
///
/// **Nothing about it is conditional.** A card window cannot exist without its board window (the
/// ownership rule in § Components), so there is no shape of the world in which some other order is
/// correct — "the close flush is always the whole story". App quit runs this same object once per
/// open board rather than a second sequence that could drift.
///
/// ### Why closures rather than an object graph
///
/// Every step here is a claim about *order*, and an order is only testable if the steps can be
/// observed. Written against `NSWindow`, `BoardStore`, and SwiftUI's dismiss action this would be
/// verifiable only by running the app; written against these seams it is a pure ordering machine
/// that a test drives with an event log. `AppModel.closeBoard(ref:cause:)` is the one production
/// call site and supplies the real ones.
///
/// The two flush seams that are `nil` today — `editorFlush` and `committerFlush` — are named rather
/// than left to be discovered: 02 fixes their relative order (editor saves before any board-level
/// flush), and a future consumer should have nowhere to land except the slot that already sits in
/// the right place. `committerFlush`'s original consumer was the auto-committer, excised 2026-08-08
/// (strategy/01-git-excision.md); the slot outlives it for whatever durable-history flush comes next.
@MainActor
public struct CloseFlushCoordinator {

    // MARK: Step 1 — the card windows

    /// This board's open card windows, read **live**: the coordinator calls it again while waiting,
    /// because the set is what shrinks as each host tears down.
    public var openCardRefs: () -> [CardWindowRef]

    /// Runs one card window's end-session hook. Driven from here rather than left to the window's own
    /// teardown so that "the sessions ended before the board's work was flushed" is an ordering this
    /// object guarantees rather than one that happens to hold because SwiftUI ran the disappear
    /// callbacks promptly.
    public var endCardSession: (CardWindowRef) async -> Void

    /// Asks the card window to go away. Its host unregisters on the way out, which is what drains
    /// `openCardRefs`.
    public var dismissCardWindow: (CardWindowRef) -> Void

    /// How long to wait for the dismissed card windows to actually unregister.
    ///
    /// A bound rather than an open-ended wait, and the reason is the quit path: this runs inside
    /// `applicationShouldTerminate`'s deferred reply, so a window that never tears down would leave
    /// the app unquittable. The sessions have already ended by then — the wait exists to keep the
    /// refcount honest, not to protect data — so expiring it costs ordering tidiness and nothing
    /// else.
    public var cardDrainDeadline: Duration = .seconds(2)

    // MARK: Step 2 — pending work

    /// The store's own pipeline settling — `BoardStore.awaitQuiescence()` in production.
    public var storeFlush: () async -> Void

    /// The card windows' debounced body saves (05-card-window.md, m6). Runs **before**
    /// `committerFlush`: 02 is explicit that editor saves land before any board-level flush, so a
    /// session's last keystrokes ride whatever board-level flush follows rather than being orphaned
    /// behind it.
    public var editorFlush: (() async -> Void)?

    /// The board-level flush that ran last — the excised auto-committer's slot, permanently `nil`
    /// since 2026-08-08 (strategy/01-git-excision.md); see the header for why the slot survives.
    public var committerFlush: (() async -> Void)?

    // MARK: Step 3 — the record

    /// Stamps the recents counts (live items only — `AppModel.liveCounts(of:)`).
    public var recordClose: () -> Void

    /// Clears the record's open-now flag. Called **only** for `.userClose`; see `BoardCloseCause`.
    public var clearOpenNow: () -> Void

    // MARK: Step 4 — teardown

    /// Releases the store, stops the board's security-scoped access, and forgets the session.
    public var tearDown: () -> Void

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "close-flush")

    public init(
        openCardRefs: @escaping () -> [CardWindowRef],
        endCardSession: @escaping (CardWindowRef) async -> Void,
        dismissCardWindow: @escaping (CardWindowRef) -> Void,
        cardDrainDeadline: Duration = .seconds(2),
        storeFlush: @escaping () async -> Void,
        editorFlush: (() async -> Void)? = nil,
        committerFlush: (() async -> Void)? = nil,
        recordClose: @escaping () -> Void,
        clearOpenNow: @escaping () -> Void,
        tearDown: @escaping () -> Void
    ) {
        self.openCardRefs = openCardRefs
        self.endCardSession = endCardSession
        self.dismissCardWindow = dismissCardWindow
        self.cardDrainDeadline = cardDrainDeadline
        self.storeFlush = storeFlush
        self.editorFlush = editorFlush
        self.committerFlush = committerFlush
        self.recordClose = recordClose
        self.clearOpenNow = clearOpenNow
        self.tearDown = tearDown
    }

    // MARK: - The sequence

    /// Runs the four steps in the one order 02 fixes. Never throws and never returns early: a board
    /// that is closing is closing, and a step that fails must not strand the store, the record, or
    /// the window.
    public func run(cause: BoardCloseCause) async {
        await closeCardWindows()
        await flushPendingWork()
        recordClose()
        if cause == .userClose {
            clearOpenNow()
        }
        tearDown()
    }

    /// Step 1. Every card window's session ends, then every card window is dismissed, then the
    /// coordinator waits for them to unregister.
    ///
    /// **All the sessions end before any window is dismissed**, deliberately. The alternative —
    /// end-then-dismiss, one card at a time — would interleave commits with window teardowns, and a
    /// teardown that took a moment would leave a later card's unsaved buffer sitting in memory that
    /// much longer for no reason. The hooks are awaited in the order the refs came back, so a board
    /// with several dirty editors commits them in a stable order rather than a racy one.
    private func closeCardWindows() async {
        let refs = openCardRefs()
        guard !refs.isEmpty else { return }

        for ref in refs {
            await endCardSession(ref)
        }
        for ref in refs {
            dismissCardWindow(ref)
        }
        await drainCardWindows()
    }

    /// Waits for the dismissed hosts to unregister, or for the deadline.
    ///
    /// Polled rather than signalled by a continuation, and the deadline is why: the point of this
    /// wait is that it *ends*, and a continuation resumed by the last unregister has no way to end
    /// if that unregister never comes. The loop costs a handful of 10 ms turns during a window close
    /// and nothing at all when the hosts tear down promptly, which they do.
    private func drainCardWindows() async {
        let start = ContinuousClock.now
        while !openCardRefs().isEmpty {
            guard ContinuousClock.now - start < cardDrainDeadline else {
                Self.logger.error("card windows did not unregister within the drain deadline; closing anyway")
                return
            }
            try? await Task.sleep(for: .milliseconds(10))
        }
    }

    /// Step 2. The store's pipeline, then the editor saves, then the pending commit — 02's order,
    /// stated once.
    ///
    /// **Callable on its own**, which is the one place the sequence is entered part-way: File ▸
    /// Duplicate needs pending work on disk before it copies but must leave the session standing
    /// (09-templates.md ▸ Save as Template's stated exception — "sessions staying open"). It reaches
    /// this step through `AppModel.flushPendingWork(for:)` rather than re-listing the three flushes,
    /// so their order still has one definition.
    public func flushPendingWork() async {
        await storeFlush()
        await editorFlush?()
        await committerFlush?()
    }
}
