"""
Pages hold and organize elements.

.. moduleauthor::
    Paul Wiemann <paulwiemann@gmail.com>, Johannes Brachem <jbrachem@posteo.de>
"""

import string
import time
from abc import ABC, abstractproperty
from collections.abc import Iterator
from functools import reduce

from . import element as elm
from . import saving_agent
from ._core import ExpMember
from ._helper import inherit_kwargs
from .element.misc import HideNavigation, Style
from .exceptions import AbortMove, AlfredError


@inherit_kwargs
class _PageCore(ExpMember):
    """
    Provides core functionality for pages.

    Args:
        prefix_element_names (bool): If True, the names of all input
            elements on this page will be prefixed with the page name.
            Defaults to None. Can be defined as a class attribute.
        minimum_display_time (str): The minimal amount of time that the page
            must be displayed, before participants can move to the next
            page. Must be specified as a string with a unit of 's'
            (for seconds), or 'm' (for minutes), for instance '30s'.
            Defaults to None. Can be defined as a class attribute.
        minimum_display_time_msg (str): A page-specific message to be displayed,
            if participants try to move forward before the minimum
            display time has expired. Defaults to None, which means that
            the default message defined in config.conf will be used.
            Can be defined as a class attribute.
        progress (int, float): Can be used to manually determine the
            level of experiment progress displayed in the experiment's
            progress bar. Can be an int or float between 0 and 100.
            Defaults to 'None', which means that the experiment-wide
            setting is used (see :attr:`.ExperimentSession.progress_bar`).
            Can be defined as a class attribute.
        {kwargs}
    """

    #: If *True*, the names of all elements added to this page will
    #: receive a prefix of the page's name.
    prefix_element_names: bool = False

    #: Can be used to manually determine to
    #: level of experiment progress displayed in the experiment's
    #: progress bar. Can be an int or float between 0 and 100.
    #: Defaults to 'None', which means that the experiment-wide
    #: setting is used (see :attr:`.ExperimentSession.progress_bar`).
    progress: int = None

    def __init__(
        self,
        prefix_element_names: bool = None,
        minimum_display_time: str = None,
        minimum_display_time_msg: str = None,
        progress: int | float = None,
        **kwargs,
    ):
        self._minimum_display_time = "0s"

        if minimum_display_time is not None:
            self.minimum_display_time = minimum_display_time

        if minimum_display_time_msg:
            self._minimum_display_time_msg = minimum_display_time_msg
        else:
            self._minimum_display_time_msg = None

        if prefix_element_names is not None:
            self.prefix_element_names = prefix_element_names

        if progress is not None:
            self.progress = progress

        self._data = {}
        self._is_closed = False
        self.show_times = []
        self.hide_times = []

        self.elements = {}

        self._element_name_counter = 1

        super().__init__(**kwargs)

    @property
    def elements(self):
        """
        Dictionary of elements belonging to this page.
        """
        return self._elements

    @elements.setter
    def elements(self, value):
        self._elements = value

    @property
    def minimum_display_time(self) -> str:
        """
        str: Minimal amount of time that a page must be displayed
        before participants can move forward.

        Must be specified as a string with a unit of 's' (for seconds),
        or 'm' (for minutes).
        """
        return self._minimum_display_time

    @property
    def _mdt(self) -> float:
        mdt = self.minimum_display_time
        unit = mdt[-1]
        if unit == "s":
            return float(mdt[:-1])
        elif unit == "m":
            return float(mdt[:-1]) * 60
        else:
            raise ValueError(
                "Please specify minimum display time with a unit ('s' - seconds, 'm' -"
                " minutes)."
            )

    @minimum_display_time.setter
    def minimum_display_time(self, value: str):
        self._minimum_display_time = value

    def added_to_experiment(self, experiment):
        # docstring inherited
        super().added_to_experiment(experiment)
        self.log.add_queue_logger(self, __name__)

        debug = self.experiment.config.getboolean("general", "debug")
        if debug and not self._mdt == 0:
            if self.experiment.config.getboolean(
                "debug", "disable_minimum_display_time"
            ):
                self.log.debug("Minimum display time disabled (debug mode).")
                self.minimum_display_time = "0s"

    @property
    def minimum_display_time_msg(self) -> str:
        """
        Message that is displayed if participants try to leave a page
        before the minimum display time is up.
        """
        msg = self._minimum_display_time_msg
        if msg is not None:
            return msg
        else:
            return self.experiment.config.get("hints", "minimum_display_time")

    @minimum_display_time_msg.setter
    def minimum_display_time_msg(self, value: str):
        self._minimum_display_time_msg = value

    @property
    def is_closed(self) -> bool:
        """
        Returns *True*, if the page is closed.
        """
        return self._is_closed

    @property
    def should_be_shown(self) -> bool:
        """
        bool: Boolean, indicating whether a page should be shown.

        Evaluates the page's own settings, as well as the status of all
        of its parent sections. The page is only shown, if all
        conditions evaluate to *True*.
        """
        thispage = super().should_be_shown
        sections = [sec.should_be_shown for sec in self.uptree()]
        return thispage and all(sections)

    @should_be_shown.setter
    def should_be_shown(self, value: bool):
        self._should_be_shown = bool(value)

    @property
    def must_be_shown(self) -> bool:
        """
        bool: False, if the experiment tolerates skipping this
        page entirely. Defaults to False.

        Notes:
            If there are input elements with *force_input=True* on a
            skippable page, particpants will be notified and validation
            will fail, even if the *must_be_shown* attribute of the page
            itself is set to False.

            If a page that must be shown has not been shown, the
            experiment will display the hint *page_must_be_shown* as defined
            in config.conf, section "hints".

            ..warning::
                It is easy to end up in a situation where
                a mandatory page has not been shown, but the participant has
                no way of moving back to it. So, please be careful when
                using this option.

        """
        return False

    @property
    def has_been_shown(self) -> bool:
        """
        Returns *True*, if the page has been displayed in the ongoing
        experiment session.
        """
        return self._has_been_shown

    def _on_showing_widget(self, show_time: float = None):
        """
        Method for internal processes on showing Widget

        Args:
            show_time: Time of showing in seconds since epoch.
        """
        if show_time is None:
            show_time = time.time()
        self.show_times.append(show_time)

        has_been_shown, self._has_been_shown = self._has_been_shown, True

        if not has_been_shown:
            self.on_first_show()

            debug_enabled = self.exp.config.getboolean("general", "debug")
            if debug_enabled and self is not self.exp.final_page:
                name = self.name + "__debug_jumplist__"
                jumplist = elm.action.JumpList(
                    scope="exp",
                    check_jumpto=False,
                    check_jumpfrom=False,
                    name=name,
                    debugmode=True,
                )
                jumplist.should_be_shown = False
                self += jumplist

        self.on_each_show()

        if self.exp.aborted:
            raise AbortMove

    def on_first_show(self):
        """
        Executed *once*, when the page is shown for the first time,
        *before* executing :meth:`~.Page.on_each_show`.

        This is your go-to-hook, if you want to have access to data
        from other pages within the experiment, and your code is meant
        to be executed only once (i.e. the first time a page is shown).

        See Also:
            See "How to use hooks" for a how to on using hooks and an overview
            of available hooks.

        """
        pass

    def on_each_show(self):
        """
        Executed *every time* the page is shown, *after* executing
        :meth:`~.Page.on_first_show`.

        See Also:
            See "How to use hooks" for a how to on using hooks and an overview
            of available hooks.

        """
        pass

    def _on_hiding_widget(self, hide_time: float = None):
        """
        Method for internal processes on hiding Widget

        Args:
            hide_time: Time of hiding in seconds since epoch.
        """
        if hide_time is None:
            hide_time = time.time()

        self.hide_times.append(hide_time)

        if not self._has_been_hidden:
            self.on_first_hide()

        self.on_each_hide()

        self._has_been_hidden = True

        self.save_data()

        if self.exp.aborted:
            raise AbortMove

    def on_first_hide(self):
        """
        Executed *once*, when the page is hidden for the first time,
        *before* executing :meth:`~.Page.on_each_hide`.

        Hook for code that is meant to be executed only once, when
        the page is hidden for the first time, **before** saving the
        page's data.

        Notes:
            Note the difference to :meth:`on_close`, which is
            executed upon final submission of the page's data. When using
            :meth:`on_first_hide`, subject input can change (e.g., when a
            subject revists a page and changes his/her input).

        See Also:
            See "How to use hooks" for a how to on using hooks and an overview
            of available hooks.

        """
        pass

    def on_each_hide(self):
        """
        Executed *every time* the page is hidden, *before* closing it
        and *before* saving data, but *after* executing
        :meth:`~.Page.on_first_hide`.

        See Also:
            See "How to use hooks" for a how to on using hooks and an overview
            of available hooks.

        """
        pass

    def on_close(self):
        """
        Executed *once*, when the page is closed, *before* data saving.

        This is your go-to-hook, if you want to have the page execute
        this code only once, when submitting the data from a page. After
        a page is closed, there can be no more changes to subject input.
        This is the most important difference of :meth:`on_close` from
        :meth:`on_first_hide` and :meth`on_each_hide`.

        See Also:
            See "How to use hooks" for a how to on using hooks and an overview
            of available hooks.

        """
        pass

    def on_exp_access(self):
        """
        Executed *once*, when the :class:`.ExperimentSession` becomes
        available to the page.

        This is your go-to-hook, if you want to have access to the
        experiment, but don't need access to data from other pages.

        See Also:
            See "How to use hooks" for a how to on using hooks and an overview
            of available hooks.

        """
        pass

    def close(self):
        """
        Closes the page. Usually, sections will take care of closing
        pages. Only call this method, if you cannot solve your problem
        by using a certain section.
        """
        self.on_close()
        self._is_closed = True

    def save_data(self, level: int = 1, sync: bool = False):
        """
        Saves current experiment data.

        Collects the current experimental data and calls the
        experiment's main SavingAgentController to save the data with
        all SavingAgents.

        Args:
            level: Level of the saving task. High level means high
                importance. If the level is below a SavingAgent's
                activation level, that agent will not be used for
                processing this task. Defaults to 1.
            sync: If True, the saving task will be prioritised and the
                experiment will pause until the task was fully completed.
                Should be used carefully. Defaults to False.

        Notes:
            Note that this function will be called automatically on every
            move in the experiment. Only call it yourself if it is
            really necessary.
        """
        if not self.exp.data_saver.main.agents and not self.exp.config.getboolean(
            "general", "debug"
        ):
            self.log.warning("No saving agents available.")
        data = self.experiment.data_manager.session_data
        self.exp.data_saver.main.save_with_all_agents(data=data, level=level, sync=sync)

    def prepare_web_widget(self):
        """
        Hook for computations for preparing a page for display.

        Gets executed *every time* before the page is displayed.
        """
        pass

    @property
    def _css_code(self):
        return []

    @property
    def _css_urls(self):
        return []

    @property
    def _js_code(self):
        return []

    @property
    def _js_urls(self):
        return []

    def _set_data(self, dictionary: dict):
        """
        Informs the page's input elements about user input.
        """
        pass


