# Copyright 2017 Red Hat, Inc.
# All Rights Reserved.
# Copyright (c) 2020-2021 Dell Inc. or its subsidiaries.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

import collections
import hashlib
import os
from urllib import parse as urlparse

from oslo_log import log
from oslo_utils import excutils
from oslo_utils import strutils
import rfc3986
import sushy
import tenacity

from ironic.common import exception
from ironic.common.i18n import _
from ironic.common import utils
from ironic.conf import CONF

LOG = log.getLogger(__name__)

REQUIRED_PROPERTIES = {
    'redfish_address': _('The URL address to the Redfish controller. It '
                         'must include the authority portion of the URL. '
                         'If the scheme is missing, https is assumed. '
                         'For example: https://mgmt.vendor.com. '
                         'If a path is added, it will be used as the API '
                         'endpoint root_prefix. Required'),
}

OPTIONAL_PROPERTIES = {
    'redfish_system_id': _('The canonical path to the ComputerSystem '
                           'resource that the driver will interact with. '
                           'It should include the root service, version and '
                           'the unique resource path to a ComputerSystem '
                           'within the same authority as the redfish_address '
                           'property. For example: /redfish/v1/Systems/1. '
                           'This property is only required if target BMC '
                           'manages more than one ComputerSystem. Otherwise '
                           'ironic will pick the only available '
                           'ComputerSystem automatically.'),
    'redfish_username': _('User account with admin/server-profile access '
                          'privilege. Although this property is not '
                          'mandatory it\'s highly recommended to set a '
                          'username. Optional'),
    'redfish_password': _('User account password. Although this property is '
                          'not mandatory, it\'s highly recommended to set a '
                          'password. Optional'),
    'redfish_verify_ca': _('Either a Boolean value, a path to a CA_BUNDLE '
                           'file or directory with certificates of trusted '
                           'CAs. If set to True the driver will verify the '
                           'host certificates; if False the driver will '
                           'ignore verifying the SSL certificate. If it\'s '
                           'a path the driver will use the specified '
                           'certificate or one of the certificates in the '
                           'directory. Defaults to True. Optional'),
    'redfish_auth_type': _('Redfish HTTP client authentication method. Can be '
                           '"basic", "session" or "auto". If not set, the '
                           'default value is taken from Ironic '
                           'configuration as ``[redfish]auth_type`` option.')
}

COMMON_PROPERTIES = REQUIRED_PROPERTIES.copy()
COMMON_PROPERTIES.update(OPTIONAL_PROPERTIES)


# All available FIRMWARE COMPONENTS
BIOS = 'bios'
"BIOS Firmware Component"

BMC = 'bmc'
"BMC Firmware Component"

FIRMWARE_COMPONENTS = [BIOS, BMC]
"""Firmware Components available to update"""


