# -*- mode: python -*-
# -*- coding: utf-8 -*-

##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
#   Emmanuel Viennet      emmanuel.viennet@viennet.net
#
##############################################################################

"""Accès aux emplois du temps

Lecture et conversion des ics.

"""
from datetime import timezone
import glob
import os
import re
import time
import icalendar

from flask import g, url_for
from app import log
from app.auth.models import User
from app.models import FormSemestre, GroupDescr, ModuleImpl, ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.sco_utils as scu


def get_ics_filename(edt_id: str) -> str | None:
    "Le chemin vers l'ics de cet edt_id"
    edt_ics_path = ScoDocSiteConfig.get("edt_ics_path")
    if not edt_ics_path.strip():
        return None
    return edt_ics_path.format(edt_id=edt_id)


def get_ics_directory() -> str | None:
    "Le répertoire contenant les ics: prend le parent de edt_ics_path"
    edt_ics_path = ScoDocSiteConfig.get("edt_ics_path")
    if not edt_ics_path.strip():
        return None
    return os.path.split(edt_ics_path)[0]


def get_ics_user_edt_filename(edt_id) -> str | None:
    """Le chemin vers le fichier ics de l'emploi du temps de l'utilisateur,
    ou None.
    edt_id est l'edt_id de l'utilisateur
    """
    if not edt_id:
        return None
    edt_ics_user_path = ScoDocSiteConfig.get("edt_ics_user_path")
    if not edt_ics_user_path.strip():
        return None
    return edt_ics_user_path.format(edt_id=edt_id)


def list_edt_calendars() -> list[str]:
    """Liste des chemins complets vers tous les ics des calendriers de semestres"""
    path = get_ics_directory()
    return glob.glob(path + "/*.ics") if path else []


def is_edt_configured() -> bool:
    "True si accès EDT configuré"
    return bool(ScoDocSiteConfig.get("edt_ics_path"))


def formsemestre_load_calendar(
    formsemestre: FormSemestre = None, edt_id: str = None
) -> tuple[bytes, icalendar.cal.Calendar]:
    """Load ics data, return raw ics and decoded calendar.
    Raises ScoValueError if not configured or not available or invalid format.
    """
    edt_ids = []
    if edt_id is None:
        if formsemestre:
            edt_ids = formsemestre.get_edt_ids()
    else:
        edt_ids = [edt_id]
    if not edt_ids:
        raise ScoValueError(
            "accès aux emplois du temps non configuré pour ce semestre (pas d'edt_id)"
        )
    # Ne charge qu'un seul ics pour le semestre, prend uniquement
    # le premier edt_id
    ics_filename = get_ics_filename(edt_ids[0])
    return load_calendar(ics_filename, formsemestre=formsemestre, edt_id=edt_id)


def load_calendar(
    ics_filename: str, formsemestre: FormSemestre | None = None, edt_id: str = None
) -> tuple[bytes, icalendar.cal.Calendar]:
    """Load ics from given (full) path. Return raw ics and decoded calendar.
    formsemestre and edt_id are optionaly used for error messages.
    May raise ScoValueError.
    """
    if ics_filename is None:
        raise ScoValueError("accès aux emplois du temps non configuré (pas de chemin)")
    try:
        with open(ics_filename, "rb") as file:
            log(f"Loading edt from {ics_filename}")
            data = file.read()
            try:
                calendar = icalendar.Calendar.from_ical(data)
            except ValueError as exc:
                log(
                    f"""formsemestre_load_calendar: error importing ics for {
                        formsemestre or ''}\npath='{ics_filename}'"""
                )
                raise ScoValueError(
                    f"calendrier ics illisible (edt_id={edt_id})"
                ) from exc
    except FileNotFoundError as exc:
        log(
            f"""formsemestre_load_calendar: ics not found for {
                formsemestre or ''}\npath='{ics_filename}'"""
        )
        raise ScoValueError(
            f"Fichier ics introuvable (filename={ics_filename})"
        ) from exc
    except PermissionError as exc:
        log(
            f"""formsemestre_load_calendar: permission denied for {formsemestre or ''
                                                                 }\npath='{ics_filename}'"""
        )
        raise ScoValueError(
            f"Fichier ics inaccessible: vérifier permissions (filename={ics_filename})"
        ) from exc

    return data, calendar


