import AppKit

/// Delegate used by ``TabTitleEditor`` to resolve tab-specific behavior.
protocol TabTitleEditorDelegate: AnyObject {
    /// Returns whether inline rename should be allowed for the given tab window.
    func tabTitleEditor(
        _ editor: TabTitleEditor,
        canRenameTabFor targetWindow: NSWindow
    ) -> Bool

    /// Returns the current title value to seed into the inline editor.
    func tabTitleEditor(
        _ editor: TabTitleEditor,
        titleFor targetWindow: NSWindow
    ) -> String

    /// Called when inline editing commits a title for a target tab window.
    func tabTitleEditor(
        _ editor: TabTitleEditor,
        didCommitTitle editedTitle: String,
        for targetWindow: NSWindow
    )

    /// Called when inline editing could not start and the host should show a fallback flow.
    func tabTitleEditor(
        _ editor: TabTitleEditor,
        performFallbackRenameFor targetWindow: NSWindow
    )

    /// Called after inline editing finishes (whether committed or cancelled).
    /// Use this to restore focus to the appropriate responder.
    func tabTitleEditor(
        _ editor: TabTitleEditor,
        didFinishEditing targetWindow: NSWindow)
}

/// Handles inline tab title editing for native AppKit window tabs.
final class TabTitleEditor: NSObject, NSTextFieldDelegate {
    /// Host window containing the tab bar where editing occurs.
    private weak var hostWindow: NSWindow?
    /// Delegate that provides and commits title data for target tab windows.
    private weak var delegate: TabTitleEditorDelegate?
    /// Local event monitor so fullscreen titlebar-window clicks can also trigger rename.
    private var eventMonitor: Any?

    /// Active inline editor view, if editing is in progress.
    private weak var inlineTitleEditor: NSTextField?
    /// Tab window currently being edited.
    private weak var inlineTitleTargetWindow: NSWindow?
    /// Original state of the tab bar.
    private var previousTabState: TabUIState?
    /// Deferred begin-editing work used to avoid visual flicker on double-click.
    private var pendingEditWorkItem: DispatchWorkItem?

