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

"""Génération bulletin BUT
"""

import collections
import datetime
import pandas as pd
import numpy as np
from flask import g, has_request_context, url_for

from app import db
from app.comp.moy_mod import ModuleImplResults
from app.comp.res_but import ResultatsSemestreBUT
from app.models import Evaluation, FormSemestre, Identite, ModuleImpl
from app.models.groups import GroupDescr
from app.models.ues import UniteEns
from app.scodoc import sco_bulletins, sco_utils as scu
from app.scodoc import sco_bulletins_json
from app.scodoc import sco_bulletins_pdf
from app.scodoc import codes_cursus
from app.scodoc import sco_groups
from app.scodoc import sco_preferences
from app.scodoc.codes_cursus import UE_SPORT, DEF
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import fmt_note


class BulletinBUT:
    """Génération du bulletin BUT.
    Cette classe génère des dictionnaires avec toutes les informations
    du bulletin, qui sont immédiatement traduisibles en JSON.
    """

    def __init__(self, formsemestre: FormSemestre):
        """ """
        self.res = ResultatsSemestreBUT(formsemestre)
        self.prefs = sco_preferences.SemPreferences(formsemestre.id)

    def etud_ue_mod_results(self, etud, ue, modimpls) -> dict:
        "dict synthèse résultats dans l'UE pour les modules indiqués"
        res = self.res
        d = {}
        etud_idx = res.etud_index[etud.id]
        if ue.type != UE_SPORT:
            ue_idx = res.modimpl_coefs_df.index.get_loc(ue.id)
        etud_moy_module = res.sem_cube[etud_idx]  # module x UE
        for modimpl in modimpls:
            if res.modimpl_inscr_df[modimpl.id][etud.id]:  # si inscrit
                if ue.type != UE_SPORT:
                    coef = res.modimpl_coefs_df[modimpl.id][ue.id]
                    if coef > 0:
                        d[modimpl.module.code] = {
                            "id": modimpl.id,
                            "coef": coef,
                            "moyenne": fmt_note(
                                etud_moy_module[
                                    res.modimpl_coefs_df.columns.get_loc(modimpl.id)
                                ][ue_idx]
                            ),
                        }
                # else:  # modules dans UE bonus sport
                #     d[modimpl.module.code] = {
                #         "id": modimpl.id,
                #         "coef": "",
                #         "moyenne": "?x?",
                #     }
        return d

    def etud_ue_results(
        self,
        etud: Identite,
        ue: UniteEns,
        decision_ue: dict,
        etud_groups: list[GroupDescr] = None,
    ) -> dict:
        """dict synthèse résultats UE
        etud_groups : liste des groupes, pour affichage du rang.
        Si UE sport et étudiant non inscrit, renvoie dict vide.
        """
        res = self.res

        if (etud.id, ue.id) in self.res.dispense_ues:
            return {}

        if ue.type == UE_SPORT:
            modimpls_spo = [
                modimpl
                for modimpl in res.formsemestre.modimpls_sorted
                if modimpl.module.ue.type == UE_SPORT
            ]
            # L'étudiant est-il inscrit à l'un des modules de l'UE bonus ?
            if not any(res.modimpl_inscr_df.loc[etud.id][[m.id for m in modimpls_spo]]):
                return {}

        d = {
            "id": ue.id,
            "titre": ue.titre,
            "numero": ue.numero,
            "type": ue.type,
            "color": ue.color,
            "competence": None,  # XXX TODO lien avec référentiel
            "moyenne": None,
            # Le bonus sport appliqué sur cette UE
            "bonus": (
                fmt_note(res.bonus_ues[ue.id][etud.id])
                if res.bonus_ues is not None and ue.id in res.bonus_ues
                else fmt_note(0.0)
            ),
            "malus": fmt_note(res.malus[ue.id][etud.id]),
            "capitalise": None,  # "AAAA-MM-JJ" TODO #sco93
            "ressources": self.etud_ue_mod_results(etud, ue, res.ressources),
            "saes": self.etud_ue_mod_results(etud, ue, res.saes),
        }
        if self.prefs["bul_show_ects"]:
            d["ECTS"] = {
                "acquis": decision_ue.get("ects", 0.0),
                "total": ue.ects or 0.0,  # float même si non renseigné
            }
        if ue.type != UE_SPORT:
            if self.prefs["bul_show_ue_rangs"]:
                rangs, effectif = res.ue_rangs[ue.id]
                rang = rangs[etud.id]
            else:
                rang, effectif = "", 0
            d["moyenne"] = {
                "value": fmt_note(res.etud_moy_ue[ue.id][etud.id]),
                "min": fmt_note(res.etud_moy_ue[ue.id].min()),
                "max": fmt_note(res.etud_moy_ue[ue.id].max()),
                "moy": fmt_note(res.etud_moy_ue[ue.id].mean()),
                "rang": rang,
                "total": effectif,  # nb etud avec note dans cette UE
                "groupes": {},
            }
            if self.prefs["bul_show_ue_rangs"]:
                for group in etud_groups:
                    if group.partition.bul_show_rank:
                        rang, effectif = self.res.get_etud_ue_rang(
                            ue.id, etud.id, group.id
                        )
                        d["moyenne"]["groupes"][group.id] = {
                            "value": rang,
                            "total": effectif,
                        }
        else:  # UE BONUS
            d["modules"] = self.etud_mods_results(etud, modimpls_spo)
            # ceci suppose que l'on a une seule UE bonus,
            # en tous cas elles auront la même description
            d["bonus_description"] = self.etud_bonus_description(etud.id)

        return d

    def etud_ues_capitalisees(self, etud: Identite) -> dict:
        """dict avec les UE capitalisees. la clé est l'acronyme d'UE, qui ne
        peut donc être capitalisée qu'une seule fois (on prend la meilleure)"""
        if not etud.id in self.res.validations.ue_capitalisees.index:
            return {}  # aucune capitalisation
        d = {}
        for _, ue_capitalisee in self.res.validations.ue_capitalisees.loc[
            [etud.id]
        ].iterrows():
            if codes_cursus.code_ue_validant(ue_capitalisee.code):
                ue = db.session.get(UniteEns, ue_capitalisee.ue_id)  # XXX cacher ?
                # déjà capitalisé ? montre la meilleure
                if ue.acronyme in d:
                    moy_cap = d[ue.acronyme]["moyenne_num"] or 0.0
                    if (not isinstance(moy_cap, float)) or (
                        (ue_capitalisee.moy_ue or 0.0) < moy_cap
                    ):
                        continue  # skip this duplicate UE

                d[ue.acronyme] = {
                    "id": ue.id,
                    "ue_code": ue_capitalisee.ue_code,
                    "titre": ue.titre,
                    "numero": ue.numero,
                    "type": ue.type,
                    "color": ue.color,
                    "moyenne": fmt_note(ue_capitalisee.moy_ue),  # arrondi en str
                    "moyenne_num": fmt_note(ue_capitalisee.moy_ue, keep_numeric=True),
                    "is_external": ue_capitalisee.is_external,
                    "date_capitalisation": ue_capitalisee.event_date,
                    "formsemestre_id": ue_capitalisee.formsemestre_id,
                    "bul_orig_url": (
                        url_for(
                            "notes.formsemestre_bulletinetud",
                            scodoc_dept=g.scodoc_dept,
                            etudid=etud.id,
                            formsemestre_id=ue_capitalisee.formsemestre_id,
                        )
                        if ue_capitalisee.formsemestre_id
                        else None
                    ),
                    "ressources": {},  # sans détail en BUT
                    "saes": {},
                }
                if self.prefs["bul_show_ects"]:
                    d[ue.acronyme]["ECTS"] = {
                        "acquis": ue.ects or 0.0,  # toujours validée ici
                        "total": ue.ects or 0.0,  # float même si non renseigné
                    }
        return d

    def etud_mods_results(self, etud, modimpls, version="long") -> dict:
        """dict synthèse résultats des modules indiqués,
        avec évaluations de chacun (sauf si version == "short")
        """
        res = self.res
        d = {}
        # etud_idx = self.etud_index[etud.id]
        for modimpl in modimpls:
            # mod_idx = self.modimpl_coefs_df.columns.get_loc(mi.id)
            # # moyennes indicatives (moyennes de moyennes d'UE)
            # try:
            #     moyennes_etuds = np.nan_to_num(
            #         np.nanmean(self.sem_cube[:, mod_idx, :], axis=1),
            #         copy=False,
            #     )
            # except RuntimeWarning:
            # # all nans in np.nanmean (sur certains etuds sans notes valides)
            #     pass
            # try:
            #     moy_indicative_mod = np.nanmean(self.sem_cube[etud_idx, mod_idx])
            # except RuntimeWarning:  # all nans in np.nanmean
            #     pass
            modimpl_results = res.modimpls_results[modimpl.id]
            if res.modimpl_inscr_df[modimpl.id][etud.id]:  # si inscrit
                d[modimpl.module.code] = {
                    "id": modimpl.id,
                    "titre": modimpl.module.titre_str(),
                    "code_apogee": modimpl.module.code_apogee,
                    "url": (
                        url_for(
                            "notes.moduleimpl_status",
                            scodoc_dept=g.scodoc_dept,
                            moduleimpl_id=modimpl.id,
                        )
                        if has_request_context()
                        else "na"
                    ),
                    "moyenne": {
                        # # moyenne indicative de module: moyenne des UE,
                        # # ignorant celles sans notes (nan)
                        # "value": fmt_note(moy_indicative_mod),
                        # "min": fmt_note(moyennes_etuds.min()),
                        # "max": fmt_note(moyennes_etuds.max()),
                        # "moy": fmt_note(moyennes_etuds.mean()),
                    },
                    "evaluations": (
                        self.etud_list_modimpl_evaluations(
                            etud, modimpl, modimpl_results, version
                        )
                        if version != "short"
                        else []
                    ),
                }
        return d

    def etud_list_modimpl_evaluations(
        self,
        etud: Identite,
        modimpl: ModuleImpl,
        modimpl_results: ModuleImplResults,
        version: str,
    ) -> list[dict]:
        """Liste des résultats aux évaluations de ce modimpl à montrer pour cet étudiant"""
        evaluation: Evaluation
        eval_results = []
        for evaluation in modimpl.evaluations:
            if (
                (evaluation.visibulletin or version == "long")
                and (evaluation.id in modimpl_results.evaluations_etat)
                and (
                    modimpl_results.evaluations_etat[evaluation.id].is_complete
                    or self.prefs["bul_show_all_evals"]
                )
            ):
                eval_notes = self.res.modimpls_results[modimpl.id].evals_notes[
                    evaluation.id
                ]

                if (evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE) or (
                    not np.isnan(eval_notes[etud.id])
                ):
                    eval_results.append(
                        self.etud_eval_results(etud, evaluation, eval_notes)
                    )
        return eval_results

    def etud_eval_results(
        self, etud: Identite, evaluation: Evaluation, eval_notes: pd.DataFrame
    ) -> dict:
        "dict resultats d'un étudiant à une évaluation"
        # eval_notes est une pd.Series avec toutes les notes des étudiants inscrits
        notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
        modimpls_evals_poids = self.res.modimpls_evals_poids[evaluation.moduleimpl_id]
        try:
            etud_ues_ids = self.res.etud_ues_ids(etud.id)
            poids = {
                ue.acronyme: modimpls_evals_poids[ue.id][evaluation.id]
                for ue in self.res.ues
                if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids)
            }
        except KeyError:
            poids = collections.defaultdict(lambda: 0.0)
        d = {
            "id": evaluation.id,
            "coef": (
                fmt_note(evaluation.coefficient)
                if evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE
                else None
            ),
            "date_debut": (
                evaluation.date_debut.isoformat() if evaluation.date_debut else None
            ),
            "date_fin": (
                evaluation.date_fin.isoformat() if evaluation.date_fin else None
            ),
            "description": evaluation.description,
            "evaluation_type": evaluation.evaluation_type,
            "note": (
                {
                    "value": fmt_note(
                        eval_notes[etud.id],
                        note_max=evaluation.note_max,
                    ),
                    "min": fmt_note(notes_ok.min(), note_max=evaluation.note_max),
                    "max": fmt_note(notes_ok.max(), note_max=evaluation.note_max),
                    "moy": fmt_note(notes_ok.mean(), note_max=evaluation.note_max),
                }
                if not evaluation.is_blocked()
                else {}
            ),
            "poids": poids,
            "url": (
                url_for(
                    "notes.evaluation_listenotes",
                    scodoc_dept=g.scodoc_dept,
                    evaluation_id=evaluation.id,
                )
                if has_request_context()
                else "na"
            ),
            # deprecated (supprimer avant #sco9.7)
            "date": (
                evaluation.date_debut.isoformat() if evaluation.date_debut else None
            ),
            "heure_debut": (
                evaluation.date_debut.time().isoformat("minutes")
                if evaluation.date_debut
                else None
            ),
            "heure_fin": (
                evaluation.date_fin.time().isoformat("minutes")
                if evaluation.date_fin
                else None
            ),
        }
        return d

    def etud_bonus_description(self, etudid):
        """description du bonus affichée dans la section "UE bonus"."""
        res = self.res
        if res.bonus_ues is None or res.bonus_ues.shape[1] == 0:
            return ""

        bonus_vect = res.bonus_ues.loc[etudid]
        if bonus_vect.nunique() > 1:
            # détail UE par UE
            details = [
                f"{fmt_note(bonus_vect[ue.id])} sur {ue.acronyme}"
                for ue in res.ues
                if ue.type != UE_SPORT
                and res.modimpls_in_ue(ue, etudid)
                and ue.id in res.bonus_ues
                and bonus_vect[ue.id] > 0.0
            ]
            if details:
                return "Bonus de " + ", ".join(details)
            else:
                return ""  # aucun bonus
        else:
            return f"Bonus de {fmt_note(bonus_vect.iloc[0])}"

    def bulletin_etud(
        self,
        etud: Identite,
        force_publishing=False,
        version="long",
    ) -> dict:
        """Le bulletin de l'étudiant dans ce semestre: dict pour la version JSON / HTML.
        - version:
            "long", "selectedevals": toutes les infos (notes des évaluations)
            "short" : ne descend pas plus bas que les modules.

        - Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai
        (bulletins non publiés sur la passerelle).
        """
        if version not in scu.BULLETINS_VERSIONS_BUT:
            raise ScoValueError("bulletin_etud: version de bulletin demandée invalide")
        res = self.res
        formsemestre = res.formsemestre
        d = {
            "version": "0",
            "type": "BUT",
            "date": datetime.datetime.now(datetime.timezone.utc)
            .astimezone()
            .isoformat(),
            "publie": not formsemestre.bul_hide_xml,
            "etat_inscription": etud.inscription_etat(formsemestre.id),
            "etudiant": etud.to_dict_bul(),
            "formation": {
                "id": formsemestre.formation.id,
                "acronyme": formsemestre.formation.acronyme,
                "titre_officiel": formsemestre.formation.titre_officiel,
                "titre": formsemestre.formation.titre,
            },
            "formsemestre_id": formsemestre.id,
            "options": sco_preferences.bulletin_option_affichage(
                formsemestre, self.prefs
            ),
        }
        published = (not formsemestre.bul_hide_xml) or force_publishing
        if not published or d["etat_inscription"] is False:
            return d

        nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
        if formsemestre.formation.referentiel_competence is None:
            etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
        else:
            etud_ues_ids = res.etud_ues_ids(etud.id)

        nbabsnj, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
        etud_groups = sco_groups.get_etud_formsemestre_groups(
            etud, formsemestre, only_to_show=True
        )
        semestre_infos = {
            "etapes": [str(x.etape_apo) for x in formsemestre.etapes if x.etape_apo],
            "date_debut": formsemestre.date_debut.isoformat(),
            "date_fin": formsemestre.date_fin.isoformat(),
            "annee_universitaire": formsemestre.annee_scolaire_str(),
            "numero": formsemestre.semestre_id,
            "inscription": "",  # inutilisé mais nécessaire pour le js de Seb.
            "groupes": [group.to_dict() for group in etud_groups],
        }
        if self.prefs["bul_show_abs"]:
            semestre_infos["absences"] = {
                "injustifie": nbabsnj,
                "total": nbabs,
                "metrique": {
                    "H.": "Heure(s)",
                    "J.": "Journée(s)",
                    "1/2 J.": "1/2 Jour.",
                }.get(sco_preferences.get_preference("assi_metrique")),
            }
        decisions_ues = self.res.get_etud_decisions_ue(etud.id) or {}
        if self.prefs["bul_show_ects"]:
            ects_tot = res.etud_ects_tot_sem(etud.id)
            ects_acquis = res.get_etud_ects_valides(etud.id, decisions_ues)
            semestre_infos["ECTS"] = {"acquis": ects_acquis, "total": ects_tot}
        if sco_preferences.get_preference("bul_show_decision", formsemestre.id):
            semestre_infos.update(
                sco_bulletins_json.dict_decision_jury(etud, formsemestre)
            )
        if d["etat_inscription"] == scu.INSCRIT:
            # moyenne des moyennes générales du semestre
            semestre_infos["notes"] = {
                "value": fmt_note(res.etud_moy_gen[etud.id]),
                "min": fmt_note(res.etud_moy_gen.min()),
                "moy": fmt_note(res.etud_moy_gen.mean()),
                "max": fmt_note(res.etud_moy_gen.max()),
            }
            if self.prefs["bul_show_rangs"] and not np.isnan(res.etud_moy_gen[etud.id]):
                # classement wrt moyenne générale, indicatif
                semestre_infos["rang"] = {
                    "value": res.etud_moy_gen_ranks[etud.id],
                    "total": nb_inscrits,
                    "groupes": {},
                }
                # Rangs par groupes
                for group in etud_groups:
                    if group.partition.bul_show_rank:
                        rang, effectif = self.res.get_etud_rang_group(etud.id, group.id)
                        semestre_infos["rang"]["groupes"][group.id] = {
                            "value": rang,
                            "total": effectif,
                        }
            else:
                semestre_infos["rang"] = {
                    "value": "-",
                    "total": nb_inscrits,
                    "groupes": {},
                }
            d.update(
                {
                    "ressources": self.etud_mods_results(
                        etud, res.ressources, version=version
                    ),
                    "saes": self.etud_mods_results(etud, res.saes, version=version),
                    "ues_capitalisees": (
                        self.etud_ues_capitalisees(etud)
                        if sco_preferences.get_preference(
                            "bul_show_ue_cap_but", formsemestre.id
                        )
                        else {}
                    ),
                    "semestre": semestre_infos,
                },
            )
            d_ues = {}
            for ue in res.ues:
                # si l'UE comporte des modules auxquels on est inscrit:
                if (ue.type == UE_SPORT) or ue.id in etud_ues_ids:
                    ue_r = self.etud_ue_results(
                        etud,
                        ue,
                        decision_ue=decisions_ues.get(ue.id, {}),
                        etud_groups=etud_groups,
                    )
                    if ue_r:  # exclu UE sport sans inscriptions
                        d_ues[ue.acronyme] = ue_r
            d["ues"] = d_ues

        else:
            semestre_infos.update(
                {
                    "notes": {
                        "value": "DEM",
                        "min": "",
                        "moy": "",
                        "max": "",
                    },
                    "rang": {"value": "DEM", "total": nb_inscrits},
                }
            )
            d.update(
                {
                    "semestre": semestre_infos,
                    "ressources": {},
                    "saes": {},
                    "ues": {},
                    "ues_capitalisees": {},
                }
            )

        return d

    def bulletin_etud_complet(self, etud: Identite, version="long") -> dict:
        """Bulletin dict complet avec toutes les infos pour les bulletins BUT pdf
        (pas utilisé pour json/html)
        Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict
        """
        d = self.bulletin_etud(etud, version=version, force_publishing=True)
        d["etudid"] = etud.id
        d["etud"] = d["etudiant"]
        d["etud"]["nomprenom"] = etud.nomprenom
        d["etud"]["etat_civil"] = etud.etat_civil
        d.update(self.res.sem)
        etud_etat = self.res.get_etud_etat(etud.id)
        d["filigranne"] = sco_bulletins_pdf.get_filigranne_apc(
            etud_etat, self.prefs, etud.id, res=self.res
        )
        if etud_etat == scu.DEMISSION:
            d["demission"] = "(Démission)"
        elif etud_etat == DEF:
            d["demission"] = "(Défaillant)"
        else:
            d["demission"] = ""

        # --- Absences
        _, d["nbabsjust"], d["nbabs"] = self.res.formsemestre.get_abs_count(etud.id)

        # --- Decision Jury
        infos, _ = sco_bulletins.etud_descr_situation_semestre(
            etud.id,
            self.res.formsemestre,
            fmt="html",
            show_date_inscr=self.prefs["bul_show_date_inscr"],
            show_decisions=self.prefs["bul_show_decision"],
            show_uevalid=self.prefs["bul_show_uevalid"],
            show_mention=self.prefs["bul_show_mention"],
        )
        d.update(infos)
        # --- Rangs
        d["rang_nt"] = (
            f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
        )
        d["rang_txt"] = "Rang " + d["rang_nt"]

        d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))

        return d