#!/usr/bin/env python
# Copyright 2016 VMware, Inc.
# All rights reserved. -- VMware Confidential

"""
    This script is executed by the `software-packages` command.

    The purpose of this script is to play the integration role between
    software-packages and PatchRunner. It is responsible to call PatchRunner
    and transform its output to the input expected by software-packages.
"""
import sys
import os
import logging
import tempfile
import time
import shutil
import json
from os.path import join, exists, abspath, dirname, isdir, basename
from lxml import etree

from update_utils import runCommand, DEPLOYMENT_ROOT, MANIFEST_FILE, getRPMBlacklist, \
        RPM_MANIFEST_FILE
from vmware.update.json_utils import prettyJsonDump, JsonFileSerializer, JsonSerializer
from vmware.update.extensions import extend, Hook
from vmware.update.specs import RequirementsResult, Requirements, \
        ValidationResult, Mismatch, InternalError, StatusInfo, ErrorInfo, \
        PatchInfo, Question
from vmware.update.l10n import msgMetadata as _T, localizedString as _

logger = logging.getLogger(__name__)

INTERNAL_ERROR_TEXT = _(_T('ur.internal.text',
                           'Internal error occurs during execution of update process.'))
INTERNAL_ERROR_RESOLUTION = _(_T('ur.internal.resolution',
                                 'Send upgrade log files to VMware technical support '
                                 'team for further assistance.'))
PATCH_SUMMARY = _(_T('patch.summary',
                     'Test patch summary for testing only.'))
TIME_TO_INSTALL= 0

def _executePatchRunnerAndSupplyUserData(command, stageDir, userData, outputFile,
                                         reportCompletion):
    '''Execute PatchRunner subcommand and supply user data to executed process.
    The routine will store the user data into a file and will pass the file
    to PatchRunner subcommand, then the file will be destroyed as soon as
    the PatchRunner loads it.

    @param command: PatchRunner command. Could be validate, prepatch, patch
    @type command: str

    @param stageDir: PatchRunner stage directory
    @type stageDir: str

    @param userData: Supplied user data as lookup of user data id and answer
    @type userData: dict

    @param outputFile: Output file passed to PatchRunner subcommand. This will
      be the file where the PatchRunner will store its results
    @type outputFile: str

    @param reportCompletion: Whether the routine needs to report the status of
      the command based on the PatchRunner subcommand exit code
    @type reportCompletion: bool
    '''
    patchRunner = join(DEPLOYMENT_ROOT, "scripts", "patches", "PatchRunner.py")

    with tempfile.NamedTemporaryFile(mode="w+") as fp:
        # Dump answers to a file}
        cnt = JsonSerializer().serialize(userData)
        fp.write(cnt)
        # Flush all the data, so the other process can read it.
        fp.flush()

        cmd = ['/usr/bin/python3', '"%s"' % patchRunner, command, "-u", '"%s"' % fp.name,
               "-o", '"%s"' % outputFile, '-d', '"%s"' % stageDir, '--disableStdoutLogging']
        out, err, rc = runCommand(["/bin/bash",  "--login", "-c", ' '.join(cmd)],
                                   progress=True,
                                   message="Running %s script." % command)

    logger.debug("Patch command %s: \n out=%s\nerror=%s\nrc=%d\n" %
                 (command, out, err, rc))

    if reportCompletion:
        if rc:
            logger.error("Patch command %s failed", command)
            _reportFailure(outputFile)
        else:
            logger.debug("Patch command %s completed successfully", command)
            _reportSuccess(outputFile)

    return rc == 0

def _reportSuccess(outputFile):
    if exists(outputFile):
        serializer = JsonFileSerializer(outputFile)
        data = serializer.deserialize()
        if data["status"] == StatusInfo.State.SUCCESS:
            logger.debug("Success status has been already reported")
        elif data["status"] == StatusInfo.State.ERROR:
            logger.warn("Unable to report success, because failure has been "
                        "already reported.")
        else:
            logger.debug("Report success")
            data["status"], data["progress"] = StatusInfo.State.SUCCESS, 100
            prettyJsonDump(outputFile, data)
    else:
        logger.debug("Report success")
        result = StatusInfo(StatusInfo.State.SUCCESS, progress=100)
        prettyJsonDump(outputFile, result)