@inherit_kwargs
class _CoreCompositePage(_PageCore):
    """
    Second base class for pages.

    Args:
        {kwargs}
    """

    def __contains__(self, element):
        try:
            return element.name in self.elements
        except AttributeError:
            return element in self.elements

    # necessary to make __getattr__ work with copying a page object
    def __getstate__(self):
        return self.__dict__

    # necessary to make __getattr__ work with copying a page object
    def __setstate__(self, state):
        self.__dict__.update(state)

    def __getitem__(self, name):
        return self.elements[name]

    def __getattr__(self, name):
        try:
            return self.elements[name]
        except KeyError:
            raise AttributeError(f"{self} has no attribute '{name}'.")

    @property
    def input_elements(self) -> dict:
        """
        Dict of all input elements on this page.

        Does not evaluate whether an input element should be shown or
        not, because that might change over the course of an experiment.
        """

        input_elements = {}
        for name, el in self.elements.items():
            if isinstance(el, (elm.core.InputElement)):
                input_elements[name] = el
        return input_elements

    @property
    def all_input_elements(self) -> dict:
        """
        Alias for :attr:`.input_elements`.
        """
        return self.input_elements

    @property
    def all_elements(self) -> dict:
        """
        Alias for :attr:`.elements`.
        """
        return self.elements

    @property
    def updated_elements(self) -> dict:
        """
        Returns a dict of all elements on the page that already have
        access to the experiment session.
        """
        return {name: elm for name, elm in self.elements.items() if elm.exp is not None}

    @property
    def filled_input_elements(self) -> dict:
        """
        Dict of all input elements on this page with non-empty data
        attribute. This includes elements that hav received participant
        input and elements with default values.
        """

        return {name: el for name, el in self.input_elements.items() if el.input}

    def append(self, *elements):
        """
        Appends a variable number of elements to the page.

        In practice, it is recommended to use the augmented assignment
        operator ``+=`` instead in order to add elements.
        """
        for elmnt in elements:
            if not isinstance(elmnt, (elm.core.Element)):
                raise TypeError(
                    f"Can only append elements to pages, not '{type(elmnt).__name__}'"
                )

            elmnt.added_to_page(self)

            if elmnt.name in dir(self):
                raise ValueError(
                    f"Element name '{elmnt.name}' is also an attribute of {self}."
                )

            if elmnt.name in self.elements:
                raise AlfredError(
                    f"{self} already has an element of name '{elmnt.name}'."
                )

            if self.exp is not None and elmnt.exp is None:
                elmnt.added_to_experiment(self.exp)

            self.elements[elmnt.name] = elmnt

    def _generate_element_name(self, element):
        i = self._element_name_counter
        c = element.__class__.__name__
        self._element_name_counter += 1

        return f"{self.name}_{c}_{i}"

    def __iadd__(self, other):
        self.append(other)
        return self

    def added_to_experiment(self, experiment):
        # docstring inherited
        super().added_to_experiment(experiment)
        self.on_exp_access()
        self._update_elements()

    def added_to_section(self, section):
        # docstring inherited
        super().added_to_section(section)
        self._update_elements()

    def _update_members_recursively(self):
        self._update_elements()

    def _update_elements(self):
        if self.exp and self.section and self.tree.startswith("_root"):
            for element in self.elements.values():
                if not element.exp:
                    element.added_to_experiment(self.experiment)

    def close(self):
        # docstring inherited
        super().close()

        for elmnt in self.elements.values():
            if isinstance(elmnt, elm.core.InputElement):
                elmnt.disabled = True

        debug_jumplist = self.elements.get(self.name + "__debug_jumplist__")
        if debug_jumplist:
            for elmnt in debug_jumplist.elements:
                elmnt.disabled = False

    @property
    def data(self) -> dict:
        """
        Returns a dict of data for all input elements on the page.

        If the page has not been shown yet, an empty dict is returned.
        """

        if not self.has_been_shown:
            return {}
        else:
            data = {}
            for element in self.input_elements.values():
                data.update(element.data)
            return data

    @property
    def unlinked_data(self) -> dict:
        """
        Returns an empty dict for 'normal' pages and the input data
        for unlinked pages.
        """
        return {}

    def _set_data(self, dictionary: dict):
        for elmnt in self.input_elements.values():
            elmnt.set_data(dictionary)

    def custom_move(self):
        """
        Hook for defining a page's own movement behavior, executed
        *every time* a movement *from* the page takes place,
        *before* :meth:`~.Page.on_first_hide` and :meth:`~.Page.on_each_hide`.

        User input to the elements on the current page is available in
        this method through the page's :attr:`.Page.data` attribute.

        Use the :class:`.ExperimentSession` s movement methods to define
        your own behavior. The available methods are

        .. autosummary::
           :nosignatures:

           ~alfred3.experiment.ExperimentSession.forward
           ~alfred3.experiment.ExperimentSession.backward
           ~alfred3.experiment.ExperimentSession.jump

        Notes:
            You can fall back to alfred3's movement system by returning
            *True* from your custom move function.

        Examples:

            Create a page that always jumps to a specific page upon
            submission::

                exp = al.Experiment()


                @exp.member
                class CustomMove(al.Page):
                    name = "custom_move"

                    def custom_move(self):
                        self.exp.jump(to="third")


                exp += al.Page(name="second")
                exp += al.Page(name="third")


            Create a page that jumps to a specific page, if it received
            a user input of 'yes', and use alfred3's usual movement
            system otherwise::

                exp = al.Experiment()

                @exp.member
                class CustomMove(al.Page):

                    def on_exp_access(self):
                        self += elm.TextEntry(name="text")

                    def custom_move(self):
                        if self.data.get("text) == "yes":
                            self.exp.jump(to="third")
                        else:
                            return True

                exp += al.Page(name="second")
                exp += al.Page(name="third")

        """
        return True

    def durations(self) -> Iterator[float]:
        """
        Iterates over the visit durations for this page.

        Yields:
            float: Duration of a visit in seconds.

        """

        if not self.has_been_shown:
            return 0

        if len(self.show_times) > len(self.hide_times):
            now = time.time()
        elif len(self.show_times) < len(self.hide_times):
            self.log.error(
                f"{self} has fewer entries in show_times than in hide_times."
            )

        for show, hide in zip(self.show_times, self.hide_times + [now]):
            yield hide - show

    def last_duration(self) -> float:
        """
        Returns the duration of the last visit to this page in the
        current session in seconds.
        """
        *_, last_duration = self.durations()
        return last_duration

    def first_duration(self) -> float:
        """
        Returns the duration of the last visit to this page in the
        current session in seconds.
        """
        first_duration, *_ = self.durations()
        return first_duration

    def validate(self) -> bool:
        """
        Returns *True*, if the validation checks pass, *False* otherwise.

        Can be overloaded for custom validation behavior.

        Returns:
            bool: A boolean, indicating whether validation was successful.

        Notes:
            The method *must* return *True* if validation is OK and
            *False* if validation fails. You can use
            :meth:`.ExperimentSession.post_message` to post a message for
            participants that will be displayed if validation fails.

        Examples:
            ::

                import alfred3 as al

                exp = al.Experiment()


                @exp.member
                class Demo(al.Page):
                    def on_exp_access(self):
                        self += al.NumberEntry(name="e1", force_input=True)
                        self += al.NumberEntry(name="e2", force_input=True)

                    def validate(self):
                        e1 = int(self.exp.values.get("e1"))
                        e2 = int(self.exp.values.get("e2"))

                        if not (e1 + e2) > 10:
                            self.exp.post_message(
                                msg="The sum of your input values must be >10",
                                level="danger",
                            )
                            return False

                        return True


        """
        return True

    def _validate(self):
        if not self.has_been_shown:
            if self.must_be_shown:
                msg = self.exp.config.get("hints", "page_must_be_shown")
                self.exp.post_message(msg, level="danger")
                return False
            else:
                return True

        # check minimum display time
        mintime = self._mdt
        if time.time() - self.show_times[0] < mintime:
            msg = self.minimum_display_time_msg.format(mdt=self.minimum_display_time)
            self.exp.message_manager.post_message(msg)
            return False

        if not self.validate():
            return False

        return True

    def _validate_elements(self):
        return all([el.validate_data() for el in self.input_elements.values()])


