# Copyright (c) 2001, 2003 by Intevation GmbH
# Authors:
# Jonathan Coles <jonathan@intevation.de>
#
# This program is free software under the GPL (>=v2)
# Read the file COPYING coming with Thuban for details.

__version__ = "$Revision: 1.39 $"

"""
A Classification provides a mapping from an input value
to data. This mapping can be specified in two ways. 
First, specific values can be associated with data. 
Second, ranges can be associated with data such that if 
an input value falls with a range that data is returned. 
If no mapping can be found then default data will 
be returned. Input values must be hashable objects

See the description of FindGroup() for more information 
on the mapping algorithm.
"""
  
import copy, operator, types

from Thuban import _

from messages import \
    LAYER_PROJECTION_CHANGED, \
    LAYER_LEGEND_CHANGED, \
    LAYER_VISIBILITY_CHANGED,\
    CLASS_CHANGED

from Thuban.Model.color import Color, Transparent, Black
from Thuban.Model.range import Range

import Thuban.Model.layer

from Thuban.Lib.connector import Publisher

class Classification(Publisher):
    """Encapsulates the classification of layer. 
    
    The Classification divides some kind of data into Groups which 
    are associated with properties. Later the properties can be 
    retrieved by matching data values to the appropriate group.
    """

    def __init__(self):
        """Initialize a classification."""
 
        self.__groups = []

        self.SetDefaultGroup(ClassGroupDefault())

    def __iter__(self):
        return ClassIterator(self.__groups) 

    def __deepcopy__(self, memo):
        clazz = Classification()

        clazz.__groups[0] = copy.deepcopy(self.__groups[0])

        for i in range(1, len(self.__groups)):
            clazz.__groups.append(copy.deepcopy(self.__groups[i]))

        return clazz

    def __SendNotification(self):
        """Notify the layer that this class has changed."""
        self.issue(CLASS_CHANGED)

    def __getattr__(self, attr):
        """Generate the compiled classification on demand"""
        if attr == "_compiled_classification":
            self._compile_classification()
            return self._compiled_classification
        raise AttributeError(attr)

    def _compile_classification(self):
        """Generate the compiled classification

        The compiled classification is a more compact representation of
        the classification groups that is also more efficient for
        performing the classification.

        The compiled classification is a list of tuples. The first
        element of the tuple is a string which describes the rest of the
        tuple. There are two kinds of tuples:

          'singletons'

            The second element of the tuple is a dictionary which
            combines several consecutive ClassGroupSingleton instances.
            The dictionary maps the values of the singletons (as
            returned by the GetValue() method) to the corresponding
            group.

          'range'

            The tuple describes a ClassGroupRange instance. The tuples
            second element is a tuple fo the form (lfunc, min, max,
            rfunc, group) where group is the original group object,
            lfunc and rfuct are comparison functions and min and max are
            lower and upper bounds of the range. Given a value and such
            a tuple the group matches if and only if

                lfunc(min, value) and rfunc(max, value)

            is true.

        The compiled classification is bound to
        self._compile_classification.
        """
        compiled = []
        for group in self.__groups[1:]:
            if isinstance(group, ClassGroupSingleton):
                if not compiled or compiled[-1][0] != "singletons":
                    compiled.append(("singletons", {}))
                compiled[-1][1].setdefault(group.GetValue(), group)
            elif isinstance(group, ClassGroupRange):
                left, min, max, right = group.GetRangeTuple()
                if left == "[":
                    lfunc = operator.le
                elif left == "]":
                    lfunc = operator.lt
                if right == "[":
                    rfunc = operator.gt
                elif right == "]":
                    rfunc = operator.ge
                compiled.append(("range", (lfunc, min, max, rfunc, group)))
            else:
                raise TypeError("Unknown group type %s", group)
        self._compiled_classification = compiled

    def _clear_compiled_classification(self):
        """Reset the compiled classification.

        If will be created on demand when self._compiled_classification
        is accessed again.

        Call this method whenever self.__groups is modified.
        """
        try:
            del self._compiled_classification
        except:
            pass

    #
    # these SetDefault* methods are really only provided for 
    # some backward compatibility. they should be considered
    # for removal once all the classification code is finished.
    #

    def SetDefaultFill(self, fill):
        """Set the default fill color.

        fill -- a Color object.
        """
        self.GetDefaultGroup().GetProperties().SetFill(fill)
        self.__SendNotification()
        
    def GetDefaultFill(self):
        """Return the default fill color."""
        return self.GetDefaultGroup().GetProperties().GetFill()
        
    def SetDefaultLineColor(self, color):
        """Set the default line color.

        color -- a Color object.
        """
        self.GetDefaultGroup().GetProperties().SetLineColor(color)
        self.__SendNotification()
        
    def GetDefaultLineColor(self):
        """Return the default line color."""
        return self.GetDefaultGroup().GetProperties().GetLineColor()
        
    def SetDefaultLineWidth(self, lineWidth):
        """Set the default line width.

        lineWidth -- an integer > 0.
        """
        assert isinstance(lineWidth, types.IntType)
        self.GetDefaultGroup().GetProperties().SetLineWidth(lineWidth)
        self.__SendNotification()
        
    def GetDefaultLineWidth(self):
        """Return the default line width."""
        return self.GetDefaultGroup().GetProperties().GetLineWidth()
        

    #
    # The methods that manipulate self.__groups have to be kept in
    # sync. We store the default group in index 0 to make it
    # convienent to iterate over the classification's groups, but
    # from the user's perspective the first (non-default) group is
    # at index 0 and the DefaultGroup is a special entity.
    #

    def SetDefaultGroup(self, group):
        """Set the group to be used when a value can't be classified.

        group -- group that the value maps to.
        """
        assert isinstance(group, ClassGroupDefault)
        if len(self.__groups) > 0:
            self.__groups[0] = group
        else:
            self.__groups.append(group)
        self.__SendNotification()

    def GetDefaultGroup(self):
        """Return the default group."""
        return self.__groups[0]

    def AppendGroup(self, item):
        """Append a new ClassGroup item to the classification.

        item -- this must be a valid ClassGroup object
        """

        self.InsertGroup(self.GetNumGroups(), item)

    def InsertGroup(self, index, group):
        assert isinstance(group, ClassGroup)
        self.__groups.insert(index + 1, group)
        self._clear_compiled_classification()
        self.__SendNotification()

    def RemoveGroup(self, index):
        """Remove the classification group with the given index"""
        self.__groups.pop(index + 1)
        self._clear_compiled_classification()
        self.__SendNotification()

    def ReplaceGroup(self, index, group):
        assert isinstance(group, ClassGroup)
        self.__groups[index + 1] = group
        self._clear_compiled_classification()
        self.__SendNotification()

    def GetGroup(self, index):
        return self.__groups[index + 1]

    def GetNumGroups(self):
        """Return the number of non-default groups in the classification."""
        return len(self.__groups) - 1

    def FindGroup(self, value):
        """Return the group that matches the value.

        Groups are effectively checked in the order the were added to
        the Classification.

        value -- the value to classify. If there is no mapping or value
                 is None, return the default properties
        """

        if value is not None:
            for typ, params in self._compiled_classification:
                if typ == "singletons":
                    group = params.get(value)
                    if group is not None:
                        return group
                elif typ == "range":
                    lfunc, min, max, rfunc, g = params
                    if lfunc(min, value) and rfunc(max, value):
                        return g

        return self.GetDefaultGroup()

    def GetProperties(self, value):
        """Return the properties associated with the given value.
       
        Use this function rather than Classification.FindGroup().GetProperties()
        since the returned group may be a ClassGroupMap which doesn't support
        a call to GetProperties(). 
        """

        group = self.FindGroup(value)
        if isinstance(group, ClassGroupMap):
            return group.GetPropertiesFromValue(value)
        else:
            return group.GetProperties()

    def TreeInfo(self):
        items = []

        def build_color_item(text, color):
            if color is Transparent:
                return ("%s: %s" % (text, _("None")), None)

            return ("%s: (%.3f, %.3f, %.3f)" % 
                    (text, color.red, color.green, color.blue),
                    color)

        def build_item(group, string):
            label = group.GetLabel()
            if label == "":
                label = string
            else:
                label += " (%s)" % string

            props = group.GetProperties()
            i = []
            v = props.GetLineColor()
            i.append(build_color_item(_("Line Color"), v))
            v = props.GetLineWidth()
            i.append(_("Line Width: %s") % v)
            v = props.GetFill()
            i.append(build_color_item(_("Fill"), v))
            return (label, i)

        for p in self:
            items.append(build_item(p, p.GetDisplayText()))

        return (_("Classification"), items)

