"""
This model defines the `NeuronGroup`, the core of most simulations.
"""

import numbers
import string
from collections.abc import MutableMapping, Sequence

import numpy as np
import sympy
from pyparsing import Word

from brian2.codegen.translation import analyse_identifiers
from brian2.core.preferences import prefs
from brian2.core.spikesource import SpikeSource
from brian2.core.variables import Variables
from brian2.equations.equations import (
    DIFFERENTIAL_EQUATION,
    PARAMETER,
    SUBEXPRESSION,
    Equations,
    check_subexpressions,
    extract_constant_subexpressions,
)
from brian2.equations.refractory import add_refractoriness
from brian2.parsing.expressions import (
    is_boolean_expression,
    parse_expression_dimensions,
)
from brian2.stateupdaters.base import StateUpdateMethod
from brian2.units.allunits import second
from brian2.units.fundamentalunits import (
    DIMENSIONLESS,
    Quantity,
    fail_for_dimension_mismatch,
)
from brian2.utils.logger import get_logger
from brian2.utils.stringtools import get_identifiers

from .group import CodeRunner, Group, get_dtype
from .subgroup import Subgroup

__all__ = ["NeuronGroup"]

logger = get_logger(__name__)


IDENTIFIER = Word(
    f"{string.ascii_letters}_", f"{string.ascii_letters + string.digits}_"
).set_results_name("identifier")


def _valid_event_name(event_name):
    """
    Helper function to check whether a name is a valid name for an event.

    Parameters
    ----------
    event_name : str
        The name to check

    Returns
    -------
    is_valid : bool
        Whether the given name is valid
    """
    parse_result = list(IDENTIFIER.scan_string(event_name))

    # parse_result[0][0][0] refers to the matched string -- this should be the
    # full identifier, if not it is an illegal identifier like "3foo" which only
    # matched on "foo"
    return len(parse_result) == 1 and parse_result[0][0][0] == event_name


def _guess_membrane_potential(equations):
    """
    Little helper function to guess which variable represents the membrane
    potential. This follows the same logic as in Brian1 but is only used to
    give a suggestion in the error message when a Brian1-style syntax is used
    for threshold or reset.
    """
    if len(equations) == 1:
        return list(equations.keys())[0]
    for name in equations:
        if name in ["V", "v", "Vm", "vm"]:
            return name

    # nothing found
    return None


# Note that we do not register this function with
# Equations.register_identifier_check, because we do not want this check to
# apply unconditionally to all equation objects ("x_post = ... : ... (summed)"
# needs to be allowed)
def check_identifier_pre_post(identifier):
    "Do not allow names ending in ``_pre`` or ``_post`` to avoid confusion."
    if identifier.endswith("_pre") or identifier.endswith("_post"):
        raise ValueError(
            f"'{identifier}' cannot be used as a variable name, the "
            "'_pre' and '_post' suffixes are used to refer to pre- and "
            "post-synaptic variables in synapses."
        )


