import datetime
import logging
from time import time
from typing import (
    List,
    Callable,
    Optional,
    Union,
    TypeVar,
    Type,
    TYPE_CHECKING,
    Tuple,
)

from Crypto.Hash import SHA256
import re
from urllib.parse import urlparse, urlunparse

from . import dataClasses
from .exceptions import *
from .pronoteAPI import (
    _Communication,
    _Encryption,
    _KeepAlive,
    _enleverAlea,
    _prepare_onglets,
    log,
)
import json

if TYPE_CHECKING:
    from requests.cookies import RequestsCookieJar
    from typing_extensions import Protocol

    class ENTFunction(Protocol):
        def __call__(self, u: str, p: str, **kwargs: str) -> RequestsCookieJar: ...


__all__ = ("ClientBase", "Client", "ParentClient", "VieScolaireClient")

T = TypeVar("T", bound="ClientBase")


class ClientBase:
    """Base for every PRONOTE client. Provides login.

    Args:
        pronote_url (str): URL of the server
        username (str)
        password (str)
        ent (Optional[Callable]): Cookies for ENT connections
        mode (bool): internal option
        uuid (str): Your application UUID (any unique string)
        account_pin (Optional[str]): 2FA PIN to the account.

            Consider deleting it after logging in.

            .. code-block:: python

                del client.account_pin

        client_identifier (Optional[str]):
            Identificator of this client provided by PRONOTE. PRONOTE uses this
            to remember a browser / client.

        device_name (Optional[str]): A name for registering this client as a device.

    Attributes:
        start_day (datetime.datetime): The first day of the school year
        week (int): The current week of the school year
        logged_in (bool): If the user is successfully logged in
        username (str)
        password (str)
        pronote_url (str)
        info (ClientInfo): Provides information about the current client. Name etc...
        last_connection (datetime.datetime)
        client_identifier (str): Identificator of this client provided by PRONOTE
    """

    def __init__(
        self,
        pronote_url: str,
        username: str = "",
        password: str = "",
        ent: Optional["ENTFunction"] = None,
        mode: str = "normal",
        uuid: str = "",
        account_pin: Optional[str] = None,
        client_identifier: Optional[str] = None,
        device_name: Optional[str] = None,
    ) -> None:
        log.info("INIT")
        # start communication session
        if not len(password) + len(username):
            raise PronoteAPIError(
                "Please provide login credentials. Cookies are None, and username and password are empty."
            )

        self.ent = ent
        if ent:
            pronote_url = pronote_url.replace("login=true", "")
            cookies = ent(username, password, pronote_url=pronote_url)
        else:
            cookies = None

        if mode != "normal" and not uuid:
            raise PronoteAPIError("UUID must not be empty")
        self.uuid = uuid
        self.login_mode = mode

        self.username = username
        self.password = password
        self.pronote_url = pronote_url
        self.communication = _Communication(pronote_url, cookies)

        self.account_pin = account_pin
        self.client_identifier = client_identifier
        self.device_name = device_name

        self.attributes, self.func_options = self.communication.initialise(
            self.client_identifier
        )

        if not self.client_identifier:
            self.client_identifier = self.func_options["dataSec"]["data"][
                "identifiantNav"
            ]

        # set up encryption
        self.encryption = _Encryption()
        self.encryption.aes_iv = self.communication.encryption.aes_iv

        # some other attribute creation
        self._last_ping = time()

        self.parametres_utilisateur: dict = {}
        self.auth_cookie: dict = {}
        self.info: dataClasses.ClientInfo

        self.start_day = datetime.datetime.strptime(
            self.func_options["dataSec"]["data"]["General"]["PremierLundi"]["V"],
            "%d/%m/%Y",
        ).date()
        self.week = self.get_week(datetime.date.today())

        self._refreshing = False

        self.periods_: Optional[List[dataClasses.Period]]
        self.periods_ = self.periods
        self.logged_in = self._login()
        self._expired = False

        self.last_connection: Optional[datetime.datetime]

    @classmethod
    def qrcode_login(
        cls: Type[T],
        qr_code: dict,
        pin: str,
        uuid: str,
        account_pin: Optional[str] = None,
        client_identifier: Optional[str] = None,
        device_name: Optional[str] = None,
        skip_2fa: bool = False,
    ) -> T:
        """Login with QR code

        The created client instance will have its username and password
        attributes set to the credentials for the next login using
        :meth:`.token_login`.

        Args:
            qr_code (dict): JSON contained in the QR code. Must have ``login``, ``jeton`` and ``url`` keys.
            pin (str): 4-digit confirmation code created during QR code setup
            uuid (str): Unique ID for your application. Must not change between logins.
            account_pin (Optional[str]): 2FA PIN to the account
            client_identifier (Optional[str]): Identificator of this client provided by PRONOTE
            device_name (Optional[str]): A name for registering this client as a device.
            skip_2fa (bool): Skip 2FA. PRONOTE will require it when connecting using the generated token (:meth:`.token_login`).
        """
        encryption = _Encryption()
        encryption.aes_set_key(pin.encode())

        short_token = bytes.fromhex(qr_code["login"])
        long_token = bytes.fromhex(qr_code["jeton"])

        try:
            login = encryption.aes_decrypt(short_token).decode()
            jeton = encryption.aes_decrypt(long_token).decode()
        except CryptoError as ex:
            raise QRCodeDecryptError("invalid confirmation code") from ex

        url = urlparse(qr_code["url"])

        # The app would query the server for all the available spaces and find
        # a space by checking if the last part of the URL matches one of the
        # space URLs. eg. "/pronote/parent.html" would match "mobile.parent.html"
        # (info url: <pronote root>/InfoMobileApp.json?id=0D264427-EEFC-4810-A9E9-346942A862A4)

        # We're gonna try the shorter route of just prepending "mobile." if it
        # isn't there already
        parts = url.path.split("/")
        if not parts[-1].startswith("mobile."):
            parts[-1] = "mobile." + parts[-1]

        # Reconstruct the url and add magic parameters at the end of the URL.
        # You can find them in a file called "ObjetCommMessage.js" in the
        # connection method when you decompile the mobile APK.
        fixed_url = url._replace(
            path="/".join(parts),
            query="fd=1&bydlg=A6ABB224-12DD-4E31-AD3E-8A39A1C2C335&login=true",
            fragment="",
        )

        client = cls(
            urlunparse(fixed_url),
            login,
            jeton,
            mode="qr_code",
            uuid=uuid,
            account_pin=account_pin,
            client_identifier=client_identifier,
            device_name=device_name,
        )
        client.login_mode = "token"  # for subsequent refreshes

        if not skip_2fa:
            # check if the account has 2FA enabled
            resp = client.post("PageInfosPerso", onglet=49)

            mode = resp["dataSec"]["data"]["securisation"].get("mode", 0)
            if mode == 0:
                log.warning("couldn't get account security mode, ignoring...")
                return client

            return cls.token_login(
                **client.export_credentials(),
                account_pin=account_pin,
                device_name=device_name,
            )

        return client

    @classmethod
    def token_login(
        cls: Type[T],
        pronote_url: str,
        username: str,
        password: str,
        uuid: str,
        account_pin: Optional[str] = None,
        client_identifier: Optional[str] = None,
        device_name: Optional[str] = None,
    ) -> T:
        """
        Login with a password token. Used for logins after :meth:`.qrcode_login`.

        The created client instance will have its username and password
        attributes set to the credentials for the next login using
        :meth:`.token_login`.

        Args:
            pronote_url (str): URL of the server
            username (str)
            password (str): Password token received from the previous login
            uuid (str): Unique ID for your application. Must not change between logins.
            account_pin (Optional[str]): 2FA PIN to the account
            client_identifier (str): Identificator of this client provided by PRONOTE
            device_name (str): Identificator of this client provided by PRONOTE
        """
        return cls(
            pronote_url,
            username,
            password,
            mode="token",
            uuid=uuid,
            account_pin=account_pin,
            client_identifier=client_identifier,
            device_name=device_name,
        )

    def _login(self) -> bool:
        """Logs in the user.

        Returns:
            bool: True if logged in, False if not
        """

        if self.ent:
            username = self.attributes["e"]
            password = self.attributes["f"]
        else:
            username = self.username
            password = self.password

        # identification phase
        ident_json = {
            "genreConnexion": 0,
            "genreEspace": int(self.attributes["a"]),
            "identifiant": username,
            "pourENT": True if self.ent else False,
            "enConnexionAuto": False,
            "demandeConnexionAuto": False,
            "demandeConnexionAppliMobile": self.login_mode == "qr_code",
            "demandeConnexionAppliMobileJeton": self.login_mode == "qr_code",
            "enConnexionAppliMobile": self.login_mode == "token",
            "uuidAppliMobile": (
                self.uuid if self.login_mode in ("qr_code", "token") else ""
            ),
            "loginTokenSAV": "",
        }
        idr = self.post("Identification", data=ident_json)
        log.debug("indentification")

        # creating the authentification data
        log.debug(str(idr))
        challenge = idr["dataSec"]["data"]["challenge"]
        e = _Encryption()
        e.aes_set_iv(self.communication.encryption.aes_iv)

        # key gen
        if self.ent:
            motdepasse = SHA256.new(str(password).encode()).hexdigest().upper()
            e.aes_set_key(motdepasse.encode())
        else:
            if idr["dataSec"]["data"]["modeCompLog"]:
                username = username.lower()
            if idr["dataSec"]["data"]["modeCompMdp"]:
                password = password.lower()
            alea = idr["dataSec"]["data"].get("alea", "")
            motdepasse = SHA256.new((alea + password).encode()).hexdigest().upper()
            e.aes_set_key((username + motdepasse).encode())

        # challenge
        try:
            dec = e.aes_decrypt(bytes.fromhex(challenge))
            dec_no_alea = _enleverAlea(dec.decode())
            ch = e.aes_encrypt(dec_no_alea.encode()).hex()
        except CryptoError as ex:
            if self.login_mode == "qr_code":
                ex.args += (
                    "exception happened during login -> probably the qr code has expired (qr code is valid during 10 minutes)",
                )
            else:
                ex.args += (
                    "exception happened during login -> probably bad username/password",
                )
            raise

        # send
        auth_json = {
            "connexion": 0,
            "challenge": ch,
            "espace": int(self.attributes["a"]),
        }
        auth_response = self.post("Authentification", data=auth_json)
        if "cle" in auth_response["dataSec"]["data"]:
            self.communication.after_auth(auth_response, e.aes_key)
            self.encryption.aes_key = e.aes_key

            actionsDoubleAuth = auth_response["dataSec"]["data"].get(
                "actionsDoubleAuth"
            )
            if actionsDoubleAuth:
                actions = json.loads(actionsDoubleAuth["V"])

                doRegisterDevice = 5 in actions or 3 in actions
                doVerifyPin = 3 in actions

                self._do_2fa(
                    doVerifyPin,
                    doRegisterDevice,
                    self.account_pin,
                    self.device_name,
                )

            log.info(f"successfully logged in as {self.username}")

            last_conn = auth_response["dataSec"]["data"].get("derniereConnexion")
            self.last_connection = (
                dataClasses.Util.datetime_parse(last_conn["V"]) if last_conn else None
            )

            if self.login_mode in ("qr_code", "token") and auth_response["dataSec"][
                "data"
            ].get("jetonConnexionAppliMobile"):
                self.password = auth_response["dataSec"]["data"][
                    "jetonConnexionAppliMobile"
                ]

            # getting listeOnglets separately because of pronote API change
            self.parametres_utilisateur = self.post("ParametresUtilisateur")
            self.info = dataClasses.ClientInfo(
                self, self.parametres_utilisateur["dataSec"]["data"]["ressource"]
            )
            self.communication.authorized_onglets = _prepare_onglets(
                self.parametres_utilisateur["dataSec"]["data"]["listeOnglets"]
            )
            log.info("got onglets data.")
            return True
        else:
            log.info("login failed")
            return False

    def _do_2fa(
        self,
        doVerifyPin: bool = False,
        doRegisterDevice: bool = False,
        pin: Optional[str] = None,
        identifier: Optional[str] = None,
    ) -> None:
        log.debug(
            "doing 2fa doPin=%s, doRegister=%s, pin=%s (redacted), identifier=%s",
            doVerifyPin,
            doRegisterDevice,
            bool(pin),
            identifier,
        )

        encryptedPin = None

        if doVerifyPin:
            log.debug("verifying pin")

            if pin is None:
                raise MFAError("PIN is required for this account")

            encryptedPin = self.communication.encryption.aes_encrypt(pin.encode()).hex()

            resp = self.post(
                "SecurisationCompteDoubleAuth",
                data={
                    "action": 0,
                    "codePin": encryptedPin,
                },
            )

            if not resp["dataSec"]["data"].get("result", False):
                raise MFAError("Invalid PIN")

        if doRegisterDevice:
            log.debug("registering device")

            if identifier is None:
                raise MFAError("A device identifier is required for this account")

            data = {
                "action": 3,
                "avecIdentification": True,
                "strIdentification": identifier,
            }
            if encryptedPin:
                data["codePin"] = encryptedPin

            self.post("SecurisationCompteDoubleAuth", data=data)

    def export_credentials(self) -> dict:
        return {
            "pronote_url": self.pronote_url,
            "username": self.username,
            "password": self.password,
            "client_identifier": self.client_identifier,
            "uuid": self.uuid,
        }

    def get_week(self, date: Union[datetime.date, datetime.datetime]) -> int:
        if isinstance(date, datetime.datetime):
            return 1 + int((date.date() - self.start_day).days / 7)
        return 1 + int((date - self.start_day).days / 7)

    @property
    def periods(self) -> List[dataClasses.Period]:
        """Get all of the periods of the year.

        Returns:
            List[Period]: All the periods of the year
        """
        if hasattr(self, "periods_") and self.periods_:
            return self.periods_
        json = self.func_options["dataSec"]["data"]["General"]["ListePeriodes"]
        return [dataClasses.Period(self, j) for j in json]

    def keep_alive(self) -> _KeepAlive:
        """
        Returns a context manager to keep the connection alive. When inside the context manager,
        it sends a "Navigation" request to the server after 5 minutes of inactivity from another thread.
        """
        return _KeepAlive(self)

    def refresh(self) -> None:
        """
        Now this is the true jank part of this program. It refreshes the connection if something went wrong.
        This is the classical procedure if something is broken.
        """
        logging.debug("Reinitialisation")
        self.communication.session.close()

        if self.ent:
            cookies = self.ent(
                self.username, self.password, pronote_url=self.pronote_url
            )
        else:
            cookies = None

        self.communication = _Communication(self.pronote_url, cookies)
        self.attributes, self.func_options = self.communication.initialise(
            self.client_identifier
        )

        # set up encryption

        self.encryption = _Encryption()
        self.encryption.aes_iv = self.communication.encryption.aes_iv
        self._login()
        self.periods_ = None
        self.periods_ = self.periods
        self.week = self.get_week(datetime.date.today())
        self._expired = True

    def session_check(self) -> bool:
        """Checks if the session has expired and refreshes it if it had (returns bool signifying if it was expired)"""
        self.post("Navigation", 7, {"onglet": 7, "ongletPrec": 7})
        if self._expired:
            self._expired = False
            return True
        return False

    def post(
        self,
        function_name: str,
        onglet: Optional[int] = None,
        data: Optional[dict] = None,
    ) -> dict:
        """Preforms a raw post to the PRONOTE server. Adds signature, then passes it to _Communication.post

        Args:
            function_name (str)
            onglet (int)
            data (dict)
        Returns:
            dict: Raw JSON
        """
        post_data = {}
        if onglet:
            post_data["Signature"] = {"onglet": onglet}
        if data:
            post_data["data"] = data

        try:
            return self.communication.post(function_name, post_data)
        except PronoteAPIError as e:
            if isinstance(e, ExpiredObject):
                raise e

            log.info(
                f"Have you tried turning it off and on again? ERROR: {e.pronote_error_code} | {e.pronote_error_msg}"
            )

            # prevent refresh recursion
            if self._refreshing:
                raise e
            else:
                self._refreshing = True
                self.refresh()
                self._refreshing = False

            return self.communication.post(function_name, post_data)

    def request_qr_code_data(self, pin: str) -> dict:
        """
        Requests data for a new login QR code. This data can be then used with :meth:`.qrcode_login`.

        Args:
            pin (str): Four digit pin to use for the QR code
        """
        req = self.post("JetonAppliMobile", 7, {"code": pin})
        return {
            # ugly way to add the mobile prefix to the url
            "url": re.sub(
                r"/(?:mobile.){,1}(\w+).html$", r"/mobile.\1.html", self.pronote_url
            ),
            **req["dataSec"]["data"],
        }