class ClassIterator:
    """Allows the Groups in a Classifcation to be interated over.

    The items are returned in the following order:
        default data, singletons, ranges, maps
    """

    def __init__(self, data): #default, points, ranges, maps):
        """Constructor.

        default -- the default group

        points -- a list of singleton groups

        ranges -- a list of range groups

        maps -- a list of map groups
        """

        self.data = data 
        self.data_index = 0

    def __iter__(self):
        return self

    def next(self):
        """Return the next item."""

        if self.data_index >= len(self.data):
            raise StopIteration
        else:
            d = self.data[self.data_index]
            self.data_index += 1
            return d

class ClassGroupProperties:
    """Represents the properties of a single Classification Group.

    These are used when rendering a layer."""

    # TODO: Actually, size is only relevant for point objects.
    # Eventually it should be spearated, e.g. when introducing symbols.

    def __init__(self, props = None):
        """Constructor.

        props -- a ClassGroupProperties object. The class is copied if
                 prop is not None. Otherwise, a default set of properties
                 is created such that: line color = Black, line width = 1,
                 size = 5 and fill color = Transparent
        """

        if props is not None:
            self.SetProperties(props)
        else:
            self.SetLineColor(Black)
            self.SetLineWidth(1)
            self.SetSize(5)
            self.SetFill(Transparent)

    def SetProperties(self, props):
        """Set this class's properties to those in class props."""

        assert isinstance(props, ClassGroupProperties)
        self.SetLineColor(props.GetLineColor())
        self.SetLineWidth(props.GetLineWidth())
        self.SetSize(props.GetSize())
        self.SetFill(props.GetFill())

    def GetLineColor(self):
        """Return the line color as a Color object."""
        return self.__stroke

    def SetLineColor(self, color):
        """Set the line color.

        color -- the color of the line. This must be a Color object.
        """

        self.__stroke = color

    def GetLineWidth(self):
        """Return the line width."""
        return self.__strokeWidth

    def SetLineWidth(self, lineWidth):
        """Set the line width.

        lineWidth -- the new line width. This must be > 0.
        """
        assert isinstance(lineWidth, types.IntType)
        if (lineWidth < 1):
            raise ValueError(_("lineWidth < 1"))

        self.__strokeWidth = lineWidth

    def GetSize(self):
        """Return the size."""
        return self.__size

    def SetSize(self, size):
        """Set the size.

        size -- the new size. This must be > 0.
        """
        assert isinstance(size, types.IntType)
        if (size < 1):
            raise ValueError(_("size < 1"))

        self.__size = size

    def GetFill(self):
        """Return the fill color as a Color object."""
        return self.__fill

    def SetFill(self, fill):
        """Set the fill color.

        fill -- the color of the fill. This must be a Color object.
        """

        self.__fill = fill

    def __eq__(self, other):
        """Return true if 'props' has the same attributes as this class"""

        #
        # using 'is' over '==' results in a huge performance gain
        # in the renderer
        #
        return isinstance(other, ClassGroupProperties)   \
            and (self.__stroke is other.__stroke or      \
                 self.__stroke == other.__stroke)        \
            and (self.__fill is other.__fill or          \
                 self.__fill == other.__fill)            \
            and self.__strokeWidth == other.__strokeWidth\
            and self.__size == other.__size

    def __ne__(self, other): 
        return not self.__eq__(other)

    def __copy__(self):
        return ClassGroupProperties(self)

    def __deepcopy__(self):
        return ClassGroupProperties(self)

    def __repr__(self):
        return repr((self.__stroke, self.__strokeWidth, self.__size,
                    self.__fill))

