# Copyright (c) 2019-2021 VMware, Inc. All rights reserved.
# VMware Confidential

"""This module contains utils for handling config schemas in VIB as software
   tags and in depot as metadata.
"""

import hashlib
import json
import logging
import os
import shutil

from .Utils.Misc import byteToStr

__all__ = ['ConfigSchema', 'ConfigSchemaSoftwareTag', 'ConfigSchemaCollection']

logger = logging.getLogger('ConfigSchema')

ARRAY = 'array'
DATA = 'data'
DEFAULTS = 'defaults'
ITEMS = 'items'
KEY = 'key'
PROPERTIES = 'properties'
METADATA = 'metadata'
REQUIRED = 'required'
TYPE = 'type'

def _getConfigSchemaFileName(schemaId):
   """Generates the file name of a schema.
   """
   return '%s-schema.json' % schemaId

def _filterDefaults(defaults, properties):
   """ Private method to filter out the defaults after removing
       all the non-user specified fields from the schema.
       Parameters:
          * defaults:   The defaults object.
          * properties: The properties fields with the non-user specified
                        objects filtered out.
   """
   for i in set(defaults.keys()):
      if i not in properties:
         del defaults[i]
         continue
      if isinstance(defaults[i], dict):
         if properties[i].get(TYPE, '') == ARRAY:
            _filterDefaults(defaults[i], properties[i][ITEMS][PROPERTIES])
         else:
            _filterDefaults(defaults[i], properties[i][PROPERTIES])
         if not defaults[i]:
            del defaults[i]


def _filterSchema(schemaObj):
   """ Private method to filter non-user specified fields from the schema
       Parameters:
         * schemObj: The schema object.
   """
   if isinstance(schemaObj, list):
      return [i for i in
              (_filterSchema(j) for j in schemaObj)
              if i is not None]
   elif isinstance(schemaObj, dict):
      #
      # Remove any variables or objects which meet following criteria
      # 1.vital=true: This property or object represents internal state
      # 2.cached=true: This property or object represents internal state
      # 3.type==file: This object tracks sticky bit files in vib.
      # 4.hidden=true: This property represents internal configuration parameter.
      # 5.storeSpecific=true: This property is used by config store framework.
      #                       (for e.g: cs_generated_id is storeSpecific property)
      if (schemaObj.get('vital', False) or schemaObj.get('cached', False) or
          schemaObj.get('hidden', False) or schemaObj.get(TYPE, '') == 'file'
          or schemaObj.get("storeSpecific", False)):
         return None

      retval = {k: v for k, v in
                  (
                     (k, _filterSchema(v)) for k, v in
                     schemaObj.items()
                  )
                  if v is not None}

      if TYPE in retval:
         if retval[TYPE] == 'object' and not retval.get(PROPERTIES):
            return None
         if retval[TYPE] == ARRAY and not retval.get(ITEMS):
            return None

         # Remove the filtered entries from `required` (i.e.; remove all the
         # values in `required` that are not present in `properties`)
         if PROPERTIES in retval and REQUIRED in retval:
            required = set(retval[REQUIRED])
            properties = set(retval[PROPERTIES])
            retval[REQUIRED] = sorted(required & properties)

         # Remove the filtered entries from `defaults`
         if retval.get(DEFAULTS):
            if PROPERTIES in retval:
               _filterDefaults(retval[DEFAULTS][retval[KEY]],
                               retval[PROPERTIES])
            # The array types are homogenous and as such there will only
            # be one type in the items.
            elif ITEMS in retval and PROPERTIES in retval[ITEMS]:
               _filterDefaults(retval[DEFAULTS][retval[KEY]],
                               retval[ITEMS][PROPERTIES])
            if not retval[DEFAULTS][retval[KEY]]:
               retval[DEFAULTS] = {}
      return retval

   else:
      return schemaObj


class ConfigSchemaSoftwareTag(object):
   """Class represents a config schema software tag in VIB.
   """
   CONFIG_SCHEMA_MAGIC = 'ConfigSchema'
   SEPARATOR = ':'

   def __init__(self, schemaId, vibPayload, payloadFilePath, checksumType,
                checksumHex):
      self.schemaId = schemaId
      self.vibPayload = vibPayload
      self.payloadFilePath = payloadFilePath
      self.checksumType = checksumType
      self.checksumHex = checksumHex

   @property
   def schemaFileName(self):
      """Generates file name of the schema.
      """
      return _getConfigSchemaFileName(self.schemaId)

   def ToString(self):
      """Returns the software tag string.
      """
      return self.SEPARATOR.join(
               (self.CONFIG_SCHEMA_MAGIC, self.schemaId, self.vibPayload,
                self.payloadFilePath, self.checksumType, self.checksumHex))

   @classmethod
   def FromString(cls, tag):
      """Converts a software tag string to object.
      """
      parts = tag.split(cls.SEPARATOR)
      if len(parts) != 6 or parts[0] != cls.CONFIG_SCHEMA_MAGIC:
         raise ValueError('Input does not appear to be a config schema '
                          'software tag')
      return cls(parts[1], parts[2], parts[3], parts[4], parts[5])

   @classmethod
   def FromPayloadFile(cls, filePath, payloadName, payloadFilePath):
      """Generate an object using the schema file, the name of payload it
         belongs to, and its member path in the payload.
      """
      schema = ConfigSchema.FromFile(filePath)
      checksumType, checksumHex = schema.checksum
      return cls(schema.schemaId, payloadName, payloadFilePath, checksumType,
                 checksumHex)


