# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 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
#   module codé par Matthias Hartmann, 2023
#
##############################################################################

"""Vues assiduité"""

import datetime
import json
import re

from collections import OrderedDict

from flask import g, request, render_template, flash
from flask import abort, url_for, redirect, Response, session
from flask_login import current_user
from flask_sqlalchemy.query import Query

from markupsafe import Markup
from werkzeug.exceptions import HTTPException

from app import db, log
from app.api import tools
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.decorators import (
    scodoc,
    permission_required,
)
from app.forms.assiduite.ajout_assiduite_etud import (
    AjoutAssiOrJustForm,
    AjoutAssiduiteEtudForm,
    AjoutJustificatifEtudForm,
)
from app.forms.assiduite.edit_assiduite_etud import EditAssiForm
from app.models import (
    Assiduite,
    Departement,
    Evaluation,
    FormSemestre,
    GroupDescr,
    Identite,
    Justificatif,
    Module,
    ModuleImpl,
    ScoDocSiteConfig,
    Scolog,
)
from app.scodoc.codes_cursus import UE_STANDARD

from app.auth.models import User
from app.models.assiduites import get_assiduites_justif, is_period_conflicting
from app.tables.list_etuds import RowEtud, TableEtud
import app.tables.liste_assiduites as liste_assi

from app.views import assiduites_bp as bp
from app.views import ScoData

# ---------------
from app.scodoc.sco_permissions import Permission
from app.scodoc import html_sco_header
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
from app.scodoc import sco_groups_view, sco_groups
from app.scodoc import sco_etud
from app.scodoc import sco_excel
from app.scodoc import sco_find_etud
from app.scodoc import sco_assiduites as scass
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_gen_cal


from app.tables.visu_assiduites import TableAssi, etuds_sorted_from_ids
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver


# --------------------------------------------------------------------
#
#   Assiduité (/ScoDoc/<dept>/Scolarite/Assiduites/...)
#
# --------------------------------------------------------------------


@bp.route("/")
@bp.route("/bilan_dept")
@scodoc
@scass.check_disabled
@permission_required(Permission.AbsChange)
def bilan_dept():
    """Gestionnaire assiduités, page principale"""

    # Gestion des billets d'absences
    if current_user.has_permission(
        Permission.AbsChange
    ) and sco_preferences.get_preference("handle_billets_abs"):
        billets = f"""
            <h2 style="margin-top: 30px;">Billets d'absence</h2>
            <ul><li><a href="{url_for("absences.list_billets", scodoc_dept=g.scodoc_dept)
            }">Traitement des billets d'absence en attente</a>
            </li></ul>
            """
    else:
        billets = ""
    # Récupération du département
    dept: Departement = Departement.query.filter_by(id=g.scodoc_dept_id).first()

    # Récupération d'un formsemestre
    # (pour n'afficher que les justificatifs liés au formsemestre)
    formsemestre_id = request.args.get("formsemestre_id", "")
    formsemestre = None
    if formsemestre_id:
        try:
            formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
        except AttributeError:
            formsemestre_id = ""

    # <=> Génération du tableau <=>

    # Récupération des étudiants du département / groupe
    etudids: list[int] = [etud.id for etud in dept.etudiants]  # cas département
    group_ids = request.args.get("group_ids", "")
    if group_ids and formsemestre:
        groups_infos = sco_groups_view.DisplayedGroupsInfos(
            group_ids.split(","),
            formsemestre_id=formsemestre.id,
            select_all_when_unspecified=True,
        )

        if groups_infos.members:
            etudids = [m["etudid"] for m in groups_infos.members]

    # justificatifs (en attente ou modifiés avec les semestres associés)
    justificatifs_query: Query = Justificatif.query.filter(
        Justificatif.etat.in_(
            [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE]
        ),
        Justificatif.etudid.in_(etudids),
    )
    # Filtrage par semestre si formsemestre_id != ""
    if formsemestre:
        justificatifs_query = justificatifs_query.filter(
            Justificatif.date_debut >= formsemestre.date_debut,
            Justificatif.date_debut <= formsemestre.date_fin,
        )

    data = liste_assi.AssiJustifData(
        assiduites_query=None,
        justificatifs_query=justificatifs_query,
    )

    fname: str = "Bilan Département"
    cache_key: str = "tableau-dept"
    titre: str = "Justificatifs en attente ou modifiés"

    if formsemestre:
        fname += f" {formsemestre.titre_annee()}"
        cache_key += f"-{formsemestre.id}"
        titre += f" {formsemestre.titre_annee()}"

    if group_ids:
        cache_key += f" {group_ids}"

    table = _prepare_tableau(
        data,
        afficher_etu=True,
        filename=fname,
        titre=titre,
        cache_key=cache_key,
    )

    if not table[0]:
        return table[1]

    # Récupération des formsemestres (pour le menu déroulant)
    formsemestres: Query = FormSemestre.get_dept_formsemestres_courants(dept)
    formsemestres_choices: dict[int, str] = {
        fs.id: fs.titre_annee() for fs in formsemestres
    }

    # Peuplement du template jinja
    return render_template(
        "assiduites/pages/bilan_dept.j2",
        tableau=table[1],
        search_etud=sco_find_etud.form_search_etud(dest_url="assiduites.bilan_etud"),
        billets=billets,
        sco=ScoData(formsemestre=formsemestre),
        formsemestres=formsemestres_choices,
        formsemestre_id=None if not formsemestre else formsemestre.id,
    )


@bp.route("/ajout_assiduite_etud", methods=["GET", "POST"])
@scodoc
@scass.check_disabled
@permission_required(Permission.AbsChange)
def ajout_assiduite_etud() -> str | Response:
    """
    ajout_assiduite_etud Saisie d'une assiduité d'un étudiant

    Args:
        etudid (int): l'identifiant de l'étudiant
        date_deb, date_fin: heures début et fin (ISO sans timezone)
        moduleimpl_id
        evaluation_id : si présent, mode "évaluation"
        fmt: si xls, renvoie le tableau des assiduités enregistrées
    Returns:
        str: l'html généré
    """
    etud = Identite.get_etud(request.args.get("etudid"))
    formsemestre_id = request.args.get("formsemestre_id", None)

    # Gestion du semestre
    formsemestre: FormSemestre | None = None
    sems_etud: list[FormSemestre] = etud.get_formsemestres()
    if formsemestre_id:
        formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
        formsemestre = formsemestre if formsemestre in sems_etud else None
        if formsemestre is None:
            raise ScoValueError("Etudiant non inscrit dans ce semestre")
    else:
        formsemestre = list(
            sorted(sems_etud, key=lambda x: x.est_courant(), reverse=True)
        )  # Mets le semestre courant en premier et les autres dans l'ordre
        formsemestre = formsemestre[0] if formsemestre else None
        if formsemestre is None:
            raise ScoValueError(
                "L'étudiant n'est actuellement pas inscrit: on ne peut pas saisir son assiduité"
            )

    # Gestion évaluations (appel à la page depuis les évaluations)
    evaluation_id: int | None = request.args.get("evaluation_id")
    saisie_eval = evaluation_id is not None
    moduleimpl_id: int | None = request.args.get("moduleimpl_id", "")

    redirect_url: str = (
        request.url
        if not saisie_eval
        else url_for(
            "notes.evaluation_check_absences_html",
            evaluation_id=evaluation_id,
            scodoc_dept=g.scodoc_dept,
        )
    )

    form = AjoutAssiduiteEtudForm(request.form)
    # On dresse la liste des modules de l'année scolaire en cours
    # auxquels est inscrit l'étudiant pour peupler le menu "module"
    choices: OrderedDict = OrderedDict()
    choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]

    # Récupération des modulesimpl du semestre si existant.
    if formsemestre:
        # indique le nom du semestre dans le menu (optgroup)
        modimpls_from_formsemestre = etud.get_modimpls_from_formsemestre(formsemestre)
        group_name: str = formsemestre.titre_annee()
        choices[group_name] = [
            (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}")
            for m in modimpls_from_formsemestre
            if m.module.ue.type == UE_STANDARD
        ]

    choices.move_to_end("", last=False)
    form.modimpl.choices = choices
    force_options: dict = None
    if form.validate_on_submit():
        if form.cancel.data:  # cancel button
            return redirect(redirect_url)
        ok = _record_assiduite_etud(etud, form, formsemestre=formsemestre)
        if ok:
            flash("enregistré")
            log(f"redirect_url={redirect_url}")
            return redirect(redirect_url)

        force_options = {"show_pres": True, "show_reta": True}

    # Le tableau des assiduités+justificatifs déjà en base:
    is_html, tableau = _prepare_tableau(
        liste_assi.AssiJustifData.from_etudiants(
            etud,
        ),
        filename=f"assiduite-{etud.nom or ''}",
        afficher_etu=False,
        filtre=liste_assi.AssiFiltre(type_obj=1),
        options=liste_assi.AssiDisplayOptions(show_module=True),
        cache_key=f"tableau-etud-{etud.id}",
        force_options=force_options,
    )
    if not is_html:
        return tableau

    return render_template(
        "assiduites/pages/ajout_assiduite_etud.j2",
        etud=etud,
        form=form,
        moduleimpl_id=moduleimpl_id,
        redirect_url=redirect_url,
        sco=ScoData(etud=etud, formsemestre=formsemestre),
        tableau=tableau,
        scu=scu,
    )


def _get_dates_from_assi_form(
    form: AjoutAssiOrJustForm,
    etud: Identite,
    from_justif: bool = False,
    formsemestre: FormSemestre | None = None,
) -> tuple[
    bool, datetime.datetime | None, datetime.datetime | None, datetime.datetime | None
]:
    """Prend les dates et heures du form, les vérifie
    puis converti en deux datetime, en timezone du serveur.
    Ramène ok=True si ok.
    Met des messages d'erreur dans le form.
    """
    debut_jour = ScoDocSiteConfig.get("assi_morning_time", "08:00")
    fin_jour = ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
    date_fin = None
    # On commence par convertir individuellement tous les champs
    try:
        date_debut = datetime.datetime.strptime(form.date_debut.data, scu.DATE_FMT)
    except ValueError:
        date_debut = None
        form.set_error("date début invalide", form.date_debut)
    try:
        date_fin = (
            datetime.datetime.strptime(form.date_fin.data, scu.DATE_FMT)
            if form.date_fin.data
            else None
        )
    except ValueError:
        date_fin = None
        form.set_error("date fin invalide", form.date_fin)

    if not from_justif and date_fin:
        # Ne prends pas en compte les heures pour les assiduités sur plusieurs jours
        heure_debut = datetime.time.fromisoformat(debut_jour)
        heure_fin = datetime.time.fromisoformat(fin_jour)
    else:
        try:
            heure_debut = datetime.time.fromisoformat(
                form.heure_debut.data or debut_jour
            )
        except ValueError:
            form.set_error("heure début invalide", form.heure_debut)
        if bool(form.heure_debut.data) != bool(form.heure_fin.data):
            form.set_error(
                "Les deux heures début et fin doivent être spécifiées, ou aucune"
            )
        try:
            heure_fin = datetime.time.fromisoformat(form.heure_fin.data or fin_jour)
        except ValueError:
            form.set_error("heure fin invalide", form.heure_fin)

    if not form.ok:
        return False, None, None, None

    # Vérifie cohérence des dates/heures
    dt_debut = datetime.datetime.combine(date_debut, heure_debut)
    dt_fin = datetime.datetime.combine(date_fin or date_debut, heure_fin)
    if dt_fin <= dt_debut:
        form.set_error("dates début/fin incohérentes")
    # La date de dépôt (si vide, la date actuelle)
    try:
        dt_entry_date = (
            datetime.datetime.strptime(form.entry_date.data, scu.DATE_FMT)
            if form.entry_date.data
            else datetime.datetime.now()  # local tz
        )
    except ValueError:
        dt_entry_date = None
        form.set_error("format de date de dépôt invalide", form.entry_date)
    # L'heure de dépôt
    try:
        entry_time = datetime.time.fromisoformat(
            form.entry_time.data or datetime.datetime.now().time().isoformat("seconds")
        )
    except ValueError:
        dt_entry_date = None
        form.set_error("format d'heure de dépôt invalide", form.entry_date)
    if dt_entry_date:
        dt_entry_date = datetime.datetime.combine(dt_entry_date, entry_time)
    # Ajoute time zone serveur
    dt_debut_tz_server = scu.TIME_ZONE.localize(dt_debut)
    dt_fin_tz_server = scu.TIME_ZONE.localize(dt_fin)

    if from_justif:
        cas: list[bool] = [
            # cas 1 (date de fin vide et pas d'heure de début)
            not form.date_fin.data and not form.heure_debut.data,
            # cas 2 (date de fin et pas d'heures)
            form.date_fin.data != "" and not form.heure_debut.data,
        ]

        if any(cas):
            dt_debut_tz_server = dt_debut_tz_server.replace(hour=0, minute=0)
            dt_fin_tz_server = dt_fin_tz_server.replace(hour=23, minute=59)

    # Vérification dates contenu dans un semestre de l'étudiant
    dates_semestres: list[tuple[datetime.date, datetime.date]] = (
        [(sem.date_debut, sem.date_fin) for sem in etud.get_formsemestres()]
        if formsemestre is None
        else [(formsemestre.date_debut, formsemestre.date_fin)]
    )

    # Vérification date début
    if not any(
        [
            dt_debut_tz_server.date() >= deb and dt_debut_tz_server.date() <= fin
            for deb, fin in dates_semestres
        ]
    ):
        form.set_error(
            (
                "La date de début n'appartient à aucun semestre de l'étudiant"
                if formsemestre is None
                else "La date de début n'appartient pas au semestre"
            ),
            form.date_debut,
        )

    # Vérification date fin
    if form.date_fin.data and not any(
        [
            dt_fin_tz_server.date() >= deb and dt_fin_tz_server.date() <= fin
            for deb, fin in dates_semestres
        ]
    ):
        form.set_error(
            (
                "La date de fin n'appartient à aucun semestre de l'étudiant"
                if not formsemestre
                else "La date de fin n'appartient pas au semestre"
            ),
            form.date_fin,
        )

    dt_entry_date_tz_server = (
        scu.TIME_ZONE.localize(dt_entry_date) if dt_entry_date else None
    )
    return form.ok, dt_debut_tz_server, dt_fin_tz_server, dt_entry_date_tz_server


