import SwiftUI
import UIKit

@MainActor
private func applyComposerInputTraits(
    to textView: UITextView,
    autocorrectionEnabled: Bool
) {
    if autocorrectionEnabled {
        textView.autocorrectionType = .default
        textView.autocapitalizationType = .sentences
        textView.spellCheckingType = .default
        textView.smartQuotesType = .default
        textView.smartDashesType = .default
        textView.smartInsertDeleteType = .default
        textView.textContentType = .none
        textView.writingToolsBehavior = .complete
        textView.allowedWritingToolsResultOptions = .plainText
        return
    }

    textView.autocorrectionType = .no
    textView.autocapitalizationType = .none
    textView.spellCheckingType = .no
    textView.smartQuotesType = .no
    textView.smartDashesType = .no
    textView.smartInsertDeleteType = .no
    textView.textContentType = .none
    textView.writingToolsBehavior = .none
    textView.allowedWritingToolsResultOptions = .plainText

    let assistant = textView.inputAssistantItem
    assistant.leadingBarButtonGroups = []
    assistant.trailingBarButtonGroups = []
}

@MainActor
private func requestSystemDictation(for textView: UITextView) {
    // UIKit does not expose a direct public "start dictation now" method.
    // This hints to the system that dictation is expected and ensures the
    // text view is active so keyboard dictation can start immediately when
    // supported by the current keyboard/input mode.
    if #available(iOS 16.4, *) {
        UITextInputContext.current()?.isDictationInputExpected = true
    }

    if !textView.isFirstResponder {
        textView.becomeFirstResponder()
    }

    textView.reloadInputViews()
}

func composerVolatileTranscriptBackgroundColor() -> UIColor {
    // Needs to read clearly against the darker glass composer background
    // without looking like a full text selection.
    UIColor(Color.themeBlue.opacity(0.20))
}

/// Clamp raw inline composer content height to min/max line bounds.
///
/// - Parameters:
///   - rawContentHeight: Measured text content height including vertical insets.
///   - lineHeight: Single-line font height.
///   - verticalInsets: Sum of top + bottom text container inset.
///   - maxLines: Maximum inline lines before internal scrolling.
func inlineComposerHeight(
    rawContentHeight: CGFloat,
    lineHeight: CGFloat,
    verticalInsets: CGFloat,
    maxLines: Int
) -> CGFloat {
    let safeLineHeight = max(lineHeight, 1)
    let safeInsets = max(verticalInsets, 0)
    let safeMaxLines = max(maxLines, 1)

    let minHeight = ceil(safeLineHeight + safeInsets)
    let maxHeight = ceil((safeLineHeight * CGFloat(safeMaxLines)) + safeInsets)
    return min(max(ceil(rawContentHeight), minHeight), maxHeight)
}

/// Heuristic fast path for very large inline composer text.
///
/// For text far beyond visible inline capacity, measuring exact wrapped height
/// requires expensive TextKit layout. Since inline composer height is clamped
/// to `maxLines` anyway, we can safely jump straight to max height.
func inlineComposerShouldFastPathToMaxHeight(
    textLength: Int,
    containerWidth: CGFloat,
    lineHeight: CGFloat,
    maxLines: Int
) -> Bool {
    let safeTextLength = max(textLength, 0)

    let safeContainerWidth: CGFloat
    if containerWidth.isFinite {
        safeContainerWidth = max(containerWidth, 1)
    } else {
        safeContainerWidth = 1
    }

    let safeLineHeight: CGFloat
    if lineHeight.isFinite {
        safeLineHeight = max(lineHeight, 1)
    } else {
        safeLineHeight = 1
    }

    let safeMaxLines = max(maxLines, 1)

    let estimatedCharacterWidth = max(safeLineHeight * 0.5, 1)
    let estimatedCharsPerLine = max(floor(safeContainerWidth / estimatedCharacterWidth), 1)
    let visibleCapacity = estimatedCharsPerLine * CGFloat(safeMaxLines)
    let overflowThreshold = max(visibleCapacity * 2, CGFloat(safeMaxLines) * 40)

    return CGFloat(safeTextLength) > overflowThreshold
}

/// A UITextView wrapper that supports pasting images from the clipboard.
///
/// SwiftUI's `TextField` ignores image paste events. This UIViewRepresentable
/// intercepts `paste:` to check `UIPasteboard.general` for images, forwarding
/// them via `onPasteImages`. Text paste still works normally.
///
/// Inline mode auto-expands with content up to `maxLines`, then keeps
/// scrolling internally for overflow.
struct PastableTextView: UIViewRepresentable {
    @Binding var text: String
    let placeholder: String
    let font: UIFont
    let textColor: UIColor
    let tintColor: UIColor
    let volatileSuffixLength: Int
    let correctionRanges: [NSRange]
    let maxLines: Int
    let autocorrectionEnabled: Bool
    let onPasteImages: ([UIImage]) -> Void
    let onCommandEnter: (() -> Void)?
    let onAlternateEnter: (() -> Void)?
    let onOverflowChange: ((Bool) -> Void)?
    let onLineCountChange: ((Int) -> Void)?
    let onFocusChange: ((Bool) -> Void)?
    let onDictationStateChange: ((Bool) -> Void)?
    let focusRequestID: Int
    let blurRequestID: Int
    let dictationRequestID: Int
    let suppressKeyboard: Bool
    let allowKeyboardRestoreOnTap: Bool
    let onKeyboardRestoreRequest: (() -> Void)?
    let accessibilityIdentifier: String?
    /// BCP 47 language code of the text view's active keyboard (e.g. "zh-Hans", "en-US").
    /// Updated when the keyboard input mode changes. Used by voice input to
    /// select the correct speech model.
    @Binding var keyboardLanguage: String?

