import SwiftUI
import AppKit

/// Which keystroke sends a message from the chat composer. Persisted in
/// `UserDefaults` and surfaced in Settings; both the home chat bar and the session
/// composer read it so the behavior — and the send-button glyph — stay consistent.
enum SubmitKeyMode: String, CaseIterable, Identifiable {
    /// Return sends the message; Shift+Return inserts a newline. (⌘Return also sends.)
    case returnSends
    /// Return and Shift+Return both insert a newline; only ⌘Return sends.
    case modifierSends

    var id: String { rawValue }
    static let storageKey = "nucleic.composer.submitKey"

    var label: String {
        switch self {
        case .returnSends: "Return"
        case .modifierSends: "⌘ + Return"
        }
    }

    var detail: String {
        switch self {
        case .returnSends: "Press Return to send. Shift+Return adds a new line."
        case .modifierSends: "Press ⌘Return to send. Return and Shift+Return add a new line."
        }
    }
}

/// Small glyph mirroring the keystroke that sends the message — the Return symbol on
/// its own, or ⌘ + Return — so the send button shows exactly which key fires it.
struct SubmitKeyIcon: View {
    let mode: SubmitKeyMode

    var body: some View {
        switch mode {
        case .returnSends:
            Image(systemName: "return")
                .font(.system(size: 16, weight: .semibold))
        case .modifierSends:
            HStack(spacing: 1) {
                Image(systemName: "command")
                Image(systemName: "return")
            }
            .font(.system(size: 12, weight: .semibold))
        }
    }
}

/// A multiline chat input backed by `NSTextView` so the composer can grow with the
/// text, scroll once it reaches its max height, and distinguish Return /
/// Shift+Return / ⌘Return for sending vs. inserting a newline — none of which the
/// SwiftUI `TextField` exposes on macOS. Visual chrome (padding, background) is
/// applied by the caller; this view draws only the text and placeholder.
struct ChatInputField: NSViewRepresentable {
    @Binding var text: String
    var placeholder: String
    var submitMode: SubmitKeyMode
    var isEnabled: Bool
    /// The fitted height (one line at rest, growing with the text, capped at half the
    /// window). The caller applies it via `.frame(height:)`; past the cap the text
    /// view scrolls inside that frame. A binding rather than `intrinsicContentSize`
    /// because SwiftUI lets an unconstrained `NSScrollView` grow to fill its parent.
    @Binding var height: CGFloat
    /// Fires when the send keystroke is pressed. The `background` flag carries whether Shift
    /// was held (⌘⇧Return), so the host can choose to fire off in the background vs. follow
    /// into the result; hosts that don't distinguish simply ignore it.
    var onSend: (_ background: Bool) -> Void
    /// Files/images pasted into or dropped onto the field, handed up so the composer can stage
    /// them as attachment chips. Defaulted so callers that don't accept attachments are unaffected.
    var onAttach: ([ComposerAttachment]) -> Void = { _ in }
    /// Fires true while a file/image drag hovers the field and false when it leaves or drops, so
    /// the composer can light up the drop zone.
    var onDropTargetedChanged: (Bool) -> Void = { _ in }
    /// When true, the field grabs first-responder focus once it's on-screen — for sheets/panels
    /// (e.g. the quick-capture idea box) that should be ready to type into the moment they open.
    /// Off by default so an inline composer doesn't steal focus on appear.
    var autoFocus = false

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

    /// Resting height when no window is available yet — one line plus insets, so the
    /// composer starts at its prior single-line size instead of flashing tall/short.
    /// Computed from the same font metrics `recomputeHeight()` uses for `minH`
    /// (`defaultLineHeight` + the top/bottom `textContainerInset`), so an empty field
    /// starts at the exact height it settles to and never visibly shrinks on open.
    static let restingHeight: CGFloat = {
        let font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
        let lineHeight = NSLayoutManager().defaultLineHeight(for: font)
        return lineHeight + 2 * 2  // textContainerInset.height (2) on top and bottom
    }()

