# Copyright (c) 2004-2005 DoCoMo Euro-Labs GmbH (Munich, Germany).
# Copyright (c) 2001-2005 LOGILAB S.A. (Paris, FRANCE).
#
# http://www.docomolab-euro.com/ -- mailto:tarlano@docomolab-euro.com
# http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
"""classes used to handle recipe elements


:version: $Revision:$ 
:author: Logilab  

:copyright:
  2001-2005 LOGILAB S.A. (Paris, FRANCE)
  
  2004-2005 DoCoMo Euro-Labs GmbH (Munich, Germany)
  
:contact:
  http://www.logilab.fr/ -- mailto:contact@logilab.fr
  
  http://www.docomolab-euro.com/ -- mailto:tarlano@docomolab-euro.com
"""

__revision__ = "$Id: ALAbstraction.py,v 1.36 2004/04/02 10:06:46 syt Exp $"
__docformat__ = "restructuredtext en"

from narval.public import AL_NS, NO_NS
from narval.element import NSAttribute, NSAttributesElement, \
     DescriptionableMixin
from narval.xml_handlers import BaseXMLHandler, DescriptionHandler
from narval.serialutils import yn_value, yn_rev_value
from narval.action import ALRecipeItem
from narval.prototype import PrototypeHandler, Prototype, Condition, \
     PrototypeException


class Transition(NSAttributesElement, DescriptionableMixin):
    """recipe transition element (static)

    :type id: str
    :ivar id: the transition's identifier, must be unique within a recipe

    :type priority: int
    :ivar priority: the transition's priority

    :type in_steps: list
    :ivar in_steps: list of references to steps incoming to this transition
    
    :type to_steps: list
    :ivar to_steps: list of references to steps outgoing from this transition

    :type on_error: dict
    :ivar on_error: dictionary where keys are the identifier of error inputs

    :type time_condition: list
    :ivar time_condition: list of time conditions on the transition, if any

    :type conditions: list
    :ivar conditions:
      list of `narval.prototype.Condition` objects to satisfy before being
      fireable    
    """

    __xml_element__ = (AL_NS, 'transition')
    
    id = NSAttribute(NO_NS, None, str, str)
    priority = NSAttribute(NO_NS, 0, int, str)
            
    def __init__(self, **kwargs):
        super(Transition, self).__init__(**kwargs)
        self.in_steps, self.out_steps = [], []
        self.on_error = {}
        self.time_conditions = []
        self.conditions = []
    
    def children_as_xml(self, encoding='UTF-8'):
        """return the xml representation of the transition's children elements

        :type encoding: str
        :param encoding:
          the encoding to use in the returned string, default to UTF-8
          
        :rtype: str
        :return: the transition as an XML document
        """
        result = []
        descr_xml = self.description_as_xml(encoding)
        descr_xml and result.append(descr_xml)
        for step in self.in_steps:
            result.append('<al:in idref="%s" onError="%s"/>' % (
                step.id, yn_rev_value(self.on_error[step.id])))
        for step in self.out_steps:
            result.append('<al:out idref="%s"/>' % step.id)
        for condition in self.conditions:
            result.append(condition.as_xml(encoding))
        for cond in self.time_conditions:
            result.append('<al:time seconds="%s" minutes="%s" hours="%s" '
                          'monthdays="%s" months="%s" weekdays="%s"/>' % cond)
        return '\n'.join(result)

    def add_condition(self, condition):
        """add a condition on the transition
        
        :type condition: `narval.prototype.Condition`
        :param condition: Condition instance to add
        """
        self.conditions.append(condition)

    def add_in_step(self, step, on_error=False):
        """add an input step

        :type step: `Step`
        :param step: step to add to transition's inputs

        :type on_error: str
        :param on_error:
          one of 'yes' or 'no' indicating whether the input is triggered on error
        """
        assert isinstance(on_error, bool)
        step_id = step.id
        self.on_error[step_id] = on_error
        self.in_steps.append(step)
        
    def add_out_step(self, step):
        """add an output step

        :type step: `Step`
        :param step: step to add to transition's outputs
        """
        self.out_steps.append(step)
        
    def remove_in_step(self, step):
        """remove an input step

        :type step: `Step`
        :param step: step to remove from transition's inputs
        """
        self.in_steps.remove(step)
        del self.on_error[step.id]
        
    def remove_out_step(self, step):
        """remove an output step

        :type step: `Step`
        :param step: step to remove from transition's outputs
        """
        self.out_steps.remove(step)


