# -*- encoding: utf-8 -*-
#
#
# Copyright (C) 2002-2004, 2022 Jörg Lehmann <joerg@pyx-project.org>
# Copyright (C) 2003-2004 Michael Schindler <m-schindler@users.sourceforge.net>
# Copyright (C) 2002-2012, 2022 André Wobst <wobsta@pyx-project.org>
#
# This file is part of PyX (https://pyx-project.org/).
#
# PyX 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.
#
# PyX 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 PyX; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA


from pyx import unit, box, utils
from pyx.graph.axis import tick


# rater
# conceptual remarks:
# - raters are used to calculate a rating for a realization of something
# - a rating means a positive floating point value
# - ratings are used to order those realizations by their suitability
#   (small ratings are better)
# - a rating of None means not suitable at all (those realizations should be
#   thrown out)


class cube:
    """a value rater
    - a cube rater has an optimal value, where the rating becomes zero
    - for a left (below the optimum) and a right value (above the optimum),
      the rating is value is set to 1 (modified by an overall weight factor
      for the rating)
    - the analytic form of the rating is cubic for both, the left and
      the right side of the rater, independently"""

    def __init__(self, opt, left=None, right=None, weight=1):
        """initializes the rater
        - by default, left is set to zero, right is set to 3*opt
        - left should be smaller than opt, right should be bigger than opt
        - weight should be positive and is a factor multiplicated to the rates"""
        if left is None:
            left = 0
        if right is None:
            right = 3*opt
        self.opt = opt
        self.left = left
        self.right = right
        self.weight = weight

    def rate(self, value, density):
        """returns a rating for a value
        - the density lineary rescales the rater (the optimum etc.),
          e.g. a value bigger than one increases the optimum (when it is
          positive) and a value lower than one decreases the optimum (when
          it is positive); the density itself should be positive"""
        opt = self.opt * density
        if value < opt:
            other = self.left * density
        elif value > opt:
            other = self.right * density
        else:
            return 0
        factor = (value - opt) / float(other - opt)
        return self.weight * (factor ** 3)


class distance:
    # TODO: update docstring
    """a distance rater (rates a list of distances)
    - the distance rater rates a list of distances by rating each independently
      and returning the average rate
    - there is an optimal value, where the rate becomes zero
    - the analytic form is linary for values above the optimal value
      (twice the optimal value has the rating one, three times the optimal
      value has the rating two, etc.)
    - the analytic form is reciprocal subtracting one for values below the
      optimal value (halve the optimal value has the rating one, one third of
      the optimal value has the rating two, etc.)"""

    def __init__(self, opt, weight=0.1):
        """inititializes the rater
        - opt is the optimal length (a visual PyX length)
        - weight should be positive and is a factor multiplicated to the rates"""
        self.opt = opt
        self.weight = weight

    def rate(self, distances, density):
        """rate distances
        - the distances are a list of positive floats in PostScript points
        - the density lineary rescales the rater (the optimum etc.),
          e.g. a value bigger than one increases the optimum (when it is
          positive) and a value lower than one decreases the optimum (when
          it is positive); the density itself should be positive"""
        if len(distances):
            opt = unit.topt(self.opt) / density
            rate = 0
            for distance in distances:
                if distance < opt:
                    rate += self.weight * (opt / distance - 1)
                else:
                    rate += self.weight * (distance / opt - 1)
            return rate / float(len(distances))