def _record_assiduite_etud(
    etud: Identite,
    form: AjoutAssiduiteEtudForm,
    formsemestre: FormSemestre | None = None,
) -> bool:
    """Enregistre les données du formulaire de saisie assiduité.
    Returns ok if successfully recorded, else put error info in the form.
    Format attendu des données du formulaire:
        form.assi_etat.data   :  'absent'
        form.date_debut.data  : '05/12/2023'
        form.heure_debut.data : '09:06' (heure locale du serveur)
    """
    (
        ok,
        dt_debut_tz_server,
        dt_fin_tz_server,
        dt_entry_date_tz_server,
    ) = _get_dates_from_assi_form(form, etud, formsemestre=formsemestre)
    # Le module (avec "autre")
    mod_data = form.modimpl.data
    if mod_data:
        if mod_data == "autre":
            moduleimpl_id = "autre"
        else:
            try:
                moduleimpl_id = int(mod_data)
            except ValueError:
                form.modimpl.error("choix de module invalide")
                ok = False
    else:
        moduleimpl_id = None

    if not ok:
        return False

    external_data = None
    moduleimpl: ModuleImpl | None = None
    match moduleimpl_id:
        case "autre":
            external_data = {"module": "Autre"}
        case None:
            moduleimpl = None
        case _:
            moduleimpl = db.session.get(ModuleImpl, moduleimpl_id)
    try:
        assi_etat: scu.EtatAssiduite = scu.EtatAssiduite.get(form.assi_etat.data)

        ass = Assiduite.create_assiduite(
            etud,
            dt_debut_tz_server,
            dt_fin_tz_server,
            assi_etat,
            description=form.description.data,
            entry_date=dt_entry_date_tz_server,
            external_data=external_data,
            moduleimpl=moduleimpl,
            notify_mail=True,
            user_id=current_user.id,
        )
        db.session.add(ass)
        db.session.commit()

        if assi_etat != scu.EtatAssiduite.PRESENT and form.est_just.data:
            # si la case "justifiée est cochée alors on créé un justificatif de même période"

            # L'état est Valide si l'user à la permission JustifValidate
            etat: scu.EtatJustificatif = scu.EtatJustificatif.ATTENTE
            if current_user.has_permission(Permission.JustifValidate):
                etat = scu.EtatJustificatif.VALIDE
            else:
                flash(
                    "Vous ne pouvez pas créer de justificatif valide,"
                    + " il est automatiquement passé 'EN ATTENTE'",
                )

            justi: Justificatif = Justificatif.create_justificatif(
                etudiant=etud,
                date_debut=dt_debut_tz_server,
                date_fin=dt_fin_tz_server,
                etat=etat,
                user_id=current_user.id,
            )

            # On met à jour les assiduités en fonction du nouveau justificatif
            justi.justifier_assiduites()

        # Invalider cache
        scass.simple_invalidate_cache(ass.to_dict(), etud.id)

        return True
    except ScoValueError as exc:
        err: str = f"Erreur: {exc.args[0]}"

        if (
            exc.args[0]
            == "Duplication: la période rentre en conflit avec une plage enregistrée"
        ):
            # Récupération de la première assiduité conflictuelle
            conflits: Query = etud.assiduites.filter(
                Assiduite.date_debut < dt_fin_tz_server,
                Assiduite.date_fin > dt_debut_tz_server,
            )
            assi: Assiduite = conflits.first()

            lien: str = url_for(
                "assiduites.edit_assiduite_etud",
                assiduite_id=assi.assiduite_id,
                scodoc_dept=g.scodoc_dept,
            )

            form.set_error(
                Markup(
                    err
                    + f' <a href="{lien}" target="_blank" title="Voir le détail de'
                    + " l'assiduité conflictuelle\">(conflit)</a>"
                )
            )
        else:
            form.set_error(err)
        return False


@bp.route("/bilan_etud")
@scodoc
@permission_required(Permission.ScoView)
def bilan_etud():
    """
    bilan_etud Affichage de toutes les assiduites et justificatifs d'un etudiant
    Args:
        etudid (int): l'identifiant de l'étudiant

    Returns:
        str: l'html généré
    """
    # Initialisation des options du tableau
    options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions(
        show_module=True
    )

    # Récupération de l'étudiant
    etud = Identite.get_etud(request.args.get("etudid"))

    # Gestion du filtre de module
    moduleimpl_id = request.args.get("moduleimpl_id", None)
    if moduleimpl_id is not None:
        try:
            moduleimpl_id = int(moduleimpl_id)
        except ValueError:
            moduleimpl_id = None
        options.moduleimpl_id = moduleimpl_id

    # Gestion des dates du bilan (par défaut l'année scolaire)
    date_debut = scu.date_debut_annee_scolaire().strftime(scu.DATE_FMT)
    date_fin: str = scu.date_fin_annee_scolaire().strftime(scu.DATE_FMT)

    # Récupération de la métrique d'assiduité
    assi_metric = scu.translate_assiduites_metric(
        sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id),
    )

    # préparation du selecteur de moduleimpl
    annee_sco = _get_anne_sco_from_request()
    moduleimpl_select: str = _module_selector_multiple(
        etud, moduleimpl_id, no_default=True, annee_sco=annee_sco
    )

    # Préparation de la page
    tableau = _prepare_tableau(
        liste_assi.AssiJustifData.from_etudiants(etud),
        filename=f"assiduites-justificatifs-{etud.id}",
        afficher_etu=False,
        filtre=liste_assi.AssiFiltre(type_obj=0),
        options=options,
        cache_key=f"tableau-etud-{etud.id}",
        moduleimpl_select=moduleimpl_select,
    )
    if not tableau[0]:
        return tableau[1]

    # Génération de la page HTML
    return render_template(
        "assiduites/pages/bilan_etud.j2",
        assi_metric=assi_metric,
        assi_seuil=_get_seuil(),
        date_debut=date_debut,
        date_fin=date_fin,
        sco=ScoData(etud=etud),
        tableau=tableau[1],
    )


@bp.route("/edit_justificatif_etud/<int:justif_id>", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
def edit_justificatif_etud(justif_id: int):
    """
    Edition d'un justificatif.
    Il faut de plus la permission pour voir/modifier la raison.

    Args:
        justif_id (int): l'identifiant du justificatif

    Returns:
        str: l'html généré
    """
    try:
        justif = Justificatif.get_justificatif(justif_id)
    except HTTPException:
        flash("Justificatif invalide")
        return redirect(url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept))

    readonly = not current_user.has_permission(Permission.AbsChange)

    form = AjoutJustificatifEtudForm(obj=justif)
    # Limite les choix d'état si l'utilisateur n'a pas la permission de valider
    choix_etat: list = [
        (scu.EtatJustificatif.ATTENTE.value, "En attente de validation")
    ]

    if current_user.has_permission(Permission.JustifValidate):
        choix_etat = [
            ("", "Choisir..."),
            (scu.EtatJustificatif.ATTENTE.value, "En attente de validation"),
            (scu.EtatJustificatif.NON_VALIDE.value, "Non valide"),
            (scu.EtatJustificatif.MODIFIE.value, "Modifié"),
            (scu.EtatJustificatif.VALIDE.value, "Valide"),
        ]

    form.etat.choices = choix_etat

    if readonly:
        form.disable_all()

    # Set the default value for the etat field
    if request.method == "GET":
        form.date_debut.data = justif.date_debut.strftime(scu.DATE_FMT)
        form.date_fin.data = justif.date_fin.strftime(scu.DATE_FMT)
        if form.date_fin.data == form.date_debut.data:
            # un seul jour: pas de date de fin, indique les heures
            form.date_fin.data = ""
            form.heure_debut.data = justif.date_debut.strftime(scu.TIME_FMT)
            form.heure_fin.data = justif.date_fin.strftime(scu.TIME_FMT)
        form.entry_date.data = (
            justif.entry_date.strftime(scu.DATE_FMT) if justif.entry_date else ""
        )
        form.entry_time.data = (
            justif.entry_date.strftime(scu.TIME_FMT) if justif.entry_date else ""
        )
        form.etat.data = str(justif.etat)

    back_url = request.args.get("back_url", None)

    redirect_url = back_url or url_for(
        "assiduites.bilan_etud",
        scodoc_dept=g.scodoc_dept,
        etudid=justif.etudiant.id,
    )

    if form.validate_on_submit():
        if form.cancel.data or not current_user.has_permission(
            Permission.AbsChange
        ):  # cancel button
            return redirect(redirect_url)
        if _record_justificatif_etud(justif.etudiant, form, justif):
            return redirect(redirect_url)

    # Fichiers
    filenames, nb_files = justif.get_fichiers()

    return render_template(
        "assiduites/pages/ajout_justificatif_etud.j2",
        can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView)
        or current_user.id == justif.user_id,
        etud=justif.etudiant,
        filenames=filenames,
        form=form,
        justif=_preparer_objet("justificatif", justif),
        nb_files=nb_files,
        title=f"Modification justificatif absence de {justif.etudiant.html_link_fiche()}",
        redirect_url=redirect_url,
        sco=ScoData(etud=justif.etudiant),
        scu=scu,
        readonly=not current_user.has_permission(Permission.AbsChange),
    )


