import Observation
import SwiftUI

// MARK: - CardRawSourceSession

/// One card window's raw-source outlet: whether it is showing, the text in it, and the three seams
/// through which it reaches the file (05-card-window.md ▸ Raw source outlet).
///
/// ### Why this is a window state and not a third body mode
///
/// `CardBodyMode` says it in its own doc comment, and this type is the other half of that sentence:
/// the outlet "swaps the **entire content area — title, body, and sidebar —** for the literal on-disk
/// `index.md`", so it is a state of the *window*, not of the body column. Folding it into the mode
/// enum would have made every `switch` over Preview/Edit answer a question about the sidebar.
///
/// ### The mode grammar, settled where 05 leaves it open
///
/// 05 fixes the keys (⌥⌘E in and out, Escape is Cancel, ⌘↩ is Apply, toggling off applies) and says
/// nothing about which body mode the window returns to. Two decisions record themselves here:
///
/// - **Leaving raw source lands in Preview.** Not a fresh choice — a consequence: 05 ▸ Mode grammar
///   lists "raw-source entry" among the three events that *leave Edit* ("Leaving Edit flushes the
///   debounce (mode flip, raw-source entry, window close)"). Entering therefore genuinely leaves
///   Edit, and Preview is the resting state it leaves to; exiting simply reveals what was already
///   there. It reads right, too: after an Apply that may have rewritten the body wholesale, the
///   rendered result is the useful thing to show, not an editor over text the user just retyped.
/// - **An empty body after Apply does not open Edit.** The empty-body rule is about *opening a card*
///   and has already run for this window (`CardBodyPresentation.openIfNeeded`); a raw exit is not an
///   open. A user who emptied the body in raw source gets the blank Preview they wrote, and ⌘E.
///
/// ### The seams, and why they are closures
///
/// `flushPendingEdits`, `read` and `apply` are filled in by the window once it has a store and a card
/// (`CardWindowHost.configureSession`) — `CardBodyEditSession.save`'s precedent, and for its reason:
/// this type is a buffer and a state machine, and it stays testable by having no idea what a board
/// is. `nil` seams mean a window that has not joined its board yet, and every one of them fails
/// closed — the outlet does not open, and an Apply writes nothing.
@MainActor
@Observable
public final class CardRawSourceSession {

    // MARK: State

    /// Whether the outlet is showing — the whole of "View ▸ Raw Source's checkmark", "the content
    /// area is swapped", and "Edit Body disables while source mode is active".
    public private(set) var isActive = false

    /// The editor's buffer: the file as it was read, plus whatever the user has typed since.
    ///
    /// **Never reconciled with disk while it is open.** A watcher reload arriving mid-session updates
    /// everything else and leaves this alone — 05's dirty-buffer rule, and here it is not even a
    /// question of dirtiness: the buffer is the *whole file*, so "following disk" would mean throwing
    /// away the user's edit the moment anything on the board changed. "A pull landing mid-session
    /// neither blocks on the open buffer nor invalidates it … Apply stays last-writer-wins" (05).
    public var text = ""

    /// The file as `enter()` read it — the baseline behind `holdsUnsavedText`, and never shown.
    ///
    /// `CardBodyEditSession.disk`'s counterpart, with the same job and none of its reconciliation:
    /// this one is set once, when the outlet opens, and is not followed by a watcher reload (the
    /// buffer above is deliberately never reconciled while it is open), so it answers exactly one
    /// question — has the user typed anything since the file was read.
    private var loadedText = ""

    /// **Whether an open outlet is holding bytes the file does not** — the raw-source half of
    /// 09-templates.md ▸ Save as Template's carve-out, which stays live under the
    /// unwritable-location lock "unless an open Edit or raw-source session holds unsaved content the
    /// suspended saves can't flush" (02-architecture.md ▸ Live-reload resilience).
    ///
    /// A closed outlet holds nothing (the buffer is emptied on the way out), and an open one that
    /// has not been typed in holds nothing either — it is showing the file. Apply is the only thing
    /// that writes it, and under the lock Apply is refused, which is precisely why a template taken
    /// past this text would silently miss it.
    public var holdsUnsavedText: Bool { isActive && text != loadedText }