def _reportFailure(outputFile):
    error=ErrorInfo(detail=[INTERNAL_ERROR_TEXT], resolution=INTERNAL_ERROR_RESOLUTION)
    if exists(outputFile):
        serializer = JsonFileSerializer(outputFile)
        data = serializer.deserialize()
        if data["status"] == StatusInfo.State.ERROR:
            logger.debug("Error status has been already reported")
            return

        if data["status"] == StatusInfo.State.SUCCESS:
            logger.warn("Wrong success status has been already reported. "
                        "Overwriting it.")

        logger.debug("Report error")
        data["status"] = StatusInfo.State.ERROR
        data["error"] = error
        prettyJsonDump(outputFile, data)
    else:
        logger.debug("Report error")
        result = StatusInfo(StatusInfo.State.ERROR, error=error)
        prettyJsonDump(outputFile, result)

def _reportProgress(outputFile, progress):
    result = StatusInfo(StatusInfo.State.RUNNING, progress=progress)
    prettyJsonDump(outputFile, result)

def _transformRequirementsRpmIngoreList(rpmIgnoreList):
    ''' Transforms the rpm ignore list recieved from Discovery hook execution to match
    the format expected by the upstream. Receive list of names, transform to list of
    names-version-build.rpm from the manifest
    Example:
    _transformRequirementsRpmIngoreList(['vmware-studio-vami-lighttpd', 'vmware-vmrc'])
    ['vmware-studio-vami-lighttpd_3.0.0.2-4446656.x86_64.rpm', 'vmware-vmrc-6.6.0-8842685.x86_64.rpm']


    @param rpmIgnoreList: Rpms to ignore during patching
    @type rpmIgnoreList: list of str

    @rtype: list of str
    '''
    if not rpmIgnoreList:
        return []

    #Load the manifest file so that we can do mapping between name and name with versions
    manifest = etree.parse(MANIFEST_FILE)
    ignoreList = []
    for rpmName in rpmIgnoreList:
        rpmXml = manifest.xpath("//package[@name='%s']" % rpmName)
        if rpmXml:
           #location is always including 'package-pool/'
           fullName = basename(rpmXml[0].find('location').text.strip())
           ignoreList.append(fullName)
        else:
           logger.debug('Could not find matching rpm for name %s', rpmName)
    return ignoreList

def _transformRequirements(requirements, defaultRpmIgnoreList):
    '''Transforms requirements from PatchRunner to requirements defined in
    patching spec. Add rpmIgnoreList based on the deployment type, on top of
    rpmIgnoreList defined by the components

    @param requirements: Aggregated component requirements:
    @type requirements: dict representing Requirements object
     Example:
         {
            "rebootRequired": false,
            "requiredDiskSpace": {
                "core": 0.1
            },
            "rpmIgnoreList": [],
            "questions": [
                {
                    "defaultAnswer": null,
                    "kind": "password",
                    "description": {
                        "id": "sso.password.desc",
                        "localized": "SSO root password is ..",
                        "translatable": "SSO root password is .."
                    },
                    "text": {
                        "id": "sso.password.text",
                        "localized": "What is the SSO root password",
                        "translatable": "What is the SSO root password"
                    },
                    "allowedValues": [],
                    "userDataId": "sso.root.password"
                }
            ]
        }
    @param defaultRpmIgnoreList: Default rpm ignore list which needs to be
      added on top of component requirements
    @type defaultRpmIgnoreList: list

    @return: Patch requirements
    @rtype update_specs.Requirements
    '''
    requiredDiskSpace = requirements['requiredDiskSpace']
    rpmIgnoreList = _transformRequirementsRpmIngoreList(requirements['rpmIgnoreList']) + defaultRpmIgnoreList
    questions = []
    rebootRequired = requirements['rebootRequired']

    for q in requirements['questions']:
        question = Question(userDataId = q['userDataId'],
                            text = q['text'],
                            description = q['description'],
                            kind = q['kind'],
                            allowedValues = q['allowedValues'],
                            defaultAnswer = q['defaultAnswer'])
        questions.append(question)
    return Requirements(requiredDiskSpace=requiredDiskSpace,
                        rpmIgnoreList=rpmIgnoreList,
                        questions=questions,
                        rebootRequired=rebootRequired)

