# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only

from functools import partial
from typing import Any, override

import structlog
from PIL import Image, ImageQt
from PySide6.QtCore import Signal
from PySide6.QtGui import QAction, QPixmap, QShowEvent, Qt
from PySide6.QtWidgets import QGraphicsOpacityEffect, QWidget

from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.utils.types import unwrap
from tagstudio.i18n.translations import Translations
from tagstudio.qt.app_settings import AppSettings
from tagstudio.qt.controllers.autofill_line_edit import QtCore, QtGui
from tagstudio.qt.controllers.modal_content import ModalContent
from tagstudio.qt.controllers.underlined_widget import UnderlinedWidget
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
from tagstudio.qt.views.styles.stylesheets import (
    autofill_line_edit_style,
    autofill_line_edit_top_style,
)
from tagstudio.qt.views.suggest_box_view import SuggestBoxView

logger = structlog.get_logger(__name__)


def _item_id(item: object) -> int:
    item_id: Any = getattr(item, "id")  # noqa: B009  # pyright: ignore[reportExplicitAny]

    if isinstance(item_id, int):
        return item_id
    else:
        raise AttributeError()


def _item_name(item: object) -> str:
    item_name: Any = getattr(item, "name")  # noqa: B009  # pyright: ignore[reportExplicitAny]

    if isinstance(item_name, str):
        return item_name
    else:
        raise AttributeError()