class Client(ClientBase):
    """
    A PRONOTE client.

    Args:
        pronote_url (str): URL of the server
        username (str)
        password (str)
        ent (Optional[Callable]): Cookies for ENT connections
        mode (bool): internal option
        uuid (str): Your application UUID (any unique string)
        account_pin (Optional[str]): 2FA PIN to the account.

            Consider deleting it after logging in.

            .. code-block:: python

                del client.account_pin

        client_identifier (Optional[str]):
            Identificator of this client provided by PRONOTE. PRONOTE uses this
            to remember a browser / client.

        device_name (Optional[str]): A name for registering this client as a device.
    """

    def lessons(
        self,
        date_from: Union[datetime.date, datetime.datetime],
        date_to: Optional[Union[datetime.date, datetime.datetime]] = None,
    ) -> List[dataClasses.Lesson]:
        """Gets all lessons in a given timespan.

        Args:
            date_from (Union[datetime.date, datetime.datetime]): first date
            date_to (Union[datetime.date, datetime.datetime]): second date,
                if None, then to the end of day_from

        Returns:
            List[Lesson]: List of lessons
        """
        user = self.parametres_utilisateur["dataSec"]["data"]["ressource"]
        data = {
            "ressource": user,
            "avecAbsencesEleve": False,
            "avecConseilDeClasse": True,
            "estEDTPermanence": False,
            "avecAbsencesRessource": True,
            "avecDisponibilites": True,
            "avecInfosPrefsGrille": True,
            "Ressource": user,
        }
        output = []

        # convert date to datetime
        if isinstance(date_from, datetime.date):
            date_from = datetime.datetime.combine(
                date_from, datetime.datetime.min.time()
            )

        if isinstance(date_to, datetime.date):
            date_to = datetime.datetime.combine(date_to, datetime.datetime.min.time())

        if not date_to:
            date_to = datetime.datetime.combine(date_from, datetime.datetime.max.time())

        first_week = self.get_week(date_from)
        last_week = self.get_week(date_to)

        # getting lessons for all the weeks.
        for week in range(first_week, last_week + 1):
            data["NumeroSemaine"] = data["numeroSemaine"] = week
            response = self.post("PageEmploiDuTemps", 16, data)
            l_list = response["dataSec"]["data"]["ListeCours"]
            for lesson in l_list:
                output.append(dataClasses.Lesson(self, lesson))

        # since we only have week precision, we need to make it more precise on our own
        return [lesson for lesson in output if date_from <= lesson.start <= date_to]

    def export_ical(self) -> str:
        """Constructs the client's ICal URL"""
        response = self.post("PageInfosPerso", 16, None)

        try:
            ical_params = response["dataSec"]["data"]["iCal"]["liste"]["V"][0]
            ical = ical_params["paramICal"]
            suppl = ical_params["paramSuppl"]
        except KeyError as e:
            raise ParsingError(
                "Could not parse ICal params",
                response,
                ("dataSec", "data", "iCal", "liste", "V"),
            ) from e

        ver = self.func_options["dataSec"]["data"]["General"]["versionPN"]

        return f"{self.communication.root_site}/ical/mesinformations.ics?icalsecurise={ical}&version={ver}&param={suppl}"

    def homework(
        self, date_from: datetime.date, date_to: Optional[datetime.date] = None
    ) -> List[dataClasses.Homework]:
        """Get homework between two given points.

        Args:
            date_from (datetime): The first date
            date_to (datetime): The second date. If unspecified to the end of the year.
        Returns:
            List[Homework]: Homework between two given points
        """
        if not date_to:
            date_to = datetime.datetime.strptime(
                self.func_options["dataSec"]["data"]["General"]["DerniereDate"]["V"],
                "%d/%m/%Y",
            ).date()
        json_data = {
            "domaine": {
                "_T": 8,
                "V": f"[{self.get_week(date_from)}..{self.get_week(date_to)}]",
            }
        }

        response = self.post("PageCahierDeTexte", 88, json_data)
        h_list = response["dataSec"]["data"]["ListeTravauxAFaire"]["V"]
        out = []
        for h in h_list:
            hw = dataClasses.Homework(self, h)
            if date_from <= hw.date <= date_to:
                out.append(hw)
        return out

    def generate_timetable_pdf(
        self,
        day: Optional[datetime.date] = None,
        portrait: bool = False,
        overflow: int = 0,
        font_size: Tuple[int, int] = (8, 3),
    ) -> str:
        """Generate a PDF timetable.

        Args:
            day (Optional[datetime.date]): the day for which to create the timetable, the whole week is always generated
            portrait (bool): switches the timetable to portrait mode
            overflow (int): Controls overflow / formatting of lesson names.

                * ``0``: no overflow
                * ``1``: overflow to the same page, long names printed on the bottom
                * ``2``: overflow to a separate page

            font_size (Tuple[int, int]): font size restrictions, first element is the *preferred* font size, and second is the *minimum*
        """
        user = {
            "G": 4,
            "N": self.parametres_utilisateur["dataSec"]["data"]["ressource"]["N"],
        }

        data = {
            "options": {
                "portrait": portrait,
                "taillePolice": font_size[0],
                "taillePoliceMin": font_size[1],
                "couleur": 1,
                "renvoi": overflow,
                "uneGrilleParSemaine": False,
                "inversionGrille": False,
                "ignorerLesPlagesSansCours": False,
                "estEDTAnnuel": day is None,
            },
            "genreGenerationPDF": 0,
            "estPlanning": False,
            "estPlanningParRessource": False,
            "estPlanningOngletParJour": False,
            "estPlanningParJour": False,
            "indiceJour": 0,
            "ressource": user,
            "ressources": [user],
            "domaine": {"_T": 8, "V": f"[{self.get_week(day) if day else 0}]"},
            "avecCoursAnnules": True,
            "grilleInverse": False,
        }

        response = self.post("GenerationPDF", 16, data)
        return (
            self.communication.root_site + "/" + response["dataSec"]["data"]["url"]["V"]
        )

    def get_recipients(self) -> List[dataClasses.Recipient]:
        """Get recipients for new discussion

        Returns:
            List[Recipient]: list of available recipients
        """
        # add teacher
        data = {"onglet": {"N": 0, "G": 3}}
        recipients = self.post("ListeRessourcesPourCommunication", 131, data)[
            "dataSec"
        ]["data"]["listeRessourcesPourCommunication"]["V"]
        # add staff
        data = {"onglet": {"N": 0, "G": 34}}
        recipients += self.post("ListeRessourcesPourCommunication", 131, data)[
            "dataSec"
        ]["data"]["listeRessourcesPourCommunication"]["V"]

        return [dataClasses.Recipient(self, r) for r in recipients]

    def get_teaching_staff(self) -> List[dataClasses.TeachingStaff]:
        """Get the teacher list

        Returns:
            List[TeachingStaff]: list of teachers and other staff
        """
        # add teacher
        teachers = self.post("PageEquipePedagogique", 37)["dataSec"]["data"]["liste"][
            "V"
        ]

        return [dataClasses.TeachingStaff(t) for t in teachers]

    # TODO: change to "subject"
    def new_discussion(
        self, subjet: str, message: str, recipients: List[dataClasses.Recipient]
    ) -> None:
        """Create a new discussion

        Args:
            subjet (str): subject of the message
            message (str): content of the message
            recipients (List[Recipient])
        """
        recipients_json = [{"N": r.id, "G": r._type, "L": r.name} for r in recipients]
        data = {
            "objet": subjet,
            "contenu": message,
            "listeDestinataires": recipients_json,
        }

        self.post("SaisieMessage", 131, data)

    def discussions(self, only_unread: bool = False) -> List[dataClasses.Discussion]:
        """Gets all the discussions in the discussions tab"""
        discussions = self.post(
            "ListeMessagerie", 131, {"avecMessage": True, "avecLu": not only_unread}
        )

        labels = {
            l["N"]: l["G"]
            for l in discussions["dataSec"]["data"]["listeEtiquettes"]["V"]
        }

        return [
            dataClasses.Discussion(self, d, labels)
            for d in discussions["dataSec"]["data"]["listeMessagerie"]["V"]
            if d.get("estUneDiscussion") and d.get("profondeur", 1) == 0
        ]

    def information_and_surveys(
        self,
        date_from: Optional[datetime.datetime] = None,
        date_to: Optional[datetime.datetime] = None,
        only_unread: bool = False,
    ) -> List[dataClasses.Information]:
        """Gets all the information and surveys in the information and surveys tab.

        Args:
            only_unread (bool): Return only unread information
            date_from (datetime.datetime): Since datetime (included)
            date_to (datetime.datetime): Until datetime (excluded)
        """
        response = self.post(
            "PageActualites", 8, {"modesAffActus": {"_T": 26, "V": "[0..3]"}}
        )
        info = []
        for liste in response["dataSec"]["data"]["listeModesAff"]:
            info += [
                dataClasses.Information(self, info)
                for info in liste["listeActualites"]["V"]
            ]

        if only_unread:
            info = [i for i in info if not i.read]

        if date_from is not None:
            info = list(
                filter(
                    lambda i: i.start_date is not None and date_from <= i.start_date,  # type: ignore
                    info,
                )
            )

        if date_to is not None:
            info = list(
                filter(
                    lambda i: i.start_date is not None and i.start_date < date_to,  # type: ignore
                    info,
                )
            )

        return info

    def menus(
        self, date_from: datetime.date, date_to: Optional[datetime.date] = None
    ) -> List[dataClasses.Menu]:
        """Get menus between two given points.

        Args:
            date_from (datetime): The first date
            date_to (datetime): The second date. If unspecified to the end of the year.
        Returns:
            List[Menu]: Menu between two given points
        """
        output = []

        if not date_to:
            date_to = date_from

        first_day = date_from - datetime.timedelta(days=date_from.weekday())

        # getting menus for all the weeks.
        while first_day <= date_to:
            data = {"date": {"_T": 7, "V": first_day.strftime("%d/%m/%Y") + " 0:0:0"}}
            response = self.post("PageMenus", 10, data)
            l_list = response["dataSec"]["data"]["ListeJours"]["V"]
            for day in l_list:
                for menu in day["ListeRepas"]["V"]:
                    menu["Date"] = day["Date"]
                    output.append(dataClasses.Menu(self, menu))
            first_day += datetime.timedelta(days=7)

        # since we only have week precision, we need to make it more precise on our own
        return [menu for menu in output if date_from <= menu.date <= date_to]

    @property
    def current_period(self) -> dataClasses.Period:
        """the current period"""
        onglets = self.parametres_utilisateur["dataSec"]["data"]["ressource"][
            "listeOngletsPourPeriodes"
        ]["V"]

        # get onglet with number 198 (mes notes), otherwise fallback to the
        # first one in the list
        onglet = next(filter(lambda x: x.get("G") == 198, onglets), onglets[0])

        id_period = onglet["periodeParDefaut"]["V"]["N"]
        return dataClasses.Util.get(self.periods, id=id_period)[0]