@bp.route("/ajout_justificatif_etud", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def ajout_justificatif_etud():
    """
    ajout_justificatif_etud : Affichage et création des justificatifs de l'étudiant
    Args:
        etudid (int): l'identifiant de l'étudiant

    Returns:
        str: l'html généré
    """
    etud = Identite.get_etud(request.args.get("etudid"))
    redirect_url = url_for(
        "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
    )

    form = AjoutJustificatifEtudForm()
    # Limite les choix d'état si l'utilisateur n'a pas la permission de valider
    choix_etat: list = [
        (scu.EtatJustificatif.ATTENTE.value, "En attente de validation")
    ]

    if current_user.has_permission(Permission.JustifValidate):
        choix_etat = [
            ("", "Choisir..."),
            (scu.EtatJustificatif.ATTENTE.value, "En attente de validation"),
            (scu.EtatJustificatif.NON_VALIDE.value, "Non valide"),
            (scu.EtatJustificatif.MODIFIE.value, "Modifié"),
            (scu.EtatJustificatif.VALIDE.value, "Valide"),
        ]

    form.etat.choices = choix_etat
    if form.validate_on_submit():
        if form.cancel.data:  # cancel button
            return redirect(redirect_url)
        ok = _record_justificatif_etud(etud, form)
        if ok:
            return redirect(redirect_url)

    is_html, tableau = _prepare_tableau(
        liste_assi.AssiJustifData.from_etudiants(
            etud,
        ),
        filename=f"justificatifs-{etud.nom or ''}",
        afficher_etu=False,
        filtre=liste_assi.AssiFiltre(type_obj=2),
        options=liste_assi.AssiDisplayOptions(show_module=False, show_desc=True),
        afficher_options=False,
        titre="Justificatifs enregistrés pour cet étudiant",
        cache_key=f"tableau-etud-{etud.id}",
    )
    if not is_html:
        return tableau

    return render_template(
        "assiduites/pages/ajout_justificatif_etud.j2",
        etud=etud,
        form=form,
        title=f"Ajout justificatif absence pour {etud.html_link_fiche()}",
        redirect_url=redirect_url,
        sco=ScoData(etud=etud),
        scu=scu,
        tableau=tableau,
    )


def _record_justificatif_etud(
    etud: Identite, form: AjoutJustificatifEtudForm, justif: Justificatif | None = None
) -> bool:
    """Enregistre les données du formulaire de saisie justificatif (et ses fichiers).
    Returns ok if successfully recorded, else put error info in the form.
    Format attendu des données du formulaire:
        form.assi_etat.data   :  'absent'
        form.date_debut.data  : '05/12/2023'
        form.heure_debut.data : '09:06' (heure locale du serveur)
    Si justif, modifie le justif existant, sinon en crée un nouveau
    """
    (
        ok,
        dt_debut_tz_server,
        dt_fin_tz_server,
        dt_entry_date_tz_server,
    ) = _get_dates_from_assi_form(form, etud, from_justif=True)
    if not ok:
        log("_record_justificatif_etud: dates invalides")
        form.set_error("Erreur: dates invalides")
        return False
    if not form.etat.data:
        log("_record_justificatif_etud: etat invalide")
        form.set_error("Erreur: état invalide")
        return False
    etat = int(form.etat.data)
    if not scu.EtatJustificatif.is_valid_etat(etat):
        log(f"_record_justificatif_etud: etat invalide ({etat})")
        form.set_error("Erreur: état invalide")
        return False

    if (
        not current_user.has_permission(Permission.JustifValidate)
        and etat != scu.EtatJustificatif.ATTENTE
    ):
        log("_record_justificatif_etud: pas la permission")
        form.set_error(
            "Erreur: vous n'avez pas la permission de définir la validité d'un justificatif"
        )
        return False

    try:
        message = ""
        if justif:
            form.date_debut.data = dt_debut_tz_server
            form.date_fin.data = dt_fin_tz_server
            form.entry_date.data = dt_entry_date_tz_server
            justif.dejustifier_assiduites()
            if justif.edit_from_form(form):
                message = "Justificatif modifié"

                # On met à jour la db pour avoir les bonnes donnés pour le journal etud
                db.session.add(justif)
                db.session.commit()

                Scolog.logdb(
                    method="edit_justificatif",
                    etudid=etud.id,
                    msg=f"justificatif modif: {justif}",
                )
            else:
                message = "Pas de modification"
            fichier_suppr: list[str] = request.form.getlist("suppr_fichier_just")

            if len(fichier_suppr) > 0 and justif.fichier is not None:
                archiver: JustificatifArchiver = JustificatifArchiver()
                for fichier in fichier_suppr:
                    archiver.delete_justificatif(etud, justif.fichier, fichier)
                    flash(f"Fichier {fichier} supprimé")

        else:
            justif = Justificatif.create_justificatif(
                etud,
                dt_debut_tz_server,
                dt_fin_tz_server,
                etat=etat,
                raison=form.raison.data,
                entry_date=dt_entry_date_tz_server,
                user_id=current_user.id,
            )
            message = "Justificatif créé"
        db.session.add(justif)
        if not _upload_justificatif_files(justif, form):
            flash("Erreur enregistrement fichiers")
            log("problem in _upload_justificatif_files, rolling back")
            db.session.rollback()
            return False
        db.session.commit()
        justif.justifier_assiduites()
        scass.simple_invalidate_cache(justif.to_dict(), etud.id)
        flash(message)
        return True
    except ScoValueError as exc:
        log(f"_record_justificatif_etud: erreur {exc.args[0]}")
        db.session.rollback()
        form.set_error(f"Erreur: {exc.args[0]}")
        return False


def _upload_justificatif_files(
    just: Justificatif, form: AjoutJustificatifEtudForm
) -> bool:
    """Enregistre les fichiers du formulaire de création de justificatif"""
    # Utilisation de l'archiver de justificatifs
    archiver: JustificatifArchiver = JustificatifArchiver()
    archive_name: str = just.fichier
    try:
        # On essaye de sauvegarder les fichiers
        for file in form.fichiers.data or []:
            archive_name, _ = archiver.save_justificatif(
                just.etudiant,
                filename=file.filename,
                data=file.stream.read(),
                archive_name=archive_name,
                user_id=current_user.id,
            )
            flash(f"Fichier {file.filename} enregistré")
        if form.fichiers.data:
            # On actualise l'archive du justificatif
            just.fichier = archive_name
            db.session.add(just)
            db.session.commit()
        return True
    except ScoValueError as exc:
        log(
            f"_upload_justificatif_files: error on {file.filename} for etud {just.etudid}"
        )
        form.set_error(f"Erreur sur fichier justificatif: {exc.args[0]}")
        return False


@bp.route("/calendrier_assi_etud")
@scodoc
@permission_required(Permission.ScoView)
def calendrier_assi_etud():
    """
    Affichage d'un calendrier de l'assiduité de l'étudiant
    Args:
        etudid (int): l'identifiant de l'étudiant

    Returns:
        str: l'html généré
    """
    etud = Identite.get_etud(request.args.get("etudid"))

    # Options
    mode_demi: bool = scu.to_bool(request.args.get("mode_demi", "t"))
    show_pres: bool = scu.to_bool(request.args.get("show_pres", "f"))
    show_reta: bool = scu.to_bool(request.args.get("show_reta", "f"))
    annee_str = request.args.get("annee", "")
    if not annee_str:
        annee = scu.annee_scolaire()
    else:
        try:
            annee = int(annee_str)
        except ValueError as exc:
            raise ScoValueError("année invalide") from exc

    # Récupération des années d'étude de l'étudiant
    annees: list[int] = []
    for ins in etud.formsemestre_inscriptions:
        date_deb = ins.formsemestre.date_debut
        date_fin = ins.formsemestre.date_fin
        annees.extend(
            [
                scu.annee_scolaire_repr(date_deb.year, date_deb.month),
                scu.annee_scolaire_repr(date_fin.year, date_fin.month),
            ]
        )
    annees = sorted(annees, reverse=True)

    # Transformation en une liste "json"
    # (sera utilisé pour générer le selecteur d'année)
    annees_str: str = json.dumps(annees)

    cal = CalendrierAssi(
        annee,
        etud,
        mode_demi=mode_demi,
        show_pres=show_pres,
        show_reta=show_reta,
    )
    calendrier: str = cal.get_html()

    # Peuplement du template jinja
    return render_template(
        "assiduites/pages/calendrier_assi_etud.j2",
        sco=ScoData(etud=etud),
        annee=annee,
        nonworkdays=_non_work_days(),
        annees=annees_str,
        calendrier=calendrier,
        mode_demi=mode_demi,
        show_pres=show_pres,
        show_reta=show_reta,
    )


@bp.route("/signal_assiduites_group")
@scodoc
@scass.check_disabled
@permission_required(Permission.AbsChange)
def signal_assiduites_group():
    """
    signal_assiduites_group Saisie des assiduités des groupes pour le jour donné

    Returns:
        str: l'html généré
    """
    # Récupération des paramètres de l'url
    #  formsemestre_id est optionnel si modimpl est indiqué
    formsemestre_id: int = request.args.get("formsemestre_id", -1)
    moduleimpl_id: int = request.args.get("moduleimpl_id")
    date: str = request.args.get("day", datetime.date.today().isoformat())
    heures: list[str] = [
        request.args.get("heure_deb", ""),
        request.args.get("heure_fin", ""),
    ]
    group_ids: list[int] = request.args.get("group_ids", None)
    if group_ids is None:
        group_ids = []
    else:
        group_ids = group_ids.split(",")
        map(str, group_ids)

    # Vérification du moduleimpl_id
    try:
        moduleimpl_id = int(moduleimpl_id)
    except (TypeError, ValueError):
        moduleimpl_id = None
    if moduleimpl_id is not None and moduleimpl_id >= 0:
        modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
    else:
        modimpl = None
    # Vérification du formsemestre_id
    try:
        formsemestre_id = int(formsemestre_id)
    except (TypeError, ValueError):
        formsemestre_id = None

    if (formsemestre_id < 0 or formsemestre_id is None) and modimpl:
        # si le module est spécifié mais pas le semestre:
        formsemestre_id = modimpl.formsemestre_id

    # Gestion des groupes
    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids,
        moduleimpl_id=moduleimpl_id,
        formsemestre_id=formsemestre_id,
        select_all_when_unspecified=True,
    )
    if not groups_infos.members:
        return render_template(
            "sco_page.j2",
            title="Saisie de l'assiduité",
            content="<h3>Aucun étudiant !</h3>",
        )

    # --- Filtrage par formsemestre ---
    formsemestre_id = groups_infos.formsemestre_id

    formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
    if formsemestre.dept_id != g.scodoc_dept_id:
        abort(404, "groupes inexistants dans ce département")

    # Récupération des étudiants des groupes
    etuds = [
        sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
        for m in groups_infos.members
    ]

    # --- Vérification de la date ---
    real_date = scu.is_iso_formated(date, True).date()

    if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin:
        # Si le jour est hors semestre, renvoyer vers choix date
        flash(
            "La date sélectionnée n'est pas dans le semestre. Choisissez une autre date."
        )

        return sco_gen_cal.calendrier_choix_date(
            formsemestre.date_debut,
            formsemestre.date_fin,
            url=url_for(
                "assiduites.signal_assiduites_group",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
                group_ids=",".join(group_ids),
                moduleimpl_id=moduleimpl_id,
                day="placeholder",
            ),
            mode="jour",
            titre="Choix de la date",
        )

    # --- Restriction en fonction du moduleimpl_id ---
    if moduleimpl_id:
        mod_inscrits = {
            x["etudid"]
            for x in sco_moduleimpl.do_moduleimpl_inscription_list(
                moduleimpl_id=moduleimpl_id
            )
        }
        etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits]
        if etuds_inscrits_module:
            etuds = etuds_inscrits_module
        else:
            # Si aucun etudiant n'est inscrit au module choisi...
            moduleimpl_id = None

    # Récupération du nom des/du groupe(s)

    if groups_infos.tous_les_etuds_du_sem:
        gr_tit = "en"
    else:
        if len(groups_infos.group_ids) > 1:
            grp = "des groupes"
        else:
            grp = "du groupe"
        gr_tit = (
            grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
        )

    # Récupération du semestre en dictionnaire
    sem = formsemestre.to_dict()

    # Page HTML
    return render_template(
        "assiduites/pages/signal_assiduites_group.j2",
        date=_dateiso_to_datefr(date),
        defdem=_get_etuds_dem_def(formsemestre),
        forcer_module=sco_preferences.get_preference(
            "forcer_module",
            formsemestre_id=formsemestre_id,
            dept_id=g.scodoc_dept_id,
        ),
        non_present=sco_preferences.get_preference(
            "non_present",
            formsemestre_id=formsemestre_id,
            dept_id=g.scodoc_dept_id,
        ),
        formsemestre_date_debut=str(formsemestre.date_debut),
        formsemestre_date_fin=str(formsemestre.date_fin),
        formsemestre_id=formsemestre_id,
        gr_tit=gr_tit,
        grp=sco_groups_view.menu_groups_choice(groups_infos),
        minitimeline=_mini_timeline(),
        moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
        nonworkdays=_non_work_days(),
        readonly="false",
        sco=ScoData(formsemestre=formsemestre),
        sem=sem["titre_num"],
        timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])),
        title="Saisie de l'assiduité",
    )


class RowEtudWithAssi(RowEtud):
    """Ligne de la table d'étudiants avec colonne Assiduité"""

    def __init__(
        self,
        table: TableEtud,
        etud: Identite,
        etat_assiduite: str,
        est_just: bool,
        *args,
        **kwargs,
    ):
        super().__init__(table, etud, *args, **kwargs)
        self.etat_assiduite = etat_assiduite
        self.est_just = est_just
        # remplace lien vers fiche par lien vers calendrier
        self.target_url = url_for(
            "assiduites.calendrier_assi_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
        )
        self.target_title = f"Calendrier de {etud.nomprenom}"

    def add_etud_cols(self):
        """Ajoute colonnes pour cet étudiant"""
        super().add_etud_cols()
        self.add_cell(
            "assi-type",
            "Présence",
            self.etat_assiduite,
            "assi-type",
        )
        self.classes += ["row-assiduite", self.etat_assiduite.lower()]

        if self.est_just:
            self.classes += ["justifiee"]


@bp.route("/etat_abs_date")
@scodoc
@permission_required(Permission.ScoView)
def etat_abs_date():
    """Tableau de l'état d'assiduité d'un ou plusieurs groupes
    sur la plage de dates date_debut, date_fin.
        group_ids   : ids de(s) groupe(s)
        date_debut, date_fin: format ISO
        evaluation_id: optionnel, évaluation concernée, pour titre et liens.
        date_debut, date_fin en ISO
        fmt : format export (xls, défaut html)
    """

    # Récupération des paramètres de la requête
    date_debut_str = request.args.get("date_debut")
    date_fin_str = request.args.get("date_fin")
    fmt = request.args.get("fmt", "html")
    group_ids = request.args.getlist("group_ids", int)
    evaluation_id = request.args.get("evaluation_id")
    evaluation = (
        Evaluation.get_evaluation(evaluation_id) if evaluation_id is not None else None
    )

    # Vérification des dates
    try:
        date_debut = datetime.datetime.fromisoformat(date_debut_str)
    except ValueError as exc:
        raise ScoValueError("date_debut invalide") from exc
    try:
        date_fin = datetime.datetime.fromisoformat(date_fin_str)
    except ValueError as exc:
        raise ScoValueError("date_fin invalide") from exc

    # Les groupes:
    groups = [GroupDescr.get_or_404(group_id) for group_id in group_ids]
    # Les étudiants de tous les groupes sélectionnés, flat list
    etuds = [
        etud for gr_etuds in [group.etuds for group in groups] for etud in gr_etuds
    ]

    # Récupération des assiduites des étudiants
    assiduites: Assiduite = Assiduite.query.filter(
        Assiduite.etudid.in_([etud.id for etud in etuds])
    )
    # Filtrage des assiduités en fonction des dates données
    assiduites = scass.filter_by_date(assiduites, Assiduite, date_debut, date_fin)

    # Génération table
    table = TableEtud(row_class=RowEtudWithAssi)
    for etud in sorted(etuds, key=lambda e: e.sort_key):
        # On récupère l'état de la première assiduité sur la période
        assi = assiduites.filter_by(etudid=etud.id).first()
        etat = ""
        if assi is not None:
            if assi.etat != scu.EtatAssiduite.PRESENT:
                etat = scu.EtatAssiduite.inverse().get(assi.etat).name
            row = table.row_class(table, etud, etat, assi.est_just)
            row.add_etud_cols()
            table.add_row(row)

    if fmt.startswith("xls"):
        return scu.send_file(
            table.excel(),
            filename=f"assiduite-eval-{date_debut.isoformat()}",
            mime=scu.XLSX_MIMETYPE,
            suffix=scu.XLSX_SUFFIX,
        )
    return render_template(
        "assiduites/pages/etat_abs_date.j2",
        date_debut=date_debut,
        date_fin=date_fin,
        evaluation=evaluation,
        etuds=etuds,
        group_title=", ".join(gr.get_nom_with_part("tous") for gr in groups),
        sco=ScoData(
            formsemestre=evaluation.moduleimpl.formsemestre if evaluation else None
        ),
        table=table,
    )


