########################################################################
# File: Request.py
# Date: 2012/07/16 13:43:45
########################################################################
"""
:mod: Request

.. module: Request
  :synopsis: request implementation

request implementation
"""
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
# Disable invalid names warning
# pylint: disable=invalid-name
__RCSID__ = "$Id$"


# # imports
import datetime
import json
import six
# # from DIRAC
from DIRAC import S_OK, S_ERROR
from DIRAC.Core.Security.ProxyInfo import getProxyInfo
from DIRAC.RequestManagementSystem.Client.Operation import Operation
from DIRAC.RequestManagementSystem.private.JSONUtils import RMSEncoder
from DIRAC.DataManagementSystem.Utilities.DMSHelpers import DMSHelpers


########################################################################
class Request(object):
  """
  :param int RequestID: requestID
  :param str Name: request' name
  :param str OwnerDN: request's owner DN
  :param str OwnerGroup: request owner group
  :param str Setup: DIRAC setup
  :param str SourceComponent: whatever
  :param int JobID: jobID
  :param datetime.datetime CreationTime: UTC datetime
  :param datetime.datetime SubmissionTime: UTC datetime
  :param datetime.datetime LastUpdate: UTC datetime
  :param datetime.datetime NotBefore: UTC datetime
  :param str Status: request's status
  :param TypedList.TypedList operations: list of operations

  It is managed by SQLAlchemy, so the RequestID should never be set by hand (except when constructed from
  JSON of course...)
  """

  ALL_STATES = ("Waiting", "Failed", "Done", "Scheduled", "Assigned", "Canceled")

  FINAL_STATES = ("Done", "Failed", "Canceled")

  _datetimeFormat = '%Y-%m-%d %H:%M:%S'

  def __init__(self, fromDict=None):
    """c'tor

    :param self: self reference
    :param fromDict: if false, new request. Can be json string that represents the object, or the dictionary directly
    """
    self.__waiting = None

    now = datetime.datetime.utcnow().replace(microsecond=0)

    self._CreationTime = now
    self._SubmitTime = now
    self._LastUpdate = now
    # the time before which the request should not be executed
    # If None, no delay
    self._NotBefore = now
    self._Status = "Done"
    self.JobID = 0
    self.Error = None
    self.DIRACSetup = None
    self.OwnerDN = None
    self.RequestName = None
    self.OwnerGroup = None
    self._SourceComponent = None

    self.dmsHelper = DMSHelpers()

    proxyInfo = getProxyInfo()
    if proxyInfo["OK"]:
      proxyInfo = proxyInfo["Value"]
      if proxyInfo["validGroup"] and proxyInfo["validDN"]:
        self.OwnerDN = proxyInfo["identity"]
        self.OwnerGroup = proxyInfo["group"]

    self.__operations__ = []

    if isinstance(fromDict, six.string_types):
      fromDict = json.loads(fromDict)
    elif not isinstance(fromDict, dict):
      fromDict = {}

    if "Operations" in fromDict:
      for opDict in fromDict.get("Operations", []):
        self += Operation(opDict)

      del fromDict["Operations"]

    for key, value in fromDict.items():
      # The JSON module forces the use of UTF-8, which is not properly
      # taken into account in DIRAC.
      # One would need to replace all the '== str' with 'in six.string_types'
      # This is converting `unicode` to `str` and doesn't make sense in Python 3
      if six.PY2 and isinstance(value, six.string_types):
        value = value.encode()

      if value:
        setattr(self, key, value)

    self._notify()

  def _notify(self):
    """ simple state machine for sub request statuses """
    # # update operations statuses
    self.__waiting = None

    # Update the Order in Operation, and set the parent
    for i in range(len(self.__operations__)):
      self.__operations__[i].Order = i
      self.__operations__[i]._parent = self

    rStatus = "Waiting"
    opStatusList = [(op.Status, op) for op in self]

    while opStatusList:
      # # Scan all status in order!
      opStatus, op = opStatusList.pop(0)

      # # Failed -> Failed
      if opStatus == "Failed" and self.__waiting is None:
        rStatus = "Failed"
        break

      # Scheduled -> Scheduled
      if opStatus == "Scheduled":
        if self.__waiting is None:
          self.__waiting = op
          rStatus = "Scheduled"
      # # First operation Queued becomes Waiting if no Waiting/Scheduled before
      elif opStatus == "Queued":
        if self.__waiting is None:
          self.__waiting = op
          op._setWaiting(self)
          rStatus = "Waiting"
      # # First operation Waiting is next to execute, others are queued
      elif opStatus == "Waiting":
        rStatus = "Waiting"
        if self.__waiting is None:
          self.__waiting = op
        else:
          op._setQueued(self)
      # # All operations Done -> Done
      elif opStatus == "Done" and self.__waiting is None:
        rStatus = "Done"
        self.Error = ''
    self.Status = rStatus

  def getWaiting(self):
    """ get waiting operation if any """
    # # update states
    self._notify()
    return S_OK(self.__waiting)

  # # Operation arithmetics
  def __contains__(self, operation):
    """ in operator

    :param self: self reference
    :param Operation.Operation subRequest: a subRequest
    """
    return bool(operation in self.__operations__)

  def __iadd__(self, operation):
    """ += operator for subRequest

    :param self: self reference
    :param Operation.Operation operation: sub-request to add
    """
    if operation not in self:
      self.__operations__.append(operation)
      operation._parent = self
      self._notify()
    return self

  def insertBefore(self, newOperation, existingOperation):
    """ insert :newOperation: just before :existingOperation:

    :param self: self reference
    :param Operation.Operation newOperation: Operation to be inserted
    :param Operation.Operation existingOperation: previous Operation sibling
    """
    if existingOperation not in self:
      return S_ERROR("%s is not in" % existingOperation)
    if newOperation in self:
      return S_ERROR("%s is already in" % newOperation)
    self.__operations__.insert(self.__operations__.index(existingOperation), newOperation)
    self._notify()
    return S_OK()

  def insertAfter(self, newOperation, existingOperation):
    """ insert :newOperation: just after :existingOperation:

    :param self: self reference
    :param Operation.Operation newOperation: Operation to be inserted
    :param Operation.Operation existingOperation: next Operation sibling
    """
    if existingOperation not in self:
      return S_ERROR("%s is not in" % existingOperation)
    if newOperation in self:
      return S_ERROR("%s is already in" % newOperation)
    self.__operations__.insert(self.__operations__.index(existingOperation) + 1, newOperation)
    self._notify()
    return S_OK()

  def addOperation(self, operation):
    """ add :operation: to list of Operations

    :param self: self reference
    :param Operation.Operation operation: Operation to be inserted
    """
    if operation in self:
      return S_ERROR("This operation is already in!!!")
    self += operation
    return S_OK()

  def isEmpty(self):
    """ Evaluate if the request is empty
    """
    return len(self.__operations__) == 0

  def __iter__(self):
    """ iterator for sub-request """
    return self.__operations__.__iter__()

  def __getitem__(self, i):
    """ [] op for sub requests """
    return self.__operations__.__getitem__(i)

  def __setitem__(self, i, value):
    """ self[i] = val """

    self.__operations__.__setitem__(i, value)
    self._notify()

  def __delitem__(self, i):
    """ del self[i]"""

    self.__operations__.__delitem__(i)
    self._notify()

  def indexOf(self, subReq):
    """ return index of subReq (execution order) """
    return self.__operations__.index(subReq) if subReq in self else -1

  def __bool__(self):
    """ for comparisons
    """
    return True

  # For Python 2 compatibility
  __nonzero__ = __bool__

  def __len__(self):
    """ nb of subRequests """
    return len(self.__operations__)

  def __str__(self):
    """ str operator """
    return self.toJSON()['Value']

  def subStatusList(self):
    """ list of statuses for all operations """
    return [subReq.Status for subReq in self]

  @property
  def SourceComponent(self):
    return self._SourceComponent

  @SourceComponent.setter
  def SourceComponent(self, value):
    if isinstance(value, six.text_type):
      value = value.encode()
    if not isinstance(value, bytes):
      raise TypeError("SourceComponent should be bytes!")
    self._SourceComponent = value

  @property
  def CreationTime(self):
    """ creation time getter """
    return self._CreationTime

  @CreationTime.setter
  def CreationTime(self, value=None):
    """ creation time setter """
    if not isinstance(value, (datetime.datetime,) + six.string_types):
      raise TypeError("CreationTime should be a datetime.datetime!")
    if isinstance(value, six.string_types):
      value = datetime.datetime.strptime(value.split(".")[0], self._datetimeFormat)
    self._CreationTime = value

  @property
  def SubmitTime(self):
    """ request's submission time getter """
    return self._SubmitTime

  @SubmitTime.setter
  def SubmitTime(self, value=None):
    """ submission time setter """
    if not isinstance(value, (datetime.datetime,) + six.string_types):
      raise TypeError("SubmitTime should be a datetime.datetime!")
    if isinstance(value, six.string_types):
      value = datetime.datetime.strptime(value.split(".")[0], self._datetimeFormat)
    self._SubmitTime = value

  @property
  def NotBefore(self):
    """ Getter for NotBefore time"""
    return self._NotBefore

  @NotBefore.setter
  def NotBefore(self, value=None):
    """ Setter for the NotBefore time """
    if not isinstance(value, (type(None), datetime.datetime) + six.string_types):
      raise TypeError("NotBefore should be a datetime.datetime!")
    if isinstance(value, six.string_types):
      value = datetime.datetime.strptime(value.split(".")[0], self._datetimeFormat)
    self._NotBefore = value

  def delayNextExecution(self, deltaTime):
    """This helper sets the NotBefore attribute in deltaTime minutes
       in the future

       :param deltaTime: time in minutes before next execution
    """
    now = datetime.datetime.utcnow().replace(microsecond=0)
    extraDelay = datetime.timedelta(minutes=deltaTime)
    self._NotBefore = now + extraDelay

    return S_OK()

  @property
  def LastUpdate(self):
    """ last update getter """
    return self._LastUpdate

  @LastUpdate.setter
  def LastUpdate(self, value=None):
    """ last update setter """
    if not isinstance(value, (datetime.datetime,) + six.string_types):
      raise TypeError("LastUpdate should be a datetime.datetime!")
    if isinstance(value, six.string_types):
      value = datetime.datetime.strptime(value.split(".")[0], self._datetimeFormat)
    self._LastUpdate = value

  @property
  def Status(self):
    """ status getter """
    self._notify()
    return self._Status

  @Status.setter
  def Status(self, value):
    """ status setter """
    if value not in Request.ALL_STATES:
      raise ValueError("Unknown status: %s" % str(value))

    # If the status moved to Failed or Done, update the lastUpdate time
    if value in ('Done', 'Failed'):
      if value != self._Status:
        self.LastUpdate = datetime.datetime.utcnow().replace(microsecond=0)

    if value == 'Done':
      self.Error = ''
    self._Status = value

  @property
  def Order(self):
    """ ro execution order getter """
    self._notify()
    opStatuses = [op.Status for op in self.__operations__]
    return opStatuses.index("Waiting") if "Waiting" in opStatuses else len(opStatuses)

  def toJSON(self):
    """ Returns the JSON formated string that describes the request """

    jsonStr = json.dumps(self, cls=RMSEncoder)
    return S_OK(jsonStr)

  def _getJSONData(self):
    """ Returns the data that have to be serialized by JSON """

    attrNames = ['RequestID', "RequestName", "OwnerDN", "OwnerGroup",
                 "Status", "Error", "DIRACSetup", "SourceComponent",
                 "JobID", "CreationTime", "SubmitTime", "LastUpdate", "NotBefore"]
    jsonData = {}

    for attrName in attrNames:

      # RequestID might not be set since it is managed by SQLAlchemy
      if not hasattr(self, attrName):
        continue

      value = getattr(self, attrName)

      if isinstance(value, datetime.datetime):
        # We convert date time to a string
        jsonData[attrName] = value.strftime(self._datetimeFormat)  # pylint: disable=no-member
      else:
        jsonData[attrName] = value

    jsonData['Operations'] = self.__operations__

    return jsonData

  def getDigest(self):
    """ return digest for request """
    digest = ['Name:' + self.RequestName]
    for op in self:
      opDigest = [str(item) for item in (op.Type, op.Type, op.Status, op.Order)]
      if op.TargetSE:
        opDigest.append(op.TargetSE)
      if op.Catalog:
        opDigest.append(op.Catalog)
      if len(op):
        opFile = op[0]
        extraFilesStr = "...+<%d files>" % (len(op) - 1) if (len(op) > 1) else ''
        opDigest.append(opFile.LFN + extraFilesStr)
      digest.append(":".join(opDigest))
    return S_OK("\n".join(digest))

  def optimize(self):
    """ Merges together the operations that can be merged. They need to have the following arguments equal:
        * Type
        * Arguments
        * SourceSE
        * TargetSE
        * Catalog
        It also makes sure that the maximum number of Files in an Operation is never overcome.

        CAUTION: this method is meant to be called before inserting into the DB.
                So if the RequestID is not 0, we don't touch

        :return: S_ERROR if the Request should not be optimized (because already in the DB)
                S_OK(True) if a optimization was carried out
                S_OK(False) if no optimization were carried out
    """

    # If the RequestID is not the default one (0), it probably means
    # the Request is already in the DB, so we don't touch anything
    if hasattr(self, 'RequestID') and getattr(self, 'RequestID'):
      return S_ERROR(
          "Cannot optimize because Request seems to be already in the DB (RequestID %s)" %
          getattr(
              self,
              'RequestID'))
    # Set to True if the request could be optimized
    optimized = False
    # Recognise Failover request series
    repAndRegList = []
    removeRepList = []
    i = 0
    while i < len(self.__operations__):
      insertNow = True
      if i < len(self.__operations__) - 1:
        op1 = self.__operations__[i]
        op2 = self.__operations__[i + 1]
        if getattr(op1, 'Type') == 'ReplicateAndRegister' and \
           getattr(op2, 'Type') == 'RemoveReplica':
          fileSetA = set(list(f.LFN for f in op1))
          fileSetB = set(list(f.LFN for f in op2))
          if fileSetA == fileSetB:
            # Source is useless if failover
            if self.dmsHelper.isSEFailover(op1.SourceSE):
              op1.SourceSE = ''
            repAndRegList.append((op1.TargetSE, op1))
            removeRepList.append((op2.TargetSE, op2))
            del self.__operations__[i]
            del self.__operations__[i]
            # If we are at the end of the request, we must insert the new operations
            insertNow = (i == len(self.__operations__))
      # print i, self.__operations__[i].Type if i < len( self.__operations__ )
      # else None, len( repAndRegList ), insertNow
      if insertNow:
        if repAndRegList:
          # We must insert the new operations there
          # i.e. insert before operation i (if it exists)
          # Replication first, removeReplica next
          optimized = True
          insertBefore = self.__operations__[i] if i < len(self.__operations__) else None
          # print 'Insert new operations before', insertBefore
          ops = [op for _, op in sorted(repAndRegList, key=lambda x: x[0])]
          ops += [op for _, op in sorted(removeRepList, key=lambda x: x[0])]
          for op in ops:
            _res = self.insertBefore(op, insertBefore) if insertBefore else self.addOperation(op)
            # Skip the newly inserted operation
            i += 1
          repAndRegList = []
          removeRepList = []
        else:
          # Skip current operation
          i += 1
      else:
        # Just to show that in that case we don't increment i
        pass

    # List of attributes that must be equal for operations to be merged
    attrList = ["Type", "Arguments", "SourceSE", "TargetSE", "Catalog"]

    i = 0
    while i < len(self.__operations__):
      while i < len(self.__operations__) - 1:
        # Some attributes need to be the same
        attrMismatch = False
        for attr in attrList:
          if getattr(self.__operations__[i], attr) != getattr(self.__operations__[i + 1], attr):
            attrMismatch = True
            break

        if attrMismatch:
          break

        # We do not do the merge if there are common files in the operations
        fileSetA = set(list(f.LFN for f in self.__operations__[i]))
        fileSetB = set(list(f.LFN for f in self.__operations__[i + 1]))
        if fileSetA & fileSetB:
          break

        # There is a maximum number of files one can add into an operation
        try:
          while len(self.__operations__[i + 1]):
            fileToMove = self.__operations__[i + 1][0]
            self.__operations__[i] += fileToMove

            # If the object is mapped to SQLAlchemy object with a relationship
            # having the delete-orphan option, the fileToMove will have
            # already disappeared from the original operation. Silly...
            # If not, we have to remove it manually

            if len(self.__operations__[i + 1])\
               and (self.__operations__[i + 1][0] == fileToMove):
              del self.__operations__[i + 1][0]
            optimized = True
          del self.__operations__[i + 1]
        except RuntimeError:
          i += 1
      i += 1

    return S_OK(optimized)
