# -*- coding: utf-8 -*-
# noinspection PyPep8Naming
"""
***************************************************************************
    qps/testing.py

    A module to support unittesting in context of GDAL and QGIS
    ---------------------
    Beginning            : 2019-01-11
    Copyright            : (C) 2020 by Benjamin Jakimow
    Email                : benjamin.jakimow@geo.hu-berlin.de
***************************************************************************
    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 software. If not, see <https://www.gnu.org/licenses/>.
***************************************************************************
"""
import gc
import hashlib
import inspect
import itertools
import os
import random
import shutil
import sys
import traceback
import uuid
import warnings
from pathlib import Path
from time import sleep
from typing import List, Optional, Set, Tuple, Union
from unittest import mock

import numpy as np
from osgeo import gdal, gdal_array, ogr, osr
from osgeo.gdal import UseExceptions

import qgis.utils
from qgis.PyQt import sip
from qgis.PyQt.QtCore import pyqtSignal, QMimeData, QObject, QPoint, QPointF, QSize, Qt
from qgis.PyQt.QtGui import QDropEvent, QIcon, QImage, QStandardItemModel
from qgis.PyQt.QtWidgets import QAction, QApplication, QDialogButtonBox, QDockWidget, QFrame, QHBoxLayout, QListWidget, \
    QMainWindow, QMenu, QSplitter, QStackedWidget, QToolBar, QTreeView, QVBoxLayout, QWidget
from qgis.core import edit, Qgis, QgsApplication, QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsFeature, \
    QgsFeatureStore, QgsField, QgsFields, QgsGeometry, QgsLayerTree, QgsLayerTreeLayer, QgsLayerTreeModel, \
    QgsLayerTreeRegistryBridge, QgsMapLayer, QgsProcessingAlgorithm, QgsProcessingContext, QgsProcessingFeedback, \
    QgsProcessingModelAlgorithm, QgsProcessingParameterNumber, QgsProcessingParameterRasterDestination, \
    QgsProcessingParameterRasterLayer, QgsProcessingProvider, QgsProcessingRegistry, QgsProject, QgsProviderRegistry, \
    QgsPythonRunner, QgsRasterLayer, QgsTemporalController, QgsVectorLayer, QgsVectorLayerUtils, QgsWkbTypes
from qgis.gui import QgisInterface, QgsAbstractMapToolHandler, QgsBrowserGuiModel, QgsFilterLineEdit, QgsGui, \
    QgsLayerTreeMapCanvasBridge, QgsLayerTreeView, QgsMapCanvas, QgsMapLayerConfigWidgetFactory, QgsMapTool, \
    QgsMessageBar, QgsOptionsDialogBase, QgsOptionsPageWidget, QgsOptionsWidgetFactory, QgsPluginManagerInterface
from qgis.testing import QgisTestCase
from .qgisenums import QGIS_WKBTYPE
from .qgsrasterlayerproperties import QgsRasterLayerSpectralProperties
from .resources import initResourceFile
from .unitmodel import UnitLookup
from .utils import findUpwardPath, loadUi, px2geo, SpatialExtent, SpatialPoint

TEST_VECTOR_GEOJSON = Path(__file__).parent / 'testvectordata.4326.geojson'

_QGIS_MOCKUP = None
_PYTHON_RUNNER = None


def start_app(cleanup: bool = True,
              init_processing: bool = True,
              init_python_runner: bool = True,
              init_editor_widgets: bool = True,
              init_iface: bool = True,
              resources: Optional[List[Union[str, Path]]] = None) -> QgsApplication:
    app = qgis.testing.start_app(cleanup)

    app.setStyle('Fusion')

    if 'delimitedtext' not in QgsProviderRegistry.instance().providerList():
        warnings.warn('QgsProviderRegistry misses "delimitedtext" provider!\n'
                      'Check your QGIS test environment. Ensure that QGIS_PREFIX_PATH is defined.\n'
                      f'Available providers: {QgsProviderRegistry.instance().providerList()}')

    from qgis.core import QgsCoordinateReferenceSystem
    assert QgsCoordinateReferenceSystem('EPSG:4326').isValid()

    UseExceptions()
    providers = QgsApplication.processingRegistry().providers()
    # global _PYTHON_RUNNER
    # global _QGIS_MOCKUP

    if init_iface:
        get_iface()

    if init_processing and len(providers) == 0:
        from processing.core.Processing import Processing

        Processing.initialize()

    if init_python_runner and not QgsPythonRunner.isValid():
        _PYTHON_RUNNER = QgsPythonRunnerMockup()
        QgsPythonRunner.setInstance(_PYTHON_RUNNER)

    # init standard EditorWidgets
    if init_editor_widgets and len(QgsGui.editorWidgetRegistry().factories()) == 0:
        QgsGui.editorWidgetRegistry().initEditors()

    if isinstance(resources, str):
        resources = [resources]

    if isinstance(resources, list):
        for path in resources:
            initResourceFile(path)

    crs1 = QgsCoordinateReferenceSystem('EPSG:4326')
    assert crs1.isValid(), 'Failed to initialize QGIS SRS database'
    crs2 = QgsCoordinateReferenceSystem.fromWkt(crs1.toWkt())
    assert crs2.isValid(), ('Failed to initialize QGIS SRS database. '
                            'Is a QgsCoordinateSystem instance created before a '
                            'QgsApplication.instance() is created, e.g. using `qgis.testing.start_app()`.')

    return app


css = """
QGroupBox{ font-weight: 600; }
QListWidget#mOptionsListWidget { background-color: rgba(69, 69, 69, 0); outline: 0;}
QFrame#mOptionsListFrame { background-color: rgba(69, 69, 69, 220);}
QListWidget#mOptionsListWidget::item { color: white; padding: 3px;}
QListWidget#mOptionsListWidget::item::selected { color: palette(window-text); background-color:palette(window); padding-right: 0px;}
QTreeView#mOptionsTreeView { background-color: rgba(69, 69, 69, 0); outline: 0;}
QFrame#mOptionsListFrame { background-color: rgba(69, 69, 69, 220);}
QTreeView#mOptionsTreeView::item { color: white; padding: 3px;}
QTreeView#mOptionsTreeView::item::selected, QTreeView#mOptionsTreeView::branch::selected { color: palette(window-text); background-color:palette(window); padding-right: 0px;}
QTableView { selection-background-color: #0078d7; selection-color: #ffffff;}
QgsPropertyOverrideButton { background: none; border: 1px solid rgba(0, 0, 0, 0%); }
QgsPropertyOverrideButton:focus { border: 1px solid palette(highlight); }'
"""


