"""Takes a bunch of Rougail YAML dispatched in differents folders
as an input and outputs a Tiramisu's file.

Created by:
EOLE (http://eole.orion.education.fr)
Copyright (C) 2005-2018

Forked by:
Cadoles (http://www.cadoles.com)
Copyright (C) 2019-2021

Silique (https://www.silique.fr)
Copyright (C) 2022-2026

This program 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/>.
"""

from pathlib import Path
from typing import (
    Any,
    Dict,
    Iterator,
    List,
    Literal,
    Optional,
    Tuple,
    Union,
    get_type_hints,
)

from ruamel.yaml.comments import CommentedMap
from pydantic import ValidationError

from tiramisu.error import display_list

from ..annotator import SpaceAnnotator
from ..i18n import _
from ..tiramisu import CONVERT_OPTION, normalize_family
from .object_model import (
    Calculation,
    Dynamic,
    Family,
    Variable,
    Choices,
    Param,
    Regexp,
    SymLink,
    VariableCalculation,
)
from .tiramisureflector import TiramisuReflector
from ..utils import load_modules
from .path import Paths
from .collect import Collect
from ..error import DictConsistencyError

property_types = Union[Literal[True], Calculation]
properties_types = Dict[str, property_types]
SUPPORTED_VERSION = ["1.0", "1.1"]


class Property:
    def __init__(self) -> None:
        self._properties: Dict[str, properties_types] = {}

    def add(
        self,
        path: str,
        property_: str,
        value: property_types,
    ) -> None:
        self._properties.setdefault(path, {})[property_] = value

    def get(self, path: str) -> None:
        return self._properties.setdefault(path, {})

    def remove(self, path: str, property_: str) -> None:
        del self._properties[path][property_]

    def __getitem__(
        self,
        path: str,
    ) -> properties_types:
        return self._properties.get(path, {})

    def __contains__(
        self,
        path: str,
    ) -> bool:
        return path in self._properties


information_types = Dict[str, Union[str, int, float, bool]]


class Informations:
    def __init__(self) -> None:
        self._data: Dict[str, information_types] = {}

    def add(
        self,
        path: str,
        key: str,
        data: Any,
    ) -> None:
        if path not in self._data:
            self._data[path] = {}
        if key in self._data[path]:
            raise Exception(f'an information "{key}" is already present in "{path}"')
        self._data[path][key] = data

    def get(
        self,
        path: str,
    ) -> information_types:
        return self._data.get(path, {})


