"""
Silique (https://www.silique.fr)
Copyright (C) 2024-2025
            
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 typing import Tuple, List, Optional

from io import BytesIO
from ruamel.yaml import YAML
import tabulate as tabulate_module
from tiramisu.error import display_list
from tabulate import tabulate

from rougail.tiramisu import normalize_family
from tiramisu import undefined
from tiramisu.error import PropertiesOptionError

from .i18n import _


ROUGAIL_VARIABLE_TYPE = (
    "https://rougail.readthedocs.io/en/latest/variable.html#variables-types"
)


ENTER = "\n\n"


DocTypes = {
    "domainname": {
        "params": {
            "allow_startswith_dot": _("the domain name can starts by a dot"),
            "allow_without_dot": _("the domain name can be a hostname"),
            "allow_ip": _("the domain name can be an IP"),
            "allow_cidr_network": _("the domain name can be network in CIDR format"),
        },
    },
    "number": {
        "params": {
            "min_number": _("the minimum value is {0}"),
            "max_number": _("the maximum value is {0}"),
        },
    },
    "integer": {
        "params": {
            "min_integer": _("the minimum value is {0}"),
            "max_integer": _("the maximum value is {0}"),
        },
    },
    "ip": {
        "msg": "IP",
        "params": {
            "cidr": _("IP must be in CIDR format"),
            "private_only": _("private IP are allowed"),
            "allow_reserved": _("reserved IP are allowed"),
        },
    },
    "network": {
        "params": {
            "cidr": _("network must be in CIDR format"),
        },
    },
    "hostname": {
        "params": {
            "allow_ip": _("the host name can be an IP"),
        },
    },
    "web_address": {
        "params": {
            "allow_ip": _("the domain name in web address can be an IP"),
            "allow_without_dot": _(
                "the domain name in web address can be only a hostname"
            ),
        },
    },
    "port": {
        "params": {
            "allow_range": _("can be range of port"),
            "allow_protocol": _("can have the protocol"),
            "allow_zero": _("port 0 is allowed"),
            "allow_wellknown": _("well-known ports (1 to 1023) are allowed"),
            "allow_registred": _("registred ports (1024 to 49151) are allowed"),
            "allow_private": _("private ports (greater than 49152) are allowed"),
        },
    },
    "secret": {
        "params": {
            "min_len": _("minimum length for the secret is {0} characters"),
            "max_len": _("maximum length for the secret is {0} characters"),
            "forbidden_char": _("forbidden characters: {0}"),
        },
    },
    "unix_filename": {
        "params": {
            "allow_relative": _("this filename could be a relative path"),
            "test_existence": _("this file must exists"),
            "types": _("file type allowed: {0}"),
        },
    },
}


_yaml = YAML()
_yaml.indent(mapping=2, sequence=4, offset=2)


def dump(informations):
    """Dump variable, means transform bool, ... to yaml string"""
    with BytesIO() as ymlfh:
        _yaml.dump(informations, ymlfh)
        ret = ymlfh.getvalue().decode("utf-8").strip()
    if ret.endswith("..."):
        ret = ret[:-3].strip()
    return ret


def to_phrase(msg, type_="variable"):
    """Add maj for the first character and ends with dot"""
    if not msg:
        # replace None to empty string
        return ""
    msg = str(msg).strip()
    # a phrase must ends with a dot
    if type_ == "variable":
        if not msg.endswith("."):
            msg += "."
    elif type_ in ["family", "description"]:
        if msg.endswith("."):
            msg = msg[:-1]
    else:
        raise Exception("unknown type")
    # and start with a maj
    return msg[0].upper() + msg[1:]


class CommonFormatter:
    """Class with common function for formatter"""

    enter_table = "\n"
    # tabulate module name
    name = None

    def __init__(self, rougailconfig, **kwarg):
        tabulate_module.PRESERVE_WHITESPACE = True
        self.header_setted = False
        self.rougailconfig = rougailconfig

    def run(
        self, informations: dict, *, dico_is_already_treated=False
    ) -> str:
        """Transform to string"""
        if informations:
            level = self.rougailconfig["doc.title_level"]
            self.options()
            if self.root:
                current = informations
                for path in self.root.split('.'):
                    current = current[path]["children"]
                informations = current
            return self._run(informations, level, dico_is_already_treated)
        return ""

    def options(self):
        self.with_family = not self.rougailconfig["doc.without_family"]
        self.root = self.rougailconfig["doc.root"]
        self.other_root_filenames = None
        if self.root:
            try:
                other_root_filenames = self.rougailconfig["doc.other_root_filenames"]
            except PropertiesOptionError:
                pass
            else:
                if other_root_filenames:
                    self.other_root_filenames = dict(zip(other_root_filenames["root_path"], other_root_filenames["filename"]))

    def compute(self, data):
        return "".join([d for d in data if d])

    # Class you needs implement to your Formatter
    def title(
        self,
        title: str,
        level: int,
    ) -> str:
        """Display family name as a title"""
        raise NotImplementedError()

    def join(
        self,
        lst: List[str],
    ) -> str:
        """Display line in table from a list"""
        raise NotImplementedError()

    def bold(
        self,
        msg: str,
    ) -> str:
        """Set a text to bold"""
        raise NotImplementedError()

    def underline(
        self,
        msg: str,
    ) -> str:
        """Set a text to underline"""
        raise NotImplementedError()

    def anchor(self,
               path: str,
               true_path: str,
               ) -> str:
        """Set a text to a link anchor"""
        return path

    def link_variable(self,
                      path: str,
                      true_path: str,
                      description: str,
                      filename: Optional[str],
                      ) -> str:
        """Set a text link to variable anchor"""
        return path

    def stripped(
        self,
        text: str,
    ) -> str:
        """Return stripped text (as help)"""
        raise NotImplementedError()

    def list(
        self,
        choices: list,
        *,
        inside_table: bool=True,
        type_: str="variable",
    ) -> str:
        """Display a liste of element"""
        raise NotImplementedError()

    def prop(
        self,
        prop: str,
        italic: bool,
        delete: bool,
        underline: bool,
    ) -> str:
        """Display property"""
        raise NotImplementedError()

    def link(
        self,
        comment: str,
        link: str,
        underline: bool,
    ) -> str:
        """Add a link"""
        raise NotImplementedError()

    ##################

    def family_informations(self) -> str:
        return ""

    def end_family_informations(self) -> str:
        return ENTER

    def display_paths(
        self,
        informations: dict,
        modified_attributes: dict,
        force_identifiers: Optional[str],
        *,
        is_variable=False
    ) -> str:
        ret_paths = []
        path = informations["path"]
        if "identifiers" in modified_attributes:
            name, previous, new = modified_attributes["identifiers"]
            ret_paths.extend(
                [
                    self.bold(self.delete(calc_path(path, self, identifier)))
                    for identifier in previous
                ]
            )
        else:
            new = []
        if "identifiers" in informations:
            for idx, identifier in enumerate(informations["identifiers"]):
                if force_identifiers and identifier != force_identifiers:
                    continue
                path_ = calc_path(path, self, identifier)
                if not idx:
                    path_ = self.anchor(path_, path)
                if identifier in new:
                    path_ = self.underline(path_)
                ret_paths.append(self.bold(path_))
        else:
            ret_paths.append(self.bold(self.anchor(path, path)))
        return ret_paths

    def table_header(
        self,
        lst: list,
    ) -> tuple:
        """Manage the header of a table"""
        return lst

    def _run(self, dico: dict, level: int, dico_is_already_treated: bool) -> str:
        """Parse the dict to transform to dict"""
        if dico_is_already_treated:
            return "".join(dico)
        return "".join([msg for msg in self.dict_to_dict(dico, level, init=True)])

    def dict_to_dict(
        self,
        dico: dict,
        level: int,
        *,
        ori_table_datas: list = None,
        init: bool = False,
    ) -> str:
        """Parse the dict to transform to dict"""
        msg = []
        if ori_table_datas is not None:
            table_datas = ori_table_datas
        else:
            table_datas = []
        ori_level = None
        for value in dico.values():
            if value["type"] == "variable":
                self.variable_to_string(value, table_datas)
            else:
                if self.with_family:
                    if value["type"] == "namespace":
                        namespace = ori_level is None
                    else:
                        namespace = False
                    if table_datas:
                        msg.append(self.table(table_datas))
                        table_datas = []
                    msg.extend(self.family_to_string(value["informations"], level, namespace))
                    msg.extend(self.dict_to_dict(value["children"], level + 1))
                    msg.append(self.end_family(level))
                else:
                    self.dict_to_dict(
                        value["children"], level + 1, ori_table_datas=table_datas
                    )
        if (init or ori_table_datas is None) and table_datas:
            msg.append(self.table(table_datas))
        return msg

    # FAMILY
    def namespace_to_title(self, informations: dict, level: int) -> str:
        """manage namespace family"""
        return self.title(
            self.get_description("family", informations, {}, None),
            level,
        )

    def family_to_string(self, informations: dict, level: int, namespace: bool) -> str:
        """manage other family type"""
        if namespace:
            ret = [self.namespace_to_title(informations, level)]
        else:
            ret = [self.title(self.get_description("family", informations, {}, None), level)]
        fam_info = self.family_informations()
        if fam_info:
            ret.append(fam_info)
        msg = self.display_paths(informations, {}, None)
        helps = informations.get("help")
        if helps:
            for help_ in helps:
                msg.append(to_phrase(help_.strip()))
        calculated_properties = []
        property_str = self.property_to_string(informations, calculated_properties, {})[1]
        if property_str:
            msg.append(property_str)
        if calculated_properties:
            msg.append(self.join(calculated_properties)
            )
        if "identifier" in informations:
            msg.append(
                self.section(_("Identifiers"), informations["identifier"], type_="family")
            )
        starts_line = self.family_informations_starts_line()
        ret.append(self.family_informations_ends_line().join([starts_line + m for m in msg]) + self.end_family_informations())
        return ret

    def family_informations_starts_line(self) -> str:
        return ""

    def family_informations_ends_line(self) -> str:
        return ENTER

    def end_family(self, level: int) -> str:
        return ""

    def convert_list_to_string(
        self, attribute: str, informations: dict, modified_attributes: dict
    ) -> str():
        datas = []
        if attribute in modified_attributes:
            name, previous, new = modified_attributes[attribute]
            for data in previous:
                datas.append(self.delete(self.to_phrase(data)))
        else:
            new = []
        if attribute in informations:
            for data in informations[attribute]:
                if isinstance(data, dict):
                    if attribute.endswith("s"):
                        attr = attribute[:-1]
                    else:
                        attr = attribute
                    data = data[attr].replace(
                        "{{ identifier }}", self.italic(data["identifier"])
                    )
                data = self.to_phrase(data)
                if data in new:
                    data = self.underline(data)
                datas.append(data)
        return self.stripped(self.join(datas))

    def get_description(
        self, type_: str, informations: dict, modified_attributes: dict, force_identifiers: Optional[str]
    ) -> str():
        def _get_description(description, identifiers, delete=False, new=[]):
            if identifiers and "{{ identifier }}" in description:
                if type_ == "variable":
                    identifiers_text = display_list(
                        [self.italic(i[-1]) for i in identifiers if not force_identifiers or i == force_identifiers], separator="or"
                    )
                    description = description.replace(
                        "{{ identifier }}", identifiers_text
                    )
                else:
                    d = []
                    for i in identifiers:
                        if force_identifiers and i != force_identifiers:
                            continue
                        new_description = description.replace(
                            "{{ identifier }}", self.italic(i[-1])
                        )
                        if new_description not in d:
                            d.append(self.to_phrase(new_description))
                    description = display_list(d, separator="or")
            else:
                description = self.to_phrase(description)
            if description in new:
                description = self.underline(description)
            if delete:
                description = self.delete(description)
            return description

        if "description" in modified_attributes:
            name, previous, new = modified_attributes["description"]
            if previous:
                modified_description = _get_description(
                    previous[0], modified_attributes.get("identifiers", []), delete=True
                )
            else:
                modified_description = None
        else:
            modified_description = None
            new = []
        description = _get_description(
            informations["description"], informations.get("identifiers"), new=new
        )
        if modified_description:
            if description:
                description = self.join([modified_description, description])
            else:
                description = modified_description
        if not description:
            return None
        return self.stripped(description)

    # VARIABLE
    def variable_to_string(
        self, informations: dict, table_datas: list, modified_attributes: dict = {}, force_identifiers: Optional[str]=None
    ) -> None:
        """Manage variable"""
        calculated_properties = []
        multi, first_column = self.variable_first_column(
            informations, calculated_properties, modified_attributes, force_identifiers
        )
        table_datas.append(
            [
                self.join(first_column),
                self.join(
                    self.variable_second_column(
                        informations,
                        calculated_properties,
                        modified_attributes,
                        multi,
                        force_identifiers,
                    )
                ),
            ]
        )

    def variable_first_column(
        self,
        informations: dict,
        calculated_properties: list,
        modified_attributes: Optional[dict],
        force_identifiers: Optional[str],
    ) -> list:
        """Collect string for the first column"""
        multi, properties = self.property_to_string(
            informations, calculated_properties, modified_attributes,
        )
        first_col = [
            self.join(self.display_paths(informations, modified_attributes, force_identifiers, is_variable=True)),
            properties,
        ]
        self.columns(first_col)
        return multi, first_col

    def variable_second_column(
        self,
        informations: dict,
        calculated_properties: list,
        modified_attributes: dict,
        multi: bool,
        force_identifiers: Optional[str],
    ) -> list:
        """Collect string for the second column"""
        second_col = []
        #
        if "description" in informations:
            description = self.get_description(
                "variable", informations, modified_attributes, force_identifiers,
            )
            second_col.append(description)
        #
        help_ = self.convert_list_to_string("help", informations, modified_attributes)
        if help_:
            second_col.append(help_)
        #
        validators = self.convert_section_to_string(
            "validators", informations, modified_attributes, multi=True
        )
        if validators:
            second_col.append(validators)
        default_is_already_set, choices = self.convert_choices_to_string(
            informations, modified_attributes
        )
        if choices:
            second_col.append(choices)
        if not default_is_already_set and "default" in informations:
            self.convert_section_to_string(
                "default", informations, modified_attributes, multi=multi
            )
            second_col.append(
                self.convert_section_to_string(
                    "default", informations, modified_attributes, multi=multi
                )
            )
        examples = self.convert_section_to_string(
            "examples", informations, modified_attributes, multi=True
        )
        if examples:
            second_col.append(examples)
        tags = self.convert_section_to_string(
            "tags", informations, modified_attributes, multi=True
        )
        if tags:
            second_col.append(tags)
        second_col.extend(calculated_properties)
        self.columns(second_col)
        return second_col

    def convert_section_to_string(
        self, attribute: str, informations: dict, modified_attributes: dict, multi: bool
    ) -> str():
        values = []
        submessage = ""
        if modified_attributes and attribute in modified_attributes:
            name, previous, new = modified_attributes[attribute]
            #            if "identifiers" in modified_attributes:
            #                iname, iprevious, inew = modified_attributes["identifiers"]
            #                identifiers = iprevious.copy()
            #                for identifier in informations.get("identifiers", []):
            #                    if identifier not in inew:
            #                        identifiers.append(identifier)
            #
            #            else:
            #                identifiers = informations.get("identifiers", [])
            if isinstance(previous, list):
                for p in previous:
                    submessage, m = self.message_to_string(p, submessage)
                    values.append(self.delete(m))
            else:
                submessage, old_values = self.message_to_string(previous, submessage)
                values.append(self.delete(old_values))
        else:
            new = []
        if attribute in informations:
            old = informations[attribute]
            name = old["name"]
            if isinstance(old["values"], list):
                for value in old["values"]:
                    submessage, old_value = self.message_to_string(value, submessage)
                    if value in new:
                        old_value = self.underline(old_value)
                    values.append(old_value)
                if multi:
                    values = self.list(values)
                else:
                    values = self.join(values)
            elif values:
                old_values = old["values"]
                submessage, old_values = self.message_to_string(old_values, submessage)
                if old["values"] in new:
                    old_values = self.underline(old_values)
                values.append(old_values)
                values = self.join(values)
            else:
                submessage, values = self.message_to_string(old["values"], submessage)
                if old["values"] in new:
                    values = self.underline(values)
        if values != []:
            return self.section(name, values, submessage=submessage)

    def convert_choices_to_string(
        self, informations: dict, modified_attributes: dict
    ) -> str():
        default_is_already_set = False
        if "choices" in informations:
            choices = informations["choices"]
            choices_values = choices["values"]
            if not isinstance(choices_values, list):
                choices_values = [choices_values]
                default_is_a_list = False
            else:
                default_is_a_list = True
            for idx, choice in enumerate(choices_values.copy()):
                if isinstance(choice, dict):
                    choices_values[idx] = self.message_to_string(choice, None)[1]
            if "default" in modified_attributes:
                name, old_default, new_default = modified_attributes["default"]
                if not old_default:
                    old_default = [None]
                if not isinstance(old_default, list):
                    old_default = [old_default]
                for value in old_default.copy():
                    if (
                        isinstance(value, str)
                        and value.endswith(".")
                        and value not in choices_values
                    ):
                        old_default.remove(value)
                        old_default.append(value[:-1])
            else:
                old_default = new_default = []
            # check if all default values are in choices (could be from a calculation)
            if "default" in informations:
                default = informations["default"]["values"]
            else:
                default = []
            if not isinstance(default, list):
                default = [default]
            for idx, value in enumerate(default.copy()):
                if isinstance(value, dict):
                    default[idx] = self.message_to_string(value, None)[1]
            default_value_not_in_choices = set(default) - set(choices_values)
            if default_value_not_in_choices:
                default_is_changed = False
                for val in default_value_not_in_choices.copy():
                    if (
                        isinstance(val, str)
                        and val.endswith(".")
                        and val[:-1] in choices_values
                    ):
                        default.remove(val)
                        default.append(val[:-1])
                        default_is_changed = True
                        if val in new_default:
                            new_default.remove(val)
                            new_default.append(val[:-1])
                if default_is_changed:
                    default_value_not_in_choices = set(default) - set(choices_values)
            if default_value_not_in_choices:
                old_default = []
                new_default = []
                default = []
            else:
                default_is_already_set = True
            if "choices" in modified_attributes:
                name, previous, new = modified_attributes["choices"]
                for choice in reversed(previous):
                    if isinstance(choice, dict):
                        choice = self.message_to_string(choice, None)[1]
                    if choice in old_default:
                        choices_values.insert(
                            0, self.delete(dump(choice) + " ← " + _("(default)"))
                        )
                    else:
                        choices_values.insert(0, self.delete(dump(choice)))
            else:
                new = []
            for idx, val in enumerate(choices_values):
                if val in old_default:
                    choices_values[idx] = (
                        dump(val) + " " + self.delete("← " + _("(default)"))
                    )
                elif val in default:
                    if val in new_default:
                        if val in new:
                            choices_values[idx] = self.underline(
                                dump(val) + " " + self.bold("← " + _("(default)"))
                            )
                        else:
                            choices_values[idx] = (
                                dump(val)
                                + " "
                                + self.underline(self.bold("← " + _("(default)")))
                            )
                    else:
                        choices_values[idx] = (
                            dump(val) + " " + self.bold("← " + _("(default)"))
                        )
                elif val in new:
                    choices_values[idx] = self.underline(dump(val))
            # if old value and new value is a list, display a list
            if not default_is_a_list and len(choices_values) == 1:
                choices_values = choices_values[0]
            return default_is_already_set, self.section(choices["name"], choices_values)
        return default_is_already_set, None

    # OTHERs
    def to_phrase(self, text: str) -> str:
        return text

    def property_to_string(
        self,
        informations: dict,
        calculated_properties: list,
        modified_attributes: dict,
    ) -> str:
        """Transform properties to string"""
        properties = []
        local_calculated_properties = {}
        multi = False
        if "properties" in modified_attributes:
            previous, new = self.get_modified_properties(
                *modified_attributes["properties"][1:]
            )
            for p, annotation in previous.items():
                if p not in new:
                    properties.append(self.prop(p, italic=False, delete=True, underline=False))
                if annotation is not None:
                    local_calculated_properties[p] = [self.delete(annotation)]

        else:
            previous = new = []
        for prop in informations.get("properties", []):
            prop_name = prop["name"]
            if prop_name not in previous and prop_name in new:
                underline = True
            else:
                underline = False
            if prop["type"] == "type":
                properties.append(self.link(prop_name, ROUGAIL_VARIABLE_TYPE, underline))
            else:
                if prop["type"] == "multiple":
                    multi = True
                if "annotation" in prop:
                    italic = True
                    prop_annotation = prop["annotation"]
                    if prop_name in new and (
                        prop_name not in previous
                        or new[prop_name] != previous[prop_name]
                    ):
                        prop_annotation = self.underline(prop_annotation)
                    local_calculated_properties.setdefault(prop["name"], []).append(
                        prop_annotation
                    )
                else:
                    italic = False
                properties.append(self.prop(prop_name, italic=italic, delete=False, underline=underline))
        if local_calculated_properties:
            for (
                calculated_property_name,
                calculated_property,
            ) in local_calculated_properties.items():
                if len(calculated_property) > 1:
                    calculated_property = self.join(calculated_property)
                else:
                    calculated_property = calculated_property[0]
                calculated_properties.append(
                    self.section(
                        calculated_property_name.capitalize(), calculated_property
                    )
                )
        if not properties:
            return multi, ""
        return multi, " ".join(properties)

    def get_modified_properties(
        self, previous: List[dict], new: List[dict]
    ) -> Tuple[dict, dict]:
        def modified_properties_parser(dico):
            return {d["name"]: d.get("annotation") for d in dico}

        return modified_properties_parser(previous), modified_properties_parser(new)

    def columns(
        self,
        col: List[str],  # pylint: disable=unused-argument
    ) -> None:
        """Manage column"""
        return

    def table(self, datas: list, with_header: bool = True) -> str:
        """Transform list to a table in string format"""
        if with_header:
            headers = self.table_header([_("Variable"), _("Description")])
        else:
            headers = ()
        msg = (
            tabulate(
                datas,
                headers=headers,
                tablefmt=self._table_name,
            )
            + "\n\n"
        )
        datas.clear()
        return msg

    def message_to_string(self, msg, ret, identifiers=[]):
        if isinstance(msg, dict):
            if "submessage" in msg:
                ret += msg["submessage"]
                msg = msg["values"]
            elif "message" in msg:
                filename = None
                if self.other_root_filenames:
                    path = msg["path"]["path"]
                    for root in self.other_root_filenames:
                        if path == root or path.startswith(f'{root}.'):
                            filename = self.other_root_filenames[root]
                            break
                if "identifiers" in msg["path"]:
                    msg["identifiers"] = [msg["path"]["identifiers"]]
                path = self.link_variable(calc_path(msg["path"], self, identifiers), msg["path"]["path"], self.get_description("variable", msg, {}, None), filename=filename)
                msg = msg["message"].format(path)
        return ret, msg

    def section(
        self,
        name: str,
        msg: str,
        submessage: str = "",
        type_ = "variable",
    ) -> str:
        """Return something like Name: msg"""
        submessage, msg = self.message_to_string(msg, submessage)
        if isinstance(msg, list):
            if len(msg) == 1:
                submessage, elt = self.message_to_string(msg[0], submessage)
                if isinstance(elt, list):
                    submessage += self.list(elt, type_=type_)
                else:
                    submessage += elt
            else:
                lst = []
                for p in msg:
                    submessage, elt = self.message_to_string(p, submessage)
                    lst.append(elt)
                submessage += self.list(lst, type_=type_)
            msg = ""
        if not isinstance(msg, str):
            submessage += dump(msg)
        else:
            submessage += msg
        return _("{0}: {1}").format(self.bold(name), submessage)


def calc_path(path, formatter=None, identifiers: List[str] = None) -> str:
    def _path_with_identifier(path, identifier):
        identifier = normalize_family(str(identifier))
        if formatter:
            identifier = formatter.italic(identifier)
        return path.replace("{{ identifier }}", identifier, 1)

    if isinstance(path, dict):
        path_ = path["path"]
        if "identifiers" in path:
            for identifier in path["identifiers"]:
                path_ = _path_with_identifier(path_, identifier)
    elif identifiers:
        path_ = path
        for identifier in identifiers:
            path_ = _path_with_identifier(path_, identifier)
    else:
        path_ = path
    return path_
