########################################################################
# Copyright (c) 2019-2024 Broadcom. All Rights Reserved.
# Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc.
# and/or its subsidiaries.
########################################################################

from copy import deepcopy
from datetime import datetime
import sys
import abc

if sys.version_info >= (3, 4):
   ABC = abc.ABC
else:
   # scons build is still using python 2.
   ABC = abc.ABCMeta('ABC', (), {})

import json

from .Bulletin import ComponentRelation
from .ReleaseUnit import NameSpec, TIME_FORMAT, VersionSpec, getUnequalMsg


class InvalidConstraint(Exception):
   """Exception class to signify a bad constraint
   """
   pass


class InvalidRange(Exception):
   """Exception class that is used to signify that a bad range
      was used as a constraint.
      i.e. We can't form a closed range with the range operators.
   """
   pass


class InvalidRelationToken(Exception):
   """Exception class that is used to signify a bad range token.
      i.e. There is no >, >=, <. <=
   """
   pass


class EmptyList(Exception):
   """Exception class to signify that empty list
      was given as a constraint.
   """
   pass


class InvalidSolutionJSON(Exception):
   """Exception class to bad solution JSON file
   """
   pass


class InvalidSolutionArg(Exception):
   """Exception class for a Bad Solution Argument
   """
   pass


class MissingSolutionArg(Exception):
   """Exception class for a Missing Solution Argument
   """
   pass


class Solution(object):
   """A solution specification represents a set of necessary component
      constraints for a solution like FDM/HA or NSX.
   """

   # Various JSON attributes which can in a solution JSON file
   solutionAttrib = 'solution'
   simpleAttribs = ['description', 'summary', 'vendor', 'docURL']

   nameSpecAttrib = 'nameSpec'
   versionSpecAttrib = 'versionSpec'
   componentsAttrib = 'components'
   releaseDateAttrib = 'releaseDate'
   readOnlyAttribs = ['releaseID', 'releaseType']

   mandatoryAttribs = (nameSpecAttrib, versionSpecAttrib, componentsAttrib)
   # Solution uses 'componentConstraints' as the attribute, while
   # componentsAttrib ('component') as the key of its dictionary.
   allAttribs = simpleAttribs + [nameSpecAttrib, versionSpecAttrib,
                'componentConstraints', releaseDateAttrib] + readOnlyAttribs

   def __init__(self, **kwargs):
      """Creates a solution specification given arguments

         Params:
            kwArgs - arguments for creating the Solution
      """
      class ListWithStr(list):
         def __str__(self):
            return ', '.join([str(elem) for elem in self])

      missingMandatoryArgs = [arg for arg in self.__class__.mandatoryAttribs
                              if arg not in kwargs.keys()]

      self.componentConstraints = ListWithStr()
      if missingMandatoryArgs:
         raise MissingSolutionArg('The following mandatory Solution kwargs'
                                  ' are missing %s' %
                                  ' ,'.join(missingMandatoryArgs))

      setattr(self, 'releaseType', self.__class__.solutionAttrib)
      for fieldName, fieldValue in kwargs.items():
         if fieldName in self.__class__.simpleAttribs:
            setattr(self, fieldName, fieldValue)

         elif fieldName == self.__class__.versionSpecAttrib:
            versionSpecKeys = ('version', 'uiString')
            for key in versionSpecKeys:
               if key not in fieldValue:
                  raise InvalidSolutionArg('Missing argument in VersionSpec: '
                                           ' %s' % key)

            self.versionSpec = VersionSpec(fieldValue['version'],
                                            fieldValue['uiString'])

         elif fieldName == self.__class__.nameSpecAttrib:
            nameSpecKeys = ('name', 'uiString')
            for key in nameSpecKeys:
               if key not in fieldValue:
                  raise InvalidSolutionArg('Missing argument in'
                                           ' NameSpec: %s' % key)

            self.nameSpec = NameSpec(fieldValue['name'],
                                      fieldValue['uiString'])

         elif fieldName == self.__class__.componentsAttrib:
            for componentName, constraints in fieldValue.items():
               self.componentConstraints.append(
                  ComponentConstraint.Factory(componentName, constraints))


         elif (fieldName not in self.__class__.readOnlyAttribs and
               fieldName != self.__class__.releaseDateAttrib):

            raise InvalidSolutionArg('Unknown Solution kwarg: %s' % fieldName)

      if 'releaseID' in kwargs:
         self.releaseID = kwargs.get('releaseID')
      else:
         self.releaseID = self.nameSpec.name + '_' + \
                          self.versionSpec.version.versionstring

      if self.__class__.releaseDateAttrib in kwargs:
         self.releaseDate = datetime.strptime(kwargs.get(
                                          self.__class__.releaseDateAttrib),
                                          TIME_FORMAT)
      else:
         self.releaseDate = datetime.utcnow()

   def MatchComponents(self, components):
      """Get components in the component collection that match to this solution.

         Returns:
            A dict that has component name as key and component as value.
      """
      solCompDict = dict()
      for constraint in self.componentConstraints:
         comps = constraint.MatchComponents(components)
         if comps:
            # Component name will not be added the component is not found.
            solCompDict.setdefault(constraint.componentName, []).extend(comps)
      return solCompDict

   def Validate(self, componentCollection):
      """Validates that a component collection meets the constraints to realize
         a solution.

         Params:
            componentCollection - A collection of components to check

         Returns:
            Boolean - True if the component collection meets the component
                      constraints of this solution
            List - The list of components from the solution whose constraints
                   aren't met from the componentCollection. If all constraints
                   are met an empty list is returned
      """
      failedValidation = []
      for componentContraint in self.componentConstraints:
         if not componentContraint.Validate(componentCollection):
            failedValidation.append(componentContraint.componentName)

      return failedValidation == [], failedValidation

   def __eq__(self, other):
      """Checks if two solutions are equal

         Returns:
            True if the solutions are equal otherwise false
      """
      return isinstance(other, Solution) and self.ToDict() == other.ToDict()

   def ToDict(self):
      """Creates a dictionary from the Solution

         Returns:
            A dictionary with all the solution attributes
      """
      solDict = {}
      componentDictItems = {}
      for componentConstraint in self.componentConstraints:
         componentDictItems.update(componentConstraint.ToDict())

      solDict[self.__class__.componentsAttrib] = componentDictItems
      solDict[self.__class__.nameSpecAttrib] = self.nameSpec.__dict__
      solDict[self.__class__.versionSpecAttrib] = self.versionSpec.ToJSONDict()

      for attribute in (self.__class__.simpleAttribs
                        + self.__class__.readOnlyAttribs):
         solDict[attribute] = getattr(self, attribute)

      solDict[self.__class__.releaseDateAttrib] = self.releaseDate.strftime(TIME_FORMAT)
      return solDict

   @classmethod
   def _FromJSONDict(cls, solutionDict):
      if ('releaseType' not in solutionDict or
          solutionDict['releaseType'] != cls.solutionAttrib):
         raise InvalidSolutionJSON('Invalid release type in solution spec.')

      return cls(**solutionDict)

   @classmethod
   def FromJSONFile(cls, filename, validation=False):
      """Creates a solution object from a JSON file.

         Params:
            filename - The JSON file to create a solution object
            validation - If True the function will perform schema validation.

         Returns:
            A Solution Object created from a JSON file
      """
      with open(filename, 'r') as f:
         solutionDict = json.load(f)
      return cls._FromJSONDict(solutionDict)

   @classmethod
   def FromJSON(cls, specStr, validation=False, schemaVersionCheck=False):
      """Creates a solution object from a JSON string.
         Params:
            specStr - The JSON string to create a solution object from
            validation - If True the function will perform schema validation.
            schemaVersionCheck - Flag to check schema version compatibility.

         Returns:
            A Solution Object created from a JSON file
      """
      solutionDict = json.loads(specStr)
      return cls._FromJSONDict(solutionDict)

   def ToJSONFile(self, filename):
      """Writes a Solution object to a JSON file

         Params:
            filename - The JSON file to write the object to
      """
      with open(filename, 'w') as f:
         json.dump(self.ToDict(), f)

   def ToJSON(self):
      """Serialize a Solution object to a JSON string.
      """
      return json.dumps(self.ToDict())

   def Copy(self):
      """Creates a copy of this solution object

         Returns:
            A new copied solution object
      """
      return deepcopy(self)

   def GetUnequalMsg(self, other):
      """Helper function for _ReleaseCollection.update() to add detailed
         attributes in error message for unequal solutions that share the same
         release ID.
      """
      return getUnequalMsg(self, other)