class QgisMockup(QgisInterface):
    """
    A "fake" QGIS Desktop instance that should provide all the interfaces a
    plugin developer might need (and nothing more)
    """

    def __init__(self, *args):
        super(QgisMockup, self).__init__()

        self.mActionSaveProject = QAction('Save Project')
        self.mActionSaveProject.triggered.connect(self._onSaveProject)
        self.mMapLayerPanelFactories: List[QgsMapLayerConfigWidgetFactory] = []

        self.mOptionsWidgetFactories: List[QgsOptionsWidgetFactory] = []

        self.mTemporalController = QgsTemporalController()
        self.mCanvas = QgsMapCanvas()
        self.mCanvas.setTemporalController(self.mTemporalController)

        extent = SpatialExtent.world()
        self.mCanvas.setDestinationCrs(extent.crs())
        self.mCanvas.setExtent(extent)
        self.mCanvas.setCanvasColor(Qt.black)
        self.mLayerTreeView = QgsLayerTreeView()
        self.mLayerTreeView.currentLayerChanged.connect(self.activateDeactivateLayerRelatedActions)
        self.mMapToolHandler: List[QgsAbstractMapToolHandler] = []

        self.mRootNode = QgsLayerTree()
        self.mLayerTreeRegistryBridge = QgsLayerTreeRegistryBridge(self.mRootNode, QgsProject.instance())
        self.mLayerTreeModel = QgsLayerTreeModel(self.mRootNode)
        self.mLayerTreeModel.setFlag(QgsLayerTreeModel.Flag.AllowNodeReorder, True)
        self.mLayerTreeModel.setFlag(QgsLayerTreeModel.Flag.AllowNodeRename, True)
        self.mLayerTreeModel.setFlag(QgsLayerTreeModel.Flag.AllowNodeChangeVisibility, True)

        p = QgsProject.instance()
        p.legendLayersAdded.connect(self._onAddLayers)
        p.layersWillBeRemoved.connect(self._onRemoveLayers)

        self.mLayerTreeView.setModel(self.mLayerTreeModel)
        self.mLayerTreeMapCanvasBridge = QgsLayerTreeMapCanvasBridge(self.mRootNode, self.mCanvas)
        self.mLayerTreeMapCanvasBridge.setAutoSetupOnFirstLayer(True)
        # QgsProject.instance().legendLayersAdded.connect(self.addLegendLayers)
        self.mPluginManager = QgsPluginManagerMockup()

        self.mBrowserGuiModel = QgsBrowserGuiModel()
        self.ui = QMainWindow()

        self.mViewMenu = self.ui.menuBar().addMenu('View')
        self.mVectorMenu = self.ui.menuBar().addMenu('Vector')
        self.mRasterMenu = self.ui.menuBar().addMenu('Raster')
        self.mWindowMenu = self.ui.menuBar().addMenu('Window')

        self.mSelectionToolBar = QToolBar()
        self.mMessageBar = QgsMessageBar()
        mainFrame = QFrame()
        self.ui.setCentralWidget(mainFrame)
        self.ui.setWindowTitle('QGIS Mockup')
        hl = QHBoxLayout()
        hl.addWidget(self.mLayerTreeView)
        hl.addWidget(self.mCanvas)
        v = QVBoxLayout()
        v.addWidget(self.mMessageBar)
        v.addLayout(hl)
        mainFrame.setLayout(v)
        self.ui.setCentralWidget(mainFrame)
        self.lyrs = []
        self.createActions()
        self.mClipBoard = QgsClipboardMockup()
        self.ui.setStyleSheet(css)
        # mock other functions
        excluded = QObject.__dict__.keys()
        self._mock = mock.Mock(spec=QgisInterface)
        for n in self._mock._mock_methods:
            assert isinstance(n, str)
            if not n.startswith('_') and n not in excluded:
                try:
                    inspect.getfullargspec(getattr(self, n))
                except Exception:
                    setattr(self, n, getattr(self._mock, n))

    def _onAddLayers(self, layers: List[QgsMapLayer]):
        # from qgis.utils import iface
        # grp = iface.layerTreeView().layerTreeModel().rootGroup()
        grp = self.mRootNode
        existing = grp.findLayerIds()
        for lyr in layers:
            if lyr.id() not in existing:
                grp.addLayer(lyr)
        s = ""

    def _onRemoveLayers(self, layerIDs: List[str]):
        to_remove: List[QgsLayerTreeLayer] = []
        for lyr in self.mRootNode.findLayers():
            lyr: QgsLayerTreeLayer
            if lyr in layerIDs or lyr.layerId() in layerIDs:
                to_remove.append(lyr)
        for lyr in reversed(to_remove):
            lyr.parent().removeChildNode(lyr)

    def _onSaveProject(self):

        p = QgsProject.instance()
        p.write()

    def actionSaveProject(self) -> QAction:
        return self.mActionSaveProject

    def activeLayer(self) -> QgsMapLayer:
        return self.mLayerTreeView.currentLayer()

    def registerMapToolHandler(self, handler: QgsAbstractMapToolHandler) -> None:

        assert isinstance(handler, QgsAbstractMapToolHandler)
        assert isinstance(handler.action(), QAction) and \
               isinstance(handler.mapTool(), QgsMapTool), 'Map tool handler is not properly constructed'

        self.mMapToolHandler.append(handler)
        handler.action().setCheckable(True)
        handler.mapTool().setAction(handler.action())
        handler.action().triggered.connect(self.switchToMapToolViaHandler)
        context = QgsAbstractMapToolHandler.Context()
        handler.action().setEnabled(handler.isCompatibleWithLayer(self.activeLayer(), context))

    def unregisterMapToolHandler(self, handler: QgsAbstractMapToolHandler) -> None:
        assert isinstance(handler, QgsAbstractMapToolHandler)
        if handler in self.mMapToolHandler:
            self.mMapToolHandler.remove(handler)
            if isinstance(handler.action(), QAction):
                handler.action().triggered.disconnect(self.switchToMapToolViaHandler)

    def switchToMapToolViaHandler(self):
        action: QAction = self.sender()
        if not isinstance(action, QAction):
            return

        for h in self.mMapToolHandler:
            h: QgsAbstractMapToolHandler
            if h.action() == action and self.mapCanvas().mapTool() != h.mapTool():
                h.setLayerForTool(self.activeLayer())
                self.mapCanvas().setMapTool(h.mapTool())
                return

    def activateDeactivateLayerRelatedActions(self, layer: QgsMapLayer):

        context = QgsAbstractMapToolHandler.Context()
        for h in self.mMapToolHandler:
            h: QgsAbstractMapToolHandler
            h.action().setEnabled(h.isCompatibleWithLayer(layer, context))
            if h.mapTool() == self.mapCanvas().mapTool():
                if not h.action().isEnabled():
                    self.mapCanvas().unsetMapTool(h.mapTool())
                    # self.mMapToolPan.trigger()
                else:
                    h.setLayerForTool(layer)

    def registerMapLayerConfigWidgetFactory(self, factory: QgsMapLayerConfigWidgetFactory):
        assert isinstance(factory, QgsMapLayerConfigWidgetFactory)

        self.mMapLayerPanelFactories.append(factory)

    def unregisterMapLayerConfigWidgetFactory(self, factory: QgsMapLayerConfigWidgetFactory):
        assert isinstance(factory, QgsMapLayerConfigWidgetFactory)
        self.mMapLayerPanelFactories = [f for f in self.mMapLayerPanelFactories if f.title() != factory.title()]

    def addLegendLayers(self, mapLayers: List[QgsMapLayer]):
        for lyr in mapLayers:
            self.mRootNode.addLayer(lyr)

    def pluginManagerInterface(self) -> QgsPluginManagerInterface:
        return self.mPluginManager

    def setActiveLayer(self, mapLayer: QgsMapLayer):
        self.mLayerTreeView.setCurrentLayer(mapLayer)

    def selectionToolBar(self) -> QToolBar:
        return self.mSelectionToolBar

    def cutSelectionToClipboard(self, mapLayer: QgsMapLayer):
        if isinstance(mapLayer, QgsVectorLayer):
            self.mClipBoard.replaceWithCopyOf(mapLayer)
            mapLayer.beginEditCommand('Features cut')
            mapLayer.deleteSelectedFeatures()
            mapLayer.endEditCommand()

    def browserModel(self) -> QgsBrowserGuiModel:
        return self.mBrowserGuiModel

    def copySelectionToClipboard(self, mapLayer: QgsMapLayer):
        if isinstance(mapLayer, QgsVectorLayer):
            self.mClipBoard.replaceWithCopyOf(mapLayer)

    def pasteFromClipboard(self, pasteVectorLayer: QgsMapLayer):
        if not isinstance(pasteVectorLayer, QgsVectorLayer):
            return

        # todo: implement

        features = self.mClipBoard.transformedCopyOf(pasteVectorLayer.crs(), pasteVectorLayer.fields())
        compatibleFeatures = []
        for f in features:
            compatibleFeatures.extend(QgsVectorLayerUtils.makeFeatureCompatible(f, pasteVectorLayer))
        pasteVectorLayer.beginEditCommand('Features pasted')
        pasteVectorLayer.addFeatures(compatibleFeatures)
        pasteVectorLayer.endEditCommand()

        return

    def iconSize(self, dockedToolbar=False):
        return QSize(30, 30)

    def mainWindow(self):
        return self.ui

    def addToolBarIcon(self, action):
        assert isinstance(action, QAction)

    def removeToolBarIcon(self, action):
        assert isinstance(action, QAction)

    def addToolBar(self, name: str) -> QToolBar:
        return self.mainWindow().addToolBar(name)

    def addDockWidget(self, area: Qt.DockWidgetArea, dockwidget: QDockWidget) -> None:

        return self.mainWindow().addDockWidget(area, dockwidget)

    def addVectorLayer(self, path, basename=None, providerkey: str = 'ogr'):
        if basename is None:
            basename = os.path.basename(path)

        lyr = QgsVectorLayer(path, basename, providerkey)
        assert lyr.isValid()
        QgsProject.instance().addMapLayer(lyr, True)
        self.mRootNode.addLayer(lyr)
        self.mLayerTreeMapCanvasBridge.setCanvasLayers()

    def legendInterface(self):
        return None

    def layerTreeCanvasBridge(self) -> QgsLayerTreeMapCanvasBridge:
        return self.mLayerTreeMapCanvasBridge

    def layerTreeView(self) -> QgsLayerTreeView:
        return self.mLayerTreeView

    def addRasterLayer(self, path, baseName: str = '') -> QgsRasterLayer:
        lyr = QgsRasterLayer(path, os.path.basename(path))
        self.lyrs.append(lyr)
        QgsProject.instance().addMapLayer(lyr, True)
        self.mRootNode.addLayer(lyr)
        return lyr

    def createActions(self):
        m = self.ui.menuBar().addAction('Add Vector')
        m = self.ui.menuBar().addAction('Add Raster')

    def mapCanvas(self) -> QgsMapCanvas:
        return self.mCanvas

    def mapCanvases(self) -> List[QgsMapCanvas]:
        return [self.mCanvas]

    def mapNavToolToolBar(self) -> QToolBar:
        return self.mMapNavToolBar

    def messageBar(self, *args, **kwargs) -> QgsMessageBar:
        return self.mMessageBar

    def registerOptionsWidgetFactory(self, factory: QgsOptionsWidgetFactory):
        assert isinstance(factory, QgsOptionsWidgetFactory)
        self.mOptionsWidgetFactories.append(factory)

    def unregisterOptionsWidgetFactory(self, factory):
        assert factory in self.mOptionsWidgetFactories
        self.mOptionsWidgetFactories.remove(factory)

    def showOptionsDialog(self,
                          parent: Optional[QWidget] = None,
                          currentPage: str = ''):

        optionsDialog = QgsOptionsMockup('Options', parent)
        for f in self.mOptionsWidgetFactories:
            page = f.createWidget(optionsDialog)
            if not isinstance(page, QWidget):
                continue
            optionsDialog.addPage(f.title(), f.title(), f.icon(), page, f.path(), f.key())
            optionsDialog.setCurrentPage(currentPage)
        optionsDialog.show()
        return optionsDialog

    def rasterMenu(self) -> QMenu:
        return self.mRasterMenu

    def vectorMenu(self) -> QMenu:
        return self.mVectorMenu

    def viewMenu(self) -> QMenu:
        return self.mViewMenu

    def windowMenu(self) -> QMenu:
        return self.mWindowMenu

    def zoomFull(self, *args, **kwargs):
        self.mCanvas.zoomToFullExtent()


