import Foundation
import Observation

// MARK: - CommentEditSession

/// One inline comment edit — **a body-edit session in miniature** (05-card-window.md ▸ The comments
/// column: "no second draft mechanism: debounced saves to the comment's own file keep it crash-safe,
/// Save (or ⌘↩) ends the session as its commit point, Cancel — or Escape, its keyboard twin —
/// reverts to session-start bytes, window close flushes the session exactly as the body's does").
///
/// ### What it borrows from `CardBodyEditSession`, and what it adds
///
/// Borrowed, deliberately verbatim: the buffer/disk pair, the single write predicate (*write if and
/// only if the buffer differs from disk*), dirty-buffer-wins on `adopt(diskBody:)`, the ~700 ms
/// trailing debounce, and the flush that a mode exit or a window close performs. **The 700 ms is
/// right here**, and its being right here is what the slow cadence next door is a contrast to: the
/// comment already exists as a file, so a save is an ordinary edit to it — it is the *draft* that
/// must not become a commit stream (`CommentDraftSession`).
///
/// Added, and the only genuinely new thing in this type: **session-start bytes**. The body has no
/// Cancel — leaving Edit is a commit, and ⌘Z in the editor is the text view's own undo — while an
/// inline comment edit has a Cancel button and an Escape that means it. 13-native-undo.md forbids
/// byte capture *on the undo stack* anywhere, and this is not that: the capture is a live
/// buffer's, held for the length of one session, discarded when the session ends, and never
/// registered anywhere. `BoardStoreComments`' own note says so — "an inline edit's revert is its
/// *session*'s … which is a live buffer, not a stack entry".
///
/// ### The revert is a write, not an unwrite
///
/// Cancel puts the captured bytes back **through the ordinary save** (`BoardStore.editComment`), so
/// the file returns to what it said with one more `modified` stamp and one more bracketed write. That
/// is the honest shape for a files-first app: the debounced saves genuinely happened, other windows
/// and other machines have already seen them, and pretending otherwise would mean holding the file
/// open for the length of a session.
///
/// A cancel that has nothing to put back writes nothing — a session that only ever read leaves the
/// file byte-identical, `mtime` included, which is the body's untouched gate applied to the exit.
@MainActor
@Observable
public final class CommentEditSession {

    // MARK: Identity

    /// Which comment is open. The row renders an editor instead of its body while this session names
    /// it, and the drop carve-out aims at its `attachments/`.
    public let commentID: ItemID

    // MARK: State

    /// What the editor is showing.
    public private(set) var text: String

    /// What the last read said the comment's `index.md` holds. The write gate's other half.
    public private(set) var disk: String

    /// **The bytes this session opened on** — Cancel's destination, captured once at `init` and never
    /// updated. See the type's note for why this capture is not the one 13 forbids.
    @ObservationIgnored
    public let sessionStart: String

    public var isDirty: Bool { text != disk }

    // MARK: Seams

    /// The debounce interval — **~700 ms**, the body's own (05 ▸ Edit), and settable so a test does
    /// not have to spend it.
    @ObservationIgnored
    public var debounceInterval: Duration = .milliseconds(700)

    /// Where a save goes — `BoardStore.editComment(_:inCard:body:)`, filled in by the window.
    ///
    /// `true` means the bytes landed. `false` covers everything that means they did not — a failed
    /// write (already bannered by `performWrite`), a suspended one under the read-only lock, and a
    /// comment or card that has gone — and they are one case here for the reason 05 gives the window:
    /// each of them leaves the buffer dirty, which keeps the text, and none of them has a different
    /// thing for this type to do.
    @ObservationIgnored
    public var save: ((String) -> Bool)?

