######################################################################################################################
# Copyright (C) 2017-2022 Spine project consortium
# Copyright Spine Items contributors
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser 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 Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################

"""Contains ImportPreviewWindow class."""
from __future__ import annotations
import fnmatch
import json
from operator import attrgetter
import os
from typing import TYPE_CHECKING, ClassVar, Type
from PySide6.QtCore import QItemSelectionModel, QModelIndex, Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QFileDialog, QListWidget, QMessageBox, QVBoxLayout
from spine_engine.project_item.project_item_resource import SourceExtras
from spinedb_api.exception import ReaderError
from spinedb_api.helpers import remove_credentials_from_url
from spinedb_api.spine_io.gdx_utils import find_gams_directory
from spinedb_api.spine_io.importers.reader import Reader
from spinedb_api.spine_io.importers.sqlalchemy_reader import SQLAlchemyReader
from spinetoolbox.config import APPLICATION_PATH
from spinetoolbox.helpers import get_open_file_name_in_last_dir
from spinetoolbox.logger import QtLogger
from spinetoolbox.project_item.specification_editor_window import SpecificationEditorWindowBase
from ...utils import UrlDict, convert_to_sqlalchemy_url
from ...widgets import UrlSelectorDialog
from ..commands import RestoreMappingsFromDict
from ..connection_manager import ConnectionManager
from ..executable_item import READER_NAME_TO_CLASS
from ..importer_specification import ImporterSpecification
from ..mvcmodels.mappings_model import MappingsModel
from ..mvcmodels.source_list_selection_model import SourceListSelectionModel
from ..ui.import_editor_window import Ui_MainWindow  # pylint: disable=import-outside-toplevel
from .import_mapping_options import ImportMappingOptions
from .import_mappings import ImportMappings
from .import_sources import ImportSources

if TYPE_CHECKING:
    from spinetoolbox.ui_main import ToolboxUI
    from ..importer import Importer


class _ReaderProblemInMapping(Exception):
    """Raised when mapping has no reader or the reader looks different to file type."""

    def __init__(self, reader_in_mapping: Type[Reader] | None):
        """
        Args:
            reader_in_mapping: reader class defined in mapping
        """
        self.reader_in_mapping = reader_in_mapping