class ComponentConstraint(ABC):
   """A Component Constraint is used to define the necessary versioning
      requirements for a component to enable a solution.
   """

   def __eq__(self, other):
      return isinstance(other, ComponentConstraint) and \
             self.ToDict() == other.ToDict()

   @staticmethod
   def Factory(name, constraints):
      """This factory method creates a component constraint based upon
         constraint type.

         Params:
            name - The name of the component that has the constraint
            constraints - Either a list of individual constraints or
                          a dictionary containing a constraint range.
      """
      if type(constraints) == list:
         return ComponentConstraintList(name, constraints)
      elif type(constraints) == dict:
         return ComponentConstraintRange(name, constraints)
      else:
         raise InvalidConstraint('Component constrains must be in either list'
                                 ' or a range in form a two value dictionary.')

   @abc.abstractmethod
   def Validate(self, componentCollection):
      """Pure Virtual method that validates whether a component collection
         meets a component constraint. Child class will provide the actual
         implementation of this metHod

         Returns:
            Bool - True if the components in the collection meet the versioning
                   constraints, otherwise false
      """
      pass

   @abc.abstractmethod
   def MatchComponents(self, components):
      """Get the components that meets the constraint from a component
         collection.

         Returns:
            A list of Component object, empty list when not found.
      """
      pass

   @abc.abstractmethod
   def ToDict(self):
      """Turns the name and constraints of this component constraint back
         into a dictionary. This is useful for JSON serialization of this
         object.

         Returns:
            Name to component constraint dictionary
      """
      pass