def get_iface() -> QgisInterface:
    if not isinstance(qgis.utils.iface, QgisInterface):
        iface = QgisMockup()

        qgis.utils.initInterface(sip.unwrapinstance(iface))
        # we use our own QgisInterface, so replace it where it might have been imported
        # like `iface = qgis.utils.iface`
        _set_iface(iface)

    return qgis.utils.iface


def _set_iface(ifaceMock: QgisInterface):
    """
    Replaces the iface variable in other plugins, i.e. the  QGIS processing plugin
    :param ifaceMock: QgisInterface
    """

    # enhance this list with further positions where iface needs to be replaced or remains None otherwise
    import processing.ProcessingPlugin
    import processing.tools.general
    modules = [processing.ProcessingPlugin,
               processing.tools.general,
               processing]

    for m in modules:
        m.iface = ifaceMock

    s = ""


# APP = None


class TestCase(QgisTestCase):
    gdal.UseExceptions()

    @staticmethod
    def check_empty_layerstore(name: str):
        error = None
        if len(QgsProject.instance().mapLayers()) > 0:
            error = f'QgsProject layers store is not empty:\n{name}:'
            for lyr in QgsProject.instance().mapLayers().values():
                error += f'\n\t{lyr.id()}: "{lyr.name()}"'
            raise AssertionError(error)

    def setUp(self):
        self.check_empty_layerstore(f'{self.__class__.__name__}::{self._testMethodName}')

    def tearDown(self):
        self.check_empty_layerstore(f'{self.__class__.__name__}::{self._testMethodName}')
        # call gc and processEvents to fail fast
        gc.collect()
        app = QApplication.instance()
        if isinstance(app, QApplication):
            app.processEvents()
        gc.collect()

    @classmethod
    def setUpClass(cls):
        cls.check_empty_layerstore(str(cls.__class__))

    @classmethod
    def tearDownClass(cls):
        cls.check_empty_layerstore(str(cls.__class__))

    @classmethod
    def showGui(cls, widgets: Union[QWidget, List[QWidget]] = None) -> bool:
        """
        Call this to show GUI(s) in case we do not run within a CI system
        """

        if widgets is None:
            widgets = []
        if not isinstance(widgets, list):
            widgets = [widgets]

        keepOpen = False

        for w in widgets:
            if isinstance(w, QWidget):
                w.show()
                keepOpen = True
            elif callable(w):
                w()

        if cls.runsInCI():
            return False

        app = QApplication.instance()
        if isinstance(app, QApplication) and keepOpen:
            app.exec_()

        return True

    @staticmethod
    def runsInCI() -> True:
        """
        Returns True if this the environment is supposed to run in a CI environment
        and should not open blocking dialogs
        """
        r = str(os.environ.get('CI', '')).lower() not in ['', 'none', 'false', '0']

        if Qgis.versionInt() >= 33400:
            from qgis.testing import QgisTestCase
            r = r or QgisTestCase.is_ci_run()
        return r

    @classmethod
    def createProcessingContextFeedback(cls) -> Tuple[QgsProcessingContext, QgsProcessingFeedback]:
        """
        Create a QgsProcessingContext with connected QgsProcessingFeedback
        """

        def onProgress(progress: float):
            sys.stdout.write('\r{:0.2f} %'.format(progress))
            sys.stdout.flush()

            if progress == 100:
                print('')

        feedback = QgsProcessingFeedback(True)
        feedback.progressChanged.connect(onProgress)

        context = QgsProcessingContext()
        context.setFeedback(feedback)

        return context, feedback

    @classmethod
    def createProcessingFeedback(cls) -> QgsProcessingFeedback:
        """
        Creates a QgsProcessingFeedback.
        :return:
        """
        feedback = QgsProcessingFeedback()

        return feedback

    def createImageCopy(self, path, overwrite_existing: bool = True) -> str:
        """
        Creates a save image copy to manipulate metadata
        :param path: str, path to valid raster image
        :type path:
        :return:
        :rtype:
        """
        path = Path(path)
        assert path.is_file()

        ds: gdal.Dataset = gdal.Open(path.as_posix())
        assert isinstance(ds, gdal.Dataset)
        drv: gdal.Driver = ds.GetDriver()

        testdir = self.createTestOutputDirectory() / 'images'
        os.makedirs(testdir, exist_ok=True)
        bn, ext = os.path.splitext(os.path.basename(path))

        newpath = testdir / f'{bn}{ext}'
        i = 0
        if newpath.is_file():
            deleted = False
            if overwrite_existing:
                ds2: gdal.Dataset = gdal.Open(newpath.as_posix())
                path2 = ds2.GetFileList()[0]
                drv2: gdal.Driver = ds2.GetDriver()
                del ds2
                n_tries = 5
                while not deleted and n_tries > 5:
                    try:
                        drv2.Delete(path2)
                        deleted = True
                    except RuntimeError as ex:
                        sleep(1)
                        n_tries -= 1

            if not deleted:
                while newpath.is_file():
                    i += 1
                    newpath = testdir / f'{bn}{i}{ext}'

        drv.CopyFiles(newpath.as_posix(), path.as_posix())

        return newpath.as_posix()

    def createTestOutputDirectory(self,
                                  root: Optional[str] = 'test-outputs',
                                  subdir: Optional[Union[str, Path]] = None,
                                  cleanup: bool = False,
                                  max_length: int = 200) -> Path:
        """
        Returns the path to a test output directory.
        Defaults to: <repo>/<root>/<test module>/<test class>/<test method>

        :param max_length: the maximum length of the path. If the path exceeds this limit, it will be hashed
                           and the has used for a directory <DIR_REPO>/<root>/<hash>.
        :param root: str, name of the folder for test output below the repository root. Defaults to <repo>/test-outputs.
        :param subdir: str or Path with subdirectories to append.
        :param cleanup: bool, set True to delete existing test ouptuts.
        :return: Path
        """
        """
        :return:
        """

        DIR_REPO = findUpwardPath(__file__, '.git').parent

        folders = []
        if hasattr(self, '__class__'):
            folders.append(self.__class__.__module__)
            folders.append(self.__class__.__name__)
        else:
            folders.append(self.__name__)

        if hasattr(self, '_testMethodName'):
            folders.append(self._testMethodName)

        if subdir:
            subdir = Path(subdir)
            folders.append(subdir)

        p = Path(DIR_REPO) / root / Path(*folders)

        if len(p.as_posix()) > max_length:
            p2 = Path(DIR_REPO) / root / hashlib.md5(p.as_posix().encode()).hexdigest()
            info = [f'Path exceeds max_length ({max_length}: {p}).',
                    f'Use MD5 hash instead: {p2}']
            warnings.warn('\n'.join(info), stacklevel=2)
            p = p2

        if cleanup and p.exists() and p.is_dir():
            shutil.rmtree(p)
        os.makedirs(p, exist_ok=True)
        return p

    def createTestCaseDirectory(self,
                                basename: str = None,
                                testclass: bool = True,
                                testmethod: bool = True
                                ):
        warnings.warn(DeprecationWarning('Use .createTestOutputDirectory'))
        return self.createTestOutputDirectory(basename)

    @classmethod
    def assertImagesEqual(cls, image1: QImage, image2: QImage):
        if image1.size() != image2.size():
            return False
        if image1.format() != image2.format():
            return False

        for x in range(image1.width()):
            for y in range(image1.height()):
                if image1.pixel(x, y) != image2.pixel(x, y):
                    return False
        return True

    def tempDir(self, subdir: str = None, cleanup: bool = False) -> Path:
        """
        Returns the <enmapbox-repository/test-outputs/test name> directory
        :param subdir:
        :param cleanup:
        :return: Path
        """
        warnings.warn(DeprecationWarning('Use createTestOutputDirectory() instead.'))
        return self.createTestOutputDirectory(subdir, cleanup)

    def assertLengthEqual(self, list1, list2):
        self.assertEqual(len(list1), len(list2))

    def assertWavelengthsEqual(self, wl1, u1, wl2, u2, precision: int = 5):
        """
        Asserts that the wavelengths in wl1 equals those in wl2
        :param wl1: number|list
        :param u1: str|list physical unit(s) of values in wl1
        :param wl2: number|list
        :param u2: str|list physical unit(s) of values in wl2
        :param precision: decimal precission used to compare numeric values
        :return:
        """

        if not isinstance(wl1, list):
            wl1 = [wl1]
        if not isinstance(wl2, list):
            wl2 = [wl2]

        self.assertLengthEqual(wl1, wl2)

        if not isinstance(u1, list):
            u1 = [u1 for _ in range(len(wl1))]
        else:
            self.assertLengthEqual(wl1, u1)
        if not isinstance(u2, list):
            u2 = [u2 for _ in range(len(wl1))]
        else:
            self.assertLengthEqual(wl2, u2)

        wl2 = [None if w is None else UnitLookup.convertLengthUnit(w, _u2, _u1) for w, _u2, _u1 in zip(wl2, u2, u1)]

        for w1, w2 in zip(wl1, wl2):
            self.assertAlmostEqual(w1, w2, precision)

    def assertIconsEqual(self, icon1: QIcon, icon2: QIcon):
        self.assertIsInstance(icon1, QIcon)
        self.assertIsInstance(icon2, QIcon)
        size = QSize(256, 256)
        self.assertEqual(icon1.actualSize(size), icon2.actualSize(size))

        img1 = QImage(icon1.pixmap(size))
        img2 = QImage(icon2.pixmap(size))
        self.assertImagesEqual(img1, img2)

    @classmethod
    def _readVSIMemFiles(cls) -> Set[str]:

        r = gdal.ReadDirRecursive('/vsimem/')
        if r is None:
            return set([])
        return set(r)


