
from importlib import import_module
from copy import copy

from .parameter import Parameter

from .. import __version__
from ..errors import ParameterError

import logging
log = logging.getLogger(__name__)


class ParametricObject(object):
    """
    Parametric objects may be defined like so:

    .. doctest::

        >>> from cqparts.params import (
        ...     ParametricObject,
        ...     PositiveFloat, IntRange,
        ... )

        >>> class Foo(ParametricObject):
        ...     x = PositiveFloat(5)
        ...     i = IntRange(1, 10, 3)  # between 1 and 10, defaults to 3
        ...     blah = 100

        >>> a = Foo(i=8)
        >>> (a.x, a.i)
        (5.0, 8)

        >>> a = Foo(i=11) # raises exception # doctest: +SKIP
        ParameterError: value of 11 outside the range {1, 10}

        >>> a = Foo(z=1)  # raises exception # doctest: +SKIP
        ParameterError: <class 'Foo'> does not accept parameter(s): z

        >>> a = Foo(x='123', i='2')
        >>> (a.x, a.i)
        (123.0, 2)

        >>> a = Foo(blah=200)  # raises exception, parameters must be Parameter types # doctest: +SKIP
        ParameterError: <class 'Foo'> does not accept any of the parameters: blah

        >>> a = Foo(x=None)  # a.x is None, a.i=3
        >>> (a.x, a.i)
        (None, 3)

    Internally to the object, parameters may be accessed simply with self.x, self.i
    These will always return the type defined

    """
    def __init__(self, **kwargs):
        # get all available parameters (recurse through inherited classes)
        params = self.class_params(hidden=True)

        # parameters explicitly defined during intantiation
        defined_params = set(kwargs.keys())

        # only accept a subset of params
        invalid_params = defined_params - set(params.keys())
        if invalid_params:
            raise ParameterError("{cls} does not accept parameter(s): {keys}".format(
                cls=repr(type(self)),
                keys=', '.join(sorted(invalid_params)),
            ))

        # Cast parameters into this instance
        for (name, param) in params.items():
            if name in kwargs:
                value = param.cast(kwargs[name])
            else:
                value = copy(param.default)
            setattr(self, name, value)

        self.initialize_parameters()

    @classmethod
    def class_param_names(cls, hidden=True):
        """
        Return the names of all class parameters.

        :param hidden: if ``False``, excludes parameters with a ``_`` prefix.
        :type hidden: :class:`bool`
        :return: set of parameter names
        :rtype: :class:`set`
        """
        param_names = set(
            k for (k, v) in cls.__dict__.items()
            if isinstance(v, Parameter)
        )
        for parent in cls.__bases__:
            if hasattr(parent, 'class_param_names'):
                param_names |= parent.class_param_names(hidden=hidden)

        if not hidden:
            param_names = set(n for n in param_names if not n.startswith('_'))
        return param_names

    @classmethod
    def class_params(cls, hidden=True):
        """
        Gets all class parameters, and their :class:`Parameter` instances.

        :return: dict of the form: ``{<name>: <Parameter instance>, ... }``
        :rtype: :class:`dict`

        .. note::

            The :class:`Parameter` instances returned do not have a value, only
            a default value.

            To get a list of an **instance's** parameters and values, use
            :meth:`params` instead.

        """
        param_names = cls.class_param_names(hidden=hidden)
        return dict(
            (name, getattr(cls, name))
            for name in param_names
        )

    def params(self, hidden=True):
        """
        Gets all instance parameters, and their *cast* values.

        :return: dict of the form: ``{<name>: <value>, ... }``
        :rtype: :class:`dict`
        """
        param_names = self.class_param_names(hidden=hidden)
        return dict(
            (name, getattr(self, name))
            for name in param_names
        )

    def __repr__(self):
        # Returns string of the form:
        #   <ClassName: diameter=3.0, height=2.0, twist=0.0>
        params = self.params(hidden=False)
        return "<{cls}: {params}>".format(
            cls=type(self).__name__,
            params=", ".join(
                "%s=%r" % (k, v)
                for (k, v) in sorted(params.items(), key=lambda x: x[0])  # sort by name
            ),
        )

    def initialize_parameters(self):
        """
        A place to set default parameters more intelligently than just a
        simple default value (does nothing by default)

        :return: ``None``

        Executed just prior to exiting the :meth:`__init__` function.

        When overriding, strongly consider calling :meth:`super`.

        """
        pass

    # Serialize / Deserialize
    def serialize(self):
        """
        Encode a :class:`ParametricObject` instance to an object that can be
        encoded by the :mod:`json` module.

        :return: a dict of the format:
        :rtype: :class:`dict`

        ::

            {
                'lib': {  # library information
                    'name': 'cqparts',
                    'version': '0.1.0',
                },
                'class': {  # importable class
                    'module': 'yourpartslib.submodule',  # module containing class
                    'name': 'AwesomeThing',  # class being serialized
                },
                'params': {  # serialized parameters of AwesomeThing
                    'x': 10,
                    'y': 20,
                }
            }

        value of ``params`` key comes from :meth:`serialize_parameters`

        .. important::

            Serialize pulls the class name from the classes ``__name__`` parameter.

            This must be the same name of the object holding the class data, or
            the instance cannot be re-instantiated by :meth:`deserialize`.

        **Examples (good / bad)**

        .. doctest::

            >>> from cqparts.params import ParametricObject, Int

            >>> # GOOD Example
            >>> class A(ParametricObject):
            ...     x = Int(10)
            >>> A().serialize()['class']['name']
            'A'

            >>> # BAD Example
            >>> B = type('Foo', (ParametricObject,), {'x': Int(10)})
            >>> B().serialize()['class']['name']  # doctest: +SKIP
            'Foo'

        In the second example, the classes import name is expected to be ``B``.
        But instead, the *name* ``Foo`` is recorded. This mismatch will be
        irreconcilable when attempting to :meth:`deserialize`.
        """
        return {
            # Encode library information (future-proofing)
            'lib': {
                'name': 'cqparts',
                'version': __version__,
            },
            # class & name record, for automated import when decoding
            'class': {
                'module': type(self).__module__,
                'name': type(self).__name__,
            },
            'params': self.serialize_parameters(),
        }

    def serialize_parameters(self):
        """
        Get the parameter data in its serialized form.

        Data is serialized by each parameter's :meth:`Parameter.serialize`
        implementation.

        :return: serialized parameter data in the form: ``{<name>: <serial data>, ...}``
        :rtype: :class:`dict`
        """

        # Get parameter data
        class_params = self.class_params()
        instance_params = self.params()

        # Serialize each parameter
        serialized = {}
        for name in class_params.keys():
            param = class_params[name]
            value = instance_params[name]
            serialized[name] = param.serialize(value)
        return serialized

    @staticmethod
    def deserialize(data):
        """
        Create instance from serial data
        """
        # Import module & get class
        try:
            module = import_module(data.get('class').get('module'))
            cls = getattr(module, data.get('class').get('name'))
        except ImportError:
            raise ImportError("No module named: %r" % data.get('class').get('module'))
        except AttributeError:
            raise ImportError("module %r does not contain class %r" % (
                data.get('class').get('module'),
                data.get('class').get('name')
            ))

        # Deserialize parameters
        class_params = cls.class_params(hidden=True)
        params = dict(
            (name, class_params[name].deserialize(value))
            for (name, value) in data.get('params').items()
        )

        # Instantiate new instance
        return cls(**params)