def parse_driver_info(node):
    """Parse the information required for Ironic to connect to Redfish.

    :param node: an Ironic node object
    :returns: dictionary of parameters
    :raises: InvalidParameterValue on malformed parameter(s)
    :raises: MissingParameterValue on missing parameter(s)
    """
    driver_info = node.driver_info or {}
    missing_info = [key for key in REQUIRED_PROPERTIES
                    if not driver_info.get(key)]
    if missing_info:
        raise exception.MissingParameterValue(_(
            'Missing the following Redfish properties in node '
            '%(node)s driver_info: %(info)s') % {'node': node.uuid,
                                                 'info': missing_info})

    # Validate the Redfish address
    address = utils.wrap_ipv6(driver_info['redfish_address'])
    try:
        parsed = rfc3986.uri_reference(address)
    except TypeError:
        raise exception.InvalidParameterValue(
            _('Invalid Redfish address %(address)s set in '
              'driver_info/redfish_address on node %(node)s') %
            {'address': address, 'node': node.uuid})

    if not parsed.scheme or not parsed.authority:
        address = 'https://%s' % address
        parsed = rfc3986.uri_reference(address)
    validator = rfc3986.validators.Validator().require_presence_of(
        'scheme', 'host',
    ).check_validity_of(
        'scheme', 'userinfo', 'host', 'path', 'query', 'fragment',
    )
    try:
        validator.validate(parsed)
    except rfc3986.exceptions.RFC3986Exception:
        raise exception.InvalidParameterValue(
            _('Invalid Redfish address %(address)s set in '
              'driver_info/redfish_address on node %(node)s') %
            {'address': address, 'node': node.uuid})
    address = '{}://{}'.format(parsed.scheme, parsed.authority)

    # Obtain the Redfish root prefix from the address path
    # If not specified, default to '/redfish/v1/'
    root_prefix = parsed.path

    redfish_system_id = driver_info.get('redfish_system_id')
    if redfish_system_id is not None:
        try:
            redfish_system_id = urlparse.quote(redfish_system_id)
        except (TypeError, AttributeError):
            raise exception.InvalidParameterValue(
                _('Invalid value "%(value)s" set in '
                  'driver_info/redfish_system_id on node %(node)s. '
                  'The value should be a path (string) to the resource '
                  'that the driver will interact with. For example: '
                  '/redfish/v1/Systems/1') %
                {'value': driver_info['redfish_system_id'], 'node': node.uuid})

    # Check if verify_ca is a Boolean or a file/directory in the file-system
    verify_ca = driver_info.get('redfish_verify_ca', True)
    if isinstance(verify_ca, str):
        if os.path.isdir(verify_ca) or os.path.isfile(verify_ca):
            pass
        else:
            try:
                verify_ca = strutils.bool_from_string(verify_ca, strict=True)
            except ValueError:
                raise exception.InvalidParameterValue(
                    _('Invalid value type set in driver_info/'
                      'redfish_verify_ca on node %(node)s. '
                      'The value should be a Boolean or the path '
                      'to a file/directory, not "%(value)s"'
                      ) % {'value': verify_ca, 'node': node.uuid})
    elif isinstance(verify_ca, bool):
        # If it's a boolean it's grand, we don't need to do anything
        pass
    else:
        raise exception.InvalidParameterValue(
            _('Invalid value type set in driver_info/redfish_verify_ca '
              'on node %(node)s. The value should be a Boolean or the path '
              'to a file/directory, not "%(value)s"') % {'value': verify_ca,
                                                         'node': node.uuid})

    auth_type = driver_info.get('redfish_auth_type', CONF.redfish.auth_type)
    if auth_type not in ('basic', 'session', 'auto'):
        raise exception.InvalidParameterValue(
            _('Invalid value "%(value)s" set in '
              'driver_info/redfish_auth_type on node %(node)s. '
              'The value should be one of "basic", "session" or "auto".') %
            {'value': auth_type, 'node': node.uuid})

    sushy_params = {'address': address,
                    'system_id': redfish_system_id,
                    'username': driver_info.get('redfish_username'),
                    'password': driver_info.get('redfish_password'),
                    'verify_ca': verify_ca,
                    'auth_type': auth_type,
                    'node_uuid': node.uuid}
    if root_prefix:
        sushy_params['root_prefix'] = root_prefix

    return sushy_params