class ParentClient(Client):
    """
    A parent PRONOTE client.

    Args:
        pronote_url (str): URL of the server
        username (str)
        password (str)
        ent (Optional[Callable]): Cookies for ENT connections
        mode (bool): internal option
        uuid (str): Your application UUID (any unique string)
        account_pin (Optional[str]): 2FA PIN to the account.

            Consider deleting it after logging in.

            .. code-block:: python

                del client.account_pin

        client_identifier (Optional[str]):
            Identificator of this client provided by PRONOTE. PRONOTE uses this
            to remember a browser / client.

        device_name (Optional[str]): A name for registering this client as a device.

    Attributes:
        children (List[ClientInfo]): List of sub-clients representing all the
            children connected to the main parent account.
    """

    def __init__(
        self,
        pronote_url: str,
        username: str = "",
        password: str = "",
        ent: Optional[Callable] = None,
        mode: str = "normal",
        uuid: str = "",
        account_pin: Optional[str] = None,
        client_identifier: Optional[str] = None,
        device_name: Optional[str] = None,
    ) -> None:
        super().__init__(
            pronote_url,
            username,
            password,
            ent,
            mode,
            uuid,
            account_pin,
            client_identifier,
            device_name,
        )

        self.children: List[dataClasses.ClientInfo] = []
        for c in self.parametres_utilisateur["dataSec"]["data"]["ressource"][
            "listeRessources"
        ]:
            self.children.append(dataClasses.ClientInfo(self, c))

        if not self.children:
            raise ChildNotFound("No children were found.")

        self._selected_child: dataClasses.ClientInfo = self.children[0]
        self.parametres_utilisateur["dataSec"]["data"][
            "ressource"
        ] = self._selected_child.raw_resource

    def set_child(self, child: Union[str, dataClasses.ClientInfo]) -> None:
        """Select a child

        Args:
            child (Union[str, ClientInfo]): Name or ClientInfo of a child.
        """
        if not isinstance(child, dataClasses.ClientInfo):
            candidates = dataClasses.Util.get(self.children, name=child)
            c = candidates[0] if candidates else None
        else:
            c = child

        if not c:
            raise ChildNotFound(f"A child with the name {child} was not found.")

        self._selected_child = c
        self.parametres_utilisateur["dataSec"]["data"][
            "ressource"
        ] = self._selected_child.raw_resource

    def post(
        self,
        function_name: str,
        onglet: Optional[int] = None,
        data: Optional[dict] = None,
    ) -> dict:
        """Preforms a raw post to the PRONOTE server.

        Adds signature, then passes it to _Communication.post

        Args:
            function_name (str)
            onglet (int)
            data (dict)
        Returns:
            Raw JSON
        """
        post_data = {}
        if onglet:
            post_data["Signature"] = {
                "onglet": onglet,
                "membre": {"N": self._selected_child.id, "G": 4},
            }

        if data:
            post_data["data"] = data

        try:
            return self.communication.post(function_name, post_data)
        except PronoteAPIError as e:
            if isinstance(e, ExpiredObject):
                raise e

            log.info(
                f"Have you tried turning it off and on again? ERROR: {e.pronote_error_code} | {e.pronote_error_msg}"
            )
            self.refresh()
            return self.communication.post(function_name, post_data)


