# Copyright 2015 Hewlett-Packard Development Company, L.P.
#
# 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 json
import os
import time

import jsonschema
from jsonschema import exceptions as json_schema_exc

from proliantutils import exception
from proliantutils.hpssa import constants
from proliantutils.hpssa import disk_allocator
from proliantutils.hpssa import objects

CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
RAID_CONFIG_SCHEMA = os.path.join(CURRENT_DIR, "raid_config_schema.json")


def _update_physical_disk_details(raid_config, server):
    """Adds the physical disk details to the RAID configuration passed."""
    raid_config['physical_disks'] = []
    physical_drives = server.get_physical_drives()
    for physical_drive in physical_drives:
        physical_drive_dict = physical_drive.get_physical_drive_dict()
        raid_config['physical_disks'].append(physical_drive_dict)


def validate(raid_config):
    """Validates the RAID configuration provided.

    This method validates the RAID configuration provided against
    a JSON schema.

    :param raid_config: The RAID configuration to be validated.
    :raises: InvalidInputError, if validation of the input fails.
    """
    raid_schema_fobj = open(RAID_CONFIG_SCHEMA, 'r')
    raid_config_schema = json.load(raid_schema_fobj)
    try:
        jsonschema.validate(raid_config, raid_config_schema)
    except json_schema_exc.ValidationError as e:
        raise exception.InvalidInputError(e.message)

    for logical_disk in raid_config['logical_disks']:

        # If user has provided 'number_of_physical_disks' or
        # 'physical_disks', validate that they have mentioned at least
        # minimum number of physical disks required for that RAID level.
        raid_level = logical_disk['raid_level']
        min_disks_reqd = constants.RAID_LEVEL_MIN_DISKS[raid_level]

        no_of_disks_specified = None
        if 'number_of_physical_disks' in logical_disk:
            no_of_disks_specified = logical_disk['number_of_physical_disks']
        elif 'physical_disks' in logical_disk:
            no_of_disks_specified = len(logical_disk['physical_disks'])

        if (no_of_disks_specified
                and no_of_disks_specified < min_disks_reqd):
            msg = ("RAID level %(raid_level)s requires at least %(number)s "
                   "disks." % {'raid_level': raid_level,
                               'number': min_disks_reqd})
            raise exception.InvalidInputError(msg)


def _select_controllers_by(server, select_condition, msg):
    """Filters out the hpssa controllers based on the condition.

    This method updates the server with only the controller which satisfies
    the condition. The controllers which doesn't satisfies the selection
    condition will be removed from the list.

    :param server: The object containing all the supported hpssa controllers
        details.
    :param select_condition: A lambda function to select the controllers based
        on requirement.
    :param msg: A String which describes the controller selection.
    :raises exception.HPSSAOperationError, if all the controller are in HBA
        mode.
    """
    all_controllers = server.controllers
    supported_controllers = [c for c in all_controllers if select_condition(c)]

    if not supported_controllers:
        reason = ("None of the available SSA controllers %(controllers)s "
                  "have %(msg)s"
                  % {'controllers': ', '.join([c.id for c in all_controllers]),
                     'msg': msg})
        raise exception.HPSSAOperationError(reason=reason)

    server.controllers = supported_controllers


