# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see guidata/LICENSE for details)
#
# The array editor subpackage was derived from Spyder's arrayeditor.py module
# which is licensed under the terms of the MIT License (see spyder/__init__.py
# for details), copyright © Spyder Project Contributors

# pylint: disable=C0103
# pylint: disable=R0903
# pylint: disable=R0911
# pylint: disable=R0201

# ruff: noqa

"""Array editor widget"""

from __future__ import annotations

import io
from typing import Any, Generic, Sequence, cast
from qtpy.compat import getsavefilename

import numpy as np
from qtpy.QtCore import QItemSelection, QItemSelectionRange, QLocale, QPoint, Qt, Slot
from qtpy.QtGui import QCursor, QDoubleValidator, QKeySequence
from qtpy.QtWidgets import (
    QAbstractItemDelegate,
    QApplication,
    QCheckBox,
    QHBoxLayout,
    QInputDialog,
    QItemDelegate,
    QLineEdit,
    QMenu,
    QMessageBox,
    QPushButton,
    QShortcut,
    QTableView,
    QVBoxLayout,
    QWidget,
)

from guidata.config import CONF, _
from guidata.configtools import get_font, get_icon
from guidata.qthelpers import add_actions, create_action, keybinding
from guidata.widgets import about
from guidata.widgets.arrayeditor import utils
from guidata.widgets.arrayeditor.arrayhandler import (
    AnySupportedArray,
    BaseArrayHandler,
    MaskedArrayHandler,
    RecordArrayHandler,
)
from guidata.widgets.arrayeditor.datamodel import (
    ArrayModelType,
    BaseArrayModel,
    DataArrayModel,
    MaskArrayModel,
    MaskedArrayModel,
    RecordArrayModel,
)


class ArrayDelegate(QItemDelegate):
    """Array Editor Item Delegate

    Args:
        dtype: Numpy's dtype of the array to edit
        parent: parent QObject
    """

    def __init__(self, dtype: np.dtype, parent=None) -> None:
        QItemDelegate.__init__(self, parent)
        self.dtype = dtype

    def createEditor(self, parent, option, index) -> QLineEdit | None:
        """Create editor widget"""
        model: BaseArrayModel = index.model()  # type: ignore
        value = model.get_value((index.row(), index.column()))
        if model.get_array().dtype.name == "bool":
            value = not value
            model.setData(index, value)
            return None
        if value is not np.ma.masked:
            editor = QLineEdit(parent)
            editor.setFont(get_font(CONF, "arrayeditor", "font"))
            editor.setAlignment(Qt.AlignmentFlag.AlignCenter)
            if utils.is_number(self.dtype):
                validator = QDoubleValidator(editor)
                validator.setLocale(QLocale("C"))
                editor.setValidator(validator)
            editor.returnPressed.connect(self.commitAndCloseEditor)
            return editor
        return None

    def commitAndCloseEditor(self) -> None:
        """Commit and close editor"""
        editor = self.sender()
        # Avoid a segfault with PyQt5. Variable value won't be changed
        # but at least Spyder won't crash. It seems generated by a bug in sip.
        try:
            self.commitData.emit(editor)
        except AttributeError as e:
            print(e)
            pass
        self.closeEditor.emit(editor, QAbstractItemDelegate.EndEditHint.NoHint)

    def setEditorData(self, editor, index) -> None:
        """Set editor widget's data"""
        if (model := index.model()) is not None and editor is not None:
            text = model.data(index, Qt.ItemDataRole.DisplayRole)
            editor.setText(text)


