# -*- coding: utf-8 -*-

from datetime import timedelta, datetime
from dateutil import parser as dateparser
import io
import logging
import numpy as np
import pytz
from typing import Any, Dict, List, Tuple

from django.db import connection
from django.http import HttpRequest, HttpResponse
from django.utils import timezone
from django.shortcuts import get_object_or_404
from django.views.decorators.cache import never_cache

from catmaid.control.common import get_request_bool
from catmaid.control.authentication import requires_user_role
from catmaid.models import Connector, Project, Treenode, Review, UserRole

logger = logging.getLogger(__name__)

try:
    import matplotlib
    # Use a noninteractive backend since most CATMAID instances are headless.
    matplotlib.use('svg')

    import matplotlib.pyplot as plt
    from matplotlib.dates import DateFormatter, DayLocator
    from pylab import figure
    from matplotlib.backends.backend_svg import FigureCanvasSVG
except ImportError:
    logger.warning("CATMAID was unable to load the matplotlib module. "
        "User analytics will not be available")


class Bout(object):
    """ Represents one bout, based on a list of events. The first event ist the
    start date/time, the last event the end.
    """
    def __init__(self, start, end=None):
        self.events = [start]
        if end:
            self.events.append(end)

    def addEvent(self, e):
        """ Increments the event counter.
        """
        self.events.append(e)

    @property
    def nrEvents(self):
        return len(self.events)

    @property
    def start(self):
        return self.events[0]

    @property
    def end(self):
        return self.events[-1]

    def __str__(self):
        return "Bout with %s events [%s, %s]" % \
                (self.nrEvents, self.start, self.end)

@never_cache
@requires_user_role(UserRole.Browse)
def plot_useranalytics(request:HttpRequest, project_id) -> HttpResponse:
    """ Creates an SVG image containing different plots for analzing the
    performance of individual users over time.
    """
    time_zone = pytz.utc

    userid = request.GET.get('userid', None)
    if not (userid and userid.strip()):
        raise ValueError("Need user ID")
    project = get_object_or_404(Project, pk=project_id) if project_id else None
    all_writes = get_request_bool(request.GET, 'all_writes', False)
    maxInactivity = int(request.GET.get('max_inactivity', 3))

    # Get the start date for the query, defaulting to 7 days ago.
    start_date = request.GET.get('start', None)
    if start_date:
        start_date = dateparser.parse(start_date)
        start_date = time_zone.localize(start_date)
    else:
        with timezone.override(time_zone):
            start_date = timezone.now() - timedelta(7)

    # Get the end date for the query, defaulting to now.
    end_date = request.GET.get('end', None)
    if end_date:
        end_date = dateparser.parse(end_date)
        end_date = time_zone.localize(end_date)
    else:
        with timezone.override(time_zone):
            end_date = timezone.now()

    # The API is inclusive and should return stats for the end date as
    # well. The actual query is easier with an exclusive end and therefore
    # the end date is set to the beginning of the next day.
    end_date = end_date + timedelta(days=1)

    if request.user.is_superuser or \
            project and request.user.has_perm('can_browse', project):
        f = generateReport( userid, project_id, maxInactivity, start_date,
                end_date, all_writes )
    else:
        f = generateErrorImage('You lack permissions to view this report.')

    # Use raw text rather than SVG fonts or pathing.
    plt.rcParams['svg.fonttype'] = 'none'
    buf = io.BytesIO()
    plt.savefig(buf, format='svg')
    return HttpResponse(buf.getvalue(), content_type='image/svg+xml')