    /// The alert waiting to be shown, if any — a validation refusal on Apply, or a file that could
    /// not be opened as source. Cleared by the OK that dismisses it, which returns the user to the
    /// text they were editing (05: "a failed validation keeps source mode open (toggle stays
    /// checked) with the alert").
    public private(set) var alert: CardRawSourceAlert?

    // MARK: Seams

    /// Flushes pending title and body edits — "Entering source mode flushes any pending title/body
    /// edits first, **then** reads the file fresh from disk" (05). The ordering is the contract: the
    /// read must see the flushed body, or the outlet would open on a file the app was about to
    /// overwrite from a buffer the user could no longer see.
    ///
    /// The window wires this to the body column's own flush *and* to the Preview flip, which is the
    /// same call: `CardBodyPresentation.setMode(.preview)` flushes on its way out of Edit by
    /// construction.
    @ObservationIgnored
    public var flushPendingEdits: (() -> Void)?

    /// Reads the file fresh — `BoardStore.readCardSource(inCard:)`.
    @ObservationIgnored
    public var read: (() -> RawSourceReadOutcome)?

    /// Validates and writes the buffer — `BoardStore.applyCardSource(inCard:text:)`.
    @ObservationIgnored
    public var apply: ((String) -> RawSourceApplyOutcome)?

    /// How many Applies have actually reached the seam. The state machine's own testimony: "Cancel
    /// wrote nothing" is otherwise an inference from bytes that a passing test could reach by
    /// accident.
    @ObservationIgnored
    public private(set) var applyAttempts = 0

    public init() {}

    // MARK: - Entering

    /// ⌥⌘E on a window that is not in source mode: flush, then read fresh, then show.
    ///
    /// **A file that cannot be read does not open the outlet** (settled here — 05 covers Apply's
    /// failure and is silent on entry's). The alternative, opening an editor over an error message or
    /// over lossily-decoded bytes, would make the escape hatch's one promise false: whatever is in
    /// that editor is what is in the file, and Apply writes it back. A refusal keeps the toggle
    /// unchecked and says why.
    ///
    /// - Returns: whether source mode opened.
    @discardableResult
    public func enter() -> Bool {
        guard !isActive else { return true }
        flushPendingEdits?()

        guard let read else { return false }
        switch read() {
        case let .read(source):
            text = source
            loadedText = source
            alert = nil
            isActive = true
            return true
        case .vanished:
            // The window is dismissing itself; an alert about a card that is already gone from the
            // board would outlive the thing it is about.
            return false
        case let .failed(error):
            alert = .unreadable(error)
            return false
        }
    }

    // MARK: - Leaving

    /// ⌘↩, the Apply button, and **⌥⌘E toggling off** — "leaving-by-toggle commits, mirroring
    /// leaving-Edit-flushes" (05).
    ///
    /// Three outcomes close the outlet and three keep it open; see `RawSourceApplyOutcome`. The two
    /// that keep it open without an alert of their own (a failed write, a locked board) are already
    /// spoken for by the banner strip and the standing lock row, and what matters here is the same in
    /// both cases: the text stays on screen, because it is the only place it exists.
    ///
    /// - Returns: whether source mode closed.
    @discardableResult
    public func applyAndLeave() -> Bool {
        guard isActive else { return false }
        guard let apply else { return false }

        applyAttempts += 1
        switch apply(text) {
        case .applied, .unchanged:
            close()
            return true
        case .vanished:
            // "A card hard-deleted externally (folder gone) discards both — nowhere left to write"
            // (05 ▸ Deletion & lifecycle).
            close()
            return true
        case let .invalid(error):
            alert = .invalid(error)
            return false
        case .suspended, .failed:
            return false
        }
    }

    /// Escape, the Cancel button, and the window closing — **"discards without ceremony"** (05).
    ///
    /// No flush, no confirmation, no `DirtyBufferGuard`: the raw buffer was never a debounced session
    /// with saves owed to it, and its Apply is an explicit act the user did not perform. The close
    /// path needs no call at all — the window goes and the buffer with it — which is exactly why this
    /// is safe to be a one-liner.
    public func cancel() {
        close()
    }