class ExampleAlgorithmProvider(QgsProcessingProvider):
    NAME = 'TestAlgorithmProvider'

    _INSTANCE = None

    @staticmethod
    def instance():
        """
        Returns the instance of this provider that is added to the
        QgsProviderRegistry.instance().
        If none exists, it creates a new one.
        """
        reg: QgsProcessingRegistry = QgsApplication.instance().processingRegistry()
        if not reg.providerById(ExampleAlgorithmProvider.NAME.lower()):
            provider = ExampleAlgorithmProvider()
            ExampleAlgorithmProvider._INSTANCE = provider
            assert isinstance(ExampleAlgorithmProvider._INSTANCE, ExampleAlgorithmProvider)
            assert reg.addProvider(ExampleAlgorithmProvider._INSTANCE)
        assert reg.providerById(ExampleAlgorithmProvider.NAME.lower())
        return ExampleAlgorithmProvider._INSTANCE

    def __init__(self, *args, **kwds):
        super().__init__(*args, **kwds)
        self._algs = []

    def load(self):
        self.refreshAlgorithms()
        return True

    def name(self):
        return self.NAME

    def longName(self):
        return self.NAME

    def id(self):
        return self.NAME.lower()

    def helpId(self):
        return self.id()

    def icon(self):
        return QIcon(r':/qps/ui/icons/profile_expression.svg')

    def svgIconPath(self):
        return r':/qps/ui/icons/profile_expression.svg'

    def loadAlgorithms(self):
        for a in self._algs:
            self.addAlgorithm(a.createInstance())

    def supportsNonFileBasedOutput(self) -> True:
        return True

    def addAlgorithm(self, algorithm: QgsProcessingAlgorithm, **kwargs) -> bool:
        result = super().addAlgorithm(algorithm)
        if result:
            # keep reference
            self._algs.append(algorithm)
        return result


class SpectralProfileDataIterator(object):

    def __init__(self,
                 n_bands_per_field: Union[int, List[int]],
                 target_crs=None):

        if not isinstance(n_bands_per_field, list):
            n_bands_per_field = [n_bands_per_field]

        if not isinstance(target_crs, QgsCoordinateReferenceSystem):
            target_crs = QgsCoordinateReferenceSystem('EPSG:4326')
        self.target_crs = target_crs
        coreData = TestObjects.coreData()
        self.coredata = coreData['data']
        self.wl = coreData['wl']
        self.wlu = coreData['wlu']
        self.gt = coreData['gt']
        self.wkt = coreData['wkt']

        px1 = px2geo(QPoint(0, 0), self.gt, pxCenter=False)
        px2 = px2geo(QPoint(1, 1), self.gt, pxCenter=False)

        self.dx = abs(px2.x() - px1.x())
        self.dy = abs(px2.y() - px1.y())

        self.source_crs = QgsCoordinateReferenceSystem(self.wkt)
        self.cnb, self.cnl, self.cns = self.coredata.shape
        n_bands_per_field = [self.cnb if nb == -1 else nb for nb in n_bands_per_field]
        for nb in n_bands_per_field:
            assert nb is None or 0 < nb
            # assert 0 < nb <= self.cnb, f'Max. number of bands can be {self.cnb}'
        self.band_indices: List[Optional[np.ndarray]] = []
        for nb in n_bands_per_field:
            if nb is None:
                self.band_indices.append(None)
            else:
                if nb <= self.cnb:
                    idx = np.linspace(0, self.cnb - 1, num=nb, dtype=np.int16)
                else:
                    # get nb bands positions along wavelength
                    idx = np.linspace(self.wl[0], self.wl[-1], num=nb, dtype=float)

                self.band_indices.append(idx)

    def sourceCrs(self) -> QgsCoordinateReferenceSystem:
        return self.source_crs

    def targetCrs(self) -> QgsCoordinateReferenceSystem:
        return self.target_crs

    def __iter__(self):
        return self

    def __next__(self):

        x = random.randint(0, self.coredata.shape[2] - 1)
        y = random.randint(0, self.coredata.shape[1] - 1)

        px = QPoint(x, y)
        # from .utils import px2geo
        pt = px2geo(px, self.gt, pxCenter=False)
        pt = SpatialPoint(self.sourceCrs(),
                          pt.x() + self.dx * random.uniform(0, 1),
                          pt.y() - self.dy * random.uniform(0, 1))
        pt = pt.toCrs(self.targetCrs())
        results = []
        for band_indices in self.band_indices:
            if band_indices is None:
                results.append((None, None, None))
            else:
                if band_indices.dtype == np.int16:
                    yValues = self.coredata[band_indices, y, x]
                    xValues = self.wl[band_indices]
                elif band_indices.dtype == float:
                    xValues = band_indices
                    yValues = self.coredata[:, y, x]
                    yValues = np.interp(xValues, self.wl, yValues)
                else:
                    raise NotImplementedError()
                results.append((yValues, xValues, self.wlu))
        return results, pt


