from __future__ import annotations

import sys

from typing import TYPE_CHECKING, Any, Callable, Sequence, cast

import qtpy

from qtpy.QtCore import (
    QModelIndex,
    QRect,
    QSize,
    QSortFilterProxyModel,
    QStringListModel,
    QTimer,
    Qt,
)
from qtpy.QtGui import QFontMetrics, QStandardItem, QStandardItemModel
from qtpy.QtWidgets import (
    QApplication,
    QComboBox,
    QLineEdit,
    QListView,
    QMainWindow,
    QSizePolicy,
    QStyleOptionViewItem,
    QStyledItemDelegate,
    QVBoxLayout,
    QWidget,
)

if TYPE_CHECKING:
    from qtpy.QtCore import QAbstractItemModel, QMargins, QModelIndex, QObject, QPoint
    from qtpy.QtGui import QKeyEvent, QMouseEvent, QPainter
    from qtpy.QtWidgets import QAbstractItemDelegate, QAbstractItemView, QPushButton


class FilterProxyModel(QSortFilterProxyModel):
    def __init__(self, combo_box: FilterComboBox):
        super().__init__(combo_box)
        self.filter_text: str = ""
        self.filter_timer: QTimer = QTimer()
        self.filter_timer.setSingleShot(True)
        self.filter_timer.timeout.connect(self.invalidateFilter)
        self.debounce_delay: int = 300  # Milliseconds
        self.combo_box: FilterComboBox = combo_box

    def set_filter_text(self, text: str):
        self.filter_text = text.lower()
        self.filter_timer.stop()
        self.filter_timer.start(self.debounce_delay)

    def filterAcceptsRow(
        self,
        source_row: int,
        source_parent: QModelIndex,
    ) -> bool:
        src_model: QAbstractItemModel | None = self.sourceModel()
        if src_model is None:
            return False
        index: QModelIndex = src_model.index(source_row, 0, source_parent)
        item_text: str | None = index.data(Qt.ItemDataRole.DisplayRole)
        line_edit: QLineEdit = self.combo_box.filter_line_edit
        if line_edit is None:
            return True
        current_text: str = line_edit.text().lower()

        if item_text is None:
            return False

        item_text_lower: str = item_text.lower()
        if self.filter_text in item_text_lower:
            return True
        return current_text == item_text_lower  # Prevents annoying selection changes.


class ButtonDelegate(QStyledItemDelegate):
    def __init__(
        self,
        combobox: FilterComboBox,
        button_text: str,
        button_callback: Callable[[str], Any],
        parent: QObject | None = None,
    ):
        super().__init__(parent)
        self.combobox: FilterComboBox = combobox
        self.button_text: str = button_text
        self.button_callback: Callable[[str], Any] = button_callback
        self.buttons: dict[str, QPushButton] = {}

    def paint(
        self,
        painter: QPainter,
        option: QStyleOptionViewItem,
        index: QModelIndex,
    ):
        super().paint(painter, option, index)
        fm: QFontMetrics = QFontMetrics(option.font)
        button_width: int = fm.horizontalAdvance(self.button_text) + 20  # Adding padding
        button_height: int = fm.height() + 10
        button_rect: QRect = QRect(option.rect.right() - button_width, option.rect.top(), button_width, button_height)
        painter.save()
        painter.setPen(Qt.PenStyle.NoPen)
        painter.setBrush(Qt.GlobalColor.lightGray)
        painter.drawRect(button_rect)
        painter.setPen(Qt.GlobalColor.black)
        painter.drawText(button_rect, Qt.AlignmentFlag.AlignCenter, self.button_text)
        painter.restore()

    def sizeHint(
        self,
        option: QStyleOptionViewItem,
        index: QModelIndex,
    ) -> QSize:
        size: QSize = super().sizeHint(option, index)
        fm: QFontMetrics = QFontMetrics(option.font)
        button_width: int = fm.horizontalAdvance(self.button_text) + 20
        button_height: int = fm.height() + 10
        return QSize(size.width() + button_width, max(size.height(), button_height))


def filter_line_edit_key_press_event(
    self: QLineEdit,
    event: QKeyEvent,
    parent_combo_box: FilterComboBox,
):
    if event.key() in (
        Qt.Key.Key_Backspace,
        Qt.Key.Key_Delete,
    ):
        self.clear()
    else:
        QLineEdit.keyPressEvent(self, event)
    line_view: QAbstractItemView | None = parent_combo_box.view()
    assert line_view is not None
    line_view.repaint()


