# -*- coding: UTF-8 -*

"""Model : site config  WORK IN PROGRESS #WIP
"""

import json
import re
import urllib.parse

from flask import flash
from app import current_app, db, log
from app.comp import bonus_spo
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu

from app.scodoc.codes_cursus import (
    ABAN,
    ABL,
    ADC,
    ADJ,
    ADJR,
    ADM,
    ADSUP,
    AJ,
    ATB,
    ATJ,
    ATT,
    CMP,
    DEF,
    DEM,
    EXCLU,
    NAR,
    PASD,
    PAS1NCI,
    RAT,
    RED,
)

CODES_SCODOC_TO_APO = {
    ABAN: "ABAN",
    ABL: "ABL",
    ADC: "ADMC",
    ADJ: "ADM",
    ADJR: "ADM",
    ADM: "ADM",
    ADSUP: "ADM",
    AJ: "AJ",
    ATB: "AJAC",
    ATJ: "AJAC",
    ATT: "AJAC",
    CMP: "COMP",
    DEF: "NAR",
    DEM: "NAR",
    EXCLU: "EXC",
    NAR: "NAR",
    PASD: "PASD",
    PAS1NCI: "PAS1NCI",
    RAT: "ATT",
    RED: "RED",
    "NOTES_FMT": "%3.2f",
}


def code_scodoc_to_apo_default(code):
    """Conversion code jury ScoDoc en code Apogée
    (codes par défaut, c'est configurable via ScoDocSiteConfig.get_code_apo)
    """
    return CODES_SCODOC_TO_APO.get(code, "DEF")


