from typing import List, Optional, Union

from psynet.trial.chain import ChainNetwork, ChainNode, ChainTrial, ChainTrialMaker

from ..utils import get_logger
from .main import Trial

logger = get_logger()


class StaticTrial(ChainTrial):
    """
    A Trial class for static experiments.

    The user must override the ``time_estimate`` class attribute,
    providing the estimated duration of the trial in seconds.
    This is used for predicting the participant's reward
    and for constructing the progress bar.

    Attributes
    ----------

    time_estimate : numeric
        The estimated duration of the trial (including any feedback), in seconds.
        This should generally correspond to the (sum of the) ``time_estimate`` parameters in
        the page(s) generated by ``show_trial``, plus the ``time_estimate`` parameter in
        the page generated by ``show_feedback`` (if defined).
        This is used for predicting the participant's reward
        and for constructing the progress bar.

    participant_id : int
        The ID of the associated participant.
        The user should not typically change this directly.
        Stored in ``property1`` in the database.

    complete : bool
        Whether the trial has been completed (i.e. received a response
        from the participant). The user should not typically change this directly.
        Stored in ``property2`` in the database.

    answer : Object
        The response returned by the participant. This is serialised
        to JSON, so it shouldn't be too big.
        The user should not typically change this directly.
        Stored in ``details`` in the database.

    earliest_async_process_start_time : Optional[datetime]
        Time at which the earliest pending async process was called.

    definition
        A dictionary of parameters defining the trial,
        inherited from the respective :class:`~psynet.trial.static.Stimulus` object.

    participant_group
        The associated participant group.

    block
        The block in which the trial is situated.
    """

    __extra_vars__ = Trial.__extra_vars__.copy()

    def show_trial(self, experiment, participant):
        raise NotImplementedError