class TestObjects(object):
    """
    Creates objects to be used for testing. It is preferred to generate objects in-memory.
    """

    _coreData = _coreDataWL = _coreDataWLU = _coreDataFWHM = _coreDataWkt = _coreDataGT = None

    @classmethod
    def coreData(cls) -> dict:
        if TestObjects._coreData is None:
            source_raster = Path(__file__).parent / 'enmap.tif'
            assert source_raster.is_file()

            ds = gdal.Open(source_raster.as_posix())
            assert isinstance(ds, gdal.Dataset)
            TestObjects._coreData = ds.ReadAsArray()
            TestObjects._coreDataGT = ds.GetGeoTransform()
            TestObjects._coreDataWkt = ds.GetProjection()

            prop = QgsRasterLayerSpectralProperties.fromRasterLayer(ds)
            TestObjects._coreDataWL = np.asarray(prop.wavelengths())
            TestObjects._coreDataWLU = prop.wavelengthUnits()[0]
            TestObjects._coreDataFWHM = prop.fwhm()

        results = {
            'data': cls._coreData,
            'wl': cls._coreDataWL,
            'wlu': cls._coreDataWLU,
            'fwhm': cls._coreDataFWHM,
            'gt': cls._coreDataGT,
            'wkt': cls._coreDataWkt,
        }
        return results

    @staticmethod
    def createDropEvent(mimeData: QMimeData) -> QDropEvent:
        """Creates a QDropEvent containing the provided QMimeData ``mimeData``"""
        return QDropEvent(QPointF(0, 0), Qt.CopyAction, mimeData, Qt.LeftButton, Qt.NoModifier)

    @staticmethod
    def spectralProfileData(n: int = 10,
                            n_bands: List[int] = None):
        """
        Returns n random spectral profiles from the test data
        :return: lost of (N,3) array of floats specifying point locations.
        """
        CD = TestObjects.coreData()
        coredata = CD['data']
        wl = CD['wl']
        wlu = CD['wlu']
        gt = CD['gt']
        wkt = CD['wkt']

        cnb, cnl, cns = coredata.shape
        assert n > 0
        if n_bands is None:
            n_bands = [-1]
        if not isinstance(n_bands, list):
            n_bands = [n_bands]
        assert isinstance(n_bands, list)
        for i in range(len(n_bands)):
            nb = n_bands[i]
            if nb == -1:
                n_bands[i] = cnb
            else:
                assert 0 < nb <= cnb, f'Number of bands need to be in range 0 < nb <= {cnb}.'

        n_bands = [nb if nb > 0 else cnb for nb in n_bands]

        for nb in n_bands:
            band_indices = np.linspace(0, cnb - 1, num=nb, dtype=np.int16)
            i = 0
            while i < n:
                x = random.randint(0, coredata.shape[2] - 1)
                y = random.randint(0, coredata.shape[1] - 1)
                yield coredata[band_indices, y, x], wl[band_indices], wlu
                i += 1

    @staticmethod
    def spectralProfiles(n=10,
                         fields: QgsFields = None,
                         n_bands: List[int] = None,
                         wlu: str = None,
                         profile_fields: List[Union[str, QgsField]] = None,
                         crs: QgsCoordinateReferenceSystem = None):

        from .speclib import createStandardFields
        from .speclib.core import create_profile_field, is_profile_field
        from .speclib.core.spectralprofile import prepareProfileValueDict, encodeProfileValueDict
        from .speclib.core import profile_fields as pFields
        from .unitmodel import UnitLookup

        if fields is None:
            fields = createStandardFields()

        add_names: bool = 'name' in fields.names()

        if profile_fields is None:
            # use
            profile_fields = pFields(fields)
        else:
            for i, f in enumerate(profile_fields):
                if isinstance(f, str):

                    fields.append(create_profile_field(f'profile{i}'))
                elif isinstance(f, QgsField):
                    fields.append(f)
            profile_fields = pFields(fields)

        assert isinstance(profile_fields, QgsFields)

        for f in profile_fields:
            assert is_profile_field(f)

        if n_bands is None:
            n_bands = [-1 for f in profile_fields]
        elif isinstance(n_bands, int):
            n_bands = [n_bands]

        assert len(n_bands) == profile_fields.count(), \
            f'Number of bands list ({n_bands}) has different lengths that number of profile fields'

        profileGenerator: SpectralProfileDataIterator = SpectralProfileDataIterator(n_bands, target_crs=crs)

        for i in range(n):
            profile = QgsFeature(fields)

            field_data, pt = profileGenerator.__next__()
            g = QgsGeometry.fromQPointF(pt.toQPointF())
            profile.setGeometry(g)

            if add_names:
                profile.setAttribute('name', f'Profile {i}')
            profile.setId(i + 1)
            for j, profile_field in enumerate(profile_fields):

                (data, wl, data_wlu) = field_data[j]
                if data is None:
                    profile.setAttribute(profile_field.name(), None)
                else:
                    if wlu is None:
                        wlu = data_wlu
                    elif wlu == '-':
                        wl = wlu = None
                    elif wlu != data_wlu:
                        wl = UnitLookup.convertLengthUnit(wl, data_wlu, wlu)

                    profileDict = prepareProfileValueDict(y=data, x=wl, xUnit=wlu)
                    value = encodeProfileValueDict(profileDict, profile_field)
                    profile.setAttribute(profile_field.name(), value)

            yield profile

    @staticmethod
    def createSpectralLibrary(n: int = 10,
                              n_empty: int = 0,
                              n_bands: Union[None, int, List[int], np.ndarray] = None,
                              name: Optional[str] = 'SpectralLibrary',
                              profile_field_names: List[str] = None,
                              wlu: str = None,
                              crs: QgsCoordinateReferenceSystem = None) -> QgsVectorLayer:
        """
        Creates a Spectral Library
        :param name:
        :param crs:
        :param profile_field_names:
        :param n_bands:
        :type n_bands:
        :param wlu:
        :type wlu:
        :param n: total number of profiles
        :type n: int
        :param n_empty: number of empty profiles, SpectralProfiles with empty x/y values
        :type n_empty: int
        :return: QgsVectorLayer
        :rtype: QgsVectorLayer
        """
        from .speclib.core.spectrallibrary import SpectralLibraryUtils
        from .speclib.core import profile_field_indices
        from .speclib import FIELD_VALUES

        assert n >= 0
        assert 0 <= n_empty <= n
        if profile_field_names:
            if n_bands in [[-1], None]:
                n_bands = [-1 for _ in profile_field_names]
            assert len(profile_field_names) == len(n_bands)

        if n_bands is None:
            n_bands = [-1]
        if isinstance(n_bands, int):
            n_bands = np.asarray([[n_bands, ]])
        elif isinstance(n_bands, list):
            n_bands = np.asarray(n_bands)
            if n_bands.ndim == 1:
                n_bands = n_bands.reshape((1, n_bands.shape[0]))

        assert isinstance(n_bands, np.ndarray)
        assert n_bands.ndim == 2

        n_profile_columns = n_bands.shape[-1]
        if not isinstance(profile_field_names, list):
            profile_field_names = [f'{FIELD_VALUES}{i}' for i in range(n_profile_columns)]

        slib: QgsVectorLayer = SpectralLibraryUtils.createSpectralLibrary(profile_fields=profile_field_names, crs=crs)

        name = name if name else os.path.basename(slib.source())
        slib.setName(name)

        with edit(slib):

            pfield_indices = profile_field_indices(slib)

            assert len(pfield_indices) == len(profile_field_names)

            if n > 0:
                # slib.beginEditCommand(f'Add {n} features')
                # and random profiles
                for groupIndex in range(n_bands.shape[0]):
                    bandsPerField = n_bands[groupIndex].tolist()
                    profiles = list(TestObjects.spectralProfiles(n,
                                                                 fields=slib.fields(),
                                                                 n_bands=bandsPerField,
                                                                 wlu=wlu,
                                                                 profile_fields=pfield_indices,
                                                                 crs=crs))

                    SpectralLibraryUtils.addProfiles(slib, profiles, addMissingFields=False)

                # delete empty profiles
                for i, feature in enumerate(slib.getFeatures()):
                    if i >= n_empty:
                        break
                    for field in profile_field_names:
                        feature.setAttribute(field, None)
                    slib.updateFeature(feature)
                # slib.endEditCommand()
        return slib

    @classmethod
    def inMemoryImage(cls, *args, **kwds):

        warnings.warn(''.join(traceback.format_stack()) + '\nUse createRasterDataset instead')
        return TestObjects.createRasterDataset(*args, **kwds)

    @classmethod
    def createMultiMaskExample(cls, *args, **kwds) -> QgsRasterLayer:

        path = '/vsimem/testMaskImage.{}.tif'.format(str(uuid.uuid4()))
        ds = TestObjects.createRasterDataset(*args, **kwds)
        nb = ds.RasterCount
        nl, ns = ds.RasterYSize, ds.RasterXSize
        arr: np.ndarray = ds.ReadAsArray()
        arr = arr.reshape((nb, nl, ns))
        nodata_values = []

        d = int(min(nl, ns) * 0.25)

        global_nodata = -9999
        for b in range(nb):
            x = random.randint(0, ns - 1)
            y = random.randint(0, nl - 1)

            # nodata = b
            nodata = global_nodata
            nodata_values.append(nodata)
            arr[b, max(y - d, 0):min(y + d, nl - 1), max(x - d, 0):min(x + d, ns - 1)] = nodata

        ds2: gdal.Dataset = gdal_array.SaveArray(arr, path, prototype=ds)

        for b, nd in enumerate(nodata_values):
            band: gdal.Band = ds2.GetRasterBand(b + 1)
            band.SetNoDataValue(nd)
            band.SetDescription(ds.GetRasterBand(b + 1).GetDescription())
        ds2.FlushCache()
        lyr = QgsRasterLayer(path)
        lyr.setName(f'Multiband Mask {lyr.bandCount()}x{lyr.height()}x{lyr.width()}')
        assert lyr.isValid()

        return lyr

    @classmethod
    def repoDirGDAL(cls, local='gdal') -> Optional[Path]:
        """
        Returns the path to a local GDAL repository.
        GDAL must be installed into the same path / upward path of this repository
        """
        d = findUpwardPath(__file__, local + '/.git', is_directory=True)
        if d:
            return d.parent
        else:
            return None

    @classmethod
    def repoDirQGIS(cls, local='QGIS') -> Optional[Path]:
        """
        Returns the path to a local QGIS repository.
        QGIS must be installed into the same path / upward path of this repository
        """
        d = findUpwardPath(__file__, local + '/.git', is_directory=True)
        if d:
            return d.parent
        else:
            return None

    @staticmethod
    def tmpDirPrefix() -> str:
        path_dir = Path('/vsimem/tmp')
        # path_dir = findUpwardPath(__file__, '.git').parent / 'test-outputs' / 'vsimem' / 'tmp'
        # os.makedirs(path_dir, exist_ok=True)

        return path_dir.as_posix() + '/'

    @staticmethod
    def createRasterDataset(ns=10, nl=20, nb=1,
                            crs=None, gt=None,
                            eType: int = gdal.GDT_Int16,
                            nc: int = 0,
                            path: Union[str, Path] = None,
                            drv: Union[str, gdal.Driver] = None,
                            wlu: str = None,
                            pixel_size: float = None,
                            add_wl: bool = True,
                            no_data_rectangle: int = 0,
                            no_data_value: Union[int, float] = -9999) -> gdal.Dataset:
        """
        Generates a gdal.Dataset of arbitrary size based on true data from a smaller EnMAP raster image
        """
        # gdal.AllRegister()
        from .classification.classificationscheme import ClassificationScheme
        scheme = None
        if nc is None:
            nc = 0

        if nc > 0:
            eType = gdal.GDT_Byte if nc < 256 else gdal.GDT_Int16
            scheme = ClassificationScheme()
            scheme.createClasses(nc)
        if gdal.GetDriverCount() == 0:
            gdal.AllRegister()

        if isinstance(drv, str):
            drv = gdal.GetDriverByName(drv)
        elif drv is None:
            drv = gdal.GetDriverByName('GTiff')
        assert isinstance(drv, gdal.Driver), 'Unable to load GDAL Driver'

        if isinstance(path, Path):
            path = path.as_posix()
        elif path is None:
            ext = drv.GetMetadataItem('DMD_EXTENSION')
            prefix = TestObjects.tmpDirPrefix()
            if len(ext) > 0:
                ext = f'.{ext}'
            if nc > 0:
                path = f'{prefix}testClassification.{uuid.uuid4()}{ext}'
            else:
                path = f'{prefix}testImage.{uuid.uuid4()}{ext}'
        assert isinstance(path, str)

        ds: gdal.Dataset = drv.Create(path, ns, nl, bands=nb, eType=eType)
        assert isinstance(ds, gdal.Dataset)
        for b in range(ds.RasterCount):
            band: gdal.Band = ds.GetRasterBand(b + 1)
            band.SetDescription(f'Test Band {b + 1}')

        if no_data_rectangle > 0:
            no_data_rectangle = min([no_data_rectangle, ns])
            no_data_rectangle = min([no_data_rectangle, nl])
            for b in range(ds.RasterCount):
                band: gdal.Band = ds.GetRasterBand(b + 1)
                band.SetNoDataValue(no_data_value)

        CD = TestObjects.coreData()
        coredata = CD['data']
        core_wl = CD['wl']
        core_wlu = CD['wlu']
        core_gt = CD['gt']
        core_wkt = CD['wkt']
        core_fwhm = CD['fwhm']

        if pixel_size:
            core_gt = (core_gt[0], abs(pixel_size), core_gt[2],
                       core_gt[3], core_gt[4], -abs(pixel_size))

        dt_out = gdal_array.flip_code(eType)
        if isinstance(crs, str) or gt is not None:
            assert isinstance(gt, list) and len(gt) == 6
            assert isinstance(crs, str) and len(crs) > 0
            c = QgsCoordinateReferenceSystem(crs)
            ds.SetProjection(c.toWkt())
            ds.SetGeoTransform(gt)
        else:
            ds.SetProjection(core_wkt)
            ds.SetGeoTransform(core_gt)

        if nc > 0:
            for b in range(nb):
                band: gdal.Band = ds.GetRasterBand(b + 1)
                assert isinstance(band, gdal.Band)

                array = np.empty((nl, ns), dtype=dt_out)
                assert isinstance(array, np.ndarray)

                array.fill(0)
                y0 = 0

                step = int(np.ceil(float(nl) / len(scheme)))

                for i, c in enumerate(scheme):
                    y1 = min(y0 + step, nl - 1)
                    array[y0:y1, :] = c.label()
                    y0 += y1 + 1
                band.SetCategoryNames(scheme.classNames())
                band.SetColorTable(scheme.gdalColorTable())

                if no_data_rectangle > 0:
                    array[0:no_data_rectangle, 0:no_data_rectangle] = no_data_value
                band.WriteArray(array)
        else:
            # fill with test data
            coredata = coredata.astype(dt_out)
            cb, cl, cs = coredata.shape
            if nb > coredata.shape[0]:
                coreddata2 = np.empty((nb, cl, cs), dtype=dt_out)
                coreddata2[0:cb, :, :] = coredata
                # todo: increase the number of output bands by linear interpolation instead just repeating the last band
                for b in range(cb, nb):
                    coreddata2[b, :, :] = coredata[-1, :, :]
                coredata = coreddata2

            xoff = 0
            while xoff < ns - 1:
                xsize = min(cs, ns - xoff)
                yoff = 0
                while yoff < nl - 1:
                    ysize = min(cl, nl - yoff)
                    ds.WriteRaster(xoff, yoff, xsize, ysize, coredata[:, 0:ysize, 0:xsize].tobytes())
                    yoff += ysize
                xoff += xsize

            if no_data_rectangle > 0:
                arr = np.empty((nb, no_data_rectangle, no_data_rectangle), dtype=coredata.dtype)
                arr.fill(no_data_value)
                ds.WriteRaster(0, 0, no_data_rectangle, no_data_rectangle, arr.tobytes())

            if add_wl:
                wl = []
                fwhm = []
                if nb > cb:
                    wl.extend(core_wl.tolist())
                    fwhm.extend(core_fwhm)
                    for b in range(cb, nb):
                        wl.append(core_wl[-1])
                else:
                    wl = core_wl[:nb].tolist()
                assert len(wl) == nb

                if wlu is None:
                    wlu = core_wlu
                elif wlu != core_wlu:
                    from .unitmodel import UnitLookup
                    wl = UnitLookup.convertLengthUnit(wl, core_wlu, wlu)

                domain = None
                if drv.ShortName == 'ENVI':
                    domain = 'ENVI'

                ds.SetMetadataItem('wavelength units', wlu, domain)
                ds.SetMetadataItem('wavelength', ','.join([str(w) for w in wl]), domain)

        ds.FlushCache()
        return ds

    TEST_PROVIDER = None

    @staticmethod
    def createRasterProcessingModel(name: str = 'Example Raster Model') -> QgsProcessingModelAlgorithm:

        reg: QgsProcessingRegistry = QgsApplication.instance().processingRegistry()
        alg = reg.algorithmById('gdal:rearrange_bands')

        return alg

    @staticmethod
    def createRasterLayer(*args, name: Optional[str] = None, **kwds) -> QgsRasterLayer:
        """
        Creates an in-memory raster layer.
        See arguments & keyword for `inMemoryImage()`
        :return: QgsRasterLayer
        """
        ds = TestObjects.createRasterDataset(*args, **kwds)
        assert isinstance(ds, gdal.Dataset)
        path = ds.GetDescription()

        name = name if name else os.path.basename(path)
        lyr = QgsRasterLayer(path, name, 'gdal')
        assert lyr.isValid()
        return lyr

    @classmethod
    def createVectorDataSet(cls, wkb=ogr.wkbPolygon,
                            n_features: int = None,
                            path: Union[str, Path] = None) -> ogr.DataSource:
        """
        Create an in-memory ogr.DataSource
        :return: ogr.DataSource
        """
        # ogr.RegisterAll()
        # ogr.UseExceptions()
        assert wkb in [ogr.wkbPoint, ogr.wkbPolygon, ogr.wkbLineString, ogr.wkbNone]

        # find the QGIS world_map.shp
        # pkgPath = QgsApplication.instance().pkgDataPath()
        # assert os.path.isdir(pkgPath)

        # pathSrc = Path(__file__).parent / 'landcover_polygons.geojson'
        pathSrc = TEST_VECTOR_GEOJSON
        assert pathSrc.is_file(), 'Unable to find {}'.format(pathSrc)

        dsSrc: ogr.DataSource = ogr.Open(pathSrc.as_posix())
        if not isinstance(dsSrc, gdal.Dataset):
            lyr = QgsVectorLayer(pathSrc.as_posix())
            assert lyr.isValid(), f'Unable to load QGS Layer: {pathSrc.as_posix()}'
        assert isinstance(dsSrc, ogr.DataSource), f'Unable to load {pathSrc}'
        lyrSrc: ogr.Layer = dsSrc.GetLayerByIndex(0)
        assert isinstance(lyrSrc, ogr.Layer)

        ldef = lyrSrc.GetLayerDefn()
        assert isinstance(ldef, ogr.FeatureDefn)

        srs = lyrSrc.GetSpatialRef()
        assert isinstance(srs, osr.SpatialReference)

        drv = ogr.GetDriverByName('GPKG')
        assert isinstance(drv, ogr.Driver)

        # set temp path
        if path:
            pathDst = Path(path).as_posix()
            lname = os.path.basename(pathDst)
        else:
            prefix = TestObjects.tmpDirPrefix() + str(uuid.uuid4())

            if wkb == ogr.wkbPolygon:
                lname = 'polygons'
                pathDst = prefix + '.test.polygons.gpkg'
            elif wkb == ogr.wkbPoint:
                lname = 'points'
                pathDst = prefix + '.test.centroids.gpkg'
            elif wkb == ogr.wkbLineString:
                lname = 'lines'
                pathDst = prefix + '.test.line.gpkg'
            elif wkb == ogr.wkbNone:
                lname = 'no_geometry'
                pathDst = prefix + '.test.nogeometry.gpkg'
            else:
                raise NotImplementedError()

        dsDst: ogr.DataSource = drv.CreateDataSource(pathDst)
        assert isinstance(dsDst, ogr.DataSource)
        lyrDst = dsDst.CreateLayer(lname, srs=srs, geom_type=wkb)
        assert isinstance(lyrDst, ogr.Layer)

        if n_features is None:
            n_features = lyrSrc.GetFeatureCount()

        assert n_features >= 0

        # copy features
        TMP_FEATURES: List[ogr.Feature] = []
        for fSrc in lyrSrc:
            assert isinstance(fSrc, ogr.Feature)
            TMP_FEATURES.append(fSrc)

        # copy field definitions
        for i in range(ldef.GetFieldCount()):
            fieldDefn = ldef.GetFieldDefn(i)
            assert isinstance(fieldDefn, ogr.FieldDefn)
            lyrDst.CreateField(fieldDefn)

        n = 0
        for fSrc in itertools.cycle(TMP_FEATURES):
            g: ogr.Geometry = fSrc.geometry()
            fDst = ogr.Feature(lyrDst.GetLayerDefn())
            assert isinstance(fDst, ogr.Feature)

            if isinstance(g, ogr.Geometry):
                if wkb == ogr.wkbPolygon:
                    g.FlattenTo2D()
                elif wkb == ogr.wkbPoint:
                    g = g.Centroid()
                elif wkb == ogr.wkbLineString:
                    g = g.GetBoundary()
                elif wkb == ogr.wkbNone:
                    g = None
                else:
                    raise NotImplementedError()
            if g:
                fDst.SetGeometry(g)

            for i in range(ldef.GetFieldCount()):
                fDst.SetField(i, fSrc.GetField(i))

            assert lyrDst.CreateFeature(fDst) == ogr.OGRERR_NONE
            n += 1

            if n >= n_features:
                break
        assert isinstance(dsDst, ogr.DataSource)
        dsDst.FlushCache()
        return dsDst

    @staticmethod
    def createEmptyMemoryLayer(fields: QgsFields,
                               name: str = 'memory layer',
                               crs: QgsCoordinateReferenceSystem = None,
                               wkbType: QGIS_WKBTYPE = QGIS_WKBTYPE.NoGeometry):

        """
            Class with static routines to create test objects
            """
        uri = ''
        if wkbType != QGIS_WKBTYPE.NoGeometry:
            uri += QgsWkbTypes.displayString(wkbType)
        else:
            uri += 'none'
        uri += '?'
        if isinstance(crs, QgsCoordinateReferenceSystem):
            uri += f'crs=epsg:{crs.srsid()}'
        options = QgsVectorLayer.LayerOptions(loadDefaultStyle=True, readExtentFromXml=True)
        lyr = QgsVectorLayer(uri, name, 'memory', options=options)
        lyr.setCustomProperty('skipMemoryLayerCheck', 1)

        assert lyr.isValid()
        with edit(lyr):
            for a in fields:
                success = lyr.addAttribute(a)
                if success:
                    i = lyr.fields().lookupField(a.name())
                    if i > -1:
                        lyr.setEditorWidgetSetup(i, a.editorWidgetSetup())
        return lyr

    @classmethod
    def createVectorLayer(cls, wkbType: QgsWkbTypes = QgsWkbTypes.Polygon,
                          n_features: int = None,
                          path: Union[str, Path] = None,
                          name: Optional[str] = None,
                          crs: QgsCoordinateReferenceSystem = None) -> QgsVectorLayer:
        """
        Create a QgsVectorLayer
        :return: QgsVectorLayer
        """
        lyrOptions = QgsVectorLayer.LayerOptions(loadDefaultStyle=False, readExtentFromXml=False)

        wkb = None

        if wkbType in [QgsWkbTypes.Point, QgsWkbTypes.PointGeometry]:
            wkb = ogr.wkbPoint
        elif wkbType in [QgsWkbTypes.LineString, QgsWkbTypes.LineGeometry]:
            wkb = ogr.wkbLineString
        elif wkbType in [QgsWkbTypes.Polygon, QgsWkbTypes.PolygonGeometry]:
            wkb = ogr.wkbPolygon
        elif wkbType == QgsWkbTypes.NoGeometry:
            wkb = ogr.wkbNone

        assert wkb is not None
        dsSrc = TestObjects.createVectorDataSet(wkb=wkb, n_features=n_features, path=path)

        assert isinstance(dsSrc, ogr.DataSource)
        lyr = dsSrc.GetLayer(0)
        assert isinstance(lyr, ogr.Layer)
        assert lyr.GetFeatureCount() > 0
        # uri = '{}|{}'.format(dsSrc.GetName(), lyr.GetName())
        uri = dsSrc.GetName()

        name = name if name else os.path.basename(uri)

        vl = QgsVectorLayer(uri, name, 'ogr', lyrOptions)
        assert isinstance(vl, QgsVectorLayer)
        assert vl.isValid()
        if wkb != ogr.wkbNone:
            if not vl.crs().isValid():
                srs = lyr.GetSpatialRef()
                srs_wkt = srs.ExportToWkt()
                crs2 = QgsCoordinateReferenceSystem(srs_wkt)
                assert crs2.isValid()
                s = ""

            assert vl.crs().isValid()
        assert vl.featureCount() == lyr.GetFeatureCount()

        if isinstance(crs, QgsCoordinateReferenceSystem) and vl.crs() != crs:
            trans = QgsCoordinateTransform(vl.crs(), crs, QgsProject.instance())
            with edit(vl):
                for f in vl.getFeatures():
                    g = f.geometry()
                    assert g.transform(trans) == Qgis.GeometryOperationResult.Success
                    f.setGeometry(g)
                    vl.updateFeature(f)
            vl.setCrs(crs)

        return vl

    @classmethod
    def processingAlgorithm(cls):

        class TestProcessingAlgorithm(QgsProcessingAlgorithm):

            def __init__(self):
                super(TestProcessingAlgorithm, self).__init__()
                s = ""

            def createInstance(self):
                return TestProcessingAlgorithm()

            def name(self):
                return 'exmaplealg'

            def displayName(self):
                return 'Example Algorithm'

            def groupId(self):
                return 'exampleapp'

            def group(self):
                return 'TEST APPS'

            def initAlgorithm(self, configuration=None):
                self.addParameter(QgsProcessingParameterRasterLayer('pathInput', 'The Input Dataset'))
                self.addParameter(
                    QgsProcessingParameterNumber('value', 'The value', QgsProcessingParameterNumber.Double, 1, False,
                                                 0.00, 999999.99))
                self.addParameter(QgsProcessingParameterRasterDestination('pathOutput', 'The Output Dataset'))

            def processAlgorithm(self, parameters, context, feedback):
                assert isinstance(parameters, dict)
                assert isinstance(context, QgsProcessingContext)
                assert isinstance(feedback, QgsProcessingFeedback)

                outputs = {}
                return outputs

        return TestProcessingAlgorithm()