class SessionCache(object):
    """Cache of HTTP sessions credentials"""

    AUTH_CLASSES = dict(
        basic=sushy.auth.BasicAuth,
        session=sushy.auth.SessionAuth,
        auto=sushy.auth.SessionOrBasicAuth
    )

    _sessions = collections.OrderedDict()

    def __init__(self, driver_info):
        # Hash the password in the data structure, so we can
        # include it in the session key.
        # NOTE(TheJulia): Multiplying the address by 4, to ensure
        # we meet a minimum of 16 bytes for salt.
        # NOTE(frickler): password may be None, make sure we have a str
        password = driver_info.get('password')
        if not password:
            password = ''
        pw_hash = hashlib.pbkdf2_hmac(
            'sha512',
            password.encode('utf-8'),
            str(driver_info.get('address') * 4).encode('utf-8'), 40)
        self._driver_info = driver_info
        # Assemble the session key and append the hashed password to it,
        # which forces new sessions to be established when the saved password
        # is changed, just like the username, or address.
        self._session_key = tuple(
            self._driver_info.get(key)
            for key in ('address', 'username', 'verify_ca')
        ) + (pw_hash.hex(),)

    def __enter__(self):
        try:
            return self.__class__._sessions[self._session_key]
        except KeyError:
            LOG.debug('A cached redfish session for Redfish endpoint '
                      '%(endpoint)s was not detected, initiating a session.',
                      {'endpoint': self._driver_info['address']})

        auth_type = self._driver_info['auth_type']

        auth_class = self.AUTH_CLASSES[auth_type]

        authenticator = auth_class(
            username=self._driver_info['username'],
            password=self._driver_info['password']
        )

        sushy_params = {'verify': self._driver_info['verify_ca'],
                        'auth': authenticator}
        if 'root_prefix' in self._driver_info:
            sushy_params['root_prefix'] = self._driver_info['root_prefix']
        conn = sushy.Sushy(
            self._driver_info['address'],
            **sushy_params
        )

        if CONF.redfish.connection_cache_size:
            self.__class__._sessions[self._session_key] = conn
            # Save a secure hash of the password into memory, so if we
            # observe it change, we can detect the session is no longer valid.

            if (len(self.__class__._sessions)
                    > CONF.redfish.connection_cache_size):
                self._expire_oldest_session()

        return conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        # NOTE(etingof): perhaps this session token is no good
        if isinstance(exc_val, sushy.exceptions.ConnectionError):
            self.__class__._sessions.pop(self._session_key, None)
        # NOTE(TheJulia): A hard access error has surfaced, we
        # likely need to eliminate the session.
        if isinstance(exc_val, sushy.exceptions.AccessError):
            self.__class__._sessions.pop(self._session_key, None)
        # NOTE(TheJulia): Something very bad has happened, such
        # as the session is out of date, and refresh of the SessionService
        # failed resulting in an AttributeError surfacing.
        # https://storyboard.openstack.org/#!/story/2009719
        if isinstance(exc_val, AttributeError):
            self.__class__._sessions.pop(self._session_key, None)

    @classmethod
    def _expire_oldest_session(cls):
        """Expire oldest session"""
        session_keys = list(cls._sessions)
        session_key = next(iter(session_keys))
        # NOTE(etingof): GC should cause sushy to HTTP DELETE session
        # at BMC. Trouble is that contemporary sushy (1.6.0) does
        # does not do that.
        cls._sessions.pop(session_key, None)


def get_update_service(node):
    """Get a node's update service.

    :param node: an Ironic node object
    :raises: RedfishConnectionError when it fails to connect to Redfish
    :raises: RedfishError when the UpdateService is not registered in Redfish
    """

    try:
        return _get_connection(node, lambda conn: conn.get_update_service())
    except sushy.exceptions.MissingAttributeError as e:
        LOG.error('The Redfish UpdateService was not found for '
                  'node %(node)s. Error %(error)s',
                  {'node': node.uuid, 'error': e})
        raise exception.RedfishError(error=e)


def get_event_service(node):
    """Get a node's event service.

    :param node: an Ironic node object.
    :raises: RedfishConnectionError when it fails to connect to Redfish
    :raises: RedfishError when the EventService is not registered in Redfish
    """

    try:
        return _get_connection(node, lambda conn: conn.get_event_service())
    except sushy.exceptions.MissingAttributeError as e:
        LOG.error('The Redfish EventService was not found for '
                  'node %(node)s. Error %(error)s',
                  {'node': node.uuid, 'error': e})
        raise exception.RedfishError(error=e)


def get_first_controller(storage):
    """Get the first storage controller from a storage object.

    :param storage: a storage object
    :returns: the first storage controller or None
    """
    try:
        controllers = storage.controllers
        if not controllers:
            raise sushy.exceptions.MissingAttributeError
        members = controllers.get_members()
        return members[0] if members else None
    except sushy.exceptions.MissingAttributeError:
        controllers = getattr(storage, 'storage_controllers', None)
        return controllers[0] if controllers else None


