# Copyright 2020 VMware, Inc.
# All rights reserved. -- VMware Confidential
''' Module containing vCSA specific utilities.
'''
import os
from os.path import join, normpath
import json
import logging
from status_reporting_sdk.msgL10n import MessageMetadata
from l10n import msgMetadata as _T, localizedString as _
from patch_errors import PermanentError
from patch_specs import DiscoveryResult, PatchContext

SERVICE_NAME_L10N_ID = "l10n.componentName"
DEPLOYMENT_TYPE_FILE = "/etc/vmware/deployment.node.type"
PRODUCTS_FILE = '/etc/vmware/products.conf'

EMBEDDED_DEPLOYMENT_TYPE = "embedded"
MANAGEMENT_DEPLOYMENT_TYPE = "management"
INFRASTRUCTURE_DEPLOYMENT_TYPE = "infrastructure"

__serviceConfig = None
_serviceFssMapping = None
_servicesProductsMapping = None
logger = logging.getLogger(__name__)

# this is for the components who do:
from fss_utils import getTargetFSS  # pylint: disable=W0611

def isDisruptiveUpgrade(patchContext):
    ''' Indicates if the upgrade is disruptive (in-place) or not.

    @param patchContext: The context of the component execution
    @type: PatchContext

    @rtype: bool
    '''
    return patchContext.upgradeType == PatchContext.DISRUPTIVE_UPGRADE

def _getSdkConfig(configFileName):
    '''Gets SDK configuration file

    @param configFileName: Name of the configuration file the caller is
      interested in
    @type configFileName: str

    @return: Absolute path to the file
    @rtype: str
    '''
    # SDK module configurations reside in $(SDK)/config
    return normpath(join(__file__, os.pardir, 'config', configFileName))

def getDeploymentType(deploymentTypeFile=DEPLOYMENT_TYPE_FILE):
    '''Extracts the vCSA deployment type. The deplopyment type result could be
    either embedded, management or infrastructure.

    @param deploymentTypeFile: File where vCSA deployment type information is
      persisted on the disk
    @type deploymentTypeFile: str

    @raise PermenentError: if the deployment type cannot be retrieved.
    '''
    if os.path.exists(deploymentTypeFile):
        with open(deploymentTypeFile) as fp:
            return fp.read().strip()
    else:
        raise PermanentError(cause=_(_T("patch.error.deploymentType.text",
                                        "vCSA Deployment type cannot be retrieved")),
                             resolution=_(_T("patch.error.deploymentType.resolution",
                                             "vCSA patching is not executed on the "
                                             "right host or the vCSA is not properly "
                                             "configured")))

def _loadServiceConfig(serviceConfigFile=_getSdkConfig("services.json")):
    '''Loads the service configuration from given configuration file. The content
    of the file is expected to be json.

    @param serviceConfigFile: Location of service configuration file
    @type serviceConfigFile: str

    @return The service config content as dictionary
    @rtype: dict

    @raise ValueError: If the file does not exist.
    '''
    if not os.path.exists(serviceConfigFile):
        raise ValueError("Invalid configuration. Cannot find services "
                         "configurations %s" % serviceConfigFile)

    with open(serviceConfigFile) as fp:
        serviceData = json.load(fp)
    return serviceData

def _getServiceConfig():
    ''' Get the service configuration

    @return The service config content as dictionary
    @rtype: dict

    @raise ValueError: If it cannot be get
    '''
    global __serviceConfig
    if __serviceConfig is None:
        __serviceConfig = _loadServiceConfig()
    return __serviceConfig

def loadComponentServiceData(serviceId):
    '''Loads the components service data for given serviceId from services.json
    file coming with the latest rpm.

    @param serviceId: The service identificator for which the data has to be
      loaded for
    @type serviceId: str

    @return: A dictionary expressing the service data for given service id
      Example: {
        "enabled": true,
        "dependsOn": ["vmware-cis-config", "visl-integration"],
        "windows": {
            "serviceName": "VMWareAfdService",
            "fbScripts": ["vmafd-firstboot.py"],
            "installFiles": ["vmware-afd.msi"]
        },
        "appliance": {
            "serviceName": "vmafdd",
            "fbScripts": ["vmware-vmafd/firstboot/vmafd-firstboot.py"],
             "installFiles": ["vmware-afd-@version@.rpm"]
        },
        "serviceDisplayName": "VMware Authentication Framework",
        "deploymentType": ["all"],
        "isCorePnidService": true
    }
    @rtype: dict

    @raise ValueError: If the serviceId does not exist
    '''
    serviceConfig = _getServiceConfig()
    if not serviceConfig.get(serviceId):
        raise ValueError("Invalid serviceId: %s" % serviceId)

    return serviceConfig[serviceId]

