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

from collections import Counter
from functools import cached_property
import numpy as np
import pandas as pd

from flask import g, flash, url_for

from app import log
from app.comp.aux_stats import StatsMoyenne
from app.comp import moy_sem
from app.comp.res_cache import ResultatsCache
from app.comp import res_sem
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 import sco_utils as scu
from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
from app.scodoc.sco_exceptions import ScoValueError

# 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):
    _cached_attrs = (
        "etud_moy_gen_ranks",
        "etud_moy_gen",
        "etud_moy_ue",
        "modimpl_inscr_df",
        "modimpls_results",
        "etud_coef_ue_df",
        "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
        #  ResultatsSemestreBUT ou ResultatsSemestreClassic
        self.etud_moy_ue = {}
        "etud_moy_ue: DataFrame columns UE, rows etudid"
        self.etud_moy_gen = {}
        self.etud_moy_gen_ranks = {}
        self.etud_moy_gen_ranks_int = {}
        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.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)}

    @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
        ]

    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. XXX notion à implémenter, pour l'instant toutes les UE du semestre.

        - 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:
            # TODO: introduire la notion de parcours (#sco93)
            return self.formsemestre.query_ues().filter(UniteEns.type != UE_SPORT).all()
        else:
            # 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_id, etudid) -> list[ModuleImpl]:
        """Liste des modimpl de cette UE auxquels l'étudiant est inscrit.
        Utile en formations classiques, surchargée pour le BUT.
        """
        # sert pour l'affichage ou non de l'UE sur le bulletin
        return [
            modimpl
            for modimpl in self.formsemestre.modimpls_sorted
            if modimpl.module.ue.id == ue_id
            and self.modimpl_inscr_df[modimpl.id][etudid]
        ]

    @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 va soustraire la moyenne d'UE et ajouter celle de l'UE capitalisée.
        if not self.validations:
            self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
        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_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
        return ue_cap.to_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)  # TODO cacher nos UEs ?
        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,
            }
        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,
            "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 get_etud_ue_cap_coef(self, etudid, ue, ue_cap):
        """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)


# Pour raccorder le code des anciens codes qui attendent une NoteTable
class NotesTableCompat(ResultatsSemestre):
    """Implementation partielle de NotesTable WIP TODO

    Les méthodes définies dans cette classe sont là
    pour conserver la compatibilité abvec les codes anciens et
    il n'est pas recommandé de les utiliser dans de nouveaux
    développements (API malcommode et peu efficace).
    """

    _cached_attrs = ResultatsSemestre._cached_attrs + (
        "bonus",
        "bonus_ues",
        "malus",
        "etud_moy_gen_ranks",
        "etud_moy_gen_ranks_int",
        "ue_rangs",
    )

    def __init__(self, formsemestre: FormSemestre):
        super().__init__(formsemestre)

        nb_etuds = len(self.etuds)
        self.bonus = None  # virtuel
        self.bonus_ues = None  # virtuel
        self.ue_rangs = {u.id: (None, nb_etuds) for u in self.ues}
        self.mod_rangs = None  # sera surchargé en Classic, mais pas en APC
        """{ modimpl_id : (rangs, effectif) }"""
        self.moy_min = "NA"
        self.moy_max = "NA"
        self.moy_moy = "NA"
        self.expr_diagnostics = ""
        self.parcours = self.formsemestre.formation.get_parcours()

    def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]:
        """Liste des étudiants inscrits
        order_by = False|'nom'|'moy' tri sur nom ou sur moyenne générale (indicative)

        Note: pour récupérer les etudids des inscrits, non triés, il est plus efficace
        d'utiliser `[ ins.etudid for ins in nt.formsemestre.inscriptions ]`
        """
        etuds = self.formsemestre.get_inscrits(
            include_demdef=include_demdef, order=(order_by == "nom")
        )
        if order_by == "moy":
            etuds.sort(
                key=lambda e: (
                    self.etud_moy_gen_ranks_int.get(e.id, 100000),
                    e.sort_key,
                )
            )
        return etuds

    def get_etudids(self) -> list[int]:
        """(deprecated)
        Liste des etudids inscrits, incluant les démissionnaires.
        triée par ordre alphabetique de NOM
        (à éviter: renvoie les etudids, mais est moins efficace que get_inscrits)
        """
        # Note: pour avoir les inscrits non triés,
        # utiliser [ ins.etudid for ins in self.formsemestre.inscriptions ]
        return [x["etudid"] for x in self.inscrlist]

    @cached_property
    def sem(self) -> dict:
        """le formsemestre, comme un gros et gras dict (nt.sem)"""
        return self.formsemestre.get_infos_dict()

    @cached_property
    def inscrlist(self) -> list[dict]:  # utilisé par PE
        """Liste des inscrits au semestre (avec DEM et DEF),
        sous forme de dict etud,
        classée dans l'ordre alphabétique de noms.
        """
        etuds = self.formsemestre.get_inscrits(include_demdef=True, order=True)
        return [e.to_dict_scodoc7() for e in etuds]

    @cached_property
    def stats_moy_gen(self):
        """Stats (moy/min/max) sur la moyenne générale"""
        return StatsMoyenne(self.etud_moy_gen)

    def get_ues_stat_dict(
        self, filter_sport=False, check_apc_ects=True
    ) -> list[dict]:  # was get_ues()
        """Liste des UEs, ordonnée par numero.
        Si filter_sport, retire les UE de type SPORT.
        Résultat: liste de dicts { champs UE U stats moyenne UE }
        """
        ues = self.formsemestre.query_ues(with_sport=not filter_sport)
        ues_dict = []
        for ue in ues:
            d = ue.to_dict()
            if ue.type != UE_SPORT:
                moys = self.etud_moy_ue[ue.id]
            else:
                moys = None
            d.update(StatsMoyenne(moys).to_dict())
            ues_dict.append(d)
        if check_apc_ects and self.is_apc and not hasattr(g, "checked_apc_ects"):
            g.checked_apc_ects = True
            if None in [ue.ects for ue in ues if ue.type != UE_SPORT]:
                flash(
                    """Calcul moyenne générale impossible: ECTS des UE manquants !""",
                    category="danger",
                )
        return ues_dict

    def get_modimpls_dict(self, ue_id=None) -> list[dict]:
        """Liste des modules pour une UE (ou toutes si ue_id==None),
        triés par numéros (selon le type de formation)
        """
        modimpls_dict = []
        for modimpl in self.formsemestre.modimpls_sorted:
            if ue_id == None or modimpl.module.ue.id == ue_id:
                d = modimpl.to_dict()
                # compat ScoDoc < 9.2: ajoute matières
                d["mat"] = modimpl.module.matiere.to_dict()
                modimpls_dict.append(d)
        return modimpls_dict

    def compute_rangs(self):
        """Calcule les classements
        Moyenne générale: etud_moy_gen_ranks
        Par UE (sauf ue bonus)
        """
        (
            self.etud_moy_gen_ranks,
            self.etud_moy_gen_ranks_int,
        ) = moy_sem.comp_ranks_series(self.etud_moy_gen)
        for ue in self.formsemestre.query_ues():
            moy_ue = self.etud_moy_ue[ue.id]
            self.ue_rangs[ue.id] = (
                moy_sem.comp_ranks_series(moy_ue)[0],  # juste en chaine
                int(moy_ue.count()),
            )
            # .count() -> nb of non NaN values

    def get_etud_ue_rang(self, ue_id, etudid) -> tuple[str, int]:
        """Le rang de l'étudiant dans cette ue
        Result: rang:str, effectif:str
        """
        rangs, effectif = self.ue_rangs[ue_id]
        if rangs is not None:
            rang = rangs[etudid]
        else:
            return "", ""
        return rang, effectif

    def etud_check_conditions_ues(self, etudid):
        """Vrai si les conditions sur les UE sont remplies.
        Ne considère que les UE ayant des notes (moyenne calculée).
        (les UE sans notes ne sont pas comptées comme sous la barre)
        Prend en compte les éventuelles UE capitalisées.

        Pour les parcours habituels, cela revient à vérifier que
        les moyennes d'UE sont toutes > à leur barre (sauf celles sans notes)

        Pour les parcours non standards (LP2014), cela peut être plus compliqué.

        Return: True|False, message explicatif
        """
        ue_status_list = []
        for ue in self.formsemestre.query_ues():
            ue_status = self.get_etud_ue_status(etudid, ue.id)
            if ue_status:
                ue_status_list.append(ue_status)
        return self.parcours.check_barre_ues(ue_status_list)

    def all_etuds_have_sem_decisions(self):
        """True si tous les étudiants du semestre ont une décision de jury.
        Ne regarde pas les décisions d'UE.
        """
        for ins in self.formsemestre.inscriptions:
            if ins.etat != scu.INSCRIT:
                continue  # skip démissionnaires
            if self.get_etud_decision_sem(ins.etudid) is None:
                return False
        return True

    def etud_has_decision(self, etudid):
        """True s'il y a une décision de jury pour cet étudiant"""
        return self.get_etud_decision_ues(etudid) or self.get_etud_decision_sem(etudid)

    def get_etud_decision_ues(self, etudid: int) -> dict:
        """Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu.
        Ne tient pas compte des UE capitalisées.
        { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : }
        Ne renvoie aucune decision d'UE pour les défaillants
        """
        if self.get_etud_etat(etudid) == DEF:
            return {}
        else:
            if not self.validations:
                self.validations = res_sem.load_formsemestre_validations(
                    self.formsemestre
                )
            return self.validations.decisions_jury_ues.get(etudid, None)

    def get_etud_decision_sem(self, etudid: int) -> dict:
        """Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu.
        { 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id }
        Si état défaillant, force le code a DEF
        """
        if self.get_etud_etat(etudid) == DEF:
            return {
                "code": DEF,
                "assidu": False,
                "event_date": "",
                "compense_formsemestre_id": None,
            }
        else:
            if not self.validations:
                self.validations = res_sem.load_formsemestre_validations(
                    self.formsemestre
                )
            return self.validations.decisions_jury.get(etudid, None)

    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_mat_moy(self, matiere_id: int, etudid: int) -> str:
        """moyenne d'un étudiant dans une matière (ou NA si pas de notes)"""
        if not self.moyennes_matieres:
            return "nd"
        return (
            self.moyennes_matieres[matiere_id].get(etudid, "-")
            if matiere_id in self.moyennes_matieres
            else "-"
        )

    def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float:
        """La moyenne de l'étudiant dans le moduleimpl
        En APC, il s'agira d'une moyenne indicative sans valeur.
        Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM)
        """
        raise NotImplementedError()  # virtual method

    def get_etud_moy_gen(self, etudid):  # -> float | str
        """Moyenne générale de cet etudiant dans ce semestre.
        Prend en compte les UE capitalisées.
        Si apc, moyenne indicative.
        Si pas de notes: 'NA'
        """
        return self.etud_moy_gen[etudid]

    def get_etud_ects_pot(self, etudid: int) -> dict:
        """
        Un dict avec les champs
         ects_pot : (float) nb de crédits ECTS qui seraient validés (sous réserve de validation par le jury),
         ects_pot_fond: (float) nb d'ECTS issus d'UE fondamentales (non électives)

        Ce sont les ECTS des UE au dessus de la barre (10/20 en principe), avant le jury (donc non
        encore enregistrées).
        """
        # was nt.get_etud_moy_infos
        # XXX pour compat nt, à remplacer ultérieurement
        ues = self.get_etud_ue_validables(etudid)
        ects_pot = 0.0
        for ue in ues:
            if (
                ue.id in self.etud_moy_ue
                and ue.ects is not None
                and self.etud_moy_ue[ue.id][etudid] > self.parcours.NOTES_BARRE_VALID_UE
            ):
                ects_pot += ue.ects
        return {
            "ects_pot": ects_pot,
            "ects_pot_fond": 0.0,  # not implemented (anciennemment pour école ingé)
        }

    def get_etud_rang(self, etudid: int):
        return self.etud_moy_gen_ranks.get(etudid, 99999)  # XXX

    def get_etud_rang_group(self, etudid: int, group_id: int):
        return (None, 0)  # XXX unimplemented TODO

    def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]:
        """Liste d'informations (compat NotesTable) sur évaluations completes
        de ce module.
        Évaluation "complete" ssi toutes notes saisies ou en attente.
        """
        modimpl = ModuleImpl.query.get(moduleimpl_id)
        modimpl_results = self.modimpls_results.get(moduleimpl_id)
        if not modimpl_results:
            return []  # safeguard
        evals_results = []
        for e in modimpl.evaluations:
            if modimpl_results.evaluations_completes_dict.get(e.id, False):
                d = e.to_dict()
                d["heure_debut"] = e.heure_debut  # datetime.time
                d["heure_fin"] = e.heure_fin
                d["jour"] = e.jour  # datetime
                d["notes"] = {
                    etud.id: {
                        "etudid": etud.id,
                        "value": modimpl_results.evals_notes[e.id][etud.id],
                    }
                    for etud in self.etuds
                }
                d["etat"] = {
                    "evalattente": modimpl_results.evaluations_etat[e.id].nb_attente,
                }
                evals_results.append(d)
            elif e.id not in modimpl_results.evaluations_completes_dict:
                # ne devrait pas arriver ? XXX
                log(
                    f"Warning: 220213 get_evals_in_mod {e.id} not in mod {moduleimpl_id} ?"
                )
        return evals_results

    def get_evaluations_etats(self):
        """[ {...evaluation et son etat...} ]"""
        # TODO: à moderniser
        from app.scodoc import sco_evaluations

        if not hasattr(self, "_evaluations_etats"):
            self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem(
                self.formsemestre.id
            )

        return self._evaluations_etats

    def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
        """Liste des états des évaluations de ce module"""
        # XXX TODO à moderniser: lent, recharge des donénes que l'on a déjà...
        return [
            e
            for e in self.get_evaluations_etats()
            if e["moduleimpl_id"] == moduleimpl_id
        ]

    def get_moduleimpls_attente(self):
        """Liste des modimpls du semestre ayant des notes en attente"""
        return [
            modimpl
            for modimpl in self.formsemestre.modimpls_sorted
            if self.modimpls_results[modimpl.id].en_attente
        ]

    def get_mod_stats(self, moduleimpl_id: int) -> dict:
        """Stats sur les notes obtenues dans un modimpl
        Vide en APC
        """
        return {
            "moy": "-",
            "max": "-",
            "min": "-",
            "nb_notes": "-",
            "nb_missing": "-",
            "nb_valid_evals": "-",
        }

    def get_nom_short(self, etudid):
        "formatte nom d'un etud (pour table recap)"
        etud = self.identdict[etudid]
        return (
            (etud["nom_usuel"] or etud["nom"]).upper()
            + " "
            + etud["prenom"].capitalize()[:2]
            + "."
        )

    @cached_property
    def T(self):
        return self.get_table_moyennes_triees()

    def get_table_moyennes_triees(self) -> list:
        """Result: liste de tuples
        moy_gen, moy_ue_0, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid
        """
        table_moyennes = []
        etuds_inscriptions = self.formsemestre.etuds_inscriptions
        ues = self.formsemestre.query_ues(with_sport=True)  # avec bonus
        for etudid in etuds_inscriptions:
            moy_gen = self.etud_moy_gen.get(etudid, False)
            if moy_gen is False:
                # pas de moyenne: démissionnaire ou def
                t = (
                    ["-"]
                    + ["0.00"] * len(self.ues)
                    + ["NI"] * len(self.formsemestre.modimpls_sorted)
                )
            else:
                moy_ues = []
                ue_is_cap = {}
                for ue in ues:
                    ue_status = self.get_etud_ue_status(etudid, ue.id)
                    if ue_status:
                        moy_ues.append(ue_status["moy"])
                        ue_is_cap[ue.id] = ue_status["is_capitalized"]
                    else:
                        moy_ues.append("?")
                t = [moy_gen] + list(moy_ues)
                # Moyennes modules:
                for modimpl in self.formsemestre.modimpls_sorted:
                    if ue_is_cap.get(modimpl.module.ue.id, False):
                        val = "-c-"
                    else:
                        val = self.get_etud_mod_moy(modimpl.id, etudid)
                    t.append(val)
            t.append(etudid)
            table_moyennes.append(t)
        # tri par moyennes décroissantes,
        # en laissant les démissionnaires à la fin, par ordre alphabetique
        etuds = [ins.etud for ins in etuds_inscriptions.values()]
        etuds.sort(key=lambda e: e.sort_key)
        self._rang_alpha = {e.id: i for i, e in enumerate(etuds)}
        table_moyennes.sort(key=self._row_key)
        return table_moyennes

    def _row_key(self, x):
        """clé de tri par moyennes décroissantes,
        en laissant les demissionnaires à la fin, par ordre alphabetique.
        (moy_gen, rang_alpha)
        """
        try:
            moy = -float(x[0])
        except (ValueError, TypeError):
            moy = 1000.0
        return (moy, self._rang_alpha[x[-1]])

    @cached_property
    def identdict(self) -> dict:
        """{ etudid : etud_dict } pour tous les inscrits au semestre"""
        return {
            ins.etud.id: ins.etud.to_dict_scodoc7()
            for ins in self.formsemestre.inscriptions
        }