def eventTimes(user_id, project_id, start_date, end_date, all_writes=True) -> Dict[str, Any]:
    """ Returns a tuple containing a list of tree node edition times, connector
    edition times and tree node review times within the date range specified
    where the editor/reviewer is the given user.
    """
    dr = (start_date, end_date)
    tns = Treenode.objects.filter(
        editor_id=user_id,
        edition_time__range=dr)
    cns = Connector.objects.filter(
        editor_id=user_id,
        edition_time__range=dr)
    rns = Review.objects.filter(
        reviewer_id=user_id,
        review_time__range=dr)

    if project_id:
        tns = tns.filter(project_id=project_id)
        cns = cns.filter(project_id=project_id)
        rns = rns.filter(project_id=project_id)

    tns = tns.values_list('edition_time', flat=True)
    cns = cns.values_list('edition_time', flat=True)
    rns = rns.values_list('review_time', flat=True)

    events = {
        'treenode_events': list(tns),
        'connector_events': list(cns),
        'review_events': list(rns)
    }

    if all_writes:
        if project_id:
            params:Tuple[str, ...] = (start_date, end_date, user_id, project_id)
            project_filter = "AND project_id = %s"
        else:
            params = (start_date, end_date, user_id)
            project_filter = ""

        # Query transaction log. This makes this feature only useful of history
        # tracking is available.
        cursor = connection.cursor()
        cursor.execute(f"""
            SELECT execution_time
            FROM catmaid_transaction_info
            WHERE execution_time >= %s
            AND execution_time <= %s
            AND user_id = %s
            {project_filter}
        """, params)
        events['write_events'] = [r[0] for r in cursor.fetchall()]

    return events

def eventsPerInterval(times, start_date, end_date, interval='day') -> Tuple[np.ndarray, List]:
    """ Creates a histogram of how many events fall into all intervals between
    <start_data> and <end_date>. The interval type can be day, hour and
    halfhour. Returned is a tuple containing two elemens: the histogram and a
    time axis, labeling every bin.
    """
    if interval=='day':
        intervalsPerDay = 1
        secondsPerInterval = 86400
    elif interval=='hour':
        intervalsPerDay = 24
        secondsPerInterval = 3600
    elif interval=='halfhour':
        intervalsPerDay = 48
        secondsPerInterval = 1800
    else:
        raise ValueError('Interval options are day, hour, or halfhour')

    # Generate axis
    daycount = (end_date - start_date).days
    dt = timedelta(0, secondsPerInterval)
    timeaxis = [start_date + n*dt for n in range(intervalsPerDay * daycount)]
    # Calculate bins
    timebins = np.zeros(intervalsPerDay * daycount)
    intervalsPerSecond = 1.0 / secondsPerInterval
    for t in times:
        i = int((t - start_date).total_seconds() * intervalsPerSecond)
        timebins[i] += 1

    return timebins, timeaxis

def activeTimes(alltimes, gapThresh):
    """ Goes through the sorted array of time differences between all events
    stored in <alltimes>. If two events are closer together than <gapThresh>
    minutes, they are counted as events within one bout. A tuple containing a
    list of bout start dates as well as a list with total numbers of events for
    each bout is returned.
    """
    # Sort all events and create a list of (time) differences between them
    alltimes.sort()
    dts = np.diff(alltimes)
    # Threshold between to events to be counted as separate bouts (seconds)
    threshold = 60 * gapThresh
    # Indicates whether we are currently in a bout and since we haven't even
    # looked at the first event, we are initially not.
    bout = None
    # Go through all events
    for i, e in enumerate(alltimes):
        if i > 0 and dts[i-1].total_seconds() < threshold:
            # Increment current bout's event counter and continue with the
            # next element as long as the time difference to the next
            # element is below our threshold.
            bout.addEvent(e) # type: ignore # mypy cannot prove bout will not be None
            continue
        else:
            # Return current bout (not available in first iteration) and create
            # a new one.
            if bout:
                yield bout
            bout = Bout(e)

    # Return last bout, if it hasn't been returned, yet
    if bout:
        yield bout

def activeTimesPerDay(active_bouts) -> Tuple[Any, List]:
    """ Creates a tuple containing the active time in hours for every day
    between the first event of the first bout and the last event of the last
    bout as well as a list with the date for every day.
    """
    # Return right away if there are no bouts
    if not active_bouts:
        return [], []

    # Find first event of first bout
    daystart = active_bouts[0].start.replace(
            hour=0, minute=0, second=0, microsecond=0)
    # Find last event of last bout
    dayend = active_bouts[-1].end
    # Get total number of between first event and last event
    numdays = (dayend - daystart).days + 1
    # Create a list of dates for every day between first and last event
    timeaxis = [daystart.date() + timedelta(d) for d in range(numdays)]

    # Calculate the netto active time for each day
    net_active_time = np.array(np.zeros(numdays))
    for bout in active_bouts:
        active_time = (bout.end - bout.start).total_seconds()
        net_active_time[(bout.start - daystart).days] += active_time

    # Return a tuple containing the active time for every
    # day in hours and the list of days.
    return np.divide(net_active_time, 3600), timeaxis