class DefaultValueDelegate(QItemDelegate):
    """Array Editor Item Delegate

    Args:
        dtype: Numpy's dtype of the array to edit
        parent: parent QObject
    """

    def __init__(self, dtype: np.dtype, parent=None) -> None:
        QItemDelegate.__init__(self, parent)
        self.dtype = dtype
        (self.default_value,) = np.zeros(1, dtype=dtype)

    def createEditor(self, parent, option, index) -> QLineEdit:
        """Create editor widget"""
        editor = QLineEdit(parent)
        editor.setFont(get_font(CONF, "arrayeditor", "font"))
        editor.setAlignment(Qt.AlignmentFlag.AlignCenter)
        if utils.is_number(self.dtype):
            validator = QDoubleValidator(editor)
            validator.setLocale(QLocale("C"))
            editor.setValidator(validator)
        editor.returnPressed.connect(self.commitAndCloseEditor)
        return editor

    def commitAndCloseEditor(self):
        """Commit and close editor"""
        editor = self.sender()
        # Avoid a segfault with PyQt5. Variable value won't be changed
        # but at least Spyder won't crash. It seems generated by a bug in sip.
        try:
            self.commitData.emit(editor)
        except AttributeError as e:
            print(e)
            pass
        self.closeEditor.emit(editor, QAbstractItemDelegate.EndEditHint.NoHint)

    def setEditorData(self, editor, index):
        """Set editor widget's data"""
        if (model := index.model()) is not None and editor is not None:
            text = model.data(index, Qt.ItemDataRole.DisplayRole)
            editor.setText(text)