    /// Where this session's **one undo step** goes, called at its commit point with the bytes the
    /// session opened on and the bytes it leaves — `CardBodyEditSession.registerUndo`'s seam one level
    /// down, and for its reason exactly: a session is one step, never one per debounced tick
    /// (13-native-undo.md ▸ Rules ▸ coalescing).
    ///
    /// `CardComments.beginEdit` points it at `BoardStore.registerCommentEdit`, on the *window's*
    /// stack. `nil` — a session with no window behind it — registers nothing.
    @ObservationIgnored
    public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)?

    /// How many saves have actually been attempted through `save`.
    @ObservationIgnored
    public private(set) var saveAttempts = 0

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

    /// Whether this session has ended. A session ends once — Save, Cancel, or the window close that
    /// beat both of them to it — and ending twice must not write twice.
    @ObservationIgnored
    public private(set) var hasEnded = false

    /// Opens a session over `body`, which is both the buffer's starting text and Cancel's
    /// destination.
    public init(commentID: ItemID, body: String) {
        self.commentID = commentID
        text = body
        disk = body
        sessionStart = body
    }

    // MARK: - Disk → buffer

    /// A thread read arrived. **Dirty-buffer-wins**, the body's single `if`: `disk` always follows the
    /// read; `text` follows it only when the buffer had nothing unsaved.
    public func adopt(diskBody: String) {
        let wasDirty = isDirty
        disk = diskBody
        guard !wasDirty else { return }
        if text != diskBody { text = diskBody }
    }

    // MARK: - Buffer → disk

    /// A keystroke. Restarts the debounce, or cancels it when the change brought the buffer back to
    /// what disk already says.
    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 — the window
    /// close's flush, which "flushes the session exactly as the body's does".
    @discardableResult
    public func flush() -> Bool {
        cancelPending()
        return saveNow()
    }

    // MARK: - The two ends

    /// **Save, or ⌘↩** — the session's commit point (05 ▸ The comments column). Flushes and ends.
    ///
    /// A named call rather than a bare `flush()` for `CardBodyEditSession.endEditSession()`'s reason:
    /// this is the session's one settlement boundary — the excised auto-committer coalesced on it
    /// (one commit per session, never per save tick), and any future history consumer would too.
    @discardableResult
    public func commit() -> Bool {
        guard !hasEnded else { return false }
        hasEnded = true
        let wrote = flush()
        registerStep()
        return wrote
    }

    /// **Cancel, or Escape** — reverts to session-start bytes and ends (05; 11-command-nexus.md's
    /// grammar table gives Escape as the button's keyboard twin).
    ///
    /// The revert is a write, and it is attempted only when something of this session's actually
    /// landed: `disk` is what the file says as far as this session knows, so `disk == sessionStart`
    /// is a session that has overwritten nothing and has nothing to put back.
    ///
    /// A *foreign* edit landing mid-session moves `disk` too, and Cancel then writes the session's
    /// start bytes over it — deliberate last-writer-wins, the same no-merge-UI philosophy the body's
    /// dirty-buffer rule states (05 ▸ Write rules). The alternative would be a merge prompt in a
    /// comment editor.
    @discardableResult
    public func cancel() -> Bool {
        guard !hasEnded else { return false }
        hasEnded = true
        cancelPending()
        guard disk != sessionStart else { return false }
        saveAttempts += 1
        guard save?(sessionStart) == true else { return false }
        text = sessionStart
        disk = sessionStart
        return true
    }

    /// The window close's end: flush, then mark the session over — the same one-way latch Save and
    /// Cancel use, so a close that beat the buttons cannot be followed by a second write.
    ///
    /// It is **not** Cancel: a close is not an abandon (05 ▸ Deletion & lifecycle — "Dismissal never
    /// eats typed work silently where a save can land"), and reverting the user's typing because they
    /// closed a window would be the opposite of that promise.
    @discardableResult
    public func endOnClose() -> Bool {
        guard !hasEnded else { return false }
        hasEnded = true
        let wrote = flush()
        registerStep()
        return wrote
    }

    // MARK: - Private

    /// The session's one step, at whichever of its two ends got here first — and **only when the
    /// session actually changed the file**: `disk` is what this session knows the file says, so
    /// `disk == sessionStart` covers both the session that only read and the one that typed its way
    /// back to where it started (`CardBodyEditSession.endEditSession`'s guard, restated).
    ///
    /// Cancel deliberately never reaches here: it writes the start bytes back, so its net effect is
    /// nothing and a step would only offer to undo an undo.
    private func registerStep() {
        guard disk != sessionStart else { return }
        registerUndo?(sessionStart, disk)
    }

    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 landing moves `disk` up to the text that
    /// landed; anything else leaves it, which keeps the buffer dirty and therefore keeps the text.
    private func saveNow() -> Bool {
        guard isDirty, let save else { return false }
        saveAttempts += 1
        guard save(text) else { return false }
        disk = text
        return true
    }
}