class ParserVariable:
    def __init__(self, rougailconfig):
        self.rougailconfig = rougailconfig
        self.load_config()
        self.paths = Paths(None, True)
        self.families = []
        self.variables = []
        self.parents = {".": []}
        self.index = 0
        self.reflector_names = {}
        self.leaders = []
        self.followers = []
        self.dynamics_variable = {}
        self.multis = {}
        self.default_multi = {}
        self.forced_descriptions = []
        self.jinja = {}
        #
        self.convert_options = list(CONVERT_OPTION)
        self.convert_options.extend(self.custom_types)
        #
        self.exclude_imports = []
        self.informations = Informations()
        self.properties = Property()
        # self.choices = Appendable()
        self.has_dyn_option = False
        self.is_init = False
        super().__init__()

    def load_config(self) -> None:
        rougailconfig = self.rougailconfig
        self.suffix = rougailconfig["suffix"]
        self.default_structural_format_version = rougailconfig[
            "default_structural_format_version"
        ]
        self.custom_types = rougailconfig["custom_types"]
        self.functions_files = rougailconfig["functions_files"]
        self.modes_level = rougailconfig["modes_level"]
        if self.modes_level:
            self.default_variable_mode = rougailconfig["default_variable_mode"]
            self.default_family_mode = rougailconfig["default_family_mode"]
        self.extra_annotators = rougailconfig["extra_annotators"]
        self.base_option_name = rougailconfig["base_option_name"]
        self.export_with_import = rougailconfig["export_with_import"]
        self.internal_functions = rougailconfig["internal_functions"]
        self.force_optional = rougailconfig["force_optional"]
        self.add_extra_options = rougailconfig[
            "structural_commandline.add_extra_options"
        ]
        self.structurals = rougailconfig["step.structural"]
        try:
            self.user_data = rougailconfig["step.user_data"]
        except:
            self.user_data = []
        try:
            self.output = rougailconfig["step.output"]
        except:
            self.output = None
        self.tiramisu_cache = rougailconfig["tiramisu_cache"]
        self.load_unexist_redefine = rougailconfig["load_unexist_redefine"]
        self.secret_pattern = rougailconfig["secret_manager.pattern"]
        # change default initkwargs in CONVERT_OPTION
        if (
            hasattr(rougailconfig, "config")
            and rougailconfig.config.option("define_default_params").value.get()
        ):
            for sub_od in rougailconfig.config.option("default_params"):
                for option in sub_od:
                    if option.owner.isdefault():
                        continue
                    convert_option = CONVERT_OPTION[sub_od.name()]
                    if "initkwargs" not in convert_option:
                        convert_option["initkwargs"] = {}
                    convert_option["initkwargs"][option.name()] = option.value.get()

    def init(self):
        if self.is_init:
            return
        root = Path(__file__).parent.parent
        self.walker = None
        self.variable = Variable
        self.family = Family
        for structural_name in self.structurals:
            structural = f"structural_{structural_name}"
            module_path = root / structural / "__init__.py"
            if not module_path.is_file():
                continue
            module = load_modules(f"rougail.{structural}", str(module_path))
            if "Variable" in module.__all__:
                self.variable = type(
                    self.variable.__name__ + "_" + structural,
                    (self.variable, module.Variable),
                    {},
                )
            if "Family" in module.__all__:
                self.family = type(
                    self.family.__name__ + "_" + structural,
                    (self.family, module.Family),
                    {},
                )
            if not self.walker and "Walker" in module.__all__:
                self.walker = module.Walker
        self.dynamic = type(Dynamic.__name__, (Dynamic, self.family), {})
        self.choices = type(Choices.__name__, (Choices, self.variable), {})
        self.regexp = type(Regexp.__name__, (Regexp, self.variable), {})
        variable_types = self.convert_options.copy()
        variable_types.remove("choice")
        variable_types.remove("regexp")
        variable_types.remove("symlink")
        self.variable_objects = [
            self.get_variable_object(obj, is_variable=True)
            for obj in [
                (self.variable, variable_types),
                SymLink,
                self.choices,
                self.regexp,
            ]
        ]
        self.family_objects = [
            self.get_variable_object(obj, is_variable=False)
            for obj in [self.dynamic, self.family]
        ]
        self.is_init = True

    def get_variable_object(self, obj, *, is_variable: bool) -> dict:
        if isinstance(obj, tuple):
            obj, types = obj
            hint = get_type_hints(obj)
        else:
            hint = get_type_hints(obj)
            types = self.get_types(hint)
        return {
            "object": obj,
            "types": types,
            "attrs": self.get_option_attrs(hint),
            "attributes_types": self.get_attributes_types(hint, variable=is_variable),
        }

    def get_types(self, hint):
        return hint["type"].__args__

    def get_option_attrs(self, hint):
        return frozenset(  # pylint: disable=W0201
            set(hint) - {"name", "path", "xmlfiles"} | {"redefine", "exists"}
        )

    ###############################################################################################
    # create, update or delete family or variable object
    ###############################################################################################
    def family_or_variable(
        self,
        name: str,
        subpath: str,
        obj: dict,
        comment: Optional[str],
        *,
        first_variable: bool = False,
        custom_type: bool = False,
        parent_option: "Collect" = None,
    ) -> None:
        option = Collect(
            self,
            name,
            subpath,
            obj,
            comment,
            parent_option,
        )
        if option.option_type == "family":
            parser = self.parse_family
        else:
            parser = self.parse_variable
        parser(
            option,
            first_variable=first_variable,
            custom_type=custom_type,
        )

    def parse_family(
        self,
        option: Collect,
        *,
        first_variable: bool = False,
        custom_type: bool = False,
    ) -> None:
        """Parse a family"""
        path = option.path
        redefine = option.redefine
        exists = option.exists
        #        if not redefine and not exists and option.user_type in self.custom_family_types:
        types = option.types
        if types:
            self.add_family(
                types,
                custom_type=True,
            )
        if path not in self.paths:
            if not self.load_unexist_redefine and exists is None and redefine:
                raise Exception(
                    f'cannot redefine the inexisting family "{path}" in {display_list(option.sources)}'
                )
            if not self.load_unexist_redefine and exists is True:
                return
            self.add_family(
                option,
                custom_type=custom_type,
            )
            force_not_first = False
        else:
            if exists in [None, True] and not redefine:
                msg = _('family "{0}" define multiple time').format(path)
                raise DictConsistencyError(msg, 32, option.sources)
            if self.load_unexist_redefine or exists in [None, True]:
                objects = option.parameters.copy()
                objects["xmlfiles"] = option.sources
                self.paths.add(
                    option,
                    self.paths[path].model_copy(update=objects),
                    force=True,
                )
            force_not_first = types == None
        children = option.children
        for idx, key in enumerate(self.list_children(option, types)):
            if key in children:
                value = children[key]
                comment = self.get_comment(key, children)
            else:
                value = {}
                comment = None
            first_variable = not force_not_first and idx == 0
            self.family_or_variable(
                key,
                path,
                value,
                comment,
                first_variable=first_variable,
                custom_type=custom_type,
                parent_option=option,
            )

    def list_children(self, option, types):
        """
        my_type: [var1, var2]

        family: []
        => [var1, var2]
        family: [var1, var3, var2]
        => [var1, var3, var2]
        family: [var3]
        => [var1, var2, var3]
        family: [var3, var2]
        => [var1, var3, var2]
        family: [var1, var3]
        => [var1, var2, var3]
        """
        option_children = list(option.children)
        if not types:
            yield from option_children
            return
        types_children = list(types.children)
        while True:
            if not option_children and not types_children:
                break
            if not option_children:
                yield from types_children
                break
            elif not types_children:
                yield from option_children
                break
            elif option_children[0] == types_children[0]:
                yield option_children.pop(0)
                types_children.pop(0)
            elif types_children[0] not in option_children:
                yield types_children.pop(0)
            else:
                name = option_children.pop(0)
                yield name
                if name in types_children:
                    types_children.remove(name)

    def add_family(
        self,
        option: Collect,
        *,
        custom_type: bool = False,
    ) -> None:
        """Add a new family"""
        path = option.path
        if custom_type:
            self.change_namespaces(option.parameters)
        family_obj = option.object["object"]
        if option.user_type == "dynamic":
            # FIXME only for version 1.0 purpose
            if "variable" in option.parameters:
                raise Exception(
                    f'dynamic family must not have "variable" attribute for "{path}" in {option.sources}'
                )
        data = dict(option.parameters)
        data["xmlfiles"] = option.sources
        data["namespace"] = option.namespace
        data["path"] = path
        data["version"] = option.version
        try:
            self.paths.add(
                option,
                family_obj(
                    name=option.name,
                    **data,
                ),
            )
        except ValidationError as err:
            raise Exception(
                f'invalid family "{path}" in "{display_list(option.sources)}": {err}'
            ) from err
        self.set_name(
            self.paths[path],
            "optiondescription_",
        )
        if "." not in path:
            parent = "."
        else:
            parent = path.rsplit(".", 1)[0]
        self.parents[parent].append(path)
        self.parents[path] = []
        self.families.append(path)
        if self.loaded_custom_types != None:
            self.loaded_custom_types[path] = option

    def parse_variable(
        self,
        option: Collect,
        *,
        first_variable: bool = False,
        custom_type: bool = False,
    ) -> None:
        """Parse variable"""
        redefine = option.redefine
        exists = option.exists
        path = option.path
        if option.types:
            redefine = True
            self.add_variable(
                option.types,
                first_variable=first_variable,
                custom_type=True,
            )
        if path not in self.paths:
            if not self.load_unexist_redefine and exists is True:
                # this variable must exist
                # but it's not the case
                # so do nothing
                return
            if not self.load_unexist_redefine and redefine:
                msg = f'cannot redefine the inexisting variable "{path}"'
                raise DictConsistencyError(msg, 46, option.sources)
            self.add_variable(
                option,
                first_variable,
                custom_type,
            )
        else:
            if not self.load_unexist_redefine:
                if exists is False:
                    return
                if not redefine:
                    msg = _('variable "{0}" define multiple time').format(path)
                    raise DictConsistencyError(msg, 45, option.sources)
            objects = option.parameters.copy()
            objects["xmlfiles"] = option.sources
            self.paths.add(
                option,
                self.paths[path].model_copy(update=objects),
                force=True,
            )

    def add_variable(
        self,
        option,
        first_variable: bool,
        custom_type: bool,
    ) -> None:
        """Add a new variable"""
        variable = option.parameters
        path = option.path
        if custom_type:
            self.change_namespaces(variable)
        data = dict(variable)
        data["xmlfiles"] = option.sources
        data["namespace"] = option.namespace
        data["path"] = path
        data["version"] = option.version
        try:
            variable_obj = option.object["object"](
                name=option.name,
                **data,
            )
        except ValidationError as err:
            raise Exception(
                f'invalid variable "{path}" in "{display_list(option.sources)}": {err}'
            ) from err
        self.paths.add(
            option,
            variable_obj,
        )
        self.variables.append(path)
        if "." in path:
            parent_path = path.rsplit(".", 1)[0]
        else:
            parent_path = "."
        self.parents[parent_path].append(path)
        self.set_name(
            variable_obj,
            "option_",
        )
        if option.family_is_sequence:
            if first_variable:
                self.leaders.append(path)
            else:
                self.followers.append(path)
        if self.loaded_custom_types != None:
            self.loaded_custom_types[path] = option

    def change_namespaces(self, variable):
        for key, value in variable.items():
            if isinstance(value, Calculation) and value.namespace != self.namespace:
                copy = value.model_copy()
                variable[key] = copy
                copy.namespace = self.namespace
            if not isinstance(value, list):
                continue
            for idx, val in enumerate(value):
                if isinstance(val, Calculation) and val.namespace != self.namespace:
                    copy = val.model_copy()
                    value[idx] = copy
                    copy.namespace = self.namespace

    def del_family(
        self,
        path: str,
    ) -> None:
        """The family is empty, so delete it"""
        del self.paths[path]
        self.families.remove(path)
        del self.parents[path]
        if "." in path:
            parent = path.rsplit(".", 1)[0]
        else:
            parent = "."
        self.parents[parent].remove(path)

    ###############################################################################################
    # set tiramisu file name
    ###############################################################################################
    def set_name(
        self,
        obj: Union[Variable, Family],
        option_prefix: str,
    ):
        """Set Tiramisu object name"""
        self.index += 1
        self.reflector_names[obj.path] = f"{option_prefix}{self.index}{self.suffix}"