# TODO: Implement "Paste" (from clipboard) feature
class ArrayView(QTableView, Generic[ArrayModelType]):
    """Array view class

    Args:
        parent: parent QObject
        model: BaseArrayModel to use
        dtype: Numpy's dtype of the array to edit
        shape: Numpy's shape of the array to edit
        variable_size: Flag to indicate if the array dimensions can be modified.
         If a BaseArrayHandler is given as input, the handler should also be in
         readonly mode. Defaults to False.
    """

    def __init__(
        self, parent: QWidget, model: ArrayModelType, dtype, shape, variable_size=False
    ) -> None:
        QTableView.__init__(self, parent)
        self._variable_size = variable_size

        self.setModel(model)
        self.setItemDelegate(ArrayDelegate(dtype, self))
        total_width = 0
        for k in range(self.model().shape[1]):
            total_width += self.columnWidth(k)
        self.viewport().resize(min(total_width, 1024), self.height())
        QShortcut(QKeySequence(QKeySequence.Copy), self, self.copy)
        QShortcut(QKeySequence(QKeySequence.Paste), self, self.paste)
        self.horizontalScrollBar().valueChanged.connect(
            lambda val: self.load_more_data(val, columns=True)
        )
        self.verticalScrollBar().valueChanged.connect(
            lambda val: self.load_more_data(val, rows=True)
        )

        if self._variable_size:
            self._current_row_index = None
            self.vheader_menu = self.setup_header_menu(0)
            vheader = self.verticalHeader()
            vheader.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
            vheader.customContextMenuRequested.connect(self.verticalHeaderContextMenu)

            self._current_col_index = None
            self.hheader_menu = self.setup_header_menu(1)
            hheader = self.horizontalHeader()
            hheader.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
            hheader.customContextMenuRequested.connect(self.horizontalHeaderContextMenu)

        self.cell_menu = self.setup_cell_menu()
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self.cellContextMenu)

    def model(self) -> ArrayModelType:
        """Returns the current BaseArrayModel or raises an TypeError

        Raises:
            ValueError: if the model is not a BaseArrayModel

        Returns:
            Current array model
        """
        assert isinstance(model := super().model(), BaseArrayModel)
        return cast(ArrayModelType, model)

    def load_more_data(self, value: int, rows=False, columns=False) -> None:
        """Load more data if needed

        Args:
            value: scrollbar value
            rows: Flag to indicate if rows should be loaded
            columns: Flag to indicate if columns should be loaded
        """
        old_selection = self.selectionModel().selection()
        old_rows_loaded = old_cols_loaded = None

        if rows and value == self.verticalScrollBar().maximum():
            old_rows_loaded = self.model().rows_loaded
            self.model().fetch(rows=rows)

        if columns and value == self.horizontalScrollBar().maximum():
            old_cols_loaded = self.model().cols_loaded
            self.model().fetch(columns=columns)

        if old_rows_loaded is not None or old_cols_loaded is not None:
            # if we've changed anything, update selection
            new_selection = QItemSelection()
            for part in old_selection:
                top = part.top()
                bottom = part.bottom()
                if (
                    old_rows_loaded is not None
                    and top == 0
                    and bottom == (old_rows_loaded - 1)
                ):
                    # complete column selected (so expand it to match updated range)
                    bottom = self.model().rows_loaded - 1
                left = part.left()
                right = part.right()
                if (
                    old_cols_loaded is not None
                    and left == 0
                    and right == (old_cols_loaded - 1)
                ):
                    # compete row selected (so expand it to match updated range)
                    right = self.model().cols_loaded - 1
                top_left = self.model().index(top, left)
                bottom_right = self.model().index(bottom, right)
                part = QItemSelectionRange(top_left, bottom_right)
                new_selection.append(part)
            self.selectionModel().select(
                new_selection, self.selectionModel().ClearAndSelect
            )

    def insert_row(self) -> None:
        """Insert row(s) in the array."""
        if (i := self._current_row_index) is not None:
            (
                i,
                insert_number,
                default_values,
                _new_label,
                valid,
            ) = self.ask_default_inserted_value(i, 0)
            if valid:
                self.model().insert_row(i, insert_number, *default_values)
            self._current_row_index = None

    def remove_row(self) -> None:
        """Remove row(s) in the array"""
        if (i := self._current_row_index) is not None:
            i, remove_number, valid = self.ask_rows_cols_to_remove(i, 0)
            if valid:
                self.model().remove_row(i, remove_number)
            self._current_row_index = None

    def insert_col(self) -> None:
        """Insert column(s) in the array"""
        if (j := self._current_col_index) is not None:
            (
                j,
                insert_number,
                default_value,
                _new_label,
                valid,
            ) = self.ask_default_inserted_value(j, 1)
            if valid:
                self.model().insert_column(j, insert_number, *default_value)
            self._current_col_index = None

    def remove_col(self) -> None:
        """Remove column(s) in the array"""
        if (j := self._current_col_index) is not None:
            j, remove_number, valid = self.ask_rows_cols_to_remove(j, 1)
            if valid:
                self.model().remove_column(j, remove_number)
            self._current_col_index = None

    def ask_default_inserted_value(
        self, index: int, axis: int
    ) -> tuple[int, int, tuple[Any, ...], Any | None, bool]:
        """Create and open a new dialog with a form to input insertion parameters

        Args:
            index: default insertion index (choice made when clicking)
            axis: insertion axis (row (0) or column (1))

        Returns:
            User inputs
        """
        InsertionDataSet = self.model().get_insertion_dataset(index, axis)
        title = (
            _("Row(s) insertion")
            if axis == 0
            else _("Column(s) insertion")
            if axis == 1
            else ""
        )
        insertion_dataset = InsertionDataSet(
            title=title,
            icon="insert.png",
        )

        is_ok = insertion_dataset.edit()
        index_: int = insertion_dataset.index_field
        max_index = (
            self.model()
            .get_array()
            .shape[self.model().correct_ndim_axis_for_current_slice(axis)]
        )
        index_ = max_index if index_ == -1 else index_
        return (
            index_,
            insertion_dataset.insert_number,
            insertion_dataset.get_values_to_insert(),
            insertion_dataset.new_label,
            is_ok,
        )  # type: ignore

    def ask_rows_cols_to_remove(self, index: int, axis: int) -> tuple[int, int, bool]:
        """Create and open a new dialog with a form to input deletion parameters

        Args:
            index: default deletion index (choice made when clicking)
            axis: deletion axis (row (0) or column (1))

        Returns:
            User inputs
        """
        DeletionDataSet = self.model().get_deletion_dataset(index, axis)
        title = (
            _("Row(s) deletion")
            if axis == 0
            else _("Column(s) deletion")
            if axis == 1
            else ""
        )
        deletion_dataset = DeletionDataSet(
            title=title,
            icon="delete.png",
        )
        is_ok = deletion_dataset.edit()
        index_ = deletion_dataset.index_field
        max_index = (
            self.model()
            .get_array()
            .shape[self.model().correct_ndim_axis_for_current_slice(axis)]
        )
        number_to_del = min(deletion_dataset.remove_number, max_index - index_)
        return index_, number_to_del, is_ok  # type: ignore

    def resize_to_contents(self) -> None:
        """Resize cells to contents"""
        QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
        self.resizeColumnsToContents()
        self.model().fetch(columns=True)
        self.resizeColumnsToContents()
        QApplication.restoreOverrideCursor()

    def setup_cell_menu(self) -> QMenu:
        """Setup context menu

        Returns:
            New QMenu object
        """
        self.copy_action = create_action(
            self,
            _("Copy"),
            shortcut=keybinding("Copy"),
            icon=get_icon("editcopy.png"),
            triggered=self.copy,
            context=Qt.ShortcutContext.WidgetShortcut,
        )
        self.paste_action = create_action(
            self,
            _("Paste"),
            shortcut=keybinding("Paste"),
            icon=get_icon("editpaste.png"),
            triggered=self.paste,
            context=Qt.ShortcutContext.WidgetShortcut,
        )
        self.paste_action.setDisabled(self.model().readonly)
        about_action = create_action(
            self,
            _("About..."),
            icon=get_icon("guidata.svg"),
            triggered=about.show_about_dialog,
        )
        if self._variable_size:
            insert_row_action = create_action(
                self,
                title=_("Insert row(s)"),
                icon=get_icon("insert.png"),
                triggered=self.insert_row,
            )
            insert_col_action = create_action(
                self,
                title=_("Insert column(s)"),
                icon=get_icon("insert.png"),
                triggered=self.insert_col,
            )
            remove_row_action = create_action(
                self,
                title=_("Remove row(s)"),
                icon=get_icon("delete.png"),
                triggered=self.remove_row,
            )
            remove_col_action = create_action(
                self,
                title=_("Remove column(s)"),
                icon=get_icon("delete.png"),
                triggered=self.remove_col,
            )
            actions = (
                self.copy_action,
                self.paste_action,
                None,
                insert_row_action,
                insert_col_action,
                None,
                remove_row_action,
                remove_col_action,
                None,
                about_action,
            )
        else:
            actions = (
                self.copy_action,
                self.paste_action,
                None,
                about_action,
            )
        menu = QMenu(self)
        add_actions(menu, actions)
        return menu

    def setup_header_menu(self, axis: int) -> QMenu:
        """Creates and return a contextual menu for a header depending on input axis.

        Args:
            axis: 0 for rows (vertical header), 1 for columns (horizontal header)

        Returns:
            New QMenu object for the given header
        """
        action_args = {
            0: (
                (_("Insert row(s)"), self.insert_row),
                (_("Remove row(s)"), self.remove_row),
            ),
            1: (
                (_("Insert column(s)"), self.insert_col),
                (_("Remove column(s)"), self.remove_col),
            ),
        }[axis]
        insert_action = create_action(
            self,
            title=action_args[0][0],
            icon=get_icon("insert.png"),
            triggered=action_args[0][1],
        )
        remove_action = create_action(
            self,
            title=action_args[1][0],
            icon=get_icon("delete.png"),
            triggered=action_args[1][1],
        )
        actions = (
            insert_action,
            None,
            remove_action,
        )
        menu = QMenu(self)
        add_actions(menu, actions)
        return menu

    def verticalHeaderContextMenu(self, pos: QPoint) -> None:
        """Reimplement Qt method"""
        vheader = self.verticalHeader()
        self._current_row_index = vheader.logicalIndexAt(pos)
        self.vheader_menu.popup(vheader.mapToGlobal(pos))

    def horizontalHeaderContextMenu(self, pos: QPoint) -> None:
        """Reimplement Qt method"""
        hheader = self.horizontalHeader()
        self._current_col_index = hheader.logicalIndexAt(pos)
        self.hheader_menu.popup(hheader.mapToGlobal(pos))

    def cellContextMenu(self, pos: QPoint) -> None:
        """Reimplement Qt method"""
        try:
            selected_index = self.selectedIndexes()[0]
            self._current_row_index, self._current_col_index = (
                selected_index.row(),
                selected_index.column(),
            )
        except IndexError:  # click outside of cells
            self._current_row_index, self._current_col_index = (
                self.model().total_rows,
                self.model().total_cols,
            )  # we get the index of the last array element to insert after the last row/column

        self.cell_menu.popup(self.viewport().mapToGlobal(pos))

    def keyPressEvent(self, event) -> None:
        """Reimplement Qt method"""
        if event == QKeySequence.Copy:
            self.copy()
        else:
            QTableView.keyPressEvent(self, event)

    def _sel_to_text(self, cell_range: list[QItemSelectionRange]) -> str | None:
        """Copy an array portion to a unicode string

        Args:
            cell_range: list of QItemSelectionRange objects

        Returns:
            String representation of the selected array portion, or None if
             the selection is empty
        """
        if not cell_range:
            return None
        model = self.model()
        row_min, row_max, col_min, col_max = utils.get_idx_rect(cell_range)
        if col_min == 0 and col_max == (model.cols_loaded - 1):
            # we've selected a whole column. It isn't possible to
            # select only the first part of a column without loading more,
            # so we can treat it as intentional and copy the whole thing
            col_max = model.total_cols - 1
        if row_min == 0 and row_max == (model.rows_loaded - 1):
            row_max = model.total_rows - 1

        model.apply_changes()
        _data = model.get_array()  # TODO check if this should apply changes or not
        output = io.BytesIO()

        try:
            np.savetxt(
                output,
                _data[row_min : row_max + 1, col_min : col_max + 1],
                delimiter="\t",
                fmt=model.get_format(),
            )
        except BaseException:
            QMessageBox.warning(
                self,
                _("Warning"),
                _("It was not possible to copy values for this array"),
            )
            return None
        contents = output.getvalue().decode("utf-8")
        output.close()
        return contents

    @Slot()
    def copy(self) -> None:
        """Copy text to clipboard"""
        cliptxt = self._sel_to_text(self.selectedIndexes())
        clipboard = QApplication.clipboard()
        clipboard.setText(cliptxt)

    @Slot()
    def paste(self) -> None:
        """Paste text from clipboard"""
        cliptxt = QApplication.clipboard().text()
        if not cliptxt:
            return
        try:
            data = np.genfromtxt(io.StringIO(cliptxt), delimiter="\t")
        except ValueError:
            data = np.array([])
        if data.size == 0:
            QMessageBox.warning(
                self,
                _("Warning"),
                _("It was not possible to paste values for this array"),
            )
            return

        model = self.model()

        # Determine where to paste
        start_row = self.currentIndex().row()
        start_col = self.currentIndex().column()

        # Iterate and paste each value
        data = np.array(data, dtype=model.get_array().dtype)
        if data.ndim in (0, 1):
            data = data.reshape((-1, 1))
        for i in range(data.shape[0]):
            for j in range(data.shape[1]):
                if (start_row + i < model.total_rows) and (
                    start_col + j < model.total_cols
                ):
                    idx = model.index(start_row + i, start_col + j)
                    model.setData(idx, str(data[i, j]))

        model.dataChanged.emit(
            model.index(start_row, start_col),
            model.index(start_row + data.shape[0] - 1, start_col + data.shape[1] - 1),
        )