class CustomListView(QListView):
    def __init__(
        self,
        parent: FilterComboBox,
    ):
        self.combobox: FilterComboBox = parent
        super().__init__(parent)
        self.button_text: str = ""
        self.button_callback: Callable[[str], Any]

    def keyPressEvent(
        self,
        event: QKeyEvent,
    ):
        if self.combobox.is_popped_up:
            self.combobox.filter_line_edit.keyPressEvent(event)
        else:
            super().keyPressEvent(event)

    def mousePressEvent(
        self,
        event: QMouseEvent,
    ):
        if not self.button_text:
            super().mousePressEvent(event)
            return
        view_port: QWidget | None = self.viewport()
        if view_port is None:
            return
        viewport_pos: QPoint = view_port.mapFromGlobal(
            event.globalPos()  # type: ignore[attr-defined]
            if qtpy.QT5
            else event.globalPosition().toPoint()
        )
        index: QModelIndex | None = self.indexAt(viewport_pos)
        if index is None or not index.isValid():
            super().mousePressEvent(event)
            return
        option: QStyleOptionViewItem = (
            self.viewOptions()  # type: ignore[attr-defined]
            if qtpy.QT5
            else QStyleOptionViewItem()
        )
        option.initFrom(self)
        option.rect = self.visualRect(index)
        button_width: int = QFontMetrics(option.font).horizontalAdvance(self.button_text) + 20
        left_limit: int = min(option.rect.right() - button_width, cast("QWidget", self.parent()).width() - button_width)
        if left_limit <= viewport_pos.x() <= option.rect.right():
            self.combobox.force_stay_popped_up = True
            self.button_callback(index.data(Qt.ItemDataRole.DisplayRole))
        super().mousePressEvent(event)