def singleDayEvents(alltimes, start_hour, end_hour) -> Tuple[Any, List]:
    alltimes.sort()
    timeaxis = [n for n in np.add(start_hour,range(end_hour-start_hour+1))]
    activity = np.zeros(end_hour-start_hour+1)
    for a in alltimes:
        if a.hour >= start_hour:
            if a.hour < end_hour:
                activity[a.hour-start_hour] += 1
    return np.true_divide(activity, (alltimes[-1] - alltimes[0]).days), timeaxis

def singleDayActiveness(activebouts, increment, start_hour, end_hour) -> Tuple[Any, Any]:
    """ Returns a ... for all bouts between <start_hour> and <end_hour> of the
    day.
    """
    # Return right away, when there are no bouts given
    if not activebouts:
        return [], []
    # Make sure 60 can be cleanly devided by <incement>
    if np.mod(60, increment) > 0:
        raise ValueError('Increments must divide 60 evenly')

    # Some constants
    stepsPerHour = 60 / increment
    hoursConsidered = (end_hour - start_hour) + 1
    daysConsidered = (activebouts[-1].end - activebouts[0].start).days + 1

    # Get start of current day
    starttime = timezone.now().replace(hour=start_hour,minute=0,second=0,microsecond=0)
    # Create time axis list with entry for every <increment> minutes between
    # <start_hour> and <end_hour>.
    timeaxis = [starttime + timedelta(0, 0, 0, 0, n * increment) \
            for n in range(stepsPerHour * hoursConsidered)]

    # Loop through all days considered to find number of weekend days
    weekendCorrection = 0
    for d in range(daysConsidered):
        # TODO: Why is 0 and 6 used for comparison?
        saturday = (activebouts[0].start + timedelta(d)).isoweekday() == 0
        sunday = (activebouts[0].start + timedelta(d)).isoweekday() == 6
        if saturday or sunday:
            weekendCorrection += 1

    # Initialize list for minutes per period with zeros
    durPerPeriod = np.zeros(stepsPerHour * hoursConsidered)
    for bout in activebouts:
        # Ignore bouts what start after requested <end_hour> or end before
        # requested <start_hour>.
        if bout.start.hour > end_hour:
            continue
        elif bout.end.hour < start_hour:
            continue
        # Crop start and end times of every valid bout to request period
        elif bout.start.hour < start_hour:
            bout.start = bout.start.replace(hour=start_hour,minute=0,second=0,microsecond=0)
        elif bout.end.hour > end_hour:
            bout.end = bout.end.replace(hour=end_hour,minute=0,second=0,microsecond=0)

        # Go through every sub bout, defined by periods if <increment> minutes,
        # and store the number of minutes for every time-fraction considered.
        for subbout in splitBout(bout,increment):
            subboutSeconds = (subbout.end - subbout.start).total_seconds()
            i = stepsPerHour * (subbout.start.hour - start_hour) + \
                    subbout.start.minute / increment
            durPerPeriod[i] += np.true_divide(subboutSeconds, 60)

    # Divide each period (in seconds) by ?
    n = increment * (daysConsidered - weekendCorrection)
    durations = np.true_divide(durPerPeriod, n)
    # Return a tuple containing a list durations and a list of timepoints
    return durations, timeaxis

def splitBout(bout,increment) -> List[Bout]:
    """ Splits one bout in periods of <increment> minutes.
    """
    if np.mod(60, increment) > 0:
        raise RuntimeError('Increments must divide 60 evenly')

    boutListOut = []
    currtime = bout.start
    nexttime = bout.start
    while nexttime < bout.end:
        basemin = increment * ( currtime.minute / increment )
        nexttime = currtime.replace(minute=0,second=0,microsecond=0) + timedelta(0,0,0,0,basemin+increment)
        if nexttime > bout.end:
            nexttime = bout.end
        boutListOut.append(Bout(currtime, nexttime))
        currtime = nexttime
    return boutListOut