@inherit_kwargs
class Page(_CoreCompositePage):
    """
    The basic page.

    Args:
        fixed_width (str): Custom value for defining a fixed width of
            the page. Only takes effect, if the experiment is set to
            generally operate with a fixed page width in *config.conf*
            (option *responsive* in section *layout* must be "false").
            Must be a string including a unit, e.g. "900px". Can be
            defined as a class attribute.

        responsive_width (str): Custom values for definig the width of
            the page in percent of the screen width. Only takes
            effect, if the option *responsive* in section *layout* is
            "true" (which is the default). Must be a single string with
            1 to 4 relative widths separated by commas, e.g. "60%, 50%".
            The first value refers to small ('sm') screens, the
            following values to the next bigger ones. Extra small screens
            like mobile phones will always get 100% width. If the string
            contains less than four values, the last value will be used
            for all screens from that size on upward.

            The sizes are taken from Bootstrap and correspond to the
            five width attributes of a :class:`.RowLayout`.

            By default, responsive widths are

            - 100% on extra small screens (below 576px, not modifiable)
            - 85% on small screens (576-767px, think of tablets)
            - 75% on medium screens (768-991px, think of small laptops)
            - 65% on large screens (992-1199px)
            - 55% on extra large screens (1200px and larger)

            Can be defined as a class attribute.

        header_color (str): A color to be used for the header
            of this page. Can be any color value understood by CSS,
            including hex and RGB. Can be defined as a class
            attribute.

        background_color (str): A color to be used for the background of
            this page. Can be any color value understood by CSS,
            including hex and RGB. Can be defined as a class
            attribute.

        footer_text (str): Page-specific footer text. Replaces the ``footer_text``
            defined in ``config.conf``. Can be defined as a class
            attribute.

        {kwargs}

    Notes:

        .. note:: In class style, you can use the initialization
           arguments of a page by defining them as class attributes.
           See "How to use pages" for details.

    Examples:

        Basic example for adding a page with a single element in
        class style::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class DemoPage(al.Page):
                title = "This is a demo"

                def on_exp_access(self):
                    self += al.Text("This is demo text.")


        Example for customly defining a set of relative widths. The
        following page will take up 80 % of available space on extra
        small screens, 70 % on small screens, and 60 % on medium, large
        and extra large screens::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class Demo(al.Page):
                responsive_width = "80%, 70%, 60%"

    """

    def __init__(
        self,
        title: str = None,
        name: str = None,
        fixed_width: str = None,
        responsive_width: str = None,
        header_color: str = None,
        background_color: str = None,
        footer_text: str = None,
        *args,
        **kwargs,
    ):
        super().__init__(title=title, name=name, *args, **kwargs)

        self._fixed_width = None
        if fixed_width:
            self.fixed_width = fixed_width

        self._responsive_width = None
        if responsive_width:
            self.responsive_width = responsive_width

        self._header_color = None
        if header_color:
            self.header_color = header_color

        self._background_color = None
        if background_color:
            self.background_color = background_color

        self._footer_text = None
        if footer_text:
            self.footer_text = footer_text

    @property
    def fixed_width(self) -> str:
        """
        Custom value for defining a fixed width of
        the page. Only takes effect, if the experiment is set to
        generally operate with a fixed page width in *config.conf*
        (option *responsive* in section *layout* must be "false").
        Must be a string including a unit, e.g. "900px". Can be
        defined as a class attribute.
        """
        return self._fixed_width

    @fixed_width.setter
    def fixed_width(self, value):
        self._fixed_width = value

    @property
    def footer_text(self) -> str:
        """Page-specific footer text."""
        return self._footer_text

    @footer_text.setter
    def footer_text(self, value):
        self._footer_text = value

    @property
    def responsive_width(self):
        """
        Custom values for definig the width of
        the page in percent of the screen width. Only takes
        effect, if the option *responsive* in section *layout* is
        "true" (which is the default). Must be a single string with
        1 to 5 relative widths separated by commas, e.g. "60%, 50%".
        The first value refers to extra small ('xs') screens, the
        following values to the next bigger ones. If the string
        contains less than five values, the last value will be used
        for all screens from that size on upward.

        The sizes are taken from Bootstrap and correspond to the
        five width attributes of a :class:`.RowLayout`.

        Can be defined as a class attribute.
        """
        return self._responsive_width

    @responsive_width.setter
    def responsive_width(self, value):
        self._responsive_width = value

    @property
    def header_color(self):
        """
        A color to be used for the header
        of this page. Can be any color value understood by CSS,
        including hex and RGB. Can be defined as a class
        attribute.
        """
        return self._header_color

    @header_color.setter
    def header_color(self, value):
        self._header_color = value

    @property
    def background_color(self):
        """
        A color to be used for the background of
        this page. Can be any color value understood by CSS,
        including hex and RGB. Can be defined as a class
        attribute.
        """
        return self._background_color

    @background_color.setter
    def background_color(self, value):
        self._background_color = value

    def _parse_responsive_width(self, width):
        return [x.strip().replace("%", "") for x in width.split(",")]

    def _responsive_media_query(self, width):
        if len(width) > 4:
            raise ValueError(
                "The option 'responsive_width' can only define up to four widths."
            )

        if len(width) < 4:
            for _ in range(4 - len(width)):
                width.append(width[-1])

        screen_size = [576, 768, 992, 1200]
        t = string.Template(
            "@media (min-width: ${screen}px) {.responsive-width { width: ${w}%;"
            " max-width: none;}}"
        )
        out = []
        for i, w in enumerate(width):
            out.append(t.substitute(screen=screen_size[i], w=w))
        return " ".join(out)

    def prepare_web_widget(self):
        # docstring inherited
        for elmnt in self.elements.values():
            elmnt._prepare_web_widget()

    def added_to_experiment(self, experiment):
        # docstring inherited
        super().added_to_experiment(experiment)
        self._set_width()
        self._set_color()

    def _set_width(self):
        if self.experiment.config.getboolean("layout", "responsive"):
            if self.responsive_width:
                w = self._parse_responsive_width(self.responsive_width)
                self += Style(code=self._responsive_media_query(w))

            elif self.experiment.config.get("layout", "responsive_width"):
                config_width = self.experiment.config.get("layout", "responsive_width")
                w = self._parse_responsive_width(config_width)
                self += Style(code=self._responsive_media_query(w))

        elif not self.fixed_width:
            w = self.experiment.config.get("layout", "fixed_width")
            self += Style(code=f".fixed-width {{ width: {w}; }}")
            self += Style(code=f".min-width {{ min-width: {w}; }}")

        else:
            self += Style(code=f".fixed-width {{ width: {self.fixed_width}; }}")
            self += Style(code=f".min-width {{ min-width: {self.fixed_width}; }}")

    def _set_color(self):
        if self.header_color:
            self += Style(code=f".logo-bg {{background-color: {self.header_color};}}")

        elif self.experiment.config.get("layout", "header_color", fallback=False):
            c = self.experiment.config.get("layout", "header_color", fallback=False)
            self += Style(code=f".logo-bg {{background-color: {c};}}")

        if self.background_color:
            self += Style(code=f"body {{background-color: {self.background_color};}}")

        elif self.experiment.config.get("layout", "background_color", fallback=False):
            c = self.experiment.config.get("layout", "background_color", fallback=False)
            self += Style(code=f"body {{background-color: {c};}}")

    @property
    def _css_code(self):
        return reduce(
            lambda lll, element: lll + element.css_code, self.elements.values(), []
        )

    @property
    def _css_urls(self):
        return reduce(
            lambda lll, element: lll + element.css_urls, self.elements.values(), []
        )

    @property
    def _js_code(self):
        return reduce(
            lambda lll, element: lll + element.js_code, self.elements.values(), []
        )

    @property
    def _js_urls(self):
        return reduce(
            lambda lll, element: lll + element.js_urls, self.elements.values(), []
        )