# --- Couleurs des évènements emploi du temps
_COLOR_PALETTE = [
    "#ff6961",
    "#ffb480",
    "#f8f38d",
    "#42d6a4",
    "#08cad1",
    "#59adf6",
    "#9d94ff",
    "#c780e8",
]
_EVENT_DEFAULT_COLOR = "rgb(214, 233, 248)"


def formsemestre_edt_dict(
    formsemestre: FormSemestre,
    group_ids: list[int] = None,
    show_modules_titles=True,
) -> list[dict]:
    """EDT complet du semestre, comme une liste de dict serialisable en json.
    Fonction appelée par l'API /formsemestre/<int:formsemestre_id>/edt
    group_ids indiquer les groupes ScoDoc à afficher (les autres sont filtrés).
    Les évènements pour lesquels le groupe ScoDoc n'est pas reconnu sont
    toujours présents.
    TODO: spécifier intervalle de dates start et end
    """
    t0 = time.time()
    try:
        events_scodoc, _ = load_and_convert_ics(formsemestre)
    except ScoValueError as exc:
        return exc.args[0]
    edt_dict = translate_calendar(
        events_scodoc, group_ids, show_modules_titles=show_modules_titles
    )
    log(
        f"formsemestre_edt_dict: loaded edt for {formsemestre} in {(time.time()-t0):g}s"
    )
    return edt_dict


def translate_calendar(
    events_scodoc: list[dict],
    group_ids: list[int] = None,
    show_modules_titles=True,
) -> list[dict]:
    """Génération des événements pour le calendrier html"""
    group_ids_set = set(group_ids) if group_ids else set()
    promo_icon = f"""<img height="18px" src="{scu.STATIC_DIR}/icons/promo.svg"
        title="promotion complète" alt="promotion"/>"""
    abs_icon = f"""<img height="28px" src="{scu.STATIC_DIR}/icons/absences.svg"
        title="saisir absences" alt="saisir absences"/>"""
    events_cal = []
    for event in events_scodoc:
        group: GroupDescr | bool = event["group"]
        if group is False:
            group_disp = f"""<div class="group-edt">
                <span title="extraction emploi du temps non configurée">
                {scu.EMO_WARNING} non configuré</span>
                </div>"""
        else:
            group_disp = (
                f"""<div class="group-name">{group.get_nom_with_part(default=promo_icon)}</div>"""
                if group
                else f"""<div class="group-edt">{event['edt_group']}
                    <span title="vérifier noms de groupe ou configuration extraction edt">
                    {scu.EMO_WARNING} non reconnu</span>
                    </div>"""
            )
        if group and group_ids_set and group.id not in group_ids_set:
            continue  # ignore cet évènement
        modimpl: ModuleImpl | bool = event["modimpl"]
        url_abs = (
            url_for(
                "assiduites.signal_assiduites_group",
                scodoc_dept=g.scodoc_dept,
                group_ids=group.id,
                heure_deb=event["heure_deb"],
                heure_fin=event["heure_fin"],
                moduleimpl_id=modimpl.id,
                jour=event["jour"],
            )
            if modimpl and group
            else None
        )
        match modimpl:
            case False:  # EDT non configuré
                mod_disp = f"""<span>{scu.EMO_WARNING} non configuré</span>"""
                bubble = "extraction emploi du temps non configurée"
            case None:  # Module edt non trouvé dans ScoDoc
                mod_disp = f"""<span class="mod-etd">{
                    scu.EMO_WARNING} {event['edt_module']}</span>"""
                bubble = "code module non trouvé dans ce semestre ScoDoc. Vérifier configuration."
            case _:  # module EDT bien retrouvé dans ScoDoc
                bubble = f"""{modimpl.module.abbrev or modimpl.module.titre or ''
                    } ({event['edt_module']})"""
                mod_disp = (
                    f"""<span class="mod-name mod-code">{modimpl.module.code}</span>"""
                )
        span_title = f" <span>{event['title']}</span>" if show_modules_titles else ""
        title = f"""<div class = "module-edt" title="{bubble}">
            <a class="discretelink" href="{url_abs or ''}">{mod_disp}{span_title}</a>
        </div>
        """

        # --- Lien saisie abs
        link_abs = (
            f"""<div class="module-edt link-abs"><a class="stdlink" href="{
                        url_abs}">{abs_icon}</a>
            </div>"""
            if url_abs
            else ""
        )

        if event["users"]:
            # enseignants reconnus dans l'evènement EDT
            ens_nomprenoms = f"""<span class="edt-ens">({
                ", ".join([u.get_nomprenom() for u in event["users"]])
            })</span>"""
        else:
            ens_nomprenoms = f"""<span class="ens-non-reconnu"
                title="enseignants edt: {', '.join(event['edt_ens_ids'])
                    if event["edt_ens_ids"] else '?'
                }">(ens. ?)</span>"""

        ens_user_names = (
            ",".join([u.user_name for u in event["users"]]) if event["users"] else ""
        )
        d = {
            # Champs utilisés par tui.calendar
            "calendarId": "cal1",
            "title": f"""{title} {group_disp} {ens_nomprenoms} {link_abs}""",
            "start": event["start"],
            "end": event["end"],
            "backgroundColor": event["group_bg_color"],
            "raw": event["raw"],
            # Infos brutes pour usage API éventuel
            "edt_ens_ids": event["edt_ens_ids"],
            "ens_user_names": ens_user_names,
            "group_id": group.id if group else None,
            "group_edt_id": event["edt_group"],
            "moduleimpl_id": modimpl.id if modimpl else None,
            "UID": event["UID"],  # icalendar event UID
        }
        events_cal.append(d)

    return events_cal