class RougailConvert(ParserVariable):
    """Main Rougail conversion"""

    def __init__(
        self,
        rougailconfig,
        *,
        custom_variable_types: dict = {},
        custom_family_types: dict = {},
    ) -> None:
        self.annotator = False
        self.has_namespace = False
        self.custom_variable_types = custom_variable_types
        self.custom_family_types = custom_family_types
        self.loaded_custom_types = None
        super().__init__(rougailconfig)

    def get_attributes_types(
        self,
        hint: dict,
        variable: bool = False,
    ) -> dict:
        """attribute is calculated if typing is like: Union[Calculation, xxx]"""
        lists = {
            "strings": [],
            "booleans": [],
            "integers": [],
            "floats": [],
            "calculation": [],
        }
        if variable:
            lists["calculation"].append("identifier")
            calculation = ["identifier"]
        else:
            calculation = []
        strings = []
        booleans = ["redefine", "exists"]
        integers = []
        floats = []
        params = []
        literals = {}
        for key, value in hint.items():
            if "Union" in value.__class__.__name__:
                key_types = value.__args__
                if str in key_types:
                    strings.append(key)
                if int in key_types:
                    integers.append(key)
                if bool in key_types:
                    booleans.append(key)
                if float in key_types:
                    floats.append(key)
                if Calculation in key_types or VariableCalculation in key_types:
                    calculation.append(key)
                if "_GenericAlias" in key_types[0].__class__.__name__:
                    if Calculation in key_types[0].__args__:
                        lists["calculation"].append(key)
                    elif "Union" in key_types[0].__args__[0].__class__.__name__:
                        key_types_list = key_types[0].__args__[0].__args__
                        if Calculation in key_types_list:
                            lists["calculation"].append(key)
                        if str in key_types_list:
                            lists["strings"].append(key)
                        if int in key_types_list:
                            lists["integers"].append(key)
                        if bool in key_types_list:
                            lists["booleans"].append(key)
                        if float in key_types_list:
                            lists["floats"].append(key)
                    else:
                        key_types_list = key_types[0].__args__
                        if Param in key_types_list:
                            params.append(key)
                        if str in key_types_list:
                            lists["strings"].append(key)
            elif value == str:
                strings.append(key)
            elif value == bool:
                booleans.append(key)
            elif "Literal" in value.__class__.__name__:
                literals[key] = value.__args__
        return {
            "strings": strings,
            "integers": integers,
            "booleans": booleans,
            "floats": floats,
            "literals": literals,
            "calculation": calculation,
            "lists": lists,
            "params": params,
        }

    def create_namespace(
        self,
        namespace_description: str,
        isolated_namespace: bool = True,
    ) -> None:
        namespace_path = normalize_family(namespace_description)
        self.has_namespace = True
        if namespace_path in self.parents:
            raise DictConsistencyError(
                _("duplicate namespace {0}").format(namespace_path),
                86,
                None,
            )
        self.namespace = namespace_path
        if not self.paths.default_namespace:
            self.paths = Paths(
                namespace_description,
                isolated_namespace,
            )
        self.version = ""
        self.sources = []
        option = Collect(
            self,
            self.namespace,
            None,
            {
                "type": "family",
                "description": namespace_description,
            },
            None,
            None,
        )
        self.parse_family(option)

    def get_comment(
        self,
        name: str,
        objects: Any,
    ) -> Optional[str]:
        if self.version == "1.0" or not isinstance(objects, CommentedMap):
            return
        if name in objects.ca.items:
            comment = objects.ca.items[name][2]
        else:
            comment = None
        if comment:
            comment = comment.value[1:].strip()
        return comment

    def parse_root_file(
        self,
        sources: list[str],
        path: str,
        version: str,
        objects: dict,
    ) -> None:
        self.version = version
        self.sources = sources
        for name, obj in objects.items():
            comment = self.get_comment(name, objects)
            self.family_or_variable(
                name,
                path,
                obj,
                comment,
            )

    def validate_file_version(
        self,
        obj: dict,
        sources: list[str],
    ) -> None:
        """version is mandatory in YAML file"""
        if obj is None:
            obj = {}
        for name in ["_version", "version"]:
            if name not in obj:
                continue
            version = str(obj.pop(name))
            break
        else:
            # the `version` attribute is not mandatory
            default_version = self.default_structural_format_version
            if default_version is not None:
                version = default_version
            else:
                msg = '"version" attribut is mandatory in YAML file'
                raise DictConsistencyError(msg, 27, sources)

        if version not in SUPPORTED_VERSION:
            msg = f'version "{version}" is not supported, list of supported versions: {display_list(SUPPORTED_VERSION, separator="or", add_quote=True)}'
            raise DictConsistencyError(msg, 28, sources)
        return version

    def annotate(
        self,
    ):
        """Apply annotation"""
        if not self.paths.has_value():
            self.init()
            self.parse_directories()
        if self.annotator:
            raise DictConsistencyError(
                _("Cannot execute annotate multiple time"), 85, None
            )
        SpaceAnnotator(self)
        self.annotator = True

    def parse_directories(self) -> None:
        if not self.walker:
            msg = _(
                'invalid "structural" definition ({0}), we cannot load any structural file!'
            ).format(self.structurals)
            raise DictConsistencyError(msg, 51, None)
        self.init()
        self.walker(self)

    def reflect(self) -> None:
        """Apply TiramisuReflector"""
        functions_files = [
            func for func in self.functions_files if func not in self.exclude_imports
        ]
        self.reflector = TiramisuReflector(
            self,
            functions_files,
        )

    def save(
        self,
    ):
        """Return tiramisu object declaration as a string"""
        self.init()
        self.annotate()
        self.reflect()
        tiram_obj = self.reflector.get_text() + "\n"
        filename = self.tiramisu_cache
        if filename:
            with open(filename, "w", encoding="utf-8") as tiramisu:
                tiramisu.write(tiram_obj)
        # print(tiram_obj)
        return tiram_obj