class ScoDocSiteConfig(db.Model):
    """Config. d'un site
    Nouveau en ScoDoc 9: va regrouper les paramètres qui dans les versions
    antérieures étaient dans scodoc_config.py
    """

    __tablename__ = "scodoc_site_config"

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(128), nullable=False, index=True)
    value = db.Column(db.Text())

    BONUS_SPORT = "bonus_sport_func_name"
    NAMES = {
        BONUS_SPORT: str,
        "always_require_ine": bool,
        "SCOLAR_FONT": str,
        "SCOLAR_FONT_SIZE": str,
        "SCOLAR_FONT_SIZE_FOOT": str,
        "INSTITUTION_NAME": str,
        "INSTITUTION_ADDRESS": str,
        "INSTITUTION_CITY": str,
        "DEFAULT_PDF_FOOTER_TEMPLATE": str,
        "enable_entreprises": bool,
        "month_debut_annee_scolaire": int,
        "month_debut_periode2": int,
        "disable_bul_pdf": bool,
        "user_require_email_institutionnel": bool,
        # CAS
        "cas_enable": bool,
        "cas_server": str,
        "cas_login_route": str,
        "cas_logout_route": str,
        "cas_validate_route": str,
        "cas_attribute_id": str,
        "cas_uid_from_mail_regexp": str,
        "cas_edt_id_from_xml_regexp": str,
        # Assiduité
        "morning_time": str,
        "lunch_time": str,
        "afternoon_time": str,
    }

    def __init__(self, name, value):
        self.name = name
        self.value = value

    def __repr__(self):
        return f"<{self.__class__.__name__}('{self.name}', '{self.value}')>"

    @classmethod
    def get_dict(cls) -> dict:
        "Returns all data as a dict name = value"
        return {
            c.name: cls.NAMES.get(c.name, lambda x: x)(c.value)
            for c in ScoDocSiteConfig.query.all()
        }

    @classmethod
    def set_bonus_sport_class(cls, class_name):
        """Record bonus_sport config.
        If class_name not defined, raise NameError
        """
        if class_name not in cls.get_bonus_sport_class_names():
            raise NameError("invalid class name for bonus_sport")
        c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first()
        if c:
            log("setting to " + class_name)
            c.value = class_name
        else:
            c = ScoDocSiteConfig(cls.BONUS_SPORT, class_name)
        db.session.add(c)
        db.session.commit()

    @classmethod
    def get_bonus_sport_class_name(cls):
        """Get configured bonus function name, or None if None."""
        klass = cls.get_bonus_sport_class_from_name()
        if klass is None:
            return ""
        else:
            return klass.name

    @classmethod
    def get_bonus_sport_class(cls):
        """Get configured bonus function, or None if None."""
        return cls.get_bonus_sport_class_from_name()

    @classmethod
    def get_bonus_sport_class_from_name(cls, class_name=None):
        """returns bonus class with specified name.
        If name not specified, return the configured function.
        None if no bonus function configured.
        If class_name not found in module bonus_sport, returns None
        and flash a warning.
        """
        if not class_name:  # None or ""
            c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first()
            if c is None:
                return None
            class_name = c.value
        if class_name == "":  # pas de bonus défini
            return None
        klass = bonus_spo.get_bonus_class_dict().get(class_name)
        if klass is None:
            flash(
                f"""Fonction de calcul bonus sport inexistante: {class_name}.
                Changez là ou contactez votre administrateur local."""
            )
        return klass

    @classmethod
    def get_bonus_sport_class_names(cls) -> list:
        """List available bonus class names
        (starting with empty string to represent "no bonus function").
        """
        return [""] + sorted(bonus_spo.get_bonus_class_dict().keys())

    @classmethod
    def get_bonus_sport_class_list(cls) -> list[tuple]:
        """List available bonus class names
        (starting with empty string to represent "no bonus function").
        """
        d = bonus_spo.get_bonus_class_dict()
        class_list = [(name, d[name].displayed_name) for name in d]
        class_list.sort(key=lambda x: x[1].replace(" du ", " de "))
        return [("", "")] + class_list

    @classmethod
    def get_code_apo(cls, code: str) -> str:
        """La représentation d'un code pour les exports Apogée.
        Par exemple, à l'IUT du H., le code ADM est réprésenté par VAL
        Les codes par défaut sont donnés dans sco_apogee_csv.
        """
        cfg = ScoDocSiteConfig.query.filter_by(name=code).first()
        if not cfg:
            code_apo = code_scodoc_to_apo_default(code)
        else:
            code_apo = cfg.value
        return code_apo

    @classmethod
    def get_codes_apo_dict(cls) -> dict[str:str]:
        "Un dict avec code jury : code exporté"
        return {code: cls.get_code_apo(code) for code in CODES_SCODOC_TO_APO}

    @classmethod
    def set_code_apo(cls, code: str, code_apo: str):
        """Enregistre nouvelle représentation du code"""
        if code_apo != cls.get_code_apo(code):
            cfg = ScoDocSiteConfig.query.filter_by(name=code).first()
            if cfg is None:
                cfg = ScoDocSiteConfig(code, code_apo)
            else:
                cfg.value = code_apo
            db.session.add(cfg)
            db.session.commit()

    @classmethod
    def is_cas_enabled(cls) -> bool:
        """True si on utilise le CAS"""
        cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first()
        return cfg is not None and cfg.value

    @classmethod
    def is_cas_forced(cls) -> bool:
        """True si CAS forcé"""
        cfg = ScoDocSiteConfig.query.filter_by(name="cas_force").first()
        return cfg is not None and cfg.value

    @classmethod
    def is_entreprises_enabled(cls) -> bool:
        """True si on doit activer le module entreprise"""
        cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
        return cfg is not None and cfg.value

    @classmethod
    def is_user_require_email_institutionnel_enabled(cls) -> bool:
        """True si impose saisie email_institutionnel"""
        cfg = ScoDocSiteConfig.query.filter_by(
            name="user_require_email_institutionnel"
        ).first()
        return cfg is not None and cfg.value

    @classmethod
    def is_bul_pdf_disabled(cls) -> bool:
        """True si on interdit les exports PDF des bulltins"""
        cfg = ScoDocSiteConfig.query.filter_by(name="disable_bul_pdf").first()
        return cfg is not None and cfg.value

    @classmethod
    def enable_entreprises(cls, enabled: bool = True) -> bool:
        """Active (ou déactive) le module entreprises. True si changement."""
        return cls.set("enable_entreprises", "on" if enabled else "")

    @classmethod
    def disable_bul_pdf(cls, enabled=True) -> bool:
        """Interdit (ou autorise) les exports PDF. True si changement."""
        return cls.set("disable_bul_pdf", "on" if enabled else "")

    @classmethod
    def get(cls, name: str, default: str = "") -> str:
        "Get configuration param; empty string or specified default if unset"
        cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
        if cfg is None:
            return default
        return cls.NAMES.get(name, lambda x: x)(cfg.value or "")

    @classmethod
    def set(cls, name: str, value: str) -> bool:
        "Set parameter, returns True if change. Commit session."
        value_str = str(value or "").strip()
        if (cls.get(name) or "") != value_str:
            cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
            if cfg is None:
                cfg = ScoDocSiteConfig(name=name, value=value_str)
            else:
                cfg.value = value_str
            current_app.logger.info(
                f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}{
                    '...' if len(cfg.value)>32 else ''}'"""
            )
            db.session.add(cfg)
            db.session.commit()
            return True
        return False

    @classmethod
    def _get_int_field(cls, name: str, default=None) -> int:
        """Valeur d'un champs integer"""
        cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
        if (cfg is None) or cfg.value is None:
            return default
        return int(cfg.value)

    @classmethod
    def _set_int_field(
        cls,
        name: str,
        value: int,
        default=None,
        range_values: tuple = (),
    ) -> bool:
        """Set champs integer. True si changement."""
        if value != cls._get_int_field(name, default=default):
            if not isinstance(value, int) or (
                range_values and (value < range_values[0]) or (value > range_values[1])
            ):
                raise ValueError("invalid value")
            cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
            if cfg is None:
                cfg = ScoDocSiteConfig(name=name, value=str(value))
            else:
                cfg.value = str(value)
            db.session.add(cfg)
            db.session.commit()
            return True
        return False

    @classmethod
    def get_month_debut_annee_scolaire(cls) -> int:
        """Mois de début de l'année scolaire."""
        return cls._get_int_field(
            "month_debut_annee_scolaire", scu.MONTH_DEBUT_ANNEE_SCOLAIRE
        )

    @classmethod
    def get_month_debut_periode2(cls) -> int:
        """Mois de début de l'année scolaire."""
        return cls._get_int_field("month_debut_periode2", scu.MONTH_DEBUT_PERIODE2)

    @classmethod
    def set_month_debut_annee_scolaire(
        cls, month: int = scu.MONTH_DEBUT_ANNEE_SCOLAIRE
    ) -> bool:
        """Fixe le mois de début des années scolaires.
        True si changement.
        """
        if cls._set_int_field(
            "month_debut_annee_scolaire", month, scu.MONTH_DEBUT_ANNEE_SCOLAIRE, (1, 12)
        ):
            log(f"set_month_debut_annee_scolaire({month})")
            return True
        return False

    @classmethod
    def set_month_debut_periode2(cls, month: int = scu.MONTH_DEBUT_PERIODE2) -> bool:
        """Fixe le mois de début des années scolaires.
        True si changement.
        """
        if cls._set_int_field(
            "month_debut_periode2", month, scu.MONTH_DEBUT_PERIODE2, (1, 12)
        ):
            log(f"set_month_debut_periode2({month})")
            return True
        return False

    @classmethod
    def get_perso_links(cls) -> list["PersonalizedLink"]:
        "Return links"
        data_links = cls.get("personalized_links")
        if not data_links:
            return []
        try:
            links_dict = json.loads(data_links)
        except json.decoder.JSONDecodeError as exc:
            # Corrupted data ? erase content
            cls.set("personalized_links", "")
            raise ScoValueError(
                "Attention: liens personnalisés erronés: ils ont été effacés."
            ) from exc
        return [PersonalizedLink(**item) for item in links_dict]

    @classmethod
    def set_perso_links(cls, links: list["PersonalizedLink"] = None):
        "Store all links"
        if not links:
            links = []
        links_dict = [link.to_dict() for link in links]
        data_links = json.dumps(links_dict)
        cls.set("personalized_links", data_links)

    @classmethod
    def extract_cas_id(cls, email_addr: str) -> str | None:
        "Extract cas_id from maill, using regexp in config. None if not possible."
        exp = cls.get("cas_uid_from_mail_regexp")
        if not exp or not email_addr:
            return None
        try:
            match = re.search(exp, email_addr)
        except re.error:
            log("error extracting CAS id from '{email_addr}' using regexp '{exp}'")
            return None
        if not match:
            log("no match extracting CAS id from '{email_addr}' using regexp '{exp}'")
            return None
        try:
            cas_id = match.group(1)
        except IndexError:
            log(
                "no group found extracting CAS id from '{email_addr}' using regexp '{exp}'"
            )
            return None
        return cas_id

    @classmethod
    def cas_uid_from_mail_regexp_is_valid(cls, exp: str) -> bool:
        "True si l'expression régulière semble valide"
        # check that it compiles
        try:
            pattern = re.compile(exp)
        except re.error:
            return False
        # and returns at least one group on a simple cannonical address
        match = pattern.search("emmanuel@exemple.fr")
        return match is not None and len(match.groups()) > 0

    @classmethod
    def cas_edt_id_from_xml_regexp_is_valid(cls, exp: str) -> bool:
        "True si l'expression régulière semble valide"
        # check that it compiles
        try:
            _ = re.compile(exp)
        except re.error:
            return False
        return True

    @classmethod
    def assi_get_rounded_time(cls, label: str, default: str) -> float:
        "Donne l'heure stockée dans la config globale sous label, en float arrondi au quart d'heure"
        return _round_time_str_to_quarter(cls.get(label, default))


def _round_time_str_to_quarter(string: str) -> float:
    """Prend une heure iso '12:20:23', et la converti en un nombre d'heures
    en arrondissant au quart d'heure: (les secondes sont ignorées)
    "12:20:00" -> 12.25
    "12:29:00" -> 12.25
    "12:30:00" -> 12.5
    """
    parts = [*map(float, string.split(":"))]
    hour = parts[0]
    minutes = round(parts[1] / 60 * 4) / 4
    return hour + minutes


class PersonalizedLink:
    def __init__(self, title: str = "", url: str = "", with_args: bool = False):
        self.title = str(title or "")
        self.url = str(url or "")
        self.with_args = bool(with_args)

    def get_url(self, params: dict = {}) -> str:
        if not self.with_args:
            return self.url
        query_string = urllib.parse.urlencode(params)
        if "?" in self.url:
            return self.url + "&" + query_string
        return self.url + "?" + query_string

    def to_dict(self) -> dict:
        "as dict"
        return {"title": self.title, "url": self.url, "with_args": self.with_args}