def get_ics_uid_pattern() -> re.Pattern:
    """L'expression régulière compilée pour extraire l'identifiant de l'enseignant.
    May raise ScoValueError.
    """
    edt_ics_uid_regexp = ScoDocSiteConfig.get("edt_ics_uid_regexp")
    try:
        edt_ics_uid_pattern = (
            re.compile(edt_ics_uid_regexp) if edt_ics_uid_regexp else None
        )
    except re.error as exc:
        raise ScoValueError(
            "expression d'extraction de l'enseignant depuis l'emploi du temps invalide"
        ) from exc
    return edt_ics_uid_pattern


def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[str]]:
    """Chargement fichier ics.
    Renvoie une liste d'évènements, et la liste des identifiants de groupes
    trouvés (utilisée pour l'aide).
    """
    # Chargement du calendier ics
    _, calendar = formsemestre_load_calendar(formsemestre)
    if not calendar:
        return [], []
    # --- Correspondances id edt -> id scodoc pour groupes
    edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre)
    default_group = formsemestre.get_default_group()
    # --- Correspondances id edt -> id scodoc pour modimpls
    edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre)
    return convert_ics(
        calendar,
        edt2group=edt2group,
        default_group=default_group,
        edt2modimpl=edt2modimpl,
    )


def convert_ics(
    calendar: icalendar.cal.Calendar,
    edt2group: dict[str, GroupDescr] = None,
    default_group: GroupDescr = None,
    edt2modimpl: dict[str, ModuleImpl] = None,
) -> tuple[list[dict], list[str]]:
    """Filtrage et extraction des identifiants des évènements calendrier.

    Renvoie une liste d'évènements, et la liste des identifiants de groupes
    trouvés (utilisée pour l'aide).

    Groupes:
        - False si extraction regexp non configuré
        - "tous" (promo) si pas de correspondance trouvée.
    """
    # --- Paramètres d'extraction
    edt_ics_title_field = ScoDocSiteConfig.get("edt_ics_title_field")
    edt_ics_title_regexp = ScoDocSiteConfig.get("edt_ics_title_regexp")
    try:
        edt_ics_title_pattern = (
            re.compile(edt_ics_title_regexp) if edt_ics_title_regexp else None
        )
    except re.error as exc:
        raise ScoValueError(
            "expression d'extraction du titre depuis l'emploi du temps invalide"
        ) from exc
    edt_ics_group_field = ScoDocSiteConfig.get("edt_ics_group_field")
    edt_ics_group_regexp = ScoDocSiteConfig.get("edt_ics_group_regexp")
    try:
        edt_ics_group_pattern = (
            re.compile(edt_ics_group_regexp) if edt_ics_group_regexp else None
        )
    except re.error as exc:
        raise ScoValueError(
            "expression d'extraction du groupe depuis l'emploi du temps invalide"
        ) from exc
    edt_ics_mod_field = ScoDocSiteConfig.get("edt_ics_mod_field")
    edt_ics_mod_regexp = ScoDocSiteConfig.get("edt_ics_mod_regexp")
    try:
        edt_ics_mod_pattern = (
            re.compile(edt_ics_mod_regexp) if edt_ics_mod_regexp else None
        )
    except re.error as exc:
        raise ScoValueError(
            "expression d'extraction du module depuis l'emploi du temps invalide"
        ) from exc
    edt_ics_uid_field = ScoDocSiteConfig.get("edt_ics_uid_field")
    edt_ics_uid_pattern = get_ics_uid_pattern()

    # --- Groupes
    group_colors = {
        group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1]
        for i, group_name in enumerate(edt2group)
    }
    edt_groups_ids = set()  # les ids de groupes normalisés tels que dans l'ics

    edt2user: dict[str, User | None] = {}  # construit au fur et à mesure (cache)
    # ---
    events = [e for e in calendar.walk() if e.name == "VEVENT"]
    events_sco = []
    for event in events:
        if "DESCRIPTION" in event:
            # --- Titre de l'évènement
            title_edt = (
                extract_event_edt_id(event, edt_ics_title_field, edt_ics_title_pattern)
                if edt_ics_title_pattern
                else "non configuré"
            )
            # title remplacé par le nom du module scodoc quand il est trouvé
            title = title_edt
            # --- ModuleImpl
            if edt_ics_mod_pattern:
                edt_module = extract_event_edt_id(
                    event, edt_ics_mod_field, edt_ics_mod_pattern
                )
                modimpl: ModuleImpl = edt2modimpl.get(edt_module, None)
                if modimpl:
                    title = modimpl.module.titre_str()
            else:
                modimpl = False
                edt_module = ""
            # --- Group
            if edt_ics_group_pattern:
                edt_group = extract_event_edt_id(
                    event, edt_ics_group_field, edt_ics_group_pattern
                )
                edt_groups_ids.add(edt_group)
                # si pas de groupe dans l'event, ou si groupe non reconnu,
                # prend toute la promo ("tous")
                event_default_group = (
                    default_group
                    if default_group
                    else (modimpl.formsemestre.get_default_group() if modimpl else None)
                )
                group: GroupDescr = (
                    edt2group.get(edt_group, event_default_group)
                    if edt_group
                    else event_default_group
                )
                group_bg_color = (
                    group_colors.get(edt_group, _EVENT_DEFAULT_COLOR)
                    if group
                    else "lightgrey"
                )
            else:
                edt_group = ""
                group = False
                group_bg_color = _EVENT_DEFAULT_COLOR

            # --- Enseignants
            users: list[User] = []
            if edt_ics_uid_pattern:
                ens_edt_ids = extract_event_edt_ids(
                    event, edt_ics_uid_field, edt_ics_uid_pattern
                )
                for ens_edt_id in ens_edt_ids:
                    if ens_edt_id in edt2user:
                        ens = edt2user[ens_edt_id]
                    else:
                        ens = User.query.filter_by(edt_id=ens_edt_id).first()
                        edt2user[ens_edt_id] = ens
                    if ens:
                        users.append(ens)
            else:
                ens_edt_ids = []
            #
            events_sco.append(
                {
                    "title": title,  # titre event ou nom module
                    "title_edt": title_edt,  # titre event
                    "edt_group": edt_group,  # id group edt non traduit
                    "group": group,  # False si extracteur non configuré
                    "group_bg_color": group_bg_color,  # associée au groupe
                    "modimpl": modimpl,  # False si extracteur non configuré
                    "edt_module": edt_module,  # id module edt non traduit
                    # Enseignant
                    "edt_ens_ids": ens_edt_ids,  # ids ens edt, normalisés mais non traduits
                    "users": users,
                    # heures pour saisie abs: en heure LOCALE DU SERVEUR
                    "heure_deb": event.decoded("dtstart")
                    .replace(tzinfo=timezone.utc)
                    .astimezone(tz=None)
                    .strftime("%H:%M"),
                    "heure_fin": event.decoded("dtend")
                    .replace(tzinfo=timezone.utc)
                    .astimezone(tz=None)
                    .strftime("%H:%M"),
                    "jour": event.decoded("dtstart").date().isoformat(),
                    "start": event.decoded("dtstart").isoformat(),
                    "end": event.decoded("dtend").isoformat(),
                    "UID": event.decoded("UID").decode("utf-8"),
                    "raw": event.to_ical().decode("utf-8"),
                }
            )
    return events_sco, sorted(edt_groups_ids)


