# Copyright 2020 VMware, Inc.
# All rights reserved. -- VMware Confidential
"""
This contains utilities required during resource addition
"""
import sys
import os
import ssl
import json
import shutil
import base64
import logging
import subprocess
from l10n import msgMetadata as _T, localizedString as _
from patch_errors import UserError
from math import floor
from get_vc_service_instance.vc_service import getVCService  # pylint:disable=E0401
from pyVim import task # pylint: disable=E0401
from pyVim.connect import SmartConnect # pylint: disable=E0401
from pyVmomi import vim # pylint: disable=E0401,E0611


MY_PAYLOAD_DIR = os.path.dirname(__file__)
sys.path.append(os.environ['VMWARE_PYTHON_PATH'])
sys.path.append(MY_PAYLOAD_DIR)
CIS_UPGRADE_RUNNER_DIR = "/usr/lib/vmware/cis_upgrade_runner/"
sys.path.append(os.path.join(CIS_UPGRADE_RUNNER_DIR, 'libs/sdk/'))
MBFACTOR = float(1<<20)
SOURCE_LAYOUT_JSON = "%s%s" % (CIS_UPGRADE_RUNNER_DIR,
                               'config/deployment-size-layout.json')
TARGET_LAYOUT_JSON = os.path.abspath(os.path.join(MY_PAYLOAD_DIR,
                                                  'config/layout.json'))
logger = logging.getLogger(__name__)
MANAGING_VC_ADDRESS = 'managing.vc.address'
MANAGING_VC_SSO_USER = 'managing.vc.sso.user'
MANAGING_VC_SSO_PASSWORD = 'managing.vc.sso.password'
MANAGING_VC_SSO_PORT = 'managing.vc.sso.port'
DIR_PATH = "/tmp/hardware_component/"


def convert_bytes_to_MB(size_in_bytes):
    """
    Converts the provided in  bytes to MB
    :param size_in_bytes:Memory size in bytes
    :type size_in_bytes:int
    :return: Memory size in MB
    """
    return(floor(size_in_bytes/MBFACTOR))


def run_command(cmd, shell=True):
    """
    Runs the commands on the shell using the subprocess
    :param cmd: command to run on the shell
    :paadditionram shell: whether the command needs to be run from shell or not
    :return: returncode, std output, std err
    """
    try:
        logger.info("Running the command: %s.", cmd)
        process = subprocess.Popen(cmd, shell=shell, stdout=subprocess.PIPE,
                                   stderr=subprocess.PIPE)
        out, err = process.communicate()
        return process.returncode, out.decode('utf-8').strip(),\
               err.decode('utf-8').strip()
    except Exception as e:
        cause = _(_T('run.command.failed',
                     'Running command %s failed with %s'),
                     (cmd, e))
        raise UserError(cause=cause)


def get_local_vc_vm(ctx):
    '''
    Gets the vCenter VM of Non-Self Managed vCenter.
    :param ctx:PatchPhaseContext
    :return: local vc vm if the vc vm is present on the given vCenter otherwise
    returns None
    :raises vim.fault.InvalidLogin : error when the connection to the managing
    vCenter Server fails due to invalid username or password.
    :raises Exception : error when getting the local vc vm
    '''
    try:
        logger.info("Connecting to Managing vCenter Server.")
        #todo: fix the below line
        context = ssl._create_unverified_context()
        managing_vc_service_instance = SmartConnect(
                                    host=ctx.userData[MANAGING_VC_ADDRESS],
                                    user=ctx.userData[MANAGING_VC_SSO_USER],
                                    pwd=ctx.userData[MANAGING_VC_SSO_PASSWORD],
                                    port=ctx.userData[MANAGING_VC_SSO_PORT],
                                    sslContext=context)
        logger.info("Connected to managing vCenter Server.")
        return get_local_vm(managing_vc_service_instance)
    except vim.fault.InvalidLogin:
        cause = _(_T("managing.vc.InvalidLogin.error.description",
                     "Cannot complete login because user name or password is "
                     "incorrect."))
        resolution = _(_T("managing.vc.InvalidLogin.error.resolution",
                          "Provide the correct managing vCenter Server "
                          "credentials."))
        raise UserError(cause=cause, resolution=resolution)
    except Exception as e:
        cause = _(_T('managing.vc.connection.failed',
                     'Managing vCenter Server connection failed due to %s.'),e)
        raise UserError(cause=cause)


