"""
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>
"""

from ._version import __version__

import os
import sys
import time
import logging
import json
import random
import threading
import functools
import copy
from pathlib import Path
from inspect import isclass
from uuid import uuid4
from configparser import NoOptionError
from typing import Union
from typing import Dict
from typing import Tuple
from typing import List
from typing import Iterator

import pymongo
from cryptography.fernet import Fernet

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


class Experiment:
    """ 
    Holds pages and sections, filled 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):
                name = "hello_world"

                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):
        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 = {}

        #: 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] = []


    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`.

        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 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.

        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"):
        """
        Decorator for adding pages and sections to the experiment.

        Works both with and without arguments.

        Args:
            of_section: Name of the section to which the new member
                belongs.

        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):
                    name = "hello_world"
                    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__

                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 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.

        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(name="final_page")
            >>> exp.final_page
            Page(class='Page', name='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.

        """

        return self._final_page

    @final_page.setter
    def final_page(self, page: Page):
        if not page.name:
            raise AlfredError("Final page must have a valid name.")
        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.
        
        See Also:
            :attr:`.final_page`: The final page as a property.
        
        Examples:
            ::
            
                exp = al.Experiment()

                @exp.as_final_page
                class Final(al.Page):
                    name = "final_page"

                    def on_exp_access(self):
                        self += al.Text("This is the final page.")

        """

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

        return wrapper()


    def create_session(
        self, session_id: str, config: ExperimentConfig, secrets: ExperimentSecrets, **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.

        TODO:
            * Take care of how the condition gets set.

        """
        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

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

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

        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: Union[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.
            **kwargs: Keyword arguments passed on to :class:`alfred3.run.ExperimentRunner.auto_run`
        
        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()

        
        """
        from alfred3.run import ExperimentRunner
        runner = ExperimentRunner(path=path)
        runner.auto_run(**kwargs)

        
    def __iadd__(self, other: Union[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 ExperimentSession:
    """
    Coordinates all parts of an experiment.

    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:
        * Object-oriented style
        * Page hooks
        * Section hooks
        * Experiment object
    
    TODO:
        * Fill the "See Also" section with useful links.
    
    .. 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,
        **urlargs,
    ):
        #: A dictionary of experiment plugins for use in the experiment 
        #: session.
        self.plugins: dict = {}

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

        self._condition = ""
        self._session = ""

        #: Indicates whether the experiment session is finished
        self.finished: bool = False

        #: Indicates whether the experiment session was aborted
        self.aborted: bool = False

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

        #: Timestamp saved upon experiment start
        self.start_timestamp: str = None
        
        #: Time of experiment start in seconds since epoch
        self.start_time: float = None

        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
        
        #: The alfred3 version used for this experiment session
        self.alfred_version = __version__

        #: Unique session identifier
        self.session_id = session_id
        self._session_status = None

        #: Non-sensitive experiment configuration. This is an
        #: :class:`~.config.ExperimentConfig` object. It offers access
        #: to all values defined in the experiment's *config.conf*
        #: through the methods *get*, *getint*, *getfloat*, and 
        #: *getboolean*.
        self.config = config if config is not None else ExperimentConfig()
        
        #: Sensitive experiment configuration (e.g. database credentials). 
        #: This is an :class:`~.config.ExperimentSecrets` object. It 
        #: offers access to all values defined in the experiment's 
        #: *secrets.conf* through the methods *get*, *getint*, 
        #: *getfloat*, and *getboolean*.
        self.secrets = secrets if secrets is not None else ExperimentSecrets()
        
        #: A dictionary of parameters passed as url parameters for use 
        #: in the experiment session.
        self.urlargs = urlargs

        #: A :class:`~.QueuedLoggingInterface`, offering logging
        #: through the methods *debug*, *info*, *warning*, *error*,
        #: *exception*, and *log*.
        self.log = QueuedLoggingInterface(base_logger="alfred3")
        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}"
            )
        )
    
    @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.name is not None:
            raise AlfredError("If you redefine the progress bar, you can't set a custom name. It's 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.

        """
        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.start_timestamp = time.strftime("%Y-%m-%d_%H:%M:%S")
        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 = "", msg: str = "The experiment run was aborted.", elements: list = None):
        """
        Aborts the experiment session.

        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.

        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.
            elements (list): A list of further elements to be included
                on the abort page. You can use this to display images
                or whatever you want.
        """
        pg_name = "_abort_" + uuid4().hex
        abort_page = Page(title=title, name=pg_name)
        abort_page += elm.display.Text(text=msg)
        abort_page += elm.misc.HideNavigation()
        abort_page += elm.misc.WebExitEnabler()
        
        if elements is not None:
            for el in elements:
                abort_page += el
        
        abort_section = _AbortSection(name="_abort_section")
        abort_section += abort_page

        self += abort_section
        abort_page._on_showing_widget()
        self.jump(to=pg_name)
        
        self.log.info(f"ExperimentSession.abort() called. Aborting session. Reason: {reason}")
        self.aborted = True
        self.aborted_because = reason

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

        Usually, this method does not need to be called manually. It 
        will be called automatically upon entering the finished section.

        """

        for func in self.finish_functions:
            func(self)

        if self.finished:
            msg = "ExperimentSession.finish() called. Experiment was already finished. Leaving method."
            self.log.warning(msg)
            return
        self.log.info("ExperimentSession.finish() called. Session is finishing.")
        self.finished = True

        for page in self.root_section.all_pages.values():
            if not page.is_closed:
                page.close()

        if self.config.getboolean("general", "debug"):
            if self.config.getboolean("debug", "disable_saving"):
                return

        self.save_data(sync=True)
        self._export_data()
    
    def _export_data(self):

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

        cfg = self.config["data"]
        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.
        
        """

        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_section.all_subsections

    @property
    def all_pages(self) -> dict:
        """dict: Dictionary of all pages in the experiment."""
        return self.root_section.content_section.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.

        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(name="my_final_page")
            >>> exp.final_page
            Page(class="Page", name="my_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.")

        self.root_section.final_page = value

    def subpath(self, path: Union[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).resolve()
        if p.is_absolute():
            return p
        else:
            return self.path / p
    
    def read_csv_todict(self, path: Union[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)
        for row in util._read_csv_todict(p, encoding=encoding, **kwargs):
            yield row
    
    def read_csv_tolist(self, path: Union[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)
        for row in util._read_csv_tolist(p, encoding=encoding, **kwargs):
            yield row

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

    @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"""
        return self.config.get("metadata", "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.
        
        """
        return self._session_status

    @session_status.setter
    def session_status(self, status: str):
        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

    @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.
        """
        return self._session
    
    @session.setter
    def session(self, value: str):
        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:
            return key.name in self.all_members or key.name in self.root_section.all_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: Union[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.fernert 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("Input must be of type str, int, or float.")

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

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

    def decrypt(self, data: Union[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("Input must be of type str or bytes.")

        d = self._encryptor.decrypt(d_bytes)
        return d.decode()
    
    @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.

        """
        self.movement_manager.forward()

    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.

        """
        self.movement_manager.forward()

    def jump(self, to: Union[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.

        """
        self.movement_manager.jump(to)

    @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)
        
        """
        mongodata = self.data_manager.iter_flat_mongo_data()
        localdata = self.data_manager.iter_flat_local_data()
        if self.config.getboolean("general", "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)
        
        """
        mongodata = self.data_manager.iter_flat_mongo_data(data_type="unlinked")
        localdata = self.data_manager.iter_flat_local_data(data_type="unlinked")
        if self.config.getboolean("general", "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.db.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.db.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
