from datetime import datetime

from flask import url_for
from flask_login import current_user
from flask_sqlalchemy.query import Query
from sqlalchemy import desc, literal, union, asc

from app import db, g
from app.auth.models import User
from app.models import Assiduite, Identite, Justificatif
from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif, to_bool
from app.tables import table_builder as tb
from app.scodoc.sco_cache import RequeteTableauAssiduiteCache
from app.scodoc.sco_permissions import Permission


class Pagination:
    """
     Pagination d'une collection de données

     On donne :
        - une collection de données (de préférence une liste / tuple)
        - le numéro de page à afficher
        - le nombre d'éléments par page

    On peut ensuite récupérer les éléments de la page courante avec la méthode `items()`

    Cette classe ne permet pas de changer de page.
    (Pour cela, il faut créer une nouvelle instance, avec la collection originelle et la nouvelle page)

    l'intéret est de ne pas garder en mémoire toute la collection, mais seulement la page courante

    """

    def __init__(self, collection: list, page: int = 1, per_page: int = -1):
        """
        __init__ Instancie un nouvel objet Pagination

        Args:
            collection (list): La collection à paginer. Il s'agit par exemple d'une requête
            page (int, optional): le numéro de la page à voir. Defaults to 1.
            per_page (int, optional): le nombre d'éléments par page. Defaults to -1. (-1 = pas de pagination/tout afficher)
        """
        # par défaut le total des pages est 1 (même si la collection est vide)
        self.total_pages = 1

        if per_page != -1:
            # on récupère le nombre de page complète et le reste
            # q => nombre de page
            # r => le nombre d'éléments restants (dernière page si != 0)
            q, r = len(collection) // per_page, len(collection) % per_page
            self.total_pages = q if r == 0 else q + 1  # q + 1 s'il reste des éléments

        # On s'assure que la page demandée est dans les limites
        current_page: int = min(self.total_pages, page if page > 0 else 1)

        # On récupère la collection de la page courante
        self.collection = (
            collection  # toute la collection si pas de pagination
            if per_page == -1
            else collection[
                per_page * (current_page - 1) : per_page * (current_page)
            ]  # sinon on récupère la page
        )

    def items(self) -> list:
        """
        items Renvoi la collection de la page courante

        Returns:
            list: la collection de la page courante
        """
        return self.collection