    /// Creates a coordinator bound to a host window and rename delegate.
    init(hostWindow: NSWindow, delegate: TabTitleEditorDelegate) {
        super.init()

        self.hostWindow = hostWindow
        self.delegate = delegate

        // This is needed so that fullscreen clicks can register since they won't
        // event on the NSWindow. We may want to tighten this up in the future by
        // only doing this if we're fullscreen.
        self.eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in
            guard let self else { return event }
            return handleMouseDown(event) ? nil : event
        }
    }

    deinit {
        if let eventMonitor {
            NSEvent.removeMonitor(eventMonitor)
        }
    }

    /// Handles leftMouseDown events from the host window and begins inline edit if possible. If this
    /// returns true then the event was handled by the coordinator.
    func handleMouseDown(_ event: NSEvent) -> Bool {
        guard event.type == .leftMouseDown else { return false }

        // If we don't have a host window to look up the click, we do nothing.
        guard let hostWindow else { return false }

        // In native fullscreen, AppKit can route titlebar clicks through a detached
        // NSToolbarFullScreenWindow. Only allow clicks from the host window or its
        // fullscreen tab bar window so rename handling stays scoped to this tab strip.
        let sourceWindow = event.window ?? hostWindow
        guard sourceWindow === hostWindow || sourceWindow === hostWindow.tabBarView?.window
        else { return false }

        // Find the tab window that is being clicked.
        let locationInScreen = sourceWindow.convertPoint(toScreen: event.locationInWindow)
        guard let tabIndex = hostWindow.tabIndex(atScreenPoint: locationInScreen),
              let targetWindow = hostWindow.tabbedWindows?[safe: tabIndex],
              delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true
        else { return false }

        guard !isMouseEventWithinEditor(event) else {
            // If the click lies within the editor,
            // we should forward the event to the editor
            inlineTitleEditor?.mouseDown(with: event)
            return true
        }
        // We only want double-clicks to enable editing
        guard event.clickCount == 2 else { return false }
        // We need to start editing in a separate event loop tick, so set that up.
        pendingEditWorkItem?.cancel()
        let workItem = DispatchWorkItem { [weak self, weak targetWindow] in
            guard let self, let targetWindow else { return }
            if self.beginEditing(for: targetWindow) {
                return
            }

            // Inline editing failed, so trigger fallback rename whatever it is.
            self.delegate?.tabTitleEditor(self, performFallbackRenameFor: targetWindow)
        }

        pendingEditWorkItem = workItem
        DispatchQueue.main.async(execute: workItem)
        return true
    }

    /// Handles rightMouseDown events from the host window.
    ///
    /// If this returns true then the event was handled by the coordinator.
    func handleRightMouseDown(_ event: NSEvent) -> Bool {
        guard event.type == .rightMouseDown else { return false }
        if isMouseEventWithinEditor(event) {
            inlineTitleEditor?.rightMouseDown(with: event)
            return true
        } else {
            return false
        }
    }

    /// Begins editing the given target tab window title. Returns true if we're able to start the
    /// inline edit.
    @discardableResult
    func beginEditing(for targetWindow: NSWindow) -> Bool {
        // Resolve the visual tab button for the target tab window. We rely on visual order
        // since native tab view hierarchy order does not necessarily match what is on screen.
        guard let hostWindow,
              let tabbedWindows = hostWindow.tabbedWindows,
              let tabIndex = tabbedWindows.firstIndex(of: targetWindow),
              let tabButton = hostWindow.tabButtonsInVisualOrder()[safe: tabIndex],
              delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true
        else { return false }

        // If we have a pending edit, we need to cancel it because we got
        // called to start edit explicitly.
        pendingEditWorkItem?.cancel()
        pendingEditWorkItem = nil
        finishEditing(commit: true)

        let tabState = TabUIState(tabButton: tabButton)

        // Build the editor using title text and style derived from the tab's existing label.
        let editedTitle = delegate?.tabTitleEditor(self, titleFor: targetWindow) ?? targetWindow.title
        let sourceLabel = sourceTabTitleLabel(from: tabState.labels.map(\.label), matching: editedTitle)
        let editorFrame = tabTitleEditorFrame(for: tabButton, sourceLabel: sourceLabel)
        guard editorFrame.width >= 20, editorFrame.height >= 14 else { return false }

        let editor = NSTextField(frame: editorFrame)
        editor.delegate = self
        editor.stringValue = editedTitle
        editor.alignment = sourceLabel?.alignment ?? .center
        editor.isBordered = false
        editor.isBezeled = false
        editor.drawsBackground = false
        editor.focusRingType = .none
        editor.lineBreakMode = .byClipping
        if let editorCell = editor.cell as? NSTextFieldCell {
            editorCell.wraps = false
            editorCell.usesSingleLineMode = true
            editorCell.isScrollable = true
        }
        if let sourceLabel {
            applyTextStyle(to: editor, from: sourceLabel, title: editedTitle)
        }

        // Hide it until the tab button has finished layout so we can avoid flicker.
        editor.isHidden = true

        inlineTitleEditor = editor
        inlineTitleTargetWindow = targetWindow
        previousTabState = tabState
        // Temporarily hide native title label views while editing so only the text field is visible.
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        tabState.hide()

        tabButton.layoutSubtreeIfNeeded()
        tabButton.displayIfNeeded()
        tabButton.addSubview(editor)
        CATransaction.commit()

        // Focus after insertion so AppKit has created the field editor for this text field.
        DispatchQueue.main.async { [weak hostWindow, weak editor] in
            guard let editor else { return }
            let responderWindow = editor.window ?? hostWindow
            guard let responderWindow else { return }
            editor.isHidden = false
            responderWindow.makeFirstResponder(editor)
            if let fieldEditor = editor.currentEditor() as? NSTextView,
               let editorFont = editor.font {
                fieldEditor.font = editorFont
                var typingAttributes = fieldEditor.typingAttributes
                typingAttributes[.font] = editorFont
                fieldEditor.typingAttributes = typingAttributes
            }
            editor.currentEditor()?.selectAll(nil)
        }

        return true
    }

    /// Finishes any in-flight inline edit and optionally commits the edited title.
    func finishEditing(commit: Bool) {
        // If we're pending starting a new edit, cancel it.
        pendingEditWorkItem?.cancel()
        pendingEditWorkItem = nil

        // To finish editing we need a current editor.
        guard let editor = inlineTitleEditor else { return }
        let editedTitle = editor.stringValue
        let targetWindow = inlineTitleTargetWindow

        // Clear coordinator references first so re-entrant paths don't see stale state.
        editor.delegate = nil
        inlineTitleEditor = nil
        inlineTitleTargetWindow = nil

        // Make sure the window grabs focus again
        if let responderWindow = editor.window ?? hostWindow {
            if let currentEditor = editor.currentEditor(), responderWindow.firstResponder === currentEditor {
                responderWindow.makeFirstResponder(nil)
            } else if responderWindow.firstResponder === editor {
                responderWindow.makeFirstResponder(nil)
            }
        }

        editor.removeFromSuperview()

        previousTabState?.restore()
        previousTabState = nil

        // Delegate owns title persistence semantics (including empty-title handling).
        guard let targetWindow else { return }

        if commit {
            delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow)
        }

        // Notify delegate that editing is done so it can restore focus.
        delegate?.tabTitleEditor(self, didFinishEditing: targetWindow)
    }

    /// Chooses an editor frame that aligns with the tab title within the tab button.
    private func tabTitleEditorFrame(for tabButton: NSView, sourceLabel: NSTextField?) -> NSRect {
        let bounds = tabButton.bounds
        let horizontalInset: CGFloat = 6
        var frame = bounds.insetBy(dx: horizontalInset, dy: 0)

        if let sourceLabel {
            let labelFrame = tabButton.convert(sourceLabel.bounds, from: sourceLabel)
            /// The `labelFrame.minY` value changes unexpectedly after double clicking selected text,
            /// I don't know exactly why, but `tabButton.bounds` appears stable enough to calculate the correct position reliably.
            frame.origin.y = bounds.midY - labelFrame.height * 0.5
            frame.size.height = labelFrame.height
        }

        return frame.integral
    }

    /// Selects the best title label candidate from private tab button subviews.
    private func sourceTabTitleLabel(from labels: [NSTextField], matching title: String) -> NSTextField? {
        let expected = title.trimmingCharacters(in: .whitespacesAndNewlines)
        if !expected.isEmpty {
            // Prefer a visible exact title match when we can find one.
            if let exactVisible = labels.first(where: {
                !$0.isHidden &&
                $0.alphaValue > 0.01 &&
                $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected
            }) {
                return exactVisible
            }

            // Fall back to any exact match, including hidden labels.
            if let exactAny = labels.first(where: {
                $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected
            }) {
                return exactAny
            }
        }

        // Otherwise heuristically choose the largest visible, centered label first.
        let visibleNonEmpty = labels.filter {
            !$0.isHidden &&
            $0.alphaValue > 0.01 &&
            !$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        }

        if let centeredVisible = visibleNonEmpty
            .filter({ $0.alignment == .center })
            .max(by: { $0.bounds.width < $1.bounds.width }) {
            return centeredVisible
        }

        if let visible = visibleNonEmpty.max(by: { $0.bounds.width < $1.bounds.width }) {
            return visible
        }

        return labels.max(by: { $0.bounds.width < $1.bounds.width })
    }

    /// Copies text styling from the source tab label onto the inline editor.
    private func applyTextStyle(to editor: NSTextField, from label: NSTextField, title: String) {
        var attributes: [NSAttributedString.Key: Any] = [:]
        if label.attributedStringValue.length > 0 {
            attributes = label.attributedStringValue.attributes(at: 0, effectiveRange: nil)
        }

        if attributes[.font] == nil, let font = label.font {
            attributes[.font] = font
        }

        if attributes[.foregroundColor] == nil {
            attributes[.foregroundColor] = label.textColor
        }

        if let font = attributes[.font] as? NSFont {
            editor.font = font
        }

        if let textColor = attributes[.foregroundColor] as? NSColor {
            editor.textColor = textColor
        }

        if !attributes.isEmpty {
            editor.attributedStringValue = NSAttributedString(string: title, attributes: attributes)
        } else {
            editor.stringValue = title
        }
    }

    // MARK: NSTextFieldDelegate

    func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
        guard control === inlineTitleEditor else { return false }

        // Enter commits and exits inline edit.
        if commandSelector == #selector(NSResponder.insertNewline(_:)) {
            finishEditing(commit: true)
            return true
        }

        // Escape cancels and restores the previous tab title.
        if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
            finishEditing(commit: false)
            return true
        }

        return false
    }

    func controlTextDidEndEditing(_ obj: Notification) {
        guard let inlineTitleEditor,
              let finishedEditor = obj.object as? NSTextField,
              finishedEditor === inlineTitleEditor
        else { return }

        // Blur/end-edit commits, matching standard NSTextField behavior.
        finishEditing(commit: true)
    }
}

