import AppKit
import SwiftUI

// MARK: - CardRawSourceView

/// The raw-source outlet's content: a monospaced editor over the literal `index.md`, with Cancel and
/// Apply beneath it (05-card-window.md ▸ Raw source outlet).
///
/// It replaces **the whole content area** — title, body and sidebar — rather than sitting beside
/// them, which is the design's own word for it and the reason the interactive controls are gone
/// while it is up: "the same frontmatter is being edited as raw text, so interactive controls over it
/// would fight the raw edit". The window keeps its title bar, its subtitle and its toolbar; nothing
/// inside the window survives.
///
/// ### No syntax highlighting, deliberately
///
/// 05 gives the Edit editor "lightweight Markdown syntax highlighting" and gives this one exactly
/// "a monospaced editor". The asymmetry is honest rather than an omission: this file is YAML *and*
/// Markdown with a delimiter between them, and a Markdown pass run over the frontmatter would tint
/// `---` as a thematic break and a `# comment` as a heading — dressing the file up as something it
/// is not, in the one surface whose promise is that it shows the file as it is. What the editor does
/// borrow is the *font*: `MarkdownHighlighter.baseAttributes` is the app's one monospaced run, so the
/// two editors match without either owning a font.
///
/// ### What it shares with the body surface, and why
///
/// ⌘F, the find bar, selection, copying, session-scoped undo, and the suppression of every automatic
/// substitution are all here too — the same reasons `CardBodySurface` gives, and one more that is
/// specific to this surface: a smart quote substituted into YAML would be the app corrupting the
/// user's frontmatter as they typed it.
struct CardRawSourceView: View {

    let session: CardRawSourceSession
    /// The window's body handle — the raw editor takes over its `findInText` while it is on screen,
    /// because the body surface it normally points at has been unmounted by the swap.
    let presentation: CardBodyPresentation

    private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }

    var body: some View {
        VStack(spacing: 0) {
            CardRawSourceEditor(session: session, presentation: presentation)
                .frame(maxWidth: .infinity, maxHeight: .infinity)

            Divider()

            HStack(spacing: bodyPointSize * 0.75) {
                Spacer()
                // Escape. `.cancelAction` and the text view's own `cancelOperation` both aim here —
                // belt and braces, because which of the two sees the key depends on where focus is —
                // and `cancel()` is idempotent, so a double hit is one discard.
                Button("Cancel") { session.cancel() }
                    .keyboardShortcut(.cancelAction)
                // **⌘↩, never plain Return** — "Return just types — it's an editor" (05). Which is
                // also why this is not `.defaultAction`: that would bind Return, and the first
                // newline the user typed in their frontmatter would apply the file instead.
                Button("Apply") { session.applyAndLeave() }
                    .keyboardShortcut(.return, modifiers: .command)
                    .buttonStyle(.borderedProminent)
            }
            .padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
            .padding(.vertical, bodyPointSize * 0.6)
        }
    }
}

// MARK: - The editor

/// The hosted text view: TextKit's own editor, configured for a file rather than for prose.
private struct CardRawSourceEditor: NSViewRepresentable {

    let session: CardRawSourceSession
    let presentation: CardBodyPresentation

    func makeCoordinator() -> Coordinator {
        Coordinator(session: session)
    }