def _getSupportedDeploymentTypes(serviceData):
    '''Extract and normalize the supported deployment type for a service in the
    service data

    @param serviceData: The data for a service in the services.json file
    @type serviceData: dictionary

    @return: List of supported deployment types for service
    '''
    if serviceData["deploymentType"] == ["all"]:
        # Expand deployment types if "all" is specified.
        return [EMBEDDED_DEPLOYMENT_TYPE, MANAGEMENT_DEPLOYMENT_TYPE,
                          INFRASTRUCTURE_DEPLOYMENT_TYPE]
    else:
        return serviceData["deploymentType"]

def _getInstalledProducts():
    """ Provides the content of the product files, if there is no such file None
    is return.
    """
    if not os.path.exists(PRODUCTS_FILE):
        return None

    global _servicesProductsMapping
    if _servicesProductsMapping is None:
        with open(PRODUCTS_FILE) as fp:
            _servicesProductsMapping = json.load(fp)
    return _servicesProductsMapping

def isServiceInstalled(componentData):
    """ Check if the component is installed or not. For that it uses the
    /etc/vmware/products.conf file. No other live checking is
    done
    @param serviceId: The name of the service
    @type serviceId: bool
    """
    installedProducts = _getInstalledProducts()

    if installedProducts is None:
        return True

    if componentData.get("products") is None:
        return True

    for p in componentData["products"]:
        if installedProducts.get(p, {}).get("installed", False):
            return True
    return False

def _getServiceFssMapping():
    ''' Get mapping between services and FSS

    @return The mapping between service name and FSS as dictionary
    @rtype: dict

    @raise ValueError: If mapping cannot be extracted
    '''
    global _serviceFssMapping
    if _serviceFssMapping is None:
        _serviceFssMapping = {}
        servicesConfig = _getServiceConfig()
        for serviceConf in servicesConfig.values():
            fssKey = serviceConf.get('FSSname')
            serviceName = serviceConf.get('appliance', {}).get('serviceName', None)
            if fssKey and serviceName:
                _serviceFssMapping[serviceName] = fssKey
        logger.info('Found services behind feature switch: %s', _serviceFssMapping)
    return _serviceFssMapping

def isFswitchEnabled(serviceId):
    """
    Some services are behind a feature switch and may not be available
    when the feature switch is off.
    """
    fss = _getServiceFssMapping().get(serviceId)
    return getTargetFSS(fss) if fss else True

def _isEnabledService(serviceId, componentData, localDeploymentType):
    '''
    Checks if the service provided is enabled on the machine. At the moment it
    checks deployment type, feature flags, and if service is enabled
    '''
    return localDeploymentType in _getSupportedDeploymentTypes(componentData) and \
            isFswitchEnabled(serviceId) and \
            isServiceInstalled(componentData) and \
            componentData['enabled']


def _getDependentServices(dependentComponents, localDeploymentType):
    '''Returns all component dependent services. The function iterates over all
    dependent components provides the dependent component services.
    The order of components is preserved

    @param componentDependent: List of dependent component in the right dependent order
    @type componentDependent: list of str

    @param localDeploymentType: The deployment type of the vCSA
    @type localDeploymentType: str

    @return: List of dependent services in the right dependent order, i.e.
    a[i] could depend on a[j] if and only if i > j
    @rtype: list
    '''
    serviceDependencies = []
    for componentDependencyId in dependentComponents:
        componentDependData = loadComponentServiceData(componentDependencyId)
        serviceName = componentDependData["appliance"].get("serviceName")
        if  serviceName and \
            _isEnabledService(serviceName,
                              componentDependData,
                              localDeploymentType):
            # Add the dependent component service
            serviceDependencies.append(serviceName)
    return serviceDependencies