    #if DEBUG
    /// Narrow test hook used by the dictation preview to prove both the
    /// synchronous and next-main-loop caret positions for every streamed update.
    var selectionProbeForTesting: PastableUITextView.SelectionProbe? = nil
    #endif

    func makeUIView(context: Context) -> PastableUITextView {
        let textView = PastableUITextView()
        textView.delegate = context.coordinator
        textView.onPasteImages = onPasteImages
        textView.onCommandEnter = onCommandEnter
        textView.onAlternateEnter = onAlternateEnter
        textView.font = font
        textView.textColor = textColor
        textView.tintColor = tintColor
        textView.backgroundColor = .clear
        textView.clipsToBounds = true
        textView.textContainerInset = UIEdgeInsets(top: 6, left: 0, bottom: 6, right: 0)
        textView.textContainer.lineFragmentPadding = 0

        // Keep scrolling enabled at all times.
        // Dynamic toggling (based on frame/height) creates UIKit↔SwiftUI
        // layout loops under chat timeline pressure.
        textView.isScrollEnabled = true
        textView.setContentCompressionResistancePriority(.required, for: .vertical)
        textView.setContentHuggingPriority(.required, for: .vertical)

        // Configure input traits for current composer mode.
        // Monospaced mode uses stability traits (no autocorrect); proportional
        // mode re-enables natural-language pipelines.
        applyComposerInputTraits(to: textView, autocorrectionEnabled: autocorrectionEnabled)
        context.coordinator.lastAutocorrectionEnabled = autocorrectionEnabled

        // Force TextKit 1. The default TextKit 2 path showed pathological
        // layout behavior under SwiftUI pressure on device.
        _ = textView.layoutManager

        textView.isAccessibilityElement = true
        textView.accessibilityIdentifier = accessibilityIdentifier
        #if DEBUG
        textView.selectionProbeForTesting = selectionProbeForTesting
        #endif

        textView.onKeyboardRestoreRequest = onKeyboardRestoreRequest
        textView.setAllowKeyboardRestoreOnTap(allowKeyboardRestoreOnTap)
        textView.installKeyboardRestoreGesture()
        if suppressKeyboard {
            textView.setKeyboardSuppressed(true)
        }

        return textView
    }

    func updateUIView(_ textView: PastableUITextView, context: Context) {
        let textChanged = textView.text != text
        textView.applyStyledText(
            text,
            font: font,
            baseColor: textColor,
            volatileSuffixLength: volatileSuffixLength,
            volatileColor: UIColor(Color.themeBlue),
            volatileBackgroundColor: composerVolatileTranscriptBackgroundColor(),
            correctionRanges: correctionRanges,
            correctionUnderlineColor: UIColor(Color.themeOrange),
            scrollCaretToVisible: false
        )
        textView.onPasteImages = onPasteImages
        textView.onCommandEnter = onCommandEnter
        textView.onAlternateEnter = onAlternateEnter
        textView.tintColor = tintColor

        let traitsChanged = context.coordinator.lastAutocorrectionEnabled != autocorrectionEnabled
        context.coordinator.lastAutocorrectionEnabled = autocorrectionEnabled
        applyComposerInputTraits(to: textView, autocorrectionEnabled: autocorrectionEnabled)
        if traitsChanged {
            DispatchQueue.main.async {
                guard textView.window != nil else { return }
                textView.reloadInputViews()
            }
        }

        textView.accessibilityIdentifier = accessibilityIdentifier
        #if DEBUG
        textView.selectionProbeForTesting = selectionProbeForTesting
        #endif

        // Manage keyboard suppression — must apply before focus request so
        // inputView is set before becomeFirstResponder fires.
        textView.onKeyboardRestoreRequest = onKeyboardRestoreRequest
        if textView.allowsKeyboardRestoreOnTap != allowKeyboardRestoreOnTap {
            textView.setAllowKeyboardRestoreOnTap(allowKeyboardRestoreOnTap)
        }
        if textView.isKeyboardSuppressed != suppressKeyboard {
            textView.setKeyboardSuppressed(suppressKeyboard)
        }

        if focusRequestID != context.coordinator.lastFocusRequestID {
            context.coordinator.lastFocusRequestID = focusRequestID
            DispatchQueue.main.async {
                guard textView.window != nil else { return }
                if !textView.isFirstResponder {
                    textView.becomeFirstResponder()
                }
                textView.moveCaretToEnd()
            }
        }

        if blurRequestID != context.coordinator.lastBlurRequestID {
            context.coordinator.lastBlurRequestID = blurRequestID
            DispatchQueue.main.async {
                if textView.isFirstResponder {
                    textView.resignFirstResponder()
                }
            }
        }

        if dictationRequestID != context.coordinator.lastDictationRequestID {
            context.coordinator.lastDictationRequestID = dictationRequestID
            DispatchQueue.main.async {
                guard textView.window != nil else { return }
                requestSystemDictation(for: textView)
            }
        }

        // Refresh line count for programmatic text changes (e.g. voice transcription)
        // so the expand button tracks streamed content.
        if textChanged {
            let coordinator = context.coordinator
            DispatchQueue.main.async { [weak textView] in
                guard let textView else { return }
                coordinator.notifyLineCountIfNeeded(textView)
            }
        }
    }

