import AppKit
import SwiftUI

// MARK: - CommentTextEditor

/// The text surface both **authoring** surfaces use: the composer, and an inline comment edit
/// session (05-card-window.md ▸ The comments column).
///
/// ### One editor for both, because they are one thing twice
///
/// 05 describes the composer as "an always-visible text area ('Add a comment…', Edit-mode Markdown
/// highlighting)" and the inline session as "a body-edit session in miniature". Both are raw Markdown
/// with the body editor's highlighting over it, both end on ⌘↩, both answer Escape, and both are
/// where a dropped file lands for their own folder. What differs is entirely outside this view —
/// which buffer the keystrokes go to, what ⌘↩ means, what Escape means — so all four arrive as
/// closures and none of them is decided here.
///
/// ### The highlighting is the body editor's, exactly
///
/// `MarkdownHighlighter` emits ranges and never a string, so "the text is the raw Markdown, character
/// for character — no hidden transforms, no smart substitutions" (05 ▸ Edit) holds here for free, and
/// the same smart-substitution deregistrations are repeated below because a comment is as much the
/// user's file as a body is.
///
/// ### It declines file drags, like the body editor
///
/// `acceptableDragTypes` drops the file types so AppKit's hit-test walks past the text view — which
/// is what lets the SwiftUI drop target *around* this view take the drop (the composer's carve-out,
/// `CommentDropCarveOut`) instead of `NSTextView` inserting a path into the user's Markdown. The
/// mechanism is `CardBodyTextView`'s, verbatim; only the target above it differs.
struct CommentTextEditor: NSViewRepresentable {

    let text: String
    let isEditable: Bool
    /// Every keystroke — straight into the session, which owns the cadence.
    let onEdit: (String) -> Void
    /// ⌘↩ — Post for the composer, Save for an inline session (11-command-nexus.md's grammar table).
    let onCommandReturn: () -> Void
    /// Escape — "focus moves out, draft untouched" for the composer; Cancel for an inline session.
    let onEscape: () -> Void
    /// Focus left. The composer's first cadence moment ("composer blur"); nothing for an inline
    /// session, whose commit points are its two buttons.
    var onBlur: () -> Void = {}
    /// Bumped to ask for the keyboard — File ▸ Add Comment's second half, and an inline session
    /// opening. A counter rather than a flag: two requests in a row are two requests.
    var focusRequest: Int = 0
    /// The pane's focus register. This surface reports itself as an **authoring** one, which is what
    /// makes ⌘F here the editor's ordinary find rather than the thread's (05-card-window.md ▸ Preview:
    /// "the composer and an inline comment edit are their own focused text surfaces with the editor's
    /// ordinary find"). `nil` in a preview or a test that mounts the editor alone.
    var focus: CardComments?

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

    func makeNSView(context: Context) -> NSScrollView {
        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 = CommentEditorTextView(frame: .zero, textContainer: container)
        textView.delegate = context.coordinator
        textView.isEditable = isEditable
        textView.isSelectable = true
        textView.isRichText = false
        textView.drawsBackground = false
        textView.isVerticallyResizable = true
        textView.isHorizontallyResizable = false
        textView.autoresizingMask = NSView.AutoresizingMask.width
        textView.minSize = CGSize(width: 0, height: 0)
        textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
        // The body editor's list, and normative here for its reason: a smart quote substituted into a
        // fenced code block would be the app silently corrupting the user's file.
        textView.isAutomaticLinkDetectionEnabled = false
        textView.isAutomaticQuoteSubstitutionEnabled = false
        textView.isAutomaticDashSubstitutionEnabled = false
        textView.isAutomaticTextReplacementEnabled = false
        textView.isAutomaticSpellingCorrectionEnabled = false
        textView.isAutomaticDataDetectionEnabled = false
        textView.smartInsertDeleteEnabled = false
        textView.allowsUndo = true
        textView.usesFindBar = true
        textView.isIncrementalSearchingEnabled = true

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

        textView.onCommandReturn = onCommandReturn
        textView.onEscape = onEscape

        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
        context.coordinator.onEdit = onEdit
        context.coordinator.onBlur = onBlur

        // The pane's focus register, and — while this editor holds the keyboard — the find ⌘F runs.
        // `performTextFinderAction` takes its verb from the sender's `tag`, which is how the standard
        // Edit ▸ Find item drives it (`CardBodySurface`'s own note); the menu item's key equivalent
        // fires before this view ever sees ⌘F, so the action has to be reachable from outside.
        let focus = focus
        textView.onFocusChange = { [weak textView] gained in
            guard gained else {
                focus?.focusLeft(.authoring)
                return
            }
            focus?.focusEntered(.authoring) { [weak textView] in
                guard let textView else { return }
                textView.window?.makeFirstResponder(textView)
                let sender = NSMenuItem()
                sender.tag = NSTextFinder.Action.showFindInterface.rawValue
                textView.performTextFinderAction(sender)
            }
        }
        return scrollView
    }

