# Copyright 2015 VMware, Inc.  All rights reserved. -- VMware Confidential
#
'''
Script responsible to discover all components which should take part of upgrade
workflow, validates components metadata, validates inter-component dependencies
and persist the component discovery result for later patch phases.
'''
import os
import logging
import json

from extensions import Hook
from patch_specs import PatchContext, DiscoveryResult, RequirementsResult, Mismatch
from os_utils import createDirectory
import transport
from status_reporting_sdk.json_utils import ObjectJsonConverter, prettyJsonDump
from rpm_utils import validateRpmsRemoval
from l10n import msgMetadata as _T, localizedString as _

from vmware.patching.utils.resource_utils import getPayloadFilePath
from vmware.patching.data.model import Component, PatchPhaseContext
from vmware.patching.executor.execution_facade import executeHook
from vmware.patching.utils.reporting_utils import INTERNAL_ERROR_TEXT, INTERNAL_ERROR_RESOLUTION, \
    configureLocalization

logger = logging.getLogger(__name__)

CANNOT_REMOVE_RPM_WARNING_TEXT = _T('ur.rpm.removal.warning.text',
                           "It won't be possible to remove deprecated rpms %s from the system")
CANNOT_REMOVE_RPM_WARNING_DESCRIPTION = _(_T('ur.rpm.removal.warning.description',
                                 "As part of the patch deprecated products rpms need to be removed"
                                 " from the system, however this won't be possible due"
                                 " to dependency problems."))
CANNOT_REMOVE_RPM_ERROR_RESOLUTION = _(_T('ur.rpm.removal.warning.resolution',
                                 ' To ensure that there will be no problems with subsequence patches'
                                 ' remove those rpms manually.'))

def _cleanupRootStageDir(stageDir):
    '''Cleans up the data persisted in previous patch process execution.

    @param stageDir: global CPO stage directory. All components stage
      directories will be created as sub-directories of global stage directory.
    @type stageDir: str
    '''
    if createDirectory(stageDir):
        logger.info("Create directory %s", stageDir)
    else:
        logger.info("Cleaning up the data persisted in previous patch process "
                    "execution persisted in %s.", stageDir)
        for f in os.listdir(stageDir):
            transport.remove(transport.local.LocalOperationsManager(),
                             os.path.join(stageDir, f))

def _createComponentModel(name, patchScript, stageDir, discoveryResult,
                          requirementsResult):
    '''Creates a component which wants to be part of the patching process.

    @param name: Component internal name. Component name will be used as
      identificator for distinguishing between components. The name is formed
      from the directory where its patching logic reside. For example:
      /foo/bar/patching/sso -> sso
    @type name: str

    @param patchScript: Holds information about where the patching component
      logic reside. Every component patch hook should be defined at
      <patchScriptPath>/__init__.py
    @type patchScript: str

    @param stageDir: Component specific stage directory. This is a unique
      per component directory, where a component can share data between
      the patching hooks.
    @type stageDir: str

    @param discoveryResult: Information about the component found after
      calling its discovery hook.
    @type discoveryResult: patch_specs.DiscoveryResult

    @param requirementsResult: Information about the component requirements.
    @type requirementsResult: patch_specs.RequirementsResult

    @return: Created component
    @rtype: vmware.patching.data.model.Component
    '''
    requirementsResult = requirementsResult or RequirementsResult()
    return Component(name, patchScript, stageDir, discoveryResult, requirementsResult)

