#!/usr/bin/env python
#
# Copyright 2008 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Code common to all chart types."""

import copy
import warnings

from mapreduce.lib.graphy import formatters
from mapreduce.lib.graphy import util


class Marker(object):

  """Represents an abstract marker, without position.  You can attach these to
  a DataSeries.

  Object attributes:
    shape: One of the shape codes (Marker.arrow, Marker.diamond, etc.)
    color: color (as hex string, f.ex. '0000ff' for blue)
    size:  size of the marker
  """
  # TODO: Write an example using markers.

  # Shapes:
  arrow = 'a'
  cross = 'c'
  diamond = 'd'
  circle = 'o'
  square = 's'
  x = 'x'

  # Note: The Google Chart API also knows some other markers ('v', 'V', 'r',
  # 'b') that I think would fit better into a grid API.
  # TODO: Make such a grid API

  def __init__(self, shape, color, size):
    """Construct a Marker.  See class docstring for details on args."""
    # TODO: Shapes 'r' and 'b' would be much easier to use if they had a
    # special-purpose API (instead of trying to fake it with markers)
    self.shape = shape
    self.color = color
    self.size = size


class _BasicStyle(object):
  """Basic style object.  Used internally."""

  def __init__(self, color):
    self.color = color


class DataSeries(object):

  """Represents one data series for a chart (both data & presentation
  information).

  Object attributes:
    points:  List of numbers representing y-values (x-values are not specified
             because the Google Chart API expects even x-value spacing).
    label:   String with the series' label in the legend.  The chart will only
             have a legend if at least one series has a label.  If some series
             do not have a label then they will have an empty description in
             the legend.  This is currently a limitation in the Google Chart
             API.
    style:   A chart-type-specific style object.  (LineStyle for LineChart,
             BarsStyle for BarChart, etc.)
    markers: List of (x, m) tuples where m is a Marker object and x is the
             x-axis value to place it at.

             The "fill" markers ('r' & 'b') are a little weird because they
             aren't a point on a line.  For these, you can fake it by
             passing slightly weird data (I'd like a better API for them at
             some point):
               For 'b', you attach the marker to the starting series, and set x
               to the index of the ending line.  Size is ignored, I think.

               For 'r', you can attach to any line, specify the starting
               y-value for x and the ending y-value for size.  Y, in this case,
               is becase 0.0 (bottom) and 1.0 (top).
    color:   DEPRECATED
  """

  # TODO: Should we require the points list to be non-empty ?
  # TODO: Do markers belong here?  They are really only used for LineCharts
  def __init__(self, points, label=None, style=None, markers=None, color=None):
    """Construct a DataSeries.  See class docstring for details on args."""
    if label is not None and util._IsColor(label):
      warnings.warn('Your code may be broken! Label is a hex triplet.  Maybe '
                    'it is a color? The old argument order (color & style '
                    'before label) is deprecated.', DeprecationWarning,
                    stacklevel=2)
    if color is not None:
      warnings.warn('Passing color is deprecated.  Pass a style object '
                    'instead.', DeprecationWarning, stacklevel=2)
      # Attempt to fix it for them.  If they also passed a style, honor it.
      if style is None:
        style = _BasicStyle(color)
    if style is not None and isinstance(style, basestring):
      warnings.warn('Your code is broken! Style is a string, not an object. '
                    'Maybe you are passing a color?  Passing color is '
                    'deprecated; pass a style object instead.',
                    DeprecationWarning, stacklevel=2)
    if style is None:
      style = _BasicStyle(None)
    self.data = points
    self.style = style
    self.markers = markers or []
    self.label = label

  def _GetColor(self):
    warnings.warn('DataSeries.color is deprecated, use '
                  'DataSeries.style.color instead.', DeprecationWarning,
                  stacklevel=2)
    return self.style.color

  def _SetColor(self, color):
    warnings.warn('DataSeries.color is deprecated, use '
                  'DataSeries.style.color instead.', DeprecationWarning,
                  stacklevel=2)
    self.style.color = color

  color = property(_GetColor, _SetColor)


class AxisPosition(object):
  """Represents all the available axis positions.

  The available positions are as follows:
    AxisPosition.TOP
    AxisPosition.BOTTOM
    AxisPosition.LEFT
    AxisPosition.RIGHT
  """
  LEFT = 'y'
  RIGHT = 'r'
  BOTTOM = 'x'
  TOP = 't'


