# Copyright 2023 VMware, Inc.
# All rights reserved. -- VMware Confidential

"""Library for Quick Patch scripts to form output.

   WARNING: This module requires Python >= 3.7.
   Prior Python versions will fail to import the module.
"""

from dataclasses import dataclass, field
from datetime import datetime
from enum import auto, IntEnum
import json
import logging
from typing import List, Optional, Sequence, Type

from .Utils import str2Time, time2Str

logger = logging.getLogger(__name__)

def _checkExtraDictKeys(d: dict, expectedKeys: set) -> None:
   """Checks and logs extra keys in a dictionary.
   """
   extraKeys = sorted(set(d.keys()) - expectedKeys)
   if extraKeys:
      # There are extra keys. This might be a result of a newer script
      # return format, log a warning.
      logger.warning("Ignoring extra members %r found in the dictionary",
                     extraKeys)


class RemediationActionStatus(IntEnum):
   """An enumeration of remediation action status
   """
   COMPLIANT = auto()
   NON_COMPLIANT = auto()
   INCOMPATIBLE = auto()
   UNKNOWN = auto()


@dataclass(frozen=True)
class LocalizableMessage:
   """Class that represents a localizable message.
   """
   msgId: str
   args: List[str]
   defaultMessage: str

   def __post_init__(self) -> None:
      """Post-init processing.
      """
      if not self.msgId:
         raise ValueError('msgId must not be empty')
      if not self.defaultMessage:
         raise ValueError('defaultMessage must not be empty')

   def toDict(self) -> dict:
      """Converts this instance to a dictionary.
      """
      return dict(vars(self))

   @classmethod
   def fromDict(cls: Type['LocalizableMessage'],
                m: dict) -> 'LocalizableMessage':
      """Converts a dictionary to a class instance.
      """
      _checkExtraDictKeys(m, {'msgId', 'args', 'defaultMessage'})
      try:
         return cls(m['msgId'], m['args'], m['defaultMessage'])
      except KeyError as e:
         raise ValueError("Member not found: {}".format(e)) from e


# Notification and Notifications class should not be mixed with the version
# in esximage.ImageManager.Utils. The ones defined in Utils are bare-bone
# representation for VAPI without validations.

@dataclass(frozen=True)
class Notification:
   """Class that represents a notification.
   """
   notificationId: str
   message: LocalizableMessage
   resolution: Optional[LocalizableMessage] = field(default=None)
   time: Optional[datetime] = field(default_factory=datetime.utcnow)

   def __post_init__(self) -> None:
      """Post-init processing.
      """
      if not self.notificationId:
         raise ValueError('id must not be empty')
      # Truncate microsecond to be at an exact millisecond, see
      # Utils.time2Str().
      # object.__setattr__() must be used as the class is frozen.
      newTime = self.time.replace(microsecond=
         self.time.microsecond - self.time.microsecond % 1000)
      object.__setattr__(self, 'time', newTime)

   def toDict(self) -> dict:
      """Converts this instance to a dictionary.
      """
      return {
         'id': self.notificationId,
         'message': self.message.toDict(),
         'resolution': self.resolution.toDict() if self.resolution else None,
         'time': time2Str(self.time),
      }

   @classmethod
   def fromDict(cls: Type['Notification'], n: dict) -> 'Notification':
      """Converts a dictionary to a class instance.
      """
      _checkExtraDictKeys(n, {'id', 'message', 'resolution', 'time'})
      try:
         message = LocalizableMessage.fromDict(n['message'])
         resolution = n['resolution']
         if resolution:
            resolution = LocalizableMessage.fromDict(resolution)
         try:
            timeStr = n['time']
            time = str2Time(timeStr)
         except ValueError as e:
            raise ValueError("Time '%s' is malformed: %s" % (timeStr, e)) from e
         return cls(n['id'], message, resolution, time)
      except KeyError as e:
         raise ValueError("Member not found: {}".format(e)) from e

