"""
This module contains the main experiment and experiment session objects.

**What's the difference between the Experiment and the
ExperimentSession?**

* The Experiment class is the first thing you create in your *script.py*.
  Its purpose is simply to collect all pages and functions that you
  add to the experiment. For this purpose it offers decorators like
  *setup* and *member*.
  From this collection, it creates a new ExperimentSession for every new
  subject.

* The ExperimentSession class is the main experiment organizer. This is
  the class that actually coordinates all the work in an ongoing
  experiment. When you interact with a Section's or a Page's *exp*
  attribute, you are actually interacting with the current
  *ExperimentSession* object.

.. moduleauthor:: Johannes Brachem <jbrachem@posteo.de>
"""

import functools
import logging
import os
import smtplib
import time
from collections.abc import Iterator
from email.message import EmailMessage
from email.utils import formataddr
from inspect import isclass
from pathlib import Path
from uuid import uuid4

import yaml
from cryptography.fernet import Fernet

from . import element as elm
from . import messages, page, util
from ._helper import _DictObj
from ._version import __version__
from .alfredlog import QueuedLoggingInterface
from .config import ExperimentConfig, ExperimentSecrets
from .data_manager import DataManager
from .exceptions import AbortMove, AlfredError
from .export import Exporter
from .page import Page, _NothingHerePage
from .saving_agent import DataSaver, MongoSavingAgent
from .section import Section, _AbortSection, _RootSection
from .ui_controller import MovementManager, UserInterface