class Axis(object):

  """Represents one axis.

  Object setings:
    min:    Minimum value for the bottom or left end of the axis
    max:    Max value.
    labels: List of labels to show along the axis.
    label_positions: List of positions to show the labels at.  Uses the scale
                     set by min & max, so if you set min = 0 and max = 10, then
                     label positions [0, 5, 10] would be at the bottom,
                     middle, and top of the axis, respectively.
    grid_spacing:  Amount of space between gridlines (in min/max scale).
                   A value of 0 disables gridlines.
    label_gridlines: If True, draw a line extending from each label
                   on the axis all the way across the chart.
  """

  def __init__(self, axis_min=None, axis_max=None):
    """Construct a new Axis.

    Args:
      axis_min: smallest value on the axis
      axis_max: largest value on the axis
    """
    self.min = axis_min
    self.max = axis_max
    self.labels = []
    self.label_positions = []
    self.grid_spacing = 0
    self.label_gridlines = False

# TODO: Add other chart types.  Order of preference:
# - scatter plots
# - us/world maps

class BaseChart(object):
  """Base chart object with standard behavior for all other charts.

  Object attributes:
    data: List of DataSeries objects. Chart subtypes provide convenience
          functions (like AddLine, AddBars, AddSegment) to add more series
          later.
    left/right/bottom/top: Axis objects for the 4 different axes.
    formatters: A list of callables which will be used to format this chart for
                display.  TODO: Need better documentation for how these
                work.
    auto_scale, auto_color, auto_legend:
      These aliases let users access the default formatters without poking
      around in self.formatters.  If the user removes them from
      self.formatters then they will no longer be enabled, even though they'll
      still be accessible through the aliases.  Similarly, re-assigning the
      aliases has no effect on the contents of self.formatters.
    display: This variable is reserved for backends to populate with a display
             object.  The intention is that the display object would be used to
             render this chart.  The details of what gets put here depends on
             the specific backend you are using.
  """

  # Canonical ordering of position keys
  _POSITION_CODES = 'yrxt'

  # TODO: Add more inline args to __init__ (esp. labels).
  # TODO: Support multiple series in the constructor, if given.
  def __init__(self):
    """Construct a BaseChart object."""
    self.data = []

    self._axes = {}
    for code in self._POSITION_CODES:
      self._axes[code] = [Axis()]
    self._legend_labels = []  # AutoLegend fills this out
    self._show_legend = False  # AutoLegend fills this out

    # Aliases for default formatters
    self.auto_color = formatters.AutoColor()
    self.auto_scale = formatters.AutoScale()
    self.auto_legend = formatters.AutoLegend
    self.formatters = [self.auto_color, self.auto_scale, self.auto_legend]
    # display is used to convert the chart into something displayable (like a
    # url or img tag).
    self.display = None

  def AddFormatter(self, formatter):
    """Add a new formatter to the chart (convenience method)."""
    self.formatters.append(formatter)

  def AddSeries(self, points, color=None, style=None, markers=None,
                label=None):
    """DEPRECATED

    Add a new series of data to the chart; return the DataSeries object."""
    warnings.warn('AddSeries is deprecated.  Instead, call AddLine for '
                  'LineCharts, AddBars for BarCharts, AddSegment for '
                  'PieCharts ', DeprecationWarning, stacklevel=2)
    series = DataSeries(points, color=color, style=style, markers=markers,
                        label=label)
    self.data.append(series)
    return series

  def GetDependentAxes(self):
    """Return any dependent axes ('left' and 'right' by default for LineCharts,
    although bar charts would use 'bottom' and 'top').
    """
    return self._axes[AxisPosition.LEFT] + self._axes[AxisPosition.RIGHT]

  def GetIndependentAxes(self):
    """Return any independent axes (normally top & bottom, although horizontal
    bar charts use left & right by default).
    """
    return self._axes[AxisPosition.TOP] + self._axes[AxisPosition.BOTTOM]

  def GetDependentAxis(self):
    """Return this chart's main dependent axis (often 'left', but
    horizontal bar-charts use 'bottom').
    """
    return self.left

  def GetIndependentAxis(self):
    """Return this chart's main independent axis (often 'bottom', but
    horizontal bar-charts use 'left').
    """
    return self.bottom

  def _Clone(self):
    """Make a deep copy this chart.

    Formatters & display will be missing from the copy, due to limitations in
    deepcopy.
    """
    orig_values = {}
    # Things which deepcopy will likely choke on if it tries to copy.
    uncopyables = ['formatters', 'display', 'auto_color', 'auto_scale',
                   'auto_legend']
    for name in uncopyables:
      orig_values[name] = getattr(self, name)
      setattr(self, name, None)
    clone = copy.deepcopy(self)
    for name, orig_value in orig_values.iteritems():
      setattr(self, name, orig_value)
    return clone

  def GetFormattedChart(self):
    """Get a copy of the chart with formatting applied."""
    # Formatters need to mutate the chart, but we don't want to change it out
    # from under the user.  So, we work on a copy of the chart.
    scratchpad = self._Clone()
    for formatter in self.formatters:
      formatter(scratchpad)
    return scratchpad

  def GetMinMaxValues(self):
    """Get the largest & smallest values in this chart, returned as
    (min_value, max_value).  Takes into account complciations like stacked data
    series.

    For example, with non-stacked series, a chart with [1, 2, 3] and [4, 5, 6]
    would return (1, 6).  If the same chart was stacking the data series, it
    would return (5, 9).
    """
    MinPoint = lambda data: min(x for x in data if x is not None)
    MaxPoint = lambda data: max(x for x in data if x is not None)
    mins  = [MinPoint(series.data) for series in self.data if series.data]
    maxes = [MaxPoint(series.data) for series in self.data if series.data]
    if not mins or not maxes:
      return None, None # No data, just bail.
    return min(mins), max(maxes)

  def AddAxis(self, position, axis):
    """Add an axis to this chart in the given position.

    Args:
      position: an AxisPosition object specifying the axis's position
      axis: The axis to add, an Axis object
    Returns:
      the value of the axis parameter
    """
    self._axes.setdefault(position, []).append(axis)
    return axis

  def GetAxis(self, position):
    """Get or create the first available axis in the given position.

    This is a helper method for the left, right, top, and bottom properties.
    If the specified axis does not exist, it will be created.

    Args:
      position: the position to search for
    Returns:
      The first axis in the given position
    """
    # Not using setdefault here just in case, to avoid calling the Axis()
    # constructor needlessly
    if position in self._axes:
      return self._axes[position][0]
    else:
      axis = Axis()
      self._axes[position] = [axis]
      return axis

  def SetAxis(self, position, axis):
    """Set the first axis in the given position to the given value.

    This is a helper method for the left, right, top, and bottom properties.

    Args:
      position: an AxisPosition object specifying the axis's position
      axis: The axis to set, an Axis object
    Returns:
      the value of the axis parameter
    """
    self._axes.setdefault(position, [None])[0] = axis
    return axis

  def _GetAxes(self):
    """Return a generator of (position_code, Axis) tuples for this chart's axes.

    The axes will be sorted by position using the canonical ordering sequence,
    _POSITION_CODES.
    """
    for code in self._POSITION_CODES:
      for axis in self._axes.get(code, []):
        yield (code, axis)

  def _GetBottom(self):
    return self.GetAxis(AxisPosition.BOTTOM)

  def _SetBottom(self, value):
    self.SetAxis(AxisPosition.BOTTOM, value)

  bottom = property(_GetBottom, _SetBottom,
                    doc="""Get or set the bottom axis""")

  def _GetLeft(self):
    return self.GetAxis(AxisPosition.LEFT)

  def _SetLeft(self, value):
    self.SetAxis(AxisPosition.LEFT, value)

  left = property(_GetLeft, _SetLeft,
                  doc="""Get or set the left axis""")

  def _GetRight(self):
    return self.GetAxis(AxisPosition.RIGHT)

  def _SetRight(self, value):
    self.SetAxis(AxisPosition.RIGHT, value)

  right = property(_GetRight, _SetRight,
                   doc="""Get or set the right axis""")

  def _GetTop(self):
    return self.GetAxis(AxisPosition.TOP)

  def _SetTop(self, value):
    self.SetAxis(AxisPosition.TOP, value)

  top = property(_GetTop, _SetTop,
                 doc="""Get or set the top axis""")
