#
# (c) 2017 Red Hat, Inc.
#
# This file is part of Ansible
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
#
from __future__ import absolute_import, division, print_function


__metaclass__ = type
import collections
import json

from contextlib import contextmanager
from copy import deepcopy

from ansible.module_utils._text import to_text
from ansible.module_utils.basic import env_fallback
from ansible.module_utils.connection import Connection, ConnectionError
from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.netconf import (
    NetconfConnection,
)


try:
    from lxml.etree import Element, SubElement
    from lxml.etree import tostring as xml_to_string

    HAS_LXML = True
except ImportError:
    from xml.etree.ElementTree import Element, SubElement
    from xml.etree.ElementTree import tostring as xml_to_string

    HAS_LXML = False

try:
    from jnpr.junos import Device
    from jnpr.junos.exception import ConnectError

    HAS_PYEZ = True
except ImportError:
    HAS_PYEZ = False

ACTIONS = frozenset(["merge", "override", "replace", "update", "set"])
JSON_ACTIONS = frozenset(["merge", "override", "update"])
FORMATS = frozenset(["xml", "text", "json"])
CONFIG_FORMATS = frozenset(["xml", "text", "json", "set"])

junos_provider_spec = {
    "host": dict(),
    "port": dict(type="int"),
    "username": dict(fallback=(env_fallback, ["ANSIBLE_NET_USERNAME"])),
    "password": dict(
        fallback=(env_fallback, ["ANSIBLE_NET_PASSWORD"]),
        no_log=True,
    ),
    "ssh_keyfile": dict(
        fallback=(env_fallback, ["ANSIBLE_NET_SSH_KEYFILE"]),
        type="path",
    ),
    "timeout": dict(type="int"),
    "transport": dict(default="netconf", choices=["cli", "netconf"]),
}
junos_argument_spec = {
    "provider": dict(
        type="dict",
        options=junos_provider_spec,
        removed_at_date="2022-06-01",
        removed_from_collection="junipernetworks.junos",
    ),
}


def tostring(element, encoding="UTF-8", pretty_print=False):
    if HAS_LXML:
        return xml_to_string(
            element,
            encoding="unicode",
            pretty_print=pretty_print,
        )
    else:
        return to_text(
            xml_to_string(element, encoding),
            encoding=encoding,
        )


def get_provider_argspec():
    return junos_provider_spec


def get_connection(module):
    if hasattr(module, "_junos_connection"):
        return module._junos_connection
    capabilities = get_capabilities(module)
    network_api = capabilities.get("network_api")
    if network_api == "cliconf":
        module._junos_connection = Connection(module._socket_path)
    elif network_api == "netconf":
        module._junos_connection = NetconfConnection(module._socket_path)
    else:
        module.fail_json(msg="Invalid connection type %s" % network_api)

    return module._junos_connection


def get_capabilities(module):
    if hasattr(module, "_junos_capabilities"):
        return module._junos_capabilities

    try:
        capabilities = Connection(module._socket_path).get_capabilities()
    except ConnectionError as exc:
        module.fail_json(msg=to_text(exc, errors="surrogate_then_replace"))
    module._junos_capabilities = json.loads(capabilities)
    return module._junos_capabilities


def get_device(module):
    provider = module.params.get("provider") or {}
    host = provider.get("host")

    kwargs = {
        "port": provider.get("port") or 830,
        "user": provider.get("username"),
    }

    if "password" in provider:
        kwargs["passwd"] = provider.get("password")

    if "ssh_keyfile" in provider:
        kwargs["ssh_private_key_file"] = provider.get("ssh_keyfile")

    if module.params.get("ssh_config"):
        kwargs["ssh_config"] = module.params["ssh_config"]

    if module.params.get("ssh_private_key_file"):
        kwargs["ssh_private_key_file"] = module.params["ssh_private_key_file"]

    kwargs["gather_facts"] = False

    try:
        device = Device(host, **kwargs)
        device.open()
        device.timeout = provider.get("timeout") or 10
    except ConnectError as exc:
        module.fail_json("unable to connect to %s: %s" % (host, to_text(exc)))

    return device


def is_netconf(module):
    capabilities = get_capabilities(module)
    return True if capabilities.get("network_api") == "netconf" else False


def _validate_rollback_id(module, value):
    try:
        if not 0 <= int(value) <= 49:
            raise ValueError
    except ValueError:
        module.fail_json(msg="rollback must be between 0 and 49")


