"""
novelWriter – Project Item Model
================================

File History:
Created: 2024-11-16 [2.6b2] ProjectNode
Created: 2024-11-16 [2.6b2] ProjectModel

This file is a part of novelWriter
Copyright (C) 2024 Veronica Berglyd Olsen and novelWriter contributors

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""  # noqa
from __future__ import annotations

import logging

from typing import TYPE_CHECKING

from PyQt6.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt
from PyQt6.QtGui import QFont, QIcon

from novelwriter.common import decodeMimeHandles, encodeMimeHandles, minmax
from novelwriter.constants import nwConst
from novelwriter.core.item import NWItem
from novelwriter.enum import nwItemClass
from novelwriter.types import QtAlignRight

if TYPE_CHECKING:
    from novelwriter.core.tree import NWTree

logger = logging.getLogger(__name__)

INV_ROOT = "invisibleRoot"
C_FACTOR = 0x0100

C_LABEL_TEXT    = 0x0000 | Qt.ItemDataRole.DisplayRole
C_LABEL_ICON    = 0x0000 | Qt.ItemDataRole.DecorationRole
C_LABEL_FONT    = 0x0000 | Qt.ItemDataRole.FontRole
C_COUNT_TEXT    = 0x0100 | Qt.ItemDataRole.DisplayRole
C_COUNT_ICON    = 0x0100 | Qt.ItemDataRole.DecorationRole
C_COUNT_ALIGN   = 0x0100 | Qt.ItemDataRole.TextAlignmentRole
C_ACTIVE_ICON   = 0x0200 | Qt.ItemDataRole.DecorationRole
C_ACTIVE_TIP    = 0x0200 | Qt.ItemDataRole.ToolTipRole
C_ACTIVE_ACCESS = 0x0200 | Qt.ItemDataRole.AccessibleTextRole
C_STATUS_ICON   = 0x0300 | Qt.ItemDataRole.DecorationRole
C_STATUS_TIP    = 0x0300 | Qt.ItemDataRole.ToolTipRole
C_STATUS_ACCESS = 0x0300 | Qt.ItemDataRole.AccessibleTextRole

NODE_FLAGS = Qt.ItemFlag.ItemIsEnabled
NODE_FLAGS |= Qt.ItemFlag.ItemIsSelectable
NODE_FLAGS |= Qt.ItemFlag.ItemIsDropEnabled

T_NodeData = str | QIcon | QFont | Qt.AlignmentFlag | None


class ProjectNode:
    """Core: Project Model Node Class.

    The project tree structure is saved as nodes in a tree, starting
    from a root node. This class makes up these nodes.

    Each node is a wrapper around an NWItem object. The NWItem is the
    object representing a single item in the project, and it only
    contains a reference to its parent as well as it top level root, but
    is itself not structured in a hierarchy in memory.

    This class provides the necessary hierarchical structure, as well as
    the data entries needed for populating the GUI project tree. It also
    handles pushing and pulling information from its NWItem when
    necessary.

    The data to be displayed could in principle be pulled from the
    NWItem whenever it is needed, but for performance reason it is
    cached, as the GUI will pull this information often.
    """

    C_NAME   = 0
    C_COUNT  = 1
    C_ACTIVE = 2
    C_STATUS = 3

    __slots__ = ("_cache", "_children", "_count", "_flags", "_item", "_parent", "_row")

    def __init__(self, item: NWItem) -> None:
        self._item = item
        self._children: list[ProjectNode] = []
        self._parent: ProjectNode | None = None
        self._row = 0
        self._cache: dict[int, T_NodeData] = {}
        self._flags = NODE_FLAGS
        self._count = 0
        self.refresh()
        self.updateCount()

    def __repr__(self) -> str:
        return (
            f"<ProjectNode handle={self._item.itemHandle} "
            f"parent={self._parent.item.itemHandle if self._parent else None} "
            f"row={self._row} "
            f"children={len(self._children)}>"
        )

    def __bool__(self) -> bool:
        # A node should always evaluate to True.
        return True

    ##
    #  Properties
    ##

    @property
    def item(self) -> NWItem:
        """The project item of the node."""
        return self._item

    @property
    def children(self) -> list[ProjectNode]:
        """All children of the node."""
        return self._children

    @property
    def count(self) -> int:
        """The count of the node."""
        return self._count

    ##
    #  Data Maintenance
    ##

    def refresh(self) -> None:
        """Refresh data values."""
        # Label
        self._cache[C_LABEL_ICON] = self._item.getMainIcon()
        self._cache[C_LABEL_TEXT] = self._item.itemName
        self._cache[C_LABEL_FONT] = self._item.getMainFont()

        # Count
        self._cache[C_COUNT_ALIGN] = QtAlignRight

        # Active
        aText, aIcon = self._item.getActiveStatus()
        self._cache[C_ACTIVE_ICON] = aIcon
        self._cache[C_ACTIVE_TIP] = aText
        self._cache[C_ACTIVE_ACCESS] = aText

        # Status
        sText, sIcon = self._item.getImportStatus()
        self._cache[C_STATUS_ICON] = sIcon
        self._cache[C_STATUS_TIP] = sText
        self._cache[C_STATUS_ACCESS] = sText

    def updateCount(self, propagate: bool = True) -> None:
        """Update counts, and propagate upwards in the tree."""
        self._count = self._item.mainCount + sum(c._count for c in self._children)  # noqa: SLF001
        self._cache[C_COUNT_TEXT] = f"{self._count:n}"
        if propagate and (parent := self._parent):
            parent.updateCount()

    ##
    #  Data Access
    ##

    def row(self) -> int:
        """Return the node's row number."""
        return self._row

    def childCount(self) -> int:
        """Return the number of children of the node."""
        return len(self._children)

    def data(self, column: int, role: Qt.ItemDataRole) -> T_NodeData:
        """Return cached node data."""
        return self._cache.get(C_FACTOR*column | role)

    def flags(self) -> Qt.ItemFlag:
        """Return cached node flags."""
        return self._flags

    def parent(self) -> ProjectNode | None:
        """Return the parent of the node."""
        return self._parent

    def child(self, row: int) -> ProjectNode | None:
        """Return a child of the node."""
        if 0 <= row < len(self._children):
            return self._children[row]
        return None

    def allChildren(self) -> list[ProjectNode]:
        """Return a recursive list of all children."""
        nodes: list[ProjectNode] = []
        self._recursiveAppendChildren(nodes)
        return nodes

    ##
    #  Data Edit
    ##

    def addChild(self, child: ProjectNode, pos: int = -1) -> None:
        """Add a child item to this item."""
        child._parent = self
        self._updateRelationships(child)
        if 0 <= pos < len(self._children):
            self._children.insert(pos, child)
        else:
            child._row = len(self._children)
            self._children.append(child)
        self._refreshChildrenPos()
        self._item.notifyNovelStructureChange()

    def takeChild(self, pos: int) -> ProjectNode | None:
        """Remove a child item and return it."""
        if 0 <= pos < len(self._children):
            node = self._children.pop(pos)
            self._refreshChildrenPos()
            self.updateCount()
            self._item.notifyNovelStructureChange()
            return node
        return None

    def moveChild(self, source: int, target: int) -> None:
        """Move a child internally."""
        count = len(self._children)
        if (source != target) and (0 <= source < count) and (0 <= target <= count):
            node = self._children.pop(source)
            self._children.insert(target, node)
            self._refreshChildrenPos()
            self._item.notifyNovelStructureChange()

    def setExpanded(self, state: bool) -> None:
        """Set the node's expanded state."""
        if state and self._children:
            self._item.setExpanded(True)
        else:
            self._item.setExpanded(False)

    ##
    #  Internal Functions
    ##

    def _recursiveAppendChildren(self, children: list[ProjectNode]) -> None:
        """Recursively add all nodes to a list."""
        for node in self._children:
            children.append(node)
            node._recursiveAppendChildren(children)  # noqa: SLF001

    def _refreshChildrenPos(self) -> None:
        """Update the row value on all children."""
        for n, child in enumerate(self._children):
            child._row = n  # noqa: SLF001
            child.item.setOrder(n)

    def _updateRelationships(self, child: ProjectNode) -> None:
        """Update a child item's relationships."""
        if self._parent:
            child.item.setParent(self._item.itemHandle)
            child.item.setRoot(self._item.itemRoot)
            child.item.setClassDefaults(self._item.itemClass)
            child._flags = NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
        else:
            child.item.setParent(None)
            child.item.setRoot(child.item.itemHandle)
            child.item.setClassDefaults(child.item.itemClass)