def generateErrorImage(msg) -> "matplotlib.figure.Figure":
    """ Creates an empty image (based on image nr. 1) and adds a message to it.
    """
    fig = plt.figure(1, figsize=(6,6))
    fig.clf()
    fig.suptitle(msg)
    return fig

def generateReport(
    user_id, project_id, activeTimeThresh, start_date, end_date, all_writes=True
) -> "matplotlib.figure.Figure":
    """ nts: node times
        cts: connector times
        rts: review times """
    events = eventTimes(user_id, project_id, start_date, end_date, all_writes)

    nts = events['treenode_events']
    cts = events['connector_events']
    rts = events['review_events']

    # If no nodes have been found, return an image with a descriptive text.
    if len(nts) == 0:
        return generateErrorImage("No tree nodes were edited during the " +
                "defined period if time.")

    annotationEvents, ae_timeaxis = eventsPerInterval( nts + cts, start_date, end_date )
    reviewEvents, re_timeaxis = eventsPerInterval( rts, start_date, end_date )

    if all_writes:
        write_events = events['write_events']
        other_write_events = write_events
        writeEvents, we_timeaxis = eventsPerInterval(other_write_events, start_date, end_date)
    else:
        other_write_events = []

    activeBouts = list(activeTimes( nts+cts+rts+other_write_events, activeTimeThresh ))
    netActiveTime, at_timeaxis = activeTimesPerDay( activeBouts )

    dayformat = DateFormatter('%b %d')

    fig = plt.figure(figsize=(9.6, 8))

    # Top left plot: created and edited nodes per day
    ax1 = plt.subplot2grid((2,2), (0,0))

    # If other writes should be shown, draw accumulated write bar first. This
    # makes the regular bar draw over it, so that only the difference is
    # visible, which is exactly what we want.
    if all_writes:
        we = ax1.bar(we_timeaxis, writeEvents, color='#00AA00', align='edge')

    an = ax1.bar(ae_timeaxis, annotationEvents, color='#0000AA', align='edge')
    rv = ax1.bar(re_timeaxis, reviewEvents, bottom=annotationEvents,
            color='#AA0000', align='edge')
    ax1.set_xlim((start_date,end_date))

    if all_writes:
        ax1.legend( (we, an, rv), ('Other changes','Annotated', 'Reviewed'), loc=2)
        ax1.set_ylabel('Nodes and changes')
    else:
        ax1.legend( (an, rv), ('Annotated', 'Reviewed'), loc=2 )
        ax1.set_ylabel('Nodes')

    yl = ax1.get_yticklabels()
    plt.setp(yl, fontsize=10)
    ax1.xaxis.set_major_formatter(dayformat)
    xl = ax1.get_xticklabels()
    plt.setp(xl, rotation=30, fontsize=10)
    ax1.set_title('Edit events', fontsize=10)

    # Bottom left plot: net active time per day
    ax2 = plt.subplot2grid((2,2), (1,0))
    ax2.bar(at_timeaxis, netActiveTime, color='k', align='edge')
    ax2.set_xlim((start_date,end_date))
    ax2.set_ylabel('Hours')
    yl = ax2.get_yticklabels()
    plt.setp(yl, fontsize=10)
    ax2.xaxis.set_major_formatter(dayformat)
    xl = ax2.get_xticklabels()
    plt.setp(xl, rotation=30, fontsize=10)
    ax2.set_title('Net daily active time', fontsize=10)

    """
    ax3 = fig.add_subplot(223)
    ax3 = eventsPerIntervalPerDayPlot(ax3, rts+nts+cts, start_date, end_date, 30 )
    """

    # Right column plot: bouts over days
    ax4 = plt.subplot2grid((2,2), (0,1), rowspan=2)
    ax4 = dailyActivePlotFigure(activeBouts, ax4, start_date, end_date)

    yl = ax4.get_yticklabels()
    plt.setp(yl, fontsize=10)
    ax4.xaxis.set_major_formatter(dayformat)
    xl = ax4.get_xticklabels()
    plt.setp(xl, rotation=30, fontsize=10)
    ax4.set_title('Active Bouts', fontsize=10)
    yl = ax4.get_yticklabels()
    plt.setp(yl, fontsize=10)
    ax4.set_ylabel('Time (24 hr)')

    fig.set_tight_layout(True)

    return fig