    /// OK on the alert — back to the text, which was never touched.
    public func dismissAlert() {
        alert = nil
    }

    private func close() {
        isActive = false
        alert = nil
        text = ""
        loadedText = ""
    }
}

// MARK: - The alert

/// The raw-source outlet's one modal surface: **the detailed alert** 05 requires on a failed
/// validation, plus the entry refusal that shares its shape.
///
/// It carries the errors rather than strings so the phrasing stays in one place and the taxonomy
/// stays undiluted — the loader's own message, line number included, is what makes an alert
/// "detailed" rather than "sorry, something is wrong".
public enum CardRawSourceAlert: Sendable, Equatable {
    /// Apply refused: the text would not load (`BoardLoader.validateCardIndex`).
    case invalid(BoardLoadError)

    /// The file could not be opened as source — unreadable, or not UTF-8.
    case unreadable(BoardWriteError)

    /// The alert's title — what happened, in the user's vocabulary (Apply; opening the source).
    public var title: String {
        switch self {
        case .invalid: "These source changes can't be applied"
        case .unreadable: "This card's source can't be opened"
        }
    }

    /// The detail, plus the reassurance that nothing was written. Both cases end in the same place —
    /// the file on disk is exactly as it was — which is the fact that makes the alert dismissible
    /// with a single OK.
    public var message: String {
        switch self {
        case let .invalid(error):
            "\(error.reason.description)\n\nThe file on disk is unchanged."
        case let .unreadable(error):
            "\(error.reason.description)\n\nThe file on disk is unchanged."
        }
    }
}

// MARK: - View ▸ Raw Source

/// View ▸ Raw Source (⌥⌘E) — the card window's raw-source toggle, with checkmark state
/// (11-command-nexus.md: "Raw Source (checkmark toggle; toggling off = Apply)"; 05-card-window.md ▸
/// Raw source outlet).
///
/// **A `Toggle` whose two directions are not symmetric**, which is the row's whole subtlety: on
/// enters, off *applies*. 04-interactions.md ▸ Configurable bindings requires one stable title with
/// checkmark state only, so the row cannot say "Apply" when it is checked — the asymmetry lives in
/// the action, and the checkmark simply fails to clear when a validation refuses (05: "a failed
/// validation keeps source mode open (toggle stays checked) with the alert"). That falls out for
/// free: the checkmark reads `isActive`, and `applyAndLeave()` leaves it standing.
///
/// Validation is scope alone — a card window in front. The read-only lock is deliberately not part of
/// it, `EditBodyCommand`'s reasoning: entering source mode is a *read*, and 02-architecture.md § the
/// lock's scope keeps editor buffers alive under the lock so a locked board's file can still be
/// opened and copied out. The Apply is the mutation, and `performWrite` refuses it there.
struct RawSourceCommand: View {

    @FocusedValue(\.cardRawSource) private var rawSource

    var body: some View {
        Toggle("Raw Source", isOn: Binding(
            get: { rawSource?.isActive == true },
            set: { isOn in
                guard let rawSource else { return }
                if isOn {
                    rawSource.enter()
                } else {
                    rawSource.applyAndLeave()
                }
            }
        ))
        .keyboardShortcut("e", modifiers: [.option, .command])
        .disabled(rawSource == nil)
    }
}

/// The focused card window's raw-source outlet, beside `FocusedValues.cardBody` — see
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
///
/// Its own focused value rather than a field on `CardBodyPresentation`: the two are read by different
/// rows (⌘E and ⌥⌘E), and one of them — Edit Body — needs *both*, which is exactly the shape a
/// separate key expresses and a merged object would hide.
struct FocusedCardRawSourceKey: FocusedValueKey {
    typealias Value = CardRawSourceSession
}

extension FocusedValues {
    var cardRawSource: CardRawSourceSession? {
        get { self[FocusedCardRawSourceKey.self] }
        set { self[FocusedCardRawSourceKey.self] = newValue }
    }
}