class ProjectModel(QAbstractItemModel):
    """Core: Project Model Class.

    This class provides the interface for the tree widget used on the
    GUI. It implements the QModelIndex based interface required, adds
    support for drag and drop, and a few other novelWriter-specific
    methods needed primarily by the project tree GUI component.
    """

    __slots__ = ("_root", "_tree")

    def __init__(self, tree: NWTree) -> None:
        super().__init__()
        self._tree = tree
        self._root = ProjectNode(NWItem(tree.project, INV_ROOT))
        self._root.item.setName("Invisible Root")
        logger.debug("Ready: ProjectModel")

    def __del__(self) -> None:  # pragma: no cover
        logger.debug("Delete: ProjectModel")

    ##
    #  Properties
    ##

    @property
    def root(self) -> ProjectNode:
        """Return the model root item."""
        return self._root

    ##
    #  Model Interface
    ##

    def rowCount(self, index: QModelIndex) -> int:
        """Return the number of rows for an entry."""
        if index.isValid():
            return index.internalPointer().childCount()
        return self._root.childCount()

    def columnCount(self, index: QModelIndex) -> int:
        """Return the number of columns for an entry."""
        return 4

    def parent(self, index: QModelIndex) -> QModelIndex:
        """Get the parent model index of another index."""
        if index.isValid() and (node := index.internalPointer()) and (parent := node.parent()):
            return self.createIndex(parent.row(), 0, parent)
        return QModelIndex()

    def index(self, row: int, column: int, parent: QModelIndex | None = None) -> QModelIndex:
        """Get the index of a child item of a parent."""
        parent = parent or QModelIndex()
        if self.hasIndex(row, column, parent):
            node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
            if child := node.child(row):
                return self.createIndex(row, column, child)
        return QModelIndex()

    def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData:
        """Return display data for a project node."""
        if index.isValid():
            return index.internalPointer().data(index.column(), role)
        return None

    def flags(self, index: QModelIndex) -> Qt.ItemFlag:
        """Return flags for a project node."""
        if index.isValid():
            return index.internalPointer().flags()
        return Qt.ItemFlag.NoItemFlags

    ##
    #  Drag and Drop
    ##

    def supportedDropActions(self) -> Qt.DropAction:
        """Return supported drop actions."""
        return Qt.DropAction.MoveAction

    def mimeTypes(self) -> list[str]:
        """Return the supported mime types of the model."""
        return [nwConst.MIME_HANDLE]

    def mimeData(self, indices: list[QModelIndex]) -> QMimeData:
        """Encode mime data about a selection."""
        handles = [
            i.internalPointer().item.itemHandle
            for i in indices if i.isValid() and i.column() == 0
        ]
        mime = QMimeData()
        encodeMimeHandles(mime, handles)
        return mime

    def canDropMimeData(
        self, data: QMimeData, action: Qt.DropAction,
        row: int, column: int, parent: QModelIndex
    ) -> bool:
        """Check if mime data can be dropped on the current location."""
        if parent.isValid() and parent.internalPointer() is not self._root:
            return data.hasFormat(nwConst.MIME_HANDLE) and action == Qt.DropAction.MoveAction
        return False

    def dropMimeData(
        self, data: QMimeData, action: Qt.DropAction,
        row: int, column: int, parent: QModelIndex
    ) -> bool:
        """Process mime data drop."""
        if self.canDropMimeData(data, action, row, column, parent):
            items = [
                index for handle in decodeMimeHandles(data)
                if (index := self.indexFromHandle(handle)).isValid()
            ]
            self.multiMove(items, parent, row)
            return True
        return False

    ##
    #  Data Access
    ##

    def row(self, index: QModelIndex) -> int:
        """Return the row number of the  index."""
        if index.isValid():
            return index.internalPointer().row()
        return -1

    def node(self, index: QModelIndex) -> ProjectNode | None:
        """Return the node for a given model index."""
        if index.isValid():
            return index.internalPointer()
        return None

    def nodes(self, indices: list[QModelIndex]) -> list[ProjectNode]:
        """Return the nodes for a list of model indices."""
        return [i.internalPointer() for i in indices if i.isValid() and i.column() == 0]

    def indexFromHandle(self, handle: str | None) -> QModelIndex:
        """Get the index representing a node in the model."""
        if handle and (node := self._tree.nodes.get(handle)):
            return self.createIndex(node.row(), 0, node)
        return QModelIndex()

    def indexFromNode(self, node: ProjectNode, column: int = 0) -> QModelIndex:
        """Get the index representing a node in the model."""
        return self.createIndex(node.row(), column, node)

    ##
    #  Model Edit
    ##

    def insertChild(self, child: ProjectNode, parent: QModelIndex, pos: int) -> None:
        """Insert a node into the model at a given position."""
        node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
        count = node.childCount()
        row = minmax(pos, 0, count) if pos >= 0 else count
        self.beginInsertRows(parent, row, row)
        node.addChild(child, row)
        self.endInsertRows()

    def removeChild(self, parent: QModelIndex, pos: int) -> ProjectNode | None:
        """Remove a node from the model and return it."""
        node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
        if 0 <= pos < node.childCount():
            self.beginRemoveRows(parent, pos, pos)
            child = node.takeChild(pos)
            self.endRemoveRows()
            return child
        return None

    def internalMove(self, index: QModelIndex, step: int) -> None:
        """Move an item internally among its siblings."""
        if index.isValid():
            node: ProjectNode = index.internalPointer()
            if parent := node.parent():
                pos = index.row()
                new = minmax(pos + step, 0, parent.childCount() - 1)
                if new != pos:
                    end = new if new < pos else new + 1
                    self.beginMoveRows(index.parent(), pos, pos, index.parent(), end)
                    parent.moveChild(pos, new)
                    self.endMoveRows()

    def multiMove(self, indices: list[QModelIndex], target: QModelIndex, pos: int = -1) -> None:
        """Move multiple items to a new location."""
        if target.isValid():
            # This is a two pass process. First we only select unique
            # non-root items for move, then we do a second pass and only
            # move those items that don't have a parent also scheduled
            # for moving or have already been moved. Child items are
            # moved with the parent.
            pruned = []
            handles = set()
            for index in indices:
                if index.isValid():
                    node: ProjectNode = index.internalPointer()
                    handle = node.item.itemHandle
                    if node.item.isRootType() is False and handle not in handles:
                        pruned.append(node)
                        handles.add(handle)
            for node in (reversed(pruned) if pos >= 0 else pruned):
                if node.item.itemParent not in handles:
                    index = self.indexFromNode(node)
                    if temp := self.removeChild(index.parent(), index.row()):
                        self.insertChild(temp, target, pos)
                        for child in reversed(node.allChildren()):
                            node._updateRelationships(child)  # noqa: SLF001
                            child.item.notifyToRefresh()
                        node.item.notifyToRefresh()

    ##
    #  Other Methods
    ##

    def clear(self) -> None:
        """Clear the project model."""
        self._root.children.clear()

    def allExpanded(self) -> list[QModelIndex]:
        """Return a list of all expanded items."""
        return [
            self.createIndex(node.row(), 0, node) for node in self._root.allChildren()
            if node.item.isExpanded
        ]

    def trashSelection(self, indices: list[QModelIndex]) -> bool:
        """Check if a selection of indices are all in trash or not."""
        for index in indices:
            if index.isValid():
                node: ProjectNode = index.internalPointer()
                if node.item.itemClass != nwItemClass.TRASH:
                    return False
        return True
