# (c) 2016 Red Hat Inc.
# (c) 2017 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function

__metaclass__ = type

DOCUMENTATION = """
author: Ansible Networking Team
connection: netconf
short_description: Provides a persistent connection using the netconf protocol
description:
- This connection plugin provides a connection to remote devices over the SSH NETCONF
  subsystem.  This connection plugin is typically used by network devices for sending
  and receiving RPC calls over NETCONF.
- Note this connection plugin requires ncclient to be installed on the local Ansible
  controller.
version_added: 1.0.0
requirements:
- ncclient
extends_documentation_fragment:
- ansible.netcommon.connection_persistent
options:
  import_modules:
    type: boolean
    description:
    - Reduce CPU usage and network module execution time
      by enabling direct execution. Instead of the module being packaged
      and executed by the shell, it will be directly executed by the Ansible
      control node using the same python interpreter as the Ansible process.
      Note- Incompatible with C(asynchronous mode).
      Note- Python 3 and Ansible 2.9.16 or greater required.
      Note- With Ansible 2.9.x fully qualified modules names are required in tasks.
    default: false
    ini:
    - section: ansible_network
      key: import_modules
    env:
    - name: ANSIBLE_NETWORK_IMPORT_MODULES
    vars:
    - name: ansible_network_import_modules
  host:
    description:
    - Specifies the remote device FQDN or IP address to establish the SSH connection
      to.
    default: inventory_hostname
    vars:
    - name: ansible_host
  port:
    type: int
    description:
    - Specifies the port on the remote device that listens for connections when establishing
      the SSH connection.
    default: 830
    ini:
    - section: defaults
      key: remote_port
    env:
    - name: ANSIBLE_REMOTE_PORT
    vars:
    - name: ansible_port
  network_os:
    description:
    - Configures the device platform network operating system.  This value is used
      to load a device specific netconf plugin.  If this option is not configured
      (or set to C(auto)), then Ansible will attempt to guess the correct network_os
      to use. If it can not guess a network_os correctly it will use C(default).
    vars:
    - name: ansible_network_os
  remote_user:
    description:
    - The username used to authenticate to the remote device when the SSH connection
      is first established.  If the remote_user is not specified, the connection will
      use the username of the logged in user.
    - Can be configured from the CLI via the C(--user) or C(-u) options.
    ini:
    - section: defaults
      key: remote_user
    env:
    - name: ANSIBLE_REMOTE_USER
    vars:
    - name: ansible_user
  password:
    description:
    - Configures the user password used to authenticate to the remote device when
      first establishing the SSH connection.
    vars:
    - name: ansible_password
    - name: ansible_ssh_pass
    - name: ansible_ssh_password
    - name: ansible_netconf_password
  private_key_file:
    description:
    - The private SSH key or certificate file used to authenticate to the remote device
      when first establishing the SSH connection.
    ini:
    - section: defaults
      key: private_key_file
    env:
    - name: ANSIBLE_PRIVATE_KEY_FILE
    vars:
    - name: ansible_private_key_file
  look_for_keys:
    default: true
    description:
    - Enables looking for ssh keys in the usual locations for ssh keys (e.g. :file:`~/.ssh/id_*`).
    env:
    - name: ANSIBLE_PARAMIKO_LOOK_FOR_KEYS
    ini:
    - section: paramiko_connection
      key: look_for_keys
    type: boolean
  host_key_checking:
    description: Set this to "False" if you want to avoid host key checking by the
      underlying tools Ansible uses to connect to the host
    type: boolean
    default: true
    env:
    - name: ANSIBLE_HOST_KEY_CHECKING
    - name: ANSIBLE_SSH_HOST_KEY_CHECKING
    - name: ANSIBLE_NETCONF_HOST_KEY_CHECKING
    ini:
    - section: defaults
      key: host_key_checking
    - section: paramiko_connection
      key: host_key_checking
    vars:
    - name: ansible_host_key_checking
    - name: ansible_ssh_host_key_checking
    - name: ansible_netconf_host_key_checking
  proxy_command:
    default: ''
    description:
      - Proxy information for running the connection via a jumphost.
      - This requires ncclient >= 0.6.10 to be installed on the controller.
    env:
      - name: ANSIBLE_NETCONF_PROXY_COMMAND
    ini:
      - {key: proxy_command, section: paramiko_connection}
    vars:
      - name: ansible_paramiko_proxy_command
      - name: ansible_netconf_proxy_command
  netconf_ssh_config:
    description:
    - This variable is used to enable bastion/jump host with netconf connection. If
      set to True the bastion/jump host ssh settings should be present in ~/.ssh/config
      file, alternatively it can be set to custom ssh configuration file path to read
      the bastion/jump host settings.
    ini:
    - section: netconf_connection
      key: ssh_config
    env:
    - name: ANSIBLE_NETCONF_SSH_CONFIG
    vars:
    - name: ansible_netconf_ssh_config
"""