    func makeNSView(context: Context) -> HeightTrackingScrollView {
        let textView = ComposerTextView()
        textView.delegate = context.coordinator
        textView.isRichText = false
        textView.allowsUndo = true
        textView.font = .systemFont(ofSize: NSFont.systemFontSize)
        textView.textColor = .labelColor
        textView.insertionPointColor = .labelColor
        textView.drawsBackground = false
        textView.backgroundColor = .clear
        textView.textContainerInset = NSSize(width: 0, height: 2)
        textView.isVerticallyResizable = true
        textView.isHorizontallyResizable = false
        textView.autoresizingMask = [.width]
        textView.minSize = NSSize(width: 0, height: 0)
        textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
        textView.textContainer?.widthTracksTextView = true
        textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
        textView.textContainer?.lineFragmentPadding = 0
        textView.string = text
        textView.placeholderString = placeholder
        // Accept file/image drags in addition to the text view's own drag types (don't replace
        // them — that would break dragging text into the field). Whichever type fires, the drop
        // handler re-reads the pasteboard for files or images.
        let mediaTypes: [NSPasteboard.PasteboardType] = [
            .fileURL, .png, .tiff, .pdf, NSPasteboard.PasteboardType("public.jpeg"),
        ]
        textView.registerForDraggedTypes(textView.registeredDraggedTypes + mediaTypes)

        let scroll = HeightTrackingScrollView()
        scroll.documentView = textView
        scroll.drawsBackground = false
        scroll.borderType = .noBorder
        scroll.hasVerticalScroller = true
        scroll.hasHorizontalScroller = false
        scroll.autohidesScrollers = true
        // Overlay from the start; the override above keeps AppKit from switching it back.
        scroll.scrollerStyle = .overlay

        context.coordinator.textView = textView
        context.coordinator.scrollView = scroll
        context.coordinator.pendingAutoFocus = autoFocus
        scroll.onLayout = { [weak coordinator = context.coordinator] in
            coordinator?.recomputeHeight()
            coordinator?.focusIfNeeded()
        }
        apply(to: textView, coordinator: context.coordinator)
        context.coordinator.recomputeHeight()
        // The window isn't attached yet in makeNSView; try once on the next runloop (and again
        // from onLayout above) so focus lands as soon as the field is on-screen.
        if autoFocus {
            DispatchQueue.main.async { [weak coordinator = context.coordinator] in
                coordinator?.focusIfNeeded()
            }
        }
        return scroll
    }

    func updateNSView(_ scroll: HeightTrackingScrollView, context: Context) {
        context.coordinator.parent = self
        guard let textView = scroll.documentView as? ComposerTextView else { return }
        let textChanged = textView.string != text
        if textChanged {
            textView.string = text
            context.coordinator.publishComposerTextChange()
        }
        apply(to: textView, coordinator: context.coordinator)
        // The composer re-renders with every parent update — every streamed transcript
        // event — so the height pass (a full `ensureLayout`) runs only when this update
        // actually replaced the text. Typing recomputes via `textDidChange`, and window
        // resizes via the scroll view's `onLayout`; nothing else can change the height.
        if textChanged { context.coordinator.recomputeHeight() }
    }

    /// Push the current SwiftUI-side configuration onto the text view. Called from
    /// both make/update so the two paths can't drift. Each display-affecting property
    /// is written only on change — an unconditional set (notably `placeholderString`,
    /// whose didSet marks the view dirty) forced a redraw on every SwiftUI re-render.
    private func apply(to textView: ComposerTextView, coordinator: Coordinator) {
        if textView.placeholderString != placeholder { textView.placeholderString = placeholder }
        if textView.isEditable != isEnabled {
            textView.isEditable = isEnabled
            textView.isSelectable = isEnabled
            textView.alphaValue = isEnabled ? 1 : 0.5
        }
        textView.submitMode = submitMode
        textView.onSend = { [weak textView, weak coordinator] background in
            guard let textView, textView.isEditable else { return }
            coordinator?.parent.onSend(background)
        }
        // Route through the coordinator's live `parent` so these never capture a stale closure.
        textView.onAttach = { [weak coordinator] attachments in
            coordinator?.parent.onAttach(attachments)
        }
        textView.onDropTargetedChanged = { [weak coordinator] targeted in
            coordinator?.parent.onDropTargetedChanged(targeted)
        }
    }