def to_start_stop(item, N):
    """
    Helper function to transform a single number, a slice or an array of
    contiguous indices to a start and stop value. This is used to allow for
    some flexibility in the syntax of specifying subgroups in `.NeuronGroup`
    and `.SpatialNeuron`.

    Parameters
    ----------
    item : slice, int or sequence
        The slice, index, or sequence of indices to use. Note that a sequence
        of indices has to be a sorted ascending sequence of subsequent integers.
    N : int
        The total number of elements in the group.

    Returns
    -------
    start : int
        The start value of the slice.
    stop : int
        The stop value of the slice.

    Examples
    --------
    >>> from brian2.groups.neurongroup import to_start_stop
    >>> to_start_stop(slice(3, 6), 10)
    (3, 6)
    >>> to_start_stop(slice(3, None), 10)
    (3, 10)
    >>> to_start_stop(5, 10)
    (5, 6)
    >>> to_start_stop([3, 4, 5], 10)
    (3, 6)
    >>> to_start_stop([3, 5, 7], 10)
    Traceback (most recent call last):
        ...
    IndexError: Subgroups can only be constructed using contiguous indices.

    """
    if isinstance(item, slice):
        start, stop, step = item.indices(N)
    elif isinstance(item, numbers.Integral):
        start = item
        stop = item + 1
        step = 1
    elif isinstance(item, (Sequence, np.ndarray)) and not isinstance(item, str):
        if not (len(item) > 0 and np.all(np.diff(item) == 1)):
            raise IndexError(
                "Subgroups can only be constructed using contiguous indices."
            )
        if not np.issubdtype(np.asarray(item).dtype, np.integer):
            raise TypeError("Subgroups can only be constructed using integer values.")
        start = int(item[0])
        stop = int(item[-1]) + 1
        step = 1
    else:
        raise TypeError(
            "Subgroups can only be constructed using slicing "
            "syntax, a single index, or an array of contiguous "
            "indices."
        )
    if step != 1:
        raise IndexError("Subgroups have to be contiguous")
    if start >= stop:
        raise IndexError(
            f"Illegal start/end values for subgroup, {int(start)}>={int(stop)}"
        )
    if start >= N:
        raise IndexError(f"Illegal start value for subgroup, {int(start)}>={int(N)}")
    if stop > N:
        raise IndexError(f"Illegal stop value for subgroup, {int(stop)}>{int(N)}")
    if start < 0:
        raise IndexError("Indices have to be positive.")
    return start, stop


class StateUpdater(CodeRunner):
    """
    The `CodeRunner` that updates the state variables of a `NeuronGroup`
    at every timestep.
    """

    def __init__(self, group, method, method_options=None):
        self.method_choice = method
        self.method_options = method_options
        CodeRunner.__init__(
            self,
            group,
            "stateupdate",
            code="",  # will be set in update_abstract_code
            clock=group.clock,
            when="groups",
            order=group.order,
            name=f"{group.name}_stateupdater",
            check_units=False,
            generate_empty_code=False,
        )

    def _get_refractory_code(self, run_namespace):
        ref = self.group._refractory
        if ref is False:
            # No refractoriness
            abstract_code = ""
        elif isinstance(ref, Quantity):
            fail_for_dimension_mismatch(
                ref,
                second,
                "Refractory period has to "
                "be specified in units "
                "of seconds but got "
                "{value}",
                value=ref,
            )
            ref = float(ref)
            if prefs.legacy.refractory_timing:
                abstract_code = f"not_refractory = (t - lastspike) > {ref}\n"
            else:
                abstract_code = f"not_refractory = timestep(t - lastspike, dt) >= timestep({ref}, dt)\n"
        else:
            identifiers = get_identifiers(ref)
            variables = self.group.resolve_all(
                identifiers, run_namespace, user_identifiers=identifiers
            )
            dims = parse_expression_dimensions(str(ref), variables)
            if dims is second.dim:
                if prefs.legacy.refractory_timing:
                    abstract_code = f"(t - lastspike) > {ref}\n"
                else:
                    abstract_code = (
                        "not_refractory = timestep(t - lastspike, dt) >="
                        f" timestep({ref}, dt)\n"
                    )
            elif dims is DIMENSIONLESS:
                if not is_boolean_expression(str(ref), variables):
                    raise TypeError(
                        "Refractory expression is dimensionless "
                        "but not a boolean value. It needs to "
                        "either evaluate to a timespan or to a "
                        "boolean value."
                    )
                # boolean condition
                # we have to be a bit careful here, we can't just use the given
                # condition as it is, because we only want to *leave*
                # refractoriness, based on the condition
                abstract_code = f"not_refractory = not_refractory or not ({ref})\n"
            else:
                raise TypeError(
                    "Refractory expression has to evaluate to a "
                    "timespan or a boolean value, expression"
                    f"'{ref}' has units {dims} instead"
                )
        return abstract_code

    def update_abstract_code(self, run_namespace):
        # Update the not_refractory variable for the refractory period mechanism
        self.abstract_code = self._get_refractory_code(run_namespace=run_namespace)

        # Get the names used in the refractory code
        _, used_known, unknown = analyse_identifiers(
            self.abstract_code, self.group.variables, recursive=True
        )

        # Get all names used in the equations (and always get "dt")
        names = self.group.equations.names
        external_names = self.group.equations.identifiers | {"dt"}

        variables = self.group.resolve_all(
            used_known | unknown | names | external_names,
            run_namespace,
            # we don't need to raise any warnings
            # for the user here, warnings will
            # be raised in create_runner_codeobj
            user_identifiers=set(),
        )
        if len(self.group.equations.diff_eq_names) > 0:
            stateupdate_output = StateUpdateMethod.apply_stateupdater(
                self.group.equations,
                variables,
                self.method_choice,
                method_options=self.method_options,
                group_name=self.group.name,
            )
            if isinstance(stateupdate_output, str):
                self.abstract_code += stateupdate_output
            else:
                # Note that the reason to send self along with this method is so the StateUpdater
                # can be modified! i.e. in GSL StateUpdateMethod a custom CodeObject gets added
                # to the StateUpdater together with some auxiliary information
                self.abstract_code += stateupdate_output(self)

        user_code = "\n".join(
            [
                f"{var} = {expr}"
                for var, expr in self.group.equations.get_substituted_expressions(
                    variables
                )
            ]
        )
        self.user_code = user_code