class BaseArrayEditorWidget(QWidget):
    """Base ArrayEditdorWidget class. Used to wrap handle n-dimensional normal Numpy's
    ndarray.

    Args:
        parent: parent QObject
        data: Numpy's ndarray or BaseArrayHandler to use.
        readonly: Flag for readonly mode. Defaults to False.
        xlabels: labels for the columns (header). Defaults to None.
        ylabels: labels for the rows (header). Defaults to None.
        variable_size: Flag to indicate if the array dimensions can be modified.
         If a BaseArrayHandler is given as input, the handler should also be in
         readonly mode Defaults to False.
        current_slice: slice of the same dimension as the Numpy ndarray that will.
         Defaults to None
    """

    def __init__(
        self,
        parent,
        data: np.ndarray | BaseArrayHandler,
        readonly=False,
        xlabels=None,
        ylabels=None,
        variable_size=False,
        current_slice: Sequence[slice | int] | None = None,
    ) -> None:
        QWidget.__init__(self, parent)

        self._variable_size = variable_size and not readonly
        self._data: BaseArrayHandler | MaskedArrayHandler
        self._init_handler(data)
        self._init_model(xlabels, ylabels, readonly, current_slice=current_slice)

        format = utils.SUPPORTED_FORMATS.get(self.model.get_array().dtype.name, "%s")
        self.model.set_format(format)
        self.view = ArrayView(
            self,
            self.model,
            self.model.get_array().dtype,
            self._data.shape,
            self._variable_size,
        )

        btn_layout = QHBoxLayout()
        btn_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
        btn = QPushButton(get_icon("format.svg"), _("Format"), self)
        # disable format button for int type
        btn.setEnabled(utils.is_float(self._data.dtype))
        btn_layout.addWidget(btn)
        btn.clicked.connect(self.change_format)
        btn = QPushButton(get_icon("resize.svg"), _("Resize"), self)
        btn_layout.addWidget(btn)
        btn.clicked.connect(self.view.resize_to_contents)
        bgcolor = QCheckBox(_("Background color"))
        bgcolor.setChecked(self.model.bgcolor_enabled)
        bgcolor.setEnabled(self.model.bgcolor_enabled)
        bgcolor.stateChanged.connect(self.model.bgcolor)
        btn_layout.addWidget(bgcolor)
        btn_layout.addStretch(1)
        btn = QPushButton(get_icon("copy_all.svg"), _("Copy all"), self)
        btn.setToolTip(_("Copy all array data to clipboard"))
        btn.clicked.connect(self.copy_all_to_clipboard)
        btn_layout.addWidget(btn)
        btn = QPushButton(get_icon("export.svg"), _("Export"), self)
        btn.setToolTip(_("Export array to a file"))
        btn_layout.addWidget(btn)
        btn.clicked.connect(self.export_array)

        layout = QVBoxLayout()
        layout.addWidget(self.view)
        layout.addLayout(btn_layout)
        self.setLayout(layout)

    def _init_handler(
        self, data: AnySupportedArray | BaseArrayHandler[AnySupportedArray]
    ) -> None:
        """Initializes and set the instance handler to use

        Args:
            data: Numpy's ndarray or BaseArrayHandler to use.
        """
        if isinstance(data, np.ndarray):
            self._data = BaseArrayHandler[AnySupportedArray](data, self._variable_size)
        elif isinstance(data, BaseArrayHandler):
            self._data = data
        else:
            raise TypeError(
                "Given data must be of type np.ndarray or BaseArrayHandler, "
                f"not {type(data)}"
            )

    def _init_model(
        self,
        xlabels: Sequence[str] | None,
        ylabels: Sequence[str] | None,
        readonly: bool,
        current_slice: Sequence[slice | int] | None = None,
    ) -> None:
        """Initializes and set the instance model to use

        Args:
            xlabels: labels for the columns (header). Defaults to None.
            ylabels: labels for the rows (header). Defaults to None.
            readonly: Flag for readonly mode. Defaults to False.
            current_slice: slice of the same dimension as the Numpy ndarray that will.
        """
        self.model = BaseArrayModel(
            self._data,
            xlabels=xlabels,
            ylabels=ylabels,
            readonly=readonly,
            parent=self,
            current_slice=current_slice,
        )

    def accept_changes(self) -> None:
        """Accept changes"""
        self.model.apply_changes()

    def reject_changes(self) -> None:
        """Reject changes"""
        self.model.clear_changes()

    def change_format(self) -> None:
        """Change display format"""
        format, valid = QInputDialog.getText(
            self,
            _("Format"),
            _("Float formatting"),
            QLineEdit.Normal,
            self.model.get_format(),
        )
        if valid:
            format = str(format)
            try:
                format % 1.1
            except BaseException:
                QMessageBox.critical(
                    self, _("Error"), _("Format (%s) is incorrect") % format
                )
                return
            self.model.set_format(format)

    def copy_all_to_clipboard(self) -> None:
        """Copy all array data, including headers, to clipboard"""
        data = self.model.get_array()
        xlabels = self.model.xlabels
        ylabels = self.model.ylabels

        output = io.StringIO()
        c0sep = "" if ylabels is None else "\t"

        # Write column headers
        if xlabels:
            output.write(c0sep + "\t".join(xlabels) + "\n")

        # Write rows with row headers
        for i, row in enumerate(data):
            label = ylabels[i] if ylabels else ""
            output.write(label + c0sep + "\t".join(map(str, row)) + "\n")

        cliptxt = output.getvalue()
        output.close()

        if cliptxt:
            clipboard = QApplication.clipboard()
            clipboard.setText(cliptxt)

    def export_array(self) -> None:
        """Export the array to a CSV file with UTF-8 BOM."""
        filename, _selfilter = getsavefilename(
            self, _("Export array"), "", _("CSV Files") + " (*.csv)"
        )
        if not filename:
            return

        data = self.model.get_array()
        xlabels = self.model.xlabels
        ylabels = self.model.ylabels

        with open(filename, "w", encoding="utf-8-sig") as file:
            c0sep = "" if ylabels is None else "\t"

            # Write column headers
            if xlabels:
                file.write(c0sep + "\t".join(xlabels) + "\n")

            # Write rows
            for i, row in enumerate(data):
                label = ylabels[i] if ylabels else ""
                file.write(label + c0sep + "\t".join(map(str, row)) + "\n")

        if not (xlabels or ylabels):
            # If no labels, overwrite file with simple array
            np.savetxt(
                filename,
                data,
                delimiter="\t",
                fmt=self.model.get_format(),
                encoding="utf-8-sig",
            )


