# coding: utf-8
# /*##########################################################################
#
# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/

__authors__ = ["V. Valls"]
__license__ = "MIT"
__date__ = "12/03/2019"


import os
import logging
import functools
from .. import qt
from .. import icons
from .Hdf5Node import Hdf5Node
from .Hdf5Item import Hdf5Item
from .Hdf5LoadingItem import Hdf5LoadingItem
from . import _utils
from ... import io as silx_io

_logger = logging.getLogger(__name__)

"""Helpers to take care of None objects as signal parameters.
PySide crash if a signal with a None parameter is emitted between threads.
"""
if qt.BINDING == 'PySide':
    class _NoneWraper(object):
        pass
    _NoneWraperInstance = _NoneWraper()

    def _wrapNone(x):
        """Wrap x if it is a None value, else returns x"""
        if x is None:
            return _NoneWraperInstance
        else:
            return x

    def _unwrapNone(x):
        """Unwrap x as a None if a None was stored by `wrapNone`, else returns
        x"""
        if x is _NoneWraperInstance:
            return None
        else:
            return x
else:
    # Allow to fix None event params to avoid PySide crashes
    def _wrapNone(x):
        return x

    def _unwrapNone(x):
        return x


def _createRootLabel(h5obj):
    """
    Create label for the very first npde of the tree.

    :param h5obj: The h5py object to display in the GUI
    :type h5obj: h5py-like object
    :rtpye: str
    """
    if silx_io.is_file(h5obj):
        label = os.path.basename(h5obj.filename)
    else:
        filename = os.path.basename(h5obj.file.filename)
        path = h5obj.name
        if path.startswith("/"):
            path = path[1:]
        label = "%s::%s" % (filename, path)
    return label


class LoadingItemRunnable(qt.QRunnable):
    """Runner to process item loading from a file"""

    class __Signals(qt.QObject):
        """Signal holder"""
        itemReady = qt.Signal(object, object, object)
        runnerFinished = qt.Signal(object)

    def __init__(self, filename, item):
        """Constructor

        :param LoadingItemWorker worker: Object holding data and signals
        """
        super(LoadingItemRunnable, self).__init__()
        self.filename = filename
        self.oldItem = item
        self.signals = self.__Signals()

    def setFile(self, filename, item):
        self.filenames.append((filename, item))

    @property
    def itemReady(self):
        return self.signals.itemReady

    @property
    def runnerFinished(self):
        return self.signals.runnerFinished

    def __loadItemTree(self, oldItem, h5obj):
        """Create an item tree used by the GUI from an h5py object.

        :param Hdf5Node oldItem: The current item displayed the GUI
        :param h5py.File h5obj: The h5py object to display in the GUI
        :rtpye: Hdf5Node
        """
        text = _createRootLabel(h5obj)
        item = Hdf5Item(text=text, obj=h5obj, parent=oldItem.parent, populateAll=True)
        return item

    def run(self):
        """Process the file loading. The worker is used as holder
        of the data and the signal. The result is sent as a signal.
        """
        h5file = None
        try:
            h5file = silx_io.open(self.filename)
            newItem = self.__loadItemTree(self.oldItem, h5file)
            error = None
        except IOError as e:
            # Should be logged
            error = e
            newItem = None
            if h5file is not None:
                h5file.close()

        # Take care of None value in case of PySide
        newItem = _wrapNone(newItem)
        error = _wrapNone(error)
        self.itemReady.emit(self.oldItem, newItem, error)
        self.runnerFinished.emit(self)

    def autoDelete(self):
        return True