    func updateNSView(_ scrollView: NSScrollView, context: Context) {
        let coordinator = context.coordinator
        coordinator.onEdit = onEdit
        coordinator.onBlur = onBlur

        guard let textView = scrollView.documentView as? CommentEditorTextView else { return }
        textView.isEditable = isEditable
        textView.onCommandReturn = onCommandReturn
        textView.onEscape = onEscape

        coordinator.show(text, in: textView, pointSize: CardWindowMetrics.bodyPointSize)

        guard focusRequest != coordinator.servedFocusRequest else { return }
        coordinator.servedFocusRequest = focusRequest
        guard focusRequest > 0 else { return }
        // Deferred a turn: this runs inside a SwiftUI update, and making a view first responder
        // re-enters AppKit's responder machinery (`CardBodySurface.Coordinator.enter`'s rule).
        Task { @MainActor [weak textView] in
            guard let textView else { return }
            textView.window?.makeFirstResponder(textView)
        }
    }

    // MARK: - Coordinator

    @MainActor
    final class Coordinator: NSObject, NSTextViewDelegate {

        weak var textView: NSTextView?
        var onEdit: ((String) -> Void)?
        var onBlur: (() -> Void)?
        var servedFocusRequest = 0

        /// Set while this coordinator is replacing the view's text, so the resulting change
        /// notification is not mistaken for typing.
        private var isSettingText = false

        /// `CardBodySurface.Coordinator.show(_:in:pointSize:)`, unchanged and for its reason: the
        /// equality guard is load-bearing rather than an optimization, because this runs on every
        /// keystroke and replacing the storage with the string it already holds would collapse the
        /// selection and throw away the undo stack on every character typed.
        func show(_ text: String, in textView: NSTextView, pointSize: CGFloat) {
            guard let storage = textView.textStorage else { return }

            if storage.string != text {
                let selected = textView.selectedRange()
                isSettingText = true
                storage.setAttributedString(NSAttributedString(
                    string: text,
                    attributes: MarkdownHighlighter.baseAttributes(pointSize: pointSize)
                ))
                isSettingText = false
                let length = (text as NSString).length
                textView.setSelectedRange(NSRange(
                    location: min(selected.location, length),
                    length: min(selected.length, max(0, length - min(selected.location, length)))
                ))
            }

            MarkdownHighlighter.highlight(storage, pointSize: pointSize)
            textView.typingAttributes = MarkdownHighlighter.baseAttributes(pointSize: pointSize)
        }

        func textDidChange(_ notification: Notification) {
            guard !isSettingText, let textView = notification.object as? NSTextView else { return }
            onEdit?(textView.string)
        }

        /// **Composer blur is a save** (05 ▸ The comments column, the first of the four cadence
        /// moments). For an inline session `onBlur` is empty: its commit points are Save and Cancel,
        /// and clicking away from it is neither.
        func textDidEndEditing(_ notification: Notification) {
            onBlur?()
        }
    }
}

// MARK: - The editor's text view

/// The authoring editor's text view, subclassed for the two keys 11-command-nexus.md's grammar table
/// gives it and for the drag types it must not take.
final class CommentEditorTextView: NSTextView {

    var onCommandReturn: (() -> Void)?
    var onEscape: (() -> Void)?

    /// **It says when it has the keyboard** — `CommentBodyTextView`'s pair, and for its reason: ⌘F's
    /// route is a focus question, and the responder is the only thing that can answer it. Reported
    /// here rather than through `textDidEndEditing` because that fires when the *field editor* ends,
    /// which for an uneditable editor (the read-only lock) never happens at all.
    var onFocusChange: ((Bool) -> Void)?

    override func becomeFirstResponder() -> Bool {
        let accepted = super.becomeFirstResponder()
        if accepted { onFocusChange?(true) }
        return accepted
    }

    override func resignFirstResponder() -> Bool {
        let resigned = super.resignFirstResponder()
        if resigned { onFocusChange?(false) }
        return resigned
    }

    /// **⌘↩** — "Post the draft … / end the edit session at its commit point"
    /// (11-command-nexus.md ▸ Fixed grammar keys). Intercepted before `super`, which would otherwise
    /// insert a newline: the chord is the gesture, not a decorated Return.
    override func keyDown(with event: NSEvent) {
        let isReturn = event.keyCode == 36 || event.keyCode == 76
        let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
            .subtracting([.function, .numericPad, .capsLock])
        if isReturn, modifiers == .command, let onCommandReturn {
            onCommandReturn()
            return
        }
        super.keyDown(with: event)
    }

    /// **Escape.** What it *means* is the caller's — focus out for the composer (never a discard),
    /// Cancel for an inline session — which is why this only forwards. Intercepted before
    /// `NSTextView`'s own meaning for it (text completion); with the find bar up the bar is first
    /// responder and never reaches this.
    override func cancelOperation(_ sender: Any?) {
        guard let onEscape else {
            super.cancelOperation(sender)
            return
        }
        onEscape()
    }

    /// **A file drop is never the editor's** — `CardBodyTextView`'s deregistration, here so the drop
    /// falls through to the authoring surface's own target and lands in *this* surface's
    /// `attachments/` (05 ▸ Attachments, the hover-target carve-out).
    override var acceptableDragTypes: [NSPasteboard.PasteboardType] {
        let fileTypes: Set<NSPasteboard.PasteboardType> = [
            .fileURL,
            NSPasteboard.PasteboardType("NSFilenamesPboardType")
        ]
        return super.acceptableDragTypes.filter { !fileTypes.contains($0) }
    }
}