def _get_guest_variable_using_Vmtoolsd(key):
    """
    Gets the value of key using the vmtools and returns the
    value.
    :param key: Name of the Guest Variable
    :return out: value associated with  "key" or None on exception
    """
    try:
        logger.info("Reading the GuestVariable %s using the vmtools.", key)
        cmd = 'vmtoolsd --cmd "info-get %s"' % key

        rc, out, _err = run_command(cmd)

        #Ignore if return code is 1 (PR# 2425995)
        logger.info("Reading GuestVariable %s with the value %s using vmtools "
                    "succeeded with return code %s.", key, out, rc)
        return out
    except Exception: # pylint: disable=W0703
        logger.exception("Reading guest Variable using vmtools failed.")
        return None


def _set_guest_var_using_vm_api(vm_obj, key, value):
    """
    Sets the guestVariable key to value using VM API
    :param vm_obj: vm object on which key need to be set
    :param key: Name of the GuestVariable to be set
    :param value: value to tbe set to the GuestVariable.
    :return bool: True if ReconfigVM_Task passes otherwise False
    """
    configSpec = vim.VirtualMachineConfigSpec()
    option = vim.option.OptionValue()
    option.key = key
    option.value = value
    configSpec.extraConfig.append(option)
    state = task.WaitForTask(task=vm_obj.ReconfigVM_Task(configSpec))
    if state == "success":
        logger.info("Setting Guest Var: %s to value: %s using vm api on vm:%s."
                    "succeeded." , key, value, vm_obj.name)
        return True
    else:
        logger.error("Setting Guest Var: %s to value: %s using vm api on "
                         "vm: %s.failed due to %s. so skipping", key, value,
                         vm_obj.name, state)
        return False

def _get_vm_uuid():
    '''
    Gets the VM's BIOS UUID using the linux command.
    :return: UUID of the VM
    :raises UserError: Error occurred while retrieving UUID of the VM.
    '''
    try:
        logger.info("Retrieving the UUID the VM.")
        cmd =  "/usr/sbin/dmidecode | grep UUID |awk '{print $2}'"
        _rc, out, _err = run_command(cmd, shell=True) #pylint: disable=W0612
        return out
    except Exception: #pylint: disable=W0703
        logger.exception("Retrieving UUID of the VM failed.")
        return None


def _get_random_value_to_set_guestVar(key):
    """
    Get the unique random value which Not set on the vm to remove false
    positive scenario
    :param key: name of the guest Variable
    :return random_value: value set to the guestVar
    """
    while True:
        random_value = base64.b64encode(os.urandom(24)).decode('utf-8')
        logger.info("Checking vCenter VM already has GuestVar:%s set to %s",
                    key, random_value)

        localVar = _get_guest_variable_using_Vmtoolsd(key)

        if localVar == random_value.lower() :
            logger.info("vCenter VM already has GuestVar:%s set to %s, "
                        "trying with different random variable ", key,
                        random_value)
            continue

        logger.info("vCenter VM Guestvar: %s will be set to value:%s",key ,
                random_value)

        return random_value