class QgsPluginManagerMockup(QgsPluginManagerInterface):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def addPluginMetadata(self, *args, **kwargs):
        super().addPluginMetadata(*args, **kwargs)

    def addToRepositoryList(self, *args, **kwargs):
        super().addToRepositoryList(*args, **kwargs)

    def childEvent(self, *args, **kwargs):
        super().childEvent(*args, **kwargs)

    def clearPythonPluginMetadata(self, *args, **kwargs):
        # super().clearPythonPluginMetadata(*args, **kwargs)
        pass

    def clearRepositoryList(self, *args, **kwargs):
        super().clearRepositoryList(*args, **kwargs)

    def connectNotify(self, *args, **kwargs):
        super().connectNotify(*args, **kwargs)

    def customEvent(self, *args, **kwargs):
        super().customEvent(*args, **kwargs)

    def disconnectNotify(self, *args, **kwargs):
        super().disconnectNotify(*args, **kwargs)

    def isSignalConnected(self, *args, **kwargs):
        return super().isSignalConnected(*args, **kwargs)

    def pluginMetadata(self, *args, **kwargs):
        super().pluginMetadata(*args, **kwargs)

    def pushMessage(self, *args, **kwargs):
        super().pushMessage(*args, **kwargs)

    def receivers(self, *args, **kwargs):
        return super().receivers(*args, **kwargs)

    def reloadModel(self, *args, **kwargs):
        super().reloadModel(*args, **kwargs)

    def sender(self, *args, **kwargs):
        return super().sender(*args, **kwargs)

    def senderSignalIndex(self, *args, **kwargs):
        return super().senderSignalIndex(*args, **kwargs)

    def showPluginManager(self, *args, **kwargs):
        super().showPluginManager(*args, **kwargs)

    def timerEvent(self, *args, **kwargs):
        super().timerEvent(*args, **kwargs)