def load_configuration(
    module,
    candidate=None,
    action="merge",
    rollback=None,
    format="xml",
):
    if all((candidate is None, rollback is None)):
        module.fail_json(msg="one of candidate or rollback must be specified")

    elif all((candidate is not None, rollback is not None)):
        module.fail_json(msg="candidate and rollback are mutually exclusive")

    if format not in FORMATS:
        module.fail_json(msg="invalid format specified")

    if format == "json" and action not in JSON_ACTIONS:
        module.fail_json(msg="invalid action for format json")
    elif format in ("text", "xml") and action not in ACTIONS:
        module.fail_json(msg="invalid action format %s" % format)
    if action == "set" and format != "text":
        module.fail_json(msg="format must be text when action is set")

    conn = get_connection(module)
    try:
        if rollback is not None:
            _validate_rollback_id(module, rollback)
            obj = Element("load-configuration", {"rollback": str(rollback)})
            conn.execute_rpc(tostring(obj))
        else:
            return conn.load_configuration(
                config=candidate,
                action=action,
                format=format,
            )
    except ConnectionError as exc:
        module.fail_json(msg=to_text(exc, errors="surrogate_then_replace"))


def get_configuration(
    module,
    compare=False,
    format="xml",
    rollback="0",
    filter=None,
):
    if format not in CONFIG_FORMATS:
        module.fail_json(msg="invalid config format specified")

    conn = get_connection(module)
    try:
        if compare:
            xattrs = {"format": format}
            _validate_rollback_id(module, rollback)
            xattrs["compare"] = "rollback"
            xattrs["rollback"] = str(rollback)
            reply = conn.execute_rpc(
                tostring(Element("get-configuration", xattrs)),
            )
        else:
            reply = conn.get_configuration(format=format, filter=filter)
    except ConnectionError as exc:
        module.fail_json(msg=to_text(exc, errors="surrogate_then_replace"))
    return reply


def commit_configuration(
    module,
    confirm=False,
    check=False,
    comment=None,
    confirm_timeout=None,
    synchronize=False,
    at_time=None,
):
    conn = get_connection(module)
    try:
        if check:
            reply = conn.validate()
        else:
            if is_netconf(module):
                reply = conn.commit(
                    confirmed=confirm,
                    timeout=confirm_timeout,
                    comment=comment,
                    synchronize=synchronize,
                    at_time=at_time,
                )
            else:
                reply = conn.commit(
                    comment=comment,
                    confirmed=confirm,
                    at_time=at_time,
                    synchronize=synchronize,
                )
    except ConnectionError as exc:
        module.fail_json(msg=to_text(exc, errors="surrogate_then_replace"))
    return reply


def command(module, cmd, format="text", rpc_only=False):
    conn = get_connection(module)
    if rpc_only:
        cmd += " | display xml rpc"
    try:
        response = conn.command(command=cmd, format=format)
    except ConnectionError as exc:
        module.fail_json(msg=to_text(exc, errors="surrogate_then_replace"))
    return response


def lock_configuration(module):
    conn = get_connection(module)
    try:
        response = conn.lock()
    except ConnectionError as exc:
        module.fail_json(msg=to_text(exc, errors="surrogate_then_replace"))
    return response


def unlock_configuration(module):
    conn = get_connection(module)
    try:
        response = conn.unlock()
    except ConnectionError as exc:
        module.fail_json(msg=to_text(exc, errors="surrogate_then_replace"))
    return response


@contextmanager
def locked_config(module):
    try:
        lock_configuration(module)
        yield
    finally:
        unlock_configuration(module)


def discard_changes(module):
    conn = get_connection(module)
    try:
        response = conn.discard_changes()
    except ConnectionError as exc:
        module.fail_json(msg=to_text(exc, errors="surrogate_then_replace"))
    return response


def get_diff(module, rollback="0"):
    reply = get_configuration(
        module,
        compare=True,
        format="text",
        rollback=rollback,
    )
    # if warning is received from device diff is empty.
    if isinstance(reply, list):
        return None

    output = reply.find(".//configuration-output")
    if output is not None:
        return to_text(output.text, encoding="latin-1").strip()


def load_config(module, candidate, warnings, action="merge", format="xml"):
    get_connection(module)
    if not candidate:
        return

    if isinstance(candidate, list):
        candidate = "\n".join(candidate)

    reply = load_configuration(module, candidate, action=action, format=format)
    if isinstance(reply, list):
        warnings.extend(reply)

    module._junos_connection.validate()
    return get_diff(module)