private extension TabTitleEditor {
    func isMouseEventWithinEditor(_ event: NSEvent) -> Bool {
        guard let editor = inlineTitleEditor?.currentEditor() else {
            return false
        }
        return editor.convert(editor.bounds, to: nil).contains(event.locationInWindow)
    }
}

private extension TabTitleEditor {
    struct TabUIState {
        /// Original hidden state for title labels that are temporarily hidden while editing.
        let labels: [(label: NSTextField, wasHidden: Bool)]
        /// Original hidden state for buttons that are temporarily hidden while editing.
        let buttons: [(button: NSButton, wasHidden: Bool)]
        /// Original button title state restored once editing finishes.
        let titleButton: (button: NSButton, title: String, attributedTitle: NSAttributedString?)?

        init(tabButton: NSView) {
            labels = tabButton
                .descendants(withClassName: "NSTextField")
                .compactMap { $0 as? NSTextField }
                .map { ($0, $0.isHidden) }
            buttons = tabButton
                .descendants(withClassName: "NSButton")
                .compactMap { $0 as? NSButton }
                .map { ($0, $0.isHidden) }
            if let button = tabButton as? NSButton {
                titleButton = (button, button.title, button.attributedTitle)
            } else {
                titleButton = nil
            }
        }

        func hide() {
            for (label, _) in labels {
                label.isHidden = true
            }
            for (btn, _) in buttons {
                btn.isHidden = true
            }
            titleButton?.button.title = ""
            titleButton?.button.attributedTitle = NSAttributedString(string: "")
        }

        func restore() {
            for (label, wasHidden) in labels {
                label.isHidden = wasHidden
            }
            for (btn, wasHidden) in buttons {
                btn.isHidden = wasHidden
            }
            if let titleButton {
                titleButton.button.title = titleButton.title
                if let attributedTitle = titleButton.attributedTitle {
                    titleButton.button.attributedTitle = attributedTitle
                }
            }
        }
    }
}
