#
# iscsi.py - iscsi class
#
# Copyright (C) 2005, 2006  IBM, Inc.  All rights reserved.
# Copyright (C) 2006  Red Hat, Inc.  All rights reserved.
#
# This program 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 2 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

from . import errors
from . import udev
from . import util
from .flags import flags
from .i18n import _
import os
import re
import shutil
import time
import itertools
from collections import namedtuple

import gi
gi.require_version("BlockDev", "3.0")
from gi.repository import BlockDev

from dasbus.error import DBusError
from dasbus.typing import get_variant, Str

import logging
log = logging.getLogger("blivet")

# Note that stage2 copies all files under /sbin to /usr/sbin
ISCSID = ""
INITIATOR_FILE = "/etc/iscsi/initiatorname.iscsi"

ISCSI_MODULES = ['cxgb3i', 'bnx2i', 'be2iscsi']


UDISKS_SERVICE = "org.freedesktop.UDisks2"
UDISKS_PATH = "/org/freedesktop/UDisks2"
UDISKS_MANAGER_PATH = "/org/freedesktop/UDisks2/Manager"
MANAGER_IFACE = "org.freedesktop.UDisks2.Manager"
INITIATOR_IFACE = MANAGER_IFACE + ".ISCSI.Initiator"
SESSION_IFACE = UDISKS_SERVICE + ".ISCSI.Session"


def has_iscsi():
    global ISCSID

    if not os.access("/sys/module/iscsi_tcp", os.X_OK):
        return False

    if not ISCSID:
        location = shutil.which("iscsid")
        if not location:
            return False
        ISCSID = location
        log.info("ISCSID is %s", ISCSID)

    return True


TargetInfo = namedtuple("TargetInfo", ["ipaddr", "port"])


class NodeInfo(object):
    """Simple representation of node information."""
    def __init__(self, name, tpgt, address, port, iface):
        self.name = name
        self.tpgt = tpgt
        self.address = address
        self.port = port
        self.iface = iface
        # These get set by log_into_node, but *NOT* _login
        self.username = None
        self.password = None
        self.r_username = None
        self.r_password = None

    @property
    def conn_info(self):
        """The 5-tuple of connection info (no auth info). This form
        is useful for interacting with udisks.
        """
        return (self.name, self.tpgt, self.address, self.port, self.iface)


class LoginInfo(object):
    def __init__(self, node, logged_in):
        self.node = node
        self.logged_in = logged_in


def _to_node_infos(variant):
    """Transforms an 'a(sisis)' GLib.Variant into a list of NodeInfo objects"""
    return [NodeInfo(*info) for info in variant]


class iSCSIDependencyGuard(util.DependencyGuard):
    error_msg = "UDisks iSCSI functionality not available"

    def _check_avail(self):
        try:
            if not util.check_object_available(UDISKS_SERVICE, UDISKS_MANAGER_PATH, MANAGER_IFACE):
                return False
            # Load the iscsi UDisks module (this also autostarts udisksd if needed)
            proxy = util.SystemBus.get_proxy(UDISKS_SERVICE, UDISKS_MANAGER_PATH, MANAGER_IFACE)
            proxy.EnableModule("iscsi", True)
        except DBusError:
            return False
        return util.check_object_available(UDISKS_SERVICE, UDISKS_MANAGER_PATH, INITIATOR_IFACE)


udisks_iscsi_required = iSCSIDependencyGuard()