class ListeAssiJusti(tb.Table):
    """
    Table listant les Assiduites et Justificatifs d'une collection d'étudiants
    L'affichage par défaut se fait par ordre de date de fin décroissante.
    """

    NB_PAR_PAGE: int = 25
    MAX_PAR_PAGE: int = 1000

    def __init__(
        self,
        table_data: "AssiJustifData",
        filtre: "AssiFiltre" = None,
        options: "AssiDisplayOptions" = None,
        no_pagination: bool = False,
        titre: str = "",
        **kwargs,
    ) -> None:
        """
        __init__ Instancie un nouveau table de liste d'assiduités/justificaitifs

        Args:
            filtre (Filtre, optional): Filtrage des objets à afficher. Defaults to None.
            page (int, optional): numéro de page de la pagination. Defaults to 1.
        """
        self.table_data: "AssiJustifData" = table_data
        # Gestion du filtre, par défaut un filtre vide
        self.filtre = filtre if filtre is not None else AssiFiltre()

        # Gestion des options, par défaut un objet Options vide
        self.options = options if options is not None else AssiDisplayOptions()

        self.no_pagination: bool = no_pagination

        self.total_page: int = None

        # Accès aux détail des justificatifs ?
        self.can_view_justif_detail = current_user.has_permission(
            Permission.AbsJustifView
        )

        # les lignes du tableau
        self.rows: list["RowAssiJusti"] = []

        # Titre du tableau, utilisé pour le cache
        self.titre = titre

        # Instanciation de la classe parent
        super().__init__(
            row_class=RowAssiJusti,
            classes=["liste_assi", "gt_table", "gt_left"],
            **kwargs,
            with_foot_titles=False,
        )

        self.add_assiduites()

    def add_assiduites(self):
        "Ajoute le contenu de la table, avec assiduités et justificatif réunis"
        # Générer les query assiduités et justificatifs
        assiduites_query_etudiants: Query = None
        justificatifs_query_etudiants: Query = None

        # Récupération du filtrage des objets -> 0 : tout, 1 : Assi, 2: Justi
        type_obj = self.filtre.type_obj()

        cle_cache: str = ":".join(
            map(
                str,
                [
                    self.titre,
                    type_obj,
                    self.options.show_pres,
                    self.options.show_reta,
                    self.options.show_desc,
                    self.options.order[0],
                    self.options.order[1],
                ],
            )
        )
        r = RequeteTableauAssiduiteCache().get(cle_cache)

        if r is None:
            if type_obj in [0, 1]:
                assiduites_query_etudiants = self.table_data.assiduites_query

                # Non affichage des présences
                if (
                    not self.options.show_pres
                    and assiduites_query_etudiants is not None
                ):
                    assiduites_query_etudiants = assiduites_query_etudiants.filter(
                        Assiduite.etat != EtatAssiduite.PRESENT
                    )
                # Non affichage des retards
                if (
                    not self.options.show_reta
                    and assiduites_query_etudiants is not None
                ):
                    assiduites_query_etudiants = assiduites_query_etudiants.filter(
                        Assiduite.etat != EtatAssiduite.RETARD
                    )

            if type_obj in [0, 2]:
                justificatifs_query_etudiants = self.table_data.justificatifs_query

            # Combinaison des requêtes

            query_finale: Query = self.joindre(
                query_assiduite=assiduites_query_etudiants,
                query_justificatif=justificatifs_query_etudiants,
            )

            # Tri de la query si option
            if self.options.order is not None:
                order_sort: str = asc if self.options.order[1] else desc
                order_col: str = self.options.order[0]
                query_finale: Query = query_finale.order_by(order_sort(order_col))

            r = query_finale.all()
            RequeteTableauAssiduiteCache.set(cle_cache, r)

        # Paginer la requête pour ne pas envoyer trop d'informations au client
        pagination: Pagination = self.paginer(r, no_pagination=self.no_pagination)
        self.total_pages = pagination.total_pages
        # Générer les lignes de la page
        for ligne in pagination.items():
            row: RowAssiJusti = self.row_class(self, ligne._asdict())
            row.ajouter_colonnes()
            self.add_row(row)

    def paginer(self, collection: list, no_pagination: bool = False) -> Pagination:
        """
        Applique une pagination à une collection en fonction des paramètres de la classe.

        Cette méthode prend une collection et applique la pagination en utilisant les
        attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`.

        Args:
            collection (list): La collection à paginer. Il s'agit par exemple d'une requête qui a déjà
            été construite et qui est prête à être exécutée.

        Returns:
            Pagination: Un objet Pagination qui encapsule les résultats de la requête paginée.

        Note:
            Cette méthode ne modifie pas la collection originelle; elle renvoie plutôt un nouvel
            objet qui contient les résultats paginés.
        """
        return Pagination(
            collection,
            self.options.page,
            -1 if no_pagination else self.options.nb_ligne_page,
        )

    def joindre(self, query_assiduite: Query = None, query_justificatif: Query = None):
        """
        Combine les requêtes d'assiduités et de justificatifs en une seule requête.

        Cette fonction prend en entrée deux requêtes optionnelles, une pour les assiduités
        et une pour les justificatifs, et renvoie une requête combinée qui sélectionne
        un ensemble spécifique de colonnes pour chaque type d'objet.

        Les colonnes sélectionnées sont:
        - obj_id: l'identifiant de l'objet (assiduite_id pour les assiduités, justif_id pour les justificatifs)
        - etudid: l'identifiant de l'étudiant
        - entry_date: la date de saisie de l'objet
        - date_debut: la date de début de l'objet
        - date_fin: la date de fin de l'objet
        - etat: l'état de l'objet
        - type: le type de l'objet ("assiduite" pour les assiduités, "justificatif" pour les justificatifs)
        - est_just : si l'assiduité est justifié (booléen) None pour les justificatifs
        - user_id : l'identifiant de l'utilisateur qui a signalé l'assiduité ou le justificatif

        Args:
            query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy
                pour les assiduités.
                Si None (default), aucune assiduité ne sera incluse dans la requête combinée.

            query_justificatif (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy
                pour les justificatifs.
                Si None (default), aucun justificatif ne sera inclus dans la requête combinée.

        Returns:
            sqlalchemy.orm.Query: Une requête combinée qui peut être exécutée pour
            obtenir les résultats.

        Raises:
            ValueError: Si aucune requête n'est fournie (les deux paramètres sont None).
        """
        queries = []

        # Définir les colonnes pour la requête d'assiduité
        if query_assiduite:
            assiduites_entities: list = [
                Assiduite.assiduite_id.label("obj_id"),
                Assiduite.etudid.label("etudid"),
                Assiduite.entry_date.label("entry_date"),
                Assiduite.date_debut.label("date_debut"),
                Assiduite.date_fin.label("date_fin"),
                Assiduite.etat.label("etat"),
                literal("assiduite").label("type"),
                Assiduite.est_just.label("est_just"),
                Assiduite.user_id.label("user_id"),
            ]

            if self.options.show_desc:
                assiduites_entities.append(Assiduite.description.label("desc"))

            query_assiduite = query_assiduite.with_entities(*assiduites_entities)
            queries.append(query_assiduite)

        # Définir les colonnes pour la requête de justificatif
        if query_justificatif:
            justificatifs_entities: list = [
                Justificatif.justif_id.label("obj_id"),
                Justificatif.etudid.label("etudid"),
                Justificatif.entry_date.label("entry_date"),
                Justificatif.date_debut.label("date_debut"),
                Justificatif.date_fin.label("date_fin"),
                Justificatif.etat.label("etat"),
                literal("justificatif").label("type"),
                # On doit avoir les mêmes colonnes sur les deux requêtes,
                # donc on la met en nul car un justifcatif ne peut être justifié
                literal(None).label("est_just"),
                Justificatif.user_id.label("user_id"),
            ]

            if self.options.show_desc:
                justificatifs_entities.append(Justificatif.raison.label("desc"))

            query_justificatif = query_justificatif.with_entities(
                *justificatifs_entities
            )
            queries.append(query_justificatif)

        # S'assurer qu'au moins une requête est fournie
        if not queries:
            raise ValueError(
                "Au moins une query (assiduité ou justificatif) doit être fournie"
            )

        # Combiner les requêtes avec une union
        query_combinee = union(*queries).alias("combinee")

        query_combinee = db.session.query(query_combinee)

        return query_combinee