    func sizeThatFits(_ proposal: ProposedViewSize, uiView textView: PastableUITextView, context: Context) -> CGSize? {
        let proposedWidth = proposal.width ?? textView.bounds.width
        let fallbackWidth = textView.window?.windowScene?.screen.bounds.width ?? 320
        let safeFallbackWidth = fallbackWidth.isFinite && fallbackWidth > 0 ? fallbackWidth : 320
        let candidateWidth = proposedWidth > 0 ? proposedWidth : safeFallbackWidth

        let width: CGFloat
        if candidateWidth.isFinite && candidateWidth > 0 && candidateWidth < 10_000 {
            width = candidateWidth
        } else {
            width = safeFallbackWidth
        }

        let lineHeight = textView.font?.lineHeight ?? font.lineHeight
        let verticalInsets = textView.textContainerInset.top + textView.textContainerInset.bottom
        let horizontalInsets = textView.textContainerInset.left
            + textView.textContainerInset.right
            + (textView.textContainer.lineFragmentPadding * 2)

        let containerWidth = max(width - horizontalInsets, 1)
        if inlineComposerShouldFastPathToMaxHeight(
            textLength: textView.textStorage.length,
            containerWidth: containerWidth,
            lineHeight: lineHeight,
            maxLines: maxLines
        ) {
            let maxInlineHeight = inlineComposerHeight(
                rawContentHeight: .greatestFiniteMagnitude,
                lineHeight: lineHeight,
                verticalInsets: verticalInsets,
                maxLines: maxLines
            )
            return CGSize(width: width, height: maxInlineHeight)
        }

        textView.textContainer.size = CGSize(width: containerWidth, height: .greatestFiniteMagnitude)
        textView.layoutManager.ensureLayout(for: textView.textContainer)

        let usedTextHeight = textView.layoutManager.usedRect(for: textView.textContainer).height + verticalInsets
        let rawContentHeight = max(usedTextHeight, textView.contentSize.height)
        let inlineHeight = inlineComposerHeight(
            rawContentHeight: rawContentHeight,
            lineHeight: lineHeight,
            verticalInsets: verticalInsets,
            maxLines: maxLines
        )

        return CGSize(width: width, height: inlineHeight)
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(
            text: $text,
            keyboardLanguage: $keyboardLanguage,
            maxLines: maxLines,
            onOverflowChange: onOverflowChange,
            onLineCountChange: onLineCountChange,
            onFocusChange: onFocusChange,
            onDictationStateChange: onDictationStateChange
        )
    }

    final class Coordinator: NSObject, UITextViewDelegate {
        @Binding var text: String
        @Binding var keyboardLanguage: String?
        let maxLines: Int
        let onOverflowChange: ((Bool) -> Void)?
        let onLineCountChange: ((Int) -> Void)?
        let onFocusChange: ((Bool) -> Void)?
        let onDictationStateChange: ((Bool) -> Void)?

        var lastFocusRequestID = 0
        var lastBlurRequestID = 0
        var lastDictationRequestID = 0
        var lastAutocorrectionEnabled: Bool?
        private var lastOverflowState = false
        private var lastDictationState = false
        private var lastReportedLineCount = 1
        nonisolated(unsafe) private var inputModeObserver: NSObjectProtocol?

        init(
            text: Binding<String>,
            keyboardLanguage: Binding<String?>,
            maxLines: Int,
            onOverflowChange: ((Bool) -> Void)?,
            onLineCountChange: ((Int) -> Void)?,
            onFocusChange: ((Bool) -> Void)?,
            onDictationStateChange: ((Bool) -> Void)?
        ) {
            _text = text
            _keyboardLanguage = keyboardLanguage
            self.maxLines = maxLines
            self.onOverflowChange = onOverflowChange
            self.onLineCountChange = onLineCountChange
            self.onFocusChange = onFocusChange
            self.onDictationStateChange = onDictationStateChange
            super.init()

            // Track keyboard language changes via notification.
            // The textView's textInputMode.primaryLanguage gives the actual
            // active keyboard for THIS text view — not a global list.
            inputModeObserver = NotificationCenter.default.addObserver(
                forName: UITextInputMode.currentInputModeDidChangeNotification,
                object: nil,
                queue: .main
            ) { [weak self] _ in
                Task { @MainActor [weak self] in
                    self?.updateKeyboardLanguage()
                }
            }
        }

        deinit {
            if let observer = inputModeObserver {
                NotificationCenter.default.removeObserver(observer)
            }
        }

