# -*- coding: utf-8 -*-
##############################################################################
#
# Copyright (c) 2009 Nexedi SA and Contributors. All Rights Reserved.
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsibility of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# guarantees and support are strongly adviced to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
##############################################################################

import transaction
import zope.interface
from AccessControl import ClassSecurityInfo
from Acquisition import aq_base
from Products.ERP5Type import Permissions, interfaces
from Products.ERP5Type.Base import Base
from Products.ERP5Type.Core.Predicate import Predicate
from Products.ERP5Type.Errors import SimulationError
from Products.ERP5Type.TransactionalVariable import getTransactionalVariable
from Products.ERP5.ExpandPolicy import policy_dict
from Products.ERP5.MovementCollectionDiff import _getPropertyAndCategoryList

from zLOG import LOG

def _compare(tester_list, prevision_movement, decision_movement):
  for tester in tester_list:
    if not tester.compare(prevision_movement, decision_movement):
      return False
  return True

class MovementGeneratorMixin:
  """
  This class provides a generic implementation of IMovementGenerator
  which can be used together the Rule mixin class bellow. It does not
  have any pretention to provide more than that.

  TODO:
    - _getInputMovementList is still not well defined. Should input
      be an amount (_getInputAmountList) or a movement? This 
      requires careful thiking.
  """
  # Default values
  _applied_rule = None
  _rule = None
  _trade_phase_list = None
  _explanation = None

  def __init__(self, applied_rule, explanation=None, rule=None, trade_phase_list=None):
    self._trade_phase_list = trade_phase_list # XXX-JPS Why a list ?
    self._applied_rule = applied_rule
    if rule is None and applied_rule is not None:
      self._rule = applied_rule.getSpecialiseValue()
    else:
      self._rule = rule # for rule specific stuff
    if explanation is None:
      self._explanation = applied_rule
    else:
      # A good example of explicit explanation can be getRootExplanationLineValue
      # since different lines could have different dates
      # such an explicit root explanation only works if
      # indexing of simulation has already happened
      self._explanation = explanation
    # XXX-JPS handle delay_mode

  # Implementation of IMovementGenerator
  def getGeneratedMovementList(self, movement_list=None, rounding=False):
    """
    Returns a list of movements generated by that rule.

    movement_list - optional IMovementList which can be passed explicitely

    rounding - boolean argument, which controls if rounding shall be applied on
               generated movements or not

    NOTE:
      - implement rounding appropriately (True or False seems
        simplistic)
    """
    # Default implementation below can be overriden by subclasses
    # however it should be generic enough not to be overriden
    # by most classes
    # Results will be appended to result
    result = []
    # Build a list of movement and business path
    input_movement_list = self._getInputMovementList(
                            movement_list=movement_list, rounding=rounding)
    for input_movement in input_movement_list:
      # Merge movement and business path properties (core implementation)
      # Lookup Business Process through composition (NOT UNION)
      business_process = input_movement.asComposedDocument()
      explanation = self._applied_rule # We use applied rule as local explanation
      trade_phase = self._getTradePhaseList(input_movement, business_process) # XXX-JPS not convenient to handle
      update_property_dict = self._getUpdatePropertyDict(input_movement)
      result.extend(business_process.getTradePhaseMovementList(explanation, input_movement,
                                                 trade_phase=trade_phase, delay_mode=None,
                                                 update_property_dict=update_property_dict))

    # And return list of generated movements
    return result

  def _getUpdatePropertyDict(self, input_movement):
    # XXX Wouldn't it better to return {} or {'delivery': None} ?
    #     Below code is mainly for root applied rules.
    #     Other movement generators usually want to reset delivery.
    return {'delivery': input_movement.getRelativeUrl()}

  def _getTradePhaseList(self, input_movement, business_process): # XXX-JPS WEIRD
    if self._trade_phase_list:
      return self._trade_phase_list
    if self._rule is not None:
      trade_phase_list = self._rule.getTradePhaseList()
      if trade_phase_list:
        return trade_phase_list
    return input_movement.getTradePhaseList() or \
      business_process.getTradePhaseList()

  def _getInputMovementList(self, movement_list=None, rounding=None): #XXX-JPS should it be amount or movement ?
    raise NotImplementedError
    # Default implementation takes amounts ?
    # Use TradeModelRuleMovementGenerator._getInputMovementList as default implementation
    # and potentially use trade phase for that.... as a way to filter out