class RowAssiJusti(tb.Row):
    "Ligne de table pour une assiduité"

    def __init__(self, table: ListeAssiJusti, ligne: dict):
        self.ligne: dict = ligne
        self.etud: Identite = Identite.get_etud(ligne["etudid"])

        super().__init__(
            table=table,
            row_id=f'{ligne["etudid"]}_{ligne["type"]}_{ligne["obj_id"]}',
        )

    def ajouter_colonnes(self, lien_redirection: str = None):
        "Ajoute colonnes actions, étudiant, type, dates..."
        # Ajout colonne actions
        if self.table.options.show_actions:
            self._actions()

        # Ajout de l'étudiant
        self.table: ListeAssiJusti
        if self.table.options.show_etu:
            self._etud(lien_redirection)

        # Type d'objet
        self._type()

        # En excel, on export les "vraies dates".
        # En HTML, on écrit en français (on laisse les dates pour le tri)

        multi_days = self.ligne["date_debut"].date() != self.ligne["date_fin"].date()

        date_affichees: list[str] = [
            self.ligne["date_debut"].strftime("%d/%m/%y de %H:%M"),  # date début
            self.ligne["date_fin"].strftime("%d/%m/%y de %H:%M"),  # date fin
        ]

        if multi_days:
            date_affichees[0] = self.ligne["date_debut"].strftime("%d/%m/%y")
            date_affichees[1] = self.ligne["date_fin"].strftime("%d/%m/%y")

        self.add_cell(
            "date_debut",
            "Date de début",
            date_affichees[0],
            data={"order": self.ligne["date_debut"]},
            raw_content=self.ligne["date_debut"],
            column_classes={
                "date",
                "date-debut",
                "external-sort",
                "external-type:date_debut",
            },
        )
        # Date de fin
        self.add_cell(
            "date_fin",
            "Date de fin",
            date_affichees[1],
            raw_content=self.ligne["date_fin"],  # Pour excel
            data={"order": self.ligne["date_fin"]},
            column_classes={
                "date",
                "date-fin",
                "external-sort",
                "external-type:date_fin",
            },
        )

        # Ajout des colonnes optionnelles
        self._optionnelles()

        # Ajout de l'utilisateur ayant saisi l'objet
        self._utilisateur()

        # Date de saisie
        self.add_cell(
            "entry_date",
            "Saisie le",
            self.ligne["entry_date"].strftime("%d/%m/%y à %H:%M")
            if self.ligne["entry_date"]
            else "?",
            data={"order": self.ligne["entry_date"] or ""},
            raw_content=self.ligne["entry_date"],
            classes=["small-font"],
            column_classes={
                "entry_date",
                "external-sort",
                "external-type:entry_date",
            },
        )

    def _type(self) -> None:
        obj_type: str = ""
        is_assiduite: bool = self.ligne["type"] == "assiduite"
        if is_assiduite:
            justifiee: str = "Justifiée" if self.ligne["est_just"] else ""
            self.classes.append("row-assiduite")
            self.classes.append(EtatAssiduite(self.ligne["etat"]).name.lower())
            if self.ligne["est_just"]:
                self.classes.append("justifiee")
            etat: str = {
                EtatAssiduite.PRESENT: "Présence",
                EtatAssiduite.ABSENT: "Absence",
                EtatAssiduite.RETARD: "Retard",
            }.get(self.ligne["etat"])
            obj_type = f"{etat} {justifiee}"
        else:
            self.classes.append("row-justificatif")
            self.classes.append(EtatJustificatif(self.ligne["etat"]).name.lower())
            etat: str = {
                EtatJustificatif.VALIDE: "valide",
                EtatJustificatif.ATTENTE: "soumis",
                EtatJustificatif.MODIFIE: "modifié",
                EtatJustificatif.NON_VALIDE: "invalide",
            }.get(self.ligne["etat"])
            obj_type = f"Justificatif {etat}"

        self.add_cell("obj_type", "Type", obj_type, classes=["assi-type"])

    def _etud(self, lien_redirection) -> None:
        etud = self.etud
        self.table.group_titles.update(
            {
                "etud_codes": "Codes",
                "identite_detail": "",
                "identite_court": "",
            }
        )

        # Ajout des informations de l'étudiant

        self.add_cell(
            "nom_disp",
            "Nom",
            etud.nom_disp(),
            "etudinfo",
            attrs={"id": str(etud.id)},
            data={"order": etud.sort_key},
            target=lien_redirection,
            target_attrs={"class": "discretelink"},
        )
        self.add_cell(
            "prenom",
            "Prénom",
            etud.prenom_str,
            "etudinfo",
            attrs={"id": str(etud.id)},
            data={"order": etud.sort_key},
            target=lien_redirection,
            target_attrs={"class": "discretelink"},
        )

    def _optionnelles(self) -> None:
        if self.table.options.show_desc:
            if self.ligne.get("type") == "justificatif":
                # protection de la "raison"
                if (
                    self.ligne["user_id"] == current_user.id
                    or self.table.can_view_justif_detail
                ):
                    description = self.ligne["desc"] if self.ligne["desc"] else ""
                else:
                    description = "(cachée)"
            else:
                description = self.ligne["desc"] if self.ligne["desc"] else ""
            self.add_cell(
                "description",
                "Description",
                description,
            )
        if self.table.options.show_module:
            if self.ligne["type"] == "assiduite":
                assi: Assiduite = Assiduite.query.get(self.ligne["obj_id"])
                mod: str = assi.get_module(True)
                self.add_cell("module", "Module", mod, data={"order": mod})
            else:
                self.add_cell("module", "Module", "", data={"order": ""})

    def _utilisateur(self) -> None:
        utilisateur: User = (
            User.query.get(self.ligne["user_id"]) if self.ligne["user_id"] else None
        )

        self.add_cell(
            "user",
            "Saisie par",
            "Inconnu" if utilisateur is None else utilisateur.get_nomprenom(),
            classes=["small-font"],
        )

    def _actions(self) -> None:
        url: str
        html: list[str] = []

        # Détails
        url = url_for(
            "assiduites.tableau_assiduite_actions",
            type=self.ligne["type"],
            action="details",
            obj_id=self.ligne["obj_id"],
            scodoc_dept=g.scodoc_dept,
        )
        html.append(f'<a title="Détails" href="{url}">ℹ️</a>')

        # Modifier
        if self.ligne["type"] == "justificatif":
            url = url_for(
                "assiduites.edit_justificatif_etud",
                justif_id=self.ligne["obj_id"],
                scodoc_dept=g.scodoc_dept,
            )
        else:
            url = url_for(
                "assiduites.tableau_assiduite_actions",
                type=self.ligne["type"],
                action="modifier",
                obj_id=self.ligne["obj_id"],
                scodoc_dept=g.scodoc_dept,
            )
        html.append(f'<a title="Modifier" href="{url}">📝</a>')

        # Supprimer
        url = url_for(
            "assiduites.tableau_assiduite_actions",
            type=self.ligne["type"],
            action="supprimer",
            obj_id=self.ligne["obj_id"],
            scodoc_dept=g.scodoc_dept,
        )
        html.append(f'<a title="Supprimer" href="{url}">❌</a>')  # utiliser url_for

        # Justifier (si type Assiduité, etat != Présent et est_just faux)

        if (
            self.ligne["type"] == "assiduite"
            and self.ligne["etat"] != EtatAssiduite.PRESENT
            and not self.ligne["est_just"]
        ):
            url = url_for(
                "assiduites.tableau_assiduite_actions",
                type=self.ligne["type"],
                action="justifier",
                obj_id=self.ligne["obj_id"],
                scodoc_dept=g.scodoc_dept,
            )
            html.append(f'<a title="Justifier" href="{url}">🗄️</a>')

        self.add_cell(
            "actions",
            "",
            "&ensp;".join(html),
            no_excel=True,
            column_classes={"actions"},
        )