class ConfigSchema(object):
   """A simple class that represents image-relevant attributes of a config
      schema.
   """
   # In VIB checksum types all contain dashes.
   HASH_TYPE = 'sha-256'

   def __init__(self, schemaStr):
      self._schemaStr = schemaStr
      jsonDict = json.loads(schemaStr)
      metadataNode = jsonDict.get('metadata', dict())
      self.vibName = metadataNode.get('vibname', None)
      self.vibVersion = metadataNode.get('vibversion', None)

      if not self.vibName or not self.vibVersion:
         raise ValueError('VIB name and version cannot be empty')

      self._filteredSchemaStr = None
      if self._schemaStr:
         origSchema = json.loads(self._schemaStr)
         # The vib metadata has the config schema tag which indicates
         # that vib payload contains a schema file. However, this
         # schema file refers to the complete schema. In some cases,
         # after the schema is filtered, there are no user-visible
         # configuration schemas. As a result, we have "DATA=[]"
         # in the schema.
         # Since the vib metadata has the config schema tag, a
         # schema file is expected in the configSchemas folder. If not
         # it breaks the metadata generation logic. Hence we always
         # create a schema with DATA=[]
         # This approach guarantees that if the schematag is present
         # in vib metadata, the schema file will be present in the
         # in the configSchema metadata folder.

         # Process schemas which are dspIntegrated=true
         dspSchemas = [sch for sch in origSchema[DATA]
                        if sch.get("dspIntegrated", False)]
         self._filteredSchemaStr = json.dumps(
            _filterSchema({METADATA: origSchema[METADATA], DATA: dspSchemas}),
            sort_keys=True)

   def __eq__(self, other):
      normalizeJson = lambda s: (
              json.dumps(json.loads(s), sort_keys=True) if s else None)
      return (self.vibName == other.vibName and
              self.vibVersion == other.vibVersion)

   def __ne__(self, other):
      return not self.__eq__(other)

   @property
   def schemaId(self):
      """ID of the schema is formed by VIB name and version.
      """
      return '%s-%s' % (self.vibName, self.vibVersion)

   @property
   def checksum(self):
      """Returns a tuple of checksum type and hex checksum.
      """
      hashObj = hashlib.new(self.HASH_TYPE.replace('-', ''))
      hashObj.update(self._schemaStr.encode())
      return self.HASH_TYPE, hashObj.hexdigest()

   @property
   def fileName(self):
      """Generates file name of the schema.
      """
      return _getConfigSchemaFileName(self.schemaId)

   @classmethod
   def FromFile(cls, filePath):
      with open(filePath, 'r') as fobj:
         return cls(fobj.read())

   def WriteFile(self, filePath, filterCS=False):
      """Write the schema file to the specified path. In the case where
         filtering out results in an empty schema; no file will be written.
         Parameters:
         * filePath: The path to the schema file being written out.
         * filterCS: Boolean indicating whether to filter out the
                     non-user specified fields from the schema or not.
         Returns:
            True if the file was written, false otherwise.
      """
      schemaStr = self._filteredSchemaStr if filterCS else self._schemaStr
      if not schemaStr:
         logger.info("Schema file (%s) skipped after filtering",
                     os.path.basename(filePath))
         return False
      with open(filePath, 'w') as fobj:
         fobj.write(schemaStr)
      return True


class ConfigSchemaCollection(dict):
   """A collection of config schema objects.
   """
   def __add__(self, other):
      """Merge two objects and return a new one.
      """
      new = self.__class__()
      for cs in self.values():
         new.AddConfigSchema(cs)
      new += other
      return new

   def __iadd__(self, other):
      for cs in other.values():
         self.AddConfigSchema(cs)
      return self

   def AddFromJSON(self, jsonStr, validate=False):
      """Adds a config schema from JSON string.
         Currently validate has not effect, but is required to match other
         collections' API.
      """
      jsonStr = byteToStr(jsonStr)
      cs = ConfigSchema(jsonStr)
      self.AddConfigSchema(cs)

   def AddConfigSchema(self, cs):
      """Adds a config schema.
      """
      if cs._filteredSchemaStr is None:
          return
      cscopy = ConfigSchema(cs._filteredSchemaStr)
      if cscopy.schemaId not in self:
         self[cscopy.schemaId] = cscopy

   def FromDirectory(self, path):
      """Populates the collection with files in a directory.
         This clears the collection before populating the objects.
      """
      if not os.path.exists(path):
         raise RuntimeError('Directory %s does not exist' % path)
      elif not os.path.isdir(path):
         raise RuntimeError('Path %s is not a directory' % path)

      self.clear()
      for root, _, files in os.walk(path, topdown=True):
         for name in files:
            filePath = os.path.join(root, name)
            cs = ConfigSchema.FromFile(filePath)
            self.AddConfigSchema(cs)

   def ToDirectory(self, path):
      """Writes out filtered config schemas into a directory.
         If the directory exists, the content of the directory will be
         clobbered. In the case where filtering the schema results in
         an empty schema; the schema file will not be written.
         Parameters:
            * path: The directory for the schema files.
         Returns:
            A list of skipped files.
      """
      if os.path.isdir(path):
         shutil.rmtree(path)
      os.makedirs(path)
      skippedFiles = []
      for cs in self.values():
         if not cs.WriteFile(os.path.join(path, cs.fileName), True):
            skippedFiles.append(cs.fileName)
      return skippedFiles