def _transformPatchInfo(patchInfo):
    '''Transforms patch info returned by PatchRunner to patch info defined in
    patching spec. Replace patch summary with patch summary defined on top
    level.

    @param patchInfo: Aggregated patch info.
    @type patchInfo: dict representing PatchInfo object
     Example:
         {
            "patchSummary": null,
            "timeToInstall": 5
         },

    @return: Patch information
    @rtype update_specs.PatchInfo
    '''
    return PatchInfo(timeToInstall=patchInfo['timeToInstall'],
                     patchSummary=PATCH_SUMMARY)

def _transformMismatches(mismatches):
    """Transforms mismatches from PatchRunner to mismatches defined
    in patching spec. Add given mismatches on top of mismatches returned by
    PatchRunner

    @param mismatches: Patch Runner mismatches
    @type mismatches: array of dict
    """
    result = []
    for mismatch in mismatches:
        result.append(Mismatch(
                text = mismatch['text'],
                description = mismatch['description'],
                resolution = mismatch['resolution'],
                problemId = mismatch['problemId'],
                severity = mismatch['severity'],
                relatedQuestionId = mismatch['relatedUserDataId'])
        )

    return result

def _getPatchRunnerStageDir(rootStageDir):
    return join(rootStageDir, "patch_runner")

def _filterB2BScripts(scriptsDir):
    '''
    Now we packages all B2B scripts into special service repo
    Execution of these scripts without actual installation of
    the new binaries will lead to crash
    '''
    with open(RPM_MANIFEST_FILE) as f:
        rpmManifestJson = json.load(f)['header']

    if 'allowed_b2b_components' not in rpmManifestJson or not exists(scriptsDir):
       logger.debug('Not filtering B2B scripts')
       return

    dirs = os.listdir(scriptsDir)
    for i in dirs:
        if i in ['first_component', 'last_component']:
            continue
        if i in rpmManifestJson['allowed_b2b_components']:
            continue

        filepath = os.path.join(scriptsDir, i)
        logger.debug('Payload does not allow component %s to be run, removing it', i)
        if os.path.isfile(filepath):
            os.remove(filepath)
        else:
            shutil.rmtree(filepath)

@extend(Hook.Requirements)
def collectRequirements(requirementsSpec):
    patchRunnerStageDir = _getPatchRunnerStageDir(requirementsSpec.stageDirectory)
    patchRunner = join(DEPLOYMENT_ROOT, "scripts", "patches", "PatchRunner.py")
    patchRunnerComponentsDir = join(DEPLOYMENT_ROOT, "scripts", "patches", "payload", "components-script")

    if not exists(patchRunnerStageDir):
        os.makedirs(patchRunnerStageDir)

    if requirementsSpec.userData.get('thirdPartyPatch'):
        #This is 3rd party update and only system scripts need to be executed
        for dr in os.listdir(patchRunnerComponentsDir):
            fullPath = join(patchRunnerComponentsDir, dr)
            if isdir(fullPath) and 'first_component' not in dr \
                and 'last_component' not in dr:
                logger.debug('Third party patching, thus removing components directory %s', fullPath)
                shutil.rmtree(fullPath)

    #Some payloads require that some scripts are not executed
    _filterB2BScripts(patchRunnerComponentsDir)

    with tempfile.NamedTemporaryFile() as fp:
        cmd = ['/usr/bin/python3', '"%s"' % patchRunner, 'discovery', "-o", '"%s"' % fp.name,
               "-d", '"%s"' % patchRunnerStageDir, '--disableStdoutLogging']
        out, err, rc = runCommand(["/bin/bash",  "--login", "-c", ' '.join(cmd)],
                                   progress=True,
                                   message="Running requirements script.")
        logger.debug("Patch command %s: \n out=%s\nerror=%s\nrc=%d\n" %
             (cmd, out, err, rc))

        # There is python bugs on the appliance and the code below does not work
        # properly
        #fp.seek(0)
        #requirementsResult = fp.read()
        with open(fp.name) as tempFp:
            requirementsResult = tempFp.read()

    rpmIgnoreList = getRPMBlacklist()

    if requirementsResult:
        logger.debug("Transform patch runner requirements results: %s",
             requirementsResult)
        patchRunnerRes = JsonSerializer().deserialize(requirementsResult)
        requirements = _transformRequirements(patchRunnerRes['requirements'],
                                              rpmIgnoreList)
        patchInfo = _transformPatchInfo(patchRunnerRes['patchInfo'])
        mismatches = _transformMismatches(patchRunnerRes['mismatches'])
        return RequirementsResult(requirements=requirements,
                                  patchInfo=patchInfo,
                                  mismatches=mismatches)
    else:
        if rc:
            logger.error("Requirements failed. Generate error result")
            mismatch = Mismatch(INTERNAL_ERROR_TEXT,
                    description=INTERNAL_ERROR_TEXT,
                    resolution=INTERNAL_ERROR_RESOLUTION,
                    severity=Mismatch.ERROR)
            mismatches = [mismatch]
        else:
            logger.debug("Requirements succeeded. Generate success result")
            mismatches = []
        requirements = Requirements(rpmIgnoreList=rpmIgnoreList)
        patchInfo = PatchInfo(TIME_TO_INSTALL, PATCH_SUMMARY)
        return RequirementsResult(requirements=requirements, patchInfo=patchInfo,
                                  mismatches=mismatches)