class RuleMixin(Predicate):
  """
  Provides generic methods and helper methods to implement
  IRule and IMovementCollectionUpdater.
  """
  # Declarative security
  security = ClassSecurityInfo()
  security.declareObjectProtected(Permissions.AccessContentsInformation)

  # Declarative interfaces
  zope.interface.implements(interfaces.IRule,
                            interfaces.IDivergenceController,
                            interfaces.IMovementCollectionUpdater,)

  # Portal Type of created children
  movement_type = 'Simulation Movement'

  # Implementation of IRule
  def constructNewAppliedRule(self, context, **kw):
    """
    Create a new applied rule in the context.

    An applied rule is an instantiation of a Rule. The applied rule is
    linked to the Rule through the `specialise` relation. The newly
    created rule should thus point to self.

    context -- usually, a parent simulation movement of the
               newly created applied rule

    activate_kw -- activity parameters, required to control
                   activity constraints

    kw -- XXX-JPS probably wrong interface specification
    """
    return context.newContent(portal_type='Applied Rule',
                              specialise_value=self, **kw)

  if 0: # XXX-JPS - if people are stupid enough not to configfure predicates,
        # it is not our role to be clever for them
        # Rules have a workflow - make sure applicable rule system works
        # if you wish, add a test here on workflow state to prevent using
        # rules which are no longer applicable
   def test(self, *args, **kw):
    """
    If no test method is defined, return False, to prevent infinite loop
    """
    if not self.getTestMethodId():
      return False
    return super(RuleMixin, self).test(*args, **kw)

  def expand(self, applied_rule, expand_policy=None, **kw):
    """
    Expand this applied rule to create new documents inside the
    applied rule.

    At expand time, we must replace or compensate certain
    properties. However, if some properties were overwritten
    by a decision (ie. a resource is changed), then we
    should not try to compensate such a decision.
    """
    policy_dict[expand_policy](**kw).expand(self, applied_rule)

  def _expandNow(self, maybe_expand, applied_rule):
    # Update moveme-nts
    #  NOTE-JPS: it is OK to make rounding a standard parameter of rules
    #            although rounding in simulation is not recommended at all
    self.updateMovementCollection(applied_rule,
      movement_generator=self._getMovementGenerator(applied_rule))
    # And forward expand
    for movement in applied_rule.getMovementList():
      maybe_expand(movement)

  security.declareProtected(Permissions.AccessContentsInformation,
                            'isAccountable')
  def isAccountable(self, movement):
    """Tells wether generated movement needs to be accounted or not.

    Only account movements which are not associated to a delivery;
    Whenever delivery is there, delivery has priority
    """
    return not movement.getDelivery()

  # Implementation of IDivergenceController # XXX-JPS move to IDivergenceController only mixin for 
  security.declareProtected( Permissions.AccessContentsInformation,
                            'isDivergent')
  def isDivergent(self, movement, ignore_list=[]):
    """
    Returns true if the Simulation Movement is divergent comparing to
    the delivery value
    """
    if not movement.getDelivery():
      return False
    return bool(self.getDivergenceList(movement))

  security.declareProtected(Permissions.View, 'getDivergenceList')
  def getDivergenceList(self, movement):
    """
    Returns a list of divergences of the movements provided
    in delivery_or_movement.

    movement -- a movement, a delivery, a simulation movement,
                or a list thereof
    """
    result_list = []
    for divergence_tester in self._getDivergenceTesterList(
                                          exclude_quantity=False):
      if divergence_tester.test(movement):
        result = divergence_tester.explain(movement)
        if isinstance(result, (list, tuple)): # for compatibility
          result_list.extend(result)
        elif result is not None:
          result_list.append(result)
    return result_list

  # Placeholder for methods to override
  def _getMovementGenerator(self, applied_rule):
    """
    Return the movement generator to use in the expand process
    """
    raise NotImplementedError

  def _getMovementGeneratorContext(self, applied_rule):
    """
    Return the movement generator context to use for expand
    XXX-JPS likely useless
    """
    raise NotImplementedError

  def _getMovementGeneratorMovementList(self, applied_rule):
    """
    Return the movement lists to provide to the movement generator
    """
    raise NotImplementedError

  def _getDivergenceTesterList(self, exclude_quantity=True):
    """
    Return the applicable divergence testers which must
    be used to test movement divergence. (ie. not all
    divergence testers of the Rule)

     exclude_quantity -- if set to true, do not consider
                         quantity divergence testers
    """
    if exclude_quantity:
      return filter(lambda x:x.isDivergenceProvider() and \
                    'quantity' not in x.getTestedPropertyList(), self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))
    else:
      return filter(lambda x:x.isDivergenceProvider(), self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))

  def _getMatchingTesterList(self):
    """
    Return the applicable divergence testers which must
    be used to match movements and build the diff (ie.
    not all divergence testers of the Rule)
    """
    return filter(lambda x:x.isMatchingProvider(), self.objectValues(
      portal_type=self.getPortalDivergenceTesterTypeList()))

  def _getUpdatingTesterList(self, exclude_quantity=False):
    """
    Return the applicable divergence testers which must be used to
    update movements. (ie. not all divergence testers of the Rule)

    exclude_quantity -- if set to true, do not consider
                        quantity divergence testers
    """
    if exclude_quantity:
      return filter(lambda x:x.isUpdatingProvider() and \
                    'quantity' not in x.getTestedPropertyList(), self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))
    else:
      return filter(lambda x:x.isUpdatingProvider(), self.objectValues(
        portal_type=self.getPortalDivergenceTesterTypeList()))

  def _getQuantityTesterList(self):
    """
    Return the applicable quantity divergence testers.
    """
    tester_list = self.objectValues(
      portal_type=self.getPortalDivergenceTesterTypeList())
    return [x for x in tester_list if 'quantity' in x.getTestedPropertyList()]

  def _newProfitAndLossMovement(self, prevision_movement):
    """
    Returns a new temp simulation movement which can
    be used to represent a profit or loss in relation
    with prevision_movement

    prevision_movement -- a simulation movement
    """
    raise NotImplementedError

  def _isProfitAndLossMovement(movement): # applied_rule XXX-JPS add this ?
    """
    Returns True if movement is a profit and loss movement.
    """
    raise NotImplementedError

  def _extendMovementCollectionDiff(self, movement_collection_diff,
                                    prevision_movement, decision_movement_list):
    """
    Compares a prevision_movement to decision_movement_list which
    are part of the matching group and updates movement_collection_diff
    accordingly

    NOTE: this method API implicitely considers that each group of matching 
    movements has 1 prevision_movement (aggregated) for N decision_movement
    It implies that prevision_movement are "more" aggregated than 
    decision_movement.

    TODO:
       - is this assumption appropriate ?
    """
    # Sample implementation - but it actually looks very generic

    # Case 1: movements which are not needed
    if prevision_movement is None:
      # decision_movement_list contains simulation movements which must
      # be deleted
      for decision_movement in decision_movement_list:
        # If not frozen and all children are deletable
        if decision_movement.isDeletable():
          # Delete deletable
          movement_collection_diff.addDeletableMovement(decision_movement)
        else:
          # Compensate non deletable
          new_movement = decision_movement.asContext(
                            quantity=-decision_movement.getQuantity())
          new_movement.setDelivery(None)
          movement_collection_diff.addNewMovement(new_movement)
      return

    # Case 2: movements which should be added
    elif len(decision_movement_list) == 0:
      # if decision_movement_list is empty, we can just create a new one.
      movement_collection_diff.addNewMovement(prevision_movement)
      return

    # Case 3: movements which are needed but may need update or
    # compensation_movement_list.
    #  let us imagine the case of a forward rule
    #  ie. what comes in must either go out or has been lost
    divergence_tester_list = self._getDivergenceTesterList()
    profit_tester_list = divergence_tester_list
    updating_tester_list = self._getUpdatingTesterList(exclude_quantity=True)
    profit_updating_tester_list = updating_tester_list
    quantity_tester_list = self._getQuantityTesterList()
    compensated_quantity = 0.0
    updatable_movement = None
    not_completed_movement = None
    updatable_compensation_movement = None
    prevision_quantity = prevision_movement.getQuantity()
    decision_quantity = 0.0
    real_quantity = 0.0
    # First, we update all properties (exc. quantity) which could be divergent
    # and if we can not, we compensate them
    for decision_movement in decision_movement_list:
      real_movement_quantity = decision_movement.getQuantity()
      if decision_movement.isPropertyRecorded('quantity'):
        decision_movement_quantity = decision_movement.getRecordedProperty('quantity')
      else:
        decision_movement_quantity = real_movement_quantity
      decision_quantity += decision_movement_quantity
      real_quantity += real_movement_quantity
      if self._isProfitAndLossMovement(decision_movement):
        if decision_movement.isFrozen():
          # Record not completed movements
          if not_completed_movement is None and not decision_movement.isCompleted():
            not_completed_movement = decision_movement
          # Frozen must be compensated
          if not _compare(profit_tester_list, prevision_movement, decision_movement):
            new_movement = decision_movement.asContext(
                                quantity=-decision_movement_quantity)
            new_movement.setDelivery(None)
            movement_collection_diff.addNewMovement(new_movement)
            compensated_quantity += decision_movement_quantity
        else:
          updatable_compensation_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
          for tester in profit_updating_tester_list:
            if not tester.compare(prevision_movement, decision_movement):
              kw.update(tester.getUpdatablePropertyDict(prevision_movement, decision_movement))
          if kw:
            movement_collection_diff.addUpdatableMovement(decision_movement, kw)
      else:
        if decision_movement.isFrozen():
          # Frozen must be compensated
          if not _compare(divergence_tester_list, prevision_movement, decision_movement):
            new_movement = decision_movement.asContext(
                                  quantity=-decision_movement_quantity)
            new_movement.setDelivery(None)
            movement_collection_diff.addNewMovement(new_movement)
            compensated_quantity += decision_movement_quantity
        else:
          updatable_movement = decision_movement
          # Not Frozen can be updated
          kw = {}
          for tester in updating_tester_list:
            if not tester.compare(prevision_movement, decision_movement):
              kw.update(tester.getUpdatablePropertyDict(prevision_movement, decision_movement))
              # XXX-JPS - there is a risk here that quantity is wrongly updated
          if kw:
            movement_collection_diff.addUpdatableMovement(decision_movement, kw)
    # Second, we calculate if the total quantity is the same on both sides
    # after compensation
    quantity_movement = prevision_movement.asContext(
                            quantity=decision_quantity-compensated_quantity)
    if not _compare(quantity_tester_list, prevision_movement, quantity_movement):
      missing_quantity = ( prevision_quantity
                           - real_quantity
                           + compensated_quantity )
      if updatable_movement is not None:
        # If an updatable movement still exists, we update it
        updatable_movement.setQuantity(
            updatable_movement.getQuantity() + missing_quantity)
        updatable_movement.clearRecordedProperty('quantity')
      elif not_completed_movement is not None:
        # It is still possible to add a new movement some movements are not
        # completed
        new_movement = prevision_movement.asContext(quantity=missing_quantity)
        new_movement.setDelivery(None)
        movement_collection_diff.addNewMovement(new_movement)
      elif updatable_compensation_movement is not None:
        # If not, it means that all movements are completed
        # but we can still update a profit and loss movement_collection_diff
        updatable_compensation_movement.setQuantity(
            updatable_compensation_movement.getQuantity() + missing_quantity)
        updatable_compensation_movement.clearRecordedProperty('quantity')
      else:
        # We must create a profit and loss movement
        new_movement = self._newProfitAndLossMovement(prevision_movement)
        movement_collection_diff.addNewMovement(new_movement)