@bp.route("/visu_assi_group")
@scodoc
@permission_required(Permission.ScoView)
def visu_assi_group():
    """Visualisation de l'assiduité d'un groupe entre deux dates.
    Paramètres:
     - date_debut, date_fin (format ISO)
     - fmt : format d'export, html (défaut) ou xls
     - group_ids : liste des groupes
     - formsemestre_modimpls_id: id d'un formasemestre, si fournit restreint les
        comptages aux assiduités liées à des modules de ce formsemestre.
    """

    # Récupération des paramètres de la requête
    dates = {
        "debut": request.args.get("date_debut"),
        "fin": request.args.get("date_fin"),
    }
    formsemestre_modimpls_id = request.args.get("formsemestre_modimpls_id")
    formsemestre_modimpls = (
        None
        if formsemestre_modimpls_id is None
        else FormSemestre.get_formsemestre(formsemestre_modimpls_id)
    )
    fmt = request.args.get("fmt", "html")

    group_ids: list[int] = request.args.get("group_ids", None)
    if group_ids is None:
        group_ids = []
    else:
        group_ids = group_ids.split(",")
        map(str, group_ids)

    # Récupération des groupes, du semestre et des étudiants
    groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids)
    formsemestre = db.session.get(FormSemestre, groups_infos.formsemestre_id)

    # Vérification de la désactivation de l'assiduité
    if err_msg := scass.has_assiduites_disable_pref(formsemestre):
        raise ScoValueError(err_msg, request.referrer)

    etuds = etuds_sorted_from_ids([m["etudid"] for m in groups_infos.members])

    # Génération du tableau des assiduités
    table: TableAssi = TableAssi(
        etuds=etuds,
        dates=list(dates.values()),
        formsemestre=formsemestre,
        formsemestre_modimpls=formsemestre_modimpls,
        convert_values=(fmt == "html"),
    )
    # Export en XLS
    if fmt.startswith("xls"):
        return scu.send_file(
            table.excel(),
            filename=f"assiduite-{groups_infos.groups_filename}",
            mime=scu.XLSX_MIMETYPE,
            suffix=scu.XLSX_SUFFIX,
        )

    # récupération du/des noms du/des groupes
    if groups_infos.tous_les_etuds_du_sem:
        gr_tit = ""
        grp = ""
    else:
        if len(groups_infos.group_ids) > 1:
            grp = "des groupes"
        else:
            grp = "du groupe"
        gr_tit = (
            grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
        )

    # Génération de la page
    return render_template(
        "assiduites/pages/visu_assi_group.j2",
        assi_metric=scu.translate_assiduites_metric(
            scu.translate_assiduites_metric(
                sco_preferences.get_preference(
                    "assi_metrique", dept_id=g.scodoc_dept_id
                ),
            ),
            inverse=False,
            short=False,
        ),
        date_debut=_dateiso_to_datefr(dates["debut"]),
        date_fin=_dateiso_to_datefr(dates["fin"]),
        gr_tit=gr_tit,
        group_ids=request.args.get("group_ids", None),
        sco=ScoData(formsemestre=groups_infos.get_formsemestre()),
        tableau=table.html(),
        title=f"Assiduité {grp} {groups_infos.groups_titles}",
    )


def _get_anne_sco_from_request() -> int | None:
    """La valeur du paramètre annee_sco de la requête GET,
    ou None si absent"""
    annee_sco: str | None = request.args.get("annee_sco", None)
    if annee_sco is None:
        return None
    # Vérification de l'année scolaire
    try:
        annee_sco_int = int(annee_sco)
    except (ValueError, TypeError) as exc:
        raise ScoValueError("Année scolaire invalide")

    return annee_sco_int


def _prepare_tableau(
    data: liste_assi.AssiJustifData,
    filename: str = "tableau-assiduites",
    afficher_etu: bool = True,
    filtre: liste_assi.AssiFiltre = None,
    options: liste_assi.AssiDisplayOptions = None,
    afficher_options: bool = True,
    moduleimpl_select: str = None,
    titre="Évènements enregistrés pour cet étudiant",
    cache_key: str = "",
    force_options: dict[str, object] = None,
    annee_sco: int | None = None,
) -> tuple[bool, Response | str]:
    """
    Prépare un tableau d'assiduités / justificatifs

    Cette fonction récupère dans la requête les arguments :

    annee_sco : int  -> XXX
    n_page : int -> XXX
    page_number : int -> XXX
    show_pres : bool -> Affiche les présences, par défaut False
    show_reta : bool -> Affiche les retard, par défaut False
    show_desc : bool -> Affiche les descriptions, par défaut False

    Returns:
        tuple[bool | Reponse|str ]:
            - bool : Vrai si la réponse est du Text/HTML
            - Reponse : du Text/HTML ou Une Reponse (téléchargement fichier)
    """

    show_pres: bool | str = request.args.get("show_pres", False)
    show_reta: bool | str = request.args.get("show_reta", False)
    show_desc: bool | str = request.args.get("show_desc", False)
    annee_sco = _get_anne_sco_from_request() if annee_sco is None else annee_sco
    nb_ligne_page: int = request.args.get("nb_ligne_page")
    # Vérification de nb_ligne_page
    try:
        nb_ligne_page: int = int(nb_ligne_page)
    except (ValueError, TypeError):
        nb_ligne_page = liste_assi.ListeAssiJusti.NB_PAR_PAGE

    page_number: int = request.args.get("n_page", 1)
    # Vérification de page_number
    try:
        page_number: int = int(page_number)
    except (ValueError, TypeError):
        page_number = 1

    fmt = request.args.get("fmt", "html")

    # Ordre
    ordre: tuple[str, str | bool] = None
    ordre_col: str = request.args.get("order_col", None)
    ordre_tri: str = request.args.get("order", None)
    if ordre_col is not None and ordre_tri is not None:
        ordre = (ordre_col, ordre_tri == "ascending")

    if options is None:
        options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions()

    options.remplacer(
        page=page_number,
        nb_ligne_page=nb_ligne_page,
        show_pres=show_pres,
        show_reta=show_reta,
        show_desc=show_desc,
        show_etu=afficher_etu,
        order=ordre,
        annee_sco=annee_sco,
    )

    if force_options is not None:
        options.remplacer(**force_options)

    table: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti(
        table_data=data,
        options=options,
        filtre=filtre,
        no_pagination=fmt.startswith("xls"),
        titre=cache_key,
    )

    if fmt.startswith("xls"):
        return False, scu.send_file(
            table.excel(),
            filename=filename,
            mime=scu.XLSX_MIMETYPE,
            suffix=scu.XLSX_SUFFIX,
        )

    return True, render_template(
        "assiduites/widgets/tableau.j2",
        table=table,
        total_pages=table.total_pages,
        options=options,
        afficher_options=afficher_options,
        titre=titre,
        moduleimpl_select=moduleimpl_select,
    )


@bp.route("/recup_assiduites_plage", methods=["POST"])
@scodoc
@permission_required(Permission.AbsChange)
def recup_assiduites_plage():
    """
    Renvoie un fichier excel contenant toutes les assiduités d'une plage
    La plage est définie par les valeurs "datedeb" et "datefin" du formulaire
    Par défaut tous les étudiants du département sont concernés
    Si le champs "formsemestre_id" est présent dans le formulaire et est non vide,
    seuls les étudiants inscrits dans ce semestre sont concernés.
    """

    date_deb: datetime.datetime = request.form.get("datedeb")
    date_fin: datetime.datetime = request.form.get("datefin")

    # Vérification des dates
    try:
        date_deb = datetime.datetime.strptime(date_deb, scu.DATE_FMT)
    except ValueError as exc:
        raise ScoValueError("date_debut invalide", dest_url=request.referrer) from exc
    try:
        date_fin = datetime.datetime.strptime(date_fin, scu.DATE_FMT)
    except ValueError as exc:
        raise ScoValueError("date_fin invalide", dest_url=request.referrer) from exc

    # Récupération des étudiants
    etuds: Query = []
    formsemestre_id: str | None = request.form.get("formsemestre_id")

    name: str = ""

    if formsemestre_id is not None and formsemestre_id != "":
        formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
        etuds = formsemestre.etuds
        name = formsemestre.session_id()
    else:
        dept: Departement = Departement.get_or_404(g.scodoc_dept_id)
        etuds = dept.etudiants
        name = dept.acronym

    # Récupération des assiduités/justificatifs
    etudids: list[int] = [etud.id for etud in etuds]
    assiduites: Query = Assiduite.query.filter(Assiduite.etudid.in_(etudids))
    justificatifs: Query = Justificatif.query.filter(Justificatif.etudid.in_(etudids))

    # Filtrage des assiduités/justificatifs en fonction des dates données
    assiduites = scass.filter_by_date(assiduites, Assiduite, date_deb, date_fin)
    justificatifs = scass.filter_by_date(
        justificatifs, Justificatif, date_deb, date_fin
    )

    table_data: liste_assi.AssiJustifData = liste_assi.AssiJustifData(
        assiduites_query=assiduites,
        justificatifs_query=justificatifs,
    )

    options: liste_assi.AssiDisplayOptions = liste_assi.AssiDisplayOptions(
        show_pres=True,
        show_reta=True,
        show_module=True,
        show_desc=True,
        show_etu=True,
        annee_sco=-1,
    )

    date_deb_str: str = date_deb.strftime("%d-%m-%Y")
    date_fin_str: str = date_fin.strftime("%d-%m-%Y")

    filename: str = f"assiduites_{name}_{date_deb_str}_{date_fin_str}"

    tableau: liste_assi.ListeAssiJusti = liste_assi.ListeAssiJusti(
        table_data,
        options=options,
        titre="tableau-dept-" + filename,
        no_pagination=True,
    )

    return scu.send_file(
        tableau.excel(),
        filename=filename,
        mime=scu.XLSX_MIMETYPE,
        suffix=scu.XLSX_SUFFIX,
    )