class rater:
    """a rater for ticks
    - the rating of axes is splited into two separate parts:
      - rating of the ticks in terms of the number of ticks, subticks,
        labels, etc.
      - rating of the label distances
    - in the end, a rate for ticks is the sum of these rates
    - it is useful to first just rate the number of ticks etc.
      and selecting those partitions, where this fits well -> as soon
      as an complete rate (the sum of both parts from the list above)
      of a first ticks is below a rate of just the number of ticks,
      subticks labels etc. of other ticks, those other ticks will never
      be better than the first one -> we gain speed by minimizing the
      number of ticks, where label distances have to be taken into account)
    - both parts of the rating are shifted into instances of raters
      defined above --- right now, there is not yet a strict interface
      for this delegation (should be done as soon as it is needed)"""

    def __init__(self, ticks, labels, range,
                 distance=distance(1*unit.v_cm), density=1):
        """initializes the axis rater
        - ticks and labels are lists of instances of a value rater
        - the first entry in ticks rate the number of ticks, the
          second the number of subticks, etc.; when there are no
          ticks of a level or there is not rater for a level, the
          level is just ignored
        - labels is analogous, but for labels
        - within the rating, all ticks with a higher level are
          considered as ticks for a given level
        - range is a value rater instance, which rates the covering
          of an axis range by the ticks (as a relative value of the
          tick range vs. the axis range), ticks might cover less or
          more than the axis range (for the standard automatic axis
          partition schemes an extention of the axis range is normal
          and should get some penalty)
        - distance is an distance rater instance"""
        self.ticks = ticks
        self.labels = labels
        self.range = range
        self.distance = distance
        self.density = density

    def __call__(self, **kwargs):
        return rater(**utils.merge_members_kwargs(self, [kwargs],
                                                 ["ticks", "labels", "range", "distance", "density"]))

    def rateticks(self, axis, ticks):
        """rates ticks by the number of ticks, subticks, labels etc.
        - takes into account the number of ticks, subticks, labels
          etc. and the coverage of the axis range by the ticks
        - when there are no ticks of a level or there was not rater
          given in the constructor for a level, the level is just
          ignored
        - the method returns the sum of the rating results divided
          by the sum of the weights of the raters
        - within the rating, all ticks with a higher level are
          considered as ticks for a given level"""
        maxticklevel, maxlabellevel = tick.maxlevels(ticks)
        if not maxticklevel and not maxlabellevel:
            return None
        numticks = [0]*maxticklevel
        numlabels = [0]*maxlabellevel
        for t in ticks:
            if t.ticklevel is not None:
                for level in range(t.ticklevel, maxticklevel):
                    numticks[level] += 1
            if t.labellevel is not None:
                for level in range(t.labellevel, maxlabellevel):
                    numlabels[level] += 1
        rate = 0
        weight = 0
        for numtick, rater in zip(numticks, self.ticks):
            rate += rater.rate(numtick, self.density)
            weight += rater.weight
        for numlabel, rater in zip(numlabels, self.labels):
            rate += rater.rate(numlabel, self.density)
            weight += rater.weight
        return rate/weight

    def raterange(self, tickrange, datarange):
        """rate the range covered by the ticks compared to the range
        of the data
        - tickrange and datarange are the ranges covered by the ticks
          and the data in graph coordinates
        - usually, the datarange is 1 (ticks are calculated for a
          given datarange)
        - the ticks might cover less or more than the data range (for
          the standard automatic axis partition schemes an extention
          of the axis range is normal and should get some penalty)"""
        return self.range.rate(tickrange, datarange)

    def ratelayout(self, axiscanvas):
        """rate distances of the labels in an axis canvas
        - the distances should be collected as box distances of
          subsequent labels
        - the axiscanvas provides a labels attribute for easy
          access to the labels whose distances have to be taken
          into account"""
        if axiscanvas.labels is None: # to disable any layout rating
            return 0
        if len(axiscanvas.labels) > 1:
            try:
                distances = [axiscanvas.labels[i].boxdistance_pt(axiscanvas.labels[i+1])
                             for i in range(len(axiscanvas.labels) - 1)]
            except box.BoxCrossError:
                return None
            return self.distance.rate(distances, self.density)
        else:
            return None


class linear(rater):
    """a rater with predefined constructor arguments suitable for a linear axis"""

    def __init__(self, ticks=[cube(4), cube(10, weight=0.5)],
                       labels=[cube(4)],
                       range=cube(1, weight=2), **kwargs):
        rater.__init__(self, ticks, labels, range, **kwargs)

lin = linear


class logarithmic(rater):
    """a rater with predefined constructor arguments suitable for a logarithmic axis"""

    def __init__(self, ticks=[cube(5, right=20), cube(20, right=100, weight=0.5)],
                       labels=[cube(5, right=20), cube(5, right=20, weight=0.5)],
                       range=cube(1, weight=2), **kwargs):
        rater.__init__(self, ticks, labels, range, **kwargs)

log = logarithmic