def create_configuration(raid_config):
    """Create a RAID configuration on this server.

    This method creates the given RAID configuration on the
    server based on the input passed.
    :param raid_config: The dictionary containing the requested
        RAID configuration. This data structure should be as follows:
        raid_config = {'logical_disks': [{'raid_level': 1, 'size_gb': 100},
                                         <info-for-logical-disk-2>
                                        ]}
    :returns: the current raid configuration. This is same as raid_config
        with some extra properties like root_device_hint, volume_name,
        controller, physical_disks, etc filled for each logical disk
        after its creation.
    :raises exception.InvalidInputError, if input is invalid.
    :raises exception.HPSSAOperationError, if all the controllers are in HBA
        mode.
    """
    server = objects.Server()

    select_controllers = lambda x: not x.properties.get('HBA Mode Enabled',
                                                        False)
    _select_controllers_by(server, select_controllers, 'RAID enabled')

    validate(raid_config)

    # Make sure we create the large disks first.  This is avoid the
    # situation that we avoid giving large disks to smaller requests.
    # For example, consider this:
    #   - two logical disks - LD1(50), LD(100)
    #   - have 4 physical disks - PD1(50), PD2(50), PD3(100), PD4(100)
    #
    # In this case, for RAID1 configuration, if we were to consider
    # LD1 first and allocate PD3 and PD4 for it, then allocation would
    # fail. So follow a particular order for allocation.
    #
    # Also make sure we create the MAX logical_disks the last to make sure
    # we allot only the remaining space available.
    logical_disks_sorted = (
        sorted((x for x in raid_config['logical_disks']
                if x['size_gb'] != "MAX"),
               reverse=True,
               key=lambda x: x['size_gb'])
        + [x for x in raid_config['logical_disks'] if x['size_gb'] == "MAX"])

    if any(logical_disk['share_physical_disks']
            for logical_disk in logical_disks_sorted
            if 'share_physical_disks' in logical_disk):
        logical_disks_sorted = _sort_shared_logical_disks(logical_disks_sorted)

    # We figure out the new disk created by recording the wwns
    # before and after the create, and then figuring out the
    # newly found wwn from it.
    wwns_before_create = set([x.wwn for x in
                              server.get_logical_drives()])

    for logical_disk in logical_disks_sorted:

        if 'physical_disks' not in logical_disk:
            disk_allocator.allocate_disks(logical_disk, server,
                                          raid_config)

        controller_id = logical_disk['controller']

        controller = server.get_controller_by_id(controller_id)
        if not controller:
            msg = ("Unable to find controller named '%(controller)s'."
                   " The available controllers are '%(ctrl_list)s'." %
                   {'controller': controller_id,
                    'ctrl_list': ', '.join(
                        [c.id for c in server.controllers])})
            raise exception.InvalidInputError(reason=msg)

        if 'physical_disks' in logical_disk:
            for physical_disk in logical_disk['physical_disks']:
                disk_obj = controller.get_physical_drive_by_id(physical_disk)
                if not disk_obj:
                    msg = ("Unable to find physical disk '%(physical_disk)s' "
                           "on '%(controller)s'" %
                           {'physical_disk': physical_disk,
                            'controller': controller_id})
                    raise exception.InvalidInputError(msg)

        controller.create_logical_drive(logical_disk)

        # Now find the new logical drive created.
        server.refresh()
        wwns_after_create = set([x.wwn for x in
                                 server.get_logical_drives()])

        new_wwn = wwns_after_create - wwns_before_create

        if not new_wwn:
            reason = ("Newly created logical disk with raid_level "
                      "'%(raid_level)s' and size %(size_gb)s GB not "
                      "found." % {'raid_level': logical_disk['raid_level'],
                                  'size_gb': logical_disk['size_gb']})
            raise exception.HPSSAOperationError(reason=reason)

        new_logical_disk = server.get_logical_drive_by_wwn(new_wwn.pop())
        new_log_drive_properties = new_logical_disk.get_logical_drive_dict()
        logical_disk.update(new_log_drive_properties)

        wwns_before_create = wwns_after_create.copy()

    _update_physical_disk_details(raid_config, server)
    return raid_config