def get_local_vm(vc_service_instance=None):
    """
    Gets the VC VM Obj if the given VC VM is present in the given
    vCenter Inventory. Following logic is used to get the VC VM Object,
    Algorithm:
    1. Get the list of VM's using the VM API FindAllByUUID. If list is empty
      return that vCenter is not self-managed else goto step 2
    2. For each VM in the VM list perform the following
       a.Set the guestVariable using VM API.
       b.Read the guestVariable using vmtools. If guestVariable is found return
          the VM object and say vCenter is self-managed, else perform the step
          2 on next vm from the list.
    :param vc_service_instance: service instance of the local VC VM
    :return: vm object it vm is present else None
    :raises UserError: Error occurred while retrieving the local VM
    """
    try:
        # This check is for logging purpose.Using this we will log the given
        # vCenter is self Managed or not
        selfManagedVC = False
        if not vc_service_instance:
            selfManagedVC = True
            vc_service_instance = getVCService()

        vm_uuid = _get_vm_uuid()
        if not vm_uuid:
            return  None

        key = "guestinfo.cis.b2b.localVCVM"

        content = vc_service_instance.RetrieveContent()
        logger.info("Filtering VM's from the VC inventory using retrieved VM "
                    "UUID %s", vm_uuid)
        vms = content.searchIndex.FindAllByUuid(datacenter=None,
                                                uuid=vm_uuid,
                                                vmSearch=True,
                                                instanceUuid=False)

        if not vms:
            logger.info("No VM with the UUID %s found in the given vCenter "
                        "Inventory", vm_uuid)
            return None

        logger.info("Found %s VM(s) with UUID %s.", len(vms), vm_uuid)

        logger.info("Getting required VM from the Obtained list using "
                    "GuestVar")
        # Get all powered-on VMs
        powered_vm = [vm_obj for vm_obj in vms
                      if vm_obj.runtime.powerState ==
                      vim.VirtualMachinePowerState.poweredOn]

        random_value = _get_random_value_to_set_guestVar(key)
        try:
            for vm in powered_vm:
                if not _set_guest_var_using_vm_api(vm, key,
                                                   random_value.lower()):
                    continue

                localVar = _get_guest_variable_using_Vmtoolsd(key)

                _set_guest_var_using_vm_api(vm, key, "")

                if random_value.lower() == localVar:
                    logger.info("GuestVar:%s has Expected value:%s on vm:%s",
                                key, random_value, vm.name)
                    logger.info("Found Local VM - %s", vm.name)

                    if selfManagedVC:
                        logger.info("The given vCenter is Self Managed vCenter")
                    else:
                        logger.info("The given vCenter is not a self Managed "
                                    "vCenter")

                    return vm
            return None
        except Exception: #pylint: disable=W0703
            logger.exception("Getting the localVM failed.")
            pass #pylint: disable=W0107
    except Exception: #pylint: disable=W0703
        logger.exception("Retrieving local VM  from the given vCenter "
                             "Server Inventory failed.")
        return None
    finally:
        try:
            # Cleans up the certificates created.
            shutil.rmtree(path=DIR_PATH, ignore_errors=True)
        except Exception: #pylint: disable=W0703
            logger.exception("clean up operation failed.")

def need_disk_addition(hardware_difference_dict):
    '''
    Checks whether disk addition is needed are not
    :param hardware_difference_dict:
    :return: True if disk addition is needed else False
    '''
    logger.info("Checking if disk addition is needed.")
    for key in hardware_difference_dict.keys():
        if key.startswith('disk') and key != "disk-root":
            logger.info("Disk addition is needed during update.")
            return True
    logger.info("Disk addition is  not needed during update.")
    return False


def need_cpu_addition(hardware_difference_dict):
    '''
    Checks whether cpu addition is needed are not
    :param hardware_difference_dict:
    :return: True if disk addition is needed else False
    '''
    logger.info("Checking if CPU addition is needed.")
    if "cpu" in hardware_difference_dict.keys() \
            and hardware_difference_dict["cpu"] >= 0:
        logger.info("CPU addition is needed during update.")
        return True
    logger.info("CPU addition is not needed during update.")
    return False


def need_memory_addition(hardware_difference_dict):
    '''
    Checks whether memory addition is needed are not
    :param hardware_difference_dict:
    :return: True if disk addition is needed else False
    '''
    logger.info("Checking if memory addition is needed.")
    if "memory" in hardware_difference_dict.keys() \
            and hardware_difference_dict["memory"] >= 0:
        logger.info("Memory addition is needed during update.")
        return True
    logger.info("Memory addition is not needed during update.")
    return False


def get_existing_root_size():
    """
    Method to determine the size of root disk
    :return: Root Disk size
    """
    logger.info("Getting existing root size")
    out = get_disk_info()
    out_json = json.loads(out)
    for item in out_json["blockdevices"]:
        if item["name"] == "sda":
            root_size = floor(int(item["size"]) / 1024)
            logger.info("Existing root size is %s KB", root_size)
            return root_size
    logger.error("Unable to find root size")
    return 0