class SubexpressionUpdater(CodeRunner):
    """
    The `CodeRunner` that updates the state variables storing the values of
    subexpressions that have been marked as "constant over dt".
    """

    def __init__(self, group, subexpressions, when="before_start"):
        code_lines = []
        for subexpr in subexpressions.ordered:
            code_lines.append(f"{subexpr.varname} = {subexpr.expr}")
        code = "\n".join(code_lines)
        CodeRunner.__init__(
            self,
            group,
            "stateupdate",
            code=code,  # will be set in update_abstract_code
            clock=group.clock,
            when=when,
            order=group.order,
            name=f"{group.name}_subexpression_update*",
        )


class Thresholder(CodeRunner):
    """
    The `CodeRunner` that applies the threshold condition to the state
    variables of a `NeuronGroup` at every timestep and sets its ``spikes``
    and ``refractory_until`` attributes.
    """

    def __init__(self, group, when="thresholds", event="spike"):
        self.event = event
        if group._refractory is False or event != "spike":
            template_kwds = {"_uses_refractory": False}
            needed_variables = []
        else:
            template_kwds = {"_uses_refractory": True}
            needed_variables = ["t", "not_refractory", "lastspike"]
        # Since this now works for general events not only spikes, we have to
        # pass the information about which variable to use to the template,
        # it can not longer simply refer to "_spikespace"
        eventspace_name = f"_{event}space"
        template_kwds["eventspace_variable"] = group.variables[eventspace_name]
        needed_variables.append(eventspace_name)
        self.variables = Variables(self)
        self.variables.add_auxiliary_variable("_cond", dtype=bool)
        CodeRunner.__init__(
            self,
            group,
            "threshold",
            code="",  # will be set in update_abstract_code
            clock=group.clock,
            when=when,
            order=group.order,
            name=f"{group.name}_{event}_thresholder",
            needed_variables=needed_variables,
            template_kwds=template_kwds,
        )

    def update_abstract_code(self, run_namespace):
        code = self.group.events[self.event]
        # Raise a useful error message when the user used a Brian1 syntax
        if not isinstance(code, str):
            if isinstance(code, Quantity):
                t = "a quantity"
            else:
                t = f"{type(code)}"
            error_msg = f"Threshold condition has to be a string, not {t}."
            if self.event == "spike":
                try:
                    vm_var = _guess_membrane_potential(self.group.equations)
                except AttributeError:  # not a group with equations...
                    vm_var = None
                if vm_var is not None:
                    error_msg += f" Probably you intended to use '{vm_var} > ...'?"
            raise TypeError(error_msg)

        self.user_code = f"_cond = {code}"

        identifiers = get_identifiers(code)
        variables = self.group.resolve_all(
            identifiers, run_namespace, user_identifiers=identifiers
        )
        if not is_boolean_expression(code, variables):
            raise TypeError(f"Threshold condition '{code}' is not a boolean expression")
        if self.group._refractory is False or self.event != "spike":
            self.abstract_code = f"_cond = {code}"
        else:
            self.abstract_code = f"_cond = ({code}) and not_refractory"