class AssiFiltre:
    """
    Classe représentant le filtrage qui sera appliqué aux objets
    du Tableau `ListeAssiJusti`
    """

    def __init__(
        self,
        type_obj: int = 0,
        entry_date: tuple[int, datetime] = None,
        date_debut: tuple[int, datetime] = None,
        date_fin: tuple[int, datetime] = None,
    ) -> None:
        """
        __init__ Instancie un nouvel objet filtre.

        Args:
            type_obj (int, optional): type d'objet (0:Tout, 1: Assi, 2:Justi). Defaults to 0.
            entry_date (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
            date_debut (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
            date_fin (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
            etats (list[int | EtatJustificatif | EtatAssiduite], optional): liste d'états valides (int | EtatJustificatif | EtatAssiduite). Defaults to None.
        """

        self.filtres = {"type_obj": type_obj}

        if entry_date is not None:
            self.filtres["entry_date"]: tuple[int, datetime] = entry_date

        if date_debut is not None:
            self.filtres["date_debut"]: tuple[int, datetime] = date_debut

        if date_fin is not None:
            self.filtres["date_fin"]: tuple[int, datetime] = date_fin

    def filtrage(self, query: Query, obj_class: db.Model) -> Query:
        """
        filtrage Filtre la query passée en paramètre et retourne l'objet filtré

        Args:
            query (Query): La query à filtrer

        Returns:
            Query: La query filtrée
        """

        query_filtree: Query = query

        cle_filtre: str
        for cle_filtre, val_filtre in self.filtres.items():
            if "date" in cle_filtre:
                type_filtrage: int
                date: datetime

                type_filtrage, date = val_filtre

                match (type_filtrage):
                    # On garde uniquement les dates supérieures au filtre
                    case 2:
                        query_filtree = query_filtree.filter(
                            getattr(obj_class, cle_filtre) > date
                        )
                    # On garde uniquement les dates inférieures au filtre
                    case 1:
                        query_filtree = query_filtree.filter(
                            getattr(obj_class, cle_filtre) < date
                        )
                    # Par défaut on garde uniquement les dates égales au filtre
                    case _:
                        query_filtree = query_filtree.filter(
                            getattr(obj_class, cle_filtre) == date
                        )

            if cle_filtre == "etats":
                etats: list[int | EtatJustificatif | EtatAssiduite] = val_filtre
                # On garde uniquement les objets ayant un état compris dans le filtre
                query_filtree = query_filtree.filter(obj_class.etat.in_(etats))

        return query_filtree

    def type_obj(self) -> int:
        """
        type_obj Renvoi le/les types d'objets à représenter

        (0:Tout, 1: Assi, 2:Justi)

        Returns:
            int: le/les types d'objets à afficher
        """
        return self.filtres.get("type_obj", 0)


