# -*- mode: python -*-
# -*- coding: utf-8 -*-

##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2021 Emmanuel Viennet.  All rights reserved.
#
# This program 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.
#
# This program 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 this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
#   Emmanuel Viennet      emmanuel.viennet@viennet.net
#
##############################################################################

"""Fonctions sur les absences
"""

# Anciennement dans ZAbscences.py, séparé pour migration

import string
import datetime
import re
import time
import calendar
import cgi

import notesdb
from sco_exceptions import ScoValueError, ScoInvalidDateError
import sco_formsemestre
import sco_compute_moy


def is_work_saturday(context):
    "Vrai si le samedi est travaillé"
    return int(context.get_preference("work_saturday"))


def MonthNbDays(month, year):
    "returns nb of days in month"
    if month > 7:
        month = month + 1
    if month % 2:
        return 31
    elif month == 2:
        if calendar.isleap(year):
            return 29
        else:
            return 28
    else:
        return 30


class ddmmyyyy:
    """immutable dates"""

    def __init__(self, date=None, fmt="ddmmyyyy", work_saturday=False):
        self.work_saturday = work_saturday
        if date is None:
            return
        try:
            if fmt == "ddmmyyyy":
                self.day, self.month, self.year = string.split(date, "/")
            elif fmt == "iso":
                self.year, self.month, self.day = string.split(date, "-")
            else:
                raise ValueError("invalid format spec. (%s)" % fmt)
            self.year = string.atoi(self.year)
            self.month = string.atoi(self.month)
            self.day = string.atoi(self.day)
        except:
            raise ScoValueError("date invalide: %s" % date)
        # accept years YYYY or YY, uses 1970 as pivot
        if self.year < 1970:
            if self.year > 100:
                raise ScoInvalidDateError("Année invalide: %s" % self.year)
            if self.year < 70:
                self.year = self.year + 2000
            else:
                self.year = self.year + 1900
        if self.month < 1 or self.month > 12:
            raise ScoInvalidDateError("Mois invalide: %s" % self.month)

        if self.day < 1 or self.day > MonthNbDays(self.month, self.year):
            raise ScoInvalidDateError("Jour invalide: %s" % self.day)

        # weekday in 0-6, where 0 is monday
        self.weekday = calendar.weekday(self.year, self.month, self.day)

        self.time = time.mktime((self.year, self.month, self.day, 0, 0, 0, 0, 0, 0))

    def iswork(self):
        "returns true if workable day"
        if self.work_saturday:
            nbdays = 6
        else:
            nbdays = 5
        if (
            self.weekday >= 0 and self.weekday < nbdays
        ):  # monday-friday or monday-saturday
            return 1
        else:
            return 0

    def __repr__(self):
        return "'%02d/%02d/%04d'" % (self.day, self.month, self.year)

    def __str__(self):
        return "%02d/%02d/%04d" % (self.day, self.month, self.year)

    def ISO(self):
        "iso8601 representation of the date"
        return "%04d-%02d-%02d" % (self.year, self.month, self.day)

    def next(self, days=1):
        "date for the next day (nota: may be a non workable day)"
        day = self.day + days
        month = self.month
        year = self.year

        while day > MonthNbDays(month, year):
            day = day - MonthNbDays(month, year)
            month = month + 1
            if month > 12:
                month = 1
                year = year + 1
        return self.__class__(
            "%02d/%02d/%04d" % (day, month, year), work_saturday=self.work_saturday
        )

    def prev(self, days=1):
        "date for previous day"
        day = self.day - days
        month = self.month
        year = self.year
        while day <= 0:
            month = month - 1
            if month == 0:
                month = 12
                year = year - 1
            day = day + MonthNbDays(month, year)

        return self.__class__(
            "%02d/%02d/%04d" % (day, month, year), work_saturday=self.work_saturday
        )

    def next_monday(self):
        "date of next monday"
        return self.next((7 - self.weekday) % 7)

    def prev_monday(self):
        "date of last monday, but on sunday, pick next monday"
        if self.weekday == 6:
            return self.next_monday()
        else:
            return self.prev(self.weekday)

    def __cmp__(self, other):
        """return a negative integer if self < other,
        zero if self == other, a positive integer if self > other"""
        return int(self.time - other.time)

    def __hash__(self):
        "we are immutable !"
        return hash(self.time) ^ hash(str(self))