path_mockup_ui = Path(__file__).parent / 'ui/optionsdialogmockup.ui'


class QgsOptionsMockup(QgsOptionsDialogBase):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        loadUi(path_mockup_ui, self)
        # w = self.findChild(QListWidget, 'mOptionsListWidget')
        assert (self.findChild(QListWidget, 'mOptionsListWidget')
                or self.findChild(QTreeView, 'mOptionsTreeView'))

        check = [
            ('mOptionsTreeView', QTreeView),
            ('mOptionsFrame', QFrame),
            ('mOptionsStackedWidget', QStackedWidget),
            ('mOptionsSplitter', QSplitter),
            ('buttonBox', QDialogButtonBox),
            ('mButtonBoxFrame', QFrame),
            ('mSearchLineEdit', QgsFilterLineEdit),
        ]

        for (name, cls) in check:
            c1 = getattr(self, name)
            assert isinstance(c1, cls)
            c2 = self.findChild(cls, name)
            assert isinstance(c2, cls)

        self.buttonBox: QDialogButtonBox
        self.mOptTreeModel = QStandardItemModel()
        self.mOptionsTreeView.setModel(self.mOptTreeModel)
        self.initOptionsBase(False, self.__class__.__name__)

        self.buttonBox.accepted.connect(self.saveOptions)
        self._pageWidgets = []

    def pageWidgets(self) -> List[QgsOptionsPageWidget]:
        return self._pageWidgets[:]

    def saveOptions(self):
        for page in self.pageWidgets():
            if isinstance(page, QgsOptionsPageWidget):
                page.apply()

    def addPage(self, title, tooltip, icon, widget, path, key):

        super().addPage(title, tooltip, icon, widget, path, key)
        self._pageWidgets.append(widget)

    def setCurrentPage(self, pageName: str = ''):
        sw: QStackedWidget = self.mOptionsStackedWidget
        for i in range(sw.count()):
            page = sw.widget(i)
            if isinstance(page, QgsOptionsPageWidget) and page.objectName() == pageName:
                sw.setCurrentIndex(i)
                return
        return