class Notifications:
   """Class that represents lists of info, warning and error notifications.
   """
   INFO = 'info'
   WARNINGS = 'warnings'
   ERRORS = 'errors'
   NOTIFICATION_TYPES = (INFO, WARNINGS, ERRORS)

   def __init__(self, info: Sequence[Notification]=(),
                warnings: Sequence[Notification]=(),
                errors: Sequence[Notification]=()) -> None:
      """Constructor.
      """
      self._notificationDict = {
         self.INFO: [],
         self.WARNINGS: [],
         self.ERRORS: [],
      }
      for nType, nList in zip(self.NOTIFICATION_TYPES,
                              (info, warnings, errors)):
         for n in nList:
            self._addNotification(nType, n)

   def __eq__(self, others: object) -> bool:
      """The == operator.
      """
      if not isinstance(others, Notifications):
         return False
      return self._notificationDict == others._notificationDict

   def _getNotifications(self, nList: str) -> List[Notification]:
      """Returns notifications from the specified list.
      """
      return self._notificationDict[nList]

   def getInfoNotifications(self) -> List[Notification]:
      """Returns info notifications.
      """
      return self._getNotifications(self.INFO)

   def getWarningNotifications(self) -> List[Notification]:
      """Returns warning notifications.
      """
      return self._getNotifications(self.WARNINGS)

   def getErrorNotifications(self) -> List[Notification]:
      """Returns error notifications.
      """
      return self._getNotifications(self.ERRORS)

   def _addNotification(self, nList: str, n: Notification) -> None:
      """Adds a notification to the specified list.
      """
      self._notificationDict[nList].append(n)

   def addInfoNotification(self, n: Notification) -> None:
      """Adds a notification to the info list.
      """
      self._addNotification(self.INFO, n)

   def addWarningNotification(self, n: Notification) -> None:
      """Adds a notification to the warning list.
      """
      self._addNotification(self.WARNINGS, n)

   def addErrorNotification(self, n: Notification) -> None:
      """Adds a notification to the error list.
      """
      self._addNotification(self.ERRORS, n)

   def toDict(self) -> dict:
      """Converts this instance to a dictionary.
      """
      notifications = {
         self.INFO: [],
         self.WARNINGS: [],
         self.ERRORS: [],
      }
      for nType, nList in self._notificationDict.items():
         for n in nList:
            notifications[nType].append(n.toDict())
      return notifications

   @classmethod
   def fromDict(cls: Type['Notifications'],
                notifications: dict) -> 'Notifications':
      """Converts a dictionary to a class instance.
      """
      _checkExtraDictKeys(notifications, set(cls.NOTIFICATION_TYPES))
      try:
         nMap = {
            nType: (
               Notification.fromDict(nDict)
                  for nDict in notifications[nType])
            for nType in cls.NOTIFICATION_TYPES
         }
      except KeyError as e:
         raise ValueError("Member not found: {}".format(e)) from e
      except ValueError as e:
         raise ValueError("A Notification is malformed: %s" % e) from e

      return cls(**nMap)


@dataclass(frozen=True)
class ApplyScriptOutput:
   """Class that represents apply script output.
   """
   notifications: Notifications

   def __post_init__(self) -> None:
      """Validates input after init.
      """
      if not isinstance(self.notifications, Notifications):
         raise TypeError("notifications must be an instance of Notifications")

   def toDict(self) -> dict:
      """Converts this instance to a dictionary.
      """
      return {
         'notifications': self.notifications.toDict(),
      }

   def toJSON(self) -> str:
      """Converts this instance to a JSON string.
      """
      return json.dumps(self.toDict())

   @classmethod
   def fromDict(cls: Type['ApplyScriptOutput'],
                scriptDict: dict) -> 'ApplyScriptOutput':
      """Converts a dictionary to a class instance.
      """
      _checkExtraDictKeys(scriptDict, {'notifications'})
      try:
         return cls(Notifications.fromDict(scriptDict['notifications']))
      except KeyError as e:
         raise ValueError("Member not found: {}".format(e)) from e

   @classmethod
   def fromJSON(cls: Type['ApplyScriptOutput'], s: str) -> 'ApplyScriptOutput':
      """Converts a serialized JSON string to a class instance.
      """
      return cls.fromDict(json.loads(s))


class RemediationActions:
   """Class that represents remediation actions reported by a scan script.
   """
   def __init__(self, actions: Sequence[LocalizableMessage]=()) -> None:
      """Constructor.
      """
      self._actionList = []

      if actions:
         for action in actions:
            self.addRemediationAction(action)

   def __eq__(self, others: object) -> bool:
      """The == operator.
      """
      if not isinstance(others, RemediationActions):
         return False
      return self._actionList == others._actionList

   def addRemediationAction(self, action: LocalizableMessage) -> None:
      """Adds a remediation action.
      """
      self._actionList.append(action)

   def toList(self) -> list:
      """Converts this instance to a list.
      """
      return [m.toDict() for m in self._actionList]

   @property
   def actionList(self) -> List[LocalizableMessage]:
      """Returns the action list.
      """
      return self._actionList

   @classmethod
   def fromList(cls: Type['RemediationActions'],
                actions: list) -> 'RemediationActions':
      """Converts a list to a class instance.
      """
      try:
         actions = [LocalizableMessage.fromDict(x) for x in actions]
      except ValueError as e:
         raise ValueError('A LocalizableMessage is malformed: %s' % e) from e
      return cls(actions)