def _discoverComponents(path, stageDir, componentNames, locale):
    '''Discovers the vCSA components which should take part of the patching
    process by calling their Discovery extension point. The method also filters
    out all components which are expressively wanted to be blacklisted from
    patching process. If a component wants to take part in the patching workflow,
    then its requirements will be collected(call its requirements hook) as part
    of discovery process.

    @param path: A path to directory containing components scripts files.
    @type path: str

    @param stageDir: Component specific stage directory. This is a unique
      per component directory, where a component can share data between
      the patching hooks.
    @type stageDir: str

    @param componentNames: The component names which the callee is interested in,
      the other will be filtered out. If None is passed, then all components
      will be returned. The name of the component is equal to the directory where
      its patching logic reside.
    @param componentNames: array of strings

    @param locale: Current locale. All user-facing messages should be
      translated to that locale.
    @type locale: str

    @return: Components which should take part in the current patch
      process.
    @rtype: List of vmware.patching.data.model.Component
    '''
    logger.info('Processing patch directory %s',
                os.path.abspath(os.path.join(path, os.pardir)))

    result = []
    for componentName in os.listdir(path):
        filePath = os.path.join(path, componentName)
        if not os.path.isdir(filePath):
            logger.warn("Invalid patching structure: %s", filePath)
            continue

        if componentNames and componentName not in componentNames:
            logger.info("Skip processing component %s", componentName)
            continue

        try:
            logger.info('Processing patch component %s', filePath)

            compStageDir = os.path.join(stageDir, componentName)
            logger.info("Create component '%s' stage directory %s", componentName,
                        compStageDir)
            createDirectory(compStageDir)

            compContext = PatchContext(compStageDir, locale)
            discoveryResult = executeHook(filePath, Hook.Discovery, compContext, None,
                                          expectedResultType=(type(None), DiscoveryResult))
            if discoveryResult is None:
                logger.warning("The component %s does not provide "
                               "component metadata, hence won't be patched",
                               componentName)
                continue

            logger.info("Found valid component %s", componentName)
            if not discoveryResult.patchable:
                logger.info("Patch of component %s will be skipped", componentName)
                # Even deprecated services won't be added to the discovered
                # components if they are defined as not patchable
                continue

            # Retrieve component requirements
            requirementsResult = executeHook(filePath, Hook.Requirements, compContext, None,
                                          expectedResultType=(type(None), RequirementsResult))

            c = _createComponentModel(componentName, filePath, compStageDir,
                                     discoveryResult, requirementsResult)
            result.append(c)
        except:
            logger.error("Could not execute discovery hook in file: %s", filePath)
            raise

    return result

def _validateComponentsDependencies(ctx):
    ''' Validates components metadata interdependencies, e.g. cyclic dependencies

    @return: True if the validation succeeded, False otherwise
    @rtype: bool
    '''
    try:
        ctx.getAllComponents(sort=True)
        return True
    except ValueError:
        logger.exception("Component inter-dependencies validation failed")
        return False

def _isSuccessful(compoents):
    '''Test if all components are ready to be patched.

    @param compoents: All components which need to be patched
    @type components: list of vmware.patching.data.model.Component

    @return: False if any component report an error mismatch, otherwise True
    @rtype: bool
    '''
    for c in compoents:
        # Iterate over component requirements mismatches
        for mismatch in c.requirementsResult.mismatches:
            if mismatch.severity == Mismatch.ERROR:
                return False

    return True

def _reportResults(components, mismatches, outputFile):
    '''Report discovery results into the file.

    @param components: Components which should take part in the current patch
      process.
    @type components: List of vmware.patching.data.model.Component

    @param mismatches: Local mismatches
    @type mismatches: list

    @param outputFile: Output file
    @type outputFile: str
    '''
    # TODO iradev: Consider using real software-update specs rather than
    # serializing to dictionary. This would handle eventual property mismatches
    if not outputFile:
        logger.warning("The output file is not provided. Skip writing results")
        return

    requiredDiskSpace = {}
    # deployment specific rpm ignore list will be added in software-packages
    # integration point
    rpmIgnoreList = []
    questions = []
    rebootRequired = False
    timeToInstall = 0
    # Do not merge patch summary changes from components for now. Leave this to
    # be set on a global level.
    patchSummary = None

    for c in components:
        discoveryResult = c.discoveryResult
        requirementsSpec = c.requirementsResult
        requirements = requirementsSpec.requirements

        # Aggregate required disk space
        for partition, diskRequirement in requirements.requiredDiskSpace.items():
            partitionRequirements = requiredDiskSpace.get(partition, 0.0)
            partitionRequirements += float(diskRequirement)
            requiredDiskSpace[partition] = partitionRequirements

        # Aggregate rpm ignore list
        rpmIgnoreList.extend(discoveryResult.ignoreRpms)

        # Aggregate time to install
        timeToInstall += requirementsSpec.patchInfo.timeToInstall

        # Aggregate questions
        questions.extend(requirements.questions)

        # Aggregate reboot required
        rebootRequired = rebootRequired or requirements.rebootRequired

        # Aggregate mismatches
        mismatches.extend(requirementsSpec.mismatches)

    prettyJsonDump(outputFile,
                   {
                        'requirements' : {
                            'requiredDiskSpace' : requiredDiskSpace,
                            'rpmIgnoreList' : rpmIgnoreList,
                            'questions' : questions,
                            'rebootRequired' : rebootRequired
                        },
                        'patchInfo' : {
                            'timeToInstall' : timeToInstall,
                            'patchSummary' : patchSummary
                        },
                        'mismatches' : mismatches
                    }
    )