class ComponentConstraintList(ComponentConstraint):
   """A Component Constraint List is defines by a list of versions
      of a component that are necessary to realize a solution.

      As an example a solution that requires a component constraint
      for a versions 1.0 and 2.0 of a particular component 'MyComponent',
      would provide a component constraint definition like this:
      Name: MyComponent
      Version: ['1.0', '2.0']
   """

   def __str__(self):
      return ("<ComponentConstraintList Name: %s; Versions: "
              "%s >" % (self.componentName, ', '.join(self.versionList)))

   def __init__(self, componentName, versionList):
      """Creates a component constraint based upon a component name
         and a list of component versions.

         Params:
            componentName - The name of the component that has a constraint
            versionList - The list of versions that we need for this
                          component
      """
      if not versionList:
         raise EmptyList('Constraing version list is empty')

      self.componentName = componentName
      self.versionList = versionList

   def Validate(self, componentCollection):
      """Validates that the components in the collection meets one or more
         of the versioning constraints in the version constraints
         list.

         Returns:
            True if the components in the collection meet the versioning
            constraints, otherwise False
      """
      for version in self.versionList:
         if componentCollection.HasComponent(self.componentName, version):
            return True
      return False

   def MatchComponents(self, components):
      """Get the components that meets the constraint from a component
         collection.

         Returns:
            A list of Component object, empty list when not found.
      """
      try:
         comps = components.GetComponents(name=self.componentName)
      except KeyError:
         # No component with the name.
         return []

      return [c for c in comps if c.compVersionStr in self.versionList]

   def ToDict(self):
      """Turns the name and constraints of this component constraint back
         into a dictionary. This is useful for JSON serialization of this
         object.

         Returns:
            dictionary mapping name to version list
      """
      return {self.componentName: self.versionList}


class ComponentConstraintRange(ComponentConstraint):
   """A Component Constraint Range is used to define a range of versions
      of a component that a solution requires.

      An example of a solution that requires a component constraint
      for a range of components that are greater than version 1.0
      and less than version 2.0
      Name: MyComponent
      Version: {">":"1.0",  "<":"2.0"}
   """

   def __str__(self):
      return ("<ComponentConstraintRange Name: %s; Versions: "
              "%s >" % (self.componentName, ', '.join([str(k) + str(v)
              for k, v in self.rangeDict.items()])))

   def __init__(self, componentName, rangeDict):
      """Creates a component constraint based upon a version range
         that is in a dictionary.

         Params:
            componentName - The name of the component that has a constraint
            rangeDict - A dictionary containg two items which describe
                        the range of the component versions.
      """
      self.componentName = componentName
      self.rangeDict = rangeDict

      if len(rangeDict) != 2:
         raise InvalidRange('The range dictionary must contain two item')

      self.relations = []

      operator1, operator2 = rangeDict.keys()
      version1, version2 = rangeDict.values()
      self.relations.append(ComponentRelation(componentName,
                                              operator1,
                                              version1))

      self.relations.append(ComponentRelation(componentName,
                                              operator2,
                                              version2))

      greaterOps = ['>=', '>']
      lesserOps = ['<=', '<']
      if operator1 in greaterOps and operator2 in greaterOps:
         raise InvalidRange('Range operators must be of opposite'
                            ' types to create a closed range, both operators'
                            ' are greater than.')

      elif operator1 in lesserOps and operator2 in lesserOps:
         raise InvalidRange('Range operators must be of opposite '
                            ' types to create a closed range, both'
                            ' operators are less than.')

      if operator1 in lesserOps:
         if version1 < version2:
            raise InvalidRange('Cannot create a closed bound when the upper'
                               ' bound value: %s, is less than the lower'
                               ' bound value: %s.' % (version1, version2))

      elif version1 > version2:
         raise InvalidRange('Cannot create a closed bound when the lower bound'
                            ' value: %s, is greater than the upper bound value'
                            ' %s' % (version1, version2))

   def Validate(self, componentCollection):
      """Validates that the components in the collection meets the range

         Returns:
            Bool - True if the components in the collection meet the versioning
                   constraints, otherwise false
      """
      for _, componentVerDict in componentCollection.items():
         for _, component in componentVerDict.items():

            isValid = True
            for relation in self.relations:
              isValid = relation.Validate(component) and isValid

            if isValid:
               return True

      return False

   def MatchComponents(self, components):
      """Get the components that meets the constraint from a component
         collection.

         Returns:
            A list of Component object, empty list when not found.
      """
      try:
         comps = components.GetComponents(name=self.componentName)
      except KeyError:
         # No component with the name.
         return []

      retList = []
      for comp in comps:
         for relation in self.relations:
            if not relation.Validate(comp):
               break
         else:
            retList.append(comp)
      return retList

   def ToDict(self):
      """Turns the name and constraints of this component constraint back
         into a dictionary. This is useful for JSON serialization of this
         object.

         Returns:
            dictionary mapping name to version list
      """
      return {self.componentName: self.rangeDict}