def _sort_shared_logical_disks(logical_disks):
    """Sort the logical disks based on the following conditions.

    When the share_physical_disks is True make sure we create the volume
    which needs more disks first. This avoids the situation of insufficient
    disks for some logical volume request.

    For example,
      - two logical disk with number of disks - LD1(3), LD2(4)
      - have 4 physical disks
    In this case, if we consider LD1 first then LD2 will fail since not
    enough disks available to create LD2. So follow a order for allocation
    when share_physical_disks is True.

    Also RAID1 can share only when there is logical volume with only 2 disks.
    So make sure we create RAID 1 first when share_physical_disks is True.

    And RAID 1+0 can share only when the logical volume with even number of
    disks.
    :param logical_disks: 'logical_disks' to be sorted for shared logical
    disks.
    :returns: the logical disks sorted based the above conditions.
    """
    is_shared = (lambda x: True if ('share_physical_disks' in x
                                    and x['share_physical_disks']) else False)
    num_of_disks = (lambda x: x['number_of_physical_disks']
                    if 'number_of_physical_disks' in x else
                    constants.RAID_LEVEL_MIN_DISKS[x['raid_level']])

    # Separate logical disks based on share_physical_disks value.
    # 'logical_disks_shared' when share_physical_disks is True and
    # 'logical_disks_nonshared' when share_physical_disks is False
    logical_disks_shared = []
    logical_disks_nonshared = []
    for x in logical_disks:
        target = (logical_disks_shared if is_shared(x)
                  else logical_disks_nonshared)
        target.append(x)

    # Separete logical disks with raid 1 from the 'logical_disks_shared' into
    # 'logical_disks_shared_raid1' and remaining as
    # 'logical_disks_shared_excl_raid1'.
    logical_disks_shared_raid1 = []
    logical_disks_shared_excl_raid1 = []
    for x in logical_disks_shared:
        target = (logical_disks_shared_raid1 if x['raid_level'] == '1'
                  else logical_disks_shared_excl_raid1)
        target.append(x)

    # Sort the 'logical_disks_shared' in reverse order based on
    # 'number_of_physical_disks' attribute, if provided, otherwise minimum
    # disks required to create the logical volume.
    logical_disks_shared = sorted(logical_disks_shared_excl_raid1,
                                  reverse=True,
                                  key=num_of_disks)

    # Move RAID 1+0 to first in 'logical_disks_shared' when number of physical
    # disks needed to create logical volume cannot be shared with odd number of
    # disks and disks higher than that of RAID 1+0.
    check = True
    for x in logical_disks_shared:
        if x['raid_level'] == "1+0":
            x_num = num_of_disks(x)
            for y in logical_disks_shared:
                if y['raid_level'] != "1+0":
                    y_num = num_of_disks(y)
                    if x_num < y_num:
                        check = (True if y_num % 2 == 0 else False)
                        if check:
                            break
        if not check:
            logical_disks_shared.remove(x)
            logical_disks_shared.insert(0, x)
            check = True

    # Final 'logical_disks_sorted' list should have non shared logical disks
    # first, followed by shared logical disks with RAID 1, and finally by the
    # shared logical disks sorted based on number of disks and RAID 1+0
    # condition.
    logical_disks_sorted = (logical_disks_nonshared
                            + logical_disks_shared_raid1
                            + logical_disks_shared)
    return logical_disks_sorted


def delete_configuration():
    """Delete a RAID configuration on this server.

    :returns: the current RAID configuration after deleting all
        the logical disks.
    """
    server = objects.Server()

    select_controllers = lambda x: not x.properties.get('HBA Mode Enabled',
                                                        False)
    _select_controllers_by(server, select_controllers, 'RAID enabled')

    for controller in server.controllers:
        # Trigger delete only if there is some RAID array, otherwise
        # hpssacli/ssacli will fail saying "no logical drives found.".
        if controller.raid_arrays:
            controller.delete_all_logical_drives()
    return get_configuration()


def get_configuration():
    """Get the current RAID configuration.

    Get the RAID configuration from the server and return it
    as a dictionary.

    :returns: A dictionary of the below format.
        raid_config = {
            'logical_disks': [{
                'size_gb': 100,
                'raid_level': 1,
                'physical_disks': [
                    '5I:0:1',
                    '5I:0:2'],
                'controller': 'Smart array controller'
                },
            ]
        }
    """
    server = objects.Server()
    logical_drives = server.get_logical_drives()
    raid_config = {}
    raid_config['logical_disks'] = []

    for logical_drive in logical_drives:
        logical_drive_dict = logical_drive.get_logical_drive_dict()
        raid_config['logical_disks'].append(logical_drive_dict)

    _update_physical_disk_details(raid_config, server)
    return raid_config


def has_erase_completed():
    server = objects.Server()
    drives = server.get_physical_drives()
    if any((drive.erase_status == 'Erase In Progress')
           for drive in drives):
        return False
    else:
        return True


def erase_devices():
    """Erase all the drives on this server.

    This method performs sanitize erase on all the supported physical drives
    in this server. This erase cannot be performed on logical drives.

    :returns: a dictionary of controllers with drives and the erase status.
    :raises exception.HPSSAException, if none of the drives support
        sanitize erase.
    """
    server = objects.Server()

    for controller in server.controllers:
        drives = [x for x in controller.unassigned_physical_drives
                  if (x.get_physical_drive_dict().get('erase_status', '')
                      == 'OK')]
        if drives:
            controller.erase_devices(drives)

    while not has_erase_completed():
        time.sleep(300)

    server.refresh()

    status = {}
    for controller in server.controllers:
        drive_status = {x.id: x.erase_status
                        for x in controller.unassigned_physical_drives}
        sanitize_supported = controller.properties.get(
            'Sanitize Erase Supported', 'False')
        if sanitize_supported == 'False':
            msg = ("Drives overwritten with zeros because sanitize erase "
                   "is not supported on the controller.")
        else:
            msg = ("Sanitize Erase performed on the disks attached to "
                   "the controller.")

        drive_status.update({'Summary': msg})
        status[controller.id] = drive_status

    return status
