# -*- encoding: utf-8 -*-
#   Copyright 2009 Agile42 GmbH, Berlin (Germany)
#   
#   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.
#
#   Authors:
#        - Felix Schwarz <felix.schwarz__at__agile42.com>
#        - Andrea Tomasini <andrea.tomasini__at__agile42.com>
#        - Jonas von Poser <jonas.vonposer__at__agile42.com>

from datetime import date, datetime, timedelta

from trac.core import Component, implements
from trac.util.datefmt import to_datetime, to_timestamp, utc
from trac.util.translation import _

from agilo.charts.api import IAgiloWidgetGenerator
from agilo.scrum.backlog.controller import BacklogController
from agilo.scrum.charts import ChartType, ScrumFlotChartWidget
from agilo.scrum.team.controller import TeamController
from agilo.utils import BacklogType, Key
from agilo.utils.days_time import unicode_strftime, midnight_with_utc_shift
from agilo.scrum.sprint.controller import SprintController

__all__ = ["BurndownChartGenerator"]


class BurndownChartGenerator(Component):
    
    implements(IAgiloWidgetGenerator)
    
    name = ChartType.BURNDOWN
    
    def can_generate_widget(self, name):
        return (name == self.name)
    
    def _push_today_color_if_available(self, widget, kwargs):
        if 'BACK_COLOR' in kwargs:
            widget.update_data(today_color=kwargs['BACK_COLOR'])
    
    def get_cache_components(self, keys):
        return ('name', 'sprint_name', 'filter_by')
    
    def generate_widget(self, name, **kwargs):
        burndown_widget = BurndownWidget(self.env)
        if 'filter_by' in kwargs:
            burndown_widget.update_data(filter_by=kwargs['filter_by'])
        self._push_today_color_if_available(burndown_widget, kwargs)
        if 'sprint_name' in kwargs:
            if 'cached_data' in kwargs:
                burndown_widget.update_data(cached_data=kwargs['cached_data'])
            sprint_name = kwargs.get('sprint_name')
            burndown_widget.populate_with_sprint_data(sprint_name)
        return burndown_widget
    
    def get_backlog_information(self):
        # Charts are a layer above the burndown so this module must not import
        # agilo.scrum.backlog globally
        return {self.name: (BacklogType.SPRINT,)}


# ----------------------------------------------------------------------------
# Calculation for 

def calculate_ideal_burndown(capacity_data, committed_hours):
    """Calculates the data for the ideal burndown, based on the actual
    capacity for the sprint, given the initial team commitment."""
    assert len(capacity_data) > 0 and capacity_data[0] != 0, \
        "Please provide a non empty list for capacity_data"
    factor = 1 + (committed_hours - capacity_data[0]) / capacity_data[0]
    # Returns the calculated idea data
    return [factor * d for d in capacity_data]

def extrapolate(date_range, data):
    """
    If data doesn't cover the date range, extrapolate from last delta
    """
    if len(data) <= len(date_range):
        d_last = float(data[-1])
        d_previous = float(data[-2])
        delta = d_previous - d_last

        nr_missing_items = len(date_range)-len(data)
        for dday in range(nr_missing_items):
            d_last -= delta
            if d_last < 0:
                d_last = 0
            data.append(d_last)
    return data

def _calculate_moving_average(x, n):
    """Replacement for matplotlib's movavg function which calculates the moving 
    average for all items of x (window size is n)"""
    if len(x) == 0:
        raise ValueError
    mvg_average = []
    data_window = x[:n-1]
    for i in range(n-1, len(x)):
        data_window.append(x[i])
        new_average = float(sum(data_window)) / n
        mvg_average.append(new_average)
        data_window.pop(0)
    return mvg_average

def calculate_trend_series(data, dates):
    avg_gap = 2
    if len(data) <= avg_gap:
        return []
    trend = [data[0]] + _calculate_moving_average(data, avg_gap)
    return extrapolate(dates, trend)
# ------------------------------------------------------------------------------


# Usage:
# from agilo.utils.widgets import BurndownWidget
# burndown = BurndownWidget(self.env)
# ${burndown.prepare_renderung(req)}
# ${burndown.display()}