class ClassGroup:
    """A base class for all Groups within a Classification"""

    def __init__(self, label = "", props = None, group = None):
        """Constructor.

        label -- A string representing the Group's label
        """

        if group is not None:
            self.SetLabel(copy.copy(group.GetLabel()))
            self.SetProperties(copy.copy(group.GetProperties()))
            self.SetVisible(group.IsVisible())
        else:
            self.SetLabel(label)
            self.SetProperties(props)
            self.SetVisible(True)

    def GetLabel(self):
        """Return the Group's label."""
        return self.label

    def SetLabel(self, label):
        """Set the Group's label.

        label -- a string representing the Group's label. This must
                 not be None.
        """
        assert isinstance(label, types.StringTypes)
        self.label = label

    def GetDisplayText(self):
        assert False, "GetDisplay must be overridden by subclass!"
        return ""

    def Matches(self, value):
        """Determines if this Group is associated with the given value.

        Returns False. This needs to be overridden by all subclasses.
        """
        assert False, "GetMatches must be overridden by subclass!"
        return False

    def GetProperties(self):
        """Return the properties associated with the given value."""

        return self.prop
 
    def SetProperties(self, prop):
        """Set the properties associated with this Group.

        prop -- a ClassGroupProperties object. if prop is None, 
                a default set of properties is created.
        """

        if prop is None: prop = ClassGroupProperties()
        assert isinstance(prop, ClassGroupProperties)
        self.prop = prop

    def IsVisible(self):
        return self.visible

    def SetVisible(self, visible):
        self.visible = visible

    def __eq__(self, other):
        return isinstance(other, ClassGroup) \
            and self.label == other.label \
            and self.GetProperties() == other.GetProperties()

    def __ne__(self, other):
        return not self.__eq__(other)

    def __repr__(self):
        return repr(self.label) + ", " + repr(self.GetProperties())
    