class ImportEditorWindow(SpecificationEditorWindowBase[Ui_MainWindow]):
    """A QMainWindow to let users define Mappings for an Importer item."""

    connection_failed = Signal(str)

    _FILE_LESS: ClassVar[str] = "anonymous"
    """Name of the 'file-less' input."""

    class _FileLessReader(Reader):
        """A connector that has no tables or contents, used for the file-less mode."""

        DISPLAY_NAME = "<unspecified>"
        FILE_EXTENSIONS = ""
        OPTIONS = {}

        def connect_to_source(self, source, **extras):
            pass

        def disconnect(self):
            pass

        def get_tables_and_properties(self):
            return {}

        def get_data_iterator(self, table, options, max_rows=-1):
            return iter([]), ()

    def __init__(
        self,
        toolbox: ToolboxUI,
        specification: ImporterSpecification,
        item: Importer | None = None,
        input_source: str | None = None,
        input_extras: SourceExtras | None = None,
    ):
        """
        Args:
            toolbox: ToolboxUI class
            specification: Importer specification
            item: Linked Importer item
            input_source: Importee file path or URL; if None, work in file-less mode
            input_extras: Additional input settings such as database schema
        """
        super().__init__(toolbox, specification, item)
        self._logger = QtLogger(self)
        self._logger.msg_error.connect(self._show_error_log)
        self._input = input_source if input_source is not None else self._FILE_LESS
        self._input_extras: SourceExtras = input_extras if input_extras is not None else {}
        self._mappings_model = MappingsModel(self._undo_stack, self)
        self._mappings_model.rowsInserted.connect(self._reselect_source_table)
        self._ui.source_list.setModel(self._mappings_model)
        self._ui.source_list.setSelectionModel(SourceListSelectionModel(self._mappings_model))
        self._ui.mapping_list.setModel(self._mappings_model)
        self._ui.mapping_list.setRootIndex(self._mappings_model.dummy_parent())
        self._ui.mapping_spec_table.setModel(self._mappings_model)
        self._ui.mapping_spec_table.setRootIndex(self._mappings_model.dummy_parent())
        self._connection_manager: ConnectionManager | None = None
        self._memoized_reader: Type[Reader] | None = None
        self._import_mappings = ImportMappings(self._mappings_model, self._ui, self._undo_stack, self)
        self._import_mapping_options = ImportMappingOptions(self._mappings_model, self._ui, self._undo_stack)
        self._import_sources = ImportSources(self._mappings_model, self._ui, self._undo_stack, self)
        self._set_input_text()
        self._ui.input_path_line_edit.editingFinished.connect(self._read_input_path_from_line)
        self._ui.input_path_line_edit.textEdited.connect(self._maybe_switch_to_file_less_mode)
        self._ui.browse_inputs_button.clicked.connect(self._show_open_file_dialog)
        self._ui.import_mappings_action.triggered.connect(self.import_mapping_from_file)
        self._ui.export_mappings_action.triggered.connect(self.export_mapping_to_file)
        self._ui.switch_input_type_action.triggered.connect(self._switch_input_type)
        self.connection_failed.connect(self.show_error)
        self._import_sources.preview_data_updated.connect(self._import_mapping_options.set_num_available_columns)
        self._mappings_model.restore(self.specification.mapping if self.specification is not None else {})
        QTimer.singleShot(0, self.start_ui)

    def is_file_less(self) -> bool:
        return not self._ui.input_path_line_edit.text()

    @property
    def settings_group(self):
        return "mappingPreviewWindow"

    def _save(self, exiting=None):
        """See base class."""
        if super()._save(exiting):
            self._import_mappings.specification_saved()
            return True
        return False

    @property
    def _duplicate_kwargs(self):
        return {"input": self._input}

    def _make_ui(self):
        return Ui_MainWindow()

    def _make_new_specification(self, spec_name):
        mappings_dict = self._mappings_model.store()
        mappings_dict.update(self._import_sources.store_connectors())
        description = self._spec_toolbar.description()
        return ImporterSpecification(spec_name, mappings_dict, description)

    def _populate_main_menu(self):
        super()._populate_main_menu()
        menu = self._spec_toolbar.menu
        before = self._spec_toolbar.save_action
        menu.insertAction(before, self._ui.switch_input_type_action)
        menu.insertSeparator(before)
        menu.insertActions(before, [self._ui.import_mappings_action, self._ui.export_mappings_action])
        menu.insertSeparator(before)

    def _set_input_text(self) -> None:
        """Sets input path/URL to the input line edit cleaning credentials from URLs."""
        if self._input == self._FILE_LESS:
            self._ui.input_path_line_edit.clear()
            return
        label = remove_credentials_from_url(self._input) if self._is_url(self._input) else self._input
        self._ui.input_path_line_edit.setText(label)

    @Slot(bool)
    def _show_open_file_dialog(self, _: bool = False) -> None:
        if self._is_database_reader(self._connection_manager.connection):
            url = self._get_input_url()
            if url is None:
                return
            self._input = str(convert_to_sqlalchemy_url(url, logger=self._toolbox))
            schema = url["schema"]
            self._input_extras = {"schema": schema if schema else None}
        else:
            file_path = self._get_input_file_path()
            if not file_path:
                return
            self._input = file_path
            self._input_extras = None
        self._set_input_text()
        self.start_ui()

    def _get_input_url(self) -> UrlDict | None:
        selector = UrlSelectorDialog(self._toolbox.qsettings(), False, self._toolbox, parent=self)
        selector.exec()
        if selector.result() != QDialog.DialogCode.Accepted:
            return None
        return selector.url_dict()

    def _get_input_file_path(self) -> str:
        filter_ = ";;".join(["*.*"] + [conn.FILE_EXTENSIONS for conn in READER_NAME_TO_CLASS.values()])
        key = f"selectInputDataFileFor{self.specification.name if self.specification else None}"
        filepath, _ = get_open_file_name_in_last_dir(
            self._toolbox.qsettings(),
            key,
            self,
            "Select an input data file to define the specification",
            APPLICATION_PATH,
            filter_=filter_,
        )
        return filepath

    @Slot(str)
    def _show_error_log(self, message: str) -> None:
        QMessageBox.warning(self, "Error", message)

    @staticmethod
    def _is_database_reader(reader: Type[Reader]) -> bool:
        """Tests if reader class works with database URLs.

        Args:
            reader: reader class to test
        """
        return reader.__name__ == SQLAlchemyReader.__name__

    @Slot(bool)
    def _switch_input_type(self, _: bool = False) -> None:
        if self.specification:
            self.specification.mapping.pop("source_type", None)
        self._memoized_reader = None
        self.start_ui()

    def _get_reader_from_mapping(self, input_path: str) -> Type[Reader]:
        """Reads reader for given input from mapping.

        Args:
            input_path: importee file path or URL

        Returns:
            reader class
        """
        if not self.specification:
            raise _ReaderProblemInMapping(None)
        mapping = self.specification.mapping
        source_type = mapping.get("source_type")
        if source_type is None:
            raise _ReaderProblemInMapping(None)
        reader = READER_NAME_TO_CLASS[source_type]
        file_extensions = reader.FILE_EXTENSIONS.split(";;")
        if input_path != self._FILE_LESS and not any(fnmatch.fnmatch(input_path, ext) for ext in file_extensions):
            if reader is SQLAlchemyReader and self._is_url(input_path):
                return reader
            raise _ReaderProblemInMapping(reader)
        return reader

    @Slot()
    def _read_input_path_from_line(self) -> None:
        """Sets input from input path line edit."""
        label = self._ui.input_path_line_edit.text()
        if self._input == self._FILE_LESS:
            if not label:
                return
            self._input = label
        else:
            if self._is_url(self._input):
                if label == remove_credentials_from_url(self._input):
                    return
                self._input_extras = None
            elif label == self._input:
                return
            self._input = label if label else self._FILE_LESS
        self.start_ui()

    @Slot(str)
    def _maybe_switch_to_file_less_mode(self, text: str) -> None:
        """Switches to file-less mode if text has been cleared."""
        if text or self._input == self._FILE_LESS:
            return
        self._input = self._FILE_LESS
        self._input_extras = None
        self.start_ui()

    def start_ui(self) -> None:
        """Connects to input and fills the tables and lists with data."""
        try:
            reader = self._get_reader_from_mapping(self._input)
        except _ReaderProblemInMapping as reader_problem:
            if reader_problem.reader_in_mapping is not None:
                QMessageBox.warning(
                    self,
                    "Verify input type",
                    f"Input type is set to {reader_problem.reader_in_mapping.DISPLAY_NAME} but the input looks incompatible. "
                    "You will be prompted to verify the type.",
                )
            reader = self._get_reader(self._input)
            if not reader:
                return
            is_db_reader = self._is_database_reader(reader)
            is_url_input = self._is_url(self._input)
            if (is_db_reader and not is_url_input) or (not is_db_reader and is_url_input):
                self._ui.input_path_line_edit.clear()
                self._input = self._FILE_LESS
                self._input_extras = None
            if reader_problem.reader_in_mapping is not None and reader is not reader_problem.reader_in_mapping:
                QMessageBox.information(
                    self,
                    "Input type changed",
                    f"Input type changed from {reader_problem.reader_in_mapping.DISPLAY_NAME} to {reader.DISPLAY_NAME}. "
                    "Don't forget to save the changes.",
                )
                self._undo_stack.resetClean()
        if self._input == self._FILE_LESS:
            self._FileLessReader.__name__ = reader.__name__
            self._FileLessReader.DISPLAY_NAME = reader.DISPLAY_NAME
            self._FileLessReader.FILE_EXTENSIONS = reader.FILE_EXTENSIONS
            self._FileLessReader.OPTIONS = reader.OPTIONS
            reader = self._FileLessReader
            self._mappings_model.set_tables_editable(True)
        else:
            self._mappings_model.set_tables_editable(False)
        self._ui.switch_input_type_action.setEnabled(True)
        reader_settings = {"gams_directory": _gams_system_directory(self._toolbox)}
        if self._connection_manager:
            self._connection_manager.close_connection()
        self._connection_manager = ConnectionManager(reader, reader_settings, self)
        self._connection_manager.connection_failed.connect(self.connection_failed.emit)
        self._connection_manager.error.connect(self.show_error)
        for header in (self._ui.source_data_table.horizontalHeader(), self._ui.source_data_table.verticalHeader()):
            self._ui.source_list.selectionModel().currentChanged.connect(
                header.set_source_table, Qt.ConnectionType.UniqueConnection
            )
        self._connection_manager.connection_ready.connect(self._handle_connection_ready)
        mapping = self.specification.mapping if self.specification else {}
        self._import_sources.set_connector(self._connection_manager, mapping)
        self._display_input_type(reader.DISPLAY_NAME)
        extras = self._input_extras if self._input_extras is not None else {}
        try:
            self._connection_manager.init_connection(self._input, **extras)
        except ReaderError as error:
            QMessageBox.warning(self, "Failed to read source", f"Couldn't read source: {error}")

    def _display_input_type(self, input_type: str) -> None:
        """Shows connector's name on the ui.

        Args:
            input_type: reader's display name
        """
        self._ui.input_type_line_edit.setText(input_type)

    @Slot()
    def _handle_connection_ready(self) -> None:
        self._ui.export_mappings_action.setEnabled(True)
        self._ui.import_mappings_action.setEnabled(True)
        self._ui.remove_unavailable_sources_button.setEnabled(self._input != self._FILE_LESS)

    def _get_reader(self, input_path: str) -> Type[Reader] | None:
        """Shows a QDialog to select a reader for the given data input.

        Args:
            input_path: Path of the file acting as an importee

        Returns:
            Asynchronous data reader class for the given importee
        """
        if self._memoized_reader:
            return self._memoized_reader
        reader_list = sorted(list(READER_NAME_TO_CLASS.values()), key=attrgetter("DISPLAY_NAME"))
        reader_names = [c.DISPLAY_NAME for c in reader_list]
        dialog = QDialog(self)
        dialog.setLayout(QVBoxLayout())
        reader_list_wg = QListWidget()
        reader_list_wg.addItems(reader_names)
        # Set current item in `reader_list_wg` based on file extension
        row = None
        for k, reader in enumerate(reader_list):
            file_extensions = reader.FILE_EXTENSIONS.split(";;")
            if any(fnmatch.fnmatch(input_path, ext) for ext in file_extensions):
                row = k
                break
        else:
            if self._is_url(input_path):
                row = reader_names.index(SQLAlchemyReader.DISPLAY_NAME)
        if row is not None:
            reader_list_wg.setCurrentRow(row)
        button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        button_box.button(QDialogButtonBox.StandardButton.Ok).clicked.connect(dialog.accept)
        button_box.button(QDialogButtonBox.StandardButton.Cancel).clicked.connect(dialog.reject)
        reader_list_wg.doubleClicked.connect(dialog.accept)
        dialog.layout().addWidget(reader_list_wg)
        dialog.layout().addWidget(button_box)
        spec_name = self._spec_toolbar.name()
        if not spec_name:
            spec_name = "unnamed specification"
        dialog.setWindowTitle(f"Select input type for {spec_name}")
        answer = dialog.exec()
        if answer != QDialog.DialogCode.Accepted:
            return None
        row = reader_list_wg.currentIndex().row()
        if row < 0:
            return None
        reader = self._memoized_reader = reader_list[row]
        return reader

    @Slot()
    def import_mapping_from_file(self) -> None:
        """Imports mapping spec from a user selected .json file to the preview window."""
        start_dir = self._toolbox.project().project_dir
        # noinspection PyCallByClass
        filename = QFileDialog.getOpenFileName(
            self, "Import mapping specification", start_dir, "Import mapping (*.json)"
        )
        if not filename[0]:
            return
        with open(filename[0]) as file_p:
            try:
                settings = json.load(file_p)
            except json.JSONDecodeError:
                self._show_status_bar_msg(f"Could not open {filename[0]}")
                return
        expected_options = ("table_mappings", "table_types", "table_row_types", "table_options", "selected_tables")
        if not isinstance(settings, dict) or not any(key in expected_options for key in settings.keys()):
            self._show_status_bar_msg(f"{filename[0]} does not contain and import mapping")
        self._undo_stack.push(RestoreMappingsFromDict(self._import_sources, self._mappings_model, settings))
        self._show_status_bar_msg(f"Mapping loaded from {filename[0]}")

    @Slot()
    def export_mapping_to_file(self) -> None:
        """Exports all mapping specs in current preview window to .json file."""
        start_dir = self._toolbox.project().project_dir
        # noinspection PyCallByClass
        filename = QFileDialog.getSaveFileName(
            self, "Export mapping spec to a file", start_dir, "Import mapping (*.json)"
        )
        if not filename[0]:
            return
        with open(filename[0], "w") as file_p:
            mappings_dict = self._mappings_model.store()
            mappings_dict.update(self._import_sources.store_connectors())
            json.dump(mappings_dict, file_p)
        self._show_status_bar_msg(f"Mapping saved to: {filename[0]}")

    @Slot(QModelIndex, int, int)
    def _reselect_source_table(self, parent: QModelIndex, first: int, last: int) -> None:
        """Selects added source table.

        This is a workaround to get the correct source table selected after a new one has been added
        since the source table view doesn't seem to update the current index correctly in this case.

        Args:
            parent: parent index
            first: first new row
            last: last new row
        """
        if parent.isValid():
            return
        index = self._mappings_model.index(last, 0)
        self._ui.source_list.selectionModel().setCurrentIndex(index, QItemSelectionModel.SelectionFlag.ClearAndSelect)

    @staticmethod
    def _is_url(string: str) -> bool:
        """Tests if given string looks like a URL.

        Args:
            string: string to test

        Returns:
            True if string looks like a URL, False otherwise
        """
        return "://" in string

    def tear_down(self) -> bool:
        if not super().tear_down():
            return False
        if self._import_sources:
            self._import_sources.close_connection()
        return True


def _gams_system_directory(toolbox: ToolboxUI) -> str:
    """Returns GAMS system path from Toolbox settings or None if GAMS default is to be used."""
    path = toolbox.qsettings().value("appSettings/gamsPath", defaultValue=None)
    if not path:
        path = find_gams_directory()
    if path is not None and os.path.isfile(path):
        path = os.path.dirname(path)
    return path