@extend(Hook.Validation)
def validate(validationSpec):
    patchRunnerStageDir = _getPatchRunnerStageDir(validationSpec.stageDirectory)
    userData = validationSpec.userData or {}
    with tempfile.NamedTemporaryFile() as fp:
        succ = _executePatchRunnerAndSupplyUserData("validate",
                                                    patchRunnerStageDir,
                                                    userData,
                                                    fp.name,
                                                    reportCompletion=False)

        # There is python bugs on the appliance and the code below does not work
        # properly
        #fp.seek(0)
        #validationResult = fp.read()
        with open(fp.name) as tempFp:
            validationResult = tempFp.read()

    if validationResult:
        logger.debug("Transform patch runner validation results: %s",
                     validationResult)
        patchRunnerRes = JsonSerializer().deserialize(validationResult)
        mismatchResult = _transformMismatches(patchRunnerRes['mismatches'])
        result = ValidationResult(mismatches=mismatchResult)
    elif not succ:
        logger.debug("Validate logic failed. Generate default error")
        mismatch = Mismatch(INTERNAL_ERROR_TEXT,
                            description=INTERNAL_ERROR_TEXT,
                            resolution=INTERNAL_ERROR_RESOLUTION,
                            severity=Mismatch.ERROR)
        result = ValidationResult([mismatch])
    else:
        logger.debug("Validate succeeded. Generate success result")
        result = ValidationResult()

    return result

@extend(Hook.Prepare)
def prepare(prepareSpec):
    # This hook is designed to be used in exceptional cases when rpms conflicts
    # need to be sorted out before the other rpms being install
    # if you change the system in this hook you need to WARN the user for that
    # as on failure the customer will need to revert via snapshot
    patchRunnerStageDir = _getPatchRunnerStageDir(prepareSpec.stageDirectory)
    userData = prepareSpec.userData or {}
    _executePatchRunnerAndSupplyUserData("system-prepare",
                                         patchRunnerStageDir,
                                         userData,
                                         prepareSpec.outputFile,
                                         reportCompletion=True)

@extend(Hook.Prepatch)
def prepatch(prepatchSpec):
    patchRunnerStageDir = _getPatchRunnerStageDir(prepatchSpec.stageDirectory)
    userData = prepatchSpec.userData or {}
    _executePatchRunnerAndSupplyUserData("prepatch",
                                         patchRunnerStageDir,
                                         userData,
                                         prepatchSpec.outputFile,
                                         reportCompletion=True)

@extend(Hook.Patch)
def patch(patchSpec):
    patchRunnerStageDir = _getPatchRunnerStageDir(patchSpec.stageDirectory)
    userData = patchSpec.userData or {}
    _executePatchRunnerAndSupplyUserData("patch",
                                         patchRunnerStageDir,
                                         userData,
                                         patchSpec.outputFile,
                                         reportCompletion=True)