class AssiDisplayOptions:
    "Options pour affichage tableau"

    def __init__(
        self,
        page: int = 1,
        nb_ligne_page: int = None,
        show_pres: str | bool = False,
        show_reta: str | bool = False,
        show_desc: str | bool = False,
        show_etu: str | bool = True,
        show_actions: str | bool = True,
        show_module: str | bool = False,
        order: tuple[str, str | bool] = None,
    ):
        self.page: int = page
        self.nb_ligne_page: int = nb_ligne_page
        if self.nb_ligne_page is not None:
            self.nb_ligne_page = min(nb_ligne_page, ListeAssiJusti.MAX_PAR_PAGE)

        self.show_pres = to_bool(show_pres)
        self.show_reta = to_bool(show_reta)
        self.show_desc = to_bool(show_desc)
        self.show_etu = to_bool(show_etu)
        self.show_actions = to_bool(show_actions)
        self.show_module = to_bool(show_module)

        self.order = (
            ("date_debut", False) if order is None else (order[0], to_bool(order[1]))
        )

    def remplacer(self, **kwargs):
        "Positionne options booléennes selon arguments"
        for k, v in kwargs.items():
            if k.startswith("show_"):
                setattr(self, k, to_bool(v))
            elif k in ["page", "nb_ligne_page"]:
                setattr(self, k, int(v))
                if k == "nb_ligne_page":
                    self.nb_ligne_page = min(
                        self.nb_ligne_page, ListeAssiJusti.MAX_PAR_PAGE
                    )
            elif k == "order":
                setattr(
                    self,
                    k,
                    ("date_debut", False) if v is None else (v[0], to_bool(v[1])),
                )


class AssiJustifData:
    "Les assiduités et justificatifs"

    def __init__(
        self, assiduites_query: Query = None, justificatifs_query: Query = None
    ):
        self.assiduites_query: Query = assiduites_query
        self.justificatifs_query: Query = justificatifs_query

    @staticmethod
    def from_etudiants(*etudiants: Identite) -> "AssiJustifData":
        data = AssiJustifData()
        data.assiduites_query = Assiduite.query.filter(
            Assiduite.etudid.in_([e.etudid for e in etudiants])
        )
        data.justificatifs_query = Justificatif.query.filter(
            Justificatif.etudid.in_([e.etudid for e in etudiants])
        )

        return data

    def get(self) -> tuple[Query, Query]:
        return self.assiduites_query, self.justificatifs_query