        /// Read the keyboard language from the actual text view via responder chain.
        ///
        /// Skips updates when the keyboard is suppressed (voice recording mode).
        /// With `inputView` set to an empty `UIView`, UIKit's `textInputMode`
        /// may report nil or the device's default keyboard instead of the
        /// user's previously active one. Writing that stale value would corrupt
        /// both the `@State keyboardLanguage` binding and the persisted
        /// `AppPreferences.Keyboard`, causing the next voice session to use
        /// the wrong speech model.
        func updateKeyboardLanguage() {
            // Walk responder chain to find our text view
            guard let textView = findFirstResponderTextView() else { return }
            // Don't trust textInputMode while keyboard is suppressed
            if let pastable = textView as? PastableUITextView, pastable.isKeyboardSuppressed {
                return
            }
            let lang = textView.textInputMode?.primaryLanguage
            keyboardLanguage = lang
            AppPreferences.Keyboard.save(lang)
        }

        private func findFirstResponderTextView() -> UITextView? {
            // The notification fires globally — find the active text view
            guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
                  let window = scene.windows.first(where: { $0.isKeyWindow })
            else { return nil }
            return findFirstResponder(in: window) as? UITextView
        }

        private func findFirstResponder(in view: UIView) -> UIView? {
            if view.isFirstResponder { return view }
            for sub in view.subviews {
                if let found = findFirstResponder(in: sub) { return found }
            }
            return nil
        }

        func textViewDidBeginEditing(_ textView: UITextView) {
            onFocusChange?(true)
            // Only read keyboard language when the real keyboard is visible.
            // During voice recording the keyboard is suppressed via empty
            // inputView, and textInputMode may report stale/wrong values.
            if let pastable = textView as? PastableUITextView, pastable.isKeyboardSuppressed {
                // Skip language update — preserve the value captured before
                // suppression so the next voice session uses the correct locale.
            } else {
                let lang = textView.textInputMode?.primaryLanguage
                keyboardLanguage = lang
                AppPreferences.Keyboard.save(lang)
            }
            notifyLineCountIfNeeded(textView)
            notifyDictationStateIfNeeded(textView)
        }

        func textViewDidEndEditing(_ textView: UITextView) {
            onFocusChange?(false)
            if lastDictationState {
                lastDictationState = false
                onDictationStateChange?(false)
            }
        }

        func textViewDidChangeSelection(_ textView: UITextView) {
            notifyDictationStateIfNeeded(textView)
        }

        func textViewDidChange(_ textView: UITextView) {
            text = textView.text

            let lineHeight = textView.font?.lineHeight ?? 17
            let verticalInsets = textView.textContainerInset.top + textView.textContainerInset.bottom
            let maxHeight = ceil((lineHeight * CGFloat(max(maxLines, 1))) + verticalInsets)
            let isOverflowing = textView.contentSize.height > (maxHeight + 0.5)

            if isOverflowing != lastOverflowState {
                lastOverflowState = isOverflowing
                onOverflowChange?(isOverflowing)
            }

            notifyLineCountIfNeeded(textView)
            notifyDictationStateIfNeeded(textView)
        }

        func notifyLineCountIfNeeded(_ textView: UITextView) {
            let lineHeight = max(textView.font?.lineHeight ?? 17, 1)
            let verticalInsets = textView.textContainerInset.top + textView.textContainerInset.bottom
            let textHeight = max(textView.contentSize.height - verticalInsets, lineHeight)
            let lineCount = max(1, Int(ceil(textHeight / lineHeight)))

            if lineCount != lastReportedLineCount {
                lastReportedLineCount = lineCount
                onLineCountChange?(lineCount)
            }
        }

        private func notifyDictationStateIfNeeded(_ textView: UITextView) {
            let isDictating = textView.textInputMode?.primaryLanguage == "dictation"
            if isDictating != lastDictationState {
                lastDictationState = isDictating
                onDictationStateChange?(isDictating)
            }
        }
    }
}

// MARK: - Full Size Text View

/// A pastable text view that fills all available space. Used in the expanded composer.
///
/// Unlike inline `PastableTextView` (auto-expands up to max lines), this variant
/// always scrolls and fills its container. Keyboard dismiss is interactive
/// (drag to dismiss). Can optionally auto-focus on appear.
struct FullSizeTextView: UIViewRepresentable {
    @Binding var text: String
    @Binding var keyboardLanguage: String?
    let font: UIFont
    let textColor: UIColor
    let tintColor: UIColor
    let volatileSuffixLength: Int
    let correctionRanges: [NSRange]
    let autocorrectionEnabled: Bool
    let onPasteImages: ([UIImage]) -> Void
    let onCommandEnter: (() -> Void)?
    let onAlternateEnter: (() -> Void)?
    let autoFocusOnAppear: Bool
    let focusRequestID: Int
    let suppressKeyboard: Bool
    let allowKeyboardRestoreOnTap: Bool
    let onKeyboardRestoreRequest: (() -> Void)?
    let accessibilityIdentifier: String?

    func makeUIView(context: Context) -> PastableUITextView {
        let textView = PastableUITextView()
        textView.delegate = context.coordinator
        textView.onPasteImages = onPasteImages
        textView.onCommandEnter = onCommandEnter
        textView.onAlternateEnter = onAlternateEnter
        textView.font = font
        textView.textColor = textColor
        textView.tintColor = tintColor
        textView.backgroundColor = .clear
        textView.isScrollEnabled = true
        textView.textContainerInset = UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12)
        textView.textContainer.lineFragmentPadding = 0
        textView.keyboardDismissMode = .interactive
        textView.alwaysBounceVertical = true
        textView.accessibilityIdentifier = accessibilityIdentifier