def extract_event_edt_id(
    event: icalendar.cal.Event,
    ics_field: str,
    pattern: re.Pattern,
    none_if_no_match=False,
) -> str | None:
    """Extrait la chaine (id) de l'évènement et la normalise.
    Si l'event n'a pas le champ: "-"
    Si pas de match: None
    """
    if not event.has_key(ics_field):
        return "-"
    data = event.decoded(ics_field).decode("utf-8")  # assume ics in utf8
    m = pattern.search(data)
    if m and len(m.groups()) > 0:
        return scu.normalize_edt_id(m.group(1))
    # fallback: if not none_if_no_match, ics field complete
    return None if none_if_no_match else data


def extract_event_edt_ids(
    event: icalendar.cal.Event,
    ics_field: str,
    pattern: re.Pattern,
) -> list[str] | None:
    """Extrait les edt_id de l'évènement et les normalise.
    Si l'event n'a pas le champ: None
    Si pas de match: liste vide
    Utilisé pour les enseignants uniquement.
    """
    if not event.has_key(ics_field):
        return
    data = event.decoded(ics_field).decode("utf-8")  # assume ics in utf8
    matches = pattern.findall(data)
    # nota: pattern may have zero or one group, so the result
    # is a list of strings, not a list of matches
    return [scu.normalize_edt_id(m) for m in matches if m]


def formsemestre_retreive_modimpls_from_edt_id(
    formsemestre: FormSemestre,
) -> dict[str, ModuleImpl]:
    """Construit un dict donnant le moduleimpl de chaque edt_id (normalisé)"""
    edt2modimpl = {}
    for modimpl in formsemestre.modimpls:
        for edt_id in modimpl.get_edt_ids():
            if edt_id:
                edt2modimpl[edt_id] = modimpl
    return edt2modimpl


def formsemestre_retreive_groups_from_edt_id(
    formsemestre: FormSemestre,
) -> dict[str, GroupDescr]:
    """Construit un dict donnant le groupe de chaque edt_id
    La clé edt_id est sans accents, lowercase.
    """
    edt2group = {}
    group: GroupDescr
    for partition in formsemestre.partitions:
        for group in partition.groups:
            for edt_id in group.get_edt_ids():
                edt2group[edt_id] = group
    return edt2group