class MaskedArrayEditorWidget(BaseArrayEditorWidget):
    """Same as BaseArrayWidgetEditorWidget but specifically handles MaskedArrayHandler
    and MaskedArrayModel. Specifically the masked data.

    Args:
        parent: parent QObject
        data: Numpy's ndarray or BaseArrayHandler to use.
        readonly: Flag for readonly mode. Defaults to False.
        xlabels: labels for the columns (header). Defaults to None.
        ylabels: labels for the rows (header). Defaults to None.
        variable_size: Flag to indicate if the array dimensions can be modified.
         If a BaseArrayHandler is given as input, the handler should also be in
         readonly mode Defaults to False.
        current_slice: slice of the same dimension as the Numpy ndarray that will.
         Defaults to None
    """

    # _data: MaskedArrayHandler

    def _init_handler(self, data: np.ma.MaskedArray | MaskedArrayHandler) -> None:
        """Initializes and set the instance handler to use

        Args:
            data: Numpy's MaskedArray or MaskedArrayHandler to use.
        """
        if isinstance(data, np.ma.MaskedArray):
            self._data = MaskedArrayHandler(data, self._variable_size)
        elif isinstance(data, MaskedArrayHandler):
            self._data = data
        else:
            raise TypeError(
                "Given data must be of type np.ma.MaskedArray or "
                "MaskedArrayHandler, not {type(data)}"
            )

    def _init_model(
        self,
        xlabels: Sequence[str] | None,
        ylabels: Sequence[str] | None,
        readonly: bool,
        current_slice: Sequence[slice | int] | None = None,
    ) -> None:
        """Initializes and set the instance model to use

        Args:
            xlabels: labels for the columns (header). Defaults to None.
            ylabels: labels for the rows (header). Defaults to None.
            readonly: Flag for readonly mode. Defaults to False.
            current_slice: slice of the same dimension as the Numpy ndarray that will.
        """
        assert isinstance(self._data, MaskedArrayHandler)
        self.model = MaskedArrayModel(
            self._data,
            xlabels=xlabels,
            ylabels=ylabels,
            readonly=readonly,
            parent=self,
            current_slice=current_slice,
        )