class Experiment:
    """
    Used to construct the experiment in script.py.

    Experiment members can be added with the augmented assigment
    operator ``+=`` (see exampples). They can be accessed with the
    dictionary-style square bracket syntac and with the attribute-style
    dot syntax (see examples).

    The class acts as an organizing container for pages and section for
    orderly instantiation of the :class:`.ExperimentSession`.

    Examples:

        Create an experiment and add simple page in object oriented
        style::

            import alfred3 as al

            exp = al.Experiment()


            @exp.member
            class HelloWorld(al.Page):
                def on_exp_access(self):
                    self += al.TextEntry(toplab="Enter something", name="text1")

        Access a page with square bracket syntax.

        >>> exp["page1"]
        Page(class="Page", name="page1")

        Access a page with attribute syntax

        >>> exp.page1
        Page(class="Page", name="page1")


    """

    def __init__(self, session_timeout: int = 60 * 60 * 24):
        self.session_timeout = session_timeout
        self._final_page = None

        #: A dictionary of all pages and sections added to the experiment.
        self.members: dict = {}

        #: A dictionary of all pages and sections that are direct children
        #: of the main _content section
        self._root_members: dict = {}

        self._admin = None

        #: A list of function that will be called upon creation of an
        #: experiment session. They are added with the :meth:`.setup`
        #: decorator
        self.setup_functions: list[callable] = []

        #: A list of function that will be called upon finishing an
        #: experiment session. They are added with the :meth:`.finish`
        #: decorator
        self.finish_functions: list[callable] = []

        #: A list of function that will be called upon aborting an
        #: experiment session. They are added with the :meth:`.abort`
        #: decorator
        self.abort_functions: list[callable] = []

    def setup(self, func):
        """
        Decorator for functions that work on the experiment session
        directly after intialization.

        The decorated function can have an arbitrary name. It *must*
        take an :class:`.ExperimentSession` object as its only argument
        (usually spelled as *exp*). You can use this decorator on as
        many function as you like.

        The purpose of this decorator is to allow manipulation of the
        :class:`.ExperimentSession` object generated by
        :class:`.Experiment`.

        See Also:
            This decorator basically works the same as :meth:`.abort`
            and :meth:`.finish`, just at a different time.

        Examples:

            In this example, we use the the ``@exp.setup`` decorator to
            add a plugin to the experiment session's plugin dictionary and
            access that same plugin later in a page hook::

                import alfred3 as al

                exp = al.Experiment()


                @exp.setup
                def setup(exp):
                    exp.plugins["a"] = "mock plugin"


                @exp.member
                class HelloWorld(al.Page):
                    name = "hello_world"

                    def on_exp_access(self):
                        print(self.exp.plugins["a"])

        """

        @functools.wraps(func)
        def wrapper():
            self.setup_functions.append(func)
            return func

        return wrapper()

    def abort(self, func):
        """
        Decorator for functions that work on the experiment session
        directly upon abortion.

        The decorated function can have an arbitrary name. It *must*
        take an :class:`.ExperimentSession` object as its only argument
        (usually spelled as *exp*). You can use this decorator on as
        many function as you like.

        The purpose of this decorator is to allow manipulation of the
        :class:`.ExperimentSession` object generated by
        :class:`.Experiment`.

        See Also:
            This decorator basically works the same as :meth:`.setup`
            and :meth:`.finish`, just at a different time.

        """

        @functools.wraps(func)
        def wrapper():
            self.abort_functions.append(func)
            return func

        return wrapper()

    def finish(self, func):
        """
        Decorator for code to be run upon :meth:`ExperimentSession.finish`.

        The decorated function can have an arbitrary name. It *must*
        take an :class:`.ExperimentSession` object as its only argument
        (usually spelled as *exp*). You can use this decorator on as
        many functions as you like.

        The purpose of this decorator is to allow manipulation of the
        :class:`.ExperimentSession` object generated by
        :class:`.Experiment` a last time before the final page is shown.
        The decorated functions are the first things to be called in the
        :meth:`ExperimentSession.finish` method.

        A common usecase would be to conditionally assign the
        experiment's final page based on subject input during the
        experiment.

        See Also:
            This decorator basically works the same as :meth:`.setup`
            and :meth:`.abort`, just at a different time.

        Examples:

            Using the ``@exp.finish`` decorator for conditionally
            changing the final page::

                import alfred3 as al
                exp = al.Experiment()

                @exp.member
                class Demo(al.Page):
                    name = "demo1"

                @exp.finish
                def set_final_page(exp):  # the decorated function can have any name

                    if exp.values["text1"] == "value":
                       exp.final_page = al.Page("Option A", name="final_page_a")

                   else:
                       exp.final_page = al.Page("Option B", name="final_page_b")

        """

        @functools.wraps(func)
        def wrapper():
            self.finish_functions.append(func)
            return func

        return wrapper

    def member(
        self, _member=None, *, of_section: str = "_content", admin: bool = False
    ):
        """
        Decorator for adding pages and sections to the experiment.

        Works both with and without arguments.

        Args:
            of_section (str): Name of the section to which the new member
                belongs.
            admin (bool): If *True*, the new member will be added to the
                admin mode instead of the normal experiment.

        Examples:

            Adding a page directly to the main content section::

                import alfred3 as al

                exp = al.Experiment()


                @exp.member
                class HelloWorld(al.Page):
                    name = "hello_world"

                    def on_exp_access(self):
                        self += al.Text("This is a 'hello, world!' Page.")

            Adding a page to a specific section::

                import alfred3 as al

                exp = al.Experiment()

                exp += al.Section(name="main")


                @exp.member(of_section="main")
                class HelloWorld(al.Page):
                    title = "Hello, World!"

                    def on_exp_access(self):
                        self += al.Text("This is a 'hello, world!' Page.")

        """

        def add_member(member):
            @functools.wraps(member)
            def wrapper():
                if isclass(member) and not member.name:
                    member.name = member.__name__

                if admin:
                    self.admin.append(member, to_section=of_section)
                else:
                    self.append(member, to_section=of_section)
                return member

            return wrapper()

        if _member is None:
            return add_member
        else:
            return add_member(_member)

    @property
    def admin(self):
        if not self._admin:
            self._admin = ExperimentAdmin()
        return self._admin

    @admin.setter
    def admin(self, value):
        self._admin = value

    @property
    def final_page(self):
        """
        page.Page: The experiment's final page.

        You can set this property to a Page class or Page instance
        directly. For more complex final pages, we recommend the use
        of the :meth:`.as_final_page` decorator.

        Returns:
            page.Page: The experiment's final page.

        Notes:
            You do not need to define a name for the final page. The name
            will be set automatically to '_final_page'.

        See Also:

            - :meth:`.as_final_page`: Class decorator for adding custom
              final pages to the experiment.
            - :attr:`.ExperimentSession.final_page` the final page
              property of the experiment session object.

        Examples:

            This property will return *None*, if the final page was not
            set manually. The experiment session will then use a
            default final page.

            >>> exp = al.Experiment()
            >>> exp.final_page
            None

            Setting a class instance as final page:

            >>> exp = al.Experiment()
            >>> exp.final_page = al.Page()
            >>> exp.final_page
            Page(class='Page', name='_final_page')
        """

        return self._final_page

    @final_page.setter
    def final_page(self, page: Page):
        page._set_name("_final_page", via="argument")
        self._final_page = page

    def as_final_page(self, page):
        """
        Class decorator for adding a custom final page to the experiment.

        Use this decorator, if you want to define a new final page with
        full access to all experiment hooks.

        Notes:
            You do not need to define a name for the final page. The name
            will be set automatically to '_final_page'.

        See Also:
            :attr:`.final_page`: The final page as a property.

        Examples:
            ::

                exp = al.Experiment()


                @exp.as_final_page
                class Final(al.Page):
                    def on_exp_access(self):
                        self += al.Text("This is the final page.")

        """

        @functools.wraps(page)
        def wrapper():
            self._final_page = page(name="_final_page")
            return page

        return wrapper()

    def create_session(
        self,
        session_id: str,
        config: ExperimentConfig,
        secrets: ExperimentSecrets,
        timeout: int = None,
        **urlargs,
    ):
        """
        Creates an experiment session.

        The arguments get passed on directly to the
        :class:`.ExperimentSession` object and are documented there.

        See Also:

            :class:`.ExperimentSession` contains documentation on how
            to interact with an experiment session object.

        """

        if urlargs.get("admin") in ["true", "True", "TRUE"]:
            self.admin.setup_functions += self.setup_functions
            self.admin.final_page = _NothingHerePage(
                name="__"
            )  # name gets changed automatically by setter
            exp_session = self.admin.create_session(
                session_id=session_id, config=config, secrets=secrets, **urlargs
            )
            return exp_session

        if urlargs.get("debug") in ["true", "True", "TRUE"]:
            config.read_dict({"general": {"debug": True}})

        timeout = timeout if timeout is not None else self.session_timeout
        exp_session = ExperimentSession(
            session_id=session_id,
            config=config,
            secrets=secrets,
            timeout=timeout,
            **urlargs,
        )

        for fun in self.setup_functions:
            fun(exp_session)

        exp_session._allow_append = True
        exp_session.abort_functions.extend(self.abort_functions)
        exp_session.finish_functions.extend(self.finish_functions)

        for member in self._root_members.values():
            exp_session += member

        if self.final_page is not None:
            exp_session.final_page = self.final_page
            # if isclass(self.final_page):
            #     exp_session.final_page = self.final_page()
            # elif isinstance(self.final_page, page._PageCore):

        return exp_session

    def append(self, *members, to_section: str = "_content"):
        """
        Append members to the experiment.


        Args:
            *members: The members to append
            to_section: The parent section for the members to append.
                All members in a function call will be appended to the
                same section.

        Members can be classes that inherit from :class:`.Section` or
        :class:`.Page`, as well as instances of these classes or their
        subclasses. To append Page or Section classes to the experiment,
        you should use the :meth:`.member` decorator.

        While it is perfectly possible to use the :meth:`.append` method,
        usually it is preferable to use to augmented assigment operator
        ``+=`` to append members to the experiment.

        .. note::
            All members must have a unique name.


        Examples:

            >>> exp = al.Experiment()
            >>> exp.append(Page(name="page1"))
            >>> exp.members
            {"page1": Page(class="Page", name="page1")}

        """

        for member in members:
            name = member.name
            if name in self.members or name in [
                "_content",
                "_root",
                "_finished_section",
            ]:
                raise ValueError(f"A section or page of name '{name}' already exists.")

            member.parent_name = to_section
            member_inst = member() if isclass(member) else member

            self.members[member.name] = member_inst

            if member_inst.parent_name == "_content":
                self._root_members[member_inst.name] = member_inst
            else:
                parent = self.members[member_inst.parent_name]
                parent += member_inst

    def run(self, path: str | Path = None, **kwargs):
        """
        Runs the experiment.

        Args:
            path: Path to the experiment directory, containing script.py.
                If None, alfred looks for a script.py in the directory
                from which this method is executed.
            open_browser: Indicates, whether alfred should try to open
                a new browser window automatically.
            debug: Indicates, whether the underlying flask app should be
                run in debug mode. Defaults to None, which leads to
                taking the value from option 'open_browser' in section
                'general' of config.conf.
            test: If true, the experiment is started in test mode. Session IDs
                and the experiment version receive a prefix of "test-".

        .. versionchanged:: 2.3.0
            Added parameter *test*.

        Notes:
            .. warning::
                If you execute this method in your script.py, make sure to
                use an ``if __name__ == "__main__"`` protector (see example)!

        See Also:
            There are two other useful ways of running your alfred
            experiment:

            1. Define a run.py in your Experiment directory (see :mod:`alfred3.run`)
            2. Use the command line interface (see :mod:`.cli`) ::

                alfred3 run

        Examples:

            A convenient way of running short alfred experiments is to
            call this method at the end of your script.py::

                import alfred3 as al

                exp = al.Experiment()
                exp += al.Page(name="demo")

                if __name__ == "__main__":
                    exp.run()

            To start an experiment in test mode::

                import alfred3 as al

                exp = al.Experiment()
                exp += al.Page(name="demo")

                if __name__ == "__main__":
                    exp.run(test=True)

            To start an experiment without trying to open a browser
            window automatically::

                import alfred3 as al

                exp = al.Experiment()
                exp += al.Page(name="demo")

                if __name__ == "__main__":
                    exp.run(open_browser=False)


        """
        from alfred3.run import ExperimentRunner

        runner = ExperimentRunner(path=path)
        runner.auto_run(**kwargs)

    def __iadd__(self, other: Section | Page):
        self.append(other, to_section="_content")
        return self

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

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


class ExperimentAdmin(Experiment):
    def create_session(
        self,
        session_id: str,
        config: ExperimentConfig,
        secrets: ExperimentSecrets,
        **urlargs,
    ):
        config.read_dict(self.admin_config)

        exp_session = ExperimentSession(
            session_id=session_id, config=config, secrets=secrets, **urlargs
        )

        for fun in self.setup_functions:
            fun(exp_session)

        exp_session._allow_append = True
        exp_session.abort_functions.extend(self.abort_functions)
        exp_session.finish_functions.extend(self.finish_functions)

        exp_session._admin_auth_page_.admin_members = self._root_members

        if self.final_page is not None:
            exp_session.final_page = self.final_page

        return exp_session

    @property
    def admin_config(self) -> dict:
        config = {}
        config["general"] = {"admin": "true"}
        config["data"] = {"save_data": "false"}
        config["navigation"] = {"finish": ""}

        return config