class SuggestBox[T](QWidget):
    item_chosen = Signal(object)
    done = Signal(str)  # Query

    def __init__(self, library: Library, settings: AppSettings, placeholder_text: str = "") -> None:
        super().__init__()
        self._lib = library
        self._settings = settings
        self._rm = ResourceManager()
        self._limit = 25
        self._is_shift_held = False
        self._search_results: list[T] = []
        self._selection_index = 0
        self.added: list[int] = []
        self.excluded: list[int] = []

        self.setLayout(SuggestBoxView(placeholder_text))
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
        self._connect_callbacks()

        self._keep_box_open_action = QAction(Translations["settings.keep_suggest_boxes_open"], self)
        self._keep_box_open_action.setCheckable(True)
        self.addAction(self._keep_box_open_action)
        self.layout().search_field.addAction(self._keep_box_open_action)
        self._keep_box_open_action.triggered.connect(
            lambda checked: self._toggle_keep_open(checked)
        )

    def _toggle_keep_open(self, checked: bool) -> None:
        self._settings.keep_suggest_boxes_open = checked
        self._settings.save()

    def set_placeholder_text(self, text: str) -> None:
        self.layout().search_field.setPlaceholderText(text)

    def hide_and_reset(self) -> None:
        self.hide()
        self.layout().search_field.setDisabled(True)
        self._on_shift_held(held=False)

    def _connect_callbacks(self) -> None:
        self.layout().search_field.textChanged.connect(self._on_search_query_changed)
        self.layout().search_field.editingFinished.connect(self._editing_finished_callback)
        self.layout().search_field.return_pressed.connect(
            lambda: self._on_search_query_submitted(self.layout().search_field.text())
        )
        self.layout().search_field.shift_return_pressed.connect(
            lambda: self._on_search_query_submitted(
                self.layout().search_field.text(), always_create=True
            )
        )

        self.layout().search_field.holding_shift.connect(partial(self._on_shift_held))
        self.layout().search_field.index_updated.connect(partial(self._on_index_updated))

    def set_hint_icon(self, icon: Image.Image | None) -> None:
        if icon:
            pixmap = QPixmap.fromImage(ImageQt.ImageQt(auto_theme_overlay(icon)))
            self.layout().hint_icon_action.setIcon(pixmap)
        else:
            self.layout().hint_icon_action.setIcon(QPixmap())

    def _on_shift_held(self, held: bool) -> None:
        self._is_shift_held = held
        for i in range(0, self.layout().content_layout.count()):
            underlined_widget = self.layout().content_layout.itemAt(i).widget()  # pyright: ignore
            assert isinstance(underlined_widget, UnderlinedWidget)

            if held and i == self._selection_index:
                opacity_effect = QGraphicsOpacityEffect(self)
                opacity_effect.setOpacity(0.3)
                underlined_widget.widget.setGraphicsEffect(opacity_effect)
            elif (
                len(self._search_results) > i
                and _item_id(self._search_results[i]) not in self.added
            ):
                underlined_widget.widget.setGraphicsEffect(None)  # pyright: ignore[reportArgumentType]
        self._update_hint_icon()

    def _on_index_updated(self, delta: int) -> None:
        # Initialize the widget count (non-hidden)
        widget_count = 0
        for i in range(0, self.layout().content_layout.count()):
            widget = unwrap(self.layout().content_layout.itemAt(i).widget())  # pyright: ignore
            if not widget.isHidden():
                widget_count += 1

        # Update the index
        old_idx = self._selection_index
        max_idx = widget_count - 1
        if self._selection_index + delta < 0:
            # Can't move further left
            self._selection_index = 0
        elif self._selection_index + delta > max_idx:
            self._selection_index = max_idx
        else:
            self._selection_index = self._selection_index + delta

        # Don't update the UI if there's no index change
        if old_idx == self._selection_index:
            return

        # Draw the correct underline for the selected widget
        for i in range(0, widget_count):
            underlined_widget = self.layout().content_layout.itemAt(i).widget()  # pyright: ignore
            assert isinstance(underlined_widget, UnderlinedWidget)
            if i == self._selection_index:
                underlined_widget.toggle_underline(is_hidden=False)
                self.layout().scroll_area.ensureWidgetVisible(
                    underlined_widget, xmargin=16, ymargin=0
                )
            else:
                underlined_widget.toggle_underline(is_hidden=True)

        self._on_shift_held(self._is_shift_held)
        self._update_hint_icon()

    def _clear_search_query(self) -> None:
        self.layout().search_field.setText("")

    def _get_item_widget(self, index: int, library: Library) -> Any:  # pyright: ignore
        raise NotImplementedError()

    def _on_search_query_changed(self, query: str) -> None:
        self._update_items(query.strip())
        self._update_hint_icon()

    def _on_search_query_submitted(self, query: str, always_create: bool = False) -> None:
        # Focus search field if no query
        if not query:
            self.done.emit(query)
            return
        elif not self.isHidden():
            self.layout().search_field.setFocus()

        # Create and add item if no search results
        if (len(self._search_results) <= 0) or always_create:
            self._on_item_create()
        else:
            self._on_item_chosen(self._search_results[self._selection_index])

        self._clear_search_query()
        self._update_items()

    def _on_item_create(self) -> None:
        raise NotImplementedError()

    def _on_item_edit(self, item: T) -> None:  # pyright: ignore[reportUnusedParameter]
        raise NotImplementedError()

    def _on_item_chosen(self, item: T) -> None:  # pyright: ignore[reportUnusedParameter]
        raise NotImplementedError()

    def _is_excluded(self, item: T) -> bool:
        return _item_id(item) in self.excluded

    def _is_selected_item_added(self) -> bool:
        return bool(
            len(self._search_results) > self._selection_index
            and _item_id(self._search_results[self._selection_index]) in self.added
        )

    def _update_hint_icon(self) -> None:
        raise NotImplementedError()

    def _update_items(self, query: str | None = None) -> None:
        """Update the item list given a search query."""
        logger.info("[SearchPanel] Updating items", limit=self._limit)
        self._selection_index = 0
        if self.layout().content_layout.count() > 0:
            self.layout().scroll_area.ensureWidgetVisible(
                self.layout().content_layout.itemAt(0).widget(),  # pyright: ignore
                xmargin=16,
                ymargin=0,
            )

        # Get results for the search query
        query_lower = "" if not query else query.lower()
        search_results: tuple[list[T], list[T]] = self._search_items(query_lower)

        # Sort and prioritize the results
        direct_results = list({item for item in search_results[0] if not self._is_excluded(item)})
        direct_results.sort(key=lambda item: _item_name(item).lower())

        ancestor_results = list({item for item in search_results[1] if not self._is_excluded(item)})
        ancestor_results.sort(key=lambda item: _item_name(item).lower())

        raw_results = list(direct_results + ancestor_results)
        priority_results: set[T] = set()

        if query and query.strip():
            for raw_item in raw_results:
                if _item_name(raw_item).lower().startswith(query_lower):
                    priority_results.add(raw_item)

        all_results: list[T] = sorted(list(priority_results), key=lambda i: len(_item_name(i))) + [
            item for item in raw_results if item not in priority_results
        ]

        # Target items already added to a selection and move them to the end of the list
        already_added: list[T] = [i for i in all_results if _item_id(i) in self.added]
        for item in already_added:
            if item in all_results:
                all_results.remove(item)
        all_results = all_results + already_added

        if self._limit > 0:
            all_results = all_results[: self._limit]

        self._search_results = all_results
        logger.info("[SearchPanel] Search results", results=self._search_results)

        for i in range(0, self._limit):
            item: T | None = all_results[i] if i < len(all_results) else None
            self._set_item_widget(item=item, index=i)

        if self.layout().content_layout.isEmpty():
            self.layout().scroll_area.setHidden(True)
            self.layout().content_layout.setContentsMargins(0, 0, 0, 0)
            self.layout().search_field.setStyleSheet(autofill_line_edit_style())
        else:
            self.layout().scroll_area.setHidden(False)
            self.layout().content_layout.setContentsMargins(4, 6, 4, 6)
            self.layout().search_field.setStyleSheet(autofill_line_edit_top_style())

    def _search_items(self, query: str) -> tuple[list[T], list[T]]:  # pyright: ignore[reportUnusedParameter]
        raise NotImplementedError()

    def _set_item_widget(self, item: T | None, index: int) -> None:  # pyright: ignore[reportUnusedParameter]
        raise NotImplementedError()

    def _editing_finished_callback(self) -> None:
        # Only trigger when the search field is clicked off of and there's no query.
        # NOTE: The search field is already cleared by this point when pressing enter.
        if self.layout().search_field.text() == "" and not self.layout().search_field.hasFocus():
            self.done.emit("")

    def _create_item_from_modal(self, edit_item_panel: ModalContent) -> None:  # pyright: ignore[reportUnusedParameter]
        raise NotImplementedError()

    def _edit_item(self, edit_item_panel: ModalContent) -> None:  # pyright: ignore[reportUnusedParameter]
        raise NotImplementedError()

    @override
    def showEvent(self, event: QShowEvent) -> None:
        self._update_items()
        self._on_shift_held(held=False)
        self.layout().search_field.setDisabled(False)
        self._clear_search_query()
        self._keep_box_open_action.setChecked(self._settings.keep_suggest_boxes_open)
        return super().showEvent(event)

    @override
    def layout(self) -> SuggestBoxView:
        return super().layout()  # pyright: ignore[reportReturnType]

    @override
    def keyPressEvent(self, event: QtGui.QKeyEvent) -> None:
        # When Escape is pressed, focus back on the search box.
        if event.key() in {QtCore.Qt.Key.Key_Enter, QtCore.Qt.Key.Key_Return}:
            self.done.emit("*")
        elif event.key() == QtCore.Qt.Key.Key_Escape:
            self.done.emit("")
