import Foundation
import Observation
import os

/// The **one modal moment on the write-failure path** (02-architecture.md § Write-failure
/// surfacing), as a mechanism: closing a window — or the board, or quitting — with a dirty buffer
/// that cannot be written.
///
/// ### Why this one case is allowed to be modal
///
/// Everything else on the write path is non-modal by construction, because everything else is on
/// disk: a failed move visibly did not happen, a failed save leaves the keystrokes in the buffer
/// and the banner standing while the debounced save retries on its own cadence. Nothing is lost
/// while the window stays open. **Closing is the moment that stops being true** — the buffer is the
/// only place that text exists, and the window is about to go away. So the close stops and asks:
/// retry, save a copy elsewhere, or discard. Failing silently here would be the one place this
/// design loses a user's work.
///
/// ### What this type is, and is not
///
/// It is the *state machine* for that moment and nothing else: it does not own the buffer, does not
/// know what a card is, and does not present anything. Two closures supply everything specific —
/// `attemptSave` flushes the dirty buffer to its real home (the debounced save's target) and
/// `writeCopy` writes the buffer's current text wherever the user pointed. Presentation is
/// `View.dirtyBufferAlert(_:copyDestination:)` in `Kanban/UI/`.
///
/// ### Who will call it
///
/// - **m6's editor sessions** — the card window's body Edit buffer and its raw-source buffer, each
///   closing with a flush (05-card-window.md: "Leaving Edit flushes the debounce ... window
///   close").
/// - **m4's board-close flush** — closing a board window flushes pending debounced work before the
///   store tears down (02 § Windows, "Close flushes"), and a flush that cannot land is this moment
///   arriving at the board level.
///
/// Both are the same shape, which is why the mechanism is generic and arrives before either
/// caller: a buffer, a save that can fail, and a close that must not proceed until the text is
/// somewhere.
@MainActor
@Observable
public final class DirtyBufferGuard {

    /// Whether a close is currently held up.
    ///
    /// Deliberately two cases. There is no `.saving` or `.retrying`: `attemptSave` is synchronous
    /// (it is a file write, and the buffer is already in memory), so there is no interval during
    /// which a third state could be observed — inventing one would only invite a spinner over a
    /// moment that does not exist.
    public enum Phase: Equatable {
        /// Nothing is holding the close: either the buffer is safe, or none was ever dirty.
        case idle
        /// The save failed. The modal must present, carrying this error's phrasing, and the close
        /// must not proceed until one of `retry()`, `saveCopy(to:)`, or `discard()` returns it to
        /// `.idle`.
        case blocked(BoardWriteError)
    }

    public private(set) var phase: Phase = .idle

    /// Flushes the dirty buffer to its real home — the same write the debounced save performs.
    private let attemptSave: @MainActor () throws(BoardWriteError) -> Void

    /// Writes the buffer's current text to an arbitrary user-picked URL — "save a copy elsewhere".
    ///
    /// Untyped `throws` on purpose: this writes outside the board, to a destination the user chose
    /// through a save panel, so its failures are `Foundation`'s (`Data.write`, `NSError` from the
    /// panel's URL) rather than the Writer's closed vocabulary. Forcing them into `BoardWriteError`
    /// would mean inventing a `WriteOperation` case for a file that is not part of any board.
    private let writeCopy: @MainActor (URL) throws -> Void

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

    public init(
        attemptSave: @escaping @MainActor () throws(BoardWriteError) -> Void,
        writeCopy: @escaping @MainActor (URL) throws -> Void
    ) {
        self.attemptSave = attemptSave
        self.writeCopy = writeCopy
    }

    /// The close flow's gate: tries the save and answers whether the close may proceed.
    ///
    /// - Returns: `true` when the buffer landed and the window may close. `false` when it did not —
    ///   `phase` is `.blocked` and the alert must present.
    ///
    /// The caller decides *whether* to call this: a clean buffer has nothing to flush, and asking
    /// this type about it would mean teaching it what "dirty" means for text it does not own.
    public func beginClose() -> Bool {
        attempt()
    }

    /// Re-attempts the save from the alert's "Try Again". Same act as `beginClose()`, named for the
    /// button that calls it — a retry after the user freed some disk or reconnected a volume is the
    /// case this whole moment exists to make possible.
    ///
    /// - Returns: `true` when the buffer landed and the close may resume.
    @discardableResult
    public func retry() -> Bool {
        attempt()
    }

    /// Writes the buffer's text to a destination the user picked, and unblocks on success.
    ///
    /// **The text is safe elsewhere, so the close may proceed** — the buffer's real home is still
    /// unwritten, and that is the trade the user just made knowingly. A failure rethrows and leaves
    /// the phase blocked: the modal stays up, because the text is still nowhere but memory.
    public func saveCopy(to url: URL) throws {
        try writeCopy(url)
        Self.logger.debug("dirty buffer saved as a copy; the close may proceed")
        phase = .idle
    }

    /// The user chose to lose the text. Unblocks unconditionally — this is the one branch with
    /// nothing to verify, and second-guessing an explicit discard would be its own kind of
    /// dishonesty.
    public func discard() {
        Self.logger.debug("dirty buffer discarded at the user's request")
        phase = .idle
    }

    private func attempt() -> Bool {
        do throws(BoardWriteError) {
            try attemptSave()
        } catch {
            Self.logger.error("dirty buffer could not be saved: \(error.description, privacy: .public)")
            phase = .blocked(error)
            return false
        }
        phase = .idle
        return true
    }
}