@bp.route("/tableau_assiduite_actions", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
def tableau_assiduite_actions():
    """Edition/suppression/information sur une assiduité ou un justificatif
    type = "assiduite" | "justificatif"
    action = "supprimer" | "details" | "justifier"
    """
    obj_type: str = request.args.get("type", "assiduite")
    action: str = request.args.get("action", "details")
    obj_id: str = int(request.args.get("obj_id", -1))

    objet: Assiduite | Justificatif
    objet_name = ""
    e = ""
    if obj_type == "assiduite":
        objet: Assiduite = Assiduite.get_or_404(obj_id)
        objet_name = scu.EtatAssiduite(objet.etat).version_lisible()
        e = scu.EtatAssiduite(objet.etat).e()
    else:
        objet: Justificatif = Justificatif.get_or_404(obj_id)
        objet_name = "Justificatif"

    # Suppression : attention, POST ou GET !
    if action == "supprimer":
        objet.supprime()
        flash(f"{objet_name} supprimé{e}")

        return redirect(request.referrer)

    # Justification d'une assiduité depuis le tableau
    if action == "justifier" and obj_type == "assiduite":
        # L'état est Valide si l'user à la permission JustifValidate
        etat: scu.EtatJustificatif = scu.EtatJustificatif.ATTENTE
        if current_user.has_permission(Permission.JustifValidate):
            etat = scu.EtatJustificatif.VALIDE
        else:
            flash(
                "Vous ne pouvez pas créer de justificatif valide,"
                + " il est automatiquement passé 'EN ATTENTE'",
            )

        # Création du justificatif correspondant
        justificatif_correspondant: Justificatif = Justificatif.create_justificatif(
            etudiant=objet.etudiant,
            date_debut=objet.date_debut,
            date_fin=objet.date_fin,
            etat=etat,
            user_id=current_user.id,
        )

        justificatif_correspondant.justifier_assiduites()
        scass.simple_invalidate_cache(
            justificatif_correspondant.to_dict(), objet.etudiant.id
        )
        if etat == scu.EtatJustificatif.VALIDE:
            flash(f"{objet_name} justifiée")
        return redirect(request.referrer)

    # Si on arrive ici, c'est que l'action n'est pas autorisée
    # cette fonction ne sert plus qu'à supprimer ou justifier
    flash("Méthode non autorisée", "error")
    return redirect(request.referrer)


def _preparer_objet(
    obj_type: str, objet: Assiduite | Justificatif, sans_gros_objet: bool = False
) -> dict:
    "Préparation d'un objet pour simplifier l'affichage jinja"
    objet_prepare: dict = objet.to_dict()
    if obj_type == "assiduite":
        objet_prepare["etat"] = (
            scu.EtatAssiduite(objet.etat).version_lisible().capitalize()
        )
        objet_prepare["real_etat"] = scu.EtatAssiduite(objet.etat).name.lower()
        objet_prepare["description"] = (
            "" if objet.description is None else objet.description
        )
        objet_prepare["description"] = objet_prepare["description"].strip()

        # Gestion du module
        objet_prepare["module"] = objet.get_module(True)

        # Gestion justification

        objet_prepare["justification"] = {
            "est_just": objet.est_just,
            "justificatifs": [],
        }

        if not sans_gros_objet:
            justificatifs: list[int] = get_assiduites_justif(objet.assiduite_id, False)
            for justi_id in justificatifs:
                justi: Justificatif = db.session.get(Justificatif, justi_id)
                objet_prepare["justification"]["justificatifs"].append(
                    _preparer_objet("justificatif", justi, sans_gros_objet=True)
                )

    else:  # objet == "justificatif"
        justif: Justificatif = objet
        objet_prepare["etat"] = (
            scu.EtatJustificatif(justif.etat).version_lisible().capitalize()
        )
        objet_prepare["real_etat"] = scu.EtatJustificatif(justif.etat).name.lower()
        objet_prepare["raison"] = "" if justif.raison is None else justif.raison
        objet_prepare["raison"] = objet_prepare["raison"].strip()

        objet_prepare["justification"] = {"assiduites": [], "fichiers": {}}
        if not sans_gros_objet:
            assiduites: list[Assiduite] = justif.get_assiduites()
            for assi in assiduites:
                objet_prepare["justification"]["assiduites"].append(
                    _preparer_objet("assiduite", assi, sans_gros_objet=True)
                )

            # fichiers justificatifs archivés:
            filenames, nb_files = justif.get_fichiers()
            objet_prepare["justification"]["fichiers"] = {
                "total": nb_files,
                "filenames": filenames,
            }

    objet_prepare["date_fin"] = objet.date_fin.strftime(scu.DATEATIME_FMT)
    objet_prepare["real_date_fin"] = objet.date_fin.isoformat()
    objet_prepare["date_debut"] = objet.date_debut.strftime(scu.DATEATIME_FMT)
    objet_prepare["real_date_debut"] = objet.date_debut.isoformat()

    objet_prepare["entry_date"] = objet.entry_date.strftime(scu.DATEATIME_FMT)

    objet_prepare["etud_nom"] = objet.etudiant.nomprenom

    if objet.user_id is not None:
        user: User = db.session.get(User, objet.user_id)
        objet_prepare["saisie_par"] = user.get_nomprenom()
    else:
        objet_prepare["saisie_par"] = "Inconnu"

    return objet_prepare


@bp.route("/signale_evaluation_abs/<int:evaluation_id>/<int:etudid>")
@scodoc
@permission_required(Permission.AbsChange)
def signale_evaluation_abs(etudid: int = None, evaluation_id: int = None):
    """
    Signale l'absence d'un étudiant à une évaluation
    Si la durée de l'évaluation est inférieure à 1 jour
    l'absence sera sur la période de l'évaluation
    sinon l'utilisateur sera redirigé vers la page de saisie des absences de l'étudiant
    """
    etud = Identite.get_etud(etudid)
    evaluation: Evaluation = Evaluation.get_or_404(evaluation_id)

    delta: datetime.timedelta = evaluation.date_fin - evaluation.date_debut
    # Si l'évaluation dure plus qu'un jour alors on redirige vers la page de saisie etudiant
    if delta > datetime.timedelta(days=1):
        # rediriger vers page saisie
        return redirect(
            url_for(
                "assiduites.ajout_assiduite_etud",
                etudid=etudid,
                evaluation_id=evaluation.id,
                date_deb=evaluation.date_debut.strftime("%Y-%m-%dT%H:%M:%S"),
                date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"),
                moduleimpl_id=evaluation.moduleimpl.id,
                saisie_eval="true",
                scodoc_dept=g.scodoc_dept,
            )
        )

    # Sinon on créé l'assiduité
    assiduite_unique: Assiduite | None = None
    try:
        assiduite_unique = Assiduite.create_assiduite(
            etud=etud,
            date_debut=scu.localize_datetime(evaluation.date_debut),
            date_fin=scu.localize_datetime(evaluation.date_fin),
            etat=scu.EtatAssiduite.ABSENT,
            moduleimpl=evaluation.moduleimpl,
        )
    except ScoValueError as exc:
        # En cas d'erreur
        msg: str = exc.args[0]
        if "Duplication" in msg:
            msg = """Une autre saisie concerne déjà cette période.
            En cliquant sur continuer vous serez redirigé vers la page de
            saisie de l'assiduité de l'étudiant."""
            dest: str = url_for(
                "assiduites.ajout_assiduite_etud",
                etudid=etudid,
                evaluation_id=evaluation.id,
                date_deb=evaluation.date_debut.strftime("%Y-%m-%dT%H:%M:%S"),
                date_fin=evaluation.date_fin.strftime("%Y-%m-%dT%H:%M:%S"),
                moduleimpl_id=evaluation.moduleimpl.id,
                saisie_eval="true",
                scodoc_dept=g.scodoc_dept,
                duplication="oui",
            )
            raise ScoValueError(msg, dest) from exc
    if assiduite_unique is not None:
        db.session.add(assiduite_unique)
        db.session.commit()

    # on flash puis on revient sur la page de l'évaluation
    flash("L'absence a bien été créée")
    # rediriger vers la page d'évaluation
    return redirect(
        url_for(
            "notes.evaluation_check_absences_html",
            evaluation_id=evaluation.id,
            scodoc_dept=g.scodoc_dept,
        )
    )


@bp.route("traitement_justificatifs")
@scodoc
@permission_required(Permission.JustifValidate)
@permission_required(Permission.AbsJustifView)
@scass.check_disabled
def traitement_justificatifs():
    """Page de traitement des justificatifs
    On traite les justificatifs par formsemestre
    On peut Valider, Invalider ou mettre en ATT
    """
    # Récupération du formsemestre
    formsemestre_id: int = request.args.get("formsemestre_id", -1)
    formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)

    lignes: list[dict] = []

    # Récupération des justificatifs
    justificatifs_query: Query = scass.filter_by_formsemestre(
        Justificatif.query, Justificatif, formsemestre
    )
    justificatifs_query = justificatifs_query.filter(
        Justificatif.etat.in_(
            [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE]
        )
    ).order_by(Justificatif.date_debut)

    justif: Justificatif
    for justif in justificatifs_query:
        etud: Identite = justif.etudiant
        assi_stats: tuple[int, int, int] = scass.get_assiduites_count(
            etud.id, formsemestre.to_dict()
        )
        etud_dict: dict = {
            "id": etud.id,
            "nom": etud.nom,
            "prenom": etud.prenom,
            "nomprenom": etud.nomprenom,
            "stats": assi_stats,
            "sort_key": etud.sort_key,
        }

        assiduites_justifiees: list[Assiduite] = justif.get_assiduites().all()

        # fichiers justificatifs archivés:
        filenames, nb_files = justif.get_fichiers()
        fichiers = {
            "total": nb_files,
            "filenames": filenames,
        }

        lignes.append(
            {
                "etud": etud_dict,
                "justif": justif,
                "assiduites": assiduites_justifiees,
                "fichiers": fichiers,
                "etat": scu.EtatJustificatif(justif.etat).name.lower(),
            }
        )

    # Tri en fonction du nom des étudiants
    lignes = sorted(lignes, key=lambda x: x["etud"]["sort_key"])

    return render_template(
        "assiduites/pages/traitement_justificatifs.j2",
        formsemestre=formsemestre,
        sco=ScoData(formsemestre=formsemestre),
        lignes=lignes,
    )


