import Foundation
import Observation

// MARK: - CardTitleEditSession

/// The card window header's title field — **a body-edit session in miniature**
/// (`CardBodyEditSession`'s own phrase for `CommentEditSession`, applied one level up), scoped to the
/// single line the header shows in Edit mode.
///
/// ### What it borrows from `CardBodyEditSession`, verbatim
///
/// The buffer/disk pair, the single write predicate (*write if and only if the buffer differs from
/// disk*), dirty-buffer-wins on `adopt(diskTitle:)`, the ~700 ms trailing debounce, `flush()` /
/// `flushOrThrow()`, and the begin/end pair whose end registers the session's one undo step. **There
/// is no session boundary of this type's own** — the card's title is only ever editable while the
/// body column is in Edit mode (m6-card-body's rule, stated on `CardWindowView`'s header), so this
/// session's `beginEditSession()` / `endEditSession()` are called from the exact places
/// `CardBodyEditSession`'s are (`CardWindowHost.configureSession`'s `bodyPresentation.flushEdits` /
/// `.beginEdits`, and `CardWindowSession.endSession()`), never on a schedule of their own. That is the
/// literal reading of "title dirtiness rides the same gates/flush points as body".
///
/// ### What differs, and why it is a sibling rather than a merge
///
/// The write target is a different `WriteOperation` — a title commits through the same `.rename`
/// vocabulary the board's inline rename uses (`BoardStore.commitCardTitle`, `BoardStore.setTitle`),
/// never `.editBody`. Folding the two into one session would mean one buffer standing for two
/// unrelated frontmatter keys with two different validation rules (a title trims and empty-removes;
/// a body does neither), so a sibling object — driven by the same triggers, writing through its own
/// seam — is the shape that keeps each write's rules in one place.
///
/// ### The value is `String?`, so "not yet captured" needs its own bit
///
/// `CardBodyEditSession.sessionOriginBody` uses `nil` for "no save has landed this session" because a
/// body is never itself optional. A title's on-disk value legitimately *is* `nil` — a card can be
/// untitled — so this type keeps `sessionHasOrigin` beside `sessionOriginTitle` rather than reusing
/// `nil` for two different meanings.
///
/// ### Single-line, by construction and by filter
///
/// A plain `TextField` already turns Return into a submit rather than a newline (`CardWindowView`'s
/// header wires `.onSubmit` to `flush()`), which is the whole of "Return ends editing rather than
/// inserting" on the keystroke that matters. `edited(_:)` additionally strips `\n`/`\r` from
/// whatever arrives — typing cannot produce either, but a paste can — so the buffer this type shows
/// and writes is single-line even when the text came in some other way. Trimming leading/trailing
/// whitespace and turning an empty result into `nil` happen at commit (`normalize(_:)`), mirroring
/// the board's inline rename exactly (`BoardStore.commitRename`): a title of three spaces reads as
/// untouched, never as a real (if blank) name.
@MainActor
@Observable
public final class CardTitleEditSession {

    // MARK: State

    /// What the field is showing — raw, as typed, never trimmed. Preview's header reads this too
    /// (`CardWindowView.bodyColumn`), for `CardBodyEditSession.text`'s own reason: a flushed buffer is
    /// ahead of the snapshot by a reload, so the buffer is the truer of the two in both modes.
    public private(set) var text: String = ""

    /// What the last snapshot said the card's `title` key holds. `nil` is untitled, a legitimate
    /// resting state rather than a missing reading.
    public private(set) var disk: String?

    /// Whether the buffer, once normalized the way a commit would, differs from disk. Comparing the
    /// *normalized* text rather than the raw one is what keeps typing spaces around an untouched title
    /// from reading as dirty — `commitRename`'s own "whitespace commits as empty" rule, read one level
    /// earlier.
    public var isDirty: Bool { Self.normalize(text) != disk }

    /// Whether an Edit session is open right now — `CardBodyEditSession.isEditing`'s own bookkeeping,
    /// kept here too because this type's undo step coalesces the same way body's does: one step per
    /// session, captured from the first landed save to the session's end.
    public private(set) var isEditing = false

    // MARK: Seams

    /// The debounce interval — body's own ~700 ms default, and settable for the same reason
    /// (`DragSession.holdTimeout`'s precedent: production default on the property, a test dials it
    /// down).
    @ObservationIgnored
    public var debounceInterval: Duration = .milliseconds(700)

    /// Where a save goes — `BoardStore.commitCardTitle(inCard:title:)`, filled in by the window once it
    /// has joined its board. Takes the *normalized* title (trimmed, empty turned to `nil`), which is
    /// what the store's own write gate compares against the card's current title.
    @ObservationIgnored
    public var save: ((String?) -> CardBodyWriteOutcome)?

    /// Where this session's one undo step goes, called at `endEditSession()` with the title disk held
    /// when the session's first save landed and the title it holds now —
    /// `CardBodyEditSession.registerUndo`'s seam, one field over.
    @ObservationIgnored
    public var registerUndo: ((_ priorTitle: String?, _ newTitle: String?) -> Void)?