import os
import logging
import json

from ansible.errors import AnsibleConnectionFailure, AnsibleError
from ansible.module_utils._text import to_bytes, to_native, to_text
from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.six import PY3
from ansible.module_utils.six.moves import cPickle
from ansible.module_utils.parsing.convert_bool import (
    BOOLEANS_TRUE,
    BOOLEANS_FALSE,
)
from ansible.playbook.play_context import PlayContext
from ansible.plugins.loader import netconf_loader
from ansible.plugins.connection import NetworkConnectionBase, ensure_connect
from distutils.version import LooseVersion

try:
    from ncclient import __version__ as NCCLIENT_VERSION
    from ncclient import manager
    from ncclient.operations import RPCError
    from ncclient.transport.errors import (
        AuthenticationError,
        SSHUnknownHostError,
    )
    from ncclient.xml_ import to_ele, to_xml
    from paramiko import ProxyCommand

    HAS_NCCLIENT = True
    NCCLIENT_IMP_ERR = None
# paramiko and gssapi are incompatible and raise AttributeError not ImportError
# When running in FIPS mode, cryptography raises InternalError
# https://bugzilla.redhat.com/show_bug.cgi?id=1778939
except Exception as err:
    HAS_NCCLIENT = False
    NCCLIENT_IMP_ERR = err

logging.getLogger("ncclient").setLevel(logging.INFO)


