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

from collections import defaultdict
from functools import cached_property
import numpy as np
import pandas as pd
from app.comp.aux import StatsMoyenne
from app.models import FormSemestre, ModuleImpl
from app.scodoc import sco_utils as scu
from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.sco_codes_parcours import UE_SPORT, ATT, DEF

# 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:
    _cached_attrs = (
        "etud_moy_gen_ranks",
        "etud_moy_gen",
        "etud_moy_ue",
        "modimpl_inscr_df",
        "modimpls_results",
    )

    def __init__(self, formsemestre: FormSemestre):
        self.formsemestre = formsemestre
        # BUT ou standard ? (apc == "approche par compétences")
        self.is_apc = formsemestre.formation.is_apc()
        # Attributs "virtuels", définis pas les sous-classes
        #  ResultatsSemestreBUT ou ResultatsSemestreStd
        self.etud_moy_ue = {}
        self.etud_moy_gen = {}
        self.etud_moy_gen_ranks = {}
        # TODO

    def load_cached(self) -> bool:
        "Load cached dataframes, returns False si pas en cache"
        data = ResultatsSemestreCache.get(self.formsemestre.id)
        if not data:
            return False
        for attr in self._cached_attrs:
            setattr(self, attr, data[attr])
        return True

    def store(self):
        "Cache our data"
        ResultatsSemestreCache.set(
            self.formsemestre.id,
            {attr: getattr(self, attr) for attr in self._cached_attrs},
        )

    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()

    @cached_property
    def etuds(self):
        "Liste des inscrits au semestre, sans les démissionnaires"
        # nb: si la liste des inscrits change, ResultatsSemestre devient invalide
        return self.formsemestre.get_inscrits(include_dem=False)

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

    @cached_property
    def ues(self):
        "Liste des UE du semestre"
        return self.formsemestre.query_ues().all()

    @cached_property
    def modimpls(self):
        """Liste des modimpls du semestre
        - triée par numéro de module en APC
        - triée par numéros d'UE/matières/modules pour les formations standard.
        """
        modimpls = self.formsemestre.modimpls.all()
        if self.is_apc:
            modimpls.sort(key=lambda m: (m.module.numero, m.module.code))
        else:
            modimpls.sort(
                key=lambda m: (
                    m.module.ue.numero,
                    m.module.matiere.numero,
                    m.module.numero,
                    m.module.code,
                )
            )
        return modimpls

    @cached_property
    def ressources(self):
        "Liste des ressources du semestre, triées par numéro de module"
        return [
            m for m in self.modimpls 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.modimpls if m.module.module_type == scu.ModuleType.SAE]


# 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 + ()

    def __init__(self, formsemestre: FormSemestre):
        super().__init__(formsemestre)
        nb_etuds = len(self.etuds)
        self.bonus = defaultdict(lambda: 0.0)  # XXX TODO
        self.ue_rangs = {u.id: (defaultdict(lambda: 0.0), nb_etuds) for u in self.ues}
        self.mod_rangs = {
            m.id: (defaultdict(lambda: 0), nb_etuds) for m in self.modimpls
        }
        self.moy_min = "NA"
        self.moy_max = "NA"

    def get_etudids(self, sorted=False) -> list[int]:
        """Liste des etudids inscrits, incluant les démissionnaires.
        Si sorted, triée par moy. générale décroissante
        Sinon, triée par ordre alphabetique de NOM
        """
        # Note: pour avoir les inscrits non triés,
        # utiliser [ ins.etudid for ins in self.formsemestre.inscriptions ]
        if sorted:
            # Tri par moy. generale décroissante
            return [x[-1] for x in self.T]

        return [x["etudid"] for x in self.inscrlist]

    @cached_property
    def inscrlist(self) -> list[dict]:  # utilisé par PE seulement
        """Liste de dict etud, avec démissionnaires
        classée dans l'ordre alphabétique de noms.
        """
        etuds = self.formsemestre.get_inscrits(include_dem=True)
        etuds.sort(key=lambda e: e.sort_key)
        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):  # 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 = []
        for ue in self.ues:
            if filter_sport and ue.type == UE_SPORT:
                continue
            d = ue.to_dict()
            d.update(StatsMoyenne(self.etud_moy_ue[ue.id]).to_dict())
            ues.append(d)
        return ues

    def get_modimpls_dict(self, ue_id=None):
        """Liste des modules pour une UE (ou toutes si ue_id==None),
        triés par numéros (selon le type de formation)
        """
        if ue_id is None:
            return [m.to_dict() for m in self.modimpls]
        else:
            return [m.to_dict() for m in self.modimpls if m.module.ue.id == ue_id]

    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:
            return {
                "code": ATT,  # XXX TODO
                "assidu": True,  # XXX TODO
                "event_date": "",
                "compense_formsemestre_id": 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_moy_gen(self, etudid):  # -> float | str
        """Moyenne générale de cet etudiant dans ce semestre.
        Prend en compte les UE capitalisées. (TODO)
        Si apc, moyenne indicative.
        Si pas de notes: 'NA'
        """
        return self.etud_moy_gen[etudid]

    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_ue_status(self, etudid: int, ue_id: int):
        return {
            "cur_moy_ue": self.results.etud_moy_ue[ue_id][etudid],
            "is_capitalized": False,  # XXX TODO
        }

    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 des évaluations valides dans un module"
        mi = ModuleImpl.query.get(moduleimpl_id)
        evals_results = []
        for e in mi.evaluations:
            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": self.results.modimpls_evals_notes[e.moduleimpl_id][e.id][
                        etud.id
                    ],
                }
                for etud in self.results.etuds
            }
            evals_results.append(d)
        return evals_results

    def get_moduleimpls_attente(self):
        return []  # XXX TODO

    def get_mod_stats(self, moduleimpl_id):
        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

        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.modimpls)
            else:
                moy_ues = self.etud_moy_ue.loc[etudid]
                t = [moy_gen] + list(moy_ues)
                # TODO UE capitalisées: ne pas afficher moyennes modules
                for modimpl in self.modimpls:
                    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
        }