class ClassGroupSingleton(ClassGroup):
    """A Group that is associated with a single value."""

    def __init__(self, value = 0, props = None, label = "", group = None):
        """Constructor.

        value -- the associated value.

        prop -- a ClassGroupProperites object. If prop is None a default
                 set of properties is created.

        label -- a label for this group.
        """
        ClassGroup.__init__(self, label, props, group)

        self.SetValue(value)

    def __copy__(self):
        return ClassGroupSingleton(self.GetValue(), 
                                   self.GetProperties(), 
                                   self.GetLabel())

    def __deepcopy__(self, memo):
        return ClassGroupSingleton(self.GetValue(), group = self)

    def GetValue(self):
        """Return the associated value."""
        return self.__value

    def SetValue(self, value):
        """Associate this Group with the given value."""
        self.__value = value

    def Matches(self, value):
        """Determine if the given value matches the associated Group value."""

        """Returns True if the value matches, False otherwise."""

        return self.__value == value

    def GetDisplayText(self):
        label = self.GetLabel()

        if label != "": return label

        return str(self.GetValue())

    def __eq__(self, other):
        return ClassGroup.__eq__(self, other) \
            and isinstance(other, ClassGroupSingleton) \
            and self.__value == other.__value

    def __repr__(self): 
        return "(" + repr(self.__value) + ", " + ClassGroup.__repr__(self) + ")"

