#  Copyright (c) 2023 Mira Geoscience Ltd.
#
#  This file is part of geoapps.
#
#  geoapps is distributed under the terms and conditions of the MIT License
#  (see LICENSE file at the root of this source code package).

from __future__ import annotations

import json
import os
import warnings
from copy import deepcopy
from pathlib import Path
from typing import Any
from uuid import UUID

from geoh5py import Workspace
from geoh5py.shared import Entity
from geoh5py.shared.exceptions import BaseValidationError, JSONParameterValidationError
from geoh5py.shared.validators import AssociationValidator

from ..shared.utils import (
    as_str_if_uuid,
    dict_mapper,
    entity2uuid,
    fetch_active_workspace,
    str2uuid,
    uuid2entity,
)
from .constants import base_validations, ui_validations
from .utils import (
    container_group2name,
    flatten,
    inf2str,
    list2str,
    none2str,
    path2workspace,
    set_enabled,
    str2inf,
    str2none,
    workspace2path,
)
from .validation import InputValidation

# pylint: disable=simplifiable-if-expression, too-many-instance-attributes


class InputFile:
    """
    Handles loading ui.json input files.

    Attributes
    ----------
    data : Input file content parsed to flat dictionary of key:value.
    ui_json: User interface serializable as ui.json format
    workspace: Target :obj:`geoh5py.workspace.Workspace`
    validations: Dictionary of validations for parameters in the input file

    Methods
    -------
    write_ui_json()
        Writes a ui.json formatted file from 'data' attribute contents.
    read_ui_json()
        Reads a ui.json formatted file into 'data' attribute dictionary.  Optionally filters
        ui.json fields other than 'value'.
    """

    _path: str | None = None
    _name: str | None = None
    _data: dict[str, Any] | None
    _ui_json: dict[str, Any] | None
    _ui_validators: InputValidation = InputValidation(
        validations=ui_validations,
        validation_options={"ignore_list": ("value",)},
    )
    _validate = True
    _validators = None
    _validations: dict | None
    _validation_options: dict | None = None
    association_validator = AssociationValidator()

    def __init__(
        self,
        data: dict[str, Any] | None = None,
        ui_json: dict[str, Any] | None = None,
        validate: bool = True,
        validations: dict | None = None,
        validation_options: dict | None = None,
    ):
        self._geoh5 = None
        self.validation_options = validation_options
        self.validate = validate
        self.validations = validations
        self.ui_json = ui_json
        self.data = data

    @property
    def data(self):
        if getattr(self, "_data", None) is None and self.ui_json is not None:
            self.data = flatten(self.ui_json)

        return self._data

    @data.setter
    def data(self, value: dict[str, Any]):
        if value is not None:
            if not isinstance(value, dict):
                raise ValueError("Input 'data' must be of type dict or None.")

            if self._ui_json is None:
                raise AttributeError("'ui_json' must be set before setting data.")

            if len(value) != len(self._ui_json):
                raise ValueError(
                    "The number of input values for 'data' must "
                    "equal the number of parameters in 'ui_json'."
                )

            if self._geoh5 is None and "geoh5" in value:
                self.geoh5 = value["geoh5"]

            with fetch_active_workspace(self._geoh5):
                value = self.promote(value)

                if self.validators is not None and self.validate:
                    self.validators.validate_data(value)

            self.update_ui_values(value)

        self._data = value

    @property
    def name(self) -> str | None:
        """
        Name of ui.json file.
        """
        if getattr(self, "_name", None) is None and self.ui_json is not None:
            self.name = self.ui_json["title"]

        return self._name

    @name.setter
    def name(self, name: str):
        if ".ui.json" not in name:
            name += ".ui.json"

        self._name = name

    @property
    def path(self) -> str | None:
        """
        Directory for the input/output ui.json file.
        """
        if getattr(self, "_path", None) is None and self.geoh5 is not None:
            self.path = os.path.dirname(self.geoh5.h5file)

        return self._path

    @path.setter
    def path(self, path: str):
        if not os.path.isdir(path):
            raise ValueError(f"The specified path: '{path}' does not exist.")

        self._path = path

    @property
    def path_name(self) -> str | None:
        if self.path is not None and self.name is not None:
            return os.path.join(self.path, self.name)

        return None

    @staticmethod
    def read_ui_json(json_file: str | Path, **kwargs):
        """
        Read and create an InputFile from ui.json
        """
        if isinstance(json_file, Path):
            json_file = str(json_file)

        if not isinstance(json_file, str) or not json_file.endswith(".ui.json"):
            raise ValueError(
                "Input file should be a str or Path with extension *.ui.json"
            )

        input_file = InputFile(**kwargs)
        input_file.path = os.path.dirname(os.path.abspath(json_file))
        input_file.name = os.path.basename(json_file)

        with open(json_file, encoding="utf-8") as file:
            input_file.ui_json = json.load(file)

        if isinstance(input_file.geoh5, Workspace):
            input_file.geoh5.close()

        return input_file

    @property
    def ui_json(self) -> dict | None:
        """
        Dictionary representing the ui.json file with promoted values.
        """
        return self._ui_json

    @ui_json.setter
    def ui_json(self, value: dict[str, Any]):
        if value is not None:
            if not isinstance(value, dict):
                raise ValueError("Input 'ui_json' must be of type dict or None.")

            self._ui_json = self.numify(value.copy())
            infered_validations = InputValidation.infer_validations(self._ui_json)

            if self.validations is None:
                self.validations = {}

            for key, validations in infered_validations.items():
                if key in self.validations:
                    validations = {**validations, **self.validations[key]}
                self.validations[key] = validations

        else:
            self._ui_json = None
            self._validations = None

        self._validators = None

    @classmethod
    def ui_validation(cls, ui_json: dict[str, Any]):
        """Validation of the ui_json forms"""
        cls._ui_validators(ui_json)

    def update_ui_values(self, data: dict):
        """
        Update the ui.json values and enabled status from input data.

        :param data: Key and value pairs expected by the ui_json.

        :raises AttributeError: If attempting to set None value to non-optional parameter.
        """
        if self.ui_json is None:
            raise AttributeError("InputFile requires a 'ui_json' to be defined.")

        for key, value in data.items():
            if isinstance(self.ui_json[key], dict):
                enabled = self.ui_json[key].get("enabled", None)
                if enabled is not None:
                    if self.validation_options.get("update_enabled", True):
                        enabled = False if value is None else True

                    was_group_enabled = set_enabled(self.ui_json, key, enabled)
                    if was_group_enabled:
                        warnings.warn(
                            f"Setting all member of group: {self.ui_json[key]['group']} "
                            f"to enabled: {enabled}."
                        )

                member = "value"
                if "isValue" in self.ui_json[key]:
                    if isinstance(value, (Entity, UUID)):
                        self.ui_json[key]["isValue"] = False
                        member = "property"
                    else:
                        self.ui_json[key]["isValue"] = True

                if (value is None) and (not self.ui_json[key].get("enabled", False)):
                    continue

                self.ui_json[key][member] = value

            else:
                self.ui_json[key] = value

    @property
    def validate(self):
        """Option to run validations."""
        return self._validate

    @validate.setter
    def validate(self, value: bool):
        if not isinstance(value, bool):
            raise ValueError("Input value for `validate` should be True or False.")

        self._validate = value

    @property
    def validation_options(self):
        """
        Pass validation options to the validators.

        The following options are supported:

        - update_enabled: bool
            If True, the enabled status of the ui_json will be updated based on the
            value provided. Default is True.
        - ignore_list: tuple
            List of keys to ignore when validating the ui_json. Default is empty tuple.

        """
        if self._validation_options is None:
            return {
                "update_enabled": True,
                "ignore_list": (),
            }

        return self._validation_options

    @validation_options.setter
    def validation_options(self, value: dict):
        if not isinstance(value, (dict, type(None))):
            raise ValueError("Input value for `validation_options` should be a dict.")

        if value is not None:
            for key in value:
                if key not in self.validation_options:
                    raise KeyError(
                        f"Input key '{key}' not supported. "
                        f"Supported keys: {list(self.validation_options.keys())}"
                    )

        self._validation_options = value

    @property
    def validations(self) -> dict | None:
        """Dictionary of validations for the ui_json."""
        if self._validations is None:
            self._validations = deepcopy(base_validations)

        return self._validations

    @validations.setter
    def validations(self, valid_dict: dict | None):
        if not isinstance(valid_dict, (dict, type(None))):
            raise TypeError(
                "Input validations must be of type 'dict' or None. "
                f"Value type {type(valid_dict)} provided"
            )

        if valid_dict is not None:
            valid_dict = {**valid_dict, **deepcopy(base_validations)}

        self._validations = valid_dict

    @property
    def validators(self):
        if getattr(self, "_validators", None) is None:
            self._validators = InputValidation(
                ui_json=self.ui_json,
                validations=self.validations,
                validation_options=self.validation_options,
            )

        return self._validators

    @property
    def geoh5(self):
        """Geoh5 workspace."""
        if self._geoh5 is None and self.data is not None:
            self._geoh5 = self.data["geoh5"]
        return self._geoh5

    @geoh5.setter
    def geoh5(self, geoh5: Workspace | None):
        if geoh5 is not None:
            if self._geoh5 is not None:
                raise UserWarning(
                    "Attribute 'geoh5' already set. "
                    "Consider creating a new InputFile from arguments."
                )

            if not isinstance(geoh5, Workspace):
                raise ValueError(
                    "Input 'geoh5' must be a valid :obj:`geoh5py.workspace.Workspace`."
                )

        self._geoh5 = geoh5

        if self.validators is not None:
            self.validators.geoh5 = geoh5

    def write_ui_json(
        self,
        name: str | None = None,
        path: str | Path | None = None,
    ):
        """
        Writes a formatted ui.json file from InputFile data

        :param name: Name of the file
        :param path: Directory to write the ui.json to.
        """

        if name is not None:
            self.name = name

        if path is not None:
            self.path = os.path.abspath(path)

        if self.path_name is None:
            raise AttributeError(
                "The input file requires 'path' and 'name' to be set before writing out."
            )

        if self.ui_json is None:
            raise AttributeError(
                "The input file requires 'ui_json' and 'data' to be set before writing out."
            )

        if self.data is not None:
            self.update_ui_values(self.data)

        with open(self.path_name, "w", encoding="utf-8") as file:
            json.dump(self.stringify(self.demote(self.ui_json)), file, indent=4)

        return self.path_name

    def set_data_value(self, key: str, value):
        """
        Set the data and json form values from a dictionary.

        :param key: Parameter name to update.
        :param value: Value to update with.
        """
        if self.validate and self.validations is not None and key in self.validations:
            if "association" in self.validations[key]:
                validations = deepcopy(self.validations[key])
                parent = self.data[self.validations[key]["association"]]
                if isinstance(parent, UUID):
                    parent = self.geoh5.get_entity(parent)[0]
                validations["association"] = parent
            else:
                validations = self.validations[key]

            validations = {k: v for k, v in validations.items() if k != "one_of"}
            self.validators.validate(key, value, validations)

        self.data[key] = value

        if key == "geoh5":
            self.geoh5 = value

        self.update_ui_values({key: value})

    @staticmethod
    def stringify(var: dict[str, Any]) -> dict[str, Any]:
        """
        Convert inf, none, and list types to strings within a dictionary

        :param var: Dictionary containing ui.json keys, values, fields

        :return: Dictionary with inf, none and list types converted to string
            representations in json format.
        """
        for key, value in var.items():
            exclude = ["choiceList", "meshType", "dataType", "groupType", "association"]
            mappers = (
                [list2str, inf2str, as_str_if_uuid, none2str]
                if key not in exclude
                else [inf2str, as_str_if_uuid, none2str]
            )
            var[key] = dict_mapper(
                value, mappers, omit={ex: [list2str] for ex in exclude}
            )

        return var

    @classmethod
    def numify(cls, ui_json: dict[str, Any]) -> dict[str, Any]:
        """
        Convert inf, none and list strings to numerical types within a dictionary

        Parameters
        ----------

        ui_json :
            dictionary containing ui.json keys, values, fields

        Returns
        -------
        Dictionary with inf, none and list string representations converted numerical types.

        """
        if not isinstance(ui_json, dict):
            raise ValueError("Input value for 'numify' must be a ui_json dictionary.")

        for key, value in ui_json.items():
            if isinstance(value, dict):
                try:
                    cls.ui_validation(value)
                except tuple(BaseValidationError.__subclasses__()) as error:
                    raise JSONParameterValidationError(key, error.args[0]) from error

                value = cls.numify(value)

            mappers = [str2none, str2inf, str2uuid, path2workspace]
            ui_json[key] = dict_mapper(value, mappers)

        return ui_json

    @classmethod
    def demote(cls, var: dict[str, Any]) -> dict[str, str]:
        """Converts promoted parameter values to their string representations."""
        mappers = [entity2uuid, as_str_if_uuid, workspace2path, container_group2name]
        for key, value in var.items():
            if isinstance(value, dict):
                var[key] = cls.demote(value)

            elif isinstance(value, (list, tuple)):
                var[key] = [dict_mapper(val, mappers) for val in value]
            else:
                var[key] = dict_mapper(value, mappers)

        return var

    def promote(self, var: dict[str, Any]) -> dict[str, Any]:
        """Convert uuids to entities from the workspace."""
        if self._geoh5 is None:
            return var

        for key, value in var.items():
            if isinstance(value, dict):
                var[key] = self.promote(value)
            else:
                if isinstance(value, list):
                    var[key] = [self._uid_promotion(key, val) for val in value]
                else:
                    var[key] = self._uid_promotion(key, value)

        return var

    def _uid_promotion(self, key, value):
        """
        Check if the value needs to be promoted.
        """
        if isinstance(value, UUID) and self._geoh5 is not None:
            self.association_validator(key, value, self._geoh5)
            value = uuid2entity(value, self._geoh5)

        return value

    @property
    def workspace(self) -> Workspace | None:
        """Return the workspace associated with the input file."""

        warnings.warn(
            "The 'workspace' property is deprecated. Use 'geoh5' instead.",
            DeprecationWarning,
        )

        return self._geoh5

    @workspace.setter
    def workspace(self, value):
        warnings.warn(
            "The 'workspace' property is deprecated. Use 'geoh5' instead.",
            DeprecationWarning,
        )
        self.geoh5 = value