def _getDependentComponents(serviceId, localDeploymentType, cachedServices=None):
    '''Returns all component dependent components. The function iterates over all
    dependent components recursively and provide the dependent component names.
    The recursion goes over Depth-First-Search in order to provide proper service
    dependency chain.

    @param serviceId: The service identificator for which the data has to be
      loaded for
    @type serviceId: str

    @param localDeploymentType: The deployment type of the vCSA
    @type localDeploymentType: str

    @return: List of dependent component in the right dependent order, i.e.
    a[i] could depend on a[j] if and only if i > j
    @rtype: list
    '''
    if cachedServices is None:
        cachedServices = {}

    class _OrderedSet(list):
        '''Due to the lack of OrderSet in python, implement dummy one by supporting
        only part of Set interface.
        Note: Do not use OrderDict because it does not exist in python2.6
        '''
        def add(self, elem):
            if elem not in self:
                self.append(elem)

        def update(self, *args, **kwargs):
            if kwargs:
                raise TypeError("update() takes no keyword arguments")

            for col in args:
                for elem in col:
                    self.add(elem)

    allComponentDependencies = _OrderedSet()
    serviceData = loadComponentServiceData(serviceId)
    componentDependencies = serviceData["dependsOn"]
    for componentDependencyId in componentDependencies:
        if componentDependencyId in cachedServices:
            allComponentDependencies.update(cachedServices[componentDependencyId])
        else:
            # Haven't see that service before compute it dependent services and add them
            # Add recursively the dependent services on our dependent component
            comps = _getDependentComponents(componentDependencyId, localDeploymentType, cachedServices=cachedServices)
            allComponentDependencies.update(comps)
            cachedServices[componentDependencyId] = comps
        componentData = loadComponentServiceData(componentDependencyId)
        if localDeploymentType in _getSupportedDeploymentTypes(componentData):
            allComponentDependencies.add(componentDependencyId)
    return allComponentDependencies

def getComponentDefintion(serviceId):
    '''Retrieves component definition based on its service definition in
    component services json file.

    @param serviceId: The service identificator for which the data has to be
      loaded for
    @type serviceId: str

    @return: The component definition
    @rtype: _ComponentDefinition

    @raise ValueError: If the serviceId does not exist
    '''
    class _ComponentDefinition():
        def __init__(self, displayName, serviceName, serviceDependencies,
                     deploymentType, componentId, dependentComponents):
            '''Represents component definition read by services.json file

            @param displayName: A component display name
            @type displayName: str

            @param serviceName: A component service
            @type serviceName: str

            @param serviceDependencies: List of component services dependencies
            @type serviceDependencies: list

            @param deploymentType: Deployments in which the component will be
              installed
            @type deploymentType: list

            @param componentId: Unique component name, which other components can
              use indicate that they depend on it.
            @type componentId: str

            @param componentDependents: The components which current component is
              depending on. That dependency will be respected during execution of
              @Patch hook.
            @type componentDependents: list of strings
            '''
            self.displayName = displayName
            self.serviceName = serviceName
            self.serviceDependencies = serviceDependencies
            self.deploymentType = deploymentType
            self.componentId = componentId
            self.dependentComponents = dependentComponents

    serviceData = loadComponentServiceData(serviceId)

    displayName = serviceData.get("serviceDisplayName")
    serviceName = serviceData["appliance"].get("serviceName")
    deploymentType = _getSupportedDeploymentTypes(serviceData)

    localDeploymentType = getDeploymentType()
    dependentComponents = _getDependentComponents(serviceId, localDeploymentType)
    serviceDependencies = _getDependentServices(dependentComponents, localDeploymentType)
    return _ComponentDefinition(displayName, serviceName, serviceDependencies,
                                deploymentType, serviceId, dependentComponents)

def getComponentDiscoveryResult(serviceId, **kwargs):
    '''Retrieves component discovery result based on the definition in component
    services json file

    @param serviceId: The service identificator for which the discovery result
      has to be returned for
    @type serviceId: str

    @param kwargs: Arguments which will be added on top of component definition
      in order to form the Discovery Result. For conflicted arguments, the
      user given arguments will be preferred.

    @return: DiscoveryResult for given service id
    @rtype: patch_specs.DiscoveryResult

    @raise ValueError: If the serviceId does not exist
    '''
    compDef = getComponentDefintion(serviceId)
    localDeploymentType = getDeploymentType()
    patchable = localDeploymentType in compDef.deploymentType
    if not patchable:
        logger.info("Component %s is not part of deployment type %s, hence its "
                    "patch logic will not be applied", serviceId, localDeploymentType)

    displName = _(MessageMetadata(SERVICE_NAME_L10N_ID, compDef.displayName)) \
                        if compDef.displayName else None
    patchServices = [compDef.serviceName] if compDef.serviceName else []
    discoveryArgs = {
        "displayName" : displName,
        "patchServices" : patchServices,
        "dependentServices" : compDef.serviceDependencies,
        "patchable" : patchable,
        "componentId" : compDef.componentId,
        "dependentComponents" : compDef.dependentComponents
    }
    discoveryArgs.update(kwargs)
    return DiscoveryResult(**discoveryArgs)