    @MainActor
    final class Coordinator: NSObject, NSTextViewDelegate {
        var parent: ChatInputField
        weak var textView: ComposerTextView?
        weak var scrollView: HeightTrackingScrollView?
        /// Set when `autoFocus` is on; cleared once focus actually lands (the field needs a
        /// window first, which it lacks in `makeNSView`), so it fires exactly once.
        var pendingAutoFocus = false

        init(_ parent: ChatInputField) { self.parent = parent }

        func textDidChange(_ notification: Notification) {
            guard let textView else { return }
            parent.text = textView.string
            publishComposerTextChange()
            recomputeHeight()
        }

        /// Let the in-session Intelligence slider follow this AppKit-backed field without
        /// coupling the representable to its SwiftUI host. Direct-bound new-chat sliders do
        /// not consume this compatibility notification.
        func publishComposerTextChange() {
            guard let textView, let window = textView.window else { return }
            NotificationCenter.default.post(
                name: .composerTextDidChange,
                object: window,
                userInfo: [ComposerTextChangeNotification.textKey: textView.string])
        }

        /// Make the text view first responder once it has a window — the auto-focus path for a
        /// freshly presented sheet/panel. No-ops after it succeeds so later layout passes (window
        /// resize, text growth) don't yank focus back.
        func focusIfNeeded() {
            guard pendingAutoFocus, let textView, let window = textView.window else { return }
            window.makeFirstResponder(textView)
            pendingAutoFocus = false
        }

        /// Report the fitted height: one line at rest, growing with the text, capped
        /// at half the window's height (beyond which the text view scrolls).
        func recomputeHeight() {
            guard let textView,
                  let layoutManager = textView.layoutManager,
                  let container = textView.textContainer else { return }
            layoutManager.ensureLayout(for: container)
            let font = textView.font ?? .systemFont(ofSize: NSFont.systemFontSize)
            let lineHeight = layoutManager.defaultLineHeight(for: font)
            let inset = textView.textContainerInset.height * 2
            let content = layoutManager.usedRect(for: container).height + inset
            let minH = lineHeight + inset
            // No taller than half the window; fall back to ~6 lines before it's onscreen.
            // In the floating (⌘N/⌘⇧N) presentation the field lives in a sheet whose window
            // auto-sizes to this composer's content — so measuring "half the window" against
            // `textView.window` there is circular: the field grows → the sheet grows → the
            // window grows → the cap grows → the field grows again, a feedback settle that
            // reads as the popup distorting and creeping downward on every keystroke. Cap
            // against the sheet's parent (the real app window) instead, which is a stable
            // reference; the inline home bar has no sheet parent and keeps using its own window.
            let referenceWindow = textView.window?.sheetParent ?? textView.window
            let windowHalf = referenceWindow.map { $0.contentLayoutRect.height / 2 }
            let maxH = max(minH, windowHalf ?? (lineHeight * 6 + inset))
            let clamped = min(max(content, minH), maxH)
            guard abs(parent.height - clamped) > 0.5 else { return }
            // Defer the binding write: recompute can fire from inside a SwiftUI
            // update (updateNSView) or AppKit layout pass.
            DispatchQueue.main.async { [weak self] in
                self?.parent.height = clamped
            }
        }
    }
}

/// Scroll view that notifies on relayout (e.g. width changes from a window resize)
/// so the fitted height can be recomputed for the new text wrapping.
final class HeightTrackingScrollView: NSScrollView {
    var onLayout: (() -> Void)?

    /// Force overlay-style scrollers, rejecting the system's legacy style ("Show scroll
    /// bars: Always", common with a mouse attached). A legacy scroller consumes layout
    /// width when it appears, which re-wraps the text one line taller, which grows the
    /// fitted height, which makes the content fit so the auto-hiding scroller disappears,
    /// which widens the text back, re-wraps it shorter, shrinks the height, and brings the
    /// scroller back — an endless oscillation that reads as the floating (⌘N/⌘⇧N) composer
    /// bouncing/creeping downward with its text squashed (the sheet window resizes with
    /// every cycle), and as a subtler jitter in the inline composers. Overlay scrollers
    /// take no layout width, so scroller visibility can never feed back into text wrap.
    /// AppKit re-applies the user's preferred style after init and on preference changes
    /// through this setter, so coercing it here keeps the style pinned for good.
    override var scrollerStyle: NSScroller.Style {
        get { super.scrollerStyle }
        set { _ = newValue; super.scrollerStyle = .overlay }
    }