@inherit_kwargs
class WidePage(Page):
    """
    A page with a wider default width.

    Args:
        {kwargs}

    Notes:
        The width of this page is only customized for responsive layouts.
        If you disabled responsive layout, it will have default width.

    Examples:
        A minimal experiment with a single wide page, holding a text
        element::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class DemoPage(al.WidePage):
                title = "This is a demo"

                def on_exp_access(self):
                    self += al.Text("This is demo text.")

    """

    responsive_width = "85%, 75%, 75%, 70%"


@inherit_kwargs
class BlankPage(Page):
    """
    A page with all default layout elements removed.

    Args:
        {kwargs}

    Examples:
        A minimal experiment with a single BlankPage::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class DemoPage(al.BlankPage):
                def on_exp_access(self):
                    self += al.SubmittingButtons(
                        "Yes",
                        "No",
                        toplab="Do you agree to this statement?",
                        name="submit1",
                    )
    """

    def added_to_experiment(self, experiment):
        super().added_to_experiment(experiment)
        self += Style("#page-navigation {display: none;}")
        self += Style("#logo-container {visibility: hidden;}")
        self += Style("#footer-container {visibility: hidden;}")
        self += Style("#progress_bar_ {visibility: hidden;}")


@inherit_kwargs
class NoNavigationPage(Page):
    """
    A page without navigation buttons.

    Args:
        {kwargs}

    Examples:
        A minimal experiment with a single NoNavigationPage, from which
        participants can move forward via the SubmittingButtons::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class DemoPage(al.NoNavigationPage):
                title = "This is a demo"

                def on_exp_access(self):
                    self += al.SubmittingButtons(
                        "Yes",
                        "No",
                        toplab="Do you agree to this statement?",
                        name="submit1",
                    )
    """

    def added_to_experiment(self, experiment):
        super().added_to_experiment(experiment)
        self += Style("#page-navigation {display: none;}")