class MaskArrayEditorWidget(MaskedArrayEditorWidget):
    """Same as BaseArrayWidgetEditorWidget but specifically handles MaskedArrayHandler
    and MaskArrayModel. Specifically the boolean mask.

    Args:
        parent: parent QObject
        data: Numpy's ndarray or BaseArrayHandler to use.
        readonly: Flag for readonly mode. Defaults to False.
        xlabels: labels for the columns (header). Defaults to None.
        ylabels: labels for the rows (header). Defaults to None.
        variable_size: Flag to indicate if the array dimensions can be modified.
         If a BaseArrayHandler is given as input, the handler should also be in
         readonly mode Defaults to False.
        current_slice: slice of the same dimension as the Numpy ndarray that will.
         Defaults to None
    """

    # _data: MaskedArrayHandler

    def _init_model(
        self,
        xlabels: Sequence[str] | None,
        ylabels: Sequence[str] | None,
        readonly: bool,
        current_slice: Sequence[slice | int] | None = None,
    ) -> None:
        """Initializes and set the instance model to use

        Args:
            xlabels: labels for the columns (header). Defaults to None.
            ylabels: labels for the rows (header). Defaults to None.
            readonly: Flag for readonly mode. Defaults to False.
            current_slice: slice of the same dimension as the Numpy ndarray that will.
        """
        assert isinstance(self._data, MaskedArrayHandler)
        self.model = MaskArrayModel(
            self._data,
            xlabels=xlabels,
            ylabels=ylabels,
            readonly=readonly,
            parent=self,
            current_slice=current_slice,
        )