        applyComposerInputTraits(to: textView, autocorrectionEnabled: autocorrectionEnabled)
        context.coordinator.lastAutocorrectionEnabled = autocorrectionEnabled
        context.coordinator.observedTextView = textView

        textView.onKeyboardRestoreRequest = onKeyboardRestoreRequest
        textView.setAllowKeyboardRestoreOnTap(allowKeyboardRestoreOnTap)
        textView.installKeyboardRestoreGesture()
        if suppressKeyboard {
            textView.setKeyboardSuppressed(true)
        }

        if autoFocusOnAppear {
            // Auto-focus after sheet animation settles
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                textView.becomeFirstResponder()
                context.coordinator.updateKeyboardLanguage(from: textView)
                // Place cursor at end
                if let end = textView.textRange(
                    from: textView.endOfDocument,
                    to: textView.endOfDocument
                ) {
                    textView.selectedTextRange = end
                }
            }
        }

        return textView
    }

    func updateUIView(_ textView: PastableUITextView, context: Context) {
        textView.applyStyledText(
            text,
            font: font,
            baseColor: textColor,
            volatileSuffixLength: volatileSuffixLength,
            volatileColor: UIColor(Color.themeBlue),
            volatileBackgroundColor: composerVolatileTranscriptBackgroundColor(),
            correctionRanges: correctionRanges,
            correctionUnderlineColor: UIColor(Color.themeOrange),
            scrollCaretToVisible: true
        )
        textView.onPasteImages = onPasteImages
        textView.onCommandEnter = onCommandEnter
        textView.onAlternateEnter = onAlternateEnter
        textView.tintColor = tintColor
        textView.onKeyboardRestoreRequest = onKeyboardRestoreRequest
        textView.accessibilityIdentifier = accessibilityIdentifier
        context.coordinator.observedTextView = textView

        let traitsChanged = context.coordinator.lastAutocorrectionEnabled != autocorrectionEnabled
        context.coordinator.lastAutocorrectionEnabled = autocorrectionEnabled
        applyComposerInputTraits(to: textView, autocorrectionEnabled: autocorrectionEnabled)
        if traitsChanged {
            DispatchQueue.main.async {
                guard textView.window != nil else { return }
                textView.reloadInputViews()
            }
        }

        if textView.allowsKeyboardRestoreOnTap != allowKeyboardRestoreOnTap {
            textView.setAllowKeyboardRestoreOnTap(allowKeyboardRestoreOnTap)
        }
        if textView.isKeyboardSuppressed != suppressKeyboard {
            textView.setKeyboardSuppressed(suppressKeyboard)
        }

        if focusRequestID != context.coordinator.lastFocusRequestID {
            context.coordinator.lastFocusRequestID = focusRequestID
            DispatchQueue.main.async {
                guard textView.window != nil else { return }
                if !textView.isFirstResponder {
                    textView.becomeFirstResponder()
                }
                textView.moveCaretToEnd()
            }
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(text: $text, keyboardLanguage: $keyboardLanguage)
    }

    final class Coordinator: NSObject, UITextViewDelegate {
        @Binding var text: String
        @Binding var keyboardLanguage: String?

        var lastAutocorrectionEnabled: Bool?
        var lastFocusRequestID = 0
        weak var observedTextView: UITextView?
        nonisolated(unsafe) private var inputModeObserver: NSObjectProtocol?

        init(text: Binding<String>, keyboardLanguage: Binding<String?>) {
            _text = text
            _keyboardLanguage = keyboardLanguage
            super.init()

            inputModeObserver = NotificationCenter.default.addObserver(
                forName: UITextInputMode.currentInputModeDidChangeNotification,
                object: nil,
                queue: .main
            ) { [weak self] _ in
                Task { @MainActor [weak self] in
                    self?.updateKeyboardLanguageFromObservedTextView()
                }
            }
        }

        deinit {
            if let observer = inputModeObserver {
                NotificationCenter.default.removeObserver(observer)
            }
        }

        func textViewDidBeginEditing(_ textView: UITextView) {
            updateKeyboardLanguage(from: textView)
        }

        func textViewDidChange(_ textView: UITextView) {
            text = textView.text
        }

        func updateKeyboardLanguage(from textView: UITextView) {
            // Skip language update when keyboard is suppressed (voice recording).
            // textInputMode is unreliable with a custom empty inputView.
            if let pastable = textView as? PastableUITextView, pastable.isKeyboardSuppressed {
                return
            }
            let lang = textView.textInputMode?.primaryLanguage
            keyboardLanguage = lang
            AppPreferences.Keyboard.save(lang)
        }

        private func updateKeyboardLanguageFromObservedTextView() {
            guard let textView = observedTextView, textView.isFirstResponder else { return }
            updateKeyboardLanguage(from: textView)
        }
    }
}

// MARK: - Custom UITextView

/// UITextView subclass that intercepts paste to extract images.
class PastableUITextView: UITextView {
    var onPasteImages: (([UIImage]) -> Void)?
    var onCommandEnter: (() -> Void)?
    var onAlternateEnter: (() -> Void)?
    var onKeyboardRestoreRequest: (() -> Void)?