class Connection(NetworkConnectionBase):
    """NetConf connections"""

    transport = "ansible.netcommon.netconf"
    has_pipelining = False

    def __init__(self, play_context, new_stdin, *args, **kwargs):
        super(Connection, self).__init__(
            play_context, new_stdin, *args, **kwargs
        )

        # If network_os is not specified then set the network os to auto
        # This will be used to trigger the use of guess_network_os when connecting.
        self._network_os = self._network_os or "auto"

        self.netconf = netconf_loader.get(self._network_os, self)
        if self.netconf:
            self._sub_plugin = {
                "type": "netconf",
                "name": self.netconf._load_name,
                "obj": self.netconf,
            }
            self.queue_message(
                "vvvv",
                "loaded netconf plugin %s from path %s for network_os %s"
                % (
                    self.netconf._load_name,
                    self.netconf._original_path,
                    self._network_os,
                ),
            )
        else:
            self.netconf = netconf_loader.get("default", self)
            self._sub_plugin = {
                "type": "netconf",
                "name": "default",
                "obj": self.netconf,
            }
            self.queue_message(
                "vvvv",
                "unable to load netconf plugin for network_os %s, falling back to default plugin"
                % self._network_os,
            )

        self.queue_message("log", "network_os is set to %s" % self._network_os)
        self._manager = None
        self.key_filename = None
        self._ssh_config = None

    def exec_command(self, cmd, in_data=None, sudoable=True):
        """Sends the request to the node and returns the reply
        The method accepts two forms of request.  The first form is as a byte
        string that represents xml string be send over netconf session.
        The second form is a json-rpc (2.0) byte string.
        """
        if self._manager:
            # to_ele operates on native strings
            request = to_ele(to_native(cmd, errors="surrogate_or_strict"))

            if request is None:
                return "unable to parse request"

            try:
                reply = self._manager.rpc(request)
            except RPCError as exc:
                error = self.internal_error(
                    data=to_text(to_xml(exc.xml), errors="surrogate_or_strict")
                )
                return json.dumps(error)

            return reply.data_xml
        else:
            return super(Connection, self).exec_command(cmd, in_data, sudoable)

    def update_play_context(self, pc_data):
        """Updates the play context information for the connection"""
        pc_data = to_bytes(pc_data)
        if PY3:
            pc_data = cPickle.loads(pc_data, encoding="bytes")
        else:
            pc_data = cPickle.loads(pc_data)
        play_context = PlayContext()
        play_context.deserialize(pc_data)
        self._play_context = play_context

    @property
    @ensure_connect
    def manager(self):
        return self._manager

    def _get_proxy_command(self, port=22):
        proxy_command = None

        # TO-DO: Add logic to scan ssh_* args to read ProxyCommand

        proxy_command = self.get_option("proxy_command")

        sock = None
        if proxy_command:
            if LooseVersion(NCCLIENT_VERSION) < LooseVersion("0.6.10"):
                raise AnsibleError(
                    "Configuring jumphost settings through ProxyCommand is unsupported in ncclient version %s. "
                    "Please upgrade to ncclient 0.6.10 or newer."
                    % NCCLIENT_VERSION
                )

            replacers = {
                "%h": self._play_context.remote_addr,
                "%p": port,
                "%r": self._play_context.remote_user,
            }

            for find, replace in replacers.items():
                proxy_command = proxy_command.replace(find, str(replace))
            sock = ProxyCommand(proxy_command)

        return sock

    def _connect(self):
        if not HAS_NCCLIENT:
            raise AnsibleError(
                "%s: %s"
                % (
                    missing_required_lib("ncclient"),
                    to_native(NCCLIENT_IMP_ERR),
                )
            )

        self.queue_message("log", "ssh connection done, starting ncclient")

        allow_agent = True
        if self._play_context.password is not None:
            allow_agent = False
        setattr(self._play_context, "allow_agent", allow_agent)

        self.key_filename = (
            self._play_context.private_key_file
            or self.get_option("private_key_file")
        )
        if self.key_filename:
            self.key_filename = str(os.path.expanduser(self.key_filename))

        self._ssh_config = self.get_option("netconf_ssh_config")
        if self._ssh_config in BOOLEANS_TRUE:
            self._ssh_config = True
        elif self._ssh_config in BOOLEANS_FALSE:
            self._ssh_config = None

        # Try to guess the network_os if the network_os is set to auto
        if self._network_os == "auto":
            for cls in netconf_loader.all(class_only=True):
                network_os = cls.guess_network_os(self)
                if network_os:
                    self.queue_message(
                        "vvv", "discovered network_os %s" % network_os
                    )
                    self._network_os = network_os

        # If we have tried to detect the network_os but were unable to i.e. network_os is still 'auto'
        # then use default as the network_os

        if self._network_os == "auto":
            # Network os not discovered. Set it to default
            self.queue_message(
                "vvv",
                "Unable to discover network_os. Falling back to default.",
            )
            self._network_os = "default"
        try:
            ncclient_device_handler = self.netconf.get_option(
                "ncclient_device_handler"
            )
        except KeyError:
            ncclient_device_handler = "default"
        self.queue_message(
            "vvv",
            "identified ncclient device handler: %s."
            % ncclient_device_handler,
        )
        device_params = {"name": ncclient_device_handler}

        try:
            port = self._play_context.port or 830
            self.queue_message(
                "vvv",
                "ESTABLISH NETCONF SSH CONNECTION FOR USER: %s on PORT %s TO %s WITH SSH_CONFIG = %s"
                % (
                    self._play_context.remote_user,
                    port,
                    self._play_context.remote_addr,
                    self._ssh_config,
                ),
            )

            params = dict(
                host=self._play_context.remote_addr,
                port=port,
                username=self._play_context.remote_user,
                password=self._play_context.password,
                key_filename=self.key_filename,
                hostkey_verify=self.get_option("host_key_checking"),
                look_for_keys=self.get_option("look_for_keys"),
                device_params=device_params,
                allow_agent=self._play_context.allow_agent,
                timeout=self.get_option("persistent_connect_timeout"),
                ssh_config=self._ssh_config,
            )
            # sock is only supported by ncclient >= 0.6.10, and will error if
            # included on older versions. We check the version in
            # _get_proxy_command, so if this returns a value, the version is
            # fine and we have something to send. Otherwise, don't even send
            # the option to support older versions of ncclient
            sock = self._get_proxy_command(port)
            if sock:
                params["sock"] = sock

            self._manager = manager.connect(**params)

            self._manager._timeout = self.get_option(
                "persistent_command_timeout"
            )
        except SSHUnknownHostError as exc:
            raise AnsibleConnectionFailure(to_native(exc))
        except AuthenticationError as exc:
            if str(exc).startswith("FileNotFoundError"):
                raise AnsibleError(
                    "Encountered FileNotFoundError in ncclient connect. Does {0} exist?".format(
                        self.key_filename
                    )
                )
            raise
        except ImportError:
            raise AnsibleError(
                "connection=netconf is not supported on {0}".format(
                    self._network_os
                )
            )

        if not self._manager.connected:
            return 1, b"", b"not connected"

        self.queue_message(
            "log", "ncclient manager object created successfully"
        )

        self._connected = True

        super(Connection, self)._connect()

        return (
            0,
            to_bytes(self._manager.session_id, errors="surrogate_or_strict"),
            b"",
        )

    def close(self):
        if self._manager:
            self._manager.close_session()
        super(Connection, self).close()
