import Foundation
import Observation

// MARK: - CardBodyEditSession

/// One card window's Edit buffer: the text the user is typing, what disk last said, and the
/// debounced save between them (05-card-window.md ▸ Edit, ▸ Write rules).
///
/// ### One comparison is the whole write rule
///
/// 05 states three gates — "untouched → never re-serialized; reverted → not written; echo of an
/// external edit → not written back" — and they are three faces of a single predicate: **write if
/// and only if the buffer differs from what is on disk**.
///
/// - *Untouched*: the user opened Edit, read, and left. `text == disk`, so nothing is written and
///   the file stays byte-identical, `mtime` included.
/// - *Reverted*: they typed and undid it. The debounce is cancelled the moment `text` matches `disk`
///   again, so the timer that was going to write does not survive the revert.
/// - *Echo*: our own save lands, the watcher reloads, and the snapshot arrives carrying the text we
///   just wrote. `adopt(diskBody:)` moves `disk` to it, the buffer is already equal, and nothing is
///   written back — which is what stops a save from ringing forever through the one-way flow.
///
/// `BoardWriter.writeBody` re-checks the same equality against the bytes it reads fresh, so the
/// guarantee holds even against a caller that skipped this type. Belt and braces on purpose: this is
/// the promise a file-backed app cannot afford to get subtly wrong.
///
/// ### Dirty-buffer-wins, as one branch
///
/// "A dirty Edit buffer is never reloaded under the cursor: while the user has unsaved keystrokes,
/// watcher reloads update everything else (board, Preview, other windows) but leave the buffer
/// alone; the debounced save then writes it — deliberate last-writer-wins. A clean buffer follows
/// disk" (05 ▸ Write rules). That is `adopt(diskBody:)`'s single `if`: `disk` always follows the
/// snapshot, and `text` follows it only when the two agreed before the snapshot arrived.
///
/// Keeping `disk` current *even while dirty* is the deliberate half. It means "dirty" reads as
/// "differs from the file", not "differs from what the file said when I started" — so a foreign edit
/// that happens to arrive at the text the user typed lands the buffer clean and writes nothing,
/// rather than re-stamping a file that already says the right thing.
///
/// ### The undo and commit seams
///
/// ⌘Z is the *editor's* undo and lives in the text view (`CardBodySurface` gives it an
/// `NSUndoManager` of its own, which is what makes it session-scoped). What lives here is the other
/// half of 05 ▸ Edit's undo sentence: the **session**, whose end is the effective Save.
/// `endEditSession()` is that moment — the Edit→Preview flip, raw-source entry, or the window
/// closing — and it is deliberately a named call rather than a side effect of `flush()`, because it
/// is where this session's **one undo step** is registered: every debounced tick inside a session
/// rides its own `performWrite` bracket, and 13-native-undo.md's rule is one step per session, never
/// per save tick (`registerUndo`).
///
/// **The commit boundary is no longer here** (06-history-undo.md ▸ Rules ▸ Auto-commit, widened
/// 2026-07-31 — "Board history sees card-window sessions, not gestures"): the committer's
/// stage-around used to open and close with this flip, and it now opens with the *window* and
/// releases when the window's session ends, so a comment posted with the body in Preview is inside
/// the same one commit as the body. What this type still owes that model is `isEditing`, read as
/// unsaved-content state rather than announced (`CardWindowHost`); the announcement seam it used to
/// wire went with the widening.
@MainActor
@Observable
public final class CardBodyEditSession {

    // MARK: State

    /// What the editor is showing — and, once the window has opened, the truest text there is: it is
    /// the buffer when the buffer is dirty and disk when it is not, which is precisely the order 05
    /// settles. Preview renders it too, so "the preview never lags the text that produced it" needs
    /// no separate mechanism.
    public private(set) var text: String = ""

    /// What the last snapshot said is on disk. The write gate's other half; never shown.
    public private(set) var disk: String = ""

    /// Whether the buffer holds keystrokes the file does not.
    public var isDirty: Bool { text != disk }

    /// **Whether an Edit session is open right now** — the body column is showing the editor.
    ///
    /// The fact 06's auto-committer staged around, before app-managed git was excised
    /// (`strategy/01-git-excision.md`, 2026-08-08): "a board change committing mid-session excludes
    /// the session card's folder from staging, so a lane move never sweeps half-typed body text into
    /// its commit" (06-history-undo.md ▸ Rules ▸ Auto-commit).
    ///
    /// **Open, not dirty**, deliberately: an *open* session is what 06 names, and the exclusion has
    /// to cover the moment between a landed ~700 ms save and the next keystroke — precisely when the
    /// buffer is clean and the file holds a half-typed paragraph no commit should carry yet. A
    /// session opened and never typed in costs one card folder its commits until the flip back, and
    /// nothing else. (Recorded as a judgment call: 06 says "open Edit sessions", the branch-switch
    /// rule qualifies its own gate with "unsaved keystrokes, or on-disk saves the session hasn't
    /// committed", and the two readings differ only for the untouched session.)
    public private(set) var isEditing = false