# d = ddmmyyyy( '21/12/99' )
def DateRangeISO(context, date_beg, date_end, workable=1):
    """returns list of dates in [date_beg,date_end]
    workable = 1 => keeps only workable days"""
    if not date_beg:
        raise ScoValueError("pas de date spécifiée !")
    if not date_end:
        date_end = date_beg
    r = []
    work_saturday = is_work_saturday(context)
    cur = ddmmyyyy(date_beg, work_saturday=work_saturday)
    end = ddmmyyyy(date_end, work_saturday=work_saturday)
    while cur <= end:
        if (not workable) or cur.iswork():
            r.append(cur)
        cur = cur.next()

    return map(lambda x: x.ISO(), r)


def day_names(context):
    """Returns week day names.
    If work_saturday property is set, include saturday
    """
    if is_work_saturday(context):
        return ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]
    else:
        return ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]


def next_iso_day(context, date):
    "return date after date"
    d = ddmmyyyy(date, fmt="iso", work_saturday=is_work_saturday(context))
    return d.next().ISO()


def YearTable(
    context,
    year,
    events=[],
    firstmonth=9,
    lastmonth=7,
    halfday=0,
    dayattributes="",
    pad_width=8,
):
    """Generate a calendar table
    events = list of tuples (date, text, color, href [,halfday])
             where date is a string in ISO format (yyyy-mm-dd)
             halfday is boolean (true: morning, false: afternoon)
    text  = text to put in calendar (must be short, 1-5 cars) (optional)
    if halfday, generate 2 cells per day (morning, afternoon)
    """
    T = [
        '<table id="maincalendar" class="maincalendar" border="3" cellpadding="1" cellspacing="1" frame="box">'
    ]
    T.append("<tr>")
    month = firstmonth
    while 1:
        T.append('<td valign="top">')
        T.append(MonthTableHead(month))
        T.append(
            MonthTableBody(
                month,
                year,
                events,
                halfday,
                dayattributes,
                is_work_saturday(context),
                pad_width=pad_width,
            )
        )
        T.append(MonthTableTail())
        T.append("</td>")
        if month == lastmonth:
            break
        month = month + 1
        if month > 12:
            month = 1
            year = year + 1
    T.append("</table>")
    return string.join(T, "\n")


# ---- BILLETS

_billet_absenceEditor = notesdb.EditableTable(
    "billet_absence",
    "billet_id",
    (
        "billet_id",
        "etudid",
        "abs_begin",
        "abs_end",
        "description",
        "etat",
        "entry_date",
        "justified",
    ),
    sortkey="entry_date desc",
)

billet_absence_create = _billet_absenceEditor.create
billet_absence_delete = _billet_absenceEditor.delete
billet_absence_list = _billet_absenceEditor.list
billet_absence_edit = _billet_absenceEditor.edit

# ------ HTML Calendar functions (see YearTable function)

# MONTH/DAY NAMES:

MONTHNAMES = (
    "Janvier",
    "F&eacute;vrier",
    "Mars",
    "Avril",
    "Mai",
    "Juin",
    "Juillet",
    "Aout",
    "Septembre",
    "Octobre",
    "Novembre",
    "D&eacute;cembre",
)

MONTHNAMES_ABREV = (
    "Jan.",
    "F&eacute;v.",
    "Mars",
    "Avr.",
    "Mai&nbsp;",
    "Juin",
    "Juil",
    "Aout",
    "Sept",
    "Oct.",
    "Nov.",
    "D&eacute;c.",
)

DAYNAMES = ("Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche")

DAYNAMES_ABREV = ("L", "M", "M", "J", "V", "S", "D")

# COLORS:

WHITE = "#FFFFFF"
GRAY1 = "#EEEEEE"
GREEN3 = "#99CC99"
WEEKDAYCOLOR = GRAY1
WEEKENDCOLOR = GREEN3


def MonthTableHead(month):
    color = WHITE
    return """<table class="monthcalendar" border="0" cellpadding="0" cellspacing="0" frame="box">
     <tr bgcolor="%s"><td class="calcol" colspan="2" align="center">%s</td></tr>\n""" % (
        color,
        MONTHNAMES_ABREV[month - 1],
    )


def MonthTableTail():
    return "</table>\n"