class SimulableMixin(Base):

  def updateSimulation(self, **kw):
    """Create/update related simulation trees by activity

    This method is used to maintain related objects in simulation trees:
    - hiding complexity of activity dependencies
    - avoiding duplicate work

    Repeated calls of this method for the same delivery will result in a single
    call to _updateSimulation. Grouping may happen at the end of the transaction
    or by the grouping method.

    See _updateSimulation for accepted parameters.
    """
    tv = getTransactionalVariable()
    key = 'SimulableMixin.updateSimulation', self.getUid()
    item_list = kw.items()
    try:
      kw, ignore = tv[key]
      kw.update(item_list)
    except KeyError:
      ignore_key = key + ('ignore',)
      ignore = tv.pop(ignore_key, set())
      tv[key] = kw, ignore
      def before_commit():
        if kw:
          path = self.getPath()
          if aq_base(self.unrestrictedTraverse(path, None)) is aq_base(self):
            self.activate(
              activity='SQLQueue',
              group_method_id='portal_rules/updateSimulation',
              tag='expand:' + path,
              after_tag='built:'+ path, # see SimulatedDeliveryBuilder
              priority=3,
              )._updateSimulation(**kw)
        del tv[key]
        ignore.update(kw)
        tv[ignore_key] = ignore
      transaction.get().addBeforeCommitHook(before_commit)
    for k, v in item_list:
      if not v:
        ignore.add(k)
      elif k not in ignore:
        continue
      del kw[k]

  def _updateSimulation(self, create_root=0, expand_root=0,
                              expand_related=0, index_related=0):
    """
    Depending on set parameters, this method will:
      create_root    -- if a root applied rule is missing, create and expand it
      expand_root    -- expand related root applied rule,
                        create it before if missing
      expand_related -- reindex related simulation movements (recursively)
      index_related  -- expand related simulation movements
    """
    if create_root or expand_root:
      applied_rule = self._getRootAppliedRule()
      if applied_rule is None:
        applied_rule = self._createRootAppliedRule()
        expand_root = applied_rule is not None
    activate_kw = {'tag': 'expand:'+self.getPath()}
    if expand_root:
      applied_rule.expand(activate_kw=activate_kw)
    else:
      applied_rule = None
    if expand_related:
      for movement in self._getAllRelatedSimulationMovementList():
        movement = movement.getObject()
        if not movement.aq_inContextOf(applied_rule):
          # XXX: make sure this will also reindex of all sub-objects recursively
          movement.expand(activate_kw=activate_kw)
    elif index_related:
      for movement in self._getAllRelatedSimulationMovementList():
        movement = movement.getObject()
        if not movement.aq_inContextOf(applied_rule):
          movement.recursiveReindexObject(activate_kw=activate_kw)

  def getRuleReference(self):
    """Returns an appropriate rule reference

    XXX: Using reference to select a rule (for a root applied rule) is wrong
         and should be replaced by predicate and workflow state.
    """
    method = self._getTypeBasedMethod('getRuleReference')
    if method is None:
      raise SimulationError("Missing type-based 'getRuleReference' script for "
                            + repr(self))
    return method()

  def _getRootAppliedRule(self):
    """Get related root applied rule if it exists"""
    applied_rule_list = self.getCausalityRelatedValueList(
        portal_type='Applied Rule')
    if len(applied_rule_list) == 1:
      return applied_rule_list[0]
    elif applied_rule_list:
      raise SimulationError('%r has more than one applied rule.' % self)

  def _createRootAppliedRule(self):
    """Create a root applied rule"""
    # XXX: Consider moving this first test to Delivery
    if self.isSimulated():
      # No need to have a root applied rule
      # if we are already in the simulation process
      return
    rule_reference = self.getRuleReference()
    if rule_reference:
      portal = self.getPortalObject()
      rule_list = portal.portal_catalog.unrestrictedSearchResults(
        portal_type=portal.getPortalRuleTypeList(),
        validation_state="validated", reference=rule_reference,
        sort_on='version', sort_order='descending')
      if rule_list:
        applied_rule = rule_list[0].constructNewAppliedRule(
          portal.portal_simulation, is_indexable=False)
        applied_rule._setCausalityValue(self)
        del applied_rule.isIndexable
        # To prevent duplicate root Applied Rule, we reindex immediately and
        # lock ZODB, and we rely on the fact that ZODB is committed after
        # catalog. This way, we guarantee the catalog is up-to-date as soon as
        # ZODB is unlocked.
        applied_rule.immediateReindexObject()
        self.serialize() # prevent duplicate root Applied Rule
        return applied_rule
      raise SimulationError("No such rule as %r is found" % rule_reference)

  def manage_beforeDelete(self, item, container):
    """Delete related Applied Rule"""
    for o in self.getCausalityRelatedValueList(portal_type='Applied Rule'):
      o.getParentValue().deleteContent(o.getId())
    super(SimulableMixin, self).manage_beforeDelete(item, container)