def dailyActivePlotFigure(activebouts, ax:"matplotlib.axes.Axes", start_date, end_date) -> "matplotlib.axes.Axes":
    """ Draws a plot of all bouts during each day between <start_date> and
    <end_date> to the plot given by <ax>.
    """
    # Y axis: Draw a line for each two hours in a day and set ticks accordingly
    for i in range(2, 24, 2):
        ax.axhline(i, color='#AAAAAA', linestyle = ':')
    ax.axhspan(8, 18, facecolor='#999999', alpha=0.25)
    ax.set_yticks(range(0, 25, 2))

    # X axis: Ticks and labels for every day
    ax.xaxis.set_major_locator(DayLocator())

    # Draw all bouts
    for bout in activebouts:
        # Ignore bouts that span accross midnight
        # TODO: Draw midnight spanning bouts, too.
        if bout.start.day == bout.end.day:
            isodate = bout.start.isocalendar()
            ax.bar( bout.start.replace(hour=0, minute=0, second=0, microsecond=0),
                    np.true_divide((bout.end-bout.start).total_seconds(), 3600),
                    bottom=bout.start.hour + bout.start.minute/60.0 + bout.start.second/3600.0,
                    alpha=0.5, color='#0000AA', align='edge', edgecolor="k")

    # Set Axis limits
    ax.set_ylim((0, 24))
    ax.invert_yaxis()
    ax.set_xlim((start_date, end_date))

    return ax

def eventsPerIntervalPerDayPlot(ax, times, start_date, end_date, interval=60) -> "matplotlib.axes.Axes":
    if np.mod(24 * 60, interval) > 0:
        raise ValueError('Interval in minutes must divide the day evenly')

    daycount = (end_date-start_date).days
    timebins = {}

    for i in range(daycount):
        timebins[i] = np.zeros(24 * 60 / interval)

    dayList = []
    daylabels = []

    for i in range(daycount):
        day = start_date + timedelta( i )
        dayList.append( day )
        daylabels.append( str(day.month) + '/' + str(day.day) )

    timeaxis = [i for i in range(24 * 60 / interval )]
    timelabels = []
    for i in range(int(24 * 60 / 30)):
        if np.mod(i,2)==0:
            timelabels.append( str(i/2) + ':00' )
        else:
            timelabels.append( str( (i-1)/2 ) + ':30' )

    for t in times:
        timebins[np.floor((t-start_date).days)][ np.floor(np.divide(t.hour*60+t.minute, interval)) ] += 1
    meandat = np.zeros(len(timebins[0]))
    ignoredDays = 0
    ind = 0
    cm = plt.get_cmap('jet',len(timebins))
    dats = []
    for dat in timebins.values():
        if np.sum(dat)==0:
            ignoredDays += 1
        else:
            tmp, = ax.plot(timeaxis, dat, marker='s', linestyle='-.',alpha=0.5, color=cm(ind))
            dats.append(tmp)
            meandat += dat
        ind += 1

    meandat = np.divide(meandat, daycount-ignoredDays)
    tmp, = ax.plot( timeaxis, meandat, color='k', linewidth=4, linestyle='-')
    dats.append(tmp)
    daylabels.append('Mean')

    ax.set_xticks(timeaxis)
    ax.set_xticklabels(timelabels)
    xl = ax.get_xticklabels()
    plt.setp(xl, rotation=30, fontsize=10)
    yl = ax.get_yticklabels()
    plt.setp(yl, fontsize=10)
    ax.set_ylabel('Events',fontsize=10)
    ax.set_xlim(8 * 60 / interval, 19 * 60 / interval)
    ax.legend(dats,daylabels,loc=2,frameon=False)

    return ax