class DataArrayEditorWidget(MaskedArrayEditorWidget):
    """Same as BaseArrayWidgetEditorWidget but specifically handles MaskedArrayHandler
    and DataArrayModel. Specifically the raw unmasked data.

    Args:
        parent: parent QObject
        data: Numpy's ndarray or BaseArrayHandler to use.
        readonly: Flag for readonly mode. Defaults to False.
        xlabels: labels for the columns (header). Defaults to None.
        ylabels: labels for the rows (header). Defaults to None.
        variable_size: Flag to indicate if the array dimensions can be modified.
         If a BaseArrayHandler is given as input, the handler should also be in
         readonly mode Defaults to False.
        current_slice: slice of the same dimension as the Numpy ndarray that will.
         Defaults to None
    """

    # _data: MaskedArrayHandler

    def __init__(
        self,
        parent: QWidget,
        data: np.ma.MaskedArray | MaskedArrayHandler,
        readonly=False,
        xlabels=None,
        ylabels=None,
        variable_size=False,
        current_slice: Sequence[slice | int] | None = None,
    ) -> None:
        super().__init__(
            parent, data, readonly, xlabels, ylabels, variable_size, current_slice
        )

    def _init_model(
        self,
        xlabels: Sequence[str] | None,
        ylabels: Sequence[str] | None,
        readonly: bool,
        current_slice: Sequence[slice | int] | None = None,
    ) -> None:
        """Initializes and set the instance model to use

        Args:
            xlabels: labels for the columns (header). Defaults to None.
            ylabels: labels for the rows (header). Defaults to None.
            readonly: Flag for readonly mode. Defaults to False.
            current_slice: slice of the same dimension as the Numpy ndarray that will.
        """
        assert isinstance(self._data, MaskedArrayHandler)
        self.model = DataArrayModel(
            self._data,
            xlabels=xlabels,
            ylabels=ylabels,
            readonly=readonly,
            parent=self,
            current_slice=current_slice,
        )