class Step(NSAttributesElement, DescriptionableMixin):
    """recipe step element (static)


    :type id: str
    :ivar id: the step's identifier, must be unique within a recipe

    :type type: str
    :ivar type: the step's type, 'recipe' or 'action'

    :type target: str
    :ivar target: the step's target name, <group>.<name>

    :type foreach: str
    :ivar foreach:
      optional identifier of an input on which the step should be repeated

    :type label: str
    :ivar label: label of the step

    :type in_transitions: list
    :ivar in_transitions: incoming transitions (`Transition`)

    :type out_transitions: list
    :ivar out_transitions: outgoing transitions (`Transition`)

    :type arguments: str or list
    :ivar arguments:
      step arguments as an XML snippet or as a list of elements
      (`narval.public.ALElement`)

    :type prototype: `narval.prototype.Prototype`
    :ivar prototype: step's prototype
    """
    
    __xml_element__ = (AL_NS, 'step')
    
    id = NSAttribute(NO_NS, None, str, str)
    type = NSAttribute(NO_NS, None, str, str)
    priority = NSAttribute(NO_NS, 0, int, str)
    target = NSAttribute(NO_NS, None, str, str)
    foreach = NSAttribute(NO_NS, None, str, str)
    
    def __init__(self, label='', arguments='', **kwargs):
        super(Step, self).__init__(**kwargs)
        # FIXME: label should be supported for multiple languages
        self.label = u''
        self.arguments = u''
        self.in_transitions, self.out_transitions = [], []
        self.prototype = Prototype()
        
    def children_as_xml(self, encoding='UTF-8'):
        """return the xml representation of the step

        :type encoding: str
        :param encoding:
          the encoding to use in the returned string, default to UTF-8
          
        :rtype: str
        :return: the step as an XML document
        """
        result = []
        if self.label:
            label = '<al:label>%s</al:label>' % self.label.encode(encoding)
            result.append(label)
        descr_xml = self.description_as_xml(encoding)
        descr_xml and result.append(descr_xml)
        proto_xml = self.prototype.as_xml(encoding)
        proto_xml and result.append(proto_xml)
        if self.arguments:
            result.append('<al:arguments>')
            if isinstance(self.arguments, unicode):
                result.append(self.arguments.encode(encoding))
            else:
                for elt in self.arguments:
                    result.append(elt.as_xml(encoding))
            result.append('</al:arguments>')
        return '\n'.join(result)


