import Foundation
import Observation

// MARK: - CommentDraftSession

/// The composer's buffer: the text the user is typing into `comments/.draft/`, what disk last said,
/// and the **slow** cadence between them (05-card-window.md ▸ The comments column).
///
/// ### It is not the body's session, and the difference is the whole point
///
/// `CardBodyEditSession` is a 700 ms trailing debounce: the body is the card, and a card should be on
/// disk almost as fast as it is typed. A draft is neither.
///
/// > **Draft saves are slow-cadence, never prompted** (flow breakage minimized): the draft writes on
/// > composer blur, window close, quit, and a lazy interval (~30 s) — not the body editor's 700 ms,
/// > so a Pro user's typing never becomes a commit stream.
///
/// So the timer here is a **lazy interval, not a debounce**: it is armed the moment the buffer first
/// goes dirty and it is *not* restarted by the keystrokes after it. A debounce would never fire while
/// someone was typing steadily and would then fire the instant they paused — which is exactly the
/// commit stream the rule exists to prevent, and exactly the wrong moment to interrupt them. An
/// interval fires on its own schedule, at most once per period, whatever the typing is doing.
///
/// ### Escape is not here, and that is a ruling
///
/// "**Escape moves focus out of the composer, draft untouched**" (ruled 2026-07-29 — Escape never
/// discards: the draft is a durable file, so 'abandon' has no meaning here; emptying the draft is the
/// discard gesture). There is therefore no `cancel()` on this type at all — the absence is the
/// design, not an omission, and adding one later would be adding a way to lose a file.
///
/// ### The emptied draft deletes itself, and this type does not know that
///
/// "A draft emptied of text with no attachments deletes its folder — no litter." That rule lives in
/// `BoardWriter.saveCommentDraft`, which is why an emptied composer here simply *saves empty text*
/// and reports `.deleted` back. Re-deriving the condition would be a second place for "no text and no
/// attachments" to mean something slightly different.
@MainActor
@Observable
public final class CommentDraftSession {

    // MARK: State

    /// What the composer is showing — the buffer when it is dirty, disk when it is not, exactly the
    /// order `CardBodyEditSession` settles for the body.
    public private(set) var text: String = ""

    /// What the last read said `comments/.draft/index.md` holds. The write gate's other half; never
    /// shown.
    public private(set) var disk: String = ""

    /// The draft's `attachments/`, republished from every thread read — the chips the composer draws,
    /// and half of what decides whether there is anything to post.
    ///
    /// It lives here rather than beside the thread because the *rule* it feeds is this session's: a
    /// draft with no text but a file in it is still a draft (it does not delete, and it does post).
    public var attachments: [String] = []

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

    /// **Whether ⌘↩ / the Comment button have anything to post** — the emptied-draft rule read
    /// forwards: a save of this buffer would delete the folder exactly when there is nothing to post,
    /// so the two questions have one answer (`CommentDraft.isEmpty`).
    public var canPost: Bool {
        !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty
    }

    // MARK: Seams

    /// The lazy interval — **~30 s** (05 ▸ The comments column), and settable so a test does not have
    /// to spend it. `CardBodyEditSession.debounceInterval`'s precedent, with its production default
    /// on the property.
    @ObservationIgnored
    public var saveInterval: Duration = .seconds(30)

    /// Where a save goes — `BoardStore.saveCommentDraft(inCard:body:)`, filled in by the window once
    /// it has a store and a card to aim at.
    ///
    /// A closure for `CardBodyEditSession.save`'s reason exactly: this type is a buffer and a clock,
    /// and it stays testable by having no idea what a board is. `nil` — or a `nil` answer, which is
    /// what a failed write and a vanished card both give — is a save that did not land, and the
    /// buffer stays dirty rather than reporting success.
    @ObservationIgnored
    public var save: ((String) -> CommentDraftOutcome?)?

    /// The post — `BoardStore.postComment(inCard:)`, which renames `.draft` to a fresh UUID and
    /// restamps in one bracket. `nil` answers a post that did not happen.
    @ObservationIgnored
    public var post: (() -> ItemID?)?

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

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