@inherit_kwargs
class TimeoutPage(Page):
    """
    A page with an additional hook that gets triggered after a timeout.

    The timeout starts when the page is shown for the first time. If a
    page is shown for a second time, the timeout will not run again.

    Args:
        timeout (str, int): Length of the timeout. Specify it as an integer,
            indicating the number of seconds to wait, or as a string
            with a unit of "s" for seconds, and "m" for minutes,
            for example "10s". Can be specified as a class attribute.
        callbackargs (dict): Dictionary of keyword arguments to be
            passed on to the :class:`.Callback` element that internally
            handles the call to the *on_timeout* method. You can specifiy
            things like *followup* behavior and determine whether data
            on the page should be submitted before calling *on_timeout*.
            Refer to the :class:`.Callback` documentation for a full
            view of the possibilities. You cannot specify the *func*
            argument in *callbackargs*, since this is fixed to
            :meth:`.on_timeout`. You can also not specify the *delay*
            argument, since this is fixed to the page's own argument
            *timeout*. Defaults to *None*. Can be specified
            as a class attribute.

        {kwargs}

    See Also:
        Timeout hook: :meth:`~.Page.on_timeout`

    Examples:
        A minimal experiment with a single TimeoutPage, holding a text
        element. The navigating buttons are hidden through the
        :class:`.HideNavigation` element.

        The *on_timeout* hook shows the implementation of the
        :class:`.AutoForwardPage`. The page will move forward after the
        timeout of 5 seconds expires::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class DemoPage(al.TimeoutPage):
                title = "This is a demo"
                timeout = "5s"

                def on_exp_access(self):
                    self += al.Text("This is demo text.")
                    self += al.HideNavigation()

                def on_timeout(self):
                    self.exp.forward()
    """

    timeout = None
    callbackargs = None

    def __init__(self, timeout: str | int = None, callbackargs: dict = None, **kwargs):
        super().__init__(**kwargs)

        if callbackargs is not None:
            self.callbackargs = callbackargs
        elif self.callbackargs is None:
            self.callbackargs = {}

        if timeout is not None:
            self.timeout = timeout

        if self.timeout is None:
            raise AlfredError("A TimeoutPage must have a 'timeout' attribute.")

        if not isinstance(self.timeout, int):
            self.timeout = self._format_timeout()

        self += elm.misc.Callback(
            func=self.on_timeout, delay=self.timeout, **self.callbackargs
        )

    def _format_timeout(self) -> int:
        unit = self.timeout[-1]
        if unit == "s":
            timeout = int(self.timeout[:-1])
        elif unit == "m":
            timeout = int(self.timeout[:-1]) * 60
        else:
            raise ValueError(
                "You must specify the unit of your timeout ('s' - seconds, or 'm' -"
                " minutes)"
            )
        return timeout

    def on_timeout(self):
        """
        Executed *once*, after the timeout of the page runs out.

        See Also:
            See "How to use hooks" for a how to on using hooks and an overview
            of available hooks.

            This hook is defined by :class:`.AutoForwardPage` and
            :class:`.AutoClosePage`.
        """
        pass


