from nengo.base import NengoObject, NengoObjectParam, ObjView
from nengo.config import Config
from nengo.connection import Connection, LearningRule
from nengo.exceptions import ValidationError
from nengo.params import ConnectionDefault, Default, NumberParam, Parameter, StringParam
from nengo.solvers import SolverParam
from nengo.synapses import SynapseParam


class TargetParam(NengoObjectParam):
    def coerce(self, probe, target):  # pylint: disable=arguments-renamed
        obj = target.obj if isinstance(target, ObjView) else target
        if not hasattr(obj, "probeable"):
            raise ValidationError(
                f"Type '{type(obj).__name__}' is not probeable",
                attr=self.name,
                obj=probe,
            )

        # do this after; better to know that type is not Probable first
        if isinstance(obj, LearningRule):
            # TODO: this special case should be able to be removed with #1310
            return Parameter.coerce(self, probe, target)
        else:
            return super().coerce(probe, target)


class AttributeParam(StringParam):
    coerce_defaults = False

    def coerce(self, probe, attr):  # pylint: disable=arguments-renamed
        value = super().coerce(probe, attr)
        if attr not in probe.obj.probeable:
            raise ValidationError(
                f"Attribute '{attr}' is not probeable on {probe.obj}.\n"
                f"Probeable attributes: {probe.obj.probeable}",
                attr=self.name,
                obj=probe,
            )
        return value


class ProbeSolverParam(SolverParam):
    def coerce(self, conn, solver):  # pylint: disable=arguments-renamed
        if solver is ConnectionDefault:
            solver = Config.default(Connection, "solver")
        return super().coerce(conn, solver)


class Probe(NengoObject):
    """A probe is an object that collects data from the simulation.

    This is to be used in any situation where you wish to gather simulation
    data (spike data, represented values, neuron voltages, etc.) for analysis.

    Probes do not directly affect the simulation.

    All Nengo objects can be probed (except Probes themselves).
    Each object has different attributes that can be probed.
    To see what is probeable for each object, print its
    ``probeable`` attribute.

    .. testcode::

       with nengo.Network():
           ens = nengo.Ensemble(10, 1)
       print(ens.probeable)

    .. testoutput::

       ('decoded_output', 'input', 'scaled_encoders')

    Parameters
    ----------
    target : Ensemble, Neurons, Node, or Connection
        The object to probe.

    attr : str, optional
        The signal to probe. Refer to the target's ``probeable`` list for
        details. If None, the first element in the ``probeable`` list
        will be used.
    sample_every : float, optional
        Sampling period in seconds. If None, the ``dt`` of the simluation
        will be used.
    synapse : Synapse, optional
        A synaptic model to filter the probed signal.
    solver : Solver, optional
        `~nengo.solvers.Solver` to compute decoders
        for probes that require them.
    label : str, optional
        A name for the probe. Used for debugging and visualization.
    seed : int, optional
        The seed used for random number generation.

    Attributes
    ----------
    attr : str or None
        The signal that will be probed. If None, the first element of the
        target's ``probeable`` list will be used.
    sample_every : float or None
        Sampling period in seconds. If None, the ``dt`` of the simluation
        will be used.
    solver : Solver or None
        `~nengo.solvers.Solver` to compute decoders. Only used for probes
        of an ensemble's decoded output.
    synapse : Synapse or None
        A synaptic model to filter the probed signal.
    target : Ensemble, Neurons, Node, or Connection
        The object to probe.
    """

    target = TargetParam("target", nonzero_size_out=True)
    attr = AttributeParam("attr", default=None, optional=True)
    sample_every = NumberParam("sample_every", default=None, optional=True, low=1e-10)
    synapse = SynapseParam("synapse", default=None, optional=True)
    solver = ProbeSolverParam("solver", default=ConnectionDefault)

    _param_init_order = ["target"]

    def __init__(
        self,
        target,
        attr=None,
        sample_every=Default,
        synapse=Default,
        solver=Default,
        label=Default,
        seed=Default,
    ):
        super().__init__(label=label, seed=seed)
        self.target = target
        self.attr = attr if attr is not None else self.obj.probeable[0]
        self.sample_every = sample_every
        self.synapse = synapse
        self.solver = solver

    def __repr__(self):
        label_txt = "" if self.label is None else f' "{self.label}"'
        return f"<Probe{label_txt} at 0x{id(self):x} of '{self.attr}' of {self.target}>"

    def __str__(self):
        label_txt = "" if self.label is None else f' "{self.label}"'
        return f"<Probe{label_txt} of '{self.attr}' of {self.target}>"

    @property
    def obj(self):
        """(Nengo object) The underlying Nengo object target."""
        return self.target.obj if isinstance(self.target, ObjView) else self.target

    @property
    def size_in(self):
        """(int) Dimensionality of the probed signal."""
        return self.target.size_out

    @property
    def size_out(self):
        """(int) Cannot connect from probes, so always 0."""
        return 0

    @property
    def slice(self):
        """(slice) The slice associated with the Nengo object target."""
        return self.target.slice if isinstance(self.target, ObjView) else None