    /// Whether a save has landed this session — `CardBodyEditSession.sessionOriginBody`'s `nil` check,
    /// spelled as its own flag because `String?` is a real value here, not a "not captured" marker.
    @ObservationIgnored
    private var sessionHasOrigin = false

    /// What disk said before this session's first landed save, when `sessionHasOrigin` is `true`.
    @ObservationIgnored
    private var sessionOriginTitle: String?

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

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

    public init() {}

    // MARK: - Disk → buffer

    /// A snapshot arrived. Dirty-buffer-wins, `CardBodyEditSession.adopt(diskBody:)`'s exact shape:
    /// `disk` always follows it; `text` follows it only when the buffer had nothing unsaved.
    public func adopt(diskTitle: String?) {
        let wasDirty = isDirty
        disk = diskTitle
        guard !wasDirty else { return }
        let shown = diskTitle ?? ""
        if text != shown { text = shown }
    }

    // MARK: - Buffer → disk

    /// The field changed. Strips embedded newlines first (paste's own hazard — typing a single Return
    /// never reaches here at all, since a plain `TextField` treats it as a submit), then restarts the
    /// debounce, or cancels it when the change brought the buffer back to what a commit of it would
    /// leave on disk.
    public func edited(_ newText: String) {
        let sanitized = Self.stripNewlines(newText)
        guard text != sanitized else { return }
        text = sanitized
        guard isDirty else {
            cancelPending()
            return
        }
        scheduleSave()
    }

    /// Saves now if there is anything to save, cancelling the pending debounce first — the mode-exit
    /// and window-close flush, `CardBodyEditSession.flush()`'s own reason.
    @discardableResult
    public func flush() -> CardBodyWriteOutcome {
        cancelPending()
        return saveNow()
    }

    /// The start of one Edit session, called alongside `CardBodyEditSession.beginEditSession()` from
    /// the same door (`CardBodyPresentation.beginEdits`). Idempotent for the same reason: a
    /// re-published focus value or a redundant menu validation must not renotify observers over an
    /// already-open session.
    public func beginEditSession() {
        guard !isEditing else { return }
        isEditing = true
    }

    /// The end of one Edit session, called alongside `CardBodyEditSession.endEditSession()` from the
    /// same door. Flushes, then registers the session's one undo step when something of this session's
    /// actually landed and left a net change.
    @discardableResult
    public func endEditSession() -> CardBodyWriteOutcome {
        let outcome = flush()
        if sessionHasOrigin, sessionOriginTitle != disk {
            registerUndo?(sessionOriginTitle, disk)
        }
        sessionHasOrigin = false
        sessionOriginTitle = nil
        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, `CardBodyEditSession.discardBuffer()`'s exact shape.
    public func discardBuffer() {
        cancelPending()
        text = disk ?? ""
        sessionHasOrigin = false
        sessionOriginTitle = nil
        if isEditing { isEditing = false }
    }

    /// `DirtyBufferGuard`'s `attemptSave`, folded together with the body's own in
    /// `CardWindowSession.init()`: the same flush, with a real failure raised instead of reported. The
    /// three non-failures are `CardBodyEditSession.flushOrThrow()`'s own — written/unchanged land the
    /// text or need not, vanished has nowhere to write, suspended is the standing read-only lock row
    /// rather than a failure — and none of them is a reason to block the close.
    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 landing moves `disk` up to the normalized text
    /// that landed, so the echo arriving a reload later finds the buffer already clean.
    private func saveNow() -> CardBodyWriteOutcome {
        guard isDirty else { return .unchanged }
        guard let save else { return .vanished }

        saveAttempts += 1
        let priorTitle = disk
        let normalized = Self.normalize(text)
        let outcome = save(normalized)
        switch outcome {
        case .written:
            if !sessionHasOrigin {
                sessionHasOrigin = true
                sessionOriginTitle = priorTitle
            }
            disk = normalized
        case .unchanged:
            disk = normalized
        case .suspended, .vanished, .failed:
            break
        }
        return outcome
    }

    /// Strips `\r\n`, `\n` and `\r` outright — the single-line field's own hard rule, applied to
    /// whatever arrives rather than only to what a commit would write, so the on-screen text never
    /// shows a line break a paste tried to bring in.
    private static func stripNewlines(_ raw: String) -> String {
        raw
            .replacingOccurrences(of: "\r\n", with: "")
            .replacingOccurrences(of: "\n", with: "")
            .replacingOccurrences(of: "\r", with: "")
    }

    /// Trimmed, empty turned to `nil` — `BoardStore.commitRename`'s own reading of a typed title,
    /// applied here so `isDirty` and the value handed to `save` agree with what the board's inline
    /// rename would do with the same keystrokes.
    private static func normalize(_ raw: String) -> String? {
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }
}