class iSCSI(object):
    """ iSCSI utility class.

        This class will automatically discover and login to iBFT (or
        other firmware) configured iscsi devices when the startup() method
        gets called. It can also be used to manually configure iscsi devices
        through the add_target() method.

        As this class needs to make sure certain things like starting iscsid
        and logging in to firmware discovered disks only happens once
        and as it keeps a global list of all iSCSI devices it is implemented as
        a Singleton.

        .. warning::
            Since this is a singleton class, calling deepcopy() on the instance
            just returns ``self`` with no copy being created.
    """

    def __init__(self):
        # Dictionary mapping discovered TargetInfo data to lists of LoginInfo
        # data.
        self.discovered_targets = {}
        # This list contains nodes discovered through iBFT (or other firmware)
        self.ibft_nodes = []
        self._initiator = ""
        self.started = False
        self.ifaces = {}

        if flags.ibft:
            try:
                initiatorname = self._call_initiator_method("GetFirmwareInitiatorName")[0]
                self._initiator = initiatorname
            except Exception as e:  # pylint: disable=broad-except
                log.info("failed to get initiator name from iscsi firmware: %s", str(e))
            else:
                # write the firmware initiator to /etc/iscsi/initiatorname.iscsi
                log.info("Setting up firmware iSCSI initiator name %s", self.initiator)
                self._call_initiator_method("SetInitiatorName", (initiatorname, None))

    # So that users can write iscsi() to get the singleton instance
    def __call__(self):
        return self

    def __deepcopy__(self, memo_dict):
        # pylint: disable=unused-argument
        return self

    @property
    @udisks_iscsi_required(critical=False, eval_mode=util.EvalMode.onetime)
    def available(self):
        return True

    @udisks_iscsi_required(critical=True, eval_mode=util.EvalMode.onetime)
    def _call_initiator_method(self, method, args=None):
        """Class a method of the ISCSI.Initiator DBus object

        :param str method: name of the method to call
        :param params: arguments to pass to the method
        :type params: GLib.Variant

        """
        proxy = util.SystemBus.get_proxy(UDISKS_SERVICE, UDISKS_MANAGER_PATH, INITIATOR_IFACE)
        try:
            ret = getattr(proxy, method)(*args)
        except DBusError as e:
            raise errors.ISCSIError(str(e)) from e
        else:
            return ret

    @property
    def initiator_set(self):
        """True if initiator is set at our level."""
        return self._initiator != ""

    @property
    @udisks_iscsi_required(critical=False, eval_mode=util.EvalMode.onetime)
    def initiator(self):
        if self._initiator != "":
            return self._initiator

        # udisks returns initiatorname as a NULL terminated bytearray
        raw_initiator = bytes(self._call_initiator_method("GetInitiatorNameRaw", [])[:-1])
        return raw_initiator.decode("utf-8", errors="replace")

    @initiator.setter
    @udisks_iscsi_required(critical=True, eval_mode=util.EvalMode.onetime)
    def initiator(self, val):
        if len(val) == 0:
            raise ValueError(_("Must provide an iSCSI initiator name"))

        active = self._get_active_sessions()
        if active:
            raise errors.ISCSIError(_("Cannot change initiator name with an active session"))

        log.info("Setting up iSCSI initiator name %s", self.initiator)
        self._call_initiator_method("SetInitiatorName", (val, None))

        if self.initiator_set and val != self._initiator:
            log.info("Restarting iscsid after initiator name change")
            rc = util.run_program(["systemctl", "restart", "iscsid"])
            if rc != 0:
                raise errors.ISCSIError(_("Failed to restart iscsid after initiator name change"))

        self._initiator = val

    def active_nodes(self, target=None):
        """Nodes logged in to"""
        if target:
            return [info.node for info in self.discovered_targets.get(target, [])
                    if info.logged_in]
        else:
            return [info.node for info in itertools.chain(*list(self.discovered_targets.values()))
                    if info.logged_in] + self.ibft_nodes

    @property
    def mode(self):
        if not self.active_nodes():
            return "none"
        if self.ifaces:
            return "bind"
        else:
            return "default"

    def _mark_node_active(self, node, active=True):
        """Mark node as one logged in to

           Returns False if not found
        """
        for login_infos in self.discovered_targets.values():
            for info in login_infos:
                if info.node is node:
                    info.logged_in = active
                    return True
        return False

    def _login(self, node_info, extra=None):
        """Try to login to the iSCSI node

        :type node_info: :class:`NodeInfo`
        :param dict extra: extra configuration for the node (e.g. authentication info)
        :raises :class:`~.errors.ISCSIError`: if login fails

        """

        if extra is None:
            extra = dict()
        extra["node.startup"] = get_variant(Str, "onboot")
        extra["node.session.auth.chap_algs"] = get_variant(Str, "SHA1,MD5")

        args = node_info.conn_info + (extra,)
        self._call_initiator_method("Login", args)

    @udisks_iscsi_required(critical=False, eval_mode=util.EvalMode.onetime)
    def _get_active_sessions(self):
        try:
            proxy = util.SystemBus.get_proxy(UDISKS_SERVICE, UDISKS_PATH,
                                             'org.freedesktop.DBus.ObjectManager')
            objects = proxy.GetManagedObjects()
        except DBusError as e:
            log.info("iscsi: Failed to get active sessions: %s", str(e))
            return []

        sessions = (obj for obj in objects.keys() if re.match(r'.*/iscsi/session[0-9]+$', obj))

        active = []
        for session in sessions:
            properties = objects[session][SESSION_IFACE]
            active.append(NodeInfo(properties["target_name"],
                                   properties["tpgt"],
                                   properties["persistent_address"],
                                   properties["persistent_port"],
                                   None))

        return active

    @udisks_iscsi_required(critical=False, eval_mode=util.EvalMode.onetime)
    def _start_ibft(self):
        if not flags.ibft:
            return

        # Make sure iscsi_ibft is loaded otherwise any attempts will fail with
        # 'Could not get list of targets from firmware. (err 21)'
        try:
            BlockDev.utils.load_kernel_module("iscsi_ibft", None)
        except BlockDev.UtilsError as e:
            log.error("failed to load iscsi_ibft: %s", str(e))

        try:
            found_nodes, _n_nodes = self._call_initiator_method("DiscoverFirmware", ([], ))
        except errors.ISCSIError as e:
            log.info("iscsi: No IBFT info found: %s", str(e))
            # an exception here means there is no ibft firmware, just return
            return

        found_nodes = _to_node_infos(found_nodes)
        active_nodes = self._get_active_sessions()
        for node in found_nodes:
            if any(node.name == a.name and node.tpgt == a.tpgt and
                   node.address == a.address and node.port == a.port for a in active_nodes):
                log.info("iscsi IBFT: already logged in node %s at %s:%s through %s",
                         node.name, node.address, node.port, node.iface)
                self.ibft_nodes.append(node)
            try:
                self._login(node)
                log.info("iscsi IBFT: logged into %s at %s:%s through %s",
                         node.name, node.address, node.port, node.iface)
                self.ibft_nodes.append(node)
            except errors.ISCSIError as e:
                log.error("Could not log into ibft iscsi target %s: %s",
                          node.name, str(e))

        self.stabilize()

    def stabilize(self):
        # Wait for udev to create the devices for the just added disks

        # It is possible when we get here the events for the new devices
        # are not send yet, so sleep to make sure the events are fired
        time.sleep(2)
        udev.settle()

    def create_interfaces(self, ifaces):
        for iface in ifaces:
            iscsi_iface_name = "iface%d" % len(self.ifaces)
            # iscsiadm -m iface -I iface0 --op=new
            util.run_program(["iscsiadm", "-m", "iface",
                              "-I", iscsi_iface_name, "--op=new"])
            # iscsiadm -m iface -I iface0 --op=update -n iface.net_ifacename -v eth0
            util.run_program(["iscsiadm", "-m", "iface",
                              "-I", iscsi_iface_name, "--op=update",
                              "-n", "iface.net_ifacename", "-v", iface])

            self.ifaces[iscsi_iface_name] = iface
            log.debug("created_interface %s:%s", iscsi_iface_name, iface)

    def delete_interfaces(self):
        if not self.ifaces:
            return None
        for iscsi_iface_name in self.ifaces:
            # iscsiadm -m iface -I iface0 --op=delete
            util.run_program(["iscsiadm", "-m", "iface",
                              "-I", iscsi_iface_name, "--op=delete"])
        self.ifaces = {}

    def startup(self):
        if self.started:
            return

        if not has_iscsi():
            return

        # make sure that the file /etc/iscsi/initiatorname.iscsi exists
        util.run_program(["systemctl", "start", "iscsi-init.service"])

        if self._initiator == "":
            log.info("no initiator set")
            return

        for fulldir in (os.path.join("/var/lib/iscsi", d) for d in
                        ['ifaces', 'isns', 'nodes', 'send_targets', 'slp', 'static']):
            if not os.path.isdir(fulldir):
                os.makedirs(fulldir, 0o755)

        log.info("iSCSI startup")
        for module in ISCSI_MODULES:
            try:
                BlockDev.utils.load_kernel_module(module, None)
            except BlockDev.UtilsError as e:
                log.error("failed to load %s: %s", module, str(e))

        # iscsiuio is needed by Broadcom offload cards (bnx2i). Currently
        # not present in iscsi-initiator-utils for Fedora.
        iscsiuio = shutil.which('iscsiuio')
        if iscsiuio:
            log.debug("iscsi: iscsiuio is at %s", iscsiuio)
            util.run_program([iscsiuio])
        else:
            log.info("iscsi: iscsiuio not found.")

        # run the daemon
        util.run_program([ISCSID])
        time.sleep(1)

        self._start_ibft()
        self.started = True

    def discover(self, ipaddr, port="3260", username=None, password=None,
                 r_username=None, r_password=None):
        """
        Discover iSCSI nodes on the target available for login.

        If we are logged in a node discovered for specified target
        do not do the discovery again as it can corrupt credentials
        stored for the node (set_auth and get_auth are using database
        in /var/lib/iscsi/nodes which is filled by discovery). Just
        return nodes obtained and stored in the first discovery
        instead.

        Returns list of nodes user can log in.
        """

        if not has_iscsi():
            raise errors.ISCSIError(_("iSCSI not available"))
        if self._initiator == "":
            raise ValueError(_("No initiator name set"))

        if self.active_nodes(TargetInfo(ipaddr, port)):
            log.debug("iSCSI: skipping discovery of %s:%s due to active nodes",
                      ipaddr, port)
        else:
            self.startup()
            auth_info = dict()
            if username:
                auth_info["username"] = get_variant(Str, username)
            if password:
                auth_info["password"] = get_variant(Str, password)
            if r_username:
                auth_info["reverse-username"] = get_variant(Str, r_username)
            if r_password:
                auth_info["reverse-password"] = get_variant(Str, r_password)

            nodes, _n_nodes = self._call_initiator_method("DiscoverSendTargets", (ipaddr, int(port), auth_info))

            found_nodes = _to_node_infos(nodes)
            t_info = TargetInfo(ipaddr, port)
            self.discovered_targets[t_info] = []
            for node in found_nodes:
                self.discovered_targets[t_info].append(LoginInfo(node, False))
                log.debug("discovered iSCSI node: %s", node.name)

        # only return the nodes we are not logged into yet
        return [info.node for info in self.discovered_targets[TargetInfo(ipaddr, port)]
                if not info.logged_in]

    def log_into_node(self, node, username=None, password=None,
                      r_username=None, r_password=None):
        """
        :param node: node to log into
        :type node: :class:`NodeInfo`
        :param str username: username to use when logging in
        :param str password: password to use when logging in
        :param str r_username: r_username to use when logging in
        :param str r_password: r_password to use when logging in
        """

        rc = False  # assume failure
        msg = ""

        auth_info = dict()
        if username:
            auth_info["username"] = get_variant(Str, username)
        if password:
            auth_info["password"] = get_variant(Str, password)
        if r_username:
            auth_info["reverse-username"] = get_variant(Str, r_username)
        if r_password:
            auth_info["reverse-password"] = get_variant(Str, r_password)

        try:
            self._login(node, auth_info)
            rc = True
            log.info("iSCSI: logged into %s at %s:%s through %s",
                     node.name, node.address, node.port, node.iface)
            if not self._mark_node_active(node):
                log.error("iSCSI: node not found among discovered")
            if username:
                node.username = username
            if password:
                node.password = password
            if r_username:
                node.r_username = r_username
            if r_password:
                node.r_password = r_password
        except errors.ISCSIError as e:
            msg = str(e)
            log.warning("iSCSI: could not log into %s: %s", node.name, msg)

        return (rc, msg)

    def add_target(self, ipaddr, port="3260", user=None, pw=None,
                   user_in=None, pw_in=None, target=None, iface=None,
                   discover_user=None, discover_pw=None,
                   discover_user_in=None, discover_pw_in=None):
        """
        Connect to iSCSI server specified by IP address and port
        and add all targets found on the server and authenticate if necessary.
        If the target parameter is set, connect only to this target.

        NOTE: the iSCSI target can have two sets of different authentication
              credentials - one for discovery and one for logging into nodes

        :param str ipaddr: target IP address
        :param str port: target port
        :param user: CHAP username for node login
        :type user: str or NoneType
        :param pw: CHAP password for node login
        :type pw: str or NoneType
        :param user_in: reverse CHAP username for node login
        :type user: str or NoneType
        :param pw_in: reverse CHAP password for node login
        :type pw_in: str or NoneType
        :param target: only add this target (if present)
        :type target: str or NoneType
        :param iface: interface to use
        :type iface: str or NoneType
        :param discover_user: CHAP username for discovery
        :type discover_user: str or NoneType
        :param discover_pw: CHAP password for discovery
        :type discover_pw: str or NoneType
        :param discover_user_in: reverse CHAP username for discovery
        :type discover_user: str or NoneType
        :param discover_pw_in: reverse CHAP password for discovery
        :type discover_pw_in: str or NoneType
        """

        found = 0
        logged_in = 0

        found_nodes = self.discover(ipaddr, port, discover_user, discover_pw,
                                    discover_user_in, discover_pw_in)
        if found_nodes is None:
            raise errors.ISCSIError(_("No iSCSI nodes discovered"))

        for node in found_nodes:
            if target and target != node.name:
                log.debug("iscsi: skipping logging to iscsi node '%s'", node.name)
                continue
            if iface:
                node_net_iface = self.ifaces.get(node.iface, node.iface)
                if iface != node_net_iface:
                    log.debug("iscsi: skipping logging to iscsi node '%s' via %s",
                              node.name, node_net_iface)
                    continue

            found = found + 1

            (rc, _msg) = self.log_into_node(node, user, pw, user_in, pw_in)
            if rc:
                logged_in = logged_in + 1

        if found == 0:
            raise errors.ISCSIError(_("No new iSCSI nodes discovered"))

        if logged_in == 0:
            raise errors.ISCSIError(_("Could not log in to any of the discovered nodes"))

        self.stabilize()

    def write(self, root, storage=None):  # pylint: disable=unused-argument
        if not self.initiator_set:
            return

        # copy "db" files.  *sigh*
        if os.path.isdir(root + "/var/lib/iscsi"):
            shutil.rmtree(root + "/var/lib/iscsi")
        if os.path.isdir("/var/lib/iscsi"):
            shutil.copytree("/var/lib/iscsi", root + "/var/lib/iscsi",
                            symlinks=True)

        # copy the initiator file too
        if not os.path.isdir(root + "/etc/iscsi"):
            os.makedirs(root + "/etc/iscsi", 0o755)
        shutil.copyfile(INITIATOR_FILE, root + INITIATOR_FILE)

    def get_node(self, name, address, port, iface):
        for node in self.active_nodes():
            if node.name == name and node.address == address and \
               node.port == int(port) and node.iface == iface:
                return node

        return None

    def get_node_disks(self, node, storage):
        node_disks = []
        iscsi_disks = (d for d in storage.devices if d.type == "iscsi")
        for disk in iscsi_disks:
            if disk.node == node:
                node_disks.append(disk)

        return node_disks


# Create iscsi singleton
iscsi = iSCSI()
""" An instance of :class:`iSCSI` """