def map_params_to_obj(module, param_to_xpath_map, param=None):
    """
    Creates a new dictionary with key as xpath corresponding
    to param and value is a list of dict with metadata and values for
    the xpath.
    Acceptable metadata keys:
        'value': Value of param.
        'tag_only': Value is indicated by tag only in xml hierarchy.
        'leaf_only': If operation is to be added at leaf node only.
        'value_req': If value(text) is required for leaf node.
        'is_key': If the field is key or not.
    eg: Output
    {
        'name': [{'value': 'ge-0/0/1'}]
        'disable': [{'value': True, tag_only': True}]
    }

    :param module:
    :param param_to_xpath_map: Modules params to xpath map
    :return: obj
    """
    if not param:
        param = module.params

    obj = collections.OrderedDict()
    for key, attribute in param_to_xpath_map.items():
        if key in param:
            is_attribute_dict = False

            value = param[key]
            if not isinstance(value, (list, tuple)):
                value = [value]

            if isinstance(attribute, dict):
                xpath = attribute.get("xpath")
                is_attribute_dict = True
            else:
                xpath = attribute

            if not obj.get(xpath):
                obj[xpath] = list()

            for val in value:
                if is_attribute_dict:
                    attr = deepcopy(attribute)
                    del attr["xpath"]

                    attr.update({"value": val})
                    obj[xpath].append(attr)
                else:
                    obj[xpath].append({"value": val})
    return obj


def map_obj_to_ele(module, want, top, value_map=None, param=None):
    if not HAS_LXML:
        module.fail_json(msg="lxml is not installed.")

    if not param:
        param = module.params

    root = Element("root")
    top_ele = top.split("/")
    ele = SubElement(root, top_ele[0])

    if len(top_ele) > 1:
        for item in top_ele[1:-1]:
            ele = SubElement(ele, item)
    container = ele
    state = param.get("state")
    active = param.get("active")
    if active:
        oper = "active"
    else:
        oper = "inactive"

    # build xml subtree
    if container.tag != top_ele[-1]:
        node = SubElement(container, top_ele[-1])
    else:
        node = container

    for fxpath, attributes in want.items():
        for attr in attributes:
            tag_only = attr.get("tag_only", False)
            leaf_only = attr.get("leaf_only", False)
            value_req = attr.get("value_req", False)
            is_key = attr.get("is_key", False)
            parent_attrib = attr.get("parent_attrib", True)
            value = attr.get("value")
            field_top = attr.get("top")

            # operation 'delete' is added as element attribute
            # only if it is key or leaf only node
            if state == "absent" and not (is_key or leaf_only):
                continue

            # convert param value to device specific value
            if value_map and fxpath in value_map:
                value = value_map[fxpath].get(value)

            if (value is not None) or tag_only or leaf_only:
                ele = node
                if field_top:
                    # eg: top = 'system/syslog/file'
                    #     field_top = 'system/syslog/file/contents'
                    # <file>
                    #   <name>test</name>
                    #   <contents>
                    #   </contents>
                    # </file>
                    ele_list = root.xpath(top + "/" + field_top)

                    if not len(ele_list):
                        fields = field_top.split("/")
                        ele = node
                        for item in fields:
                            inner_ele = root.xpath(top + "/" + item)
                            if len(inner_ele):
                                ele = inner_ele[0]
                            else:
                                ele = SubElement(ele, item)
                    else:
                        ele = ele_list[0]

                if value is not None and not isinstance(value, bool):
                    value = to_text(value, errors="surrogate_then_replace")

                if fxpath:
                    tags = fxpath.split("/")
                    for item in tags:
                        ele = SubElement(ele, item)

                if tag_only:
                    if state == "present":
                        if not value:
                            # if value of tag_only node is false, delete the node
                            ele.set("delete", "delete")

                elif leaf_only:
                    if state == "present":
                        ele.set(oper, oper)
                        ele.text = value
                    else:
                        ele.set("delete", "delete")
                        # Add value of leaf node if required while deleting.
                        # in some cases if value is present while deleting, it
                        # can result in error, hence the check
                        if value_req:
                            ele.text = value
                        if is_key:
                            par = ele.getparent()
                            par.set("delete", "delete")
                else:
                    ele.text = value
                    par = ele.getparent()

                    if parent_attrib:
                        if state == "present":
                            # set replace attribute at parent node
                            if not par.attrib.get("replace"):
                                par.set("replace", "replace")

                            # set active/inactive at parent node
                            if not par.attrib.get(oper):
                                par.set(oper, oper)
                        else:
                            par.set("delete", "delete")

    return root.getchildren()[0]


def to_param_list(module):
    aggregate = module.params.get("aggregate")
    if aggregate:
        if isinstance(aggregate, dict):
            return [aggregate]
        else:
            return aggregate
    else:
        return [module.params]