def get_system(node):
    """Get a Redfish System that represents a node.

    :param node: an Ironic node object
    :raises: RedfishConnectionError when it fails to connect to Redfish
    :raises: RedfishError if the System is not registered in Redfish
    """
    driver_info = parse_driver_info(node)
    system_id = driver_info['system_id']

    try:
        return _get_connection(
            node,
            lambda conn, system_id: conn.get_system(system_id),
            system_id)
    except sushy.exceptions.ResourceNotFoundError as e:
        LOG.error('The Redfish System "%(system)s" was not found for '
                  'node %(node)s. Error %(error)s',
                  {'system': system_id or '<default>',
                   'node': node.uuid, 'error': e})
        raise exception.RedfishError(error=e)


def get_system_collection(node):
    """Get a Redfish System Collection that includes the node

    :param node: an Ironic node object
    :raises: RedfishConnectionError when it fails to connect to Redfish
    :raises: RedfishError if the System is not registered in Redfish
    """
    driver_info = parse_driver_info(node)
    system_id = driver_info['system_id']

    try:
        return _get_connection(
            node,
            lambda conn, system_id: conn.get_system_collection(),
            system_id)
    except sushy.exceptions.ResourceNotFoundError as e:
        LOG.error('The Redfish Systems Collection "%(system)s" was not found'
                  ' for node %(node)s. Error %(error)s',
                  {'system': system_id or '<default>',
                   'node': node.uuid, 'error': e})
        raise exception.RedfishError(error=e)


def get_task_monitor(node, uri):
    """Get a TaskMonitor for a node.

    :param node: an Ironic node object
    :param uri: the URI of a TaskMonitor
    :raises: RedfishConnectionError when it fails to connect to Redfish
    :raises: RedfishError when the TaskMonitor is not available in Redfish
    """

    try:
        return _get_connection(node, lambda conn: conn.get_task_monitor(uri))
    except sushy.exceptions.ResourceNotFoundError as e:
        LOG.error('The Redfish TaskMonitor "%(uri)s" was not found for '
                  'node %(node)s. Error %(error)s',
                  {'uri': uri, 'node': node.uuid, 'error': e})
        raise exception.RedfishError(error=e)


def _get_connection(node, lambda_fun, *args):
    """Get a Redfish connection to a node.

    This method gets a Redfish connection to a node by calling the passed
    lambda function, and returns the sushy object returned by the function.

    :param node: an Ironic node object
    :param lambda_fun: the function to call to retrieve the desired sushy
                       object
    :param args: the arguments to pass to the function
    :returns: the sushy object returned by the lambda function
    :raises: RedfishConnectionError when it fails to connect to Redfish
    """
    driver_info = parse_driver_info(node)

    @tenacity.retry(
        retry=tenacity.retry_if_exception_type(
            exception.RedfishConnectionError),
        stop=tenacity.stop_after_attempt(CONF.redfish.connection_attempts),
        wait=tenacity.wait_fixed(CONF.redfish.connection_retry_interval),
        reraise=True)
    def _get_cached_connection(lambda_fun, *args):
        try:
            with SessionCache(driver_info) as conn:
                return lambda_fun(conn, *args)

        # TODO(lucasagomes): We should look at other types of
        # ConnectionError such as AuthenticationError or SSLError and stop
        # retrying on them
        except sushy.exceptions.ConnectionError as e:
            LOG.warning('For node %(node)s, got a connection error from '
                        'Redfish at address "%(address)s" using auth type '
                        '"%(auth_type)s". Error: %(error)s',
                        {'address': driver_info['address'],
                         'auth_type': driver_info['auth_type'],
                         'node': node.uuid, 'error': e})
            raise exception.RedfishConnectionError(node=node.uuid, error=e)
        except sushy.exceptions.AccessError as e:
            LOG.warning('For node %(node)s, we received an authentication '
                        'access error from address %(address)s with auth_type '
                        '%(auth_type)s. The client will not be re-used upon '
                        'the next re-attempt. Please ensure your using the '
                        'correct credentials. Error: %(error)s',
                        {'address': driver_info['address'],
                         'auth_type': driver_info['auth_type'],
                         'node': node.uuid, 'error': e})
            raise exception.RedfishError(node=node.uuid, error=e)
        except AttributeError as e:
            LOG.warning('For node %(node)s, we received at AttributeError '
                        'when attempting to utilize the client. A new '
                        'client session shall be used upon the next attempt.'
                        'Attribute Error: %(error)s',
                        {'node': node.uuid, 'error': e})
            raise exception.RedfishError(node=node.uuid, error=e)

    try:
        return _get_cached_connection(lambda_fun, *args)
    except exception.RedfishConnectionError as e:
        with excutils.save_and_reraise_exception():
            LOG.error('Failed to connect to Redfish at %(address)s for '
                      'node %(node)s. Error: %(error)s',
                      {'address': driver_info['address'],
                       'node': node.uuid, 'error': e})


