########################################################################
# Copyright (c) 2021-2022 VMware, Inc.  All rights reserved.
# All Rights Reserved
########################################################################
#
# Notification.py
#

__all__ = ['Notification', 'NotificationCollection']

import datetime
import logging
import os
import shutil

from . import Errors
from .Utils import XmlUtils
from .Vib import SoftwarePlatform

from copy import deepcopy

etree = XmlUtils.FindElementTree()

log = logging.getLogger('Notification')

def getDefaultNotificationFileName(notif):
   """Default naming function for notifications.
   """
   return notif.id + '.xml'

class Notification(object):
   """A Notification defines a set of Bulletin packages for recall.
      Right now only NOTIFICATION_RECALL (category: recall) and
      RELEASE_NOTIFICATION (releaseType: notification) are supported.
      This class only deals with notifications, so any release types
      other than RELEASE_NOTIFICATION will raise an exception. As for
      NOTIFICATION_TYPES, here we only support NOTIFICATION_RECALL.
      Otherwise, it would also cause an error.

      Class Variables:
         * NOTIFICATION_RECALL
         * RELEASE_NOTIFICATION
         * SEVERITY_CRITICAL
         * SEVERITY_SECURITY
         * SEVERITY_GENERAL
         * SEVERITY_TYPES
         * ATTRS_TO_VERIFY        - A list of attributes to verify whether
                                    two notifications are equal
         * RECALL_ATTRS           - Attributes related to recalled release
                                    units
         * RECALL_XML_INFO        - A list of (XML search path, release unit
                                    name, attribute name) tuples of each type
                                    of recalled release unit, e.g., for addon,
                                    the tuple is ('recalledAddonList/addonSpec',
                                    'addon', 'recalledAddons')

       Attributes:
         * id                    - A string specifying the unique bulletin ID.
         * vendor                - A string specifying the vendor/publisher.
         * summary               - The abbreviated (single-line) bulletin
                                   summary text.
         * severity              - A string specifying the bulletin's severity.
         * category              - A string specifying the bulletin's category.
                                   Since it is a notification, the category
                                   will be related to NOTIFICATION_TYPES. Right
                                   now only NOTIFICATION_RECALL is supported.
         * releaseType           - A string specifying the release type. It
                                   can only be "notification" here.
         * description           - The (multi-line) bulletin description text.
         * kbUrl                 - A URL to a knowledgebase article related to
                                   the bulletin.
         * contact               - Contact information for the bulletin's
                                   publisher.
         * releaseDate           - An integer or float value giving the
                                   bulletin's release date/time. May be None if
                                   release date is unknown.
         * platforms             - A list of SofwarePlatform objects, each
                                   contains info for version, locale and
                                   productLineID.
         * recalledComponents    - A list of components to recall,
                                   componentSpec: name + version
         * recalledAddons        - A list of addons to recall,
                                   addonSpec: name + version
   """
   NOTIFICATION_RECALL = 'recall'
   RELEASE_NOTIFICATION = 'notification'
   SEVERITY_CRITICAL = 'critical'
   SEVERITY_SECURITY = 'security'
   SEVERITY_GENERAL = 'general'
   SEVERITY_TYPES = (SEVERITY_SECURITY, SEVERITY_CRITICAL, SEVERITY_GENERAL)

   # The following attributes are equal if two notifications are equal
   # Specifically, we want 'releaseType' to be the same for two equal
   # notifications to avoid the problem of "recall of recall", e.g., in
   # an earlier version, this notification recalls component1 with version
   # 1.0, then later we publish a newer version of the same notification,
   # recalling component2 with version 2.0, then before doing the new recall,
   # we need to return component1 with version 1.0 first, which could be
   # troublesome.
   ATTRS_TO_VERIFY = ('id', 'vendor', 'releaseDate', 'platforms', 'severity',
                      'recalledComponents', 'recalledAddons', 'category',
                      'releaseType', 'summary', 'description', 'kbUrl',
                      'contact')
   RECALL_ATTRS = ('recalledComponents', 'recalledAddons')
   RECALL_XML_INFO = [('recalledComponentList/componentSpec', 'component',
                      'recalledComponents'),
                     ('recalledAddonList/addonSpec', 'addon', 'recalledAddons')]

   def __init__(self, id, **kwargs):
      """Class constructor.
            Parameters:
               * id     - A string giving the unique ID of the Notification.
               * kwargs - A list of keyword arguments used to pop the
                          object's attributes.
            Returns: A new Notification instance.
      """
      if not id:
         raise Errors.BulletinFormatError("id parameter cannot be None")
      self._id = id
      tz = XmlUtils.UtcInfo()
      now = datetime.datetime.now(tz=tz)

      self.vendor            = kwargs.pop('vendor', '')
      self.summary           = kwargs.pop('summary', '')
      self.severity          = kwargs.pop('severity', '')
      self.category          = kwargs.pop('category', '')
      self.releaseType       = kwargs.pop('releaseType', '')
      self.description       = kwargs.pop('description', '')
      self.kbUrl             = kwargs.pop('kbUrl', '')
      self.contact           = kwargs.pop('contact', '')
      self.releaseDate       = kwargs.pop('releaseDate', now)

      self.platforms         = list()
      for p in kwargs.pop('platforms', list()):
         if isinstance(p, SoftwarePlatform):
            self.platforms.append(p)

      # the following two attributes can be empty if there is no recalled
      # component/addon list info in notification xml
      self.recalledComponents = kwargs.pop(
                                'recalledComponents', list())

      self.recalledAddons = kwargs.pop('recalledAddons', list())

      if kwargs:
         badkws = ', '.join("'%s'" % kw for kw in kwargs)
         raise TypeError("Unrecognized keyword argument(s): %s." % badkws)

   __repr__ = lambda self: self.__str__()
   __hash__ = lambda self: hash(self._id)

   id = property(lambda self: self._id)

   def __eq__(self, other):
      """Compare two notifications. Two notifications are equal when
         attributes in ATTRS_TO_VERIFY match. Specifically, we do not
         care about the order of recalled components inside the
         recalledComponentList.
      """
      for attr in self.ATTRS_TO_VERIFY:
         old = getattr(self, attr)
         new = getattr(other, attr)
         if attr in self.RECALL_ATTRS:
            old, new = set(old), set(new)
         if old != new:
            return False
      return True

   def __str__(self):
      return etree.tostring(self.ToXml()).decode()

   def __add__(self, other):
      """Merge this notification with another to form a new object
         consisting of the attributes from the newer notification.

            Parameters:
               * other - another Notification instance.
            Returns: A new Notification instance.
            Raises:
               * ValueError          - If attempting to add notification
                                       with different attributes in
                                       ATTRS_TO_VERIFY, or attempting to
                                       add an object that is not a
                                       Notification object.
      """
      if not isinstance(other, self.__class__):
         msg = "Operation not supported for type %s." % other.__class__.__name__
         raise ValueError(msg)

      # Two notifications are merged only when ATTRS_TO_VERIFY are equal.
      if self != other:
         raise ValueError("Cannot merge unequal notifications.")

      kwargs = {attr : getattr(self, attr) for attr in self.ATTRS_TO_VERIFY}
      ret = self.__class__(**kwargs)

      return ret

   @classmethod
   def _XmlToKwargs(cls, xml):
      kwargs = {}
      tagval = (xml.findtext("kbUrl") or "").strip()
      if tagval != "":
         kwargs['kbUrl'] = tagval

      for tag in ('id', 'vendor', 'summary', 'severity', 'description',
                  'contact', 'category', 'releaseType'):
         kwargs[tag] = (xml.findtext(tag) or "").strip()

      rd = (xml.findtext("releaseDate") or "").strip()
      if rd:
         try:
            kwargs['releaseDate'] = XmlUtils.ParseXsdDateTime(rd)
         except Exception as e:
            bullid = kwargs.pop('id', 'unkown')
            msg = 'Notification %s has invalid releaseDate: %s' % (bullid, e)
            raise Errors.BulletinFormatError(msg)
      else:
         #Set release date if it is not in the input
         now = datetime.datetime.now(tz=XmlUtils.UtcInfo())
         kwargs['releaseDate'] = now

      kwargs['platforms'] = list()
      for platform in xml.findall('platforms/softwarePlatform'):
         kwargs['platforms'].append(SoftwarePlatform.FromXml(platform))

      if kwargs['releaseType'] == cls.RELEASE_NOTIFICATION:
         if kwargs['category'] == cls.NOTIFICATION_RECALL:
            for xmlPath, unitName, attrName in cls.RECALL_XML_INFO:
               recalledReleaseUnits = list()
               for releaseUnits in xml.findall(xmlPath):
                  releaseUnitInfo = (releaseUnits.get('name'),
                                     releaseUnits.get('version'))
                  if releaseUnitInfo:
                     tagName = xmlPath[xmlPath.find('/') + 1:]
                     if not releaseUnitInfo[0]:
                        msg = '%s has empty %s name' % (
                              tagName, unitName)
                        raise Errors.BulletinFormatError(msg)
                     if not releaseUnitInfo[1]:
                        msg = '%s has empty %s version' % (
                              tagName, unitName)
                        raise Errors.BulletinFormatError(msg)
                     recalledReleaseUnits.append(releaseUnitInfo)
               if recalledReleaseUnits:
                  kwargs[attrName] = recalledReleaseUnits
         else:
            raise NotImplementedError("category is not %s" %
                                       cls.NOTIFICATION_RECALL)
      else:
         raise TypeError("releaseType is not %s" % cls.RELEASE_NOTIFICATION)

      return kwargs

   @classmethod
   def FromXml(cls, xml, **kwargs):
      """Creates a Notification instance from XML.

            Parameters:
               * xml    - Must be either an instance of ElementTree, or a
                          string of XML-formatted data.
               * kwargs - Initialize constructor arguments from keywords.
                          Primarily useful to provide default or required
                          arguments when XML data is from a template.
            Returns: A new Notification object.
            Exceptions:
               * BulletinFormatError - If the given xml is not a valid XML, or
                                       does not contain required elements or
                                       attributes.
      """
      if etree.iselement(xml):
         node = xml
      else:
         try:
            node = XmlUtils.ParseXMLFromString(xml)
         except Exception as e:
            msg = "Error parsing XML data: %s." % e
            raise Errors.BulletinFormatError(msg)

      kwargs.update(cls._XmlToKwargs(node))
      bullid = kwargs.pop('id', '')

      return cls(bullid, **kwargs)

   def ToXml(self):
      """Serializes the object to XML, returns an ElementTree.Element object.
      """
      root = etree.Element('bulletin')
      for tag in ('id', 'vendor', 'summary', 'severity', 'category',
                  'releaseType', 'description', 'kbUrl', 'contact'):
         elem = etree.SubElement(root, tag).text = \
                str(getattr(self, tag))

      etree.SubElement(root, 'releaseDate').text = \
                      self.releaseDate.isoformat()

      platforms = etree.SubElement(root, 'platforms')
      for p in self.platforms:
         platforms.append(p.ToXml())

      if self.releaseType == self.RELEASE_NOTIFICATION:
         if self.category == self.NOTIFICATION_RECALL:
            for xmlPath, unitName, attrName in self.RECALL_XML_INFO:
               if not getattr(self, attrName):
                  continue
               listName = xmlPath[:xmlPath.find('/')]
               recalledReleaseUnitColl = etree.SubElement(root, listName)
               for recalledReleaseUnitInfo in getattr(self, attrName):
                  if recalledReleaseUnitInfo:
                     tagName = xmlPath[xmlPath.find('/') + 1:]
                     if not recalledReleaseUnitInfo[0]:
                        msg = '%s has empty %s name' % (
                              tagName, unitName)
                        raise Errors.BulletinFormatError(msg)
                     if not recalledReleaseUnitInfo[1]:
                        msg = '%s has empty %s version' % (
                              tagName, unitName)
                        raise Errors.BulletinFormatError(msg)
                     recalledReleaseUnit = etree.Element(tagName,
                        attrib={
                           'name': recalledReleaseUnitInfo[0],
                           'version': recalledReleaseUnitInfo[1],
                        })
                     recalledReleaseUnitColl.append(recalledReleaseUnit)
         else:
            raise NotImplementedError("category is not %s" %
                                       self.NOTIFICATION_RECALL)
      else:
         raise TypeError("releaseType is not %s" % self.RELEASE_NOTIFICATION)

      return root

   def Validate(self):
      if self.severity not in self.SEVERITY_TYPES:
         msg = 'Unrecognized value "%s", severity must be one of %s.' % (
               self.severity, self.SEVERITY_TYPES)
         raise Errors.BulletinValidationError(msg)

      if self.category != self.NOTIFICATION_RECALL:
         msg = 'Unrecognized value "%s", category must be %s.' % (
               self.category, self.NOTIFICATION_RECALL)
         raise Errors.BulletinValidationError(msg)

      if self.releaseType != self.RELEASE_NOTIFICATION:
         msg = 'Unrecognized value "%s", releaseType must be %s.' % (
               self.releaseType, self.RELEASE_NOTIFICATION)
         raise Errors.BulletinValidationError(msg)
      # TODO: schema check


