import enum
import os
import warnings
from typing import Dict, List, Optional, Tuple, Callable, TYPE_CHECKING

from matplotlib.figure import Figure
from matplotlib.axes import Axes

from PySide6.QtCore import *
from PySide6.QtWidgets import QMessageBox, QDialog

from .app_config import ApplicationConfig, PixelReticleType
from .util import get_random_matplotlib_color

from wiser.plugins import Plugin

from wiser.raster.dataset import *
from wiser.raster.loader import RasterDataLoader

from wiser.raster.spectrum import Spectrum
from wiser.raster.spectral_library import SpectralLibrary
from wiser.raster.envi_spectral_library import ENVISpectralLibrary
from wiser.raster.loaders.envi import EnviFileFormatError
from wiser.raster.utils import have_spatial_overlap, can_transform_between_srs

from wiser.raster.stretch import StretchBase

from wiser.raster.roi import RegionOfInterest, roi_to_pyrep, roi_from_pyrep

from wiser.raster.data_cache import DataCache

from wiser.gui.subprocessing_manager import MultiprocessingManager, ProcessManager
from wiser.gui.ui_library import (
    DatasetChooserDialog,
    SpectrumChooserDialog,
    ROIChooserDialog,
    BandChooserDialog,
    DynamicInputDialog,
    TableDisplayWidget,
    MatplotlibDisplayWidget,
)
from wiser.gui.spectrum_plot import SpectrumPlotGeneric
from wiser.gui.util import StateChange

if TYPE_CHECKING:
    from wiser.gui.reference_creator_dialog import CrsCreatorState
    from wiser.gui.kmeans import KMeansRunHistoryManager
    from wiser.gui.linear_unmixing import LinearUnmixingHistoryManager
    from wiser.gui.permanent_plugins.pca_plugin import PCAHistoryManager
    from wiser.gui.mnf import MNFHistoryManager


def make_unique_name(candidate: str, used_names: str) -> str:
    # If the name is already unique, return it
    if candidate not in used_names:
        return candidate

    # Try to generate a unique name by tacking a number onto the name
    i = 2
    while True:
        name = f"{candidate} {i}"
        if name not in used_names:
            return name

        i += 1