class QgsClipboardMockup(QObject):
    changed = pyqtSignal()

    def __init__(self, parent=None):
        super(QgsClipboardMockup, self).__init__(parent)

        self.mFeatureFields = None
        self.mFeatureClipboard = None
        self.mCRS = None
        self.mSrcLayer = None
        self.mUseSystemClipboard = False
        QApplication.clipboard().dataChanged.connect(self.systemClipboardChanged)

    def replaceWithCopyOf(self, src: QgsVectorLayer):
        if isinstance(src, QgsVectorLayer):
            self.mFeatureFields = src.fields()
            self.mFeatureClipboard = src.selectedFeatures()
            self.mCRS = src.crs()
            self.mSrcLayer = src

            return

        elif isinstance(src, QgsFeatureStore):
            raise NotImplementedError()

    def copyOf(self, field: QgsFields):

        return self.mFeatureClipboard

    def transformedCopyOf(self, crs: QgsCoordinateReferenceSystem, fields: QgsFields) -> List[QgsFeature]:

        features = self.copyOf(fields)
        ct = QgsCoordinateTransform(self.mCRS, crs, QgsProject.instance())

        for f in features:
            g: QgsGeometry = f.geometry()
            g.transform(ct)
            f.setGeometry(g)

        return features

    def setSystemClipBoard(self):
        """
        cb = QApplication.clipboard()
        textCopy = self.generateClipboardText()

        m = QMimeData()
        m.setText(textCopy)

        # todo: set HTML
        """
        raise NotImplementedError()

    def generateClipboardText(self):

        """
        textFields = ['wkt_geom'] + [n for n in self.mFeatureFields]

        textLines = '\t'.join(textFields)
        textFields.clear()
        """

        raise NotImplementedError()

    def systemClipboardChanged(self):
        pass


class QgsPythonRunnerMockup(QgsPythonRunner):
    """
    A Qgs PythonRunner implementation
    """

    def __init__(self):
        super(QgsPythonRunnerMockup, self).__init__()

    def evalCommand(self, cmd: str, result: str):
        try:
            o = compile(cmd)
        except Exception as ex:
            result = str(ex)
            return False
        return True

    def runCommand(self, command, messageOnError=''):
        try:
            o = compile(command, 'fakemodule', 'exec')
            exec(o)
        except Exception as ex:
            messageOnError = str(ex)
            command = ['{}:{}'.format(i + 1, l) for i, l in enumerate(command.splitlines())]
            print('\n'.join(command), file=sys.stderr)
            raise ex
        return True