@inherit_kwargs
class AutoForwardPage(TimeoutPage):
    """
    A page that automatically moves forward after the timeout expires.

    Args:
        {kwargs}

    Notes:
        This page will work with customly defined :meth:`.custom_move`
        methods.

    Examples:
        ::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class DemoPage(al.AutoForwardPage):
                timeout = "5s"

                def on_exp_access(self):
                    self += al.Text("This page will move after 5 seconds.")

    """

    callbackargs = {"followup": "forward"}


@inherit_kwargs
class AutoClosePage(TimeoutPage):
    """
    A page that automatically closes itself after the timeout expires.

    Args:
        {kwargs}

    Examples:
        ::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class DemoPage(al.AutoClosePage):
                timeout = "5s"

                def on_exp_access(self):
                    self += al.Text("This page will close after 5 seconds.")

    """

    def on_timeout(self):
        self.close()


@inherit_kwargs
class NoDataPage(Page):
    """
    A page that does not collect any data.

    Args:
        {kwargs}

    Notes:
        This page *will* still trigger a saving event upon moving,
        so that, for example, updates to the :attr:`.ExperimentSession.adata`
        dictionary will be saved. It just does not save any data itself,
        i.e. input elements on this page will not appear in the data.

    See Also:
        See :class:`.NoSavingPage` for a page that does not trigger a
        saving event, but still collects data.

    Examples:

        A simple NoDataPage::

            import alfred3 as al

            exp = al.Experiment()


            @exp.meber
            class DemoPage1(al.NoDataPage):
                title = "My demo page"

                def on_exp_access(self):
                    self += al.TextEntry(placeholder="Enter something", name="el1")
                    self += al.Alert(
                        text="Note: input to this element WILL NOT BE SAVED!",
                        category="danger",
                    )

    """

    data = {}