    // MARK: Seams

    /// The debounce interval — **~700 ms** (05 ▸ Edit), and settable so a test does not have to
    /// spend it. `DragSession.holdTimeout`'s precedent: a production default on the property, and
    /// the suite dialling it down to milliseconds.
    @ObservationIgnored
    public var debounceInterval: Duration = .milliseconds(700)

    /// Where a save goes. Filled in by the window once it has a store and a card to aim at
    /// (`CardWindowHost`), which is also why it is a closure rather than a store reference: this type
    /// is a buffer and a clock, and it stays testable by having no idea what a board is.
    ///
    /// `nil` is a session with nowhere to write — before the window has joined its board, and after
    /// it has left. A flush then keeps the buffer dirty rather than reporting success.
    @ObservationIgnored
    public var save: ((String) -> CardBodyWriteOutcome)?

    /// Where this session's **one undo step** goes, called at `endEditSession()` with the body disk
    /// held when the session's first save landed and the body it holds now (13-native-undo.md
    /// ▸ Rules: "an Edit session is one step, registered at the Edit→Preview flip").
    ///
    /// A closure for `save`'s reason exactly — this type is a buffer and a clock, and it stays
    /// testable by having no idea what a board or an undo stack is. `CardWindowHost` points it at
    /// `BoardStore.registerBodyEdit(inCard:priorBody:newBody:)`, which builds the step; `nil` is a
    /// session whose window has not joined its board, and registers nothing.
    @ObservationIgnored
    public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)?

    /// What disk said before this session's **first** landed save — the step's before-value, held
    /// from the first write until the session ends.
    ///
    /// `nil` means "no save has landed in this session", which is also what a session that only ever
    /// read looks like: nothing was written, so there is nothing to undo and no step to register. It
    /// is captured at the *write*, not at Edit entry, so that a session opened and abandoned leaves
    /// the stack exactly as it found it.
    @ObservationIgnored
    private var sessionOriginBody: String?

    @ObservationIgnored
    private var pending: Task<Void, Never>?

    /// How many saves have actually been attempted through `save` — the debounce's own testimony,
    /// which a test would otherwise have to infer from `mtime`s.
    @ObservationIgnored
    public private(set) var saveAttempts = 0

    public init() {}

    // MARK: - Disk → buffer

    /// A snapshot arrived. **Dirty-buffer-wins**: `disk` always follows it; `text` follows it only
    /// when the buffer had nothing unsaved.
    ///
    /// Called on every snapshot the window renders, including the first, which is how the buffer is
    /// filled at all — a card window opens by adopting its card's body.
    public func adopt(diskBody: String) {
        let wasDirty = isDirty
        disk = diskBody
        guard !wasDirty else { return }
        // Assigning an equal string would still notify observers, and an observer here is a text
        // view that would replace its contents under the cursor.
        if text != diskBody { text = diskBody }
    }

    // MARK: - Buffer → disk

    /// The editor changed. Restarts the debounce — or cancels it outright, when the change brought
    /// the buffer back to what disk already says (05's *reverted* gate: a revert must not leave a
    /// timer standing that would then write nothing but a `modified` stamp).
    public func edited(_ newText: String) {
        guard text != newText else { return }
        text = newText
        guard isDirty else {
            cancelPending()
            return
        }
        scheduleSave()
    }

    /// Saves now if there is anything to save, cancelling the pending debounce first — "leaving Edit
    /// flushes the debounce (mode flip, raw-source entry, window close)" (05 ▸ Mode grammar).
    ///
    /// Synchronous, because the write is: `BoardWriter` is a temp file and a rename, and a flush that
    /// returned before the bytes landed would be no flush at all — the close path in particular has
    /// to know the answer before it lets the window go.
    @discardableResult
    public func flush() -> CardBodyWriteOutcome {
        cancelPending()
        return saveNow()
    }

    /// The start of one Edit session — the flip into Edit, or a window that opened straight into it
    /// because its card's body was empty (`CardBodyMode.opening(body:)`).
    ///
    /// Idempotent, because the mode can be re-asserted by a menu validation pass or a re-published
    /// focus value, and `isEditing` is observed: assigning `true` over `true` would still notify, and
    /// the notification is a view rebuild for a session that never changed (`adopt(diskBody:)`'s
    /// equal-assignment note, one property along).
    public func beginEditSession() {
        guard !isEditing else { return }
        isEditing = true
    }

    /// The end of one Edit session — the flip back to Preview, raw-source entry, or the window
    /// closing. Flushes, then registers the session's one undo step (see the type's doc comment).
    @discardableResult
    public func endEditSession() -> CardBodyWriteOutcome {
        let outcome = flush()
        // The session's one undo step, registered here and nowhere else — see `registerUndo`. It
        // fires only when something of this session's actually landed: `sessionOriginBody` is set by
        // the first successful write, and the guard against `disk` covers the session that typed its
        // way back to where it started across several ticks (each of which wrote, so the origin is
        // set, but whose net effect on the file is nothing to undo).
        if let origin = sessionOriginBody, origin != disk {
            registerUndo?(origin, disk)
        }
        sessionOriginBody = nil
        // Guarded on `isEditing` for `beginEditSession`'s reason — a window closing from Preview
        // calls this too, and should, but has no session flag to move.
        if isEditing { isEditing = false }
        return outcome
    }

    /// **Throws the buffer away and takes disk's word for it** — the Discard branch of the
    /// save-or-discard step (13-native-undo.md ▸ Undo routing; `SessionSettleGate`).
    ///
    /// It reverts the *buffer* and ends the session; any uncommitted on-disk saves are the wholesale
    /// operation behind the step's to reconcile, because only that operation knows which state it is
    /// restoring to. Splitting it that way is what keeps the two halves from being two answers able to
    /// disagree: one pass writes the card's files, once.
    ///
    /// The pending debounce is cancelled first, which is the load-bearing half — a surviving timer
    /// would write the discarded text back over the restored card a moment later.
    public func discardBuffer() {
        cancelPending()
        text = disk
        sessionOriginBody = nil
        if isEditing { isEditing = false }
    }

    /// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of
    /// reported.
    ///
    /// The three non-failures deliberately do *not* throw, because each of them is a state in which
    /// blocking the close would be dishonest:
    ///
    /// - `.written` / `.unchanged` — the text is on disk.
    /// - `.vanished` — the card's folder is gone, so there is nowhere for the save to land; 05 ▸
    ///   Deletion & lifecycle answers exactly this case with "nowhere left to write", and a modal
    ///   offering Try Again against a deleted folder would be a button that can only fail.
    /// - `.suspended` — the board is locked read-only, which is 05's "where a save can land"
    ///   qualifier failing rather than a write failing: no write was attempted, the lock row has been
    ///   standing the whole time the user was typing, and the lock's own clearing rule (a successful
    ///   reload) is not something a close can wait on.
    public func flushOrThrow() throws(BoardWriteError) {
        switch flush() {
        case .written, .unchanged, .vanished, .suspended:
            return
        case let .failed(error):
            throw error
        }
    }

    // MARK: - Private

    private func scheduleSave() {
        cancelPending()
        let interval = debounceInterval
        pending = Task { [weak self] in
            try? await Task.sleep(for: interval)
            guard !Task.isCancelled, let self else { return }
            self.pending = nil
            _ = self.saveNow()
        }
    }

    private func cancelPending() {
        pending?.cancel()
        pending = nil
    }

    /// The gate, and the one place `save` is called.
    ///
    /// A successful landing moves `disk` up to the text that landed, so the echo arriving a reload
    /// later finds the buffer already clean. A failure, a suspension and a vanished card all leave
    /// `disk` where it was, which keeps the buffer dirty — and therefore keeps the text, which is the
    /// whole point.
    private func saveNow() -> CardBodyWriteOutcome {
        guard isDirty else { return .unchanged }
        guard let save else { return .vanished }

        saveAttempts += 1
        // Read before the write, because the write is what makes it stale: this is the body the
        // session is about to start overwriting, and only the *first* landed save of a session may
        // claim it (13-native-undo.md ▸ Rules — one step per session, not per tick).
        let priorBody = disk
        let outcome = save(text)
        switch outcome {
        case .written:
            if sessionOriginBody == nil { sessionOriginBody = priorBody }
            disk = text
        case .unchanged:
            // Nothing was replaced — the file already read like the buffer, so this session has
            // overwritten nothing yet and has nothing to hand an undo. (It is also the one outcome
            // where `disk` was demonstrably wrong, so the bytes it held are not a state to restore.)
            disk = text
        case .suspended, .vanished, .failed:
            break
        }
        return outcome
    }
}
