from abc import abstractmethod
from time import sleep
from typing import Any

from qgis.core import QgsApplication, QgsTask
from qgis.PyQt import QtCore
from qgis.PyQt.QtCore import QAbstractTableModel, QModelIndex, QObject, Qt, QVariant
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QProgressBar

from oslandia.helpdesk.gitlab_object import PageLoadResult
from oslandia.toolbelt import PlgLogger
from oslandia.toolbelt.preferences import PlgOptionsManager, PlgSettingsStructure


class AbstractGitlabObjectModel(QAbstractTableModel):
    qsig_page_loaded_signal = QtCore.pyqtSignal(int)

    def __init__(self, parent: QObject | None = None) -> None:
        """
        QAbstractTableModel for GitLab API object display. This is an abstract class.

        Data for GitLab API object are loaded for each available page in the API.
        Data load is done in a QgsTask.

        Method to implement:
        - _column_headers : return the column header list for the displayed data
        - _object_data_for_role : return the GitLab object data for a column and a Qt role
        - _load_data_for_page : return the result of a page load, will be used to get GitLab object for a row

        Optional implementation:
        - _loading_col : return column for loading text

        To define a filter that will be used to get content with _load_data_for_page you must use set_filter function.
        It will store a filter object and use it for each page load.

        Args:
            parent: QObject parent
        """
        super().__init__(parent)
        self.log = PlgLogger().log

        self.nb_row = 0
        self.nb_page = 0

        self._data_loaded = False
        self._gitlab_object_row: list[Any] = []

        self.main_task: QgsTask | None = None
        self.main_task_id: int | None = None

        self.allows_null_value = False
        self.obj_filter = None

        self._progress_bar: QProgressBar | None = None

        settings: PlgSettingsStructure = PlgOptionsManager.get_plg_settings()
        self.nb_item_by_page = settings.nb_item_by_page

    def wait_for_data_load(self) -> None:
        """Wait until all data is loaded. Check that main task is finished with
        self._data_loaded.
        """
        while not self._data_loaded:
            QgsApplication.processEvents()
            sleep(0.1)

    def set_progress_bar(self, progress_bar: QProgressBar | None) -> None:
        """Define progress bar to check main load task progress.

        :param progress_bar: progress bar to connect to main loading task
        :type progress_bar: Optional[QProgressBar]
        """
        self._progress_bar = progress_bar

        if self._progress_bar:
            self._progress_bar.setRange(0, 100)
        self._connect_task_to_progress_bar(self._get_main_task())

    def _get_main_task(self) -> QgsTask | None:
        """Return main stask from QgsApplication.taskManager with current id

        :return: main task if running
        :rtype: Optional[QgsTask]
        """
        if self.main_task_id is not None:
            task_manager = QgsApplication.taskManager()
            return task_manager.task(self.main_task_id)
        return None

    def _connect_task_to_progress_bar(self, main_task: QgsTask | None) -> None:
        """Connect a task to the current progress bar

        :param main_task: task to connect
        :type main_task: Optional[QgsTask]
        """
        if main_task and self._progress_bar:
            self._progress_bar.setVisible(True)
            main_task.progressChanged.connect(
                lambda val: self._progress_bar.setValue(int(val))
            )

    @abstractmethod
    def _column_headers(self) -> list[str]:
        """Returns column header list

        :raises NotImplementedError: method must be implemented.
        :return: columns header
        :rtype: List[str]
        """
        raise NotImplementedError(
            "Must define header in implemented AbstractGitlabObjectModel"
        )

    @abstractmethod
    def _object_data_for_role(
        self,
        gitlab_object: Any,
        col: int,
        role: Qt.ItemDataRole = Qt.ItemDataRole.DisplayRole,
    ) -> Any:
        """Return qt model data for a specific column and role

        :param gitlab_object: object to display
        :type gitlab_object: Any
        :param col: column for table display
        :type col: int
        :param role: qt role, defaults to Qt.ItemDataRole.DisplayRole
        :type role: Qt.ItemDataRole, optional
        :raises NotImplementedError: must be implemented

        :return: qt model data
        :rtype: Any
        """
        raise NotImplementedError(
            "Must define header in implemented AbstractGitlabObjectModel"
        )

    @abstractmethod
    def _load_object_page(
        self, task: QgsTask | None, obj_filter: Any, page: int
    ) -> PageLoadResult | None:
        """Load object for a specific page

        :param task: task used for load
        :type task: Optional[QgsTask]
        :param obj_filter: _description_
        :type obj_filter: str
        :param page: page to load
        :type page: int
        :raises NotImplementedError: method must be implemented
        :return: page load result
        :rtype: PageLoadResult
        """
        raise NotImplementedError(
            "Must define header in implemented AbstractGitlabObjectModel"
        )

    @abstractmethod
    def _loading_col(self) -> int:
        """Return column used to display loading text

        :return: loading column (default 0)
        :rtype: int
        """
        return 0

    def headerData(  # noqa: N802
        self,
        section: int,
        orientation: Qt.Orientation,
        role: Qt.ItemDataRole.DisplayRole,
    ) -> QVariant | None:
        """Define data for header, only display role is supported

        :param section: section number (column for Horizontal orientation ,
            row for Vertical orientation)
        :type section: int
        :param orientation: orientation
        :type orientation: Qt.Orientation
        :param role: data role
        :type role: Qt.ItemDataRole.DisplayRole
        :return: header data
        :rtype: QVariant
        """
        if (
            orientation == Qt.Orientation.Horizontal
            and role == Qt.ItemDataRole.DisplayRole
        ):
            return self._column_headers()[section]
        return None

    def rowCount(self, parent: QModelIndex | None = None) -> int:  # noqa: N802
        """Define number of row in model

        :param parent: parent, defaults to QModelIndex() not used in case of table
        :type parent: QModelIndex, optional

        :return: number of row
        :rtype: int
        """

        return self.nb_row

    def columnCount(self, parent: QModelIndex | None = None) -> int:  # noqa: N802
        """Define number of column in model.

        :param parent: parent, defaults to QModelIndex() not used in case of table
        :type parent: QModelIndex, optional

        :return: number of column
        :rtype: int
        """

        return len(self._column_headers())

    def data(
        self, index: QModelIndex, role: Qt.ItemDataRole = Qt.ItemDataRole.DisplayRole
    ) -> Any:
        """Define data for model index and role.
        Use data from cache or display loading information before cache load

        :param index: model index
        :type index: QModelIndex
        :param role: data role, defaults to Qt.ItemDataRole.DisplayRole
        :type role: Qt.ItemDataRole, optional

        :return: model data for index and role
        :rtype: Any
        """
        if not index.isValid():
            return None

        row, col = index.row(), index.column()

        # No value for null value row
        if self.allows_null_value and index.row() == 0:
            return None

        # If row loaded, get object and return qt model data from implementation
        if gitlab_object := self._gitlab_object_row[row]:
            return self._object_data_for_role(gitlab_object, col, role)

        # Display loading text
        if col == self._loading_col():
            if role == Qt.ItemDataRole.DisplayRole:
                return self._loading_col_str()
            elif role == Qt.ItemDataRole.DecorationRole:
                return self._loading_col_icon()

        return None

    def _loading_col_str(self) -> str:
        """String display for loading column if no gitlab object available

        :return: string for loading column
        :rtype: str
        """
        if self._data_loaded:
            return self.tr("Error during request, please refresh")
        return self.tr("Loading...")

    def _loading_col_icon(self) -> QIcon:
        """Icon displayed for loading column if no gitlab object available

        :return: icon for loading column
        :rtype: QIcon
        """
        if self._data_loaded:
            return QgsApplication.getThemeIcon("mIconWarning.svg")
        return QgsApplication.getThemeIcon("mTaskQueued.svg")

    def flags(self, index: QModelIndex) -> Qt.ItemFlags:
        """Define flag to disable edition

        :param index: model index
        :type index: QModelIndex
        :return: flag for index
        :rtype: Qt.ItemFlags
        """
        default_flags = super().flags(index)
        return default_flags & ~Qt.ItemFlag.ItemIsEditable  # Disable editing

    def _load_finished(
        self, exception: Exception | None, result: bool | None = None
    ) -> None:
        """Remove progress bar if defined after load finished.

        :param exception: exception raised during task, None if no exception raised
        :type exception: Exception | None
        :param result: value returned by the task function, None if the task failed
        :type result: bool | None
        """
        self._data_loaded = True
        if self._progress_bar:
            self._progress_bar.setVisible(False)

    def set_filter(self, obj_filter: Any) -> None:
        """Define filter used to load data.

        Current data is cleared and we launch QgsTask to load all available pages.

        :param obj_filter: object filter
        :type obj_filter: Any
        """
        self.obj_filter = obj_filter

        # Get current main task from QgsApplication.taskManager
        # Needed because self.main_task can be deleted
        main_task: QgsTask | None = self._get_main_task()
        if main_task is not None:
            main_task.cancel()

        # Create a new task, need to keep reference to get task on_finished signal
        self.main_task = QgsTask.fromFunction(
            self._load_task_description(),
            lambda _task: True,
            on_finished=self._load_finished,
        )
        self._connect_task_to_progress_bar(self.main_task)
        self._data_loaded = False

        # Clear all data
        self.beginResetModel()
        self._gitlab_object_row.clear()

        # Load first page
        result: PageLoadResult | None = self._load_object_page(None, obj_filter, 1)

        # If no result for first page finish to reset model
        if result is None:
            self.endResetModel()
            return

        self.nb_row: int = result.nb_object

        if self.allows_null_value:
            self.nb_row += 1

        self._gitlab_object_row = [None] * self.nb_row
        self.nb_page: int = result.nb_page
        self._page_loaded(None, result)

        # Create parallel task for page load
        settings: PlgSettingsStructure = PlgOptionsManager.get_plg_settings()
        nb_parallel_task = settings.nb_parallel_task
        self.nb_item_by_page = settings.nb_item_by_page

        # Define page for each chunk.
        task_chunk_list: list[list[int]] = [
            list(range(i, min(i + nb_parallel_task, self.nb_page + 1)))
            for i in range(2, self.nb_page + 1, nb_parallel_task)
        ]
        subtask_list = []
        for task_chunk in task_chunk_list:
            chunk_subtask = []
            for page in task_chunk:
                subtask = QgsTask.fromFunction(
                    description=self.tr("Page {} / {}").format(page, self.nb_page),
                    function=self._load_object_page,
                    on_finished=self._page_loaded,
                    obj_filter=self.obj_filter,
                    flags=QgsTask.Flag.Silent,
                    page=page,
                )
                chunk_subtask.append(subtask)

                # Creating dependencies using the last set of chunck subtask
                if len(subtask_list) == 0:
                    self.main_task.addSubTask(
                        subtask,
                        subTaskDependency=QgsTask.SubTaskDependency.ParentDependsOnSubTask,
                    )
                else:
                    self.main_task.addSubTask(
                        subtask,
                        dependencies=subtask_list[-1],
                        subTaskDependency=QgsTask.SubTaskDependency.ParentDependsOnSubTask,
                    )
            subtask_list.append(chunk_subtask)

        self.main_task_id = QgsApplication.taskManager().addTask(self.main_task)

        self.endResetModel()

    def _page_loaded(
        self, exception: Exception | None, result: PageLoadResult | None = None
    ) -> None:
        """Function call after QgsTask finished for issue load

        :param exception: exception raised during task, None if no exception raised
        :type exception: Optional[Exception]
        :param result: result of issue load, None in case of error during request
        :type result: Optional[PageLoadResult]
        """
        if result is not None:
            # Add page content for each row
            page: int = result.page
            gitlab_object_list: list[Any] = result.gitlab_object_list
            self.qsig_page_loaded_signal.emit(page)

            for i, gitlab_object in enumerate(gitlab_object_list):
                row = i + (page - 1) * self.nb_item_by_page
                if self.allows_null_value:
                    row += 1
                self.set_row_object(row, gitlab_object)

    def _load_task_description(self) -> str:
        """Test display in QGIS task manager for page load task

        :return: page load description
        :rtype: str
        """
        return self.tr("Load {} pages for obj_filter {}").format(
            self.nb_page, self.obj_filter
        )

    def set_row_object(self, row: int, gitlab_object: Any) -> None:
        """Define data for a row

        :param row: row
        :type row: int
        :param gitlab_object: issue
        :type gitlab_object: Any
        """
        # Store in row dict
        self._gitlab_object_row[row] = gitlab_object

        # Indicate that model has changed
        super().dataChanged.emit(
            self.index(row, 0), self.index(row, self.columnCount() - 1)
        )
