# -*- 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 inspect
import itertools
import os
import random
import secrets
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 .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
    if not (QgsCoordinateReferenceSystem('EPSG:4326').isValid()):
        raise AssertionError('Unable to load CRS EPSG:4326.')

    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')
    if not (crs1.isValid()):
        raise AssertionError('Failed to initialize QGIS SRS database')
    crs2 = QgsCoordinateReferenceSystem.fromWkt(crs1.toWkt())
    if not (crs2.isValid()):
        raise AssertionError('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(parent=self.ui)
        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:
            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)

    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:

        if not (isinstance(handler.action(), QAction)
                and isinstance(handler.mapTool(), QgsMapTool)):
            raise AssertionError('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:
        if not (isinstance(handler, QgsAbstractMapToolHandler)):
            raise AssertionError
        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):
        if not (isinstance(factory, QgsMapLayerConfigWidgetFactory)):
            raise AssertionError

        self.mMapLayerPanelFactories.append(factory)

    def unregisterMapLayerConfigWidgetFactory(self, factory: QgsMapLayerConfigWidgetFactory):
        if not (isinstance(factory, QgsMapLayerConfigWidgetFactory)):
            raise AssertionError
        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):
        if not (isinstance(action, QAction)):
            raise AssertionError

    def removeToolBarIcon(self, action):
        if not (isinstance(action, QAction)):
            raise AssertionError

    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)
        if not (lyr.isValid()):
            raise AssertionError
        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):
        _ = self.ui.menuBar().addAction('Add Vector')
        _ = 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):
        if not (isinstance(factory, QgsOptionsWidgetFactory)):
            raise AssertionError
        self.mOptionsWidgetFactories.append(factory)

    def unregisterOptionsWidgetFactory(self, factory):
        if not (factory in self.mOptionsWidgetFactories):
            raise AssertionError
        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):
    """
    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


# 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:
            try:
                app.exec()
            except Exception:
                app.exec()

        return True

    @staticmethod
    def runsInCI() -> bool:
        """
        Returns True if 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)
        if not (path.is_file()):
            raise AssertionError

        ds: gdal.Dataset = gdal.Open(path.as_posix())
        if not (isinstance(ds, gdal.Dataset)):
            raise AssertionError
        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:
                        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 / secrets.token_urlsafe(8).upper()
            info = [f'Path exceeds max_length ({max_length}: {p}).',
                    f'Use random name 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=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
            if not (isinstance(ExampleAlgorithmProvider._INSTANCE, ExampleAlgorithmProvider)):
                raise AssertionError
            if not (reg.addProvider(ExampleAlgorithmProvider._INSTANCE)):
                raise AssertionError
        if not (reg.providerById(ExampleAlgorithmProvider.NAME.lower())):
            raise AssertionError
        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:
            if not (nb is None or 0 < nb):
                raise AssertionError
            # 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)  # nosec B311 # no security relevant sampling
        y = random.randint(0, self.coredata.shape[1] - 1)  # nosec B311 # no security relevant sampling

        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),  # nosec B311 # not security relevant sampling
            pt.y() - self.dy * random.uniform(0, 1)  # nosec B311 # not security relevant sampling
        )
        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'
            if not (source_raster.is_file()):
                raise AssertionError

            ds = gdal.Open(source_raster.as_posix())
            if not (isinstance(ds, gdal.Dataset)):
                raise AssertionError
            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']
        _ = CD['gt']
        _ = CD['wkt']

        cnb, cnl, cns = coredata.shape
        if not (n > 0):
            raise AssertionError
        if n_bands is None:
            n_bands = [-1]
        if not isinstance(n_bands, list):
            n_bands = [n_bands]
        if not (isinstance(n_bands, list)):
            raise AssertionError
        for i in range(len(n_bands)):
            nb = n_bands[i]
            if nb == -1:
                n_bands[i] = cnb
            else:
                if not (0 < nb <= cnb):
                    raise AssertionError(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)  # nosec B311 # no security relevant sampling
                y = random.randint(0, coredata.shape[1] - 1)  # nosec B311 # no security relevant sampling
                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)

        if not (isinstance(profile_fields, QgsFields)):
            raise AssertionError

        for f in profile_fields:
            if not (is_profile_field(f)):
                raise AssertionError

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

        if not (len(n_bands) == profile_fields.count()):
            raise AssertionError(
                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

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

        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]))

        if not (isinstance(n_bands, np.ndarray)):
            raise AssertionError
        if not (n_bands.ndim == 2):
            raise AssertionError

        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)

            if not (len(pfield_indices) == len(profile_field_names)):
                raise AssertionError

            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)  # nosec B311 # not security relevant sampling
            y = random.randint(0, nl - 1)  # nosec B311 # not security relevant sampling

            # 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()}')
        if not (lyr.isValid()):
            raise AssertionError

        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')
        if not (isinstance(drv, gdal.Driver)):
            raise AssertionError('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}'
        if not (isinstance(path, str)):
            raise AssertionError

        ds: gdal.Dataset = drv.Create(path, ns, nl, bands=nb, eType=eType)
        if not (isinstance(ds, gdal.Dataset)):
            raise AssertionError
        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:
            if not (isinstance(gt, list) and len(gt) == 6):
                raise AssertionError
            if not (isinstance(crs, str) and len(crs) > 0):
                raise AssertionError
            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)
                if not (isinstance(band, gdal.Band)):
                    raise AssertionError

                array = np.empty((nl, ns), dtype=dt_out)
                if not (isinstance(array, np.ndarray)):
                    raise AssertionError

                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()
                if not (len(wl) == nb):
                    raise AssertionError

                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)
        if not (isinstance(ds, gdal.Dataset)):
            raise AssertionError
        path = ds.GetDescription()

        name = name if name else os.path.basename(path)
        lyr = QgsRasterLayer(path, name, 'gdal')
        if not (lyr.isValid()):
            raise AssertionError
        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()
        if not (wkb in [ogr.wkbPoint, ogr.wkbPolygon, ogr.wkbLineString, ogr.wkbNone]):
            raise AssertionError

        # 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
        if not (pathSrc.is_file()):
            raise AssertionError('Unable to find {}'.format(pathSrc))

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

        ldef = lyrSrc.GetLayerDefn()
        if not (isinstance(ldef, ogr.FeatureDefn)):
            raise AssertionError

        srs = lyrSrc.GetSpatialRef()
        if not (isinstance(srs, osr.SpatialReference)):
            raise AssertionError

        drv = ogr.GetDriverByName('GPKG')
        if not (isinstance(drv, ogr.Driver)):
            raise AssertionError

        # 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)
        if not (isinstance(dsDst, ogr.DataSource)):
            raise AssertionError
        lyrDst = dsDst.CreateLayer(lname, srs=srs, geom_type=wkb)
        if not (isinstance(lyrDst, ogr.Layer)):
            raise AssertionError

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

        if not (n_features >= 0):
            raise AssertionError

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

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

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

            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))

            if not (lyrDst.CreateFeature(fDst) == ogr.OGRERR_NONE):
                raise AssertionError
            n += 1

            if n >= n_features:
                break
        if not (isinstance(dsDst, ogr.DataSource)):
            raise AssertionError
        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)

        if not (lyr.isValid()):
            raise AssertionError
        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

        if not (wkb is not None):
            raise AssertionError
        dsSrc = TestObjects.createVectorDataSet(wkb=wkb, n_features=n_features, path=path)

        if not (isinstance(dsSrc, ogr.DataSource)):
            raise AssertionError
        lyr = dsSrc.GetLayer(0)
        if not (isinstance(lyr, ogr.Layer)):
            raise AssertionError
        if not (lyr.GetFeatureCount() > 0):
            raise AssertionError
        # uri = '{}|{}'.format(dsSrc.GetName(), lyr.GetName())
        uri = dsSrc.GetName()

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

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

            if not (vl.crs().isValid()):
                raise AssertionError
        if not (vl.featureCount() == lyr.GetFeatureCount()):
            raise AssertionError

        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()
                    if not (g.transform(trans) == Qgis.GeometryOperationResult.Success):
                        raise AssertionError
                    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__()

            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):
                if not (isinstance(parameters, dict)):
                    raise AssertionError
                if not (isinstance(context, QgsProcessingContext)):
                    raise AssertionError
                if not (isinstance(feedback, QgsProcessingFeedback)):
                    raise AssertionError

                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')
        if not (self.findChild(QListWidget, 'mOptionsListWidget')
                or self.findChild(QTreeView, 'mOptionsTreeView')):
            raise AssertionError

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

        for (name, cls) in check:
            c1 = getattr(self, name)
            if not (isinstance(c1, cls)):
                raise AssertionError
            c2 = self.findChild(cls, name)
            if not (isinstance(c2, cls)):
                raise AssertionError

        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:
            _ = compile(cmd)
        except Exception as ex:
            _ = str(ex)
            return False
        return True

    def runCommand(self, command, messageOnError=''):
        try:
            o = compile(command, 'fakemodule', 'exec')
            exec(o)  # nosec: B102 # a QgsPythonRunnerMockup is expected to execute user-defined Python code
        except Exception as ex:
            _ = 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