def need_root_resize(hardware_difference_dict):
    """
    Checks whether root disk resize is needed are not
    :param hardware_difference_dict: Dictionary with hardware differences
    :return: True if disk resize is needed else False
    """
    logger.info("Checking if root resize is needed.")

    target_root = hardware_difference_dict.get("disk-root")
    if not target_root:
        logger.info("Target layout.json doesn't contain root disk data. "
                    "Do not resize.")
        return False

    logger.info("Root size in target layout.json is [%s]", target_root)
    root_in_gb = int(target_root.strip("GB"))
    expected_root_size_in_kb = root_in_gb * 1024 * 1024

    existing_root_size_in_kb = get_existing_root_size()
    if existing_root_size_in_kb < expected_root_size_in_kb:
        logger.info("Existing root size [%s]", existing_root_size_in_kb)
        logger.info("Expected root size [%s]", expected_root_size_in_kb)
        logger.info("Root disk resize is needed during update.")
        return True

    logger.info("Root disk resize is not needed during update.")
    return False


def need_disk_changes(hardware_difference_dict):
    """
    Checks if any disk chnages are required. Currently root disk can
    be resized and other disks can only be added.
    :param hardware_difference_dict: Dictionary with hardware differences
    :return:
    """
    return need_disk_addition(hardware_difference_dict) \
           or need_root_resize(hardware_difference_dict)


def find_diff_between_source_and_target_layout(current_deployment):
    '''
    Finds the difference between source and target layout json file
    :return: dictionary containing objects which target json have but source
    doesnt have
    :raises UserError: Error occurred while Finding the difference between
    source and target layout
    '''
    try:
        with open(SOURCE_LAYOUT_JSON, 'r') as source_layout_fp:
            source_layout = json.load(source_layout_fp)

        with open(TARGET_LAYOUT_JSON, 'r') as target_layout_fp:
            target_layout = json.load(target_layout_fp)

        hardware_difference_dict = \
            dict((set(target_layout[current_deployment].items()))
                    -(set(source_layout[current_deployment].items())))

        return hardware_difference_dict
    except Exception as e:
        cause = _(_T("diff.between.source.target.layout.json.error.text",
                     "Finding the difference between source and target layout "
                     "JSON failed due to %s."), e)
        raise UserError(cause=cause)


def is_hardware_addition_needed():
    '''
    Identifies whether hardware addition requirement
    is there or not during the B2B
    :return: True if there is hardware addition requirement else False
    :return: current_deployment type if there is hardware requirement else None
    :return: hardware_difference_dict if there is hardware requirement else None
    '''
    from deployment_size import GetDeploymentType
    GET_DEPLOYMENT_OBJ = GetDeploymentType()
    current_deployment = GET_DEPLOYMENT_OBJ.get_deployment_size()
    hardware_difference_dict = \
        find_diff_between_source_and_target_layout(current_deployment)
    if len(hardware_difference_dict) == 0:
        logger.info("No requirement of adding hardware resources during "
                    "update.")
    else:
        cpu_addition_needed = need_cpu_addition(hardware_difference_dict)
        memory_addition_needed = need_memory_addition(hardware_difference_dict)
        disk_changes_needed = need_disk_changes(hardware_difference_dict)

        if cpu_addition_needed or memory_addition_needed or\
                disk_changes_needed:
            logger.info("Adding hardware resources are required during update.")
            return True
    return False


def wrapper_get_hardware_difference_dict():
    """
    Wrapper which calls all the methods needed to get the diff between
    dictionary.
    :return dict: hardware_difference_dict
    """
    from deployment_size import GetDeploymentType
    GET_DEPLOYMENT_OBJ = GetDeploymentType()
    current_deployment = GET_DEPLOYMENT_OBJ.get_deployment_size()
    hardware_difference_dict = \
        find_diff_between_source_and_target_layout(current_deployment)
    return hardware_difference_dict


def get_disk_addition_info(hardware_difference_dict):
    """
    Identifies the total storage needed as well as the no of disks to be added
    :param hardware_difference_dict: diff of dict containing difference
    between source and target layout json
    Ex:  {
            "disk-vstats2": "1GB",
            "disk-vstats1": "2GB",
            "memory" : 19456,
            "cpu": 4
        }
    :return int:total no of disks to be added
    """
    no_of_disks_to_add = 0
    for key in hardware_difference_dict:
        if key.startswith("disk") and key != "disk-root":
            no_of_disks_to_add= no_of_disks_to_add + 1

    return no_of_disks_to_add


def get_disk_info():
    """
    Gets the disk present in the given Appliance
    :return dict: info relating to the disk already present in the applaince
    """
    logger.info("Getting information related to existing partitions.")
    cmd = 'lsblk -o name,size,type -n -l -b --json'
    rc, out, err = run_command(cmd)
    if rc:
        raise Exception(err)
    return out