@bp.route("signal_assiduites_hebdo")
@scodoc
@permission_required(Permission.ScoView)
@scass.check_disabled
def signal_assiduites_hebdo():
    """
    signal_assiduites_hebdo

    paramètres obligatoires :
    - formsemestre_id : id du formsemestre
    - groups_id : id des groupes (séparés par des virgules -> 1,2,3)

    paramètres optionnels :
    - week : date semaine (iso 8601 -> 20XX-WXX), par défaut la semaine actuelle
    - moduleimpl_id : id du moduleimpl (par défaut None)


    Permissions :
    - ScoView -> page en lecture seule
    - AbsChange -> page en lecture/écriture
    """

    # Récupération des paramètres
    moduleimpl_id: int = request.args.get("moduleimpl_id", None)
    group_ids: str = request.args.get("group_ids", "")  # ex: "1,2,3"
    formsemestre_id: int = request.args.get("formsemestre_id", -1)
    week: str = request.args.get("week", datetime.datetime.now().strftime("%G-W%V"))
    # Vérification des paramètres
    if group_ids == "" or formsemestre_id == -1:
        raise ScoValueError("Paramètres manquants", dest_url=request.referrer)

    # Récupération du moduleimpl
    try:
        moduleimpl_id: int = int(moduleimpl_id)
    except (ValueError, TypeError):
        moduleimpl_id: str | None = None if moduleimpl_id != "autre" else moduleimpl_id

    # Récupération du formsemestre
    formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)

    # Vérification semaine dans format iso 8601 et formsemestre
    regex_iso8601 = r"^\d{4}-W\d{2}$"
    if week and not re.match(regex_iso8601, week):
        raise ScoValueError("Semaine invalide", dest_url=request.referrer)

    fs_deb_iso8601 = formsemestre.date_debut.strftime("%Y-W%W")
    fs_fin_iso8601 = formsemestre.date_fin.strftime("%Y-W%W")

    # Utilisation de la propriété de la norme iso 8601
    # les chaines sont triables par ordre alphanumérique croissant
    # et produiront le même ordre que les dates par ordre chronologique croissant
    if (not week) or week < fs_deb_iso8601 or week > fs_fin_iso8601:
        if week:
            flash(
                """La semaine n'est pas dans le semestre,
                choisissez la semaine sur laquelle saisir l'assiduité"""
            )
        return sco_gen_cal.calendrier_choix_date(
            date_debut=formsemestre.date_debut,
            date_fin=formsemestre.date_fin,
            url=url_for(
                "assiduites.signal_assiduites_hebdo",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
                group_ids=group_ids,
                moduleimpl_id=moduleimpl_id,
                week="placeholder",
            ),
            mode="semaine",
            titre="Choix de la semaine",
        )

    # Vérification des groupes
    group_ids = group_ids.split(",") if group_ids != "" else []

    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True
    )
    if not groups_infos.members:
        return render_template(
            "sco_page.j2",
            title="Assiduité: feuille saisie hebdomadaire",
            content="<h3>Aucun étudiant !</h3>",
        )

    # Récupération des étudiants
    etudiants: list[Identite] = [
        Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members
    ]

    if groups_infos.tous_les_etuds_du_sem:
        gr_tit = "en"
    else:
        if len(groups_infos.group_ids) > 1:
            grp = "des groupes"
        else:
            grp = "du groupe"
        gr_tit = (
            grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
        )

    # Gestion des jours
    jours: dict[str, list[str]] = {
        "lun": [
            "Lundi",
            datetime.datetime.strptime(week + "-1", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "mar": [
            "Mardi",
            datetime.datetime.strptime(week + "-2", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "mer": [
            "Mercredi",
            datetime.datetime.strptime(week + "-3", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "jeu": [
            "Jeudi",
            datetime.datetime.strptime(week + "-4", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "ven": [
            "Vendredi",
            datetime.datetime.strptime(week + "-5", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "sam": [
            "Samedi",
            datetime.datetime.strptime(week + "-6", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "dim": [
            "Dimanche",
            datetime.datetime.strptime(week + "-7", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
    }

    non_travail = sco_preferences.get_preference("non_travail")
    non_travail = non_travail.replace(" ", "").split(",")

    hebdo_jours: list[tuple[bool, str]] = []
    for key, val in jours.items():
        hebdo_jours.append((key in non_travail, val))

    url_choix_semaine = url_for(
        "assiduites.signal_assiduites_hebdo",
        group_ids=",".join(map(str, groups_infos.group_ids)),
        week="",
        scodoc_dept=g.scodoc_dept,
        formsemestre_id=groups_infos.formsemestre_id,
        moduleimpl_id=moduleimpl_id,
    )

    erreurs: list = session.pop("feuille_abs_hebdo-erreurs", [])

    return render_template(
        "assiduites/pages/signal_assiduites_hebdo.j2",
        title="Assiduité: saisie hebdomadaire",
        gr=gr_tit,
        etudiants=etudiants,
        moduleimpl_select=_module_selector(
            formsemestre=formsemestre, moduleimpl_id=moduleimpl_id
        ),
        hebdo_jours=hebdo_jours,
        readonly=not current_user.has_permission(Permission.AbsChange),
        non_present=sco_preferences.get_preference(
            "non_present",
            formsemestre_id=formsemestre_id,
            dept_id=g.scodoc_dept_id,
        ),
        url_choix_semaine=url_choix_semaine,
        query_string=request.query_string.decode(encoding="utf-8"),
        erreurs=erreurs,
    )


@bp.route("edit_assiduite_etud/<int:assiduite_id>", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
def edit_assiduite_etud(assiduite_id: int):
    """
    Page affichant les détails d'une assiduité
    Si le current_user alors la page propose un formulaire de modification
    """
    try:
        assi: Assiduite = Assiduite.get_assiduite(assiduite_id=assiduite_id)
    except HTTPException:
        flash("Assiduité invalide")
        return redirect(url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept))

    etud: Identite = assi.etudiant
    formsemestre: FormSemestre = assi.get_formsemestre()

    # Vérification de la désactivation de l'assiduité
    if err_msg := scass.has_assiduites_disable_pref(formsemestre):
        raise ScoValueError(err_msg, request.referrer)

    readonly: bool = not current_user.has_permission(Permission.AbsChange)

    form: EditAssiForm = EditAssiForm(request.form)
    if readonly:
        form.disable_all()

    # peuplement moduleimpl_select
    choices: OrderedDict = OrderedDict()
    choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]

    # Récupération des modulesimpl du semestre si existant.
    if formsemestre:
        # indique le nom du semestre dans le menu (optgroup)
        modimpls_from_formsemestre = etud.get_modimpls_from_formsemestre(formsemestre)
        group_name: str = formsemestre.titre_annee()
        choices[group_name] = [
            (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}")
            for m in modimpls_from_formsemestre
            if m.module.ue.type == UE_STANDARD
        ]

    choices.move_to_end("", last=False)
    form.modimpl.choices = choices

    # Vérification formulaire
    if form.validate_on_submit():
        if form.cancel.data:  # cancel button
            return redirect(request.referrer)

        # vérification des valeurs

        # Gestion de l'état
        etat = form.assi_etat.data
        try:
            etat = int(etat)
            etat = scu.EtatAssiduite.inverse().get(etat, None)
        except ValueError:
            etat = None

        if etat is None:
            form.error_messages.append("État invalide")
            form.ok = False

        description = form.description.data or ""
        description = description.strip()
        moduleimpl_id = form.modimpl.data if form.modimpl.data is not None else -1
        # Vérifications des dates / horaires

        ok, dt_deb, dt_fin, dt_entry = _get_dates_from_assi_form(
            form, etud, from_justif=True, formsemestre=formsemestre
        )
        if ok:
            if is_period_conflicting(
                dt_deb, dt_fin, etud.assiduites, Assiduite, assi.id
            ):
                form.set_error("La période est en conflit avec une autre assiduité")
                form.ok = False

        if form.ok:
            assi.etat = etat
            assi.description = description
            if moduleimpl_id != -1:
                assi.set_moduleimpl(moduleimpl_id)

            assi.date_debut = dt_deb
            assi.date_fin = dt_fin
            assi.entry_date = dt_entry

            db.session.add(assi)
            db.session.commit()

            scass.simple_invalidate_cache(assi.to_dict(format_api=True), assi.etudid)

            flash("enregistré")
            return redirect(request.referrer)

    # Remplissage du formulaire
    form.assi_etat.data = str(assi.etat)
    form.description.data = assi.description
    moduleimpl_id: int | str | None = assi.get_moduleimpl_id() or ""
    form.modimpl.data = str(moduleimpl_id)

    form.date_debut.data = assi.date_debut.strftime(scu.DATE_FMT)
    form.heure_debut.data = assi.date_debut.strftime(scu.TIME_FMT)
    form.date_fin.data = assi.date_fin.strftime(scu.DATE_FMT)
    form.heure_fin.data = assi.date_fin.strftime(scu.TIME_FMT)
    form.entry_date.data = assi.entry_date.strftime(scu.DATE_FMT)
    form.entry_time.data = assi.entry_date.strftime(scu.TIME_FMT)

    return render_template(
        "assiduites/pages/edit_assiduite_etud.j2",
        etud=etud,
        sco=ScoData(etud=etud, formsemestre=formsemestre),
        form=form,
        readonly=readonly,
        objet=_preparer_objet("assiduite", assi),
        title=f"Assiduité {etud.nom_short}",
    )


def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str:
    """Génère la liste des assiduités d'un étudiant pour le bulletin mail"""

    # On récupère la métrique d'assiduité
    metrique: str = scu.translate_assiduites_metric(
        sco_preferences.get_preference("assi_metrique", formsemestre_id=semestre.id),
    )

    # On récupère le nombre maximum de ligne d'assiduité
    max_nb: int = int(
        sco_preferences.get_preference(
            "bul_mail_list_abs_nb", formsemestre_id=semestre.id
        )
    )

    # On récupère les assiduités et les justificatifs de l'étudiant
    assiduites = scass.filter_by_formsemestre(
        etud.assiduites, Assiduite, semestre
    ).order_by(Assiduite.entry_date.desc())
    justificatifs = scass.filter_by_formsemestre(
        etud.justificatifs, Justificatif, semestre
    ).order_by(Justificatif.entry_date.desc())

    # On calcule les statistiques
    stats: dict = scass.get_assiduites_stats(
        assiduites, metric=metrique, filtered={"split": True}
    )

    # On sépare :
    # - abs_j = absences justifiées
    # - abs_nj = absences non justifiées
    # - retards = les retards
    # - justifs = les justificatifs

    abs_j: list[str] = [
        {"date": _get_date_str(assi.date_debut, assi.date_fin)}
        for assi in assiduites
        if assi.etat == scu.EtatAssiduite.ABSENT and assi.est_just is True
    ]
    abs_nj: list[str] = [
        {"date": _get_date_str(assi.date_debut, assi.date_fin)}
        for assi in assiduites
        if assi.etat == scu.EtatAssiduite.ABSENT and assi.est_just is False
    ]
    retards: list[str] = [
        {"date": _get_date_str(assi.date_debut, assi.date_fin)}
        for assi in assiduites
        if assi.etat == scu.EtatAssiduite.RETARD
    ]

    justifs: list[dict[str, str]] = [
        {
            "date": _get_date_str(justi.date_debut, justi.date_fin),
            "raison": "" if justi.raison is None else justi.raison,
            "etat": {
                scu.EtatJustificatif.VALIDE: "justificatif valide",
                scu.EtatJustificatif.NON_VALIDE: "justificatif invalide",
                scu.EtatJustificatif.ATTENTE: "justificatif en attente de validation",
                scu.EtatJustificatif.MODIFIE: "justificatif ayant été modifié",
            }.get(justi.etat),
        }
        for justi in justificatifs
    ]

    return render_template(
        "assiduites/widgets/liste_assiduites_mail.j2",
        abs_j=abs_j[:max_nb],
        abs_nj=abs_nj[:max_nb],
        retards=retards[:max_nb],
        justifs=justifs[:max_nb],
        stats=stats,
        metrique=scu.translate_assiduites_metric(metrique, short=True, inverse=False),
        metric=metrique,
    )


@bp.route("feuille_abs_hebdo", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
@scass.check_disabled
def feuille_abs_hebdo():
    """
    GET : Renvoie un tableau excel pour permettre la saisie des absences
    POST: Enregistre les absences saisies et renvoie sur la page de saisie hebdomadaire
    Affiche un choix de semaine si "week" n'est pas renseigné

    Si POST:
        renvoie sur la page saisie_assiduites_hebdo
    """

    # Récupération des groupes
    group_ids: str = request.args.get("group_ids", "")
    if group_ids == "":
        raise ScoValueError("Paramètre 'group_ids' manquant", dest_url=request.referrer)

    # Vérification du semestre
    formsemestre_id: int = request.args.get("formsemestre_id", -1)
    formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)

    # Vériication de la semaine
    week: str = request.args.get("week", datetime.datetime.now().strftime("%G-W%V"))

    regex_iso8601 = r"^\d{4}-W\d{2}$"
    if week and not re.match(regex_iso8601, week):
        raise ScoValueError("Semaine invalide", dest_url=request.referrer)

    fs_deb_iso8601 = formsemestre.date_debut.strftime("%Y-W%W")
    fs_fin_iso8601 = formsemestre.date_fin.strftime("%Y-W%W")

    # Utilisation de la propriété de la norme iso 8601
    # les chaines sont triables par ordre alphanumérique croissant
    # et produiront le même ordre que les dates par ordre chronologique croissant
    if (not week) or week < fs_deb_iso8601 or week > fs_fin_iso8601:
        if week:
            flash(
                """La semaine n'est pas dans le semestre,
                choisissez la semaine sur laquelle saisir l'assiduité"""
            )
        return sco_gen_cal.calendrier_choix_date(
            date_debut=formsemestre.date_debut,
            date_fin=formsemestre.date_fin,
            url=url_for(
                "assiduites.feuilles_abs_hebdo",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
                group_ids=group_ids,
                week="placeholder",
            ),
            mode="semaine",
            titre="Choix de la semaine",
        )

    # Vérification des groupes
    group_ids = group_ids.split(",") if group_ids != "" else []

    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True
    )
    if not groups_infos.members:
        return render_template(
            "sco_page.j2",
            title="Assiduité: feuille saisie hebdomadaire",
            content="<h3>Aucun étudiant !</h3>",
        )

    # Gestion des jours
    jours: dict[str, list[str]] = {
        "lun": [
            "Lundi",
            datetime.datetime.strptime(week + "-1", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "mar": [
            "Mardi",
            datetime.datetime.strptime(week + "-2", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "mer": [
            "Mercredi",
            datetime.datetime.strptime(week + "-3", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "jeu": [
            "Jeudi",
            datetime.datetime.strptime(week + "-4", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "ven": [
            "Vendredi",
            datetime.datetime.strptime(week + "-5", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "sam": [
            "Samedi",
            datetime.datetime.strptime(week + "-6", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
        "dim": [
            "Dimanche",
            datetime.datetime.strptime(week + "-7", "%G-W%V-%u").strftime("%d/%m/%Y"),
        ],
    }

    non_travail = sco_preferences.get_preference("non_travail")
    non_travail = non_travail.replace(" ", "").split(",")

    hebdo_jours: list[tuple[bool, str]] = []
    for key, val in jours.items():
        hebdo_jours.append((key in non_travail, val))

    if request.method == "POST":
        url_saisie: str = url_for(
            "assiduites.signal_assiduites_hebdo",
            scodoc_dept=g.scodoc_dept,
            **request.args,
        )
        # Vérification du fichier
        file = request.files.get("file", None)
        if file is None or file.filename == "":
            flash("Erreur : Pas de fichier")
            return redirect(url_saisie)
        # Vérification des heures
        heures: list[str] = request.form.get("heures", "").split(",")
        if len(heures) != 4:
            flash("Erreur : Les heures sont incorrectes")
            return redirect(url_saisie)
        # Récupération du moduleimpl
        moduleimpl_id = request.form.get("moduleimpl_id")

        # Enregistrement des assiduites
        erreurs = _import_feuille_abs_hebdo(
            file,
            heures=["08:15", "13:00", "13:00", "18:15"],
            moduleimpl_id=moduleimpl_id,
        )

        if erreurs:
            session["feuille_abs_hebdo-erreurs"] = erreurs

        return redirect(url_saisie)

    filename = f"feuille_signal_abs_{week}"
    xls = _excel_feuille_abs(
        formsemestre=formsemestre, groups_infos=groups_infos, hebdo_jours=hebdo_jours
    )
    return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)


@bp.route("feuille_abs_formsemestre", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.AbsChange)
@scass.check_disabled
def feuille_abs_formsemestre():
    """
    Permet l'importation d'une liste d'assiduités depuis un fichier excel
    GET:
        Affiche un formulaire pour l'import et les erreurs lors de l'import
    POST:
        Nécessite un fichier excel contenant une liste d'assiduités (file)

    """
    # Récupération du formsemestre
    formsemestre_id: int = request.args.get("formsemestre_id", -1)
    formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
    erreurs: list = []
    if request.method == "POST":
        # Récupération et vérification du fichier
        file = request.files.get("file")
        if file is None or file.filename == "":
            raise ScoValueError("Pas de fichier", dest_url=request.referrer)

        # Récupération du type d'identifiant
        type_identifiant: str = request.form.get("type_identifiant", "etudid")

        # Importation du fichier
        erreurs = _import_excel_assiduites_list(
            file, formsemestre=formsemestre, type_etud_identifiant=type_identifiant
        )

        if erreurs:
            flash("Erreurs lors de l'importation, voir bas de page", "error")
        else:
            flash("Importation réussie")

    return render_template(
        "assiduites/pages/feuille_abs_formsemestre.j2",
        erreurs=erreurs,
        titre_form=formsemestre.titre_annee(),
        sco=ScoData(formsemestre=formsemestre),
    )


# --- Fonctions internes ---


def _import_feuille_abs_hebdo(
    file, heures: list[str], moduleimpl_id: int = None
) -> list:
    """
    Importe un fichier excel au format de la feuille d'absence hebdomadaire
    (voir _excel_feuille_abs)

    Génère les assiduités correspondantes et retourne une liste d'erreurs

    Les erreurs sont sous la forme :
    (message, [num_ligne, ...contenu_ligne])

    Attention : num_ligne correspond au numéro de la ligne dans le fichier excel
    """

    data: list = sco_excel.excel_file_to_list(file)
    erreurs: list = []

    # Récupération des jours (entête du tableau)
    jours: list[str] = [
        str_jour.split(" ")[1] for str_jour in data[1][4][4:] if str_jour
    ]

    lettres: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"

    # On récupère uniquement les lignes d'étudiants (on ignore les headers)
    data: list = data[1][6:]

    # Chaque ligne commence par [!etudid][nom][prenom][groupe]
    for num, ligne in enumerate(data):
        etudid = ligne[0].replace("!", "")  # on enlève le point d'exclamation
        try:
            etud = Identite.get_etud(etudid)
        except HTTPException as exc:
            erreurs.append((exc.description, [num + 6, "A"] + ligne))
            continue

        for i, etat in enumerate(ligne[4:]):
            try:
                etat: str = etat.strip().upper()
                if etat:
                    # Vérification de l'état
                    if etat not in ["ABS", "RET", "PRE"]:
                        raise ScoValueError(f"État invalide => {etat}")

                    etat: scu.EtatAssiduite = {
                        "ABS": scu.EtatAssiduite.ABSENT,
                        "RET": scu.EtatAssiduite.RETARD,
                        "PRE": scu.EtatAssiduite.PRESENT,
                    }.get(etat, scu.EtatAssiduite.ABSENT)
                else:
                    continue

                # Génération des dates de début et de fin de l'assiduité
                heure_debut: str = heures[0] if i % 2 == 0 else heures[2]
                heure_fin: str = heures[1] if i % 2 == 0 else heures[3]

                try:
                    date_debut: datetime.datetime = datetime.datetime.strptime(
                        jours[i // 2] + " " + heure_debut, "%d/%m/%Y %H:%M"
                    )
                    date_fin: datetime.datetime = datetime.datetime.strptime(
                        jours[i // 2] + " " + heure_fin, "%d/%m/%Y %H:%M"
                    )
                except ValueError as exc:
                    raise ScoValueError("Dates Invalides") from exc

                # On met les dates à la timezone du serveur
                date_debut = scu.TIME_ZONE.localize(date_debut)
                date_fin = scu.TIME_ZONE.localize(date_fin)

                # Création de l'assiduité
                assiduite: Assiduite = Assiduite.create_assiduite(
                    etud=etud,
                    date_debut=date_debut,
                    date_fin=date_fin,
                    etat=etat,
                    user_id=current_user.id,
                )

                if moduleimpl_id:
                    assiduite.set_moduleimpl(moduleimpl_id)

                db.session.add(assiduite)
                scass.simple_invalidate_cache(assiduite.to_dict())
            except HTTPException as exc:
                erreurs.append((exc.description, [num + 6, lettres[i + 4]] + ligne))
            except ScoValueError as exc:
                erreurs.append((exc.args[0], [num + 6, lettres[i + 4]] + ligne))
                continue

    # On commit les changements
    db.session.commit()

    return erreurs


def _import_excel_assiduites_list(
    file, formsemestre: FormSemestre, type_etud_identifiant: str = "etudid"
) -> list:
    """
    Importe un fichier excel contenant une liste d'assiduités
    sous le format :

    | etudid/nip/ine | date_debut | date_fin | etat [optionnel -> "ABS"] | Module [optionnel -> None] |

    Génère les assiduités correspondantes et retourne une liste d'erreurs

    Les erreurs sont sous la forme :
    (message, [num_ligne, ...contenu_ligne])

    Attention : num_ligne correspond au numéro de la ligne dans le fichier excel
    """

    # On récupère les données du fichier
    data: list = sco_excel.excel_file_to_list(file)

    # On récupère le deuxième élément de la liste (les lignes du tableur)
    # Le premier element de la liste correspond à la description des feuilles excel
    data: list = data[1]

    # On parcourt les lignes et on les traite
    erreurs: list[tuple[str, list]] = []
    for num, ligne in enumerate(data):
        identifiant_etud = ligne[0]  # etudid/nip/ine
        date_debut_str = ligne[1]  # iso / fra / excel
        date_fin_str = ligne[2]  # iso / fra / excel
        etat = ligne[3].strip().upper()  # etat abs par défaut, sinon RET ou PRE
        etat = etat or "ABS"
        module = ligne[4] or None  # code du module
        moduleimpl: ModuleImpl | None = None
        try:
            # On récupère l'étudiant
            etud: Identite = _find_etud(identifiant_etud, type_etud_identifiant)

            # On vérifie que l'étudiant appartient au semestre
            if formsemestre not in etud.get_formsemestres():
                raise ScoValueError("Étudiant non inscrit dans le semestre")

            # On transforme les dates
            date_debut: datetime.datetime = _try_parse_date(date_debut_str)
            date_fin: datetime.datetime = _try_parse_date(date_fin_str)

            # On met les dates à la timezone du serveur
            date_debut = scu.TIME_ZONE.localize(date_debut)
            date_fin = scu.TIME_ZONE.localize(date_fin)

            # Vérification de l'état
            if etat not in ["ABS", "RET", "PRE"]:
                raise ScoValueError(f"État invalide => {etat}")

            etat: scu.EtatAssiduite = {
                "ABS": scu.EtatAssiduite.ABSENT,
                "RET": scu.EtatAssiduite.RETARD,
                "PRE": scu.EtatAssiduite.PRESENT,
            }.get(etat, scu.EtatAssiduite.ABSENT)

            # On récupère le moduleimpl à partir du code du module et du formsemestre
            if module:
                moduleimpl = _get_moduleimpl_from_code(module, formsemestre)

            assiduite: Assiduite = Assiduite.create_assiduite(
                etud=etud,
                date_debut=date_debut,
                date_fin=date_fin,
                etat=etat,
                moduleimpl=moduleimpl,
            )
            db.session.add(assiduite)
            scass.simple_invalidate_cache(assiduite.to_dict())

        except ScoValueError as exc:
            erreurs.append((exc.args[0], [num + 1] + ligne))
        except HTTPException as exc:
            erreurs.append((exc.description, [num + 1] + ligne))

    db.session.commit()
    return erreurs


def _get_moduleimpl_from_code(
    module_code: str, formsemestre: FormSemestre
) -> ModuleImpl:
    query: Query = ModuleImpl.query.filter(
        ModuleImpl.module.has(Module.code == module_code),
        ModuleImpl.formsemestre_id == formsemestre.id,
    )

    moduleimpl: ModuleImpl = query.first()
    if moduleimpl is None:
        raise ScoValueError("Module non trouvé")
    return moduleimpl


def _try_parse_date(date: str) -> datetime.datetime:
    """
    Tente de parser une date sous différents formats
    renvoie la première date valide
    """

    # On tente de parser la date en iso (yyyy-mm-ddThh:mm:ss)
    try:
        return datetime.datetime.fromisoformat(date)
    except ValueError:
        pass

    # On tente de parser la date en français (dd/mm/yyyy hh:mm:ss)
    try:
        return datetime.datetime.strptime(date, "%d/%m/%Y %H:%M:%S")
    except ValueError:
        pass

    raise ScoValueError("Date invalide")


def _find_etud(identifiant: str, type_identifiant: str) -> Identite:
    """
    Renvoie l'étudiant correspondant à l'identifiant
    """
    if type_identifiant == "etudid":
        return tools.get_etud(etudid=identifiant)
    elif type_identifiant == "nip":
        return tools.get_etud(nip=identifiant)
    elif type_identifiant == "ine":
        return tools.get_etud(ine=identifiant)
    else:
        raise ScoValueError("Type d'identifiant invalide")


def _excel_feuille_abs(
    formsemestre: FormSemestre,
    groups_infos: sco_groups_view.DisplayedGroupsInfos,
    hebdo_jours: list[tuple[bool, str]],
):
    """
    Génère un fichier excel pour la saisie des absences hebdomadaires

    Colonnes :
        - A : [formsemestre_id][etudid...]
        - B : [nom][nom...]
        - C : [prenom][prenom...]
        - D : [groupes][groupe...]
        - 2 colonnes (matin/aprem) par jour de la semaine
    """

    ws = sco_excel.ScoExcelSheet("Saisie_ABS")

    # == Préparation des données ==
    lines: list[tuple] = []

    for membre in groups_infos.members:
        etudid = membre["etudid"]
        groups = sco_groups.get_etud_groups(etudid, formsemestre_id=formsemestre.id)
        grc = sco_groups.listgroups_abbrev(groups)
        line = [
            str(etudid),
            membre["nom"].upper(),
            membre["prenom"].lower().capitalize(),
            membre["etat"],
            grc,
        ]
        line += ["" for _ in range(len(hebdo_jours) * 2)]
        lines.append(line)

    # == Préparation du fichier ==

    # colonnes
    lettres: str = "EFGHIJKLMNOPQRSTUVWXYZ"
    # ajuste largeurs colonnes (unite inconnue, empirique)
    ws.set_column_dimension_width("A", 11.0 / 7)  # codes
    # ws.set_column_dimension_hidden("A", True)  # codes
    ws.set_column_dimension_width("B", 164.00 / 7)  # noms
    ws.set_column_dimension_width("C", 109.0 / 7)  # prenoms
    ws.set_column_dimension_width("D", 164.0 / 7)  # groupes

    i: int = 0
    for jour in hebdo_jours:
        ws.set_column_dimension_width(lettres[i], 100.0 / 7)
        ws.set_column_dimension_width(lettres[i + 1], 100.0 / 7)
        if jour[0]:
            ws.set_column_dimension_hidden(lettres[i], True)
            ws.set_column_dimension_hidden(lettres[i + 1], True)
        i += 2

    # fontes
    font_base = sco_excel.Font(name="Arial", size=12)
    font_bold = sco_excel.Font(name="Arial", bold=True)
    font_italic = sco_excel.Font(
        name="Arial", size=12, italic=True, color=sco_excel.COLORS.RED.value
    )
    font_titre = sco_excel.Font(name="Arial", bold=True, size=14)
    font_purple = sco_excel.Font(name="Arial", color=sco_excel.COLORS.PURPLE.value)
    font_brown = sco_excel.Font(name="Arial", color=sco_excel.COLORS.BROWN.value)

    # bordures
    side_thin = sco_excel.Side(border_style="thin", color=sco_excel.COLORS.BLACK.value)
    border_top = sco_excel.Border(top=side_thin)
    border_right = sco_excel.Border(right=side_thin)
    border_sides = sco_excel.Border(left=side_thin, right=side_thin, bottom=side_thin)

    # fonds
    fill_light_yellow = sco_excel.PatternFill(
        patternType="solid", fgColor=sco_excel.COLORS.LIGHT_YELLOW.value
    )

    # styles
    style_titres = {"font": font_titre}
    style_expl = {"font": font_italic}

    style_ro = {  # cells read-only
        "font": font_purple,
        "border": border_right,
    }
    style_dem = {
        "font": font_brown,
        "border": border_top,
    }
    style_nom = {  # style pour nom, prenom, groupe
        "font": font_base,
        "border": border_top,
    }
    style_abs = {
        "font": font_bold,
        "fill": fill_light_yellow,
        "border": border_sides,
    }

    # filtre
    filter_top = 6
    filter_bottom = filter_top + len(lines)
    filter_left = "A"
    filter_right = lettres[len(hebdo_jours) * 2 - 1]
    ws.set_auto_filter(f"${filter_left}${filter_top}:${filter_right}${filter_bottom}")

    # == Ecritures statiques ==
    ws.append_single_cell_row(
        "Saisir les assiduités dans les cases jaunes (ABS, RET, PRE)", style=style_expl
    )
    ws.append_single_cell_row("Ne pas modifier les cases en mauve !", style_expl)
    # Nom du semestre
    ws.append_single_cell_row(
        scu.unescape_html(formsemestre.titre_annee()), style_titres
    )
    # ligne blanche
    ws.append_blank_row()

    # == Ecritures dynamiques ==
    # Ecriture des entêtes
    row = [ws.make_cell("", style=style_titres) for _ in range(4)]
    for jour in hebdo_jours:
        row.append(ws.make_cell(" ".join(jour[1]), style=style_titres))
        row.append(ws.make_cell("", style=style_titres))
    ws.append_row(row)

    row = [
        ws.make_cell(f"!{formsemestre.id}", style=style_ro),
        ws.make_cell("Nom", style=style_titres),
        ws.make_cell("Prénom", style=style_titres),
        ws.make_cell("Groupe", style=style_titres),
    ]

    for jour in hebdo_jours:
        row.append(ws.make_cell("Matin", style=style_titres))
        row.append(ws.make_cell("Après-Midi", style=style_titres))

    ws.append_row(row)

    # Ecriture des données
    for line in lines:
        st = style_nom
        if line[3] != "I":
            st = style_dem
            if line[3] == "D":  # demissionnaire
                s = "DEM"
            else:
                s = line[3]  # etat autre
        else:
            s = line[4]  # groupes TD/TP/...
        ws.append_row(
            [
                ws.make_cell("!" + line[0], style_ro),  # code
                ws.make_cell(line[1], st),
                ws.make_cell(line[2], st),
                ws.make_cell(s, st),
            ]
            + [ws.make_cell(" ", style_abs) for _ in range(len(hebdo_jours) * 2)]
        )

    # ligne blanche
    ws.append_blank_row()

    return ws.generate()


def _dateiso_to_datefr(date_iso: str) -> str:
    """
    _dateiso_to_datefr Transforme une date iso en date format français

    Args:
        date_iso (str): date au format iso (YYYY-MM-DD)

    Raises:
        ValueError: Si l'argument `date_iso` n'est pas au bon format

    Returns:
        str: date au format français (DD/MM/YYYY)
    """

    regex_date_iso: str = r"^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])$"

    # Vérification de la date_iso
    if not re.match(regex_date_iso, date_iso):
        raise ValueError(
            f"La dateiso passée en paramètre [{date_iso}] n'est pas valide."
        )

    return f"{date_iso[8:10]}/{date_iso[5:7]}/{date_iso[0:4]}"


def _get_date_str(deb: datetime.datetime, fin: datetime.datetime) -> str:
    """
    _get_date_str transforme une période en chaîne lisible

    Args:
        deb (datetime.datetime): date de début
        fin (datetime.datetime): date de fin

    Returns:
        str:
            "le dd/mm/yyyy de hh:MM à hh:MM" si les deux date sont sur le même jour
            "du dd/mm/yyyy hh:MM audd/mm/yyyy hh:MM" sinon
    """
    if deb.date() == fin.date():
        temps = deb.strftime("%d/%m/%Y %H:%M").split(" ") + [fin.strftime(scu.TIME_FMT)]
        return f"le {temps[0]} de {temps[1]} à {temps[2]}"
    return f'du {deb.strftime("%d/%m/%Y %H:%M")} au {fin.strftime("%d/%m/%Y %H:%M")}'


def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> str:
    """
    _module_selector Génère un HTMLSelectElement à partir des moduleimpl du formsemestre

    Args:
        formsemestre (FormSemestre): Le formsemestre d'où les moduleimpls seront pris.

    Returns:
        str: La représentation str d'un HTMLSelectElement
    """
    # récupération des ues du semestre
    ntc: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)

    modimpls_list: list[dict] = ntc.get_modimpls_dict()

    # prévoie la sélection par défaut d'un moduleimpl s'il a été passé en paramètre
    selected = "" if moduleimpl_id is not None else "selected"

    modules: list[dict[str, str | int]] = []
    # Récupération de l'id et d'un nom lisible pour chaque moduleimpl
    for modimpl in modimpls_list:
        modname: str = (
            (modimpl["module"]["code"] or "")
            + " "
            + (modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or "")
        )
        modules.append({"moduleimpl_id": modimpl["moduleimpl_id"], "name": modname})

    return render_template(
        "assiduites/widgets/moduleimpl_selector.j2",
        formsemestre_id=formsemestre.id,
        modules=modules,
        moduleimpl_id=moduleimpl_id,
        selected=selected,
    )


def _module_selector_multiple(
    etud: Identite,
    moduleimpl_id: int = None,
    only_form: FormSemestre = None,
    no_default: bool = False,
    annee_sco: int | None = None,
) -> str:
    """menu HTML <select> pour choix moduleimpl
    Prend les semestres de l'année indiquée, sauf si only_form est indiqué.
    """
    modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(
        scu.annee_scolaire() if annee_sco is None else annee_sco
    )
    choices = OrderedDict()
    for formsemestre_id in modimpls_by_formsemestre:
        formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
        if only_form is not None and formsemestre != only_form:
            continue
        # indique le nom du semestre dans le menu (optgroup)
        choices[formsemestre.titre_annee()] = [
            {
                "moduleimpl_id": m.id,
                "name": f"{m.module.code} {m.module.abbrev or m.module.titre or ''}",
            }
            for m in modimpls_by_formsemestre[formsemestre_id]
            if m.module.ue.type == UE_STANDARD
        ]

        if formsemestre.est_courant():
            choices.move_to_end(formsemestre.titre_annee(), last=False)

    return render_template(
        "assiduites/widgets/moduleimpl_selector_multiple.j2",
        choices=choices,
        moduleimpl_id=moduleimpl_id,
        no_default=no_default,
    )


def _timeline(heures=None) -> str:
    """
    _timeline retourne l'html de la timeline

    Args:
        formsemestre_id (int, optional): un formsemestre. Defaults to None.
            Le formsemestre sert à obtenir la période par défaut de la timeline
            sinon ce sera de 2 heure dès le début de la timeline

    Returns:
        str: l'html en chaîne de caractères
    """
    return render_template(
        "assiduites/widgets/timeline.j2",
        t_start=ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00"),
        t_mid=ScoDocSiteConfig.assi_get_rounded_time("assi_lunch_time", "13:00:00"),
        t_end=ScoDocSiteConfig.assi_get_rounded_time("assi_afternoon_time", "18:00:00"),
        tick_time=ScoDocSiteConfig.get("assi_tick_time", 15),
        heures=heures,
    )


def _mini_timeline() -> str:
    """
    _mini_timeline Retourne l'html lié au mini timeline d'assiduités

    Returns:
        str: l'html en chaîne de caractères
    """
    return render_template(
        "assiduites/widgets/minitimeline.j2",
        t_start=ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00"),
        t_end=ScoDocSiteConfig.assi_get_rounded_time("assi_afternoon_time", "18:00:00"),
    )


def _non_work_days() -> str:
    """Abbréviation des jours non travaillés: "'sam','dim'".
    donnés par les préférences du département
    """
    non_travail = sco_preferences.get_preference("non_travail")
    non_travail = non_travail.replace(" ", "").split(",")
    return ",".join([f"'{i.lower()}'" for i in non_travail])


def _get_seuil() -> int:
    """Seuil d'alerte des absences (en unité de la métrique),
    tel que fixé dans les préférences du département."""
    return sco_preferences.get_preference("assi_seuil", dept_id=g.scodoc_dept_id)


def _get_etuds_dem_def(formsemestre) -> str:
    """Une chaine json donnant les étudiants démissionnaires ou défaillants
    du formsemestre, sous la forme
    '{"516" : "D", ... }'
    """
    return (
        "{"
        + ", ".join(
            [
                f'"{ins.etudid}" : "{ins.etat}"'
                for ins in formsemestre.inscriptions
                if ins.etat != scu.INSCRIT
            ]
        )
        + "}"
    )


# --- Gestion du calendrier ---


class JourAssi(sco_gen_cal.Jour):
    """
    Représente un jour d'assiduité
    """

    def __init__(
        self,
        date: datetime.date,
        assiduites: Query,
        justificatifs: Query,
        parent: "CalendrierAssi",
    ):
        super().__init__(date)

        # assiduités et justificatifs du jour
        self.assiduites = assiduites
        self.justificatifs = justificatifs

        self.parent = parent

    def get_html(self) -> str:
        # si non travaillé on renvoie une case vide
        if self.is_non_work():
            return ""

        html: str = (
            self._get_html_demi() if self.parent.mode_demi else self._get_html_normal()
        )
        html = f'<div class="assi_case">{html}</div>'

        if self.has_assiduite():
            minitimeline: str = f"""
            <div class="dayline">
                <div class="dayline-title">
                    <span>{self.get_date()}</span>
                    {self._generate_minitimeline()}
                </div>
            </div>
            """
            html += minitimeline

        return html

    def has_assiduite(self) -> bool:
        """Renvoie True si le jour a une assiduité"""
        return self.assiduites.count() > 0

    def _get_html_normal(self) -> str:
        """
        Renvoie l'html de la case du calendrier
        (version journee normale (donc une couleur))
        """
        class_name = self._get_color_normal()
        return f'<span class="{class_name}"></span>'

    def _get_html_demi(self) -> str:
        """
        Renvoie l'html de la case du calendrier
        (version journee divisée en demi-journées (donc 2 couleurs))
        """
        matin = self._get_color_demi(True)
        aprem = self._get_color_demi(False)
        return f'<span class="{matin}"></span><span class="{aprem}"></span>'

    def _get_color_normal(self) -> str:
        """renvoie la classe css correspondant
        à la case du calendrier
        (version journee normale)
        """
        etat = ""
        est_just = ""

        if self.is_non_work():
            return "color nonwork"

        etat = self._get_color_assiduites_cascade(
            self._get_etats_from_assiduites(self.assiduites),
            show_pres=self.parent.show_pres,
            show_reta=self.parent.show_reta,
        )

        est_just = self._get_color_justificatifs_cascade(
            self._get_etats_from_justificatifs(self.justificatifs),
        )

        return f"color {etat} {est_just}"

    def _get_color_demi(self, matin: bool) -> str:
        """renvoie la classe css correspondant
        à la case du calendrier
        (version journee divisée en demi-journees)
        """
        heure_midi = scass.str_to_time(ScoDocSiteConfig.get("assi_lunch_time", "13:00"))
        plage: tuple[datetime.datetime, datetime.datetime] = ()
        if matin:
            heure_matin = scass.str_to_time(
                ScoDocSiteConfig.get("assi_morning_time", "08:00")
            )
            plage = (
                # date debut
                scu.localize_datetime(
                    datetime.datetime.combine(self.date, heure_matin)
                ),
                # date fin
                scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)),
            )
        else:
            heure_soir = scass.str_to_time(
                ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
            )

            # séparation en demi journées
            plage = (
                # date debut
                scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)),
                # date fin
                scu.localize_datetime(datetime.datetime.combine(self.date, heure_soir)),
            )

        assiduites = [
            assi
            for assi in self.assiduites
            if scu.is_period_overlapping(
                (assi.date_debut, assi.date_fin), plage, bornes=False
            )
        ]

        justificatifs = [
            justi
            for justi in self.justificatifs
            if scu.is_period_overlapping(
                (justi.date_debut, justi.date_fin), plage, bornes=False
            )
        ]

        etat = self._get_color_assiduites_cascade(
            self._get_etats_from_assiduites(assiduites),
            show_pres=self.parent.show_pres,
            show_reta=self.parent.show_reta,
        )

        est_just = self._get_color_justificatifs_cascade(
            self._get_etats_from_justificatifs(justificatifs),
        )

        if est_just == "est_just" and any(
            not assi.est_just
            for assi in assiduites
            if assi.etat != scu.EtatAssiduite.PRESENT
        ):
            est_just = ""

        return f"color {etat} {est_just}"

    def _get_etats_from_assiduites(self, assiduites: Query) -> list[scu.EtatAssiduite]:
        return list(set(scu.EtatAssiduite(assi.etat) for assi in assiduites))

    def _get_etats_from_justificatifs(
        self, justificatifs: Query
    ) -> list[scu.EtatJustificatif]:
        return list(set(scu.EtatJustificatif(justi.etat) for justi in justificatifs))

    def _get_color_assiduites_cascade(
        self,
        etats: list[scu.EtatAssiduite],
        show_pres: bool = False,
        show_reta: bool = False,
    ) -> str:
        if scu.EtatAssiduite.ABSENT in etats:
            return "absent"
        if scu.EtatAssiduite.RETARD in etats and show_reta:
            return "retard"
        if scu.EtatAssiduite.PRESENT in etats and show_pres:
            return "present"

        return "sans_etat"

    def _get_color_justificatifs_cascade(
        self,
        etats: list[scu.EtatJustificatif],
    ) -> str:
        if scu.EtatJustificatif.VALIDE in etats:
            return "est_just"
        if scu.EtatJustificatif.ATTENTE in etats:
            return "attente"
        if scu.EtatJustificatif.MODIFIE in etats:
            return "modifie"
        if scu.EtatJustificatif.NON_VALIDE in etats:
            return "invalide"

        return ""

    def _generate_minitimeline(self) -> str:
        """
        Génère la minitimeline du jour
        """
        # Récupérer le référenciel de la timeline
        heure_matin: datetime.timedelta = _time_to_timedelta(
            scass.str_to_time(ScoDocSiteConfig.get("assi_morning_time", "08:00"))
        )
        heure_soir: datetime.timedelta = _time_to_timedelta(
            scass.str_to_time(ScoDocSiteConfig.get("assi_afternoon_time", "17:00"))
        )
        # longueur_timeline =  heure_soir - heure_matin
        longueur_timeline: datetime.timedelta = heure_soir - heure_matin

        # chaque block d'assiduité est défini par:
        # longueur = ( (fin-deb) / longueur_timeline ) * 100
        # emplacement = ( (deb - heure_matin) / longueur_timeline ) * 100
        # longueur + emplacement = 100%  sinon on réduit longueur

        assiduite_blocks: list[dict[str, float | str]] = []

        for assi in self.assiduites:
            deb: datetime.timedelta = _time_to_timedelta(
                assi.date_debut.time()
                if assi.date_debut.date() == self.date
                else heure_matin
            )
            fin: datetime.timedelta = _time_to_timedelta(
                assi.date_fin.time()
                if assi.date_fin.date() == self.date
                else heure_soir
            )

            emplacement: float = max(((deb - heure_matin) / longueur_timeline) * 100, 0)
            longueur: float = ((fin - deb) / longueur_timeline) * 100
            if longueur + emplacement > 100:
                longueur = 100 - emplacement

            etat: str = scu.EtatAssiduite(assi.etat).name.lower()
            est_just: str = "est_just" if assi.est_just else ""

            assiduite_blocks.append(
                {
                    "longueur": longueur,
                    "emplacement": emplacement,
                    "etat": etat,
                    "est_just": est_just,
                    "bubble": _generate_assiduite_bubble(assi),
                    "id": assi.assiduite_id,
                }
            )

        return render_template(
            "assiduites/widgets/minitimeline_simple.j2",
            assi_blocks=assiduite_blocks,
        )


class CalendrierAssi(sco_gen_cal.Calendrier):
    """
    Représente un calendrier d'assiduité d'un étudiant
    """

    def __init__(self, annee: int, etudiant: Identite, **options):
        # On prend du 01/09 au 31/08
        date_debut: datetime.datetime = datetime.datetime(annee, 9, 1, 0, 0)
        date_fin: datetime.datetime = datetime.datetime(annee + 1, 8, 31, 23, 59)
        super().__init__(date_debut, date_fin)

        # On récupère les assiduités et les justificatifs
        self.etud_assiduites: Query = scass.filter_by_date(
            etudiant.assiduites,
            Assiduite,
            date_deb=date_debut,
            date_fin=date_fin,
        )
        self.etud_justificatifs: Query = scass.filter_by_date(
            etudiant.justificatifs,
            Justificatif,
            date_deb=date_debut,
            date_fin=date_fin,
        )

        # Ajout des options (exemple : mode_demi, show_pres, show_reta, ...)
        for key, value in options.items():
            setattr(self, key, value)

    def instanciate_jour(self, date: datetime.date) -> JourAssi:
        """
        Instancie un jour d'assiduité
        """
        assiduites: Query = scass.filter_by_date(
            self.etud_assiduites,
            Assiduite,
            date_deb=datetime.datetime.combine(date, datetime.time(0, 0)),
            date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)),
        )
        justificatifs: Query = scass.filter_by_date(
            self.etud_justificatifs,
            Justificatif,
            date_deb=datetime.datetime.combine(date, datetime.time(0, 0)),
            date_fin=datetime.datetime.combine(date, datetime.time(23, 59, 59)),
        )
        return JourAssi(date, assiduites, justificatifs, parent=self)


def _time_to_timedelta(t: datetime.time) -> datetime.timedelta:
    if isinstance(t, datetime.timedelta):
        return t
    return datetime.timedelta(hours=t.hour, minutes=t.minute, seconds=t.second)


def _generate_assiduite_bubble(assiduite: Assiduite) -> str:
    # Récupérer informations modules impl
    moduleimpl_infos: str = assiduite.get_module(traduire=True)

    # Récupérer informations saisie
    saisie: str = assiduite.get_saisie()

    motif: str = assiduite.description or "Non spécifié"

    # Récupérer date

    if assiduite.date_debut.date() == assiduite.date_fin.date():
        jour = assiduite.date_debut.strftime("%d/%m/%Y")
        heure_deb: str = assiduite.date_debut.strftime("%H:%M")
        heure_fin: str = assiduite.date_fin.strftime("%H:%M")
        date: str = f"{jour} de {heure_deb} à {heure_fin}"
    else:
        date: str = (
            f"du {assiduite.date_debut.strftime('%d/%m/%Y')} "
            + f"au {assiduite.date_fin.strftime('%d/%m/%Y')}"
        )

    return render_template(
        "assiduites/widgets/assiduite_bubble.j2",
        moduleimpl=moduleimpl_infos,
        etat=scu.EtatAssiduite(assiduite.etat).name.lower(),
        date=date,
        saisie=saisie,
        motif=motif,
    )