def MonthTableBody(
    month, year, events=[], halfday=0, trattributes="", work_saturday=False, pad_width=8
):
    firstday, nbdays = calendar.monthrange(year, month)
    localtime = time.localtime()
    current_weeknum = time.strftime("%U", localtime)
    current_year = localtime[0]
    T = []
    # cherche date du lundi de la 1ere semaine de ce mois
    monday = ddmmyyyy("1/%d/%d" % (month, year))
    while monday.weekday != 0:
        monday = monday.prev()

    if work_saturday:
        weekend = ("D",)
    else:
        weekend = ("S", "D")

    if not halfday:
        for d in range(1, nbdays + 1):
            weeknum = time.strftime(
                "%U", time.strptime("%d/%d/%d" % (d, month, year), "%d/%m/%Y")
            )
            day = DAYNAMES_ABREV[(firstday + d - 1) % 7]
            if day in weekend:
                bgcolor = WEEKENDCOLOR
                weekclass = "wkend"
                attrs = ""
            else:
                bgcolor = WEEKDAYCOLOR
                weekclass = "wk" + str(monday).replace("/", "_")
                attrs = trattributes
            color = None
            legend = ""
            href = ""
            descr = ""
            # event this day ?
            # each event is a tuple (date, text, color, href)
            #  where date is a string in ISO format (yyyy-mm-dd)
            for ev in events:
                ev_year = int(ev[0][:4])
                ev_month = int(ev[0][5:7])
                ev_day = int(ev[0][8:10])
                if year == ev_year and month == ev_month and ev_day == d:
                    if ev[1]:
                        legend = ev[1]
                    if ev[2]:
                        color = ev[2]
                    if ev[3]:
                        href = ev[3]
                    if len(ev) > 4 and ev[4]:
                        descr = ev[4]
            #
            cc = []
            if color != None:
                cc.append('<td bgcolor="%s" class="calcell">' % color)
            else:
                cc.append('<td class="calcell">')

            if href:
                href = 'href="%s"' % href
            if descr:
                descr = 'title="%s"' % cgi.escape(descr, quote=True)
            if href or descr:
                cc.append("<a %s %s>" % (href, descr))

            if legend or d == 1:
                if pad_width != None:
                    n = pad_width - len(legend)  # pad to 8 cars
                    if n > 0:
                        legend = "&nbsp;" * (n / 2) + legend + "&nbsp;" * ((n + 1) / 2)
            else:
                legend = "&nbsp;"  # empty cell
            cc.append(legend)
            if href or descr:
                cc.append("</a>")
            cc.append("</td>")
            cell = string.join(cc, "")
            if day == "D":
                monday = monday.next(7)
            if (
                weeknum == current_weeknum
                and current_year == year
                and weekclass != "wkend"
            ):
                weekclass += " currentweek"
            T.append(
                '<tr bgcolor="%s" class="%s" %s><td class="calday">%d%s</td>%s</tr>'
                % (bgcolor, weekclass, attrs, d, day, cell)
            )
    else:
        # Calendar with 2 cells / day
        for d in range(1, nbdays + 1):
            weeknum = time.strftime(
                "%U", time.strptime("%d/%d/%d" % (d, month, year), "%d/%m/%Y")
            )
            day = DAYNAMES_ABREV[(firstday + d - 1) % 7]
            if day in weekend:
                bgcolor = WEEKENDCOLOR
                weekclass = "wkend"
                attrs = ""
            else:
                bgcolor = WEEKDAYCOLOR
                weekclass = "wk" + str(monday).replace("/", "_")
                attrs = trattributes
            if (
                weeknum == current_weeknum
                and current_year == year
                and weekclass != "wkend"
            ):
                weeknum += " currentweek"

            if day == "D":
                monday = monday.next(7)
            T.append(
                '<tr bgcolor="%s" class="wk%s" %s><td class="calday">%d%s</td>'
                % (bgcolor, weekclass, attrs, d, day)
            )
            cc = []
            for morning in (1, 0):
                color = None
                legend = ""
                href = ""
                descr = ""
                for ev in events:
                    ev_year = int(ev[0][:4])
                    ev_month = int(ev[0][5:7])
                    ev_day = int(ev[0][8:10])
                    if ev[4] != None:
                        ev_half = int(ev[4])
                    else:
                        ev_half = 0
                    if (
                        year == ev_year
                        and month == ev_month
                        and ev_day == d
                        and morning == ev_half
                    ):
                        if ev[1]:
                            legend = ev[1]
                        if ev[2]:
                            color = ev[2]
                        if ev[3]:
                            href = ev[3]
                        if len(ev) > 5 and ev[5]:
                            descr = ev[5]
                #
                if color != None:
                    cc.append('<td bgcolor="%s" class="calcell">' % (color))
                else:
                    cc.append('<td class="calcell">')
                if href:
                    href = 'href="%s"' % href
                if descr:
                    descr = 'title="%s"' % cgi.escape(descr, quote=True)
                if href or descr:
                    cc.append("<a %s %s>" % (href, descr))
                if legend or d == 1:
                    n = 3 - len(legend)  # pad to 3 cars
                    if n > 0:
                        legend = "&nbsp;" * (n / 2) + legend + "&nbsp;" * ((n + 1) / 2)
                else:
                    legend = "&nbsp;&nbsp;&nbsp;"  # empty cell
                cc.append(legend)
                if href or descr:
                    cc.append("</a>")
                cc.append("</td>\n")
            T.append(string.join(cc, "") + "</tr>")
    return string.join(T, "\n")