class ExperimentSession:
    """
    Coordinates all parts of an experiment session.

    Args:
        session_id: Unique session identifier
        config: Non-secret experiment configuration
        secrets: Secret experiment configuration, such as database
            credentials
        **urlargs: Keyword arguments from url parameters. Will be stored
            in the attribute :attr:`.urlargs` for access in the
            experiment session.

    Usually, there is no need to initialize an ExperimentSession
    manually. The :class:`.Experiment` object will take care of that
    for you.

    You have access to this object in Page and Section hooks through
    their accessors :attr:`.Page.exp` and :attr:`Section.exp`.
    It connects all parts of an experiment and allows you to access
    data from other pages and even other experiment sessions.

    .. note::
        Because the experiment session is newly created for every new
        subject, you can only access it in the *class* style
        of writing an alfred experiment by deriving new :class:`.Page`
        and/or :class:`.Section` classes and using their hooks to add
        elements.

    See Also:
        - :doc:`howto_exp` explains how to go about writing an experiment.
        - :doc:`howto_hooks` explains how to use hooks.
        - :class:`.Experiment` is used to construct the experiment

    .. versionchanged:: 2.0
        Many changes, including new methods and properties for improved
        usability. Name changed from "Experiment" to "ExperimentSession".

    """

    def __init__(
        self,
        session_id: str,
        config: ExperimentConfig = None,
        secrets: ExperimentSecrets = None,
        timeout: int = None,
        **urlargs,
    ):
        self._plugins = _DictObj()  # docs in getter
        self._tmp = _DictObj()

        # list of dictionaries, each containing a query specification
        # that can be used to query plugin-related data from a mongoDB
        # (if a mongoDB is used). The query is saved along with the
        # experiment data
        self._plugin_data_queries = []

        self._finish_functions: list[callable] = []  # docs in getter
        self._abort_functions: list[callable] = []  # docs in getter

        self._condition = ""  # docs in getter
        self._session = ""  # docs in getter

        self.session_timeout = timeout  # docs in getter
        self.finished: bool = False  # docs in getter
        self.aborted: bool = False  # docs in getter

        #: If the experiment session was aborted, this variable indicates the reason.
        self._aborted_because: str = None

        self._start_time: float = None  # docs in getter
        self._type = "web"  # provided for backwards compatibility

        #: Used to prevent people from appending during setup
        #: Gets set to *True* in :meth:`.Experiment.create_session`,
        #: after execution of the setup functions
        self._allow_append: bool = True

        self._alfred_version = __version__  # docs in getter

        self._session_id = session_id  # docs in getter
        self._session_status = None  # docs in getter

        self._config = (
            config if config is not None else ExperimentConfig()
        )  # docs in getter
        self._secrets = (
            secrets if secrets is not None else ExperimentSecrets()
        )  # docs in getter
        self._urlargs = urlargs  # docs in getter

        self._log = QueuedLoggingInterface(base_logger="alfred3")  # docs in getter
        self.log.queue_logger = logging.getLogger("exp." + self.exp_id)
        self.log.session_id = self.session_id

        self._encryptor = self._set_encryptor()

        self.movement_manager = MovementManager(self)
        self.data_manager = DataManager(self)
        self.data_saver = DataSaver(self)
        self.message_manager = messages.MessageManager()

        self._root_section = _RootSection(self)
        self.root_section.append_root_sections()
        self.root_section._update_members_recursively()
        self.root_section._generate_unset_tags_in_subtree()

        self.user_interface_controller = UserInterface(self)
        self.ui = self.user_interface_controller

        self.progress_bar = elm.display.ProgressBar()  # documented in getter method

        # init logging message
        self.log.info(
            f"Alfred {self.type} experiment session initialized! "
            f"Alfred version: {self.alfred_version}, "
            f"Experiment title: {self.title}, "
            f"Experiment version: {self.version}"
        )
        mock = self.secrets.getboolean("mongo_saving_agent", "mock", fallback=False)
        if not self.admin_mode and not mock:
            self._save_data(sync=True)

    def append_plugin_data_query(self, query: dict):
        """
        Appends a query dictionary to the internal list of plugin data
        queries.

        The query dictionary must have the following basic structure::

            {
                "title": "Short title",
                "type": "data_type",
                "query": {
                    "filter": {"field1": "val1"},
                    ...
                }
            }

        The 'filter' subfield of 'query' contains the actual filter to
        be passed on to the database. You can also define a 'projection'
        subfield to control exactly which field will be returned by the
        query. If part of the data is saved in encrypted form, you should
        specify the field ``'encrypted': True``.

        Raises:
            ValueError: If the input dictionary does not conform to the
                specifications.
        """
        if query in self._plugin_data_queries:
            return

        elif "title" not in query:
            raise ValueError("Query must contain field 'title'.")

        elif "type" not in query:
            raise ValueError("Query must contain field 'filename'.")

        elif "query" not in query:
            raise ValueError("Query must contain field 'query'.")

        elif "filter" not in query["query"]:
            raise ValueError("Field 'query' must contain subfield 'filter'.")

        self._plugin_data_queries.append(query)

    @property
    def session_expired(self) -> bool:
        """
        bool: Indicates whether the session has expired.

        See Also:
            :attr:`.session_timeout`
        """
        if not self.session_timeout:
            return False
        else:
            return (time.time() - self.start_time) > self.session_timeout

    @property
    def progress_bar(self) -> elm.display.ProgressBar:
        """
        The experiment's progress bar.

        There are two options in *config.conf* that control the progress
        bar, both in the *layout* section:

        * ``show_progress`` (true/false) toggles whether a progress bar
          is displayed at all
        * ``fix_progress_top`` (true/false) toggles whether the progress
          bar  should stay at a fixed position at the top of the page,
          when subjects scroll down on a long page.

        The progress bar can be customized by redefining it with a
        :class:`.element.ProgressBar` of your choosing.

        See Also:
            See :class:`.element.ProgressBar` for more information on
            how to specify a custom progress bar.

        Notes:
            The experiment-wide progress bar *always* receives a name
            of ``progress_bar_``.

        Examples:

            Example of controlling the progress bar in the config.conf:

            .. code-block:: ini

                [layout]
                show_progress = true
                fix_progress_top = false

            Example of redefining the experiment-wide progress bar::

                import alfred3 as al

                exp = al.Experiment()


                @exp.setup
                def setup(exp):
                    exp.progress_bar = al.ProgressBar(show_text=True, bar_height="10px")

        """
        return self._progress_bar

    @progress_bar.setter
    def progress_bar(self, bar: elm.display.ProgressBar):
        if bar is None:
            self._progress_bar = None
            return
        if bar.name is not None:
            raise AlfredError(
                "If you redefine the progress bar, you can't set a custom name. It is"
                " fixed to 'progress_bar_'."
            )
        bar.name = "progress_bar_"
        bar.added_to_experiment(self)
        self._progress_bar = bar

    def start(self):
        """
        Starts the experiment.

        Usually, this method does not need to be called manually. It
        will be called automatically, when the ``/experiment/start``
        url route is called.

        Notes:
            A special url parameter 'jumpto' can be used to trigger a
            jump to a specific page at the start of the session.

        Examples:
            Example for a custom jump via url parameter at the start of
            the experiment session. Let's take a minimal experiment with
            two pages::

                import alfred3 as al

                exp = al.Experiment()

                exp += al.Page(name="page1")
                exp += al.Page(name="page2")

            Now, if you can start the experiment with the normal url::

                http://127.0.0.1:5000/start

            This is the url that is used by the auto-start function if
            you start the experiment via ``alfred3 run``. The experiment
            will start on page1. But you can also
            start the experiment by manually calling the following url::

                http://127.0.0.1:5000/start?jumpto=page2

            In this case, the experiment will immediately jump to page2.

        """
        self._start()

    def _start(self):
        if self.aborted:
            return

        if not self.all_members:
            raise AlfredError("There are no pages in your experiment.")

        if self.start_time:
            msg = (
                "ExperimentSession._start() was called. The experiment was already"
                " running. Leaving method."
            )
            self.log.warning(msg)
            return

        self._start_time = time.time()
        self.log.info("ExperimentSession._start() called. Session is starting.")
        self.user_interface_controller.start()

        jumpto = self.urlargs.get("jumpto")
        if jumpto and not self.aborted:
            self.log.info(
                f"Experiment session started with a jump. Jumping to Page '{jumpto}'."
            )
            self.movement_manager.move("jump", to=jumpto)

    def abort(
        self,
        reason: str,
        title: str = "Experiment aborted",
        msg: str = "Sorry! The experiment was aborted.",
        icon: str = "mug-hot",
        page: Page = None,
    ):
        """
        Aborts the experiment session.

        Args:
            reason (str): The reason for which the session was aborted.
            title (str): Title of the abort page.
            msg (str): Message displayed on the abort page.
            icon (str): Name of the icon that you wish to show on the
                page. All free icons from Font Awesome 5 can be used:
                https://fontawesome.com/icons?d=gallery&p=1&m=free
            page (alfred3.page.Page): A custom page that you want to
                display upon experiment abortion. If you use this argument,
                the arguments *title* and *msg* will be ignored.

        When a session is aborted, the experiment will jump to the
        "Abort Page", informing the participant about the event.

        In an aborted experiment, the movement system is shut down. The
        corresponding dataset will contain the info that the session was
        aborted, as well as the reason.

        Examples:
            This is a minimal example of a participant screening. In this
            case, the experiment will abort::

                import alfred3 as al

                exp = al.Experiment()


                @exp.member
                class Screening(al.Page):
                    title = "Participant Screening"

                    def on_exp_access(self):
                        self += al.NumberEntry(
                            leftlab="Please enter your age",
                            name="age",
                            force_input=True,
                        )

                    def on_first_hide(self):
                        if int(self.exp.values.get("age")) < 25:
                            self.exp.abort(
                                reason="screening",
                                title="Experiment aborted",
                                icon="users",
                                msg="Sorry, you do not fulfill the criteria for "
                                "participation.",
                            )
        """
        if self.aborted:
            self.log.debug(
                "ExperimentSession.abort() called, but it was already aborted. New"
                f" Reason: {reason}, Old Reason: {self._aborted_because}"
            )
            return

        for func in self.abort_functions:
            func(self)

        pg_name = "_abort_" + uuid4().hex

        if page is not None:
            if not isinstance(page, Page):
                raise TypeError("Abort page must be a page.")
            abort_page = page
            abort_page._set_name(pg_name, via="abort")
        else:
            abort_page = Page(title=title, name=pg_name)
            if icon:
                ic = util.icon(icon, size="80pt")
                abort_page += elm.display.VerticalSpace("50px")
                abort_page += elm.display.Html(ic, align="center")
            if msg:
                abort_page += elm.display.VerticalSpace("100px")
                abort_page += elm.display.Text(msg, align="center")

            abort_page += elm.misc.HideNavigation()
            abort_page += elm.misc.WebExitEnabler()

        abort_section = _AbortSection(name="_abort_section")
        abort_section += abort_page

        if not self._allow_append:
            self._allow_append = True
            self += abort_section
            self._allow_append = False
        else:
            self += abort_section

        abort_page._on_showing_widget()
        self.movement_manager._direct_jump(to=pg_name)

        self.log.info(
            f"ExperimentSession.abort() called. Aborting session. Reason: {reason}"
        )
        self.aborted = True
        self._aborted_because = reason
        self._save_data()

    def finish(self):
        """
        Closes all previous pages and saves data.

        This method gets called automatically with the last click in an
        experiment. You can manually call it earlier to mark a dataset
        as complete. This may be useful if you want to append some
        purely informational or optional pages at the end of your
        experiment.

        Examples:

            The experiment is finished on hiding the first page::
                import alfred3 as al

                exp = al.Experiment()


                @exp.member
                class First(al.Page):
                    def on_exp_access(self):
                        self += al.TextEntry(name="el1")

                    def on_first_hide(self):
                        self.exp.finish()


                @exp.member
                class Second(al.Page):
                    def on_exp_access(self):
                        self += al.TextEntry(name="el2")

        """
        if self.aborted:
            msg = (
                "ExperimentSession.finish() called, but the experiment was already"
                " aborted. Cancelling ExperimentSession.finish() - the experiment is"
                " not finished."
            )
            self.log.warning(msg)
            return

        for func in self.finish_functions:
            func(self)

        if self.finished:
            msg = (
                "ExperimentSession.finish() called. Experiment was already finished."
                " Finishing again."
            )
        else:
            msg = "ExperimentSession.finish() called. Session is finishing."

        self.log.info(msg)
        self.finished = True
        self._close_previous_pages()
        self._save_data(sync=True)
        self._export_data()

    def _close_previous_pages(self):
        for i, pg in enumerate(self.root_section.all_pages.values()):
            if i > self.movement_manager.current_index:
                break
            if not pg.is_closed:
                pg.close()

    def _export_data(self):
        if not self.config.getboolean("local_saving_agent", "use"):
            return

        cfg = self.config["data"]
        if not cfg.getboolean("save_data"):
            self.log.debug("Option 'save_data' was 'false'. Not exporting any data.")
            return

        exporter = Exporter(self)
        if cfg.getboolean("export_exp_data") and self.config.getboolean(
            "local_saving_agent", "use"
        ):
            exporter.export(DataManager.EXP_DATA)
        if cfg.getboolean("export_unlinked_data") and self.root_section.unlinked_data:
            exporter.export(DataManager.UNLINKED_DATA)
        if cfg.getboolean("export_codebook"):
            exporter.export(DataManager.CODEBOOK_DATA)
        if cfg.getboolean("export_move_history") and cfg.getboolean(
            "record_move_history"
        ):
            exporter.export(DataManager.HISTORY)

    def _save_data(self, sync: bool = False):
        """
        Saves data with the main and unlinked saving agents.

        Usually, there is no need to call this method manually, as data
        is saved automatically on every move.

        Args:
            sync: If *True*, the experiment will only proceed after the
                saving task was completed.

        .. warning::
           Note that a call to this function will NOT prompt a call to
           the :meth:`~page.CustomSavingPage.save_data` method of
           :class:`~.page.CustomSavingPage` instances in the experiment.
           You need to call those manually.

        """
        if self.config.getboolean("general", "debug"):
            if self.config.getboolean("debug", "disable_saving"):
                self.log.debug("Saving is disabled.")
                return

        data = self.data_manager.session_data
        self.data_saver.main.save_with_all_agents(data=data, level=99, sync=sync)

        if self.root_section.unlinked_data:
            for agent in self.data_saver.unlinked.agents.values():
                data = self.data_manager.unlinked_data_with(agent)
                self.data_saver.unlinked.save_with_agent(
                    data=data, name=agent.name, level=99, sync=sync
                )

    @property
    def content(self):
        """
        Section: The experiment's main content section, which holds all
        sections and pages added to the experiment.
        """
        return self.root_section._content

    @property
    def root_section(self):
        """
        _RootSection: The experiment's root section, organizing all other
        sections, including :attr:`.content`.
        """
        return self._root_section

    @property
    def all_members(self) -> dict:
        """
        dict: Dictionary of all sections and pages in the experiment.

        Excludes the final page.

        """
        return self.root_section._content.all_members

    @property
    def all_sections(self) -> dict:
        """dict: Dictionary of all sections in the experiment."""
        return self.root_section._content.all_subsections

    @property
    def all_pages(self) -> dict:
        """dict: Dictionary of all pages in the experiment."""
        return self.root_section._content.all_pages

    @property
    def final_page(self) -> Page:
        """
        The experiment's final page.

        This page will be displayed after a subject has finished the
        experiment. It will not contain any navigation elements. You can
        use this property to change the final page by assigning a page
        of your design.

        Notes:
            You do not need to define a name for the final page. The name
            will be set automatically to '_final_page'.

        See Also:

            * You can change the final page in a similar way using
              :attr:`.Experiment.final_page`.

            * For conditional assignment of the experiment's final page,
              doing so using a function with the
              :meth:`.Experiment.finish` decorator is the recommended
              way to go.

        Examples:

            >>> import alfred3 as al
            >>> exp = al.ExperimentSession()
            >>> exp.final_page = al.Page()
            >>> exp.final_page
            Page(class="Page", name="_final_page")

        """

        return self.root_section.final_page

    @final_page.setter
    def final_page(self, value: Page):
        if not isinstance(value, page._PageCore):
            raise ValueError("Not a valid page.")

        if value.name != "_final_page":
            value._set_name("_final_page", via="argument")
        value += elm.misc.HideNavigation()
        self.root_section.finished_section.members.clear()
        self.root_section.finished_section += value

    def subpath(self, path: str | Path) -> Path:
        """
        Returns the full path of an experiment subdirectory.

        If the given *path* is absolute, it will not be altered, but
        transformed to a :class:`pathlib.Path` object.

        Returns:
            pathlib.Path: Absolute path

        """
        p = Path(path)
        if p.is_absolute():
            return p
        else:
            return self.path / path

    def read_csv_todict(
        self, path: str | Path, encoding: str = "utf-8", **kwargs
    ) -> Iterator[dict]:
        """
        Iterates over the rows in a .csv file, yielding dictionaries.

        Args:
            path: The path to the .csv file. Usually, you want this to
                be a relative path to a file in a subdirectory of the
                experiment directory.
            encoding: Encoding of the .csv file. Defaults to 'utf-8'.
            **kwargs: Further arguments passed on to :class:`csv.DictReader`

        Yields:
            dict: A dictionary in which the keys are the column names.

        Examples:

            Consider the following csv-file, located at
            ``files/data.csv`` in your experiment directory::

                col1, col2, col3
                text_a, text_b, text_c
                text_d, text_e, text_f


            When building a page, usual usage would be::

                import alfred3 as al

                exp = al.Experiment()


                @exp.member
                class CSVDemoPage(al.Page):  # this could also be a Section
                    name = "csv_demo"

                    def on_exp_access(self):
                        for row in self.exp.read_csv_todict("files/data.csv"):
                            print(row)

            The output would be the following::

                {
                    "col1": "text_a",
                    "col2": "text_b",
                    "col3": "text_c",
                }  # first iteration
                {
                    "col1": "text_d",
                    "col2": "text_e",
                    "col3": "text_f",
                }  # second iteration

            If you need a full list of the rows, you can wrap the
            function call in ``list()``::

                import alfred3 as al

                exp = al.Experiment()


                @exp.member
                class CSVDemoPage(al.Page):  # this could also be a Section
                    name = "csv_demo"

                    def on_exp_access(self):
                        data = list(self.exp.read_csv_todict("files/data.csv"))
                        print(data)

            The output would be the following::

                [
                    {"col1": "text_a", "col2": "text_b", "col3": "text_c"},
                    {"col1": "text_d", "col2": "text_e", "col3": "text_f"},
                ]


        """
        p = self.subpath(path)
        yield from util._read_csv_todict(p, encoding=encoding, **kwargs)

    def read_csv_tolist(
        self, path: str | Path, encoding: str = "utf-8", **kwargs
    ) -> Iterator[list]:
        """
        Iterates over the rows in a .csv file, yielding lists.

        Args:
            path: The path to the .csv file. Usually, you want this to
                be a relative path to a file in a subdirectory of the
                experiment directory.
            encoding: Encoding of the .csv file. Defaults to 'utf-8'.
            **kwargs: Further arguments passed on to :class:`csv.reader`

        Yields:
            list: A list of the values in one row.

        Examples:

            Consider the following csv-file::

                col1, col2, col3
                text_a, text_b, text_c
                text_d, text_e, text_f


            When building a page, usual usage would be::

                import alfred3 as al

                exp = al.Experiment()


                @exp.member
                class CSVDemoPage(al.Page):  # this could also be a Section
                    name = "csv_demo"

                    def on_exp_access(self):
                        for row in self.exp.read_csv_tolist("files/data.csv"):
                            print(row)

            The output would be the following::

                ["col1", "col2", "col3"]  # first iteration yields column names
                ["text_a", "text_b", "text_c"]  # second iteration
                ["text_a", "text_b", "text_c"]  # third iteration

            If you need a full list of the rows, you can wrap the
            function call in ``list()``::

                import alfred3 as al

                exp = al.Experiment()


                @exp.member
                class CSVDemoPage(al.Page):  # this could also be a Section
                    name = "csv_demo"

                    def on_exp_access(self):
                        data = list(self.exp.read_csv_tolist("files/data.csv"))
                        print(data)

            The output would be the following::

                [
                    ["col1", "col2", "col3"],
                    ["text_a", "text_b", "text_c"],
                    ["text_a", "text_b", "text_c"],
                ]


        """
        p = self.subpath(path)
        yield from util._read_csv_tolist(p, encoding=encoding, **kwargs)

    def read_yaml_todict(self, path: str | Path, encoding: str = "utf-8"):
        """
        Imports a ``.yaml`` file to a dict.

        This method uses the library PyYAML, specifically the function
        ``safe_load``. See the documentation of PyYAML at
        https://pyyaml.org/wiki/PyYAMLDocumentation
        for further information.

        Args:
            path: The path to the .yaml file. Usually, you want this to
                be a relative path to a file in a subdirectory of the
                experiment directory.
            encoding: Encoding of the .yaml file. Defaults to 'utf-8'.

        Returns:
            dict: A dict representing the parsed yaml file.

        Examples:

            This example assumes that you have a .yaml file saved in your experiment
            directory in the subdirectory resources::

                import alfred3 as al

                exp = al.Experiment()


                @exp.member
                class Test(al.Page):
                    def on_exp_access(self):
                        yaml_dict = self.exp.read_yaml_todict(
                            "resources/test_yaml.yaml"
                        )
                        self += al.Text(str(yaml_dict))

        """
        yaml_path = self.subpath(path)
        with open(yaml_path, encoding=encoding) as yamlfile:
            yaml_dict = yaml.safe_load(yamlfile)

        return yaml_dict

    @property
    def author(self) -> str:
        """str: Returns the experiment author."""
        return self.config.get("metadata", "author")

    @property
    def admin_mode(self) -> bool:
        """bool: Indicates whether the experiment runs in admin mode."""
        return self.config.getboolean("general", "admin")

    @property
    def test_mode(self) -> bool:
        """
        bool: Indicates whether the experiment runs in test mode.

        In test mode, alfred3 prefixes all session IDs with ``test``
        for easy and save separation of test and production sessions.

        You can start the test mode by appending ``test=true`` to
        alfred3's start url. In a local experiment, this would mean
        using::

            http://127.0.0.1:5000/start?test=true

        .. warning:: **Keep in mind** that a test session will take up a
            slot in list randomization via :class:`.ListRandomizer` just
            as any other session. You have to use experiment
            version numbers to manage randomization slots.

        Notes:
            The test mode will **also be activated in debug mode**.

        """
        test_mode = self.urlargs.get("test", False) in ["true", "True", "TRUE"]
        debug_mode = self.config.getboolean("general", "debug")
        return test_mode or debug_mode

    @property
    def type(self) -> str:
        """str: Type of the experiment"""

        return self.config.get("experiment", "type", fallback="web")

    @property
    def version(self) -> str:
        """str: Experiment version"""
        version = self.config.get("metadata", "version")
        if self.test_mode:
            version = "test-" + version
        return version

    @property
    def title(self) -> str:
        """str: Experiment title"""
        return self.config.get("metadata", "title")

    @property
    def exp_id(self) -> str:
        """str: Experiment id"""
        return self.config.get("metadata", "exp_id")

    @property
    def path(self) -> Path:
        """Path: Path to the experiment directory"""
        return Path(self.config.expdir).resolve()

    @property
    def session_status(self) -> str:
        """
        str: Session status for the current experiment.

        Can be used to store information about the state of an
        experiment.

        """
        txt = (
            "The property ExperimentSession.session_status will be removed in the next"
            " major release."
        )
        FutureWarning(txt)
        self.log.warning(txt)
        return self._session_status

    @session_status.setter
    def session_status(self, status: str):
        txt = (
            "The property ExperimentSession.session_status will be removed in the next"
            " major release."
        )
        FutureWarning(txt)
        self.log.warning(txt)
        if not isinstance(status, str):
            raise TypeError
        self._session_status = status

    @property
    def adata(self) -> dict:
        """
        dict: Shortcut for accessing the additional data dictionary.

        The additional data dictionary is meant to be a place where you
        can store data manually. It will be saved to the final dataset,
        where all additional data for a session will be stored as a
        single string.

        See Also:

            You can add specific values to a page with the
            :class:`.Value` element. For each Value element, alfred will
            save an individual column to the final dataset.


        """
        return self.data_manager.additional_data

    @adata.setter
    def adata(self, data: dict):
        self.data_manager.additional_data = data

    @property
    def additional_data(self) -> dict:
        """
        dict: The additional data dictionary. See :attr:`.adata`.
        """

        return self.data_manager.additional_data

    @additional_data.setter
    def additional_data(self, data: dict):
        self.data_manager.additional_data = data

    @property
    def condition(self) -> str:
        """str: Experiment condition."""
        return self._condition

    @condition.setter
    def condition(self, value: str):
        if not isinstance(value, str):
            raise ValueError("Condition must be a string")

        self._condition = value

    @property
    def session(self) -> str:
        """
        str: Experiment session.

        This property can be used, e.g. for repeated measures designs
        with multiple experiment sessions for each participant.
        """
        txt = (
            "The property ExperimentSession.session will be removed in the next major"
            " release."
        )
        self.log.debug(txt)
        return self._session

    @session.setter
    def session(self, value: str):
        txt = (
            "The property ExperimentSession.session will be removed in the next major"
            " release."
        )
        self.log.debug(txt)

        if not isinstance(value, str):
            raise ValueError("Session must be a string")

        self._condition = value

    def append(self, *items):
        """
        Appends Sections or Pages to the experiment's root section.

        While it is perfectly possible to use the :meth:`.append` method,
        usually it is preferable to use to augmented assigment operator
        ``+=`` to append members to the experiment.

        All members must have a unique name.

        Examples:

            >>> import alfred3 as al
            >>> exp = al.ExperimentSession()
            >>> exp.append(al.Page(name="page1"))
            >>> exp.members
            {"page1": Page(class="Page", name="page1")}

        """
        if self.aborted:
            return

        if not self._allow_append:
            raise AlfredError("You cannot append members during setup.")

        for item in items:
            self.root_section.members["_content"].append(item)

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

    def __contains__(self, key):
        try:
            name_in_members = key.name in self.all_members
            name_in_elements = key.name in self.root_section.all_elements
            return name_in_members or name_in_elements
        except AttributeError:
            return key in self.all_members or key in self.root_section.all_elements

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

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

    def _set_encryptor(self):
        """
        Sets the experiments encryptor.

        1. Encryptor with key from environment variable
            If 'ALFRED_ENCRYPTION_KEY' is defined in the environment
            and no non-public custom key is defined in the experiments'
            *secrets.conf*.
        2. Encryptor with key from experiment secrets.conf
            If 'public_key = false' and a key is defined in the
            experiments' *secrets.conf*.
        3. No encryptor
            If 'public_key = false' and no key is defined in the
            experiments' *secrets.conf*.

        """

        key = os.environ.get("ALFRED_ENCRYPTION_KEY", None)
        if self.secrets.get("encryption", "key", fallback=None):
            key = self.secrets.get("encryption", "key")

        if key:
            return Fernet(key=key.encode())
        else:
            self.log.debug("No encryption key found. Encryptor was not set.")
            return None

    def encrypt(self, data: str | int | float) -> str:
        """
        Encrypts the input and returns the encrypted string.

        Args:
            data: Input object that you want to encrypt. If the input is
                *None*, the function will return *None*.

        Returns:
            str: Encrypted data

        Raises:
            AlfredError: If no encryptor is available, which usually
            means that no encryption key was provided.

        Notes:
            In web experiments deployed via mortimer, a safe, user-specific
            secret key will be used for encryption.

            For encryption in local experiments, you must provide an
            encryption key yourself. You have two options:

            1. Save the key to the environment variable "ALFRED_ENCRYPTION_KEY".
               Alfred will look for this variable and use the key
               provided here.

            2. Create a secrets.conf file in your experiment directory
               and define the option "key" in section "encryption".
               If you define this option, this key will be preferred
               over the key from the environment variable.

            The key must be a string containing a valid fernet key,
            generated by :meth:`cryptography.fernert.Fernet.generate_key`.

            A string key can be generated with a few lines of python code:

            >>> from cryptography.fernet import Fernet
            >>> key = Fernet.generate_key()
            >>> print(key.decode())
            biNJTMLgFYc5fKAd-DNi1ioh44BTnfAQGbozpDcXZ-M=

            You can copy-paste a key that you generated this way into
            your environment variable or secrets.conf (*but obviously
            DON'T copy the key from this public example!*).

        """
        if not self._encryptor:
            raise AlfredError(
                "For encryption to work, you must set an encryption key in"
                " secrets.conf."
            )

        if data is None:
            return None

        if type(data) not in [str, int, float]:
            raise TypeError(
                f"EncryptionError: Input must be of type str, "
                f"int, or float. Type received: {type(data)}"
            )

        d_str = str(data)
        d_bytes = d_str.encode()

        encrypted = self._encryptor.encrypt(d_bytes)
        return encrypted.decode()

    def decrypt(self, data: str | bytes) -> str:
        """
        Decrypts input and returns the decrypted object as `str`.

        The method uses the built-in Fernet instance to decrypt the input.

        Args:
            data: Encrypted string or bytes object.

        Returns:
            str: Decrypted data

        """
        if type(data) is str:
            d_bytes = data.encode()
        elif type(data) is bytes:
            d_bytes = data
        else:
            raise TypeError(
                f"Input must be of type str or bytes.Type received: {type(data)}"
            )

        d = self._encryptor.decrypt(d_bytes)
        return d.decode()

    def send_mail(self, msg: EmailMessage, tls: bool = False):
        """
        Sends an email message using credentials defined in secrets.conf.

        Args:
            msg (EmailMessage): The *msg* is a :class:`EmailMessage`
                objects that holds information on the sender, the subject,
                the recipient, and the text body.
            tls (bool): If *True*, will try to connect with the mail
                server over tls. If *False* (default), will try to
                connect over SSL.

        Notes:
            Your *secrets.conf* must have a section ``[mail]`` with the
            following fields:

            - *address*: Sender email address.
            - *name*: Sender name.
            - *password*: Password to sender email account.
            - *server*: SMTP server address of sender's email provider.
            - *port*: SMTP server port. If port is zero, the standard
                SMTP-over-SSL port (465) is used.
        """
        email = self.secrets.get("mail", "address")
        name = self.secrets.get("mail", "name")
        password = self.secrets.get("mail", "password")
        server = self.secrets.get("mail", "server")
        port = self.secrets.getint("mail", "port")

        if "From" not in msg:
            msg["From"] = formataddr((name, email))

        if tls:
            with smtplib.SMTP(server, port) as smtp:
                smtp.ehlo()
                smtp.starttls()
                smtp.login(email, password)
                smtp.helo()
                smtp.send_message(msg)

        else:
            with smtplib.SMTP_SSL(server, port) as smtp:
                smtp.login(email, password)
                smtp.send_message(msg)

    @property
    def current_page(self):
        """
        alfred3.page.Page: The currently active page of the experiment.
        """
        return self.movement_manager.current_page

    def forward(self):
        """
        Moves the experiment forward one page.

        Can be called in the :meth:`.Page.custom_move` hook to implement
        very specific movement behavior.

        See Also:
            Refer to the documentation of :meth:`.Page.custom_move` for
            guidance on how to implement a custom movement method.

        """
        try:
            self.movement_manager._move(direction="forward")
        except AbortMove:
            self.log.debug(
                f"Movement from {self.current_page} in direction 'forward' was aborted."
            )

    def backward(self):
        """
        Moves the experiment backward one page.

        Can be called in the :meth:`.Page.custom_move` hook to implement
        very specific movement behavior.

        See Also:
            Refer to the documentation of :meth:`.Page.custom_move` for
            guidance on how to implement a custom movement method.

        """
        try:
            self.movement_manager._move(direction="backward")
        except AbortMove:
            self.log.debug(
                f"Movement from {self.current_page} in direction 'backward' was"
                " aborted."
            )

    def jump(self, to: str | int):
        """
        Jumps to a specific page in the experiment.

        Args:
            to (str, int): The name or index of the target page.
                **Preferred usage is by name**, because that reduces
                ambiguity.

        Can be called in the :meth:`.Page.custom_move` hook to implement
        very specific movement behavior.

        See Also:
            Refer to the documentation of :meth:`.Page.custom_move` for
            guidance on how to implement a custom movement method.

        """
        try:
            self.movement_manager._move(direction=f"jump>{to}")
        except AbortMove:
            self.log.debug(
                f"Jump from {self.current_page} to page with name '{to}' was aborted."
            )

    @property
    def values(self) -> dict:
        """
        Dictionary of input elements and their current values.

        Elements are identified by their name. Only elements from
        pages that have already been shown to a user appear in this
        dictionary.

        You cannot (and should not) change subject data by manipulating
        the dictionary returned by this property.

        Returns:
            dict: Dictionary of input elements and their current values.

        """
        return self.data_manager.values

    @property
    def session_data(self) -> dict:
        """
        Full dataset of the current experimental session.

        Contains client information, experiment metadata, element values,
        movement history, and additional data.

        You cannot (and should not) change subject data by manipulating
        the dictionary returned by this property.

        Returns:
            dict: Full dataset of the current experimental session.

        """
        return self.data_manager.session_data

    @property
    def move_history(self) -> list[dict]:
        """
        A list, containing the movement history for the current session.

        Each entry in this list is a dictionary. The entries in each
        of these dictionaries are based on the :class:`.Move` dataclass.

        You cannot (and should not) change subject data by manipulating
        the dictionary returned by this property.

        Returns:
            list: A list, containing a dictionary for each move of the
            subject in the current session.

        See Also:
            Please refer to the documentation of :class:`.Move` for an
            explanation of the saved fields.


        """
        return self.data_manager.move_history

    @property
    def metadata(self) -> dict:
        """
        A dict of information about the experiment.

        This dict contains general information about the experiment,
        such as

        - Start time
        - Experiment version
        - Title
        - Author
        - (...)

        Returns:
            dict: A dict of information about the experiment.


        """
        return self.data_manager.metadata

    @property
    def client_data(self) -> dict:
        """
        dict: Dictionary of information about the client, such as:

            - browser
            - operating system
            - screen resolution
            - device type
            - (...)

        Returns:
            dict: Dictionary of information about the client

        """
        return self.data_manager.client_data

    @property
    def all_exp_data(self) -> list[dict]:
        """
        list: List of all experiment datasets.

        This allows you to access data collected in other experiment
        sessions.

        See Also:
            :attr:`.all_unlinked_data` provides the same access to
            unlinked data.

        Examples:
            Use *all_exp_data* to get a pandas.DataFrame object with
            all experiment data::

                import alfred3 as al
                import pandas as pd

                exp = al.Experiment()


                @exp.member
                class DemoPage(al.Page):
                    name = "demo"

                    def on_exp_access(self):
                        df = pd.DataFrame(self.exp.all_exp_data)

        """
        if self.secrets.getboolean("mongo_saving_agent", "use"):
            mongodata = self.data_manager.iter_flat_mongo_data()
        else:
            mongodata = []
        localdata = self.data_manager.iter_flat_local_data()
        if self.config.getboolean("mortimer_specific", "runs_on_mortimer"):
            return list(mongodata)
        else:
            return list(mongodata) + list(localdata)

    @property
    def all_unlinked_data(self) -> list[dict]:
        """
        list: List of all unlinked datasets.

        This allows you to access data collected in other experiment
        sessions.

        See Also:
            :attr:`.all_exp_data` provides the same access to the
            experiment data.

        Examples:
            Use *all_unlinked_data* to get a pandas.DataFrame object with
            all experiment data::

                import alfred3 as al
                import pandas as pd

                exp = al.Experiment()


                @exp.member
                class DemoPage(al.Page):
                    name = "demo"

                    def on_exp_access(self):
                        df = pd.DataFrame(self.exp.all_unlinked_data)

        """
        if self.secrets.getboolean("mongo_saving_agent_unlinked", "use"):
            mongodata = self.data_manager.iter_flat_mongo_data(data_type="unlinked")
        else:
            mongodata = []
        localdata = self.data_manager.iter_flat_local_data(data_type="unlinked")
        if self.config.getboolean("mortimer_specific", "runs_on_mortimer"):
            return list(mongodata)
        else:
            return list(mongodata) + list(localdata)

    def get_page_data(self, name: str) -> dict:
        """
        Get the data dictionary of a specific page.

        Args:
            name: The name of the target page

        Returns:
            dict: Data dictionary of a specific page.

        """
        return self.data_manager.get_page_data(name=name)

    def get_section_data(self, name: str) -> dict:
        """
        Get the data dictionary for all pages in a specific section.

        Includes pages in child-sections of the target section.

        Args:
            name: The name of the target section

        Returns:
            dict: Data dictionary of a specific section.


        """
        return self.data_manager.get_section_data(name=name)

    def post_message(self, msg: str, title: str = "", level: str = "info"):
        """
        Post a message for *one-time* display after the next move.

        Both the message and its title can contain github flavored
        markdown and emoji shortcodes.

        The message will always be displayed only once. If the current
        page cannot be left, e.g. due to invalid input, it will be
        displayed on the current page. Otherwise, it will always be
        displayed on the *next* displayed page.

        Args:
            msg: Message main text.
            title: Message title, will be displayed as a bold heading.
            level: Message level, controls the display style. Can be
                'info' (default), 'warning', 'danger', 'success',
                'primary', 'secondary', 'dark', or 'light'.

        """
        self.message_manager.post_message(msg, title, level)

    @property
    def db(self):
        """
        Database object of the main mongo saving agent.

        You can use this property to access multiple collections in the
        used database, if your monogo saving agent is connected through
        an account with the necessary privileges.

        .. note::
            This is the *database* object, which can contain multiple
            *collections* of documents. Data is always saved to a
            collection in a database, not to a database directly.

        See Also:
            :attr:`.db_main` returns the collection, to which the main
            mongo saving agent writes its data.

        Returns:
            pymongo.database.Database: A database object. If no mongo
            saving agent is present in the experiment, *None* is returned.


        """
        for agent in self.data_saver.main.agents.values():
            if isinstance(agent, MongoSavingAgent):
                return agent.db
        return None

    @property
    def db_main(self):
        """
        Document collection of the main mongo saving agent.

        You can use this property to interact with the collection
        through the pymongo api: https://pymongo.readthedocs.io/en/stable/

        Returns:
            pymongo.collections.Collection: A collection object. If no
            mongo saving agent is present in the experiment, *None* is
            returned.


        """
        for agent in self.data_saver.main.agents.values():
            if isinstance(agent, MongoSavingAgent):
                return agent.col
        return None

    @property
    def db_unlinked(self):
        """
        Document collection of the unlinked mongo saving agent.

        You can use this property to interact with the collection
        through the pymongo api: https://pymongo.readthedocs.io/en/stable/

        Returns:
            pymongo.collections.Collection: A collection object. If no
            unlinked mongo saving agent is present in the experiment,
            *None* is returned.


        """
        for agent in self.data_saver.unlinked.agents.values():
            if isinstance(agent, MongoSavingAgent):
                return agent.col
        return None

    @property
    def db_misc(self):
        """
        Document collection for miscellaneous data.

        If the option 'misc_collection' was defined for the main
        mongo saving agent, that collection will be returned. Otherwise,
        the main collection of the main mongo saving agent will be returned.

        You can use this property to interact with the collection
        through the pymongo api: https://pymongo.readthedocs.io/en/stable/

        Returns:
            pymongo.collections.Collection: A collection object. If no
            mongo saving agent is present in the experiment, *None* is
            returned.


        """
        for agent in self.data_saver.main.agents.values():
            if isinstance(agent, MongoSavingAgent):
                return agent.misc_col
        return None

    @property
    def plugins(self) -> _DictObj:
        """
        dict: A modified dictionary of experiment plugins.

        This dictionary allows access to values via dot-notation.
        """
        return self._plugins

    @property
    def tmp(self) -> _DictObj:
        """
        A modified dictionary of temporary data.

        This dictionary allows access to values via dot-notation.

        Notes:
            **How to decide whether you should use :attr:`.tmp` or
            :attr:`.adata`?** :attr:`.adata` is saved to the experiment
            data set, :attr:`.tmp` is not. If you are certain that
            you need something only temporarily in an ongoing session,
            use :attr:`.tmp`. If you may want to access the information
            while analyzing your data later on, use :attr:`.adata`.
        """
        return self._plugins

    @property
    def finish_functions(self):
        """
        List[callable]: A list of functions that will be called upon finishing an
        experiment session.

        See Also:
            The :meth:`.Experiment.finish` decorator can be used to add functions to
            this list.
        """
        return self._finish_functions

    @finish_functions.setter
    def finish_functions(self, value):
        self._finish_functions = value

    @property
    def abort_functions(self):
        """
        List[callable]: A list of functions that will be called upon aborting an
        experiment session.

        See Also:
            The :meth:`.Experiment.abort` decorator can be used to add functions to this
            list.
        """
        return self._abort_functions

    @abort_functions.setter
    def abort_functions(self, value):
        self._abort_functions = value

    @property
    def session_timeout(self):
        """
        int: The session's timeout (in seconds).

        After expiration of the timeout, the session will abort on
        the next move.

        The default timeout is 24 hours. You can set the timeout in
        experiment setup. In the example below, we set the timeout
        to two hours::

            import alfred3 as al

            exp = al.Experiment()


            @exp.setup
            def setup(exp):
                exp.session_timeout = 60 * 60 * 2


            exp += al.Page(name="demo")

        See Also:
            :attr:`.session_expired`

        """
        return self._session_timeout

    @session_timeout.setter
    def session_timeout(self, value):
        if value is not None and not isinstance(value, (int, float)):
            raise TypeError
        self._session_timeout = value

    @property
    def finished(self):
        """
        bool: Indicates, whether the experiment has finished orderly.
        """
        return self._finished

    @finished.setter
    def finished(self, value: bool):
        if not isinstance(value, bool):
            raise TypeError
        self._finished = value

    @property
    def aborted(self):
        """
        bool: Indicates, whether the experiment was aborted.
        """
        return self._aborted

    @aborted.setter
    def aborted(self, value: bool):
        if not isinstance(value, bool):
            raise TypeError
        self._aborted = value

    @property
    def start_timestamp(self):
        """
        str: A human-readable string, indicating the start time.
        """
        if self._start_time:
            return time.strftime("%Y-%m-%d_%H:%M:%S", time.localtime(self._start_time))
        else:
            return None

    @property
    def start_time(self):
        """
        float: Start time in seconds since epoch.
        """
        return self._start_time

    @property
    def alfred_version(self):
        """
        str: The alfred3 version used for the current experiment.
        """
        return self._alfred_version

    @property
    def session_id(self):
        """
        str: Unique session identifier
        """
        sid = "test-" + self._session_id if self.test_mode else self._session_id
        return sid

    @property
    def config(self):
        """
        alfred3.config.ExperimentConfig: The experiment configuration.

        Provides the four methods
        :meth:`~alfred3.config.ExperimentConfig.get`,
        :meth:`~alfred3.config.ExperimentConfig.getint`,
        :meth:`~alfred3.config.ExperimentConfig.getfloat`, and
        :meth:`~alfred3.config.ExperimentConfig.getboolean` that allow
        users to retrieve values from the configuration.

        See Also:
            :doc:`howto_config` provides guidance on how to use
            configuration in alfred3.

        """
        return self._config

    @property
    def secrets(self):
        """
        alfred3.config.ExperimentSecrets: The secret experiment configuration.

        Provides the four methods
        :meth:`~alfred3.config.ExperimentSecrets.get`,
        :meth:`~alfred3.config.ExperimentSecrets.getint`,
        :meth:`~alfred3.config.ExperimentSecrets.getfloat`, and
        :meth:`~alfred3.config.ExperimentSecrets.getboolean` that allow
        users to retrieve values from the configuration.

        See Also:
            :doc:`howto_config` provides guidance on how to use
            configuration in alfred3.
        """
        return self._secrets

    @property
    def urlargs(self):
        """
        dict: A dictionary of key-value pairs that the experiment
        might have received as url parameters.

        Examples:
            We use a local experiment, running on http://127.0.0.1:5000
            for this example. You might start the experiment via::

                https://127.0.0.1:5000/start?param1=abc&param2=defg

            In this case, ``param1=abc`` and ``param2=defg`` are the
            url parameters. They are captured and saved by the experiment.
            The following minimal experiment will print the value of
            param1 ('abc') on the first page::

                import alfred3 as al

                exp = al.Experiment()


                @exp.member
                class Demo(al.Page):
                    def on_exp_access(self):
                        param1 = self.exp.urlargs.get("param1")
                        self += al.Text(param1)

        """
        return self._urlargs

    @property
    def log(self):
        """
        alfred3.alfredlog.QueuedLoggingInterface: Alfred's logging interface.

        The interface uses Python's :mod:`logging` module under the hood,
        which is why it offers the same basic logging functions, *debug*,
        *info*, *warning*, *error*, *exception*, and *log*.

        Examples:
            Logging during setup::

                import alfred3 as al

                exp = al.Experiment()


                @exp.setup
                def setup(exp):
                    exp.log.info("Setup is starting")


                exp += al.Page(name="demo")

        """
        return self._log