class VieScolaireClient(ClientBase):
    """A PRONOTE client for Vie Scolaire accounts.

    Args:
        pronote_url (str): URL of the server
        username (str)
        password (str)
        ent (Optional[Callable]): Cookies for ENT connections
        mode (bool): internal option
        uuid (str): Your application UUID (any unique string)
        account_pin (Optional[str]): 2FA PIN to the account.

            Consider deleting it after logging in.

            .. code-block:: python

                del client.account_pin

        client_identifier (Optional[str]):
            Identificator of this client provided by PRONOTE. PRONOTE uses this
            to remember a browser / client.

        device_name (Optional[str]): A name for registering this client as a device.

    Attributes:
        classes (List[StudentClass]): List of all classes this account has access to.
    """

    def __init__(
        self,
        pronote_url: str,
        username: str = "",
        password: str = "",
        ent: Optional[Callable] = None,
        mode: str = "normal",
        uuid: str = "",
        account_pin: Optional[str] = None,
        client_identifier: Optional[str] = None,
        device_name: Optional[str] = None,
    ) -> None:
        super().__init__(
            pronote_url,
            username,
            password,
            ent,
            mode,
            uuid,
            account_pin,
            client_identifier,
            device_name,
        )
        self.classes = [
            dataClasses.StudentClass(self, json)
            for json in self.parametres_utilisateur["dataSec"]["data"]["listeClasses"][
                "V"
            ]
        ]