    func makeNSView(context: Context) -> NSScrollView {
        // The stack is built by hand for `CardBodySurface`'s reason — `NSTextView(frame:)` hands back
        // a TextKit 2 view, and this app's text machinery (its find bar, its storage access) is
        // written against TextKit 1 throughout. One substrate, not two.
        let storage = NSTextStorage()
        let layoutManager = NSLayoutManager()
        storage.addLayoutManager(layoutManager)
        let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
        container.widthTracksTextView = true
        layoutManager.addTextContainer(container)

        let textView = CardRawSourceTextView(frame: .zero, textContainer: container)
        textView.delegate = context.coordinator
        textView.isEditable = true
        textView.isSelectable = true
        // Plain text in every sense: no attributes the user can introduce, and none the app adds
        // beyond the monospaced base run.
        textView.isRichText = false
        textView.drawsBackground = false
        textView.isVerticallyResizable = true
        textView.isHorizontallyResizable = false
        textView.autoresizingMask = .width
        textView.minSize = CGSize(width: 0, height: 0)
        textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
        // The list `CardBodySurface` explains, with more at stake: a smart quote or an en dash
        // substituted into YAML is not a cosmetic liberty, it is the app rewriting the user's
        // frontmatter behind the cursor.
        textView.isAutomaticLinkDetectionEnabled = false
        textView.isAutomaticQuoteSubstitutionEnabled = false
        textView.isAutomaticDashSubstitutionEnabled = false
        textView.isAutomaticTextReplacementEnabled = false
        textView.isAutomaticSpellingCorrectionEnabled = false
        textView.isAutomaticDataDetectionEnabled = false
        textView.smartInsertDeleteEnabled = false
        textView.isContinuousSpellCheckingEnabled = false
        textView.allowsUndo = true
        textView.usesFindBar = true
        textView.isIncrementalSearchingEnabled = true
        // One run, three properties: a plain-text `NSTextView` drives display off `font` and
        // `textColor` rather than off `typingAttributes`, so all three are filled from the app's
        // single monospaced run — `MarkdownHighlighter.baseAttributes`, borrowed for its font rather
        // than for its highlighting, which this editor deliberately does not do.
        let base = MarkdownHighlighter.baseAttributes(pointSize: CardWindowMetrics.bodyPointSize)
        textView.typingAttributes = base
        textView.font = base[.font] as? NSFont
        textView.textColor = base[.foregroundColor] as? NSColor

        let gutter = CardWindowMetrics.gutter(bodyPointSize: CardWindowMetrics.bodyPointSize)
        textView.textContainerInset = CGSize(width: gutter, height: gutter)

        textView.onCancel = { [session] in session.cancel() }

        let scrollView = NSScrollView()
        scrollView.documentView = textView
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalScroller = false
        scrollView.autohidesScrollers = true
        scrollView.drawsBackground = false
        scrollView.findBarPosition = .aboveContent

        context.coordinator.textView = textView

        // Deferred one turn, `CardBodySurface`'s reason: this runs inside a SwiftUI update and both
        // writes below re-enter graphs that update is already walking.
        Task { @MainActor [weak textView] in
            guard let textView else { return }
            // ⌘F is find-in-text "over the focused body surface (Preview's selectable text, the Edit
            // editor, **raw source**)" — 05 ▸ Preview names this surface explicitly. The body
            // surface's own closure died with the view the swap unmounted; this one replaces it, and
            // the body surface reclaims it when the swap goes the other way.
            presentation.findInText = { [weak textView] in
                guard let textView else { return }
                textView.window?.makeFirstResponder(textView)
                let sender = NSMenuItem()
                sender.tag = NSTextFinder.Action.showFindInterface.rawValue
                textView.performTextFinderAction(sender)
            }
            textView.window?.makeFirstResponder(textView)
        }

        return scrollView
    }

    func updateNSView(_ scrollView: NSScrollView, context: Context) {
        context.coordinator.session = session
        guard let textView = scrollView.documentView as? CardRawSourceTextView,
              let storage = textView.textStorage
        else { return }

        // The equality guard is load-bearing, not an optimization: this runs on every keystroke (the
        // buffer changed, so SwiftUI re-ran the update), and re-setting the storage to the string it
        // already holds would collapse the selection and drop the undo stack per character typed.
        // In practice it therefore only fires once — the initial fill, where the text flows the other
        // way for the only time in the session.
        guard storage.string != session.text else { return }
        let selected = textView.selectedRange()
        storage.setAttributedString(NSAttributedString(
            string: session.text,
            attributes: MarkdownHighlighter.baseAttributes(pointSize: CardWindowMetrics.bodyPointSize)
        ))
        let length = (session.text as NSString).length
        textView.setSelectedRange(NSRange(
            location: min(selected.location, length),
            length: min(selected.length, max(0, length - min(selected.location, length)))
        ))
    }

    // MARK: Coordinator

    @MainActor
    final class Coordinator: NSObject, NSTextViewDelegate {

        var session: CardRawSourceSession
        weak var textView: NSTextView?

        /// **The editor's own undo manager** — `CardBodySurface`'s rule, applied to the surface 06
        /// names in the same breath: "While a text-editing surface is focused (card title field, body
        /// Edit mode, **raw source**, board inline rename), ⌘Z/⇧⌘Z are that editor's own text undo —
        /// standard, transient, session-scoped" (06-history-undo.md ▸ Undo routing). Owning one here
        /// rather than borrowing the window's is what keeps ⌘Z after a Cancel from reaching back into
        /// a buffer that was deliberately discarded.
        private let editorUndoManager = UndoManager()

        init(session: CardRawSourceSession) {
            self.session = session
        }

        func undoManager(for view: NSTextView) -> UndoManager? {
            editorUndoManager
        }

        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            session.text = textView.string
        }

        /// Focus leaving the editor ends the undo session (06's "session-scoped"), and is not a
        /// commit: only Apply writes, and it is a button and a chord, never a side effect of clicking
        /// somewhere else.
        func textDidEndEditing(_ notification: Notification) {
            editorUndoManager.removeAllActions()
        }
    }
}

// MARK: - CardRawSourceTextView

/// The raw editor's text view, subclassed for exactly one key.
///
/// **Escape is Cancel** (05 ▸ Raw source outlet), and it has to be caught here because an editable
/// `NSTextView` has its own meaning for it (text completion) and would swallow it before the button's
/// key equivalent ever ran. With the find bar up the bar is first responder and this is never
/// reached — which is correct: Escape closes the find bar, exactly as it does everywhere else in
/// macOS.
final class CardRawSourceTextView: NSTextView {

    var onCancel: (() -> Void)?

    override func cancelOperation(_ sender: Any?) {
        guard let onCancel else {
            super.cancelOperation(sender)
            return
        }
        onCancel()
    }
}