    override func layout() {
        super.layout()
        onLayout?()
    }
}

/// Text view that routes Return/Shift+Return/⌘Return to send-or-newline per the
/// active `SubmitKeyMode`, and paints a placeholder when empty.
final class ComposerTextView: NSTextView {
    var placeholderString: String = "" { didSet { needsDisplay = true } }
    var submitMode: SubmitKeyMode = .modifierSends
    /// Send callback; the flag is true when Shift was held with the send keystroke (⌘⇧Return).
    var onSend: ((_ background: Bool) -> Void)?
    /// Files/images pasted or dropped onto the field. Set by `ChatInputField`.
    var onAttach: (([ComposerAttachment]) -> Void)?
    /// Drag-hover state for the field's drop-zone highlight.
    var onDropTargetedChanged: ((Bool) -> Void)?

    // MARK: Paste

    /// Intercept ⌘V when the clipboard holds files or an image, staging them as attachments
    /// instead of pasting a path/picture into the text. Ordinary text (including rich text that
    /// merely carries an image rep) falls through to the normal paste.
    override func paste(_ sender: Any?) {
        let pasteboard = NSPasteboard.general
        if let onAttach, ComposerAttachment.pasteboardHasAttachments(pasteboard) {
            let attachments = ComposerAttachment.fromPasteboard(pasteboard)
            if !attachments.isEmpty {
                onAttach(attachments)
                return
            }
        }
        super.paste(sender)
    }

    // MARK: Drag & drop

    override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
        if ComposerAttachment.pasteboardHasDraggableMedia(sender.draggingPasteboard) {
            onDropTargetedChanged?(true)
            return .copy
        }
        return super.draggingEntered(sender)
    }

    override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
        if ComposerAttachment.pasteboardHasDraggableMedia(sender.draggingPasteboard) {
            return .copy
        }
        return super.draggingUpdated(sender)
    }

    override func draggingExited(_ sender: NSDraggingInfo?) {
        onDropTargetedChanged?(false)
        super.draggingExited(sender)
    }

    override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool {
        if ComposerAttachment.pasteboardHasDraggableMedia(sender.draggingPasteboard) { return true }
        return super.prepareForDragOperation(sender)
    }

    override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
        onDropTargetedChanged?(false)
        let pasteboard = sender.draggingPasteboard
        if let onAttach, ComposerAttachment.pasteboardHasDraggableMedia(pasteboard) {
            let attachments = ComposerAttachment.fromPasteboard(pasteboard)
            if !attachments.isEmpty {
                onAttach(attachments)
                return true
            }
        }
        return super.performDragOperation(sender)
    }

    override func keyDown(with event: NSEvent) {
        let isReturn = event.keyCode == 36 || event.keyCode == 76  // Return, numeric-keypad Enter
        // Don't hijack Return while an input method is composing (e.g. confirming
        // a Pinyin/Kana candidate), and never send from a disabled composer.
        if isReturn, isEditable, !hasMarkedText() {
            let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
            let shift = flags.contains(.shift)
            let command = flags.contains(.command)
            let send: Bool
            switch submitMode {
            case .returnSends: send = command || !shift   // Return / ⌘Return send; Shift+Return = newline
            case .modifierSends: send = command           // only ⌘Return sends
            }
            if send {
                // Shift held with the send keystroke (⌘⇧Return) asks the host to fire off in the
                // background rather than follow into the result. Whenever a send carries Shift,
                // Command is also held (Shift-only Return inserts a newline in both modes), so the
                // flag cleanly distinguishes ⌘Return from ⌘⇧Return.
                onSend?(shift)
                return
            }
            // Otherwise fall through so AppKit inserts the newline.
        }
        super.keyDown(with: event)
    }

    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
        guard string.isEmpty, !placeholderString.isEmpty else { return }
        let attrs: [NSAttributedString.Key: Any] = [
            .foregroundColor: NSColor.placeholderTextColor,
            .font: font ?? .systemFont(ofSize: NSFont.systemFontSize),
        ]
        let padding = textContainer?.lineFragmentPadding ?? 0
        let point = NSPoint(x: textContainerInset.width + padding, y: textContainerInset.height)
        placeholderString.draw(at: point, withAttributes: attrs)
    }
}