    #if DEBUG
    enum SelectionProbePhase: Equatable {
        case immediate
        case deferred
    }

    typealias SelectionProbe = (SelectionProbePhase, NSRange, Int) -> Void

    /// Test-only hooks for IME and streamed-caret regression coverage.
    /// Kept out of Release builds so the production type surface stays clean.
    var markedTextRangeOverrideForTesting: UITextRange?
    var selectionProbeForTesting: SelectionProbe?
    #endif

    private var renderedTextCache = ""
    private var renderedVolatileSuffixLengthCache = -1
    private var renderedCorrectionRangesCache: [NSRange] = []
    private var renderedFontCache: UIFont?
    private var renderedBaseColorCache: UIColor?
    private var renderedVolatileColorCache: UIColor?
    private var renderedVolatileBackgroundColorCache: UIColor?
    private var renderedCorrectionUnderlineColorCache: UIColor?

    /// When true, keyboard is hidden but cursor remains visible via empty `inputView`.
    private(set) var isKeyboardSuppressed = false

    /// When false, taps on the text view do not restore the keyboard even if
    /// suppression is active (used during active voice recording).
    private(set) var allowsKeyboardRestoreOnTap = true

    private var cachedPasteboardChangeCount: Int = -1
    private var cachedPasteboardHasImages = false