def get_enabled_macs(task, system):
    """Get information on MAC addresses of enabled ports using Redfish.

    :param task: a TaskManager instance containing the node to act on.
    :param system: a Redfish System object
    :returns: a dictionary containing MAC addresses of enabled interfaces
        in a {'mac': <state>} format, where <state> is a sushy constant
    """

    enabled_macs = {}
    if (system.ethernet_interfaces
            and system.ethernet_interfaces.summary):
        macs = system.ethernet_interfaces.summary

        # Identify ports for the NICs being in 'enabled' state
        for nic_mac, nic_state in macs.items():
            if nic_state != sushy.STATE_ENABLED:
                continue
            elif not nic_mac:
                LOG.warning("Ignoring device for %(node)s as no MAC "
                            "reported", {'node': task.node.uuid})
                continue
            enabled_macs[nic_mac] = nic_state

    if not enabled_macs:
        LOG.debug("No ethernet interface information is available "
                  "for node %(node)s", {'node': task.node.uuid})
    return enabled_macs


def wait_until_get_system_ready(node):
    """Wait until Redfish system is ready.

    :param node: an Ironic node object
    :raises: RedfishConnectionError on time out.
    """
    @tenacity.retry(
        retry=tenacity.retry_if_exception_type(
            exception.RedfishConnectionError),
        stop=tenacity.stop_after_attempt(CONF.redfish.connection_attempts),
        wait=tenacity.wait_fixed(CONF.redfish.connection_retry_interval),
        reraise=True)
    def _get_system(driver_info, system_id):
        try:
            with SessionCache(driver_info) as conn:
                return conn.get_system(system_id)
        except sushy.exceptions.BadRequestError as e:
            err_msg = ("System is not ready for node %(node)s, with error"
                       "%(error)s, so retrying it",
                       {'node': node.uuid, 'error': e})
            LOG.warning(err_msg)
            raise exception.RedfishConnectionError(node=node.uuid, error=e)
    driver_info = parse_driver_info(node)
    system_id = driver_info['system_id']
    return _get_system(driver_info, system_id)


def get_manager(node, system, manager_id=None):
    """Get a node's manager.

    :param system: a Sushy system object
    :param manager_id: the id of the manager
    :return: a sushy Manager
    :raises: RedfishError when the System doesn't have Managers associated
    """

    try:
        sushy_manager = None
        available_managers = system.managers
        if available_managers:
            if manager_id is None:
                sushy_manager = available_managers[0]
            else:
                for manager in available_managers:
                    if manager.identity == manager_id:
                        sushy_manager = manager
        if sushy_manager is None:
            raise Exception("Couldn't find any Sushy Manager")
        return sushy_manager
    except sushy.exceptions.MissingAttributeError as e:
        LOG.error('Redfish Managers for node %(node)s are not associated '
                  'with system %(system)s. Error %(error)s',
                  {'system': system.identity,
                   'node': node.uuid, 'error': e})
        raise exception.RedfishError(error=e)
    except Exception as exc:
        LOG.error('Redfish Manager was not found for '
                  'node %(node)s under system %(system)s. Error %(error)s',
                  {'system': system.identity,
                   'node': node.uuid, 'error': exc})
        raise exception.RedfishError(error=exc)