# --------------------------------------------------------------------
#
# Cache absences
#
# On cache simplement (à la demande) le nombre d'absences de chaque etudiant
# dans un semestre donné.
# Toute modification du semestre (invalidation) invalide le cache
#  (simple mécanisme de "listener" sur le cache de semestres)
# Toute modification des absences d'un étudiant invalide les caches des semestres
# concernés à cette date (en général un seul semestre)
#
# On ne cache pas la liste des absences car elle est rarement utilisée (calendrier,
#  absences à une date donnée).
#
# --------------------------------------------------------------------
class CAbsSemEtud:
    """Comptes d'absences d'un etudiant dans un semestre"""

    def __init__(self, context, sem, etudid):
        self.context = context
        self.sem = sem
        self.etudid = etudid
        self._loaded = False
        formsemestre_id = sem["formsemestre_id"]
        context.Notes._getNotesCache().add_listener(
            self.invalidate, formsemestre_id, (etudid, formsemestre_id)
        )

    def CountAbs(self):
        if not self._loaded:
            self.load()
        return self._CountAbs

    def CountAbsJust(self):
        if not self._loaded:
            self.load()
        return self._CountAbsJust

    def load(self):
        "Load state from DB"
        # log('loading CAbsEtudSem(%s,%s)' % (self.etudid, self.sem['formsemestre_id']))
        # Reload sem, it may have changed
        self.sem = sco_formsemestre.get_formsemestre(
            self.context, self.sem["formsemestre_id"]
        )
        debut_sem = notesdb.DateDMYtoISO(self.sem["date_debut"])
        fin_sem = notesdb.DateDMYtoISO(self.sem["date_fin"])

        self._CountAbs = self.context.Absences.CountAbs(
            etudid=self.etudid, debut=debut_sem, fin=fin_sem
        )
        self._CountAbsJust = self.context.Absences.CountAbsJust(
            etudid=self.etudid, debut=debut_sem, fin=fin_sem
        )
        self._loaded = True

    def invalidate(self, args=None):
        "Notify me that DB has been modified"
        # log('invalidate CAbsEtudSem(%s,%s)' % (self.etudid, self.sem['formsemestre_id']))
        self._loaded = False


# Accès au cache des absences
ABS_CACHE_INST = {}  # { DeptId : { formsemestre_id : { etudid :  CAbsEtudSem } } }


def getAbsSemEtud(context, sem, etudid):
    AbsSemEtuds = getAbsSemEtuds(context, sem)
    if not etudid in AbsSemEtuds:
        AbsSemEtuds[etudid] = CAbsSemEtud(context, sem, etudid)
    return AbsSemEtuds[etudid]


def getAbsSemEtuds(context, sem):
    u = context.GetDBConnexionString()  # identifie le dept de facon fiable
    if not u in ABS_CACHE_INST:
        ABS_CACHE_INST[u] = {}
    C = ABS_CACHE_INST[u]
    if sem["formsemestre_id"] not in C:
        C[sem["formsemestre_id"]] = {}
    return C[sem["formsemestre_id"]]


def invalidateAbsEtudDate(context, etudid, date):
    """Doit etre appelé à chaque modification des absences pour cet étudiant et cette date.
    Invalide cache absence et PDF bulletins si nécessaire.
    date: date au format ISO
    """
    # Semestres a cette date:
    etud = context.getEtudInfo(etudid=etudid, filled=True)[0]
    sems = [
        sem
        for sem in etud["sems"]
        if sem["date_debut_iso"] <= date and sem["date_fin_iso"] >= date
    ]

    # Invalide les PDF et les abscences:
    for sem in sems:
        # Inval cache bulletin et/ou note_table
        if sco_compute_moy.formsemestre_expressions_use_abscounts(
            context, sem["formsemestre_id"]
        ):
            pdfonly = False  # seules certaines formules utilisent les absences
        else:
            pdfonly = (
                True  # efface toujours le PDF car il affiche en général les absences
            )

        context.Notes._inval_cache(
            pdfonly=pdfonly, formsemestre_id=sem["formsemestre_id"]
        )

        # Inval cache compteurs absences:
        AbsSemEtuds = getAbsSemEtuds(context, sem)
        if etudid in AbsSemEtuds:
            AbsSemEtuds[etudid].invalidate()