class NotificationCollection(dict):
   """This class represents a collection of Notification objects and provides
      methods and properties for modifying the collection.
   """
   def __iadd__(self, other):
      """Merge this collection with another collection.
            Parameters:
               * other - another NotificationCollection instance.
      """
      for notif in other.values():
         self.AddNotification(notif)
      return self

   def __add__(self, other):
      """Merge this collection with another to form a new collection consisting
         of the union of Notifications from both.
            Parameters:
               * other - another NotificationCollection instance.
            Returns: A new NotificationCollection instance.
      """
      new = NotificationCollection(self)
      new.update(self)
      for notif in other.values():
         new.AddNotification(notif)
      return new

   def AddNotification(self, notification):
      """Add a Notification instance to the collection.

      Parameters:
         * notification - A Notification instance.
      """
      notifid = notification.id
      if notifid in self:
         self[notifid] += notification
      else:
         self[notifid] = notification

   def AddNotificationFromXml(self, xml):
      """Add a Notification instance based on the xml data.

      Parameters:
         * xml - An instance of ElementTree or an XML string
      Exceptions:
         * BulletinFormatError
      """
      notif = Notification.FromXml(xml)
      self.AddNotification(notif)

   def AddNotificationsFromXml(self, xml):
      """Add multiple notifications from an XML file.
            Parameters:
               * xml = An instance of ElementTree or an XML string.
            Exceptions:
               * BulletinFormatError
      """
      if etree.iselement(xml):
         node = xml
      else:
         try:
            node = XmlUtils.ParseXMLFromString(xml)
         except Exception as e:
            msg = "Error parsing XML data: %s." % e
            raise Errors.BulletinFormatError(msg)

      for notif in node.findall("bulletin"):
         self.AddNotificationFromXml(notif)

   def FromDirectory(self, path, ignoreInvalidFiles=False):
      """Populate this NotificationCollection instance from a directory of
         Bulletin xml files. This method may replace existing Notifications
         in the collection.

            Parameters:
               * path               - A string specifying a directory name.
               * ignoreinvalidfiles - If True, causes the method to silently
                                      ignore BulletinFormatError exceptions.
                                      Useful if a directory may contain both
                                      Bulletin xml content and other content.
            Returns: None
            Exceptions:
               * BulletinIOError     - The specified directory does not exist or
                                       cannot be read, or one or more files
                                       could not be read.
               * BulletinFormatError - One or more files were not a valid
                                       Bulletin xml.
      """
      if not os.path.isdir(path):
         msg = 'NotificationCollection path %s is not a directory.' % (path)
         raise Errors.BulletinIOError(msg)

      for root, _, files in os.walk(path, topdown=True):
         for name in files:
            filepath = os.path.join(root, name)
            try:
               with open(filepath) as f:
                  c = f.read()
                  self.AddNotificationFromXml(c)
            except Errors.BulletinFormatError as e:
               if not ignoreInvalidFiles:
                  msg = 'Failed to add file %s to NotificationCollection: ' \
                        '%s' % (filepath, e)
                  raise Errors.BulletinFormatError(msg)
            except EnvironmentError as e:
               msg = 'Failed to add Notification from file %s: %s' % (
                     filepath, e)
               raise Errors.BulletinIOError(msg)

   def ToDirectory(self, path, namingfunc=None):
      """Write Bulletin XML in the NotificationCollection to a directory. If the
         directory exists, the content of the directory will be clobbered.

            Parameters:
               * path       - A string specifying a directory name.
               * namingfunc - A function that names an individual XML file, by
                              default getDefaultNotificationFileName().
            Return: None
            Exceptions:
               * BulletinIOError - The specified directory is not a directory or
                                   cannot create an empty directory
      """
      try:
         if os.path.isdir(path):
             shutil.rmtree(path)
         os.makedirs(path)
      except EnvironmentError as e:
         msg = 'Could not create dir %s for NotificationCollection: %s' % (
               path, e)
         raise Errors.BulletinIOError(msg)

      if not os.path.isdir(path):
         msg = 'Failed to write NotificationCollection, %s is not a \
               directory.' % path
         raise Errors.BulletinIOError(msg)

      if namingfunc is None:
         namingfunc = getDefaultNotificationFileName

      for notif in self.values():
         filepath = os.path.join(path, namingfunc(notif))
         try:
            xml = notif.ToXml()
            with open(filepath, 'wb') as f:
               f.write(etree.tostring(xml))
         except EnvironmentError as e:
            msg = 'Failed to write Notification xml to %s: %s' % (
                  filepath, e)
            raise Errors.BulletinIOError(msg)

