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

"""Cursus en BUT

Classe raccordant avec ScoDoc 7:
 ScoDoc 7 utilisait sco_cursus_dut.SituationEtudCursus

 Ce module définit une classe SituationEtudCursusBUT
 avec la même interface.

"""
import collections
from operator import attrgetter

from flask import g, url_for

from app import db, log
from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat

from app.models.but_refcomp import (
    ApcAnneeParcours,
    ApcCompetence,
    ApcNiveau,
    ApcParcours,
    ApcParcoursNiveauCompetence,
    ApcReferentielCompetences,
)
from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
    ApcValidationAnnee,
    ApcValidationRCUE,
)
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD

from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError

from app.scodoc import sco_cursus_dut


class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
    """Pour compat ScoDoc 7: à revoir pour le BUT"""

    def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT):
        super().__init__(etud, formsemestre_id, res)
        # Ajustements pour le BUT
        self.can_compensate_with_prev = False  # jamais de compensation à la mode DUT

    def check_compensation_dut(self, semc: dict, ntc: NotesTableCompat):
        "Jamais de compensation façon DUT"
        return False

    def parcours_validated(self):
        "True si le parcours est validé"
        return False  # XXX TODO


class EtudCursusBUT:
    """L'état de l'étudiant dans son cursus BUT
    Liste des niveaux validés/à valider
    (utilisé pour le résumé sur la fiche étudiant)
    """

    def __init__(self, etud: Identite, formation: Formation):
        """formation indique la spécialité préparée"""
        # Vérifie que l'étudiant est bien inscrit à un sem. de cette formation
        if formation.id not in (
            ins.formsemestre.formation.id for ins in etud.formsemestre_inscriptions
        ):
            raise ScoValueError(
                f"{etud.nomprenom} non inscrit dans {formation.titre} v{formation.version}"
            )
        if not formation.referentiel_competence:
            raise ScoNoReferentielCompetences(formation=formation)
        #
        self.etud = etud
        self.formation = formation
        self.inscriptions = sorted(
            [
                ins
                for ins in etud.formsemestre_inscriptions
                if ins.formsemestre.formation.referentiel_competence
                and (
                    ins.formsemestre.formation.referentiel_competence.id
                    == formation.referentiel_competence.id
                )
            ],
            key=lambda s: (s.formsemestre.semestre_id, s.formsemestre.date_debut),
        )
        "Liste des inscriptions aux sem. de la formation, triées par indice et chronologie"
        self.parcour: ApcParcours = self.inscriptions[-1].parcour
        "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
        self.niveaux_by_annee: dict[int, list[ApcNiveau]] = {}
        "{ annee:int : liste des niveaux à valider }"
        self.niveaux: dict[int, ApcNiveau] = {}
        "cache les niveaux"
        for annee in (1, 2, 3):
            niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
                annee, [self.parcour] if self.parcour else None
            )[1]
            # groupe les niveaux de tronc commun et ceux spécifiques au parcour
            self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
                niveaux_d[self.parcour.id] if self.parcour else []
            )
            self.niveaux.update(
                {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
            )

        self.validation_par_competence_et_annee = {}
        """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
        for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
            niveau = validation_rcue.niveau()
            if not niveau.competence.id in self.validation_par_competence_et_annee:
                self.validation_par_competence_et_annee[niveau.competence.id] = {}
            previous_validation = self.validation_par_competence_et_annee.get(
                niveau.competence.id
            ).get(validation_rcue.annee())
            # prend la "meilleure" validation
            if (not previous_validation) or (
                sco_codes.BUT_CODES_ORDER[validation_rcue.code]
                > sco_codes.BUT_CODES_ORDER[previous_validation.code]
            ):
                self.validation_par_competence_et_annee[niveau.competence.id][
                    niveau.annee
                ] = validation_rcue

        self.competences = {
            competence.id: competence
            for competence in (
                self.parcour.query_competences()
                if self.parcour
                else self.formation.referentiel_competence.get_competences_tronc_commun()
            )
        }
        "cache { competence_id : competence }"

    def to_dict(self):
        """
        {
            competence_id : {
                annee : meilleure_validation
            }
        }
        """
        # XXX lent, provisoirement utilisé par TableJury.add_but_competences()
        return {
            competence.id: {
                annee: self.validation_par_competence_et_annee.get(
                    competence.id, {}
                ).get(annee)
                for annee in ("BUT1", "BUT2", "BUT3")
            }
            for competence in self.competences.values()
        }

    # XXX TODO OPTIMISATION ACCESS TABLE JURY
    def to_dict_codes(self) -> dict[int, dict[str, int]]:
        """
        {
            competence_id : {
                annee : { validation }
            }
        }
        où validation est un petit dict avec niveau_id, etc.
        """
        d = {}
        for competence in self.competences.values():
            d[competence.id] = {}
            for annee in ("BUT1", "BUT2", "BUT3"):
                validation_rcue: ApcValidationRCUE = (
                    self.validation_par_competence_et_annee.get(competence.id, {}).get(
                        annee
                    )
                )

                d[competence.id][annee] = (
                    validation_rcue.to_dict_codes() if validation_rcue else None
                )
        return d

    def competence_annee_has_niveau(self, competence_id: int, annee: str) -> bool:
        "vrai si la compétence à un niveau dans cette annee ('BUT1') pour le parcour de cet etud"
        # slow, utile pour affichage fiche
        return annee in [n.annee for n in self.competences[competence_id].niveaux]

    def load_validation_by_niveau(self) -> dict[int, list[ApcValidationRCUE]]:
        """Cherche les validations de jury enregistrées pour chaque niveau
        Résultat: { niveau_id : [ ApcValidationRCUE ] }
        meilleure validation pour ce niveau
        """
        validations_by_niveau = collections.defaultdict(lambda: [])
        for validation_rcue in ApcValidationRCUE.query.filter_by(etud=self.etud):
            validations_by_niveau[validation_rcue.niveau().id].append(validation_rcue)
        validation_by_niveau = {
            niveau_id: sorted(
                validations, key=lambda v: sco_codes.BUT_CODES_ORDER[v.code]
            )[0]
            for niveau_id, validations in validations_by_niveau.items()
            if validations
        }
        return validation_by_niveau


class FormSemestreCursusBUT:
    """L'état des étudiants d'un formsemestre dans leur cursus BUT
    Permet d'obtenir pour chacun liste des niveaux validés/à valider
    """

    def __init__(self, res: ResultatsSemestreBUT):
        """res indique le formsemestre de référence,
        qui donne la liste des étudiants et le référentiel de compétence.
        """
        self.res = res
        self.formsemestre = res.formsemestre
        if not res.formsemestre.formation.referentiel_competence:
            raise ScoNoReferentielCompetences(formation=res.formsemestre.formation)
        # Données cachées pour accélerer les accès:
        self.referentiel_competences_id: int = (
            self.res.formsemestre.formation.referentiel_competence_id
        )
        self.ue_ids: set[int] = set()
        "set of ue_ids known to belong to our cursus"
        self.parcours_by_id: dict[int, ApcParcours] = {}
        "cache des parcours"
        self.niveaux_by_parcour_by_annee: dict[int, dict[int, list[ApcNiveau]]] = {}
        "cache { parcour_id : { annee : [ parcour] } }"
        self.niveaux_by_id: dict[int, ApcNiveau] = {}
        "cache niveaux"

    def get_niveaux_parcours_etud(self, etud: Identite) -> dict[int, list[ApcNiveau]]:
        """Les niveaux compétences que doit valider cet étudiant.
        Le parcour considéré est celui de l'inscription dans le semestre courant.
        Si on est en début de cursus, on peut être en tronc commun sans avoir choisi
        de parcours. Dans ce cas, on n'aura que les compétences de tronc commun.
        Il faudra donc, avant de diplômer, s'assurer que les compétences du parcours
        du dernier semestre (S6) sont validées (avec parcour non NULL).
        """
        parcour_id = self.res.etuds_parcour_id.get(etud.id)
        if parcour_id is None:
            parcour = None
        else:
            if parcour_id not in self.parcours_by_id:
                self.parcours_by_id[parcour_id] = db.session.get(
                    ApcParcours, parcour_id
                )
            parcour = self.parcours_by_id[parcour_id]

        return self.get_niveaux_parcours_by_annee(parcour)

    def get_niveaux_parcours_by_annee(
        self, parcour: ApcParcours
    ) -> dict[int, list[ApcNiveau]]:
        """La liste des niveaux de compétences du parcours, par année BUT.
        { 1 : [ niveau, ... ] }
        Si parcour est None, donne uniquement les niveaux tronc commun
            (cas utile par exemple en 1ere année, mais surtout pas pour donner un diplôme!)
        """
        parcour_id = None if parcour is None else parcour.id
        if parcour_id in self.niveaux_by_parcour_by_annee:
            return self.niveaux_by_parcour_by_annee[parcour_id]

        ref_comp: ApcReferentielCompetences = (
            self.res.formsemestre.formation.referentiel_competence
        )
        niveaux_by_annee = {}
        for annee in (1, 2, 3):
            niveaux_d = ref_comp.get_niveaux_by_parcours(
                annee, [parcour] if parcour else None
            )[1]
            # groupe les niveaux de tronc commun et ceux spécifiques au parcour
            niveaux_by_annee[annee] = niveaux_d["TC"] + (
                niveaux_d[parcour.id] if parcour else []
            )
            self.niveaux_by_parcour_by_annee[parcour_id] = niveaux_by_annee
            self.niveaux_by_id.update(
                {niveau.id: niveau for niveau in niveaux_by_annee[annee]}
            )
        return niveaux_by_annee

    def get_etud_validation_par_competence_et_annee(self, etud: Identite):
        """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
        validation_par_competence_et_annee = {}
        for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
            # On s'assurer qu'elle concerne notre cursus !
            ue = validation_rcue.ue2
            if ue.id not in self.ue_ids:
                if (
                    ue.formation.referentiel_competences_id
                    == self.referentiel_competences_id
                ):
                    self.ue_ids = ue.id
                else:
                    continue  # skip this validation
            niveau = validation_rcue.niveau()
            if not niveau.competence.id in validation_par_competence_et_annee:
                validation_par_competence_et_annee[niveau.competence.id] = {}
            previous_validation = validation_par_competence_et_annee.get(
                niveau.competence.id
            ).get(validation_rcue.annee())
            # prend la "meilleure" validation
            if (not previous_validation) or (
                sco_codes.BUT_CODES_ORDER[validation_rcue.code]
                > sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
            ):
                self.validation_par_competence_et_annee[niveau.competence.id][
                    niveau.annee
                ] = validation_rcue
        return validation_par_competence_et_annee

    def list_etud_inscriptions(self, etud: Identite):
        "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
        self.niveaux_by_annee = {}
        "{ annee : liste des niveaux à valider }"
        self.niveaux: dict[int, ApcNiveau] = {}
        "cache les niveaux"
        for annee in (1, 2, 3):
            niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
                annee, [self.parcour] if self.parcour else None  # XXX WIP
            )[1]
            # groupe les niveaux de tronc commun et ceux spécifiques au parcour
            self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
                niveaux_d[self.parcour.id] if self.parcour else []
            )
            self.niveaux.update(
                {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
            )

        self.validation_par_competence_et_annee = {}
        """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
        for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
            niveau = validation_rcue.niveau()
            if not niveau.competence.id in self.validation_par_competence_et_annee:
                self.validation_par_competence_et_annee[niveau.competence.id] = {}
            previous_validation = self.validation_par_competence_et_annee.get(
                niveau.competence.id
            ).get(validation_rcue.annee())
            # prend la "meilleure" validation
            if (not previous_validation) or (
                sco_codes.BUT_CODES_ORDER[validation_rcue.code]
                > sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
            ):
                self.validation_par_competence_et_annee[niveau.competence.id][
                    niveau.annee
                ] = validation_rcue

        self.competences = {
            competence.id: competence
            for competence in (
                self.parcour.query_competences()
                if self.parcour
                else self.formation.referentiel_competence.get_competences_tronc_commun()
            )
        }
        "cache { competence_id : competence }"


def but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:
    """Nombre d'ECTS validés par etud dans le BUT de référentiel indiqué.
    Ne prend que les UE associées à des niveaux de compétences,
    et ne les compte qu'une fois même en cas de redoublement avec re-validation.
    """
    validations = (
        ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
        .filter(ScolarFormSemestreValidation.ue_id != None)
        .join(UniteEns)
        .join(ApcNiveau)
        .join(ApcCompetence)
        .filter_by(referentiel_id=referentiel_competence_id)
    )

    ects_dict = {}
    for v in validations:
        key = (v.ue.semestre_idx, v.ue.niveau_competence.id)
        if v.code in CODES_UE_VALIDES:
            ects_dict[key] = v.ue.ects

    return sum(ects_dict.values()) if ects_dict else 0.0


def etud_ues_de_but1_non_validees(
    etud: Identite, formation: Formation, parcour: ApcParcours
) -> list[UniteEns]:
    """Liste des UEs de S1 et S2 non validées, dans son parcours"""
    # Les UEs avec décisions, dans les S1 ou S2 d'une formation de même code:
    validations = (
        ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
        .filter(ScolarFormSemestreValidation.ue_id != None)
        .join(UniteEns)
        .filter(db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2))
        .join(Formation)
        .filter_by(formation_code=formation.formation_code)
    )
    codes_validations_by_ue_code = collections.defaultdict(list)
    for v in validations:
        codes_validations_by_ue_code[v.ue.ue_code].append(v.code)

    # Les UEs du parcours en S1 et S2:
    ues = formation.query_ues_parcour(parcour).filter(
        db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2)
    )
    # Liste triée des ues non validées
    return sorted(
        [
            ue
            for ue in ues
            if not any(
                (
                    code_ue_validant(code)
                    for code in codes_validations_by_ue_code[ue.ue_code]
                )
            )
        ],
        key=attrgetter("numero", "acronyme"),
    )


def formsemestre_warning_apc_setup(
    formsemestre: FormSemestre, res: ResultatsSemestreBUT
) -> str:
    """Vérifie que la formation est OK pour un BUT:
    - ref. compétence associé
    - tous les niveaux des parcours du semestre associés à des UEs du formsemestre
    - pas d'UE non associée à un niveau
    Renvoie fragment de HTML.
    """
    if not formsemestre.formation.is_apc():
        return ""
    if formsemestre.formation.referentiel_competence is None:
        return f"""<div class="formsemestre_status_warning">
        La <a class="stdlink" href="{
            url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
        }">formation n'est pas associée à un référentiel de compétence.</a>
        </div>
        """
    # Vérifie les niveaux de chaque parcours
    H = []
    for parcour in formsemestre.parcours or [None]:
        annee = (formsemestre.semestre_id + 1) // 2
        niveaux_ids = {
            niveau.id
            for niveau in ApcNiveau.niveaux_annee_de_parcours(
                parcour, annee, formsemestre.formation.referentiel_competence
            )
        }
        ues_parcour = formsemestre.formation.query_ues_parcour(parcour).filter(
            UniteEns.semestre_idx == formsemestre.semestre_id
        )
        ues_niveaux_ids = {
            ue.niveau_competence.id for ue in ues_parcour if ue.niveau_competence
        }
        if niveaux_ids != ues_niveaux_ids:
            H.append(
                f"""Parcours {parcour.code if parcour else "Tronc commun"} :
                {len(ues_niveaux_ids)} UE avec niveaux
                mais {len(niveaux_ids)} niveaux à valider !
            """
            )
    if not H:
        return ""
    return f"""<div class="formsemestre_status_warning">
    Problème dans la configuration de la formation:
    <ul>
        <li>{ '</li><li>'.join(H) }</li>
    </ul>
    <p class="help">Vérifiez les parcours cochés pour ce semestre,
    et les associations entre UE et niveaux <a class="stdlink" href="{
            url_for("notes.parcour_formation", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
        }">dans la formation.</a>
    </p>
    </div>
    """


def ue_associee_au_niveau_du_parcours(
    ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
) -> UniteEns:
    "L'UE associée à ce niveau, ou None"
    ues = [ue for ue in ues_possibles if ue.niveau_competence_id == niveau.id]
    if len(ues) > 1:
        # plusieurs UEs associées à ce niveau: élimine celles sans parcours
        ues_pair_avec_parcours = [ue for ue in ues if ue.parcours]
        if ues_pair_avec_parcours:
            ues = ues_pair_avec_parcours
    if len(ues) > 1:
        log(f"_niveau_ues: {len(ues)} associées au niveau {niveau} / {sem_name}")
    return ues[0] if ues else None


def parcour_formation_competences(
    parcour: ApcParcours, formation: Formation
) -> tuple[list[dict], float]:
    """
    [
        {
            'competence' : ApcCompetence,
            'niveaux' : {
                1 : { ... },
                2 : { ... },
                3 : {
                    'niveau' : ApcNiveau,
                    'ue_impair' : UniteEns, # actuellement associée
                    'ues_impair' : list[UniteEns], # choix possibles
                    'ue_pair' : UniteEns,
                    'ues_pair' : list[UniteEns],
                }
            }
        }
    ],
    ects_parcours (somme des ects des UEs associées)
    """
    refcomp: ApcReferentielCompetences = formation.referentiel_competence

    def _niveau_ues(competence: ApcCompetence, annee: int) -> dict:
        """niveau et ues pour cette compétence de cette année du parcours.
        Si parcour est None, les niveaux du tronc commun
        """
        if parcour is not None:
            # L'étudiant est inscrit à un parcours: cherche les niveaux
            niveaux = ApcNiveau.niveaux_annee_de_parcours(
                parcour, annee, competence=competence
            )
        else:
            # sans parcours, on cherche les niveaux du Tronc Commun de cette année
            niveaux = [
                niveau
                for niveau in refcomp.get_niveaux_by_parcours(annee)[1]["TC"]
                if niveau.competence_id == competence.id
            ]

        if len(niveaux) > 0:
            if len(niveaux) > 1:
                log(
                    f"""_niveau_ues: plus d'un niveau pour {competence}
                    annee {annee} {("parcours " + parcour.code) if parcour else ""}"""
                )
            niveau = niveaux[0]
        elif len(niveaux) == 0:
            return {
                "niveau": None,
                "ue_pair": None,
                "ue_impair": None,
                "ues_pair": [],
                "ues_impair": [],
            }
        # Toutes les UEs de la formation dans ce parcours ou tronc commun
        ues = [
            ue
            for ue in formation.ues
            if (
                (not ue.parcours)
                or (parcour is not None and (parcour.id in (p.id for p in ue.parcours)))
            )
            and ue.type == UE_STANDARD
        ]
        ues_pair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee)]
        ues_impair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee - 1)]

        # UE associée au niveau dans ce parcours
        ue_pair = ue_associee_au_niveau_du_parcours(
            ues_pair_possibles, niveau, f"S{2*annee}"
        )
        ue_impair = ue_associee_au_niveau_du_parcours(
            ues_impair_possibles, niveau, f"S{2*annee-1}"
        )

        return {
            "niveau": niveau,
            "ue_pair": ue_pair,
            "ues_pair": [
                ue
                for ue in ues_pair_possibles
                if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
            ],
            "ue_impair": ue_impair,
            "ues_impair": [
                ue
                for ue in ues_impair_possibles
                if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
            ],
        }

    competences = [
        {
            "competence": competence,
            "niveaux": {annee: _niveau_ues(competence, annee) for annee in (1, 2, 3)},
        }
        for competence in (
            parcour.query_competences()
            if parcour
            else refcomp.competences.order_by(ApcCompetence.numero)
        )
    ]
    ects_parcours = sum(
        sum(
            (ni["ue_impair"].ects or 0) if ni["ue_impair"] else 0
            for ni in cp["niveaux"].values()
        )
        for cp in competences
    ) + sum(
        sum(
            (ni["ue_pair"].ects or 0) if ni["ue_pair"] else 0
            for ni in cp["niveaux"].values()
        )
        for cp in competences
    )
    return competences, ects_parcours