class RecipeXMLHandler(BaseXMLHandler):
    """XML handler for recipe elements"""
    tr_class = Transition
    st_class = Step
    
    def __init__(self, elmt, ns_context, locator):
        super(RecipeXMLHandler, self).__init__(elmt, ns_context, locator)
        self._stack = [self.elmt]
        self._sub_hdlr = None
        
    def start_element(self, name, attrs):
        """SAX callback: start a new xml node

        :type name: tuple
        :param name: the tag name as a tuple (uri, name)
        
        :type attrs: dict
        :param attrs:
          the node's attribute values, indexed by attribute's name as a tuple
          (uri, name)
        """
        prefix, local = name
        if self._stack[-1] == 'arguments':
            # namespaces handling
            qname, ns = self.get_qname(name)
            result = [ns]
            for a_name, value in attrs.items():
                a_qname, ns = self.get_qname(a_name)
                if not ns in result:
                    result.append(ns)
                result.append('%s="%s"' % (a_qname, value))
            self._stack[-2].arguments = '%s<%s%s>' % (self._stack[-2].arguments,
                                                      qname, ' '.join(result))
            return
        if prefix != AL_NS:
            log(LOG_ERR, 'unknown prefix %s in action prototype, tag %s ignored',
                (prefix, local))
            return
        # prototype
        if local in ('input', 'output', 'condition'):
            self._sub_hdlr = PrototypeHandler(self._stack[-1], self._ns_context,
                                             self._locator)
            
        if self._sub_hdlr:
            self._sub_hdlr.start_element(name, attrs)
        elif local == 'description':
            self._sub_hdlr = DescriptionHandler(self._stack[-1],
                                               attrs.get((NO_NS, 'lang'), 'en'))
        elif local in('arguments', 'label', 'match'):
            self._stack.append(local)
        elif local == 'step':
            elmt = self.st_class()
            elmt.init_attrs(attrs)
            self.elmt.add_element(elmt)
            self._stack.append(elmt)
        elif local == 'transition':
            elmt = self.tr_class()
            elmt.init_attrs(attrs)
            self.elmt.add_element(elmt)
            self._stack.append(elmt)
        elif local == 'in':
            trans = self._stack[-1]
            trans.add_in_step(self.elmt.elements[attrs[(NO_NS, 'idref')]],
                              yn_value(attrs.get((NO_NS, 'onError'), 'no')))
        elif local == 'out':
            trans = self._stack[-1]
            try:
                trans.add_out_step(self.elmt.elements[attrs[(NO_NS, 'idref')]])
            except KeyError:
                raise Exception('Bad recipe %s: no such step %s (defined: %s' %
                                (self.elmt.name,
                                 attrs[(NO_NS, 'idref')],
                                 self.elmt.elements.keys()))
        elif local == 'condition':
            cond = Condition()
            cond.init_attrs(attrs)
            self._stack[-1].conditions.append(cond)
            self._stack.append(cond)
        elif local == 'time':
            self._stack[-1].time_conditions.append(
                (attrs.get((NO_NS, 'seconds'), '*'),
                 attrs.get((NO_NS, 'minutes'), '*'),
                 attrs.get((NO_NS, 'hours'), '*'),
                 attrs.get((NO_NS, 'monthdays'), '*'),
                 attrs.get((NO_NS, 'months'), '*'),
                 attrs.get((NO_NS, 'weekdays'), '*')))
        else:
            log(LOG_ERR, 'Ignoring tag %s', local)
            
    def end_element(self, name):
        """SAX callback: close a xml node

        :type name: tuple
        :param name: the tag name as a tuple (uri, name)
        """
        prefix, local = name
        if self._stack[-1] == 'arguments' and not (
            prefix == AL_NS and local == 'arguments'):
            self._stack[-2].arguments += '</%s>' % (self.get_qname(name)[0])
        elif prefix != AL_NS:
            return
        if self._sub_hdlr:
            try:
                self._sub_hdlr.end_element(name)
            except AttributeError:
                # description handler
                self._sub_hdlr = None
        elif local in ('step', 'transition', 'condition',
                       'match', 'arguments', 'label'):
            self._stack.pop()

        if local in ('input', 'output', 'condition'):
            self._sub_hdlr = None

    def characters(self, content):
        """SAX callback: get some (non empty) string

        :type content: unicode
        :param content: the non empty string to hold
        """
        content = content.strip()
        if not content:
            return
        if self._sub_hdlr:
            self._sub_hdlr.characters(content)
        elif self._stack[-1] == 'arguments':
            self._stack[-2].arguments += content.replace('<', '&lt;')
        elif self._stack[-1] == 'label':
            self._stack[-2].label += content
        else:
            log(LOG_ERR, 'Duh !!??? %s', self._stack[-1])

    def get_qname(self, name):
        """get the qualified name for a tag name

        :type name: tuple
        :param name: the tag name as a tuple (uri, name)

        :rtype: tuple
        :return:
          the qualified name <prefix>:<name> and the formatted xml name
          space declaration
        """
        if not name[0]:
            return name[1], ''
        else:
            for uri, prefix in self._ns_context.items():
                if uri == name[0]:
                    return '%s:%s' % (prefix, name[1]), \
                           ' xmlns:%s="%s"' % (prefix, uri)
            raise Exception('Unknown prefix for namespace %s', name[0])
    

    