@inherit_kwargs
class NoSavingPage(Page):
    """
    A page that does not trigger a saving event on moving.

    Args:
        {kwargs}

    Notes:
        This page will not trigger any saving action on its own. But if
        experiment data is saved at another point of the experiment, data
        collected on a NoSavingPage *will* be saved. You can switch off
        this behavior by overriding the *data* attribute, effectively
        creating a NoDataNoSavingPage (see Example 2).

        Note also that upon finishing, the experiment will always trigger
        a saving event, even if you only have NoSavingPages in your
        experiment. To prevent this, you can switch the option
        'save_data' in section 'data' of config.conf to 'false'.

    .. warning:: Note that a NoSavingPage does not trigger a saving
        event, but it does collect data! If a later page triggers a
        saving event, data from the NoSavingPage will be saved to the
        experiment data! To prevent this behavior, use a :class:`.NoDataPage`
        instead of NoSavingPage.

    See Also:
        See :class:`.NoDataPage` for a page that does not collect any
        data.

    Examples:

        Example 1, simple usage::

            import alfred3 as al

            exp = al.Experiment()


            @exp.meber
            class DemoPage1(al.NoSavingPage):
                title = "My demo page"

                def on_exp_access(self):
                    self += al.TextEntry(placeholder="Enter something", name="el1")


            @exp.member
            class DemoPage2(al.Page):
                title = "My second demo page"

                def on_first_show(self):
                    user_input = self.exp.values.get("el1")
                    self += al.Text(
                        text=f"You input on the previous page: {{user_input}}"
                    )

                    self += al.Text(
                        text=(
                            "Note that a 'NoSavingPage' does not trigger a saving "
                            "event, but it does collect data! If a later page "
                            "triggers a "
                            "saving event, data from the 'NoSavingPage' will be "
                            "saved to "
                            "the experiment data! To prevent this behavior, use a "
                            "'NoDataPage' instead of 'NoSavingPage'."
                        )
                    )


        Example 2, a NoSavingPage that also does not collect any data::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class NoDataNoSavingPage(al.NoSavingPage):
                data = {{}}

    """

    def save_data(self, *args, **kwargs):
        """
        This page type does not save data on its own, so this method has
        no effect.
        """
        pass