class Resetter(CodeRunner):
    """
    The `CodeRunner` that applies the reset statement(s) to the state
    variables of neurons that have spiked in this timestep.
    """

    def __init__(self, group, when="resets", order=None, event="spike"):
        self.event = event
        # Since this now works for general events not only spikes, we have to
        # pass the information about which variable to use to the template,
        # it can not longer simply refer to "_spikespace"
        eventspace_name = f"_{event}space"
        template_kwds = {"eventspace_variable": group.variables[eventspace_name]}
        needed_variables = [eventspace_name]
        order = order if order is not None else group.order
        CodeRunner.__init__(
            self,
            group,
            "reset",
            code="",  # will be set in update_abstract_code
            clock=group.clock,
            when=when,
            order=order,
            name=f"{group.name}_{event}_resetter",
            override_conditional_write=["not_refractory"],
            needed_variables=needed_variables,
            template_kwds=template_kwds,
        )

    def update_abstract_code(self, run_namespace):
        code = self.group.event_codes[self.event]
        # Raise a useful error message when the user used a Brian1 syntax
        if not isinstance(code, str):
            if isinstance(code, Quantity):
                t = "a quantity"
            else:
                t = f"{type(code)}"
            error_msg = f"Reset statement has to be a string, not {t}."
            if self.event == "spike":
                vm_var = _guess_membrane_potential(self.group.equations)
                if vm_var is not None:
                    error_msg += f" Probably you intended to use '{vm_var} = ...'?"
            raise TypeError(error_msg)

        self.abstract_code = code