class RecipeElement(ALRecipeItem):
    """a recipe element : a graph of steps and transitions

    :type name: str
    :ivar name: the recipe's name

    :type group: str
    :ivar group: the recipe's group (i.e. cookbook)

    :type restart: bool
    :ivar restart:
      flag indicating whether the plan should be automatically restarted
      when it's finished

    :type decay: int
    :ivar decay: number of seconds until the plan is forgotten after its end

    :type elements: dict
    :ivar elements:
      dictionary of steps and transitions in the recipe, indexed by their
      identifier
      
    :type memory: `narval.memory.Memory` or None
    :ivar memory:
      a reference to the interpreter's memory, set when the element is added
      to memory
    """
    
    __xml_element__ = (AL_NS, 'recipe')
    __child_handler__ = RecipeXMLHandler

    restart = NSAttribute(NO_NS, False, yn_value, yn_rev_value)
    # default DECAY set to 90 seconds, because we use too much RAM
    decay = NSAttribute(NO_NS, 90, int, str)

    def __init__(self, **kwargs):
        super(RecipeElement, self).__init__(**kwargs)
        # recipe's steps and transitions indexed by their identifier
        self.elements = {}
        self.check, self.errors = None, ''
        self.memory = None
        self.start_step, self.end_step = None, None
        
    def children_as_xml(self, encoding='UTF-8'):
        """return the xml representation of the recipe

        :type encoding: str
        :param encoding:
          the encoding to use in the returned string, default to UTF-8
          
        :rtype: str
        :return: the recipe as an XML document
        """
        result = []
        descr_xml = self.description_as_xml(encoding)
        descr_xml and result.append(descr_xml)
        # first serialize steps
        for elmt in self.elements.values():
            if isinstance(elmt, Step):
                result.append(elmt.as_xml(encoding, namespaces_def=False))
        # then serialize transitions
        for elmt in self.elements.values():
            if not isinstance(elmt, Step):
                result.append(elmt.as_xml(encoding, namespaces_def=False))
        return '\n'.join(result)

    def transitions(self):
        """return an iterator on transition instances in this recipe"""
        for s_or_t in self.elements.values():
            if isinstance(s_or_t, Transition):
                yield s_or_t
        
    def steps(self):
        """return an iterator on step instances in this recipe"""
        for s_or_t in self.elements.values():
            if isinstance(s_or_t, Step):
                yield s_or_t
        
    def check_syntax(self) :
        """check the recipe's validity

        :rtype: tuple
        :return:
          a 2-uple (status, error) where status is 'bad' or 'ok'. If status is
          'bad', error is a string describing detected errors
        """
        # if that recipe was checked, return result
        if self.check is not None:
            return self.check, self.errors
        log(LOG_INFO, 'check syntax for recipe %s.%s at %s', (self.group, self.name, id(self)))
        errors = []
        # FIXME: check prototype matchs syntax (SyntaxError, NameError...)
        # FIXME: detect cycles
        # make lists of steps / transitions
        steps_io = {}
        for step in self.steps():
            steps_io[step.id] = ([], [])
            if step.type == 'recipe' :
                try:
                    self.memory.get_recipe(step.target)
                except KeyError:
                    errors.append('* bad recipe reference %s in step %s' % 
                                  (step.target, step.id))
            elif step.type == 'action' :
                # check action exists
                try:
                    action = self.memory.get_action(step.target)
                except KeyError:
                    errors.append('* bad action reference %s for step %s' % 
                                  (step.target, step.id))
                else:
                    # check prototypes
                    try:
                        step.prototype.check(action)
                    except PrototypeException, ex :
                        errors.append('* prototype error for step %s: %s' % 
                                      (step.id, str(ex)))
            else :
                msg = '* missing "type" attribute for step %s' %  step.id
                errors.append(msg)
        # check that transitions are pointing to existing steps
        for transition in self.transitions() :
            #inout = transition.in_steps + transition.out_steps
            for ref in transition.in_steps:
                ref = ref.id
                if not steps_io.has_key(ref):
                    errors.append('* bad step reference %s in transition %s' % (
                        ref, transition.id))
                else:
                    steps_io[ref][1].append(transition.id)
            for ref in transition.out_steps:
                ref = ref.id
                if not steps_io.has_key(ref):
                    errors.append('* bad step reference %s in transition %s' % (
                        ref, transition.id))
                else:
                    steps_io[ref][0].append(transition.id)
        # check start step and end step
        start_cands, end_cands = [], []
        for sid in steps_io.keys():
            if len(steps_io[sid][0]) == 0:
                start_cands.append(sid)
            if len(steps_io[sid][1]) == 0:
                end_cands.append(sid)
        if len(start_cands) != 1:
            msg = '* Unable to find start step (candidats:%s)' % start_cands
            errors.append(msg)
        else:
            self.start_step = start_cands[0]
        if len(end_cands) != 1:
            msg = '* Unable to find end step (candidats:%s)' % end_cands
            errors.append(msg)
        else:
            self.end_step = end_cands[0]
        if errors:
            self.check = 'bad'
            errors.insert(0, 'Bad recipe %s:' % self.name)
            self.errors = '\n'.join(errors)
        else:
            self.check = 'ok'
        return self.check, self.errors

    # Edit interface ##########################################################
    
    def add_element(self, element):
        """add a step or a transition to the recipe

        :type element: `narval.public.ALElement`
        :param element: the element (step or transition) to add to the recipe

        :rtype: str
        :return: the identifier of the added element
        """
        if self.elements.has_key(element.id):
            raise Exception('duplicated element id %s in recipe %s.%s' % (
                element.id, self.group, self.name))
        if not element.id:
            raise Exception('element with no id %s' % element)
        self.elements[element.id] = element
        return element.id

    def remove_element(self, element):
        """remove a step or a transition from the recipe

        :type element: `narval.public.ALElement`
        :param element:
          the element (step or transition) to remove from the recipe
        """
        del self.elements[element.id]