    /// Tap gesture that restores the keyboard when the user taps the text view
    /// while keyboard is suppressed (e.g. during voice recording).
    /// Exposed as internal for `@testable import` — do not use outside tests.
    private(set) lazy var keyboardRestoreTap: UITapGestureRecognizer = {
        let tap = UITapGestureRecognizer(target: self, action: #selector(handleKeyboardRestoreTap))
        tap.cancelsTouchesInView = false
        tap.delegate = self
        return tap
    }()

    func applyStyledText(
        _ text: String,
        font: UIFont,
        baseColor: UIColor,
        volatileSuffixLength: Int,
        volatileColor: UIColor,
        volatileBackgroundColor: UIColor = .clear,
        correctionRanges: [NSRange] = [],
        correctionUnderlineColor: UIColor = .clear,
        scrollCaretToVisible: Bool = false
    ) {
        let clampedVolatileSuffixLength = max(0, min(volatileSuffixLength, text.count))
        let totalLength = (text as NSString).length
        let clampedCorrectionRanges = correctionRanges.compactMap { range in
            let clampedRange = NSIntersectionRange(range, NSRange(location: 0, length: totalLength))
            return clampedRange.length > 0 ? clampedRange : nil
        }
        let styleUnchanged = Self.hasExactUTF16Representation(renderedTextCache, text)
            && renderedVolatileSuffixLengthCache == clampedVolatileSuffixLength
            && renderedCorrectionRangesCache == clampedCorrectionRanges
            && (renderedFontCache?.isEqual(font) ?? false)
            && (renderedBaseColorCache?.isEqual(baseColor) ?? false)
            && (renderedVolatileColorCache?.isEqual(volatileColor) ?? false)
            && (renderedVolatileBackgroundColorCache?.isEqual(volatileBackgroundColor) ?? false)
            && (renderedCorrectionUnderlineColorCache?.isEqual(correctionUnderlineColor) ?? false)

        guard !styleUnchanged else { return }

        // Do not replace attributedText while an IME composition is active
        // (e.g. Chinese Pinyin preedit text). Reassigning attributedText here
        // commits the marked text prematurely, which turns the candidate buffer
        // into raw latin letters like "jiugongge" instead of keeping the
        // composition open for selection.
        if Self.shouldDeferStyledTextUpdateWhileComposing(
            hasMarkedText: effectiveMarkedTextRange != nil,
            currentText: self.text,
            incomingText: text
        ) {
            return
        }

        let incomingTextHasInlineStyling = clampedVolatileSuffixLength > 0 || !clampedCorrectionRanges.isEmpty
        let renderedPlainBaseStyleMatches = renderedVolatileSuffixLengthCache == 0
            && renderedCorrectionRangesCache.isEmpty
            && (renderedFontCache?.isEqual(font) ?? false)
            && (renderedBaseColorCache?.isEqual(baseColor) ?? false)

        if !incomingTextHasInlineStyling,
           renderedPlainBaseStyleMatches,
           Self.hasExactUTF16Representation(self.text, text) {
            typingAttributes = [
                .font: font,
                .foregroundColor: baseColor,
            ]
            renderedTextCache = text
            renderedVolatileSuffixLengthCache = 0
            renderedCorrectionRangesCache = []
            renderedFontCache = font
            renderedBaseColorCache = baseColor
            renderedVolatileColorCache = volatileColor
            renderedVolatileBackgroundColorCache = volatileBackgroundColor
            renderedCorrectionUnderlineColorCache = correctionUnderlineColor
            return
        }

        let previousSelection = self.selectedRange
        let previousTextLength = textStorage.length
        let keepCaretPinnedToEnd = previousSelection.length == 0 && previousSelection.location >= previousTextLength
        let baseAttributes: [NSAttributedString.Key: Any] = [
            .font: font,
            .foregroundColor: baseColor,
        ]
        let replacement = Self.minimalTextReplacement(current: textStorage.string, incoming: text)

        // `attributedText` assignment replaces the text view's entire value and
        // resets UIKit's editing state before we can restore the selection. During
        // streamed dictation that briefly paints the caret at the replacement
        // location, then at the end, which looks like a cursor flicker. Mutate the
        // existing NSTextStorage in one edit batch instead; Apple exposes it as the
        // mutable TextKit backing store specifically for styled text updates.
        textStorage.beginEditing()
        if replacement.current.length > 0 || replacement.incoming.length > 0 {
            let replacementText = (text as NSString).substring(with: replacement.incoming)
            textStorage.replaceCharacters(in: replacement.current, with: replacementText)
        }

        // Swift String equality is canonically equivalent, but TextKit ranges
        // address the exact UTF-16 storage. Verify the intended representation
        // before applying any incoming ranges, and recover with a full replacement
        // rather than ever styling a mismatched backing store.
        if !Self.hasExactUTF16Representation(textStorage.string, text) {
            textStorage.replaceCharacters(
                in: NSRange(location: 0, length: textStorage.length),
                with: text
            )
        }
        assert(
            Self.hasExactUTF16Representation(textStorage.string, text),
            "Styled text storage must exactly match the incoming UTF-16 representation"
        )

        let fullRange = NSRange(location: 0, length: textStorage.length)
        if fullRange.length > 0 {
            textStorage.setAttributes(baseAttributes, range: fullRange)

            if clampedVolatileSuffixLength > 0 {
                let volatileStart = text.index(text.endIndex, offsetBy: -clampedVolatileSuffixLength)
                let volatileRange = NSRange(volatileStart..<text.endIndex, in: text)
                textStorage.addAttributes(
                    [
                        .foregroundColor: volatileColor,
                        .backgroundColor: volatileBackgroundColor,
                    ],
                    range: volatileRange
                )
            }

            for range in clampedCorrectionRanges {
                textStorage.addAttributes(
                    [
                        .underlineStyle: NSUnderlineStyle.single.rawValue,
                        .underlineColor: correctionUnderlineColor,
                    ],
                    range: range
                )
            }
        }
        textStorage.endEditing()
        typingAttributes = baseAttributes

        let maxSelectionLocation = totalLength
        let restoredSelection: NSRange
        if keepCaretPinnedToEnd {
            restoredSelection = NSRange(location: maxSelectionLocation, length: 0)
        } else {
            let clampedLocation = min(previousSelection.location, maxSelectionLocation)
            restoredSelection = NSRange(
                location: clampedLocation,
                length: min(previousSelection.length, max(0, maxSelectionLocation - clampedLocation))
            )
        }
        if !NSEqualRanges(self.selectedRange, restoredSelection) {
            self.selectedRange = restoredSelection
        }
        if scrollCaretToVisible {
            scrollRangeToVisible(restoredSelection)
        }
        #if DEBUG
        if keepCaretPinnedToEnd {
            selectionProbeForTesting?(.immediate, selectedRange, textStorage.length)
        }
        #endif

        if keepCaretPinnedToEnd {
            // TextKit can apply its own selection adjustment after the storage
            // edit returns, moving the caret back to the old transcript boundary.
            // Reassert the terminal insertion point on the next main-queue turn,
            // but only if this exact transcript is still current.
            DispatchQueue.main.async { [weak self] in
                guard let self,
                      Self.hasExactUTF16Representation(self.textStorage.string, text)
                else { return }
                let terminalSelection = NSRange(location: self.textStorage.length, length: 0)
                if !NSEqualRanges(self.selectedRange, terminalSelection) {
                    self.selectedRange = terminalSelection
                }
                if scrollCaretToVisible {
                    self.scrollRangeToVisible(terminalSelection)
                }
                #if DEBUG
                self.selectionProbeForTesting?(.deferred, self.selectedRange, self.textStorage.length)
                #endif
            }
        }

        renderedTextCache = text
        renderedVolatileSuffixLengthCache = clampedVolatileSuffixLength
        renderedCorrectionRangesCache = clampedCorrectionRanges
        renderedFontCache = font
        renderedBaseColorCache = baseColor
        renderedVolatileColorCache = volatileColor
        renderedVolatileBackgroundColorCache = volatileBackgroundColor
        renderedCorrectionUnderlineColorCache = correctionUnderlineColor
    }

    static func minimalTextReplacement(
        current: String,
        incoming: String
    ) -> (current: NSRange, incoming: NSRange) {
        let currentScalars = current.unicodeScalars
        let incomingScalars = incoming.unicodeScalars
        var currentStart = currentScalars.startIndex
        var incomingStart = incomingScalars.startIndex
        let currentEnd = currentScalars.endIndex
        let incomingEnd = incomingScalars.endIndex

        // Compare exact scalars, not Swift Characters. Character equality treats
        // precomposed and decomposed forms as equal even though TextKit sees
        // different UTF-16 lengths.
        while currentStart != currentEnd,
              incomingStart != incomingEnd,
              currentScalars[currentStart].value == incomingScalars[incomingStart].value {
            currentScalars.formIndex(after: &currentStart)
            incomingScalars.formIndex(after: &incomingStart)
        }

        var currentSuffixStart = currentEnd
        var incomingSuffixStart = incomingEnd
        while currentSuffixStart != currentStart,
              incomingSuffixStart != incomingStart {
            let previousCurrent = currentScalars.index(before: currentSuffixStart)
            let previousIncoming = incomingScalars.index(before: incomingSuffixStart)
            guard currentScalars[previousCurrent].value == incomingScalars[previousIncoming].value else { break }
            currentSuffixStart = previousCurrent
            incomingSuffixStart = previousIncoming
        }

        return (
            current: NSRange(currentStart..<currentSuffixStart, in: current),
            incoming: NSRange(incomingStart..<incomingSuffixStart, in: incoming)
        )
    }

    static func hasExactUTF16Representation(_ lhs: String, _ rhs: String) -> Bool {
        lhs.utf16.elementsEqual(rhs.utf16)
    }

    private var effectiveMarkedTextRange: UITextRange? {
        #if DEBUG
        if let markedTextRangeOverrideForTesting {
            return markedTextRangeOverrideForTesting
        }
        #endif
        return markedTextRange
    }

    static func shouldDeferStyledTextUpdateWhileComposing(
        hasMarkedText: Bool,
        currentText: String,
        incomingText: String
    ) -> Bool {
        guard hasMarkedText else { return false }
        return currentText == incomingText
    }

    override var intrinsicContentSize: CGSize {
        // Return noIntrinsicMetric — sizeThatFits is the authoritative source
        // for SwiftUI layout. Having intrinsicContentSize compete with
        // sizeThatFits causes layout oscillation.
        return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
    }

    override var keyCommands: [UIKeyCommand]? {
        let send = UIKeyCommand(input: "\r", modifierFlags: .command, action: #selector(handleCommandReturn))
        send.discoverabilityTitle = "Send"

        let queueFollowUp = UIKeyCommand(input: "\r", modifierFlags: .alternate, action: #selector(handleAlternateReturn))
        queueFollowUp.discoverabilityTitle = "Queue Follow-up"

        return [send, queueFollowUp]
    }

    @objc private func handleCommandReturn() {
        onCommandEnter?()
    }

    @objc private func handleAlternateReturn() {
        onAlternateEnter?()
    }

    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        guard action == #selector(paste(_:)) else {
            return super.canPerformAction(action, withSender: sender)
        }

        if super.canPerformAction(action, withSender: sender) {
            return true
        }

        // Selection gestures invoke canPerformAction frequently.
        // Cache by pasteboard changeCount to avoid repeated expensive probes.
        return pasteboardHasImages()
    }

    private func pasteboardHasImages() -> Bool {
        let pb = UIPasteboard.general
        let changeCount = pb.changeCount
        if changeCount != cachedPasteboardChangeCount {
            cachedPasteboardChangeCount = changeCount
            cachedPasteboardHasImages = pb.hasImages
        }
        return cachedPasteboardHasImages
    }

    override func paste(_ sender: Any?) {
        let pb = UIPasteboard.general

        // Check for images first
        if pb.hasImages, let images = pb.images, !images.isEmpty {
            onPasteImages?(images)
            // If clipboard also has text, paste that too
            if pb.hasStrings {
                super.paste(sender)
            }
            return
        }

        // Normal text paste
        super.paste(sender)
    }

    // MARK: - Keyboard Suppression

    /// Install the tap gesture that restores the keyboard when the user taps
    /// the text view while keyboard is suppressed.
    func installKeyboardRestoreGesture() {
        keyboardRestoreTap.isEnabled = false
        addGestureRecognizer(keyboardRestoreTap)
    }

    func moveCaretToEnd() {
        let end = textStorage.length
        selectedRange = NSRange(location: end, length: 0)
        scrollRangeToVisible(selectedRange)
    }

    /// Toggle keyboard suppression. When suppressed, the cursor remains visible
    /// but the system keyboard is hidden via an empty `inputView`.
    func setKeyboardSuppressed(_ suppressed: Bool) {
        isKeyboardSuppressed = suppressed
        inputView = suppressed ? UIView() : nil
        keyboardRestoreTap.isEnabled = suppressed && allowsKeyboardRestoreOnTap
        if window != nil {
            reloadInputViews()
        }
    }

    /// Enable/disable restoring the keyboard via text-view tap while suppressed.
    func setAllowKeyboardRestoreOnTap(_ allow: Bool) {
        allowsKeyboardRestoreOnTap = allow
        keyboardRestoreTap.isEnabled = isKeyboardSuppressed && allow
    }

    @objc private func handleKeyboardRestoreTap() {
        guard isKeyboardSuppressed, allowsKeyboardRestoreOnTap else { return }
        // Immediately restore keyboard for responsiveness
        isKeyboardSuppressed = false
        inputView = nil
        keyboardRestoreTap.isEnabled = false
        reloadInputViews()
        onKeyboardRestoreRequest?()
    }
}

// MARK: - PastableUITextView + UIGestureRecognizerDelegate

extension PastableUITextView: UIGestureRecognizerDelegate {
    func gestureRecognizer(
        _ gestureRecognizer: UIGestureRecognizer,
        shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer
    ) -> Bool {
        gestureRecognizer === keyboardRestoreTap
    }
}