class NeuronGroup(Group, SpikeSource):
    """
    A group of neurons.


    Parameters
    ----------
    N : int
        Number of neurons in the group.
    model : str, `Equations`
        The differential equations defining the group
    method : (str, function), optional
        The numerical integration method. Either a string with the name of a
        registered method (e.g. "euler") or a function that receives an
        `Equations` object and returns the corresponding abstract code. If no
        method is specified, a suitable method will be chosen automatically.
    threshold : str, optional
        The condition which produces spikes. Should be a single line boolean
        expression.
    reset : str, optional
        The (possibly multi-line) string with the code to execute on reset.
    refractory : {str, `Quantity`}, optional
        Either the length of the refractory period (e.g. ``2*ms``), a string
        expression that evaluates to the length of the refractory period
        after each spike (e.g. ``'(1 + rand())*ms'``), or a string expression
        evaluating to a boolean value, given the condition under which the
        neuron stays refractory after a spike (e.g. ``'v > -20*mV'``)
    events : dict, optional
        User-defined events in addition to the "spike" event defined by the
        ``threshold``. Has to be a mapping of strings (the event name) to
        strings (the condition) that will be checked.
    namespace: dict, optional
        A dictionary mapping identifier names to objects. If not given, the
        namespace will be filled in at the time of the call of `Network.run`,
        with either the values from the ``namespace`` argument of the
        `Network.run` method or from the local context, if no such argument is
        given.
    dtype : (`dtype`, `dict`), optional
        The `numpy.dtype` that will be used to store the values, or a
        dictionary specifying the type for variable names. If a value is not
        provided for a variable (or no value is provided at all), the preference
        setting `core.default_float_dtype` is used.
    codeobj_class : class, optional
        The `CodeObject` class to run code with.
    dt : `Quantity`, optional
        The time step to be used for the simulation. Cannot be combined with
        the `clock` argument.
    clock : `Clock`, optional
        The update clock to be used. If neither a clock, nor the `dt` argument
        is specified, the `defaultclock` will be used.
    order : int, optional
        The priority of of this group for operations occurring at the same time
        step and in the same scheduling slot. Defaults to 0.
    name : str, optional
        A unique name for the group, otherwise use ``neurongroup_0``, etc.

    Notes
    -----
    `NeuronGroup` contains a `StateUpdater`, `Thresholder` and `Resetter`, and
    these are run at the 'groups', 'thresholds' and 'resets' slots (i.e. the
    values of their `when` attribute take these values). The `order`
    attribute will be passed down to the contained objects but can be set
    individually by setting the `order` attribute of the `state_updater`,
    `thresholder` and `resetter` attributes, respectively.
    """

    add_to_magic_network = True

    def __init__(
        self,
        N,
        model,
        method=("exact", "euler", "heun"),
        method_options=None,
        threshold=None,
        reset=None,
        refractory=False,
        events=None,
        namespace=None,
        dtype=None,
        dt=None,
        clock=None,
        order=0,
        name="neurongroup*",
        codeobj_class=None,
    ):
        Group.__init__(
            self,
            dt=dt,
            clock=clock,
            when="start",
            order=order,
            namespace=namespace,
            name=name,
        )
        if dtype is None:
            dtype = {}
        if isinstance(dtype, MutableMapping):
            dtype["lastspike"] = self._clock.variables["t"].dtype

        self.codeobj_class = codeobj_class

        try:
            self._N = N = int(N)
        except ValueError:
            if isinstance(N, str):
                raise TypeError(
                    "First NeuronGroup argument should be size, not equations."
                ) from None
            raise
        if N < 1:
            raise ValueError(f"NeuronGroup size should be at least 1, was {str(N)}")

        self.start = 0
        self.stop = self._N

        ##### Prepare and validate equations
        if isinstance(model, str):
            model = Equations(model)
        if not isinstance(model, Equations):
            raise TypeError(
                "model has to be a string or an Equations "
                f"object, is '{type(model)}' instead."
            )

        # Check flags
        model.check_flags(
            {
                DIFFERENTIAL_EQUATION: ("unless refractory",),
                PARAMETER: ("constant", "shared", "linked"),
                SUBEXPRESSION: ("shared", "constant over dt"),
            }
        )

        # add refractoriness
        #: The original equations as specified by the user (i.e. without
        #: the multiplied `int(not_refractory)` term for equations marked as
        #: `(unless refractory)`)
        self.user_equations = model
        if refractory is not False:
            model = add_refractoriness(model)
        uses_refractoriness = len(model) and any(
            [
                "unless refractory" in eq.flags
                for eq in model.values()
                if eq.type == DIFFERENTIAL_EQUATION
            ]
        )

        # Separate subexpressions depending whether they are considered to be
        # constant over a time step or not
        model, constant_over_dt = extract_constant_subexpressions(model)
        self.equations = model

        self._linked_variables = set()
        logger.diagnostic(
            f"Creating NeuronGroup of size {self._N}, equations {self.equations}."
        )

        # All of the following will be created in before_run

        #: The refractory condition or timespan
        self._refractory = refractory
        if uses_refractoriness and refractory is False:
            logger.warn(
                'Model equations use the "unless refractory" flag but '
                "no refractory keyword was given.",
                "no_refractory",
            )

        #: The state update method selected by the user
        self.method_choice = method

        if events is None:
            events = {}

        if threshold is not None and (reset is None and refractory is False):
            if not ("rand(" in threshold or "randn(" in threshold):
                logger.warn(
                    f"The NeuronGroup '{self.name}' sets a threshold but "
                    "neither a reset condition nor a refractory "
                    "condition has been set. Did you forget either of "
                    "those? If this was intended, set the reset "
                    "argument to an empty string in order to avoid "
                    "this warning.",
                    name_suffix="only_threshold",
                )

        if threshold is not None:
            if "spike" in events:
                raise ValueError(
                    "The NeuronGroup defines both a threshold and a 'spike' event"
                )
            events["spike"] = threshold

        # Setup variables
        # Since we have to create _spikespace and possibly other "eventspace"
        # variables, we pass the supported events
        self._create_variables(dtype, events=list(events.keys()))

        #: Events supported by this group
        self.events = events

        #: Code that is triggered on events (e.g. reset)
        self.event_codes = {}

        #: Checks the spike threshold (or abitrary user-defined events)
        self.thresholder = {}

        #: Reset neurons which have spiked (or perform arbitrary actions for
        #: user-defined events)
        self.resetter = {}

        for event_name in events.keys():
            if not isinstance(event_name, str):
                raise TypeError(
                    "Keys in the 'events' dictionary have to be "
                    f"strings, not type {event_name}."
                )
            if not _valid_event_name(event_name):
                raise TypeError(
                    f"The name '{event_name}' cannot be used as an event name."
                )
            # By default, user-defined events are checked after the threshold
            when = "thresholds" if event_name == "spike" else "after_thresholds"
            # creating a Thresholder will take care of checking the validity
            # of the condition
            thresholder = Thresholder(self, event=event_name, when=when)
            self.thresholder[event_name] = thresholder
            self.contained_objects.append(thresholder)

        if reset is not None:
            self.run_on_event("spike", reset, when="resets")

        #: Performs numerical integration step
        self.state_updater = StateUpdater(self, method, method_options)
        self.contained_objects.append(self.state_updater)

        #: Update the "constant over a time step" subexpressions
        self.subexpression_updater = None
        if len(constant_over_dt):
            self.subexpression_updater = SubexpressionUpdater(self, constant_over_dt)
            self.contained_objects.append(self.subexpression_updater)

        if refractory is not False:
            # Set the refractoriness information
            self.variables["lastspike"].set_value(-1e4 * second)
            self.variables["not_refractory"].set_value(True)

        # Activate name attribute access
        self._enable_group_attributes()

    @property
    def spikes(self):
        """
        The spikes returned by the most recent thresholding operation.
        """
        # Note that we have to directly access the ArrayVariable object here
        # instead of using the Group mechanism by accessing self._spikespace
        # Using the latter would cut _spikespace to the length of the group
        spikespace = self.variables["_spikespace"].get_value()
        return spikespace[: spikespace[-1]]

    def state(self, name, use_units=True, level=0):
        try:
            return Group.state(self, name, use_units=use_units, level=level + 1)
        except KeyError as ex:
            if name in self._linked_variables:
                raise TypeError(
                    f"Link target for variable {name} has not been set."
                ) from ex
            else:
                raise ex

    def run_on_event(self, event, code, when="after_resets", order=None):
        """
        Run code triggered by a custom-defined event (see `NeuronGroup`
        documentation for the specification of events).The created `Resetter`
        object will be automatically added to the group, it therefore does not
        need to be added to the network manually. However, a reference to the
        object will be returned, which can be used to later remove it from the
        group or to set it to inactive.

        Parameters
        ----------
        event : str
            The name of the event that should trigger the code
        code : str
            The code that should be executed
        when : str, optional
            The scheduling slot that should be used to execute the code.
            Defaults to `'after_resets'`. See :ref:`scheduling` for possible values.
        order : int, optional
            The order for operations in the same scheduling slot. Defaults to
            the order of the `NeuronGroup`.

        Returns
        -------
        obj : `Resetter`
            A reference to the object that will be run.
        """
        if event not in self.events:
            error_message = f"Unknown event '{event}'."
            if event == "spike":
                error_message += " Did you forget to define a threshold?"
            raise ValueError(error_message)
        if event in self.resetter:
            raise ValueError(
                "Cannot add code for event '%s', code for this "
                "event has already been added." % event
            )
        self.event_codes[event] = code
        resetter = Resetter(self, when=when, order=order, event=event)
        self.resetter[event] = resetter
        self.contained_objects.append(resetter)

        return resetter

    def set_event_schedule(self, event, when="after_thresholds", order=None):
        """
        Change the scheduling slot for checking the condition of an event.

        Parameters
        ----------
        event : str
            The name of the event for which the scheduling should be changed
        when : str, optional
            The scheduling slot that should be used to check the condition.
            Defaults to `'after_thresholds'`. See :ref:`scheduling` for possible values.
        order : int, optional
            The order for operations in the same scheduling slot. Defaults to
            the order of the `NeuronGroup`.
        """
        if event not in self.thresholder:
            raise ValueError(f"Unknown event '{event}'.")
        order = order if order is not None else self.order
        self.thresholder[event].when = when
        self.thresholder[event].order = order

    def __getitem__(self, item):
        start, stop = to_start_stop(item, self._N)

        return Subgroup(self, start, stop)

    def _create_variables(self, user_dtype, events):
        """
        Create the variables dictionary for this `NeuronGroup`, containing
        entries for the equation variables and some standard entries.
        """
        self.variables = Variables(self)
        self.variables.add_constant("N", self._N)

        # Standard variables always present
        for event in events:
            self.variables.add_array(
                f"_{event}space", size=self._N + 1, dtype=np.int32, constant=False
            )
        # Add the special variable "i" which can be used to refer to the neuron index
        self.variables.add_arange("i", size=self._N, constant=True, read_only=True)
        # Add the clock variables
        self.variables.create_clock_variables(self._clock)

        for eq in self.equations.values():
            dtype = get_dtype(eq, user_dtype)
            check_identifier_pre_post(eq.varname)
            if eq.type in (DIFFERENTIAL_EQUATION, PARAMETER):
                if "linked" in eq.flags:
                    # 'linked' cannot be combined with other flags
                    if not len(eq.flags) == 1:
                        raise SyntaxError(
                            "The 'linked' flag cannot be combined with other flags"
                        )
                    self._linked_variables.add(eq.varname)
                else:
                    constant = "constant" in eq.flags
                    shared = "shared" in eq.flags
                    size = 1 if shared else self._N
                    self.variables.add_array(
                        eq.varname,
                        size=size,
                        dimensions=eq.dim,
                        dtype=dtype,
                        constant=constant,
                        scalar=shared,
                    )
            elif eq.type == SUBEXPRESSION:
                self.variables.add_subexpression(
                    eq.varname,
                    dimensions=eq.dim,
                    expr=str(eq.expr),
                    dtype=dtype,
                    scalar="shared" in eq.flags,
                )
            else:
                raise AssertionError(f"Unknown type of equation: {eq.eq_type}")

        # Add the conditional-write attribute for variables with the
        # "unless refractory" flag
        if self._refractory is not False:
            for eq in self.equations.values():
                if eq.type == DIFFERENTIAL_EQUATION and "unless refractory" in eq.flags:
                    not_refractory_var = self.variables["not_refractory"]
                    var = self.variables[eq.varname]
                    var.set_conditional_write(not_refractory_var)

        # Stochastic variables
        for xi in self.equations.stochastic_variables:
            self.variables.add_auxiliary_variable(xi, dimensions=(second**-0.5).dim)

        # Check scalar subexpressions
        for eq in self.equations.values():
            if eq.type == SUBEXPRESSION and "shared" in eq.flags:
                var = self.variables[eq.varname]
                for identifier in var.identifiers:
                    if identifier in self.variables:
                        if not self.variables[identifier].scalar:
                            raise SyntaxError(
                                f"Shared subexpression '{eq.varname}' "
                                "refers to non-shared variable "
                                f"'{identifier}'."
                            )

    def before_run(self, run_namespace=None):
        # Check units
        self.equations.check_units(self, run_namespace=run_namespace)
        # Check that subexpressions that refer to stateful functions are labeled
        # as "constant over dt"
        check_subexpressions(self, self.equations, run_namespace)
        super().before_run(run_namespace=run_namespace)

    def _repr_html_(self):
        text = [rf"NeuronGroup '{self.name}' with {self._N} neurons.<br>"]
        text.append(r"<b>Model:</b><nr>")
        text.append(sympy.latex(self.equations))

        def add_event_to_text(event):
            if event == "spike":
                event_header = "Spiking behaviour"
                event_condition = "Threshold condition"
                event_code = "Reset statement(s)"
            else:
                event_header = f'Event "{event}"'
                event_condition = "Event condition"
                event_code = "Executed statement(s)"
            condition = self.events[event]
            text.append(
                rf'<b>{event_header}:</b><ul style="list-style-type: none; margin-top:'
                r' 0px;">'
            )
            text.append(rf"<li><i>{event_condition}: </i>")
            text.append(f"<code>{str(condition)}</code></li>")
            statements = self.event_codes.get(event, None)
            if statements is not None:
                text.append(rf"<li><i>{event_code}:</i>")
                if "\n" in str(statements):
                    text.append("</br>")
                text.append(rf"<code>{str(statements)}</code></li>")
            text.append("</ul>")

        if "spike" in self.events:
            add_event_to_text("spike")
        for event in self.events:
            if event != "spike":
                add_event_to_text(event)

        return "\n".join(text)