def _validateDeprecatedRpmRemoval(ctx, fmwMismatches):
    '''
    @return: None if removal will be successful or mismatch with the problem for
        unsuccessful removal
    '''
    #Gather rpms for removal remove duplicates
    allRpmList = set()
    for component in ctx.getAllComponents():
        allRpmList = allRpmList.union(component.discoveryResult.deprecatedProducts)

    allRpmList=list(allRpmList) #list is needed for reporting
    result = validateRpmsRemoval(allRpmList)

    if not result:
        mismatch = Mismatch(_(CANNOT_REMOVE_RPM_WARNING_TEXT, allRpmList), \
                           description=CANNOT_REMOVE_RPM_WARNING_DESCRIPTION, \
                           resolution=CANNOT_REMOVE_RPM_ERROR_RESOLUTION, \
                           severity=Mismatch.WARNING)
        fmwMismatches.append(mismatch)

def discover(stageDir, outputFile, components, locale):
    '''The entry point of discovery CPO phase.

    The discovery phase discovers all components which need to take part in the
    patching process, validates their metadata, validates their inter-component
    dependencies and persist their result for later patch phases. If a component
    wants to take part in the patching workflow, then its requirements will be
    collected(call its requirements hook) as part of discovery process.

    @param stageDir: global CPO stage directory. All components stage
      directories will be created as sub-directories of global stage directory.
    @type stageDir: str

    @param outputFile: File where the output of the command needs to be written
      to
    @type outputFile: str

    @param components: The components which the customer want to patch, the
      other will be filtered out. If None is passed, then all components will be
      returned. The name of the component is equal to the directory where
      its patching logic reside.
    @param components: array of strings

    @param locale: Current locale. All user-facing messages should be
      translated to that locale.
    @type locale: str

    @return: True if the discovery phase succeed and False otherwise
    @rtype: bool

    @raise Exception: if discovery phase raise unhandled exception
    '''
    logger.info("Do discovery... Components: %s, locale: %s", components, locale)
    configureLocalization(locale)
    _cleanupRootStageDir(stageDir)
    fmwMismatches = []
    patchComponents = []
    try:
        scriptsRootDir = getPayloadFilePath('components-script')
        patchComponents = _discoverComponents(scriptsRootDir, stageDir, components,
                                              locale)

        # Log which components have been found
        logger.info("Discovery completed. Result: %s\n",
                    json.dumps(patchComponents,
                               default=ObjectJsonConverter(False).encode,
                               indent=4,
                               sort_keys=True))

        ctx = PatchPhaseContext(stageDir, patchComponents, locale)
        if not _validateComponentsDependencies(ctx):
            mismatch = Mismatch(text=INTERNAL_ERROR_TEXT,
                            description=INTERNAL_ERROR_TEXT,
                            resolution=INTERNAL_ERROR_RESOLUTION)
            fmwMismatches.append(mismatch)
            result = False
        else:
            result = _isSuccessful(patchComponents)

        if result:
            ctx.persist(stageDir)

        _validateDeprecatedRpmRemoval(ctx, fmwMismatches)

        return result
    except Exception:
        mismatch = Mismatch(text=INTERNAL_ERROR_TEXT,
                        description=INTERNAL_ERROR_TEXT,
                        resolution=INTERNAL_ERROR_RESOLUTION)
        fmwMismatches.append(mismatch)
        raise
    finally:
        _reportResults(patchComponents, fmwMismatches, outputFile)