class ClassGroupDefault(ClassGroup):
    """The default Group. When values do not match any other
       Group within a Classification, the properties from this
       class are used."""

    def __init__(self, props = None, label = "", group = None):
        """Constructor.

        prop -- a ClassGroupProperites object. If prop is None a default
                 set of properties is created.

        label -- a label for this group.
        """

        ClassGroup.__init__(self, label, props, group)

    def __copy__(self):
        return ClassGroupDefault(self.GetProperties(), self.GetLabel())

    def __deepcopy__(self, memo):
        return ClassGroupDefault(label = self.GetLabel(), group = self)

    def Matches(self, value):
        return True

    def GetDisplayText(self):
        label = self.GetLabel()

        if label != "": return label

        return _("DEFAULT")

    def __eq__(self, other):
        return ClassGroup.__eq__(self, other) \
            and isinstance(other, ClassGroupDefault) \
            and self.GetProperties() == other.GetProperties()

    def __repr__(self): 
        return "(" + ClassGroup.__repr__(self) + ")"

class ClassGroupRange(ClassGroup):
    """A Group that represents a range of values that map to the same
       set of properties."""

    def __init__(self, _range = (0,1), props = None, label = "", group=None):
        """Constructor.

        The minumum value must be strictly less than the maximum.

        _range -- either a tuple (min, max) where min < max or
                  a Range object

        prop -- a ClassGroupProperites object. If prop is None a default
                 set of properties is created.

        label -- a label for this group.
        """

        ClassGroup.__init__(self, label, props, group)
        self.SetRange(_range)

    def __copy__(self):
        return ClassGroupRange(self.__range,
                               props = self.GetProperties(), 
                               label = self.GetLabel())

    def __deepcopy__(self, memo):
        return ClassGroupRange(copy.copy(self.__range), 
                               group = self)

    def GetMin(self):
        """Return the range's minimum value."""
        return self.__range.GetRange()[1]

    def SetMin(self, min):
        """Set the range's minimum value.
     
        min -- the new minimum. Note that this must be less than the current
               maximum value. Use SetRange() to change both min and max values.
        """
     
        self.SetRange((min, self.__range.GetRange()[2]))

    def GetMax(self):
        """Return the range's maximum value."""
        return self.__range.GetRange()[2]

    def SetMax(self, max):
        """Set the range's maximum value.
     
        max -- the new maximum. Note that this must be greater than the current
               minimum value. Use SetRange() to change both min and max values.
        """
        self.SetRange((self.__range.GetRange()[1], max))

    def SetRange(self, _range):
        """Set a new range.

        _range -- Either a tuple (min, max) where min < max or
                  a Range object.

        Raises ValueError on error.
        """

        if isinstance(_range, Range):
            self.__range = _range
        elif isinstance(_range, types.TupleType) and len(_range) == 2:
            self.__range = Range(("[", _range[0], _range[1], "["))
        else:
            raise ValueError()

    def GetRange(self):
        """Return the range as a string"""
        return self.__range.string(self.__range.GetRange())

    def GetRangeTuple(self):
        return self.__range.GetRange()

    def Matches(self, value):
        """Determine if the given value lies with the current range.

        The following check is used: min <= value < max.
        """

        return operator.contains(self.__range, value)

    def GetDisplayText(self):
        label = self.GetLabel()

        if label != "": return label

        return self.__range.string(self.__range.GetRange())

    def __eq__(self, other):
        return ClassGroup.__eq__(self, other) \
            and isinstance(other, ClassGroupRange) \
            and self.__range == other.__range

    def __repr__(self): 
        return "(" + str(self.__range) + ClassGroup.__repr__(self) + ")"

class ClassGroupMap(ClassGroup):
    """Currently, this class is not used."""

    FUNC_ID = "id"

    def __init__(self, map_type = FUNC_ID, func = None, prop = None, label=""):
        ClassGroup.__init__(self, label)

        self.map_type = map_type
        self.func = func

        if self.func is None:
            self.func = func_id

    def Map(self, value):
        return self.func(value)

    def GetProperties(self):
        return None

    def GetPropertiesFromValue(self, value):
        pass

    def GetDisplayText(self):
        return "Map: " + self.map_type

    #
    # built-in mappings
    #
    def func_id(value):
        return value