@inherit_kwargs
class UnlinkedDataPage(NoDataPage):
    """
    A page that saves data separately from the experiment data.

    Args:
        encrypt (str): Takes one the following values: 'agent' (default) will
            encrypt data based on each saving agent's configuration.
            'always' will encrypt all data entered on this page,
            regardless of saving agent configuration. 'never' will turn
            off encryption for this page, regardless of saving agent
            configuration. Can be specified as a class attribute.
        {kwargs}

    Unlinked data is data that does not contain any identifiers that
    would allow someone to establish a connection between an
    experiment data set and the unlinked data set.
    A common use case is the sepration of identifying personal
    information that might be needed for non-experimental purposes such
    as compensation admninistration, from experiment data.

    In practice that means that the UnlinkedDataPage does not save any
    of the following:

    - Time of saving
    - Session ID
    - Experiment condition
    - Additional data
    - Start time
    - Experiment version
    - Alfred version

    It will, however, save the following information:

    - Experiment Title
    - Experiment ID
    - Experiment Author
    - Page tag
    - Page ID

    Thus, the saved data *can* be linked to an *experiment* and to a
    page. That is intended and indeed necessary so that data
    can be retrieved and processed. The key point is that there is no
    identifier for linking data to data from a specific experimental
    *session* (i.e. the name of a subject, saved with an
    UnlinkedDataPage cannot be linked to his/her answers given on other
    Pages).

    .. warning::
        All data from UnlinkedDataPages is saved in a single unlinked
        data document, so data from two different unlinked pages *are*
        linked to each other (though not to the rest of the experiment
        data).

    """

    encrypt: str = "agent"

    def __init__(self, encrypt: str = None, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if encrypt is not None:
            self.encrypt = encrypt

        if self.encrypt not in ["agent", "always", "never"]:
            raise ValueError(
                "The argument 'encrypt' must take one of the following values: 'agent',"
                " 'always', 'never'."
            )

    @property
    def unlinked_data(self) -> dict:
        # docstring inherited
        if not self.has_been_shown:
            return {}
        else:
            data = {}
            for element in self.input_elements.values():
                data.update(element.data)
            return data

    def save_data(self, level: int = 1, sync: bool = False):
        """
        Saves current unlinked data.

        Collects the unlinked data from all UnlinkedDataPages in the
        experiment and engages the experiment's unlinked
        SavingAgentController to save the data with all SavingAgents.

        Args:
            level: Level of the saving task. High level means high
                importance. If the level is below a SavingAgent's
                activation level, that agent will not be used for
                processing this task. Defaults to 1.
            sync: If True, the saving task will be prioritised and the
                experiment will pause until the task was fully completed.
                Should be used carefully. Defaults to False.
        """
        unlinked_agents = self._experiment.data_saver.unlinked.agents
        debug_enabled = self._experiment.config.getboolean("general", "debug")
        if not unlinked_agents and not debug_enabled:
            self.log.warning("No saving agent for unlinked data available.")

        for agent in self._experiment.data_saver.unlinked.agents.values():
            if self.encrypt == "agent":
                data = self.experiment.data_manager.unlinked_data_with(agent)
            elif self.encrypt == "always":
                data = self.experiment.data_manager.unlinked_data
                data = self.experiment.data_manager.encrypt_values(data)
            elif self.encrypt == "never":
                data = self.experiment.data_manager.unlinked_data

            self.exp.data_saver.unlinked.save_with_agent(
                data=data, name=agent.name, level=level, sync=sync
            )


class _CustomSavingPage(Page, ABC):
    """
    TODO: This class still needs some work.

    Allows you to add custom SavingAgents directly to the page.

    Args:
        save_to_main: If True, data will be *also* saved using the
            experiment's main SavingAgentController and all of its
            SavingAgents. Defaults to False. Can be specified as a
            class attribute.

    Notes:
        Since this is an abstract class, it can not be instantiated directly.
        You have to derive a child class and define the property
        :meth:`custom_save_data`, which must return a dictionary. Through
        this property, you control exactly which data will be saved by this
        page.

        .. warning::
            Each SavingAgent maintains one file or one document.
            On saving, the document will be fully replaced with the current
            data. That means, you should not let two CustomSavingPages
            share a SavingAgent, as they will override each other's data.
            That is, unless that is your intended behavior, e.g. when the
            pages share data.

    Examples:
        Example 1: Saving ordinary page data (like other pages)::

            class MyPage(CustomSavingPage):
                @property
                def custom_save_data(self):
                    return self.data


        Example 2: Saving a static dictionary::

            class MyPage(CustomSavingPage):
                @property
                def custom_save_data(self):
                    return {"key": "value"}
    """

    save_to_main: bool = False

    def __init__(self, save_to_main: bool = None, **kwargs):
        super().__init__(**kwargs)

        if save_to_main is not None:
            self.save_to_main = save_to_main

    def added_to_experiment(self, experiment):
        if not self._experiment:
            super().added_to_experiment(experiment=experiment)
            self.saving_agent_controller = saving_agent.SavingAgentController(
                self._experiment
            )
        self._check_for_duplicate_agents()

    def _check_for_duplicate_agents(self):
        comparison = []
        comparison += list(self._experiment.data_saver.main.agents.values())
        comparison += list(self._experiment.data_saver.unlinked.agents.values())

        for pg in self._experiment.root_section.all_pages.values():
            if pg == self:
                continue
            try:
                comparison += list(pg.saving_agent_controller.agents.values())
            except AttributeError:
                pass

        for agent in self.saving_agent_controller.agents.values():
            self._check_one_duplicate(agent, comparison)

    @staticmethod
    def _check_one_duplicate(agent, agents_list):
        for ag in agents_list:
            if ag == agent:
                raise ValueError(
                    "A SavingAgent added to a CustomSavingPage must be unique"
                )

    def append_saving_agents(self, *args):
        """
        Appends saving agents to this page.

        These saving agents will be used to save the page's data.
        """
        for agent in args:
            self.saving_agent_controller.append(agent)
        self._check_for_duplicate_agents()

    def append_failure_saving_agents(self, *args):
        for agent in args:
            self.saving_agent_controller.append_failure_agent(agent)

    @abstractproperty
    def custom_save_data(self):
        pass

    def save_data(self, level=1, sync=False):
        if not isinstance(self.custom_save_data, dict):
            raise ValueError(
                "The porperty 'custom_page_data' must return a dictionary."
            )

        if self.save_to_main:
            self._experiment.data_saver.main.save_with_all_agents(
                level=level, sync=sync
            )

        self.saving_agent_controller.save_with_all_agents(
            data=self.custom_save_data, level=level, sync=sync
        )


class _DefaultFinalPage(Page):
    """
    The default final page.
    """

    def on_exp_access(self):
        self.title = self.exp.config.get("hints", "final_page_title")
        txt = self.exp.config.get("hints", "final_page_text")
        self += elm.display.Text(":mortar_board:", font_size=70, align="center")
        self += elm.display.VerticalSpace("20px")
        self += elm.display.Text(text=txt, align="center")
        self += elm.misc.WebExitEnabler()


class _NothingHerePage(Page):
    title = "There's nothing here"

    def on_exp_access(self):
        self += elm.display.VerticalSpace("30px")
        self += elm.display.Text(":heavy_minus_sign:", font_size=70, align="center")
        self += elm.misc.WebExitEnabler()


@inherit_kwargs
class PasswordPage(WidePage):
    """
    A wide page that requires the input of a password for continuation.

    Args:
        password: A string, or a tuple of strings. Those are the accepted
            passwords.
        {kwargs}

    Examples:

        A minimal experiment with a password page::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class Demo(al.PasswordPage):
                password = "test"

    """

    password = None
    title = "Password required"

    def __init__(self, password: str | tuple[str] = None, **kwargs):
        super().__init__(**kwargs)
        if password is not None:
            self.password = password
        if self.password is None or not len(self.password):
            raise ValueError("PasswordPage must have at least one password.")

        if not isinstance(self.password, (str, list, tuple)):
            raise ValueError(
                f"Argument 'password' of {type(self).__name__} must be a string, list,"
                " or tuple."
            )

    def on_exp_access(self):
        self += HideNavigation()
        self += elm.display.Text(":closed_lock_with_key:", font_size=70, align="center")
        self += elm.display.VerticalSpace("20px")

        # use single password or multiple password element depending on input
        pwargs = dict(
            toplab="Please enter the password to continue",
            width="wide",
            name="pw",
            align="center",
            force_input=True,
        )
        if isinstance(self.password, str):
            self += elm.input.PasswordEntry(password=self.password, **pwargs)
        else:
            self += elm.input.MultiplePasswordEntry(passwords=self.password, **pwargs)

        self += elm.action.SubmittingButtons(
            "Submit",
            align="center",
            name="pw_submit",
            width="narrow",
            button_style="btn-primary",
        )

        # enables submit via enter-press for password field
        self += elm.misc.JavaScript(
            code="""$('#pw').on("keydown", function(event) {
        if (event.key == "Enter") {
            $("#alt-submit").attr("name", "move");
            $("#alt-submit").val("forward");
            $("#form").submit();
            }});"""
        )