@dataclass(frozen=True)
class MemoryReservations:
   """Class that represents memory reservation requirements.
   """
   temporaryReservation: int
   permanentReservationIncrease: int

   def __post_init__(self) -> None:
      """Validates input after init.
      """
      if (not isinstance(self.temporaryReservation, int) or
          self.temporaryReservation < 0):
         raise ValueError("temporaryReservation must be a non-negative integer")
      if (not isinstance(self.permanentReservationIncrease, int) or
          self.permanentReservationIncrease < 0):
         raise ValueError("permanentReservationIncrease must be a non-negative "
                          "integer")

   def toDict(self) -> dict:
      """Converts this instance to a dictionary.
      """
      return {
         'temporaryReservation': self.temporaryReservation,
         'permanentReservationIncrease': self.permanentReservationIncrease,
      }

   @classmethod
   def fromDict(cls: Type['MemoryReservations'],
                memResDict: dict) -> 'MemoryReservations':
      """Converts a dictionary to a class instance.
      """
      _checkExtraDictKeys(memResDict,
         {'temporaryReservation', 'permanentReservationIncrease'})
      try:
         return cls(memResDict['temporaryReservation'],
                    memResDict['permanentReservationIncrease'])
      except KeyError as e:
         raise ValueError("Member not found: {}".format(e)) from e


@dataclass(frozen=True)
class ScanScriptOutput(ApplyScriptOutput):
   """Class that represents scan script output.
   """
   remediationActionStatus: RemediationActionStatus
   remediationActions: RemediationActions
   memoryReservations: Optional[MemoryReservations] = None
   maintenanceMode: Optional[str] = None
   upgradeActions: Optional[List[str]] = None

   def __post_init__(self) -> None:
      """Validates input after init.
      """
      super().__post_init__()

      if not isinstance(self.remediationActionStatus, RemediationActionStatus):
         raise TypeError("remediationActionStatus must be an instance of "
                         "RemediationActionStatus")
      if not isinstance(self.remediationActions, RemediationActions):
         raise TypeError("remediationActions must be an instance of "
                         "RemediationActions")
      if (self.memoryReservations is not None and
          not isinstance(self.memoryReservations, MemoryReservations)):
         raise TypeError("memoryReservations must be either None or an "
                         "instance of MemoryReservations")
      if (self.maintenanceMode is not None and
          (not isinstance(self.maintenanceMode, str) or
           not self.maintenanceMode)):
         raise ValueError("maintenanceMode must be None or a non-empty "
                          "string")
      if self.upgradeActions is not None:
         if not isinstance(self.upgradeActions, list):
            raise TypeError("upgradeActions must be a list of non-empty "
                            "strings")
         for action in self.upgradeActions:
            if not isinstance(action, str) or not action:
               raise ValueError("Each member in upgradeActions must be a "
                                "non-empty string")

   def toDict(self) -> dict:
      """Converts this instance to a dictionary.
      """
      output = super().toDict()
      output['remediationActionStatus'] = self.remediationActionStatus.name
      output['remediationActions'] = self.remediationActions.toList()

      # Optional fields are written out only when they were filled in.
      if self.memoryReservations is not None:
         output['memoryReservations'] = self.memoryReservations.toDict()
      if self.maintenanceMode is not None:
         output['maintenanceMode'] = self.maintenanceMode
      if self.upgradeActions is not None:
         output['upgradeActions'] = self.upgradeActions

      return output

   @classmethod
   def fromDict(cls: Type['ScanScriptOutput'],
                scriptDict: dict) -> 'ScanScriptOutput':
      """Converts a dictionary to a class instance.
      """
      _checkExtraDictKeys(scriptDict,
         {'notifications', 'remediationActionStatus', 'remediationActions',
          'memoryReservations', 'maintenanceMode', 'upgradeActions'})
      try:
         notifications = scriptDict['notifications']
         status = scriptDict['remediationActionStatus']
         actions = scriptDict['remediationActions']
      except KeyError as e:
         raise ValueError("Member not found: {}".format(e)) from e

      try:
         statusObj = getattr(RemediationActionStatus, status)
      except AttributeError as e:
         raise ValueError("Unexpected remediationActionStatus value '%s'"
                          % status) from e

      # Optional fields.
      memoryReservations = None
      if 'memoryReservations' in scriptDict:
         memoryReservations = MemoryReservations.fromDict(
            scriptDict['memoryReservations'])
      maintenanceMode = scriptDict.get('maintenanceMode')
      upgradeActions = scriptDict.get('upgradeActions')

      return cls(Notifications.fromDict(notifications),
                 statusObj,
                 RemediationActions.fromList(actions),
                 memoryReservations=memoryReservations,
                 maintenanceMode=maintenanceMode,
                 upgradeActions=upgradeActions)