class FilterComboBox(QComboBox):
    def __init__(
        self,
        parent: QWidget | None = None,
        *,
        init: bool = True,
    ):
        if init:
            super().__init__(parent)
            self.setEditable(True)
        if self.isEditable():
            self.setCompleter(None)  # type: ignore[arg-type]
            self.lineEdit().setValidator(None)  # type: ignore[arg-type]
        self.proxy_model: FilterProxyModel = FilterProxyModel(self)
        super().setModel(self.proxy_model)
        model: QStringListModel | QAbstractItemModel | None = QStringListModel(self) if init else self.model()
        assert isinstance(model, (QStringListModel, QStandardItemModel)), f"Invalid source model type: {model.__class__.__name__}"
        self.setModel(model)

        self.items: list[str] = []
        self.items_loaded: bool = False
        self.is_popped_up: bool = False
        self.orig_text: str = ""
        self.force_stay_popped_up: bool = False
        self.old_width: int = self.width()

        self.filter_line_edit: QLineEdit = QLineEdit(self)
        self.filter_line_edit.setPlaceholderText("Type to filter...")
        self.filter_line_edit.setClearButtonEnabled(True)
        self.filter_line_edit.textChanged.connect(self.filter_items)
        self.filter_line_edit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        self.filter_line_edit.hide()
        self.filter_line_edit.keyPressEvent = lambda event: filter_line_edit_key_press_event(self.filter_line_edit, event, self)  # type: ignore[method-assign, assignment]
        main_view: CustomListView = CustomListView(self)
        main_view.combobox = self
        margins: QMargins = cast("QMargins", main_view.viewportMargins())
        main_view.setViewportMargins(margins.left(), margins.top() + self.filter_line_edit.height(), margins.right(), margins.bottom())
        self.setView(main_view)
        self.setMinimumWidth(200)
        self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred)

    def lineEdit(self) -> QLineEdit:
        line_edit: QLineEdit | None = super().lineEdit()
        if line_edit is None:
            line_edit = QLineEdit(self)
            self.setLineEdit(line_edit)
        line_edit.setReadOnly(not self.isEditable())
        if not self.isEditable():
            line_edit.mousePressEvent = lambda *args: self.hidePopup() if self.is_popped_up else self.showPopup()  # type: ignore[attr-value]
        else:
            line_edit.mousePressEvent = lambda *args: QLineEdit.mousePressEvent(line_edit, *args)  # type: ignore[attr-value]
        line_edit.home(False)  # noqa: FBT003
        return line_edit

    def setModel(
        self,
        model: QStringListModel | QStandardItemModel,
    ):
        assert isinstance(model, (QStringListModel, QStandardItemModel))
        self.proxy_model.setSourceModel(model)
        self.source_model: QStringListModel | QStandardItemModel = model

    def keyPressEvent(
        self,
        event: QKeyEvent,
    ):
        if self.is_popped_up:
            self.filter_line_edit.keyPressEvent(event)
        else:
            super().keyPressEvent(event)

    def set_combo_box_text(
        self,
        text: str,
        *,
        always_on_top: bool = True,  # Recommended
    ):
        text_search_index: int = self.findText(text, Qt.MatchFlag.MatchCaseSensitive)
        if always_on_top:
            if text_search_index != -1:  # Text found
                self.removeItem(text_search_index)
            new_index: int = 0
            self.insertItem(new_index, text)
        elif text_search_index == -1:  # Text not found
            self.addItem(text)
            new_index = self.findText(text)
        self.setCurrentIndex(new_index)
        line_edit: QLineEdit = self.lineEdit()
        if line_edit is not None:
            line_edit.setText(text)
        else:
            self.setCurrentText(text)

    def showPopup(self):
        self.orig_text = self.currentText()
        if not self.items_loaded and self.items:
            if isinstance(self.source_model, QStringListModel):
                self.source_model.setStringList(self.items)
            elif isinstance(self.source_model, QStandardItemModel):
                self.source_model.clear()
                for item in self.items:
                    self.source_model.appendRow(QStandardItem(item))
            self.set_combo_box_text(self.orig_text)
            self.items_loaded = True

        self.old_width = self.width()
        max_width: int = self.old_width
        delegate: QAbstractItemDelegate | None = self.itemDelegate()
        assert isinstance(delegate, QStyledItemDelegate)
        items_to_measure: int = min(self.source_model.rowCount(), 1000)
        for i in range(items_to_measure):
            item_width: int = delegate.sizeHint(QStyleOptionViewItem(), self.source_model.index(i, 0)).width()
            max_width = max(item_width, max_width)
        adjusted_width: int = max_width
        self.resize(adjusted_width, self.height())
        self.filter_line_edit.setFixedWidth(adjusted_width)
        self.filter_line_edit.adjustSize()
        self.filter_line_edit.setParent(self.view())
        self.filter_line_edit.move(0, 0)
        self.filter_line_edit.show()
        super().showPopup()
        self.resize(self.old_width, self.height())
        self.is_popped_up = True
        self.filter_line_edit.setFocus()
        cast("CustomListView", self.view()).repaint()

    def hidePopup(self):
        if self.force_stay_popped_up:
            self.force_stay_popped_up = False
            return
        super().hidePopup()
        self.is_popped_up = False
        self.resize(self.old_width, self.height())

    def populate_combo_box(
        self,
        items: Sequence[str],
    ):
        self.items = list(items)
        self.items_loaded = False

    def filter_items(
        self,
        text: str,
    ):
        self.proxy_model.set_filter_text(text)

    def set_button_delegate(
        self,
        button_text: str,
        button_callback: Callable[[str], Any],
    ):
        listview: QAbstractItemView | None = self.view()
        assert isinstance(listview, CustomListView)
        listview.button_text = button_text
        listview.button_callback = button_callback
        listview.setItemDelegate(ButtonDelegate(self, button_text, button_callback, listview))


if __name__ == "__main__":

    class MainWindow(QMainWindow):
        def __init__(self):
            super().__init__()
            self.setWindowTitle("FilterComboBox Test")
            self.setGeometry(100, 100, 300, 200)
            central_widget: QWidget = QWidget(self)
            self.setCentralWidget(central_widget)
            layout: QVBoxLayout = QVBoxLayout(central_widget)
            self.combo_box: FilterComboBox = FilterComboBox()
            self.combo_box.populate_combo_box(
                [f"Item {i}" for i in range(10)],
            )
            layout.addWidget(self.combo_box)
            central_widget.setLayout(layout)

    app = QApplication(sys.argv)
    main_window = MainWindow()
    main_window.show()
    sys.exit(app.exec())