class RecordArrayEditorWidget(BaseArrayEditorWidget):
    """Same as BaseArrayWidgetEditorWidget but specifically handles RecordArrayHandler
    and RecordArrayModel which are made to wrap Numpy's structured arrays.

    Args:
        parent: parent QObject
        data: Numpy's ndarray or BaseArrayHandler to use.
        readonly: Flag for readonly mode. Defaults to False.
        xlabels: labels for the columns (header). Defaults to None.
        ylabels: labels for the rows (header). Defaults to None.
        variable_size: Flag to indicate if the array dimensions can be modified.
         If a BaseArrayHandler is given as input, the handler should also be in
         readonly mode Defaults to False.
        current_slice: slice of the same dimension as the Numpy ndarray that will.
         Defaults to None
    """

    def __init__(
        self,
        parent,
        data: RecordArrayHandler,
        dtype_name: str,
        readonly=False,
        xlabels=None,
        ylabels=None,
        variable_size=False,
        current_slice: Sequence[slice | int] | None = None,
    ) -> None:
        self._dtype_name = dtype_name
        super().__init__(
            parent, data, readonly, xlabels, ylabels, variable_size, current_slice
        )

    def _init_handler(self, data: np.ndarray | RecordArrayHandler) -> None:
        """Initializes and set the instance handler to use

        Args:
            data: Numpy's ndarray or BaseArrayHandler to use.
        """
        if isinstance(data, np.ma.MaskedArray):
            self._data = RecordArrayHandler(data, self._variable_size)
        elif isinstance(data, RecordArrayHandler):
            self._data = data
        else:
            raise TypeError(
                f"Given data must be of type np.ndarray or RecordArrayHandler, not {type(data)}"
            )

    def _init_model(
        self,
        xlabels: Sequence[str] | None,
        ylabels: Sequence[str] | None,
        readonly: bool,
        current_slice: Sequence[slice | int] | None = None,
    ) -> None:
        """Initializes and set the instance model to use

        Args:
            xlabels: labels for the columns (header). Defaults to None.
            ylabels: labels for the rows (header). Defaults to None.
            readonly: Flag for readonly mode. Defaults to False.
            current_slice: slice of the same dimension as the Numpy ndarray that will.
        """
        assert isinstance(self._data, RecordArrayHandler)
        self.model = RecordArrayModel(
            self._data,
            self._dtype_name,
            xlabels=xlabels,
            ylabels=ylabels,
            readonly=readonly,
            parent=self,
            current_slice=current_slice,
        )