class BurndownWidget(ScrumFlotChartWidget):
    """Burndown chart widget which generates HTML and JS code so that Flot
    can generate a burndown chart for the sprint including actual data, ideal 
    burndown and moving average."""
    
    default_width =  750
    default_height = 350
    
    def __init__(self, env, **kwargs):
        template_filename = 'scrum/backlog/templates/agilo_burndown_chart.html'
        self._define_chart_resources(env, template_filename, kwargs)
        super(BurndownWidget, self).__init__(env, template_filename, **kwargs)
        # Creates a ref to a TeamController
        self.t_controller = TeamController(self.env)
        self.sp_controller = SprintController(self.env)
        self.b_controller = BacklogController(self.env)
    
    def _datetime_to_js_milliseconds(self, datetime_date, tz):
        """Convert a datetime into milliseconds which are directly usable by 
        flot"""
        # Flot always uses UTC. In order to display the data with the correct 
        # days, we have to move the data to UTC
        
        # "Normally you want the timestamps to be displayed according to a
        # certain time zone, usually the time zone in which the data has been
        # produced. However, Flot always displays timestamps according to UTC.
        # It has to as the only alternative with core Javascript is to interpret
        # the timestamps according to the time zone that the visitor is in,
        # which means that the ticks will shift unpredictably with the time zone
        # and daylight savings of each visitor.
        utc_offset = tz.utcoffset(datetime_date)
        fake_utc_datetime = datetime_date.replace(tzinfo=utc) + utc_offset
        seconds_since_epoch = to_timestamp(fake_utc_datetime)
        milliseconds_since_epoch = seconds_since_epoch * 1000
        return milliseconds_since_epoch
    
    def _convert_to_utc_timeseries(self, start, end, input_data, now=None):
        """Takes a list of input_data and converts it into a list of tuples 
        (datetime in UTC, data). It returns a tuple every 24h from the sprint
        start datetime."""
        utc_data = []
        stop = False
        current_date = start
        for data in input_data:
            if now is not None and current_date > now:
                current_date = now
                # AT: We need to break after the append, we are already at now
                # the last data, if available would be the same exact value as
                # now. This happens in tests because the sprint is created in
                # the same microsecond as the now will be calculated adding two
                # values at last
                stop = True
            elif current_date > end:
                current_date = end
            # AT: the dates should be already UTC, but you never know
            day_data = (current_date.astimezone(utc), data)
            utc_data.append(day_data)
            if stop:
                break
            current_date += timedelta(days=1)
        return utc_data
    
    def now(self, tz=None):
        # FIXME: (AT) this is the now on the server, has no meaning for the
        # user we should use either the UTC now or the user now. The
        # problem is that when we call this we do not have the time
        # zone of the client
        return to_datetime(None, tzinfo=tz)
    
    def _get_today_color(self, sprint, actual_data, ideal_data):
        today_color = self.data.get('today_color')
        if sprint.is_currently_running:
            nr_sprint_days_passed = self.now().toordinal() - sprint.start.toordinal() + 1
            if len(actual_data) > nr_sprint_days_passed and len(ideal_data) > nr_sprint_days_passed:
                remaining_time = actual_data[nr_sprint_days_passed]
                ideal_remaining_time = ideal_data[nr_sprint_days_passed]
                if (ideal_remaining_time == 0) and (remaining_time == 0):
                    pass
                elif (ideal_remaining_time == 0) or \
                    (remaining_time / ideal_remaining_time > 1.3):
                    today_color = '#f35e5e'
                elif remaining_time / ideal_remaining_time > 1.1:
                    today_color = '#e0e63d'
        return today_color
    
    def _get_localized_month_names(self):
        """Return a list of all month names in the current locale as unicode
        strings."""
        localized_month_names = []
        for i in range(1, 13):
            month_name = unicode_strftime(date(2008, i, 1), '%b')
            localized_month_names.append(month_name)
        return localized_month_names
    
    def _get_capacity_data(self, sprint_name):
        """
        Returns the capacity data for this sprint in the form of a list.
        Capacity per day is calculated as the whole team capacity that day,
        removed a proportional amount for the contingent set by the team in 
        this sprint.
        """
        cmd_capacity = TeamController.CalculateDailyCapacityCommand(self.env,
                                                            sprint=sprint_name)
        capacity = self.t_controller.process_command(cmd_capacity)
        result = [sum(capacity[i:]) for i in range(len(capacity))]
        result.append(0)
        return result
    
    def _get_backlog(self, sprint_name):
        backlog = self._get_prefetched_backlog()
        if backlog is None:
            get_backlog = BacklogController.GetBacklogCommand(self.env,
                                                              name=Key.SPRINT_BACKLOG,
                                                              scope=sprint_name,
                                                              reload=True,
                                                              filter_by=self.data.get('filter_by'))
            backlog = self.b_controller.process_command(get_backlog)
        return backlog
    
    def _get_capacity(self, sprint_name, filtered_burndown):
        capacity_data = []
        if not filtered_burndown:
            capacity_data = self._get_capacity_data(sprint_name)
        return capacity_data
    
    def _calculate_ideal_burndown(self, capacity_data, first_remaining_time, nr_days):
        has_remaining_time = (first_remaining_time is not None)
        has_capacity_data = (len(capacity_data) > 0 and capacity_data[0] != 0)
        if has_remaining_time and has_capacity_data:
            ideal_data = calculate_ideal_burndown(capacity_data, first_remaining_time)
        elif has_remaining_time and (nr_days > 1):
            ideal_data = []
            decrement_per_day = float(first_remaining_time) / (nr_days - 1)
            current_value = first_remaining_time
            for i in range(nr_days):
                ideal_data.append(current_value)
                current_value -= decrement_per_day
        else:
            ideal_data = []
        return ideal_data
    
    def get_commitment(self, sprint_name):
        cmd_commitment = TeamController.GetTeamCommitmentCommand(self.env,
                                                                 sprint=sprint_name)
        commitment = self.t_controller.process_command(cmd_commitment)
        return commitment
    
    def get_remaining_time_series(self, sprint_name, backlog, commitment):
        cmd_rem_times = SprintController.GetRemainingTimesCommand(self.env, 
                            sprint=sprint_name, cut_to_today=True, 
                            commitment=commitment, tickets=backlog)
        actual_data = self.sp_controller.process_command(cmd_rem_times)
        return actual_data
    
    def populate_with_sprint_data(self, sprint_name):
        sprint = self._load_sprint(sprint_name)
        if sprint == None:
            return
        
        start_sprint = sprint.start.toordinal()
        end_sprint = sprint.end.toordinal() + 1
        dates = range(start_sprint, end_sprint + 1)
        
        backlog = self._get_backlog(sprint_name)
        filtered_backlog = getattr(backlog, 'filter_by', None) is not None
        
        commitment = self.get_commitment(sprint_name)
        actual_data = self.get_remaining_time_series(sprint_name, backlog, commitment)
        capacity_data = self._get_capacity(sprint_name, filtered_backlog)
        first_remaining_time = commitment
        if first_remaining_time is None and len(actual_data) > 0:
            first_remaining_time = actual_data[0]
        ideal_data = self._calculate_ideal_burndown(capacity_data, first_remaining_time, len(dates))
        
        trend_data = calculate_trend_series(actual_data, dates)
        today_color = self._get_today_color(sprint, actual_data, ideal_data)
        
        now = self.now()
        utc_timeseries = lambda x, y=None: self._convert_to_utc_timeseries(sprint.start, sprint.end, x, y)
        utc_remaining_times = utc_timeseries(actual_data, now)
        utc_ideal_data = utc_timeseries(ideal_data)
        utc_capacity_data = utc_timeseries(capacity_data)
        utc_trend_data = utc_timeseries(trend_data)
        
        localized_month_names = self._get_localized_month_names()
        self.data.update(dict(localized_month_names = localized_month_names,
                              today_color=today_color,
                              utc_remaining_times=utc_remaining_times,
                              utc_ideal_data=utc_ideal_data,
                              utc_capacity_data=utc_capacity_data,
                              utc_trend_data=utc_trend_data, 
                              sprint_start=sprint.start, sprint_end=sprint.end,))
    
    def _convert_to_flot_timeseries(self, data, tz):
        flot_data = []
        for point_in_time, value in data:
            flot_milliseconds = self._datetime_to_js_milliseconds(point_in_time, tz)
            if isinstance(value, datetime):
                value = self._datetime_to_js_milliseconds(value, tz)
            flot_data.append((flot_milliseconds, value))
        return flot_data
    
    def _get_weekend_starts(self, start, end, tz):
        weekend_data = []
        # The start is stored in UTC but the weekend should be drawn
        # localized
        day = start.astimezone(tz)
        while day < end:
            if day.isoweekday() in (6, 7):
                weekend_start = midnight_with_utc_shift(day)
                weekend_end = weekend_start + timedelta(days=1)
                weekend_data.append((weekend_start, weekend_end))
            day += timedelta(days=1)
        return weekend_data
    
    def _get_today_data(self, start, end, tz):
        today_data = []
        now = self.now(tz)
        if start <= now and now <= end:
            # Now is already calculated in the given timezone so we have to get
            # the midnight in that timezone, shifted to UTC time
            today_midnight = midnight_with_utc_shift(now)
            tomorrow_midnight = today_midnight + timedelta(days=1)
            today_data = [(today_midnight, tomorrow_midnight)]
        return today_data
    
    def _add_today_and_weekend_data(self, tz):
        if 'sprint_start' not in self.data or 'sprint_end' not in self.data:
            weekend_data = []
            today_data = []
        else:
            # We can not add today+weekends before because their dates depend 
            # on the timezone
            sprint_start = self.data['sprint_start']
            sprint_end = self.data['sprint_end']
            weekend_data = self._get_weekend_starts(sprint_start, sprint_end, tz)
            today_data = self._get_today_data(sprint_start, sprint_end, tz)
        self.data.update(dict(
            utc_weekend_data = weekend_data,
            utc_today_data = today_data,
        ))
    
    def _convert_utc_times_to_local_timezone(self, tz):
        for key in self.data.keys():
            if key.startswith('utc_'):
                new_key = key[len('utc_'):]
                flot_data = self._convert_to_flot_timeseries(self.data[key], tz)
                self.data[new_key] = flot_data
    
    def prepare_rendering(self, req):
        super(BurndownWidget, self).prepare_rendering(req)
        
        self._add_today_and_weekend_data(req.tz)
        self._convert_utc_times_to_local_timezone(req.tz)