class Hdf5TreeModel(qt.QAbstractItemModel):
    """Tree model storing a list of :class:`h5py.File` like objects.

    The main column display the :class:`h5py.File` list and there hierarchy.
    Other columns display information on node hierarchy.
    """

    H5PY_ITEM_ROLE = qt.Qt.UserRole
    """Role to reach h5py item from an item index"""

    H5PY_OBJECT_ROLE = qt.Qt.UserRole + 1
    """Role to reach h5py object from an item index"""

    USER_ROLE = qt.Qt.UserRole + 2
    """Start of range of available user role for derivative models"""

    NAME_COLUMN = 0
    """Column id containing HDF5 node names"""

    TYPE_COLUMN = 1
    """Column id containing HDF5 dataset types"""

    SHAPE_COLUMN = 2
    """Column id containing HDF5 dataset shapes"""

    VALUE_COLUMN = 3
    """Column id containing HDF5 dataset values"""

    DESCRIPTION_COLUMN = 4
    """Column id containing HDF5 node description/title/message"""

    NODE_COLUMN = 5
    """Column id containing HDF5 node type"""

    LINK_COLUMN = 6
    """Column id containing HDF5 link type"""

    COLUMN_IDS = [
        NAME_COLUMN,
        TYPE_COLUMN,
        SHAPE_COLUMN,
        VALUE_COLUMN,
        DESCRIPTION_COLUMN,
        NODE_COLUMN,
        LINK_COLUMN,
    ]
    """List of logical columns available"""

    sigH5pyObjectLoaded = qt.Signal(object)
    """Emitted when a new root item was loaded and inserted to the model."""

    sigH5pyObjectRemoved = qt.Signal(object)
    """Emitted when a root item is removed from the model."""

    sigH5pyObjectSynchronized = qt.Signal(object, object)
    """Emitted when an item was synchronized."""

    def __init__(self, parent=None, ownFiles=True):
        """
        Constructor

        :param qt.QWidget parent: Parent widget
        :param bool ownFiles: If true (default) the model will manage the files
            life cycle when they was added using path (like DnD).
        """
        super(Hdf5TreeModel, self).__init__(parent)

        self.header_labels = [None] * len(self.COLUMN_IDS)
        self.header_labels[self.NAME_COLUMN] = 'Name'
        self.header_labels[self.TYPE_COLUMN] = 'Type'
        self.header_labels[self.SHAPE_COLUMN] = 'Shape'
        self.header_labels[self.VALUE_COLUMN] = 'Value'
        self.header_labels[self.DESCRIPTION_COLUMN] = 'Description'
        self.header_labels[self.NODE_COLUMN] = 'Node'
        self.header_labels[self.LINK_COLUMN] = 'Link'

        # Create items
        self.__root = Hdf5Node()
        self.__fileDropEnabled = True
        self.__fileMoveEnabled = True
        self.__datasetDragEnabled = False

        self.__animatedIcon = icons.getWaitIcon()
        self.__animatedIcon.iconChanged.connect(self.__updateLoadingItems)
        self.__runnerSet = set([])

        # store used icons to avoid the cache to release it
        self.__icons = []
        self.__icons.append(icons.getQIcon("item-none"))
        self.__icons.append(icons.getQIcon("item-0dim"))
        self.__icons.append(icons.getQIcon("item-1dim"))
        self.__icons.append(icons.getQIcon("item-2dim"))
        self.__icons.append(icons.getQIcon("item-3dim"))
        self.__icons.append(icons.getQIcon("item-ndim"))

        self.__ownFiles = ownFiles
        self.__openedFiles = []
        """Store the list of files opened by the model itself."""
        # FIXME: It should be managed one by one by Hdf5Item itself

        # It is not possible to override the QObject destructor nor
        # to access to the content of the Python object with the `destroyed`
        # signal cause the Python method was already removed with the QWidget,
        # while the QObject still exists.
        # We use a static method plus explicit references to objects to
        # release. The callback do not use any ref to self.
        onDestroy = functools.partial(self._closeFileList, self.__openedFiles)
        self.destroyed.connect(onDestroy)

    @staticmethod
    def _closeFileList(fileList):
        """Static method to close explicit references to internal objects."""
        _logger.debug("Clear Hdf5TreeModel")
        for obj in fileList:
            _logger.debug("Close file %s", obj.filename)
            obj.close()
        fileList[:] = []

    def _closeOpened(self):
        """Close files which was opened by this model.

        File are opened by the model when it was inserted using
        `insertFileAsync`, `insertFile`, `appendFile`."""
        self._closeFileList(self.__openedFiles)

    def __updateLoadingItems(self, icon):
        for i in range(self.__root.childCount()):
            item = self.__root.child(i)
            if isinstance(item, Hdf5LoadingItem):
                index1 = self.index(i, 0, qt.QModelIndex())
                index2 = self.index(i, self.columnCount() - 1, qt.QModelIndex())
                self.dataChanged.emit(index1, index2)

    def __itemReady(self, oldItem, newItem, error):
        """Called at the end of a concurent file loading, when the loading
        item is ready. AN error is defined if an exception occured when
        loading the newItem .

        :param Hdf5Node oldItem: current displayed item
        :param Hdf5Node newItem: item loaded, or None if error is defined
        :param Exception error: An exception, or None if newItem is defined
        """
        # Take care of None value in case of PySide
        newItem = _unwrapNone(newItem)
        error = _unwrapNone(error)
        row = self.__root.indexOfChild(oldItem)

        rootIndex = qt.QModelIndex()
        self.beginRemoveRows(rootIndex, row, row)
        self.__root.removeChildAtIndex(row)
        self.endRemoveRows()

        if newItem is not None:
            rootIndex = qt.QModelIndex()
            if self.__ownFiles:
                self.__openedFiles.append(newItem.obj)
            self.beginInsertRows(rootIndex, row, row)
            self.__root.insertChild(row, newItem)
            self.endInsertRows()

            if isinstance(oldItem, Hdf5LoadingItem):
                self.sigH5pyObjectLoaded.emit(newItem.obj)
            else:
                self.sigH5pyObjectSynchronized.emit(oldItem.obj, newItem.obj)

        # FIXME the error must be displayed

    def isFileDropEnabled(self):
        return self.__fileDropEnabled

    def setFileDropEnabled(self, enabled):
        self.__fileDropEnabled = enabled

    fileDropEnabled = qt.Property(bool, isFileDropEnabled, setFileDropEnabled)
    """Property to enable/disable file dropping in the model."""

    def isDatasetDragEnabled(self):
        return self.__datasetDragEnabled

    def setDatasetDragEnabled(self, enabled):
        self.__datasetDragEnabled = enabled

    datasetDragEnabled = qt.Property(bool, isDatasetDragEnabled, setDatasetDragEnabled)
    """Property to enable/disable drag of datasets."""

    def isFileMoveEnabled(self):
        return self.__fileMoveEnabled

    def setFileMoveEnabled(self, enabled):
        self.__fileMoveEnabled = enabled

    fileMoveEnabled = qt.Property(bool, isFileMoveEnabled, setFileMoveEnabled)
    """Property to enable/disable drag-and-drop of files to
    change the ordering in the model."""

    def supportedDropActions(self):
        if self.__fileMoveEnabled or self.__fileDropEnabled:
            return qt.Qt.CopyAction | qt.Qt.MoveAction
        else:
            return 0

    def mimeTypes(self):
        types = []
        if self.__fileMoveEnabled or self.__datasetDragEnabled:
            types.append(_utils.Hdf5DatasetMimeData.MIME_TYPE)
        return types

    def mimeData(self, indexes):
        """
        Returns an object that contains serialized items of data corresponding
        to the list of indexes specified.

        :param List[qt.QModelIndex] indexes: List of indexes
        :rtype: qt.QMimeData
        """
        if len(indexes) == 0:
            return None

        indexes = [i for i in indexes if i.column() == 0]
        if len(indexes) > 1:
            raise NotImplementedError("Drag of multi rows is not implemented")
        if len(indexes) == 0:
            raise NotImplementedError("Drag of cell is not implemented")

        node = self.nodeFromIndex(indexes[0])

        if self.__fileMoveEnabled and node.parent is self.__root:
            mimeData = _utils.Hdf5DatasetMimeData(node=node, isRoot=True)
        elif self.__datasetDragEnabled:
            mimeData = _utils.Hdf5DatasetMimeData(node=node)
        else:
            mimeData = None
        return mimeData

    def flags(self, index):
        defaultFlags = qt.QAbstractItemModel.flags(self, index)

        if index.isValid():
            node = self.nodeFromIndex(index)
            if self.__fileMoveEnabled and node.parent is self.__root:
                # that's a root
                return qt.Qt.ItemIsDragEnabled | defaultFlags
            elif self.__datasetDragEnabled:
                return qt.Qt.ItemIsDragEnabled | defaultFlags
            return defaultFlags
        elif self.__fileDropEnabled or self.__fileMoveEnabled:
            return qt.Qt.ItemIsDropEnabled | defaultFlags
        else:
            return defaultFlags

    def dropMimeData(self, mimedata, action, row, column, parentIndex):
        if action == qt.Qt.IgnoreAction:
            return True

        if self.__fileMoveEnabled and mimedata.hasFormat(_utils.Hdf5DatasetMimeData.MIME_TYPE):
            if mimedata.isRoot():
                dragNode = mimedata.node()
                parentNode = self.nodeFromIndex(parentIndex)
                if parentNode is not dragNode.parent:
                    return False

                if row == -1:
                    # append to the parent
                    row = parentNode.childCount()
                else:
                    # insert at row
                    pass

                dragNodeParent = dragNode.parent
                sourceRow = dragNodeParent.indexOfChild(dragNode)
                self.moveRow(parentIndex, sourceRow, parentIndex, row)
                return True

        if self.__fileDropEnabled and mimedata.hasFormat("text/uri-list"):

            parentNode = self.nodeFromIndex(parentIndex)
            if parentNode is not self.__root:
                while(parentNode is not self.__root):
                    node = parentNode
                    parentNode = node.parent
                row = parentNode.indexOfChild(node)
            else:
                if row == -1:
                    row = self.__root.childCount()

            messages = []
            for url in mimedata.urls():
                try:
                    self.insertFileAsync(url.toLocalFile(), row)
                    row += 1
                except IOError as e:
                    messages.append(e.args[0])
            if len(messages) > 0:
                title = "Error occurred when loading files"
                message = "<html>%s:<ul><li>%s</li><ul></html>" % (title, "</li><li>".join(messages))
                qt.QMessageBox.critical(None, title, message)
            return True

        return False

    def headerData(self, section, orientation, role=qt.Qt.DisplayRole):
        if orientation == qt.Qt.Horizontal:
            if role in [qt.Qt.DisplayRole, qt.Qt.EditRole]:
                return self.header_labels[section]
        return None

    def insertNode(self, row, node):
        if row == -1:
            row = self.__root.childCount()
        self.beginInsertRows(qt.QModelIndex(), row, row)
        self.__root.insertChild(row, node)
        self.endInsertRows()

    def moveRow(self, sourceParentIndex, sourceRow, destinationParentIndex, destinationRow):
        if sourceRow == destinationRow or sourceRow == destinationRow - 1:
            # abort move, same place
            return
        return self.moveRows(sourceParentIndex, sourceRow, 1, destinationParentIndex, destinationRow)

    def moveRows(self, sourceParentIndex, sourceRow, count, destinationParentIndex, destinationRow):
        self.beginMoveRows(sourceParentIndex, sourceRow, sourceRow, destinationParentIndex, destinationRow)
        sourceNode = self.nodeFromIndex(sourceParentIndex)
        destinationNode = self.nodeFromIndex(destinationParentIndex)

        if sourceNode is destinationNode and sourceRow < destinationRow:
            item = sourceNode.child(sourceRow)
            destinationNode.insertChild(destinationRow, item)
            sourceNode.removeChildAtIndex(sourceRow)
        else:
            item = sourceNode.removeChildAtIndex(sourceRow)
            destinationNode.insertChild(destinationRow, item)

        self.endMoveRows()
        return True

    def index(self, row, column, parent=qt.QModelIndex()):
        try:
            node = self.nodeFromIndex(parent)
            return self.createIndex(row, column, node.child(row))
        except IndexError:
            return qt.QModelIndex()

    def data(self, index, role=qt.Qt.DisplayRole):
        node = self.nodeFromIndex(index)

        if role == self.H5PY_ITEM_ROLE:
            return node

        if role == self.H5PY_OBJECT_ROLE:
            return node.obj

        if index.column() == self.NAME_COLUMN:
            return node.dataName(role)
        elif index.column() == self.TYPE_COLUMN:
            return node.dataType(role)
        elif index.column() == self.SHAPE_COLUMN:
            return node.dataShape(role)
        elif index.column() == self.VALUE_COLUMN:
            return node.dataValue(role)
        elif index.column() == self.DESCRIPTION_COLUMN:
            return node.dataDescription(role)
        elif index.column() == self.NODE_COLUMN:
            return node.dataNode(role)
        elif index.column() == self.LINK_COLUMN:
            return node.dataLink(role)
        else:
            return None

    def columnCount(self, parent=qt.QModelIndex()):
        return len(self.COLUMN_IDS)

    def hasChildren(self, parent=qt.QModelIndex()):
        node = self.nodeFromIndex(parent)
        if node is None:
            return 0
        return node.hasChildren()

    def rowCount(self, parent=qt.QModelIndex()):
        node = self.nodeFromIndex(parent)
        if node is None:
            return 0
        return node.childCount()

    def parent(self, child):
        if not child.isValid():
            return qt.QModelIndex()

        node = self.nodeFromIndex(child)

        if node is None:
            return qt.QModelIndex()

        parent = node.parent

        if parent is None:
            return qt.QModelIndex()

        grandparent = parent.parent
        if grandparent is None:
            return qt.QModelIndex()
        row = grandparent.indexOfChild(parent)

        assert row != - 1
        return self.createIndex(row, 0, parent)

    def nodeFromIndex(self, index):
        return index.internalPointer() if index.isValid() else self.__root

    def _closeFileIfOwned(self, node):
        """"Close the file if it was loaded from a filename or a
        drag-and-drop"""
        obj = node.obj
        for f in self.__openedFiles:
            if f is obj:
                _logger.debug("Close file %s", obj.filename)
                obj.close()
                self.__openedFiles.remove(obj)

    def synchronizeIndex(self, index):
        """
        Synchronize a file a given its index.

        Basically close it and load it again.

        :param qt.QModelIndex index: Index of the item to update
        """
        node = self.nodeFromIndex(index)
        if node.parent is not self.__root:
            return

        filename = node.obj.filename
        self.insertFileAsync(filename, index.row(), synchronizingNode=node)

    def h5pyObjectRow(self, h5pyObject):
        for row in range(self.__root.childCount()):
            item = self.__root.child(row)
            if item.obj == h5pyObject:
                return row
        return -1

    def synchronizeH5pyObject(self, h5pyObject):
        """
        Synchronize a h5py object in all the tree.

        Basically close it and load it again.

        :param h5py.File h5pyObject: A :class:`h5py.File` object.
        """
        index = 0
        while index < self.__root.childCount():
            item = self.__root.child(index)
            if item.obj == h5pyObject:
                qindex = self.index(index, 0, qt.QModelIndex())
                self.synchronizeIndex(qindex)
            index += 1

    def removeIndex(self, index):
        """
        Remove an item from the model using its index.

        :param qt.QModelIndex index: Index of the item to remove
        """
        node = self.nodeFromIndex(index)
        if node.parent != self.__root:
            return
        self._closeFileIfOwned(node)
        self.beginRemoveRows(qt.QModelIndex(), index.row(), index.row())
        self.__root.removeChildAtIndex(index.row())
        self.endRemoveRows()
        self.sigH5pyObjectRemoved.emit(node.obj)

    def removeH5pyObject(self, h5pyObject):
        """
        Remove an item from the model using the holding h5py object.
        It can remove more than one item.

        :param h5py.File h5pyObject: A :class:`h5py.File` object.
        """
        index = 0
        while index < self.__root.childCount():
            item = self.__root.child(index)
            if item.obj == h5pyObject:
                qindex = self.index(index, 0, qt.QModelIndex())
                self.removeIndex(qindex)
            else:
                index += 1

    def insertH5pyObject(self, h5pyObject, text=None, row=-1):
        """Append an HDF5 object from h5py to the tree.

        :param h5pyObject: File handle/descriptor for a :class:`h5py.File`
            or any other class of h5py file structure.
        """
        if text is None:
            text = _createRootLabel(h5pyObject)
        if row == -1:
            row = self.__root.childCount()
        self.insertNode(row, Hdf5Item(text=text, obj=h5pyObject, parent=self.__root))

    def hasPendingOperations(self):
        return len(self.__runnerSet) > 0

    def insertFileAsync(self, filename, row=-1, synchronizingNode=None):
        if not os.path.isfile(filename):
            raise IOError("Filename '%s' must be a file path" % filename)

        # create temporary item
        if synchronizingNode is None:
            text = os.path.basename(filename)
            item = Hdf5LoadingItem(text=text, parent=self.__root, animatedIcon=self.__animatedIcon)
            self.insertNode(row, item)
        else:
            item = synchronizingNode

        # start loading the real one
        runnable = LoadingItemRunnable(filename, item)
        runnable.itemReady.connect(self.__itemReady)
        runnable.runnerFinished.connect(self.__releaseRunner)
        self.__runnerSet.add(runnable)
        qt.silxGlobalThreadPool().start(runnable)

    def __releaseRunner(self, runner):
        self.__runnerSet.remove(runner)

    def insertFile(self, filename, row=-1):
        """Load a HDF5 file into the data model.

        :param filename: file path.
        """
        try:
            h5file = silx_io.open(filename)
            if self.__ownFiles:
                self.__openedFiles.append(h5file)
            self.sigH5pyObjectLoaded.emit(h5file)
            self.insertH5pyObject(h5file, row=row)
        except IOError:
            _logger.debug("File '%s' can't be read.", filename, exc_info=True)
            raise

    def clear(self):
        """Remove all the content of the model"""
        for _ in range(self.rowCount()):
            qindex = self.index(0, 0, qt.QModelIndex())
            self.removeIndex(qindex)

    def appendFile(self, filename):
        self.insertFile(filename, -1)

    def indexFromH5Object(self, h5Object):
        """Returns a model index from an h5py-like object.

        :param object h5Object: An h5py-like object
        :rtype: qt.QModelIndex
        """
        if h5Object is None:
            return qt.QModelIndex()

        filename = h5Object.file.filename

        # Seach for the right roots
        rootIndices = []
        for index in range(self.rowCount(qt.QModelIndex())):
            index = self.index(index, 0, qt.QModelIndex())
            obj = self.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE)
            if obj.file.filename == filename:
                # We can have many roots with different subtree of the same
                # root
                rootIndices.append(index)

        if len(rootIndices) == 0:
            # No root found
            return qt.QModelIndex()

        path = h5Object.name + "/"
        path = path.replace("//", "/")

        # Search for the right node
        found = False
        foundIndices = []
        for _ in range(1000 * len(rootIndices)):
            # Avoid too much iterations, in case of recurssive links
            if len(foundIndices) == 0:
                if len(rootIndices) == 0:
                    # Nothing found
                    break
                # Start fron a new root
                foundIndices.append(rootIndices.pop(0))

                obj = self.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE)
                p = obj.name + "/"
                p = p.replace("//", "/")
                if path == p:
                    found = True
                    break

            parentIndex = foundIndices[-1]
            for index in range(self.rowCount(parentIndex)):
                index = self.index(index, 0, parentIndex)
                obj = self.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE)

                p = obj.name + "/"
                p = p.replace("//", "/")
                if path == p:
                    foundIndices.append(index)
                    found = True
                    break
                elif path.startswith(p):
                    foundIndices.append(index)
                    break
            else:
                # Nothing found, start again with another root
                foundIndices = []

            if found:
                break

        if found:
            return foundIndices[-1]
        return qt.QModelIndex()
