import logging
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path

from ctdam.conv.hexdecoder import decode_hex
from ctdam.parser import CnvFile
from ctdam.parser.ctddata import CTDData
from ctdam.parser.hexfile import HexFile
from ctdam.proc.modules import map_proc_name_to_class
from ctdam.proc.seabird.module import ProcessingModule
from ctdam.proc.seabird.step import ProcessingStep
from ctdam.proc.settings import Configuration, IncompleteConfigFile
from ctdam.proc.utils import (
    default_seabird_exe_path,
    fill_file_type_dir,
)

try:
    from ctdam.vis.visualize import basic_bokeh_plot
except ImportError:
    plotting_available = False
else:
    plotting_available = True


logger = logging.getLogger(__name__)


class Procedure:
    """
    Runs a couple of processing steps in sequence on one or more CTD data
    source files.

    It can use seabird internal processing modules, as well as custom ones.
    These can be in the form of independent windows exes or just pure python
    code. The input data can be .hex, .cnv or pandas DataFrames. The input and
    all module and extra information is stored in a dict that usually will be
    generated by the settings Configuration module that reads a toml config.

    Parameters
    ----------
    configuration: dict | Configuration:
        The information necessary to run a processing procedure.

    seabird_exe_directory: Path | str | None = None:
        The path to the directory where the Sea-Bird exes reside in.
        Usually not necessary, as this Class knows the default install path.

    available_hex_converters: list[str] = ["datcnv"]:
        A list of the known hex converters.

    auto_run: bool = True:
        Whether to autopilot the whole procedure.

    procedure_fingerprint_directory: Path | str | None = None:
        A path to a directory where the fingerprint are meant to be stored in.
        If none given, this option is considered to be turned off.

    file_type_dir: Path | str | None = None:
        A path to a directory where the individual Sea-Bird file types are
        differentiated into respective directories.
        If none given, this option is considered to be turned off.

    verbose: bool = False:
        Sets whether the Sea-Bird modules are run silently or not.

    timeout: int = 60:
        The time in seconds after which individual processing steps will be
        killed automatically.

    Returns
    -------
    In auto_run mode, a .cnv file or an instance of CnvFile, depending on the
        file_type parameter inside of the configuration.
    Otherwise it is an invocation that collected and evaluated the information
        necessary to run a processing procedure on one or more target files.

    """

    def __init__(
        self,
        configuration: dict | Configuration,
        seabird_exe_directory: Path | str | None = None,
        available_hex_converters: list[str] = ["datcnv", "hex2py"],
        auto_run: bool = True,
        procedure_fingerprint_directory: Path | str | None = None,
        file_type_dir: Path | str | None = None,
        plot: bool = False,
        verbose: bool = False,
        timeout: int = 60,
    ) -> None:
        if isinstance(configuration, Configuration):
            self.config = configuration.data
        else:
            self.config = configuration
        self.available_hex_converters = available_hex_converters
        self.procedure_fingerprint_directory = procedure_fingerprint_directory
        self.file_type_dir = file_type_dir
        if plotting_available:
            self.plot = plot
        else:
            self.plot = False
        self.verbose = verbose
        self.timeout = timeout
        # set default exe dir, when none given
        if (
            seabird_exe_directory is None
            or not Path(seabird_exe_directory).exists()
        ):
            self.exe_directory = default_seabird_exe_path()
        else:
            self.exe_directory = Path(seabird_exe_directory)
        self.xmlcon = None
        if auto_run:
            self.output = self.run()

    def run(
        self,
        file: CTDData | CnvFile | HexFile | Path | str = "",
    ) -> CTDData:
        """
        Runs given file or uses the one inside of the config.

        A 'run' consists of the application of all the given modules to a
        given file. It is the structure that can be represented by a
        fingerprint file.

        Parameters
        ----------
        files: CnvFile | Path | str :
            The input file.
             (Default value = '')
        """
        try:
            input = self.config["input"]
        except KeyError:
            input = ""
        except TypeError as error:
            raise IncompleteConfigFile(
                f"Input information has a wrong format: {error}. Aborting."
            )
        file = file if file else input
        if isinstance(file, CnvFile):
            self.ctd_data = file.to_ctd_data()
            self.target = file.path_to_file
            self.load_config()
        elif isinstance(file, HexFile):
            self.target = file.path_to_file
            self.load_config()
            self.ctd_data = decode_hex(file, downcast_only=self.downcast_only)
        elif isinstance(file, Path | str):
            self.target = Path(input) if file == "" else Path(file)
            if self.target == Path("."):
                raise ValueError(
                    "Wrong target file input! \n"
                    + f"Configuration given: {self.config} and file parameter: {file}"
                )
            self.load_config()
            # check whether we are working on hexes or cnvs and react accordingly
            first_module = list(self.modules.keys())[0]
            if first_module.lower() in self.available_hex_converters:
                # remove the first module information and only use it for the
                # conversion.
                hex_converter = self.modules.pop(first_module)
                # convert files to cnvs and continue working as usual
                if first_module.lower() == "datcnv":
                    self.ctd_data = self.convert(
                        self.target, {first_module: hex_converter}
                    )
                elif first_module.lower() == "hex2py":
                    self.ctd_data = decode_hex(
                        self.target,
                        downcast_only=self.downcast_only,
                        **hex_converter,
                    )
            else:
                # auto-convert if hex given as input
                if self.target.suffix == ".hex":
                    self.ctd_data = decode_hex(
                        self.target, downcast_only=self.downcast_only
                    )
                else:
                    self.ctd_data = CnvFile(self.target).to_ctd_data()
        else:
            self.ctd_data = file
            self.target = file.metadata_source.path_to_file
            self.load_config()
        return_value = self.go()
        self.procedure_fingerprint()
        if self.plot:
            basic_bokeh_plot(return_value, print_plot=True, show_plot=False)

        return return_value

    def load_config(self):
        """
        Thorough input/format check of the processing configuration, that
        either stems from a .toml config file, or is a self-build dictionary.
        Checks for the presence of certain keys, and then, depending on their
        importance, either fails or sets default values.
        """
        self.psa_directory = self.check_config_entry("psa_directory", None)
        self.output_dir = Path(
            self.check_config_entry("output_dir", self.target.parent)
        )
        if not self.output_dir.exists():
            self.output_dir.mkdir(parents=True)
        self.output_name = self.check_config_entry("output_name", None)
        self.output_type = self.check_config_entry("output_type", "cnv")
        self.output_columns = self.check_config_entry("output_columns", "all")
        self.xmlcon = self.check_config_entry("xmlcon", None)
        self.bad_flag = self.check_config_entry("bad_flag", -9.990e-29)
        self.downcast_only = self.check_config_entry("downcast_only", True)
        self.remove_flags = self.check_config_entry("remove_flags", True)
        try:
            self.modules = self.config["modules"].copy()
            assert isinstance(self.modules, dict)
            for module in list(self.modules.values()):
                assert isinstance(module, dict)
        except KeyError:
            raise IncompleteConfigFile(
                "No processing modules given. Aborting."
            )
        except AssertionError as error:
            raise IncompleteConfigFile(
                f"Module information is misconfigured: {error}. Aborting."
            )

    def check_config_entry(self, key: str, default_value):
        """Handles configuration file entries."""
        try:
            value = self.config[key]
        except KeyError:
            value = default_value
        else:
            if len(str(value)) == 0:
                value = default_value
        return value

    def is_seabird_module(self, module: dict) -> bool:
        """
        Answers the simple boolean question, whether the module in question is
        a seabird module or not.

        Does that by checking for the presence of a certain key 'psa'. All
        modules that are meant to run as a standalone executable should follow
        this principle and set their config file to the psa key.

        Parameters
        ----------
        module: dict :
            The specific module parameters.

        Returns
        -------

        """
        for key in list(module.keys()):
            if key == "psa":
                return True
        return False

    def create_seabird_module(self, module_info: dict) -> ProcessingModule:
        module_name = list(module_info.keys())[0]
        psa = Path(module_info[module_name]["psa"])
        if len(psa.parents) > 0 and not self.psa_directory:
            self.psa_directory = psa.parent
        module = ProcessingModule(
            name=module_name,
            exe_dir=self.exe_directory,
            psa_dir=self.psa_directory,
            psa_path=psa,
        )
        if "file_suffix" in module_info[module_name].keys():
            module.new_file_suffix = module_info[module_name]["file_suffix"]
        return module

    def create_seabird_step(
        self,
        module: ProcessingModule,
        input_path: Path,
        output_name: Path | str | None = None,
    ) -> ProcessingStep:
        output_name = output_name if output_name else input_path.name
        if input_path.suffix == ".hex":
            hex_file = HexFile(input_path)
            if hex_file.xmlcon:
                self.xmlcon = hex_file.xmlcon.path_to_file.absolute()
        step = ProcessingStep(
            module=module,
            input_path=input_path,
            xmlcon_path=self.xmlcon,
            output_path=self.output_dir,
            original_input_path=self.target,
            new_name=str(output_name),
            verbose=self.verbose,
        )
        return step

    def convert(
        self,
        hex_path: Path,
        hex_converter: dict,
    ) -> CTDData:
        """
        Covers the conversion of hex to cnv file.

        At the moment, this is simply done by using DatCnv, so we could just
        use the general Sea-Bird module pipeline. This is therefore meant to
        be future-compatible for a time where we might have developed other
        hex-converters.

        Parameters
        ----------
        hex_path: Path :
            The path to the target hex file.

        hex_converter: dict :
            The module parameters for the conversion.

        Returns
        -------

        """
        module = self.create_seabird_module(hex_converter)
        output_file_path = self.new_file_path(hex_path.with_suffix(".cnv"))
        step = self.create_seabird_step(module, hex_path, output_file_path)
        step.run()
        try:
            cnv = CnvFile(output_file_path)
        except FileNotFoundError:
            message = f"Failed to convert {hex_path}, using {list(hex_converter.keys())[0]}."
            sys.exit(message)
        else:
            self.xmlcon = step.xmlcon
            return cnv.to_ctd_data()

    def new_file_path(self, file: Path = Path(".")) -> Path:
        """
        Creates the new output file path.

        Takes the file type directory or the given output directory and joins
        them with the given output name.

        Parameters
        ----------
        file: Path :
            The current path to the target file.

        Returns
        -------
        The new path to write the target file to.

        """
        new_name = self.output_name if self.output_name else file.name
        directory = self.output_dir
        return directory.joinpath(new_name)

    def go(self) -> CTDData:
        """
        Performs the processing on all target files.

        This is the 'main' method of the procedure. All previous methods
        prepare data for this method to then finally transform the input files
        into the wanted format.
        The main purpose of this method is the coordination of the two
        different forms of processing modules: standalone executables with
        config files, mainly Sea-Bird processing modules, and python-internal
        classes that implement the Module interface. The caveats are mainly
        the switching from one form to the other. This for example results in
        a in or out parsing of a CnvFile object.

        The output is controlled by self.output_type and is either a cnv file
        at a target path or a CnvFile object.
        """
        path_to_cnv = self.new_file_path(self.target)
        if self.target.suffix == ".cnv":
            try:
                shutil.copy(self.target, path_to_cnv)
            except shutil.SameFileError:
                pass
        last_step = "first"
        for module_name, module_info in self.modules.items():
            module = {module_name.lower(): module_info}
            if self.is_seabird_module(module_info):
                seabird_module = self.create_seabird_module(module)
                step = self.create_seabird_step(
                    module=seabird_module,
                    input_path=path_to_cnv,
                )
                if last_step == "internal":
                    self.ctd_data.to_cnv(file_path=path_to_cnv)
                step.run(timeout=self.timeout)
                last_step = "seabird"
            else:
                if module_name == "hex2py":
                    continue
                own_module = map_proc_name_to_class(module_name)
                if last_step == "seabird":
                    self.ctd_data = CnvFile(path_to_cnv).to_ctd_data()
                try:
                    self.ctd_data = own_module(
                        input=self.ctd_data,
                        arguments=module_info,
                        output_name=str(path_to_cnv),
                        original_input_path=self.target,
                        bad_flag=self.bad_flag,
                    )
                except Exception as error:
                    logger.error(
                        f"Processing step {own_module} failed: {error}. Aborting processing of file {self.ctd_data.path_to_file}."
                    )
                    return self.ctd_data
                if not isinstance(self.ctd_data, CTDData):
                    try:
                        self.btl = self.ctd_data
                        self.ctd_data = self.btl.ctd_data
                    except Exception:
                        logger.error(
                            f"Failed to retrieve new cnv version after step {own_module}. Aborting processing."
                        )
                        break
                last_step = "internal"
        # handle output
        if self.output_type == "cnv":
            if last_step == "internal":
                self.ctd_data.to_cnv(
                    file_path=path_to_cnv,
                    remove_flags=self.remove_flags,
                    output_parameters=self.output_columns,
                    bad_flag=self.bad_flag,
                )
            if self.file_type_dir:
                self.file_type_dir = Path(self.file_type_dir)
                self.handle_fill_type_dir_creation()
        return self.ctd_data

    def procedure_fingerprint(self) -> Configuration | None:
        """
        Handles the creation of individual processing procedure fingerprints.

        A fingerprint is a 'receipt' of one invocation of this class to one or
        more target files or data. It shall serve the purpose of an easy to
        understand proof of what exactly has been done with the data to
        retreive the given result. The especially neat thing is, that they are
        at the very same time plain configuration files, allowing the easy
        re-running of processing procedures. What distinguishes them from the
        usual configuration files, is one, the exact target file list, that can
        be ommitted upon invocation, and a timestamp that prefixes the source
        file name and adheres to ISO 8601.
        """
        if not self.procedure_fingerprint_directory:
            return
        # producing fingerprint that adheres to ISO 8601
        timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        directory = Path(self.procedure_fingerprint_directory).absolute()
        if not directory.exists():
            directory.mkdir(parents=True)
        if isinstance(self.config, dict):
            name = Path("processing_config.toml")
            config = Configuration(
                path=directory.joinpath(f"{timestamp}_{name}"),
                data=self.config,
            )
        else:
            config = self.config
            name = config.path.name
        config.write(directory.joinpath(f"{timestamp}_{name}"))
        return config

    def handle_fill_type_dir_creation(self):
        # handle input files, eg. hex, xmlcon, bl
        input_dir = self.target.parent
        file_name = self.target.stem
        for target_file in input_dir.iterdir():
            if target_file.stem == file_name:
                fill_file_type_dir(self.file_type_dir, target_file)
        # handle xmlcon, which can reside in a different directory
        if self.xmlcon:
            fill_file_type_dir(self.file_type_dir, self.xmlcon)
        # handle output files, eg. cnv, btl
        for target_file in self.output_dir.iterdir():
            if target_file.stem.startswith(file_name):
                fill_file_type_dir(self.file_type_dir, target_file)