    public init() {}

    // MARK: - Disk → buffer

    /// A thread read arrived. **Dirty-buffer-wins**, the body's rule applied here for its reason: the
    /// composer is a text surface with a cursor in it, and a reload landing another machine's draft
    /// under that cursor would be the app eating keystrokes.
    ///
    /// `nil` is a card with no draft at all — the ordinary state before the first keystroke, and the
    /// state a post leaves behind. Disk is then the empty string, which is what a clean composer
    /// shows.
    public func adopt(draft: CommentDraft?) {
        let wasDirty = isDirty
        attachments = draft?.attachments ?? []
        disk = draft?.body ?? ""
        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 != disk { text = disk }
    }

    // MARK: - Buffer → disk

    /// The composer changed. **Arms the interval; never restarts it** — see the type's note.
    ///
    /// A change that brings the buffer back to what disk already says cancels the armed save outright,
    /// `CardBodyEditSession.edited(_:)`'s *reverted* gate: a tick that fired on a no-op would stamp
    /// `modified` on a draft nobody touched.
    public func edited(_ newText: String) {
        guard text != newText else { return }
        text = newText
        guard isDirty else {
            cancelPending()
            return
        }
        armInterval()
    }

    /// **Composer blur** — the first of 05's four cadence moments. Named rather than folded into
    /// `flush()` because it is the one a view calls, and because the other three are the window's.
    @discardableResult
    public func blurred() -> CommentDraftOutcome? {
        flush()
    }

    /// Saves now if there is anything to save, disarming the interval first — window close, app quit,
    /// and blur all land here.
    ///
    /// Synchronous, because the write is: the close path has to know the answer before it lets the
    /// window go (`CardBodyEditSession.flush()`'s reason, unchanged).
    @discardableResult
    public func flush() -> CommentDraftOutcome? {
        cancelPending()
        return saveNow()
    }

    /// **⌘↩ and the Comment button** — one gesture: flush the buffer into `.draft/`, then rename it
    /// into the thread (05 ▸ The comments column: "posting renames `.draft` → a fresh lowercase UUID
    /// and **restamps** `created`/`modified` in the same write bracket … one gesture, one commit").
    ///
    /// **The flush comes first and is not optional.** The post is a rename of a *folder*, so whatever
    /// the composer has not yet written would simply not be in the comment — the one place the slow
    /// cadence would otherwise be visible as lost text.
    ///
    /// Nothing to post is a no-op rather than a refusal: the button is disabled and ⌘↩ in an empty
    /// composer should do nothing at all, not post an empty comment and not raise anything.
    ///
    /// **After a post the composer empties**, because the draft is gone — the folder it was editing is
    /// now a comment in the thread. Clearing `disk` too is what keeps the buffer clean rather than
    /// dirty-against-a-file-that-no-longer-exists.
    @discardableResult
    public func postNow() -> ItemID? {
        guard canPost else { return nil }
        flush()
        guard let posted = post?() else { return nil }
        cancelPending()
        text = ""
        disk = ""
        attachments = []
        return posted
    }

    // MARK: - Private

    /// Arms the interval **once**. A second dirty keystroke inside the period rides the timer that is
    /// already running, which is the difference between an interval and a debounce.
    private func armInterval() {
        guard pending == nil else { return }
        let interval = saveInterval
        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, so the thread read arriving a moment later
    /// finds the buffer already clean. A `nil` — a failure, a suspension under the read-only lock, a
    /// vanished card — leaves `disk` where it was, which keeps the buffer dirty and therefore keeps
    /// the text.
    ///
    /// `.deleted` is a landing like any other: the folder is gone *because* the buffer was empty, so
    /// disk and the buffer agree perfectly.
    private func saveNow() -> CommentDraftOutcome? {
        guard isDirty else { return nil }
        guard let save else { return nil }

        saveAttempts += 1
        let outcome = save(text)
        if outcome != nil {
            disk = text
        }
        return outcome
    }
}