class StaticTrialMaker(ChainTrialMaker):
    """
    Administers a sequence of trials in a static experiment.
    The class is intended for use with the
    :class:`~psynet.trial.static.StaticTrial` helper class.
    which should be customised to show the relevant node
    for the experimental paradigm.

    The user may also override the following methods, if desired:

    * :meth:`~psynet.trial.static.StaticTrialMaker.choose_block_order`;
      chooses the order of blocks in the experiment. By default the blocks
      are ordered randomly.

    * :meth:`~psynet.trial.static.StaticTrialMaker.choose_participant_group`;
        Only relevant if the trial maker uses nodes with non-default participant groups.
        In this case the experimenter is expected to supply a function that takes participant as an argument
        and returns the chosen participant group for that trial maker.
        For example, to randomly assign participants to one of two groups called g1 and g2, one could write::

            choose_participant_group=lambda(participant): random.choice(["g1", "g2"])

        Similarly, to alternate participants between two groups, one could write::

            choose_participant_group=lambda(participant): ["g1", "g2"][participant.id % 2]

    * :meth:`~psynet.trial.main.TrialMaker.on_complete`,
      run once the sequence of trials is complete.

    * :meth:`~psynet.trial.main.TrialMaker.performance_check`;
      checks the performance of the participant
      with a view to rejecting poor-performing participants.

    * :meth:`~psynet.trial.main.TrialMaker.compute_performance_reward`;
      computes the final performance reward to assign to the participant.

    Further customisable options are available in the constructor's parameter list,
    documented below.

    Parameters
    ----------

    trial_class
        The class object for trials administered by this maker
        (should subclass :class:`~psynet.trial.static.StaticTrial`).

    nodes
        The nodes to be administered to the participants. This can be provided as
        a list of :class:`~psynet.trial.static.StaticNode` objects,
        or as a function (taking no arguments) that can be called to generate such
        a list. The latter is useful for generating nodes based on local files
        (e.g. large media assets) that are not available on the deployed server.

    expected_trials_per_participant
        Expected number of trials that each participant will complete.
        This is used for timeline/progress estimation purposes.
        This can either be an integer, or the string ``"n_nodes"``,
        which will be read as referring to the number of nodes in ``start_nodes``.

    max_trials_per_participant
        Maximum number of trials that each participant may complete (optional);
        once this number is reached, the participant will move on
        to the next stage in the timeline.
        This can either be an integer, or the string ``"n_nodes"``,
        which will be read as referring to the number of nodes in ``start_nodes``.

    recruit_mode
        Selects a recruitment criterion for determining whether to recruit
        another participant. The built-in criteria are ``"n_participants"``
        and ``"n_trials"``.

    target_n_participants
        Target number of participants to recruit for the experiment. All
        participants must successfully finish the experiment to count
        towards this quota. This target is only relevant if
        ``recruit_mode="n_participants"``.

    target_trials_per_node
        Target number of trials to recruit for each node in the experiment. This target is only relevant if
        ``recruit_mode="n_trials"``.

    max_trials_per_block
        Determines the maximum number of trials that a participant will be allowed to experience in each block,
        including failed trials. Note that this number does not include repeat trials.

    allow_repeated_nodes
        Determines whether the participant can be administered the same node more than once.

    max_unique_nodes_per_block
        Determines the maximum number of unique nodes that a participant will be allowed to experience
        in each block. Once this quota is reached, the participant will be forced to repeat
        previously experienced nodes.

    balance_across_nodes
        If ``True`` (default), active balancing across participants is enabled, meaning that
        node selection favours nodes that have been presented fewest times to any participant
        in the experiment, excluding failed trials.

    check_performance_at_end
        If ``True``, the participant's performance
        is evaluated at the end of the series of trials.
        Defaults to ``False``.
        See :meth:`~psynet.trial.main.TrialMaker.performance_check`
        for implementing performance checks.

    check_performance_every_trial
        If ``True``, the participant's performance
        is evaluated after each trial.
        Defaults to ``False``.
        See :meth:`~psynet.trial.main.TrialMaker.performance_check`
        for implementing performance checks.

    fail_trials_on_premature_exit
        If ``True``, a participant's trials are marked as failed
        if they leave the experiment prematurely.
        Defaults to ``True``.

    fail_trials_on_participant_performance_check
        If ``True``, a participant's trials are marked as failed
        if the participant fails a performance check.
        Defaults to ``True``.

    n_repeat_trials
        Number of repeat trials to present to the participant. These trials
        are typically used to estimate the reliability of the participant's
        responses. Repeat trials are presented at the end of the trial maker,
        after all blocks have been completed.
        Defaults to 0.

    choose_participant_group
        Only relevant if the trial maker uses nodes with non-default participant groups.
        In this case the experimenter is expected to supply a function that takes participant as an argument
        and returns the chosen participant group for that trial maker.
        For example, to randomly assign participants to one of two groups called g1 and g2, one could write::

            choose_participant_group=lambda(participant): random.choice(["g1", "g2"])

        Similarly, to alternate participants between two groups, one could write::

            choose_participant_group=lambda(participant): ["g1", "g2"][participant.id % 2]

    sync_group_type
        Optional SyncGroup type to use for synchronizing participant allocation to nodes.
        When this is set, then the ordinary node allocation logic will only apply to the 'leader'
        of each SyncGroup. The other members of this SyncGroup will follow that leader around,
        so that in every given trial the SyncGroup works on the same node together.

    Attributes
    ----------

    check_timeout_interval_sec : float
        How often to check for trials that have timed out, in seconds (default = 30).
        Users are invited to override this.

    response_timeout_sec : float
        How long until a trial's response times out, in seconds (default = 60)
        (i.e. how long PsyNet will wait for the participant's response to a trial).
        This is a lower bound on the actual timeout
        time, which depends on when the timeout daemon next runs,
        which in turn depends on :attr:`~psynet.trial.main.TrialMaker.check_timeout_interval_sec`.
        Users are invited to override this.

    async_timeout_sec : float
        How long until an async process times out, in seconds (default = 300).
        This is a lower bound on the actual timeout
        time, which depends on when the timeout daemon next runs,
        which in turn depends on :attr:`~psynet.trial.main.TrialMaker.check_timeout_interval_sec`.
        Users are invited to override this.

    network_query : sqlalchemy.orm.Query
        An SQLAlchemy query for retrieving all networks owned by the current trial maker.
        Can be used for operations such as the following: ``self.network_query.count()``.

    n_networks : int
        Returns the number of networks owned by the trial maker.

    networks : list
        Returns the networks owned by the trial maker.

    performance_threshold : float
        Score threshold used by the default performance check method, defaults to 0.0.
        By default, corresponds to the minimum proportion of non-failed trials that
        the participant must achieve to pass the performance check.

    end_performance_check_waits : bool
        If ``True`` (default), then the final performance check waits until all trials no
        longer have any pending asynchronous processes.
    """

    def __init__(
        self,
        *,
        id_: str,
        trial_class,
        nodes: Optional[Union[callable, List["StaticNode"]]],
        expected_trials_per_participant: int | str,
        max_trials_per_participant: Optional[int | str] = None,
        recruit_mode: Optional[str] = None,
        target_n_participants: Optional[int] = None,
        target_trials_per_node: Optional[int] = None,
        max_trials_per_block: Optional[int] = None,
        allow_repeated_nodes: bool = False,
        balance_across_nodes: bool = True,
        check_performance_at_end: bool = False,
        check_performance_every_trial: bool = False,
        fail_trials_on_premature_exit: bool = True,
        fail_trials_on_participant_performance_check: bool = True,
        n_repeat_trials: int = 0,
        assets=None,
        choose_participant_group: Optional[callable] = None,
        sync_group_type: Optional[str] = None,
    ):
        # balance_across_chains = (
        #     active_balancing_across_participants or active_balancing_within_participants
        # )
        # balance_strategy = set()
        # if active_balancing_within_participants:
        #     balance_strategy.add("within")
        # if active_balancing_across_participants:
        #     balance_strategy.add("across")

        assert isinstance(expected_trials_per_participant, (int, float, str))
        if isinstance(expected_trials_per_participant, str):
            assert expected_trials_per_participant == "n_nodes"
            expected_trials_per_participant = (
                "n_start_nodes"  # form expected by ChainTrialMaker
            )

        assert max_trials_per_participant is None or isinstance(
            max_trials_per_participant, (int, float, str)
        )
        if isinstance(max_trials_per_participant, str):
            assert max_trials_per_participant == "n_nodes"
            max_trials_per_participant = (
                "n_start_nodes"  # form expected by ChainTrialMaker
            )

        if callable(nodes):
            if expected_trials_per_participant is None:
                raise ValueError(
                    "If nodes is a function, expected_trials_per_participant must be explicitly provided."
                )
        else:
            assert isinstance(nodes, list)
            if (
                isinstance(expected_trials_per_participant, (int, float))
                and expected_trials_per_participant > len(nodes)
                and not allow_repeated_nodes
            ):
                raise ValueError(
                    f"expected_trials_per_participant ({expected_trials_per_participant}) "
                    f"may not exceed len(nodes) ({len(nodes)}) "
                    "unless allow_repeated_nodes = True."
                )
            # We used to set chains_per_experiment to len(nodes) here, but this is
            # problematic if the nodes are generated from a function, because that
            # function has not yet been called. On reflection, it seems unnecessary to
            # set chains_per_experiment at all if start_nodes is being provided explicitly.
            # chains_per_experiment = len(nodes)

        chains_per_experiment = None

        if allow_repeated_nodes:
            assert (
                max_trials_per_participant is not None
                or max_trials_per_block is not None
            )

        super().__init__(
            id_=id_,
            start_nodes=nodes,
            trial_class=trial_class,
            network_class=StaticNetwork,
            node_class=StaticNode,
            recruit_mode=recruit_mode,
            target_n_participants=target_n_participants,
            expected_trials_per_participant=expected_trials_per_participant,
            max_trials_per_participant=max_trials_per_participant,
            max_trials_per_block=max_trials_per_block,
            chain_type="across",
            chains_per_participant=None,
            chains_per_experiment=chains_per_experiment,
            max_nodes_per_chain=1,
            trials_per_node=target_trials_per_node if target_trials_per_node else 1e6,
            balance_across_chains=balance_across_nodes,
            # balance_strategy=balance_strategy,
            allow_revisiting_networks_in_across_chains=allow_repeated_nodes,
            check_performance_at_end=check_performance_at_end,
            check_performance_every_trial=check_performance_every_trial,
            fail_trials_on_premature_exit=fail_trials_on_premature_exit,
            fail_trials_on_participant_performance_check=fail_trials_on_participant_performance_check,
            n_repeat_trials=n_repeat_trials,
            assets=assets,
            choose_participant_group=choose_participant_group,
            sync_group_type=sync_group_type,
        )


class StaticNetwork(ChainNetwork):
    pass


class StaticNode(ChainNode):
    def summarize_trials(self, trials: list, experiment, participant):
        return None

    def create_definition_from_seed(self, seed, experiment, participant):
        return None