class ApplicationState(QObject):
    """
    This class holds all WISER application state.
    """

    # Signal:  a data-set with the specified ID was added
    dataset_added = Signal(int, bool)

    # Signal:  the data-set with the specified ID was removed
    dataset_removed = Signal(int)

    # Signal: the mainview dataset was changed
    mainview_dataset_changed = Signal(int)

    # Signal:  a spectral library with the specified ID was added
    spectral_library_added = Signal(int)

    # Signal:  the spectral library with the specified ID was removed
    spectral_library_removed = Signal(int)

    roi_added = Signal(RegionOfInterest)

    roi_removed = Signal(RegionOfInterest)

    # Signal:  the contrast stretch was changed for a specific dataset and set
    #          of bands.  The first argument is the ID of the dataset, and the
    #          second argument is a tuple of bands.
    stretch_changed = Signal(int, tuple)

    # Signal:  the active spectrum changed
    active_spectrum_changed = Signal()

    # TODO(donnie):  collected_spectra_changed = Signal(StateChange, int)
    collected_spectra_changed = Signal(object, int, int)

    # TODO(donnie):  Signals for config changes and color changes!

    def __init__(self, app, config: Optional[ApplicationConfig] = None):
        super().__init__()

        # A reference to the overall UI
        self._app = app

        self._cache = None

        # The plugins currently loaded into WISER.
        self._plugins: Dict[str, Plugin] = {}

        self._current_dir = os.getcwd()
        self._raster_data_loader = RasterDataLoader()

        # Source of numeric IDs for assigning to objects in the application
        # state.  IDs are unique across all objects, not just for each type of
        # object, just for the sake of simplicity.
        self._next_id = 1

        # All datasets loaded in the application.  The key is the numeric ID of
        # the data set, and the value is the RasterDataSet object.
        self._datasets = {}

        # Stretches for all data sets are stored here.  The key is a tuple of
        # the (dataset ID, band #), and the value is a Stretch object.
        self._stretches: Dict[Tuple[int, int], StretchBase] = {}

        # All spectral libraries loaded in the application.  The key is the
        # numeric ID of the spectral library, and the value is the
        # SpectralLibrary object.
        self._spectral_libraries: Dict[int, SpectralLibrary] = {}

        # All regions of interest in the application.  The key is the numeric ID
        # of the ROI, and the value is a RegionOfInterest object.
        self._regions_of_interest: Dict[int, RegionOfInterest] = {}

        # A collection of all spectra in the application state, so that we can
        # look them up by ID.
        self._all_spectra: Dict[int, Spectrum] = {}

        # The "currently active" spectrum, which is set when the user clicks on
        # pixels, or wants to view an ROI average spectrum, etc.
        self._active_spectrum: Optional[Spectrum] = None

        # The spectra collected by the user, possibly for export, or conversion
        # into a spectral library.
        self._collected_spectra: List[Spectrum] = []

        # Configuration state.

        if config is None:
            config = ApplicationConfig()

        self._config: ApplicationConfig = config

        # A dictionary holding the CRSs that the user has created.
        # The key is the CRS name.
        self._user_created_crs: Dict[str, Tuple[osr.SpatialReference, CrsCreatorState]] = {}

        self._process_pool_manager = MultiprocessingManager()

        self._running_processes: Dict[int, ProcessManager] = {}

        # Plugin Chooser Dialogs
        self._plugin_dataset_chooser_dialog: Optional[DatasetChooserDialog] = None

        self._plugin_spectrum_chooser_dialog: Optional[SpectrumChooserDialog] = None

        self._plugin_roi_chooser_dialog: Optional[ROIChooserDialog] = None

        self._plugin_band_chooser_dialog: Optional[BandChooserDialog] = None

        self._dynamic_input_dialog: Optional[DynamicInputDialog] = None

        # The set of generic spectrum plots that users can make for their plugins
        self._generic_spectrum_plots: set[SpectrumPlotGeneric] = set()

        # The set of table display widgets that users can make for their plugins
        self._table_display_widgets: set[TableDisplayWidget] = set()

        # The set of matplotlib display widgets that users can make for their plugins
        self._matplotlib_display_widgets: set[MatplotlibDisplayWidget] = set()

        # Linear-unmix run history is application state, not dialog state — it
        # persists across opens/closes of the LinearUnmixingDialog so users can
        # always revisit past runs.  Local import to avoid an import cycle with
        # linear_unmixing.py (which transitively imports from this module).
        from wiser.gui.linear_unmixing import LinearUnmixingHistoryManager

        self._linear_unmix_history = LinearUnmixingHistoryManager(self)

        # PCA and MNF histories follow the same pattern — application state
        # so the past-runs viewer survives across dialog open/close cycles.
        # Local imports for the same reason as above (the modules they live in
        # transitively import from this one).
        from wiser.gui.permanent_plugins.pca_plugin import PCAHistoryManager
        from wiser.gui.mnf import MNFHistoryManager

        self._pca_history = PCAHistoryManager(self)
        self._mnf_history = MNFHistoryManager(self)

        # K-Means run history — same application-state pattern.  Replaces the
        # legacy KMeansParameters-keyed centroid dict so every completed run is
        # an independent, timestamped, deletable record (see issue #613).
        from wiser.gui.kmeans import KMeansRunHistoryManager

        self._kmeans_history = KMeansRunHistoryManager(self)

    def get_linear_unmix_history(self) -> "LinearUnmixingHistoryManager":
        """Return the application-wide linear-unmixing run history manager."""
        return self._linear_unmix_history

    def get_pca_history(self) -> "PCAHistoryManager":
        """Return the application-wide PCA run history manager."""
        return self._pca_history

    def get_mnf_history(self) -> "MNFHistoryManager":
        """Return the application-wide MNF run history manager."""
        return self._mnf_history

    def get_kmeans_history(self) -> "KMeansRunHistoryManager":
        """Return the application-wide K-Means run history manager."""
        return self._kmeans_history

    def add_running_process(self, process_manager: ProcessManager):
        process_manager_id = process_manager.get_process_manager_id()
        self._running_processes[process_manager_id] = process_manager
        process_manager.get_task().finished.connect(
            lambda process_manager_id=process_manager_id: self._retire_process_manager(process_manager_id)
        )

    def remove_running_process(self, process_manager_id: int):
        self._running_processes.pop(process_manager_id, None)

    def _retire_process_manager(self, process_manager_id: int):
        process_manager = self._running_processes.pop(process_manager_id, None)
        if process_manager is None:
            return
        process_manager.close(wait=False, cancel_running=False)

    def cancel_all_running_processes(self):
        process_managers = list(self._running_processes.values())
        for process_manager in process_managers:
            process_manager.close(wait=True, cancel_running=True)
        self._running_processes.clear()
        self._process_pool_manager.close()

    def get_running_processes(self) -> Dict[int, ProcessManager]:
        return self._running_processes

    def submit_parallel_task(self, operation: Callable, kwargs: Dict = {}):
        return self._process_pool_manager.create_task(operation, kwargs)

    def get_next_process_pool_id(self):
        return self._process_pool_manager.get_next_process_pool_id()

    def take_next_id(self) -> int:
        """
        Returns the next ID for use with an object, and also increments the
        internal "next ID" value.
        """
        id = self._next_id
        self._next_id += 1
        return id

    def add_plugin(self, class_name: str, plugin: Plugin):
        if class_name in self._plugins:
            raise ValueError(f'Plugin class "{class_name}" is already added')

        self._plugins[class_name] = plugin

    def get_plugins(self) -> Dict[str, Plugin]:
        return dict(self._plugins)

    def get_loader(self):
        """
        Returns the ``RasterDataLoader`` instance being used by WISER to load
        data sets.
        """
        return self._raster_data_loader

    def get_current_dir(self) -> str:
        """
        Returns the current directory of the application.  This is the last
        directory that the user accessed in a load or save operation, so that
        the next load or save can start at the same directory.
        """
        return self._current_dir

    def set_current_dir(self, current_dir: str) -> None:
        """
        Sets the current directory of the application.  This is the last
        directory that the user accessed in a load or save operation, so that
        the next load or save can start at the same directory.
        """
        self._current_dir = current_dir

    def set_data_cache(self, data_cache: DataCache):
        self._cache = data_cache

    def update_cwd_from_path(self, path: str) -> None:
        """
        This helper function makes it easier to update the current working
        directory (CWD) at the end of a file-load or file-save operation.  The
        specified path is assumed to be either a directory or a file:

        *   If it is a directory then the current directory is set to that path.
        *   If it is a file then the directory portion of the path is taken and
            used for the current directory.

        The function only updates the current directory if the filesystem
        actually reports it as a valid directory.  If the directory can't be
        identified from the specified path (e.g. the OS doesn't report the path
        as a valid directory), this function simply logs a warning.
        """
        dir = os.path.abspath(path)
        if not os.path.isdir(dir):
            dir = os.path.dirname(dir)

        if os.path.isdir(dir):
            self._current_dir = dir
        else:
            warnings.warn(f'Couldn\'t update CWD from path "{path}"')

    def show_status_text(self, text: str, seconds=0):
        self._app.show_status_text(text, seconds)

    def clear_status_text(self):
        self._app.show_status_text("")

    def open_file(self, file_path):
        """
        A general file-open operation in WISER.  This method can be used
        for loading any kind of data file whose type and contents can be
        identified automatically.  This operation should not be used for
        importing ASCII spectral data, regions of interest, etc. since
        WISER cannot identify the file's contents automatically.
        """

        # Remember the directory of the selected file, for next file-open
        self.update_cwd_from_path(file_path)

        # Is the file a project file?

        if file_path.endswith(".wiser"):
            self.load_project_file(file_path)
            return

        # Figure out if the user wants to open a raster data set or a
        # spectral library.

        if file_path.endswith(".sli") or file_path.endswith(".hdr"):
            # ENVI file, possibly a spectral library.  Find out.
            try:
                # Will this work??
                library = ENVISpectralLibrary(file_path)

                # Wow it worked!  It must be a spectral library.
                self.add_spectral_library(library)
                return

            except FileNotFoundError:
                pass
            except EnviFileFormatError:
                pass

        # Either the data doesn't look like a spectral library, or loading
        # it as a spectral library didn't work.  Load it as a regular raster
        # data file.

        raster_data_list = self._raster_data_loader.load_from_file(path=file_path, data_cache=self._cache)

        for raster_data in raster_data_list:
            self.add_dataset(raster_data)

    def add_dataset(self, dataset: RasterDataSet, view_dataset: bool = True):
        """
        Add a dataset to the application state.  A unique numeric ID is assigned
        to the dataset, which is also set on the dataset itself.

        The method will fire a signal indicating that the dataset was added.
        """
        if not isinstance(dataset, RasterDataSet):
            raise TypeError("dataset must be a RasterDataSet")

        ds_id = self.take_next_id()
        dataset.set_id(ds_id)
        self._datasets[ds_id] = dataset

        self.dataset_added.emit(ds_id, view_dataset)
        # self.state_changed.emit(tuple(ObjectType.DATASET, ActionType.ADDED, dataset))

    def has_dataset(self, ds_id: int) -> bool:
        """
        Returns whether the dataset with the specified numeric ID is in the application state.
        """
        return ds_id in self._datasets

    def get_dataset(self, ds_id: int) -> RasterDataSet:
        """
        Return the dataset with the specified numeric ID.  If the ID is
        unrecognized then a KeyError will be raised.
        """
        return self._datasets[ds_id]

    def get_cache(self) -> DataCache:
        return self._cache

    def num_datasets(self):
        """Return the number of datasets in the application state."""
        return len(self._datasets)

    def get_datasets(self) -> List[RasterDataSet]:
        """
        Return a list of datasets in the application state.  The returned list
        is separate from the internal application-state data structures, and
        therefore may be mutated by the caller without harm.
        """
        return list(self._datasets.values())

    def remove_dataset(self, ds_id: int):
        """
        Remove the dataset with the specified numeric ID from the application
        state and the cache. If the ID is unrecognized then a KeyError will
        be raised.

        The method will fire a signal indicating that the dataset was removed.
        """
        dataset_to_del = self._datasets[ds_id]
        dataset_to_del.delete_underlying_dataset()
        # First we remove it form the computation cache
        comp_cache = self._cache.get_computation_cache()
        comp_key = comp_cache.get_cache_key(dataset_to_del)
        comp_cache.remove_cache_item(comp_key)

        # Next we remove it from the render cache
        render_cache = self._cache.get_render_cache()
        render_cache.clear_keys_from_partial(render_cache.get_partial_key(dataset_to_del))

        del self._datasets[ds_id]
        # Remove all stretches that are associated with this data set
        for key in list(self._stretches.keys()):
            if key[0] == ds_id:
                del self._stretches[key]

        self.dataset_removed.emit(ds_id)
        # self.state_changed.emit(tuple(ObjectType.DATASET, ActionType.REMOVED, dataset))

    def multiple_datasets_same_size(self):
        """
        This function returns True if there are multiple datasets, and they are
        all the same size.  If either of these cases is not true then False is
        returned.
        """
        if len(self._datasets) < 2:
            return False

        datasets = list(self._datasets.values())
        ds0_dim = (datasets[0].get_width(), datasets[0].get_height())
        for ds in datasets[1:]:
            ds_dim = (ds.get_width(), ds.get_height())
            if ds_dim != ds0_dim:
                return False

        return True

    def multiple_datasets_link_compatible(self):
        same_size = self.multiple_datasets_same_size()
        if same_size:
            return same_size, GeographicLinkState.PIXEL

        datasets = list(self._datasets.values())
        ds0_srs = datasets[0].get_spatial_ref()
        if ds0_srs is None:
            return False, GeographicLinkState.NO_LINK

        for ds in datasets[1:]:
            ds_srs = ds.get_spatial_ref()
            if ds_srs is None or not ds0_srs.IsSame(ds_srs):
                return False, GeographicLinkState.NO_LINK

        return True, GeographicLinkState.SPATIAL

    def multiple_displayed_datasets_link_compatible(
        self,
    ) -> Tuple[bool, GeographicLinkState]:
        displayed_datasets = self._app._main_view.get_visible_datasets()

        same_size = True
        if len(displayed_datasets) < 2:
            return False, GeographicLinkState.NO_LINK

        # Else, make sure they're all the same size
        ds0_dim = (
            displayed_datasets[0].get_width(),
            displayed_datasets[0].get_height(),
        )
        for ds in displayed_datasets[1:]:
            ds_dim = (ds.get_width(), ds.get_height())
            if ds_dim != ds0_dim:
                same_size = False

        if same_size:
            return same_size, GeographicLinkState.PIXEL

        ds0_srs: osr.SpatialReference = displayed_datasets[0].get_spatial_ref()
        ds0 = displayed_datasets[0]
        if ds0_srs is None:
            return False, GeographicLinkState.NO_LINK

        for ds in displayed_datasets[1:]:
            ds_srs = ds.get_spatial_ref()
            can_transform = can_transform_between_srs(ds0_srs, ds_srs)
            have_overlap = have_spatial_overlap(
                ds0_srs,
                ds0.get_geo_transform(),
                ds0.get_width(),
                ds0.get_height(),
                ds_srs,
                ds.get_geo_transform(),
                ds.get_width(),
                ds.get_height(),
            )
            if ds_srs is None or not can_transform or not have_overlap:
                return False, GeographicLinkState.NO_LINK

        return True, GeographicLinkState.SPATIAL

    def multiple_displayed_datasets_same_size(self):
        """
        This function returns True if there are multiple visible datasets and
        they are all the same size.
        """
        # Get access to the displayed datasets
        displayed_datasets = self._app._main_view.get_visible_datasets()

        if len(displayed_datasets) < 2:
            return False

        # Else, make sure they're all the same size
        ds0_dim = (
            displayed_datasets[0].get_width(),
            displayed_datasets[0].get_height(),
        )
        for ds in displayed_datasets[1:]:
            ds_dim = (ds.get_width(), ds.get_height())
            if ds_dim != ds0_dim:
                return False

        return True

    def unique_dataset_name(self, candidate: str):
        ds_names = {ds.get_name() for ds in self._datasets.values()}
        ds_names = {name for name in ds_names if name}
        return make_unique_name(candidate, ds_names)

    def unique_roi_name(self, candidate: str):
        roi_names = {roi.get_name() for roi in self._regions_of_interest.values()}
        return make_unique_name(candidate, roi_names)

    def set_stretches(self, ds_id: int, bands: Tuple, stretches: List[StretchBase]):
        if len(bands) != len(stretches):
            raise ValueError(
                "bands and stretches must both be the same "
                + f"length (got {len(bands)} bands, {len(stretches)} stretches)"
            )
        for i in range(len(bands)):
            key = (ds_id, bands[i])
            stretch = stretches[i]
            self._stretches[key] = stretch

        self.stretch_changed.emit(ds_id, bands)

    def get_stretches(self, ds_id: int, bands: Tuple):
        # TODO(donnie):  This comment is not a docstring so it will be excluded
        #     from the auto-generated docs.
        # Returns the current stretches for the specified dataset ID and bands.
        # If a band has no stretch specified, its corresponding value will be
        # ``None``.
        return [self._stretches.get((ds_id, b), None) for b in bands]

    def add_spectral_library(self, library):
        """
        Add a spectral library to the application state, assigning a new ID to
        the library.  The method will fire a signal indicating that the spectral
        library was added, including the ID assigned to the library.
        """
        if not isinstance(library, SpectralLibrary):
            raise TypeError("library must be a SpectralLibrary")

        lib_id = self.take_next_id()
        library.set_id(lib_id)
        self._spectral_libraries[lib_id] = library

        self.spectral_library_added.emit(lib_id)

    def has_spectral_library(self, lib_id: int) -> bool:
        """
        Returns whether the spectral library with the specified numeric ID is in the application state.
        """
        return lib_id in self._spectral_libraries

    def get_spectral_library(self, lib_id):
        """
        Return the spectral library with the specified ID.  If the ID is
        unrecognized, a KeyError will be raised.
        """
        return self._spectral_libraries[lib_id]

    def num_spectral_libraries(self):
        """
        Return the number of spectral libraries in the application state.
        """
        return len(self._spectral_libraries)

    def get_spectral_libraries(self):
        """
        Return a list of all the spectral libraries in the application state.
        """
        return self._spectral_libraries.values()

    def remove_spectral_library(self, lib_id):
        """
        Remove the specified spectral library from the application state.
        The method will fire a signal indicating that the spectral library
        was removed.
        """
        del self._spectral_libraries[lib_id]
        self.spectral_library_removed.emit(lib_id)

    def config(self) -> ApplicationConfig:
        return self._config

    def get_config(self, option: str, default=None, as_type=None):
        """
        Returns the value of the specified config option.  An optional default
        value may be specified.

        Options are specified as a sequence of names separated by dots '.',
        just like a series of object-member accesses on an object hierarchy.
        """
        return self._config.get(option, default, as_type)

    def set_config(self, option, value):
        """
        Sets the value of the specified config option.
        """
        self._config.set(option, value)

    def add_roi(self, roi: RegionOfInterest, make_name_unique=False) -> None:
        """
        Add a Region of Interest to WISER's state.  A ``ValueError`` is raised
        if the ROI does not have a unique name.
        """

        if roi is None:
            raise ValueError("ROI cannot be None")

        if roi.get_name() is None:
            raise ValueError("ROI name cannot be None")

        # Verify that the ROI's name is unique.
        names_in_use = set()
        for existing_roi in self._regions_of_interest.values():
            names_in_use.add(existing_roi.get_name())

        name = roi.get_name()
        if name in names_in_use:
            if make_name_unique:
                i = 2
                while True:
                    u_name = name + "_" + str(i)
                    if u_name not in names_in_use:
                        roi.set_name(u_name)
                        break
                    i += 1

            else:
                raise ValueError(f'A region of interest named "{name}" already exists.')

        # If the ROI doesn't have a color, choose a random color for the ROI
        # that isn't already used.
        if roi.get_color() is None:
            colors_in_use = set()
            for existing_roi in self._regions_of_interest.values():
                colors_in_use.add(existing_roi.get_color())

            color = get_random_matplotlib_color(colors_in_use)
            roi.set_color(color)

        roi_id = self.take_next_id()
        roi.set_id(roi_id)
        self._regions_of_interest[roi_id] = roi
        self.roi_added.emit(roi)

    def remove_roi(self, roi_id: int) -> None:
        """
        Removes the specified Region of Interest from WISER's state.
        A ``KeyError`` is raised if no ROI has the specified ID.
        """
        roi = self._regions_of_interest[roi_id]
        del self._regions_of_interest[roi_id]
        self.roi_removed.emit(roi)

    def get_roi(self, **kwargs) -> Optional[RegionOfInterest]:
        """
        Retrieve a Region of Interest from WISER's state.  The caller may
        specify an ``id`` keyword-argument to retrieve an ROI by ID, or
        a ``name`` keyword-argument to retrieve an ROI by name.  Names are
        case-sensitive.
        """
        if "id" in kwargs:
            return self._regions_of_interest.get(kwargs["id"])

        elif "name" in kwargs:
            for roi in self._regions_of_interest.values():
                if roi.get_name() == kwargs["name"]:
                    return roi
            return None

        else:
            raise KeyError('Must specify either "id" or "name" keyword argument')

    def get_rois(self) -> List[RegionOfInterest]:
        """
        Returns a list of all Regions of Interest in WISER's application state.
        """
        return self._regions_of_interest.values()

    def has_spectrum(self, spectrum_id: int) -> bool:
        """
        Returns whether the spectrum with the specified numeric ID is in the application state.
        """
        return spectrum_id in self._all_spectra

    def get_spectrum(self, spectrum_id: int) -> Spectrum:
        """
        Retrieve a spectrum from WISER's state.  A ``KeyError`` is raised if
        the ID doesn't correspond to a spectrum.
        """
        return self._all_spectra[spectrum_id]

    def get_all_spectra(self):
        """
        Retrieves all spectra in the spectrum plot.
        """
        return self._all_spectra

    def get_active_spectrum(self):
        """
        Retrieve the current active spectrum.  The "active spectrum" is the
        spectrum that the user most recently selected, or it may be the
        output of a band-math expression, plugin, etc. that computes a spectrum.
        """
        return self._active_spectrum

    def set_active_spectrum(self, spectrum: Spectrum):
        """
        Set the current active spectrum to be the specified spectrum.
        The "active spectrum" is the spectrum that the user most recently
        selected, or it may be the output of a band-math expression, plugin,
        etc. that computes a spectrum.
        """

        # If we already have an active spectrum, remove its ID from the mapping.
        if self._active_spectrum:
            del self._all_spectra[self._active_spectrum.get_id()]

        # Assign an ID to this spectrum if it doesn't have one.
        if spectrum is not None and spectrum.get_id() is None:
            id = self.take_next_id()
            spectrum.set_id(id)
            self._all_spectra[id] = spectrum

        # Store it!  Then fire an event.
        self._active_spectrum = spectrum
        self.active_spectrum_changed.emit()

    def collect_spectrum(self, spectrum: Spectrum):
        """
        Add the specified spectrum to the "collected spectra" group.

        Note that the specified spectrum cannot be the current "active
        spectrum"; if it is, a ``RuntimeError`` will be raised.  The
        current "active spectrum" is collected via the
        ``collect_active_spectrum()`` method.
        """

        if spectrum is None:
            raise ValueError("spectrum cannot be None")

        if spectrum is self._active_spectrum:
            raise RuntimeError("Use collect_active_spectrum() to collect the " + "active spectrum")

        # Assign an ID to this spectrum if it doesn't have one.
        if spectrum.get_id() is None:
            spectrum.set_id(self.take_next_id())

        # Store it!  Then fire an event.
        index = len(self._collected_spectra)
        self._collected_spectra.append(spectrum)
        self._all_spectra[spectrum.get_id()] = spectrum
        self.collected_spectra_changed.emit(StateChange.ITEM_ADDED, index, spectrum.get_id())

    def collect_active_spectrum(self):
        """
        Add the current "active spectrum" to the "collected spectra" group.

        A ``RuntimeError`` will be raised if there is no active spectrum when
        this method is called.
        """

        if self._active_spectrum is None:
            raise RuntimeError("There is no active spectrum to collect.")

        spectrum = self._active_spectrum

        # Causes "active spectrum changed" signal to be emitted
        self.set_active_spectrum(None)

        # Causes "collected spectrum changed" signal to be emitted
        self.collect_spectrum(spectrum)

    def get_collected_spectra(self) -> List[Spectrum]:
        """
        Returns the current list of collected spectra.
        """
        return list(self._collected_spectra)

    def remove_collected_spectrum(self, index):
        """
        Removes a collected spectrum from the list of collected spectra.
        The spectrum to remove is specified by a 0-based index.
        """
        id = self._collected_spectra[index].get_id()
        del self._collected_spectra[index]
        del self._all_spectra[id]
        self.collected_spectra_changed.emit(StateChange.ITEM_REMOVED, index, id)

    def remove_all_collected_spectra(self):
        """
        Removes all spectra from the list of collected spectra.
        """
        for s in self._collected_spectra:
            del self._all_spectra[s.get_id()]

        self._collected_spectra.clear()
        self.collected_spectra_changed.emit(StateChange.ITEM_REMOVED, -1, -1)

    def get_user_created_crs(self):
        return self._user_created_crs

    def add_user_created_crs(
        self, name: str, crs: osr.SpatialReference, crs_creator_state: "CrsCreatorState"
    ):
        if name in self._user_created_crs:
            # Ask the user whether to overwrite the existing CRS
            reply = QMessageBox.question(
                None,
                self.tr("CRS Already Exists"),
                self.tr(f"A CRS named “{name}” already exists. Overwrite it?"),
            )
            if reply == QMessageBox.Yes:
                self._user_created_crs[name] = (crs, crs_creator_state)
        else:
            self._user_created_crs[name] = (crs, crs_creator_state)

    # region UI Library Access

    def choose_dataset_ui(
        self,
        description: Optional[str] = None,
        in_test_mode=False,
    ) -> Optional[RasterDataSet]:
        self._plugin_dataset_chooser_dialog = DatasetChooserDialog(
            app_state=self,
            description=description,
            parent=self._app,
        )
        if in_test_mode:
            self._plugin_dataset_chooser_dialog.show()
        else:
            if self._plugin_dataset_chooser_dialog.exec_() == QDialog.Accepted:
                return self._plugin_dataset_chooser_dialog.get_chosen_object()

    def choose_spectrum_ui(
        self,
        description: Optional[str] = None,
        in_test_mode=False,
    ) -> Optional[Spectrum]:
        self._plugin_spectrum_chooser_dialog = SpectrumChooserDialog(
            app_state=self,
            description=description,
            parent=self._app,
        )

        if in_test_mode:
            self._plugin_spectrum_chooser_dialog.show()
        else:
            if self._plugin_spectrum_chooser_dialog.exec_() == QDialog.Accepted:
                return self._plugin_spectrum_chooser_dialog.get_chosen_object()

    def choose_roi_ui(
        self,
        description: Optional[str] = None,
        in_test_mode=False,
    ) -> Optional[RegionOfInterest]:
        self._plugin_roi_chooser_dialog = ROIChooserDialog(
            app_state=self,
            description=description,
            parent=self._app,
        )
        if in_test_mode:
            self._plugin_roi_chooser_dialog.show()
        else:
            if self._plugin_roi_chooser_dialog.exec_() == QDialog.Accepted:
                return self._plugin_roi_chooser_dialog.get_chosen_object()

    def choose_band_ui(
        self,
        description: Optional[str] = None,
        in_test_mode=False,
    ) -> Optional[RasterDataBand]:
        self._plugin_band_chooser_dialog = BandChooserDialog(
            app_state=self,
            description=description,
            parent=self._app,
        )
        if in_test_mode:
            self._plugin_band_chooser_dialog.show()
        else:
            if self._plugin_band_chooser_dialog.exec_() == QDialog.Accepted:
                return self._plugin_band_chooser_dialog.get_chosen_object()

    def create_form(
        self,
        form_inputs: List[Tuple[str, str, int, Optional[List[Any]]]],
        title: Optional[str] = None,
        description: Optional[str] = None,
        in_test_mode=False,
    ) -> Optional[Dict[str, Any]]:
        self._dynamic_input_dialog = DynamicInputDialog(
            dialog_title=title,
            description=description,
            parent=self._app,
        )

        if in_test_mode:
            self._dynamic_input_dialog.show()
        else:
            return self._dynamic_input_dialog.create_input_dialog(form_inputs)

    def show_spectra_in_plot(
        self,
        spectra: List[Spectrum],
        plot_title: Optional[str] = None,
        parent=None,
    ):
        """
        Takes the list of spectra passed in and displays it in a generic
        spectrum plot.
        """
        if parent is None:
            generic_spectrum_plot = SpectrumPlotGeneric(
                app_state=self,
                parent=self._app,
            )
        else:
            generic_spectrum_plot = SpectrumPlotGeneric(
                app_state=self,
                parent=parent,
            )
        if plot_title is not None:
            generic_spectrum_plot.set_title(plot_title)
        for spectrum in spectra:
            if spectrum.get_id() is None:
                spectrum.set_id(self.take_next_id())
            generic_spectrum_plot.add_collected_spectrum(spectrum)
        self._generic_spectrum_plots.add(generic_spectrum_plot)

        # We keep a reference to generic_spectrum_plot so it doesn't get garbage collected
        generic_spectrum_plot.closed.connect(
            lambda: self._on_generic_spectrum_plot_closed(generic_spectrum_plot)
        )

        generic_spectrum_plot.show()
        generic_spectrum_plot.raise_()
        generic_spectrum_plot.activateWindow()

    def _on_generic_spectrum_plot_closed(self, spectrum_plot: SpectrumPlotGeneric):
        self._generic_spectrum_plots.remove(spectrum_plot)
        spectrum_plot.deleteLater()

    def show_table_widget(
        self,
        header: List[str],
        rows: List[List[Any]],
        window_title: Optional[str] = None,
        description: Optional[str] = None,
    ):
        """Creates and shows a table widget that is meant for display."""
        if len(header) != len(rows[0]):
            QMessageBox.warning(
                self._app,
                self.tr("Error!"),
                self.tr("Number of columns must match number of items in header!"),
            )
        # Do not pass self._app as the parent of TableDisplayWidget
        table_display_widget: TableDisplayWidget = TableDisplayWidget()
        table_display_widget.create_table(
            header=header,
            rows=rows,
            title=window_title,
            description=description,
        )

        # We keep a reference to table_display_widget so it doesn't get garbage collected
        self._table_display_widgets.add(table_display_widget)
        table_display_widget.closed.connect(
            lambda: self._on_table_display_widget_closed(table_display_widget)
        )

        table_display_widget.show()

    def _on_table_display_widget_closed(self, table_display_widget: TableDisplayWidget):
        self._table_display_widgets.remove(table_display_widget)
        table_display_widget.deleteLater()

    def show_matplotlib_display_widget(
        self,
        figure: Figure,
        axes: Axes,
        window_title: Optional[str] = None,
        description: Optional[str] = None,
    ):
        """Creates and shows a widget with a matplotlib plot"""
        matplotlib_display = MatplotlibDisplayWidget()
        matplotlib_display.create_plot(
            figure=figure,
            axes=axes,
            window_title=window_title,
            description=description,
        )

        self._matplotlib_display_widgets.add(matplotlib_display)

        matplotlib_display.closed.connect(
            lambda: self._on_matplotlib_display_widget_closed(matplotlib_display)
        )

        matplotlib_display.show()

    def _on_matplotlib_display_widget_closed(self, matplotlib_display: MatplotlibDisplayWidget):
        self._matplotlib_display_widgets.remove(matplotlib_display)
        matplotlib_display.deleteLater()
