##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
# See LICENSE
##############################################################################

"""Résultats semestre: méthodes communes aux formations classiques et APC
"""

from collections import Counter
from collections.abc import Generator
from functools import cached_property
import numpy as np
import pandas as pd

from flask import g, url_for

from app.auth.models import User
from app.comp import res_sem
from app.comp.res_cache import ResultatsCache
from app.comp.jury import ValidationsSemestre
from app.comp.moy_mod import ModuleImplResults
from app.models import FormSemestre, FormSemestreUECoef
from app.models import Identite
from app.models import ModuleImpl, ModuleImplInscription
from app.models.ues import UniteEns
from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM
from app.scodoc import sco_evaluation_db
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_groups
from app.scodoc import sco_utils as scu

# Il faut bien distinguer
#  - ce qui est caché de façon persistente (via redis):
#      ce sont les attributs listés dans `_cached_attrs`
#      le stockage et l'invalidation sont gérés dans sco_cache.py
#
#  - les valeurs cachées durant le temps d'une requête
#      (durée de vie de l'instance de ResultatsSemestre)
#      qui sont notamment les attributs décorés par `@cached_property``
#
class ResultatsSemestre(ResultatsCache):
    """Les résultats (notes, ...) d'un formsemestre
    Classe commune à toutes les formations (classiques, BUT)
    """

    _cached_attrs = (
        "bonus",
        "bonus_ues",
        "dispense_ues",
        "etud_coef_ue_df",
        "etud_moy_gen_ranks",
        "etud_moy_gen",
        "etud_moy_ue",
        "modimpl_inscr_df",
        "modimpls_results",
        "moyennes_matieres",
    )

    def __init__(self, formsemestre: FormSemestre):
        super().__init__(formsemestre, ResultatsSemestreCache)
        # BUT ou standard ? (apc == "approche par compétences")
        self.is_apc = formsemestre.formation.is_apc()
        # Attributs "virtuels", définis dans les sous-classes
        self.bonus: pd.Series = None  # virtuel
        "Bonus sur moy. gen. Series de float, index etudid"
        self.bonus_ues: pd.DataFrame = None  # virtuel
        "DataFrame de float, index etudid, columns: ue.id"
        self.dispense_ues: set[tuple[int, int]] = set()
        """set des dispenses d'UE: (etudid, ue_id), en APC seulement."""
        #  ResultatsSemestreBUT ou ResultatsSemestreClassic
        self.etud_moy_ue = {}
        "etud_moy_ue: DataFrame columns UE, rows etudid"
        self.etud_moy_gen: pd.Series = None
        self.etud_moy_gen_ranks = {}
        self.etud_moy_gen_ranks_int = {}
        self.moy_gen_rangs_by_group = None  # virtual
        self.modimpl_inscr_df: pd.DataFrame = None
        "Inscriptions: row etudid, col modimlpl_id"
        self.modimpls_results: ModuleImplResults = None
        "Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }"
        self.etud_coef_ue_df = None
        """coefs d'UE effectifs pour chaque étudiant (pour form. classiques)"""
        self.modimpl_coefs_df: pd.DataFrame = None
        """Coefs APC: rows = UEs (sans bonus), columns = modimpl, value = coef."""

        self.validations = None
        self.moyennes_matieres = {}
        """Moyennes de matières, si calculées. { matiere_id : Series, index etudid }"""

    def __repr__(self):
        return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>"

    def compute(self):
        "Charge les notes et inscriptions et calcule toutes les moyennes"
        # voir ce qui est chargé / calculé ici et dans les sous-classes
        raise NotImplementedError()

    def get_inscriptions_counts(self) -> Counter:
        """Nombre d'inscrits, défaillants, démissionnaires.

        Exemple: res.get_inscriptions_counts()[scu.INSCRIT]

        Result: a collections.Counter instance
        """
        return Counter(ins.etat for ins in self.formsemestre.inscriptions)

    @cached_property
    def etuds(self) -> list[Identite]:
        "Liste des inscrits au semestre, avec les démissionnaires et les défaillants"
        # nb: si la liste des inscrits change, ResultatsSemestre devient invalide
        return self.formsemestre.get_inscrits(include_demdef=True)

    @cached_property
    def etud_index(self) -> dict[int, int]:
        "dict { etudid : indice dans les inscrits }"
        return {e.id: idx for idx, e in enumerate(self.etuds)}

    def etud_ues_ids(self, etudid: int) -> list[int]:
        """Liste des UE auxquelles l'etudiant est inscrit, sans bonus
        (surchargée en BUT pour prendre en compte les parcours)
        """
        # Pour les formations classiques, etudid n'est pas utilisé
        # car tous les étudiants sont inscrits à toutes les UE
        return [ue.id for ue in self.ues if ue.type != UE_SPORT]

    def etud_ues(self, etudid: int) -> Generator[UniteEns]:
        """Liste des UE auxquelles l'étudiant est inscrit."""
        return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid))

    def etud_ects_tot_sem(self, etudid: int) -> float:
        """Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)"""
        etud_ues = self.etud_ues(etudid)
        return sum([ue.ects or 0 for ue in etud_ues]) if etud_ues else 0.0

    def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray:
        """Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue.
        Utile pour stats bottom tableau recap.
        Résultat: 1d array of float
        """
        # différent en BUT et classique: virtuelle
        raise NotImplementedError

    @cached_property
    def etuds_dict(self) -> dict[int, Identite]:
        """dict { etudid : Identite } inscrits au semestre,
        avec les démissionnaires et defs."""
        return {etud.id: etud for etud in self.etuds}

    @cached_property
    def ues(self) -> list[UniteEns]:
        """Liste des UEs du semestre (avec les UE bonus sport)
        (indices des DataFrames).
        Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs.
        """
        return self.formsemestre.query_ues(with_sport=True).all()

    @cached_property
    def ressources(self):
        "Liste des ressources du semestre, triées par numéro de module"
        return [
            m
            for m in self.formsemestre.modimpls_sorted
            if m.module.module_type == scu.ModuleType.RESSOURCE
        ]

    @cached_property
    def saes(self):
        "Liste des SAÉs du semestre, triées par numéro de module"
        return [
            m
            for m in self.formsemestre.modimpls_sorted
            if m.module.module_type == scu.ModuleType.SAE
        ]

    # --- JURY...
    def load_validations(self) -> ValidationsSemestre:
        """Load validations, set attribute and return value"""
        if not self.validations:
            self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
        return self.validations

    def get_etud_ue_validables(self, etudid: int) -> list[UniteEns]:
        """Liste des UEs du semestre qui doivent être validées

        Rappel: l'étudiant est inscrit à des modimpls et non à des UEs.

        - En BUT: on considère que l'étudiant va (ou non) valider toutes les UEs des modules
        du parcours.

        - En classique: toutes les UEs des modimpls auxquels l'étudiant est inscrit sont
        susceptibles d'être validées.

        Les UE "bonus" (sport) ne sont jamais "validables".
        """
        if self.is_apc:
            return list(self.etud_ues(etudid))
        # Formations classiques:
        # restreint aux UE auxquelles l'étudiant est inscrit (dans l'un des modimpls)
        ues = {
            modimpl.module.ue
            for modimpl in self.formsemestre.modimpls_sorted
            if self.modimpl_inscr_df[modimpl.id][etudid]
        }
        ues = sorted(list(ues), key=lambda x: x.numero or 0)
        return ues

    def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]:
        """Liste des modimpl de cette UE auxquels l'étudiant est inscrit.
        Utile en formations classiques, surchargée pour le BUT.
        Inclus modules bonus le cas échéant.
        """
        # Utilisée pour l'affichage ou non de l'UE sur le bulletin
        # Méthode surchargée en BUT
        modimpls = [
            modimpl
            for modimpl in self.formsemestre.modimpls_sorted
            if modimpl.module.ue.id == ue.id
            and self.modimpl_inscr_df[modimpl.id][etudid]
        ]
        if not with_bonus:
            return [
                modimpl for modimpl in modimpls if modimpl.module.ue.type != UE_SPORT
            ]
        return modimpls

    @cached_property
    def ue_au_dessus(self, seuil=10.0) -> pd.DataFrame:
        """DataFrame columns UE, rows etudid, valeurs: bool
        Par exemple, pour avoir le nombre d'UE au dessus de 10 pour l'étudiant etudid
        nb_ues_ok = sum(res.ue_au_dessus().loc[etudid])
        """
        return self.etud_moy_ue > (seuil - scu.NOTES_TOLERANCE)

    def apply_capitalisation(self):
        """Recalcule la moyenne générale pour prendre en compte d'éventuelles
        UE capitalisées.
        """
        # Supposant qu'il y a peu d'UE capitalisées,
        # on recalcule les moyennes gen des etuds ayant des UE capitalisée.
        self.load_validations()
        ue_capitalisees = self.validations.ue_capitalisees
        for etudid in ue_capitalisees.index:
            recompute_mg = False
            # ue_codes = set(ue_capitalisees.loc[etudid]["ue_code"])
            # for ue_code in ue_codes:
            #     ue = ue_by_code.get(ue_code)
            #     if ue is None:
            #         ue = self.formsemestre.query_ues.filter_by(ue_code=ue_code)
            #         ue_by_code[ue_code] = ue

            # Quand il y a une capitalisation, vérifie toutes les UEs
            sum_notes_ue = 0.0
            sum_coefs_ue = 0.0
            for ue in self.formsemestre.query_ues():
                ue_cap = self.get_etud_ue_status(etudid, ue.id)
                if ue_cap is None:
                    continue
                if ue_cap["is_capitalized"]:
                    recompute_mg = True
                coef = ue_cap["coef_ue"]
                if not np.isnan(ue_cap["moy"]) and coef:
                    sum_notes_ue += ue_cap["moy"] * coef
                    sum_coefs_ue += coef

            if recompute_mg and sum_coefs_ue > 0.0:
                # On doit prendre en compte une ou plusieurs UE capitalisées
                # et donc recalculer la moyenne générale
                self.etud_moy_gen[etudid] = sum_notes_ue / sum_coefs_ue
                # Ajoute le bonus sport
                if self.bonus is not None and self.bonus[etudid]:
                    self.etud_moy_gen[etudid] += self.bonus[etudid]
                    self.etud_moy_gen[etudid] = max(
                        0.0, min(self.etud_moy_gen[etudid], 20.0)
                    )

    def get_etud_etat(self, etudid: int) -> str:
        "Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)"
        ins = self.formsemestre.etuds_inscriptions.get(etudid, None)
        if ins is None:
            return ""
        return ins.etat

    def _get_etud_ue_cap(self, etudid: int, ue: UniteEns) -> dict:
        """Donne les informations sur la capitalisation de l'UE ue pour cet étudiant.
        Résultat:
            Si pas capitalisée: None
            Si capitalisée: un dict, avec les colonnes de validation.
        """
        capitalisations = self.validations.ue_capitalisees.loc[etudid]
        if isinstance(capitalisations, pd.DataFrame):
            ue_cap = capitalisations[capitalisations["ue_code"] == ue.ue_code]
            if ue_cap.empty:
                return None
            if isinstance(ue_cap, pd.DataFrame):
                # si plusieurs fois capitalisée, prend le max
                cap_idx = ue_cap["moy_ue"].values.argmax()
                ue_cap = ue_cap.iloc[cap_idx]
        else:
            if capitalisations["ue_code"] == ue.ue_code:
                ue_cap = capitalisations
            else:
                return None
        # converti la Series en dict, afin que les np.int64 reviennent en int
        # et remplace les NaN (venant des NULL en base) par des None
        ue_cap_dict = ue_cap.to_dict()
        if ue_cap_dict["formsemestre_id"] is not None and np.isnan(
            ue_cap_dict["formsemestre_id"]
        ):
            ue_cap_dict["formsemestre_id"] = None
        if ue_cap_dict["compense_formsemestre_id"] is not None and np.isnan(
            ue_cap_dict["compense_formsemestre_id"]
        ):
            ue_cap_dict["compense_formsemestre_id"] = None
        return ue_cap_dict

    def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict:
        """L'état de l'UE pour cet étudiant.
        Result: dict, ou None si l'UE n'est pas dans ce semestre.
        """
        ue = UniteEns.query.get(ue_id)
        if ue.type == UE_SPORT:
            return {
                "is_capitalized": False,
                "was_capitalized": False,
                "is_external": False,
                "coef_ue": 0.0,
                "cur_moy_ue": 0.0,
                "moy": 0.0,
                "event_date": None,
                "ue": ue.to_dict(),
                "formsemestre_id": None,
                "capitalized_ue_id": None,
                "ects_pot": 0.0,
                "ects": 0.0,
                "ects_ue": ue.ects,
            }
        if not ue_id in self.etud_moy_ue:
            return None
        if not self.validations:
            self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
        cur_moy_ue = self.etud_moy_ue[ue_id][etudid]
        moy_ue = cur_moy_ue
        is_capitalized = False  # si l'UE prise en compte est une UE capitalisée
        # s'il y a precedemment une UE capitalisée (pas forcement meilleure):
        was_capitalized = False
        if etudid in self.validations.ue_capitalisees.index:
            ue_cap = self._get_etud_ue_cap(etudid, ue)
            if ue_cap and not np.isnan(ue_cap["moy_ue"]):
                was_capitalized = True
                if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue):
                    moy_ue = ue_cap["moy_ue"]
                    is_capitalized = True

        # Coef l'UE dans le semestre courant:
        if self.is_apc:
            # utilise les ECTS comme coef.
            coef_ue = ue.ects
        else:
            # formations classiques
            coef_ue = self.etud_coef_ue_df[ue_id][etudid]
        if (not coef_ue) and is_capitalized:  # étudiant non inscrit dans l'UE courante
            if self.is_apc:
                # Coefs de l'UE capitalisée en formation APC: donné par ses ECTS
                ue_capitalized = UniteEns.query.get(ue_cap["ue_id"])
                coef_ue = ue_capitalized.ects
                if coef_ue is None:
                    orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"])
                    raise ScoValueError(
                        f"""L'UE capitalisée {ue_capitalized.acronyme}
                    du semestre {orig_sem.titre_annee()}
                    n'a pas d'indication d'ECTS.
                    Corrigez ou faite corriger le programme
                    <a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
                    formation_id=ue_capitalized.formation_id)}">via cette page</a>.
                    """
                    )
            else:
                # Coefs de l'UE capitalisée en formation classique:
                # va chercher le coef dans le semestre d'origine
                coef_ue = ModuleImplInscription.sum_coefs_modimpl_ue(
                    ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"]
                )

        return {
            "is_capitalized": is_capitalized,
            "was_capitalized": was_capitalized,
            "is_external": ue_cap["is_external"] if is_capitalized else ue.is_external,
            "coef_ue": coef_ue,
            "ects_pot": ue.ects or 0.0,
            "ects": self.validations.decisions_jury_ues.get(etudid, {})
            .get(ue.id, {})
            .get("ects", 0.0)
            if self.validations.decisions_jury_ues
            else 0.0,
            "ects_ue": ue.ects,
            "cur_moy_ue": cur_moy_ue,
            "moy": moy_ue,
            "event_date": ue_cap["event_date"] if is_capitalized else None,
            "ue": ue.to_dict(),
            "formsemestre_id": ue_cap["formsemestre_id"] if is_capitalized else None,
            "capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None,
        }

    def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float:
        "Détermine le coefficient de l'UE pour cet étudiant."
        # calcul différent en classique et BUT
        raise NotImplementedError()

    def get_etud_ue_cap_coef(self, etudid, ue, ue_cap):  # UNUSED in ScoDoc 9
        """Calcule le coefficient d'une UE capitalisée, pour cet étudiant,
        injectée dans le semestre courant.

        ue : ue du semestre courant

        ue_cap = resultat de formsemestre_get_etud_capitalisation
        { 'ue_id' (dans le semestre source),
          'ue_code', 'moy', 'event_date','formsemestre_id' }
        """
        # 1- Coefficient explicitement déclaré dans le semestre courant pour cette UE ?
        ue_coef_db = FormSemestreUECoef.query.filter_by(
            formsemestre_id=self.formsemestre.id, ue_id=ue.id
        ).first()
        if ue_coef_db is not None:
            return ue_coef_db.coefficient

        # En APC: somme des coefs des modules vers cette UE
        # En classique:  Capitalisation UE externe: quel coef appliquer ?
        # En ScoDoc 7, calculait la somme des coefs dans l'UE du semestre d'origine
        # ici si l'étudiant est inscrit dans le semestre courant,
        # somme des coefs des modules de l'UE auxquels il est inscrit
        return self.compute_etud_ue_coef(etudid, ue)

    # --- TABLEAU RECAP

    def get_table_recap(
        self,
        convert_values=False,
        include_evaluations=False,
        mode_jury=False,
        allow_html=True,
    ):
        """Table récap. des résultats.
        allow_html: si vrai, peut mettre du HTML dans les valeurs

        Result: tuple avec
        - rows: liste de dicts { column_id : value }
        - titles: { column_id : title }
        - columns_ids: (liste des id de colonnes)

        Si convert_values, transforme les notes en chaines ("12.34").
        Les colonnes générées sont:
            etudid
            rang : rang indicatif (basé sur moy gen)
            moy_gen : moy gen indicative
            moy_ue_<ue_id>, ...,  les moyennes d'UE
            moy_res_<modimpl_id>_<ue_id>, ... les moyennes de ressources dans l'UE
            moy_sae_<modimpl_id>_<ue_id>, ... les moyennes de SAE dans l'UE

        On ajoute aussi des attributs:
        - pour les lignes:
            _css_row_class (inutilisé pour le monent)
            _<column_id>_class classe css:
                - la moyenne générale a la classe col_moy_gen
                - les colonnes SAE ont la classe col_sae
                - les colonnes Resources ont la classe col_res
                - les colonnes d'UE ont la classe col_ue
                - les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_<ue_id>
            _<column_id>_order : clé de tri
        """
        if convert_values:
            fmt_note = scu.fmt_note
        else:
            fmt_note = lambda x: x

        parcours = self.formsemestre.formation.get_parcours()
        barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE
        barre_valid_ue = parcours.NOTES_BARRE_VALID_UE
        barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING
        NO_NOTE = "-"  # contenu des cellules sans notes
        rows = []
        # column_id : title
        titles = {}
        # les titres en footer: les mêmes, mais avec des bulles et liens:
        titles_bot = {}
        dict_nom_res = {}  # cache uid : nomcomplet

        def add_cell(
            row: dict,
            col_id: str,
            title: str,
            content: str,
            classes: str = "",
            idx: int = 100,
        ):
            "Add a row to our table. classes is a list of css class names"
            row[col_id] = content
            if classes:
                row[f"_{col_id}_class"] = classes + f" c{idx}"
            if not col_id in titles:
                titles[col_id] = title
                titles[f"_{col_id}_col_order"] = idx
                if classes:
                    titles[f"_{col_id}_class"] = classes
            return idx + 1

        etuds_inscriptions = self.formsemestre.etuds_inscriptions
        ues = self.formsemestre.query_ues(with_sport=True)  # avec bonus
        ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
        modimpl_ids = set()  # modimpl effectivement présents dans la table
        for etudid in etuds_inscriptions:
            idx = 0  # index de la colonne
            etud = Identite.query.get(etudid)
            row = {"etudid": etudid}
            # --- Codes (seront cachés, mais exportés en excel)
            idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx)
            idx = add_cell(
                row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx
            )
            # --- Rang
            idx = add_cell(
                row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
            )
            row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}"
            # --- Identité étudiant
            idx = add_cell(
                row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx
            )
            idx = add_cell(
                row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx
            )
            row["_nom_disp_order"] = etud.sort_key
            idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx)
            idx = add_cell(
                row, "nom_short", "Nom", etud.nom_short, "identite_court", idx
            )
            row["_nom_short_order"] = etud.sort_key
            row["_nom_short_target"] = url_for(
                "notes.formsemestre_bulletinetud",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=self.formsemestre.id,
                etudid=etudid,
            )
            row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"'
            row["_nom_disp_target"] = row["_nom_short_target"]
            row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"]

            idx = 30  # début des colonnes de notes
            # --- Moyenne générale
            moy_gen = self.etud_moy_gen.get(etudid, False)
            note_class = ""
            if moy_gen is False:
                moy_gen = NO_NOTE
            elif isinstance(moy_gen, float) and moy_gen < barre_moy:
                note_class = " moy_ue_warning"  # en rouge
            idx = add_cell(
                row,
                "moy_gen",
                "Moy",
                fmt_note(moy_gen),
                "col_moy_gen" + note_class,
                idx,
            )
            titles_bot["_moy_gen_target_attrs"] = (
                'title="moyenne indicative"' if self.is_apc else ""
            )
            # --- Moyenne d'UE
            nb_ues_validables, nb_ues_warning = 0, 0
            for ue in ues_sans_bonus:
                ue_status = self.get_etud_ue_status(etudid, ue.id)
                if ue_status is not None:
                    col_id = f"moy_ue_{ue.id}"
                    val = ue_status["moy"]
                    note_class = ""
                    if isinstance(val, float):
                        if val < barre_moy:
                            note_class = " moy_inf"
                        elif val >= barre_valid_ue:
                            note_class = " moy_ue_valid"
                            nb_ues_validables += 1
                        if val < barre_warning_ue:
                            note_class = " moy_ue_warning"  # notes très basses
                            nb_ues_warning += 1
                    idx = add_cell(
                        row,
                        col_id,
                        ue.acronyme,
                        fmt_note(val),
                        "col_ue" + note_class,
                        idx,
                    )
                    titles_bot[
                        f"_{col_id}_target_attrs"
                    ] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """
                    if mode_jury:
                        # pas d'autre colonnes de résultats
                        continue
                    # Bonus (sport) dans cette UE ?
                    # Le bonus sport appliqué sur cette UE
                    if (self.bonus_ues is not None) and (ue.id in self.bonus_ues):
                        val = self.bonus_ues[ue.id][etud.id] or ""
                        val_fmt = val_fmt_html = fmt_note(val)
                        if val:
                            val_fmt_html = f'<span class="green-arrow-up"></span><span class="sp2l">{val_fmt}</span>'
                        idx = add_cell(
                            row,
                            f"bonus_ue_{ue.id}",
                            f"Bonus {ue.acronyme}",
                            val_fmt_html if allow_html else val_fmt,
                            "col_ue_bonus",
                            idx,
                        )
                        row[f"_bonus_ue_{ue.id}_xls"] = val_fmt
                    # Les moyennes des modules (ou ressources et SAÉs) dans cette UE
                    idx_malus = idx  # place pour colonne malus à gauche des modules
                    idx += 1
                    for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False):
                        if ue_status["is_capitalized"]:
                            val = "-c-"
                        else:
                            modimpl_results = self.modimpls_results.get(modimpl.id)
                            if modimpl_results:  # pas bonus
                                if self.is_apc:  # BUT
                                    moys_vers_ue = modimpl_results.etuds_moy_module.get(
                                        ue.id
                                    )
                                    val = (
                                        moys_vers_ue.get(etudid, "?")
                                        if moys_vers_ue is not None
                                        else ""
                                    )
                                else:  # classique: Series indépendante de l'UE
                                    val = modimpl_results.etuds_moy_module.get(
                                        etudid, "?"
                                    )
                            else:
                                val = ""

                        col_id = (
                            f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
                        )
                        val_fmt = val_fmt_html = fmt_note(val)
                        if convert_values and (
                            modimpl.module.module_type == scu.ModuleType.MALUS
                        ):
                            val_fmt_html = (
                                (scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else ""
                            )
                        idx = add_cell(
                            row,
                            col_id,
                            modimpl.module.code,
                            val_fmt_html,
                            # class col_res mod_ue_123
                            f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}",
                            idx,
                        )
                        row[f"_{col_id}_xls"] = val_fmt
                        if modimpl.module.module_type == scu.ModuleType.MALUS:
                            titles[f"_{col_id}_col_order"] = idx_malus
                        titles_bot[f"_{col_id}_target"] = url_for(
                            "notes.moduleimpl_status",
                            scodoc_dept=g.scodoc_dept,
                            moduleimpl_id=modimpl.id,
                        )
                        nom_resp = dict_nom_res.get(modimpl.responsable_id)
                        if nom_resp is None:
                            user = User.query.get(modimpl.responsable_id)
                            nom_resp = user.get_nomcomplet() if user else ""
                            dict_nom_res[modimpl.responsable_id] = nom_resp
                        titles_bot[
                            f"_{col_id}_target_attrs"
                        ] = f""" title="{modimpl.module.titre} ({nom_resp})" """
                        modimpl_ids.add(modimpl.id)
            nb_ues_etud_parcours = len(self.etud_ues_ids(etudid))
            ue_valid_txt = (
                ue_valid_txt_html
            ) = f"{nb_ues_validables}/{nb_ues_etud_parcours}"
            if nb_ues_warning:
                ue_valid_txt_html += " " + scu.EMO_WARNING
            add_cell(
                row,
                "ues_validables",
                "UEs",
                ue_valid_txt_html,
                "col_ue col_ues_validables",
                29,  # juste avant moy. gen.
            )
            row["_ues_validables_xls"] = ue_valid_txt
            if nb_ues_warning:
                row["_ues_validables_class"] += " moy_ue_warning"
            elif nb_ues_validables < len(ues_sans_bonus):
                row["_ues_validables_class"] += " moy_inf"
            row["_ues_validables_order"] = nb_ues_validables  # pour tri
            if mode_jury and self.validations:
                if self.is_apc:
                    # formations BUT: pas de code semestre, concatene ceux des UE
                    dec_ues = self.validations.decisions_jury_ues.get(etudid)
                    if dec_ues:
                        jury_code_sem = ",".join(
                            [dec_ues[ue_id].get("code", "") for ue_id in dec_ues]
                        )
                    else:
                        jury_code_sem = ""
                else:
                    # formations classiques: code semestre
                    dec_sem = self.validations.decisions_jury.get(etudid)
                    jury_code_sem = dec_sem["code"] if dec_sem else ""
                idx = add_cell(
                    row,
                    "jury_code_sem",
                    "Jury",
                    jury_code_sem,
                    "jury_code_sem",
                    1000,
                )
                idx = add_cell(
                    row,
                    "jury_link",
                    "",
                    f"""<a href="{url_for('notes.formsemestre_validation_etud_form',
                    scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, etudid=etudid
                    )
                    }">{"saisir" if not jury_code_sem else "modifier"} décision</a>""",
                    "col_jury_link",
                    idx,
                )
            rows.append(row)

        col_idx = self.recap_add_partitions(rows, titles)
        self.recap_add_cursus(rows, titles, col_idx=col_idx + 1)
        self._recap_add_admissions(rows, titles)

        # tri par rang croissant
        rows.sort(key=lambda e: e["_rang_order"])

        # INFOS POUR FOOTER
        bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note)
        if include_evaluations:
            self._recap_add_evaluations(rows, titles, bottom_infos)

        # Ajoute style "col_empty" aux colonnes de modules vides
        for col_id in titles:
            c_class = f"_{col_id}_class"
            if "col_empty" in bottom_infos["moy"].get(c_class, ""):
                for row in rows:
                    row[c_class] = row.get(c_class, "") + " col_empty"
                titles[c_class] += " col_empty"
                for row in bottom_infos.values():
                    row[c_class] = row.get(c_class, "") + " col_empty"

        # --- TABLE FOOTER: ECTS, moyennes, min, max...
        footer_rows = []
        for (bottom_line, row) in bottom_infos.items():
            # Cases vides à styler:
            row["moy_gen"] = row.get("moy_gen", "")
            row["_moy_gen_class"] = "col_moy_gen"
            # titre de la ligne:
            row["prenom"] = row["nom_short"] = (
                row.get("_title", "") or bottom_line.capitalize()
            )
            row["_tr_class"] = bottom_line.lower() + (
                (" " + row["_tr_class"]) if "_tr_class" in row else ""
            )
            footer_rows.append(row)
        titles_bot.update(titles)
        footer_rows.append(titles_bot)
        column_ids = [title for title in titles if not title.startswith("_")]
        column_ids.sort(
            key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000)
        )
        return (rows, footer_rows, titles, column_ids)

    def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict:
        """Les informations à mettre en bas de la table: min, max, moy, ECTS"""
        row_min, row_max, row_moy, row_coef, row_ects, row_apo = (
            {"_tr_class": "bottom_info", "_title": "Min."},
            {"_tr_class": "bottom_info"},
            {"_tr_class": "bottom_info"},
            {"_tr_class": "bottom_info"},
            {"_tr_class": "bottom_info"},
            {"_tr_class": "bottom_info", "_title": "Code Apogée"},
        )
        # --- ECTS
        for ue in ues:
            colid = f"moy_ue_{ue.id}"
            row_ects[colid] = ue.ects
            row_ects[f"_{colid}_class"] = "col_ue"
            # style cases vides pour borders verticales
            row_coef[colid] = ""
            row_coef[f"_{colid}_class"] = "col_ue"
            # row_apo[colid] = ue.code_apogee or ""
        row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT])
        row_ects["_moy_gen_class"] = "col_moy_gen"

        # --- MIN, MAX, MOY, APO

        row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min())
        row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max())
        row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean())
        for ue in ues:
            colid = f"moy_ue_{ue.id}"
            row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min())
            row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max())
            row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean())
            row_min[f"_{colid}_class"] = "col_ue"
            row_max[f"_{colid}_class"] = "col_ue"
            row_moy[f"_{colid}_class"] = "col_ue"
            row_apo[colid] = ue.code_apogee or ""

            for modimpl in self.formsemestre.modimpls_sorted:
                if modimpl.id in modimpl_ids:
                    colid = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
                    if self.is_apc:
                        coef = self.modimpl_coefs_df[modimpl.id][ue.id]
                    else:
                        coef = modimpl.module.coefficient or 0
                    row_coef[colid] = fmt_note(coef)
                    notes = self.modimpl_notes(modimpl.id, ue.id)
                    if np.isnan(notes).all():
                        # aucune note valide
                        row_min[colid] = np.nan
                        row_max[colid] = np.nan
                        moy = np.nan
                    else:
                        row_min[colid] = fmt_note(np.nanmin(notes))
                        row_max[colid] = fmt_note(np.nanmax(notes))
                        moy = np.nanmean(notes)
                    row_moy[colid] = fmt_note(moy)
                    if np.isnan(moy):
                        # aucune note dans ce module
                        row_moy[f"_{colid}_class"] = "col_empty"
                    row_apo[colid] = modimpl.module.code_apogee or ""

        return {  # { key : row } avec key = min, max, moy, coef
            "min": row_min,
            "max": row_max,
            "moy": row_moy,
            "coef": row_coef,
            "ects": row_ects,
            "apo": row_apo,
        }

    def _recap_etud_groups_infos(
        self, etudid: int, row: dict, titles: dict
    ):  # XXX non utilisé
        """Table recap: ajoute à row les colonnes sur les groupes pour cet etud"""
        # dec = self.get_etud_decision_sem(etudid)
        # if dec:
        #    codes_nb[dec["code"]] += 1
        row_class = ""
        etud_etat = self.get_etud_etat(etudid)
        if etud_etat == DEM:
            gr_name = "Dém."
            row_class = "dem"
        elif etud_etat == DEF:
            gr_name = "Déf."
            row_class = "def"
        else:
            # XXX probablement à revoir pour utiliser données cachées,
            # via get_etud_groups_in_partition ou autre
            group = sco_groups.get_etud_main_group(etudid, self.formsemestre.id)
            gr_name = group["group_name"] or ""
        row["group"] = gr_name
        row["_group_class"] = "group"
        if row_class:
            row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class])
        titles["group"] = "Gr"

    def _recap_add_admissions(self, rows: list[dict], titles: dict):
        """Ajoute les colonnes "admission"
        rows est une liste de dict avec une clé "etudid"
        Les colonnes ont la classe css "admission"
        """
        fields = {
            "bac": "Bac",
            "specialite": "Spécialité",
            "type_admission": "Type Adm.",
            "classement": "Rg. Adm.",
        }
        first = True
        for i, cid in enumerate(fields):
            titles[f"_{cid}_col_order"] = 10000 + i  # tout à droite
            if first:
                titles[f"_{cid}_class"] = "admission admission_first"
                first = False
            else:
                titles[f"_{cid}_class"] = "admission"
        titles.update(fields)
        for row in rows:
            etud = Identite.query.get(row["etudid"])
            admission = etud.admission.first()
            first = True
            for cid in fields:
                row[cid] = getattr(admission, cid) or ""
                if first:
                    row[f"_{cid}_class"] = "admission admission_first"
                    first = False
                else:
                    row[f"_{cid}_class"] = "admission"

    def recap_add_cursus(self, rows: list[dict], titles: dict, col_idx: int = None):
        """Ajoute colonne avec code cursus, eg 'S1 S2 S1'"""
        cid = "code_cursus"
        titles[cid] = "Cursus"
        titles[f"_{cid}_col_order"] = col_idx
        formation_code = self.formsemestre.formation.formation_code
        for row in rows:
            etud = Identite.query.get(row["etudid"])
            row[cid] = " ".join(
                [
                    f"S{ins.formsemestre.semestre_id}"
                    for ins in reversed(etud.inscriptions())
                    if ins.formsemestre.formation.formation_code == formation_code
                ]
            )

    def recap_add_partitions(
        self, rows: list[dict], titles: dict, col_idx: int = None
    ) -> int:
        """Ajoute les colonnes indiquant les groupes
        rows est une liste de dict avec une clé "etudid"
        Les colonnes ont la classe css "partition"
        Renvoie l'indice de la dernière colonne utilisée
        """
        partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
            self.formsemestre.id
        )
        first_partition = True
        col_order = 10 if col_idx is None else col_idx
        for partition in partitions:
            cid = f"part_{partition['partition_id']}"
            rg_cid = cid + "_rg"  # rang dans la partition
            titles[cid] = partition["partition_name"]
            if first_partition:
                klass = "partition"
            else:
                klass = "partition partition_aux"
            titles[f"_{cid}_class"] = klass
            titles[f"_{cid}_col_order"] = col_order
            titles[f"_{rg_cid}_col_order"] = col_order + 1
            col_order += 2
            if partition["bul_show_rank"]:
                titles[rg_cid] = f"Rg {partition['partition_name']}"
                titles[f"_{rg_cid}_class"] = "partition_rangs"
            partition_etud_groups = partitions_etud_groups[partition["partition_id"]]
            for row in rows:
                group = None  # group (dict) de l'étudiant dans cette partition
                # dans NotesTableCompat, à revoir
                etud_etat = self.get_etud_etat(row["etudid"])
                if etud_etat == scu.DEMISSION:
                    gr_name = "Dém."
                    row["_tr_class"] = "dem"
                elif etud_etat == DEF:
                    gr_name = "Déf."
                    row["_tr_class"] = "def"
                else:
                    group = partition_etud_groups.get(row["etudid"])
                    gr_name = group["group_name"] if group else ""
                if gr_name:
                    row[cid] = gr_name
                    row[f"_{cid}_class"] = klass
                # Rangs dans groupe
                if (
                    partition["bul_show_rank"]
                    and (group is not None)
                    and (group["id"] in self.moy_gen_rangs_by_group)
                ):
                    rang = self.moy_gen_rangs_by_group[group["id"]][0]
                    row[rg_cid] = rang.get(row["etudid"], "")

            first_partition = False
        return col_order

    def _recap_add_evaluations(
        self, rows: list[dict], titles: dict, bottom_infos: dict
    ):
        """Ajoute les colonnes avec les notes aux évaluations
        rows est une liste de dict avec une clé "etudid"
        Les colonnes ont la classe css "evaluation"
        """
        # nouvelle ligne pour description évaluations:
        bottom_infos["descr_evaluation"] = {
            "_tr_class": "bottom_info",
            "_title": "Description évaluation",
        }
        first_eval = True
        index_col = 9000  # à droite
        for modimpl in self.formsemestre.modimpls_sorted:
            evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl)
            eval_index = len(evals) - 1
            inscrits = {i.etudid for i in modimpl.inscriptions}
            first_eval_of_mod = True
            for e in evals:
                cid = f"eval_{e.id}"
                titles[
                    cid
                ] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
                klass = "evaluation"
                if first_eval:
                    klass += " first"
                elif first_eval_of_mod:
                    klass += " first_of_mod"
                titles[f"_{cid}_class"] = klass
                first_eval_of_mod = first_eval = False
                titles[f"_{cid}_col_order"] = index_col
                index_col += 1
                eval_index -= 1
                notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
                    e.evaluation_id
                )
                for row in rows:
                    etudid = row["etudid"]
                    if etudid in inscrits:
                        if etudid in notes_db:
                            val = notes_db[etudid]["value"]
                        else:
                            # Note manquante mais prise en compte immédiate: affiche ATT
                            val = scu.NOTES_ATTENTE
                        row[cid] = scu.fmt_note(val)
                        row[f"_{cid}_class"] = klass + {
                            "ABS": " abs",
                            "ATT": " att",
                            "EXC": " exc",
                        }.get(row[cid], "")
                    else:
                        row[cid] = "ni"
                        row[f"_{cid}_class"] = klass + " non_inscrit"

                bottom_infos["coef"][cid] = e.coefficient
                bottom_infos["min"][cid] = "0"
                bottom_infos["max"][cid] = scu.fmt_note(e.note_max)
                bottom_infos["descr_evaluation"][cid] = e.description or ""
                bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for(
                    "notes.evaluation_listenotes",
                    scodoc_dept=g.scodoc_dept,
                    evaluation_id=e.id,
                )