# orm/state.py
# Copyright (C) 2005-2013 the SQLAlchemy authors and contributors <see AUTHORS file>
#
# This module is part of SQLAlchemy and is released under
# the MIT License: http://www.opensource.org/licenses/mit-license.php

"""Defines instrumentation of instances.

This module is usually not directly visible to user applications, but
defines a large part of the ORM's interactivity.

"""

import weakref
from .. import util
from . import exc as orm_exc, attributes, util as orm_util, interfaces
from .attributes import (
    PASSIVE_NO_RESULT,
    SQL_OK, NEVER_SET, ATTR_WAS_SET, NO_VALUE,\
    PASSIVE_NO_INITIALIZE
    )
sessionlib = util.importlater("sqlalchemy.orm", "session")
instrumentation = util.importlater("sqlalchemy.orm", "instrumentation")
mapperlib = util.importlater("sqlalchemy.orm", "mapperlib")


class InstanceState(interfaces._InspectionAttr):
    """tracks state information at the instance level."""

    session_id = None
    key = None
    runid = None
    load_options = util.EMPTY_SET
    load_path = ()
    insert_order = None
    _strong_obj = None
    modified = False
    expired = False
    deleted = False
    _load_pending = False

    is_instance = True

    def __init__(self, obj, manager):
        self.class_ = obj.__class__
        self.manager = manager
        self.obj = weakref.ref(obj, self._cleanup)
        self.callables = {}
        self.committed_state = {}

    @util.memoized_property
    def attrs(self):
        """Return a namespace representing each attribute on
        the mapped object, including its current value
        and history.

        The returned object is an instance of :class:`.AttributeState`.

        """
        return util.ImmutableProperties(
            dict(
                (key, AttributeState(self, key))
                for key in self.manager
            )
        )

    @property
    def transient(self):
        """Return true if the object is transient."""
        return self.key is None and \
            not self._attached

    @property
    def pending(self):
        """Return true if the object is pending."""
        return self.key is None and \
            self._attached

    @property
    def persistent(self):
        """Return true if the object is persistent."""
        return self.key is not None and \
            self._attached

    @property
    def detached(self):
        """Return true if the object is detached."""
        return self.key is not None and \
            not self._attached

    @property
    def _attached(self):
        return self.session_id is not None and \
            self.session_id in sessionlib._sessions

    @property
    def session(self):
        """Return the owning :class:`.Session` for this instance,
        or ``None`` if none available."""

        return sessionlib._state_session(self)

    @property
    def object(self):
        """Return the mapped object represented by this
        :class:`.InstanceState`."""
        return self.obj()

    @property
    def identity(self):
        """Return the mapped identity of the mapped object.
        This is the primary key identity as persisted by the ORM
        which can always be passed directly to
        :meth:`.Query.get`.

        Returns ``None`` if the object has no primary key identity.

        .. note::
            An object which is transient or pending
            does **not** have a mapped identity until it is flushed,
            even if its attributes include primary key values.

        """
        if self.key is None:
            return None
        else:
            return self.key[1]

    @property
    def identity_key(self):
        """Return the identity key for the mapped object.

        This is the key used to locate the object within
        the :attr:`.Session.identity_map` mapping.   It contains
        the identity as returned by :attr:`.identity` within it.


        """
        # TODO: just change .key to .identity_key across
        # the board ?  probably
        return self.key

    @util.memoized_property
    def parents(self):
        return {}

    @util.memoized_property
    def _pending_mutations(self):
        return {}

    @util.memoized_property
    def mapper(self):
        """Return the :class:`.Mapper` used for this mapepd object."""
        return self.manager.mapper

    @property
    def has_identity(self):
        """Return ``True`` if this object has an identity key.

        This should always have the same value as the
        expression ``state.persistent or state.detached``.

        """
        return bool(self.key)

    def _detach(self):
        self.session_id = self._strong_obj = None

    def _dispose(self):
        self._detach()
        del self.obj

    def _cleanup(self, ref):
        instance_dict = self._instance_dict()
        if instance_dict:
            instance_dict.discard(self)

        self.callables = {}
        self.session_id = self._strong_obj = None
        del self.obj

    def obj(self):
        return None

    @property
    def dict(self):
        o = self.obj()
        if o is not None:
            return attributes.instance_dict(o)
        else:
            return {}

    def _initialize_instance(*mixed, **kwargs):
        self, instance, args = mixed[0], mixed[1], mixed[2:]
        manager = self.manager

        manager.dispatch.init(self, args, kwargs)

        try:
            return manager.original_init(*mixed[1:], **kwargs)
        except:
            manager.dispatch.init_failure(self, args, kwargs)
            raise

    def get_history(self, key, passive):
        return self.manager[key].impl.get_history(self, self.dict, passive)

    def get_impl(self, key):
        return self.manager[key].impl

    def _get_pending_mutation(self, key):
        if key not in self._pending_mutations:
            self._pending_mutations[key] = PendingCollection()
        return self._pending_mutations[key]

    def __getstate__(self):
        d = {'instance': self.obj()}
        d.update(
            (k, self.__dict__[k]) for k in (
                'committed_state', '_pending_mutations', 'modified', 'expired',
                'callables', 'key', 'parents', 'load_options',
                'class_',
            ) if k in self.__dict__
        )
        if self.load_path:
            d['load_path'] = self.load_path.serialize()

        self.manager.dispatch.pickle(self, d)

        return d

    def __setstate__(self, state):
        inst = state['instance']
        if inst is not None:
            self.obj = weakref.ref(inst, self._cleanup)
            self.class_ = inst.__class__
        else:
            # None being possible here generally new as of 0.7.4
            # due to storage of state in "parents".  "class_"
            # also new.
            self.obj = None
            self.class_ = state['class_']
        self.manager = manager = instrumentation.manager_of_class(self.class_)
        if manager is None:
            raise orm_exc.UnmappedInstanceError(
                        inst,
                        "Cannot deserialize object of type %r - "
                        "no mapper() has "
                        "been configured for this class within the current "
                        "Python process!" %
                        self.class_)
        elif manager.is_mapped and not manager.mapper.configured:
            mapperlib.configure_mappers()

        self.committed_state = state.get('committed_state', {})
        self._pending_mutations = state.get('_pending_mutations', {})
        self.parents = state.get('parents', {})
        self.modified = state.get('modified', False)
        self.expired = state.get('expired', False)
        self.callables = state.get('callables', {})

        self.__dict__.update([
            (k, state[k]) for k in (
                'key', 'load_options',
            ) if k in state
        ])

        if 'load_path' in state:
            self.load_path = orm_util.PathRegistry.\
                                deserialize(state['load_path'])

        # setup _sa_instance_state ahead of time so that
        # unpickle events can access the object normally.
        # see [ticket:2362]
        if inst is not None:
            manager.setup_instance(inst, self)
        manager.dispatch.unpickle(self, state)

    def _initialize(self, key):
        """Set this attribute to an empty value or collection,
           based on the AttributeImpl in use."""

        self.manager.get_impl(key).initialize(self, self.dict)

    def _reset(self, dict_, key):
        """Remove the given attribute and any
           callables associated with it."""

        old = dict_.pop(key, None)
        if old is not None and self.manager[key].impl.collection:
            self.manager[key].impl._invalidate_collection(old)
        self.callables.pop(key, None)

    def _expire_attribute_pre_commit(self, dict_, key):
        """a fast expire that can be called by column loaders during a load.

        The additional bookkeeping is finished up in commit_all().

        Should only be called for scalar attributes.

        This method is actually called a lot with joined-table
        loading, when the second table isn't present in the result.

        """
        dict_.pop(key, None)
        self.callables[key] = self

    def _set_callable(self, dict_, key, callable_):
        """Remove the given attribute and set the given callable
           as a loader."""

        old = dict_.pop(key, None)
        if old is not None and self.manager[key].impl.collection:
            self.manager[key].impl._invalidate_collection(old)
        self.callables[key] = callable_

    def _expire(self, dict_, modified_set):
        self.expired = True
        if self.modified:
            modified_set.discard(self)

        self.modified = False
        self._strong_obj = None

        self.committed_state.clear()

        InstanceState._pending_mutations._reset(self)

        # clear out 'parents' collection.  not
        # entirely clear how we can best determine
        # which to remove, or not.
        InstanceState.parents._reset(self)

        for key in self.manager:
            impl = self.manager[key].impl
            if impl.accepts_scalar_loader and \
                    (impl.expire_missing or key in dict_):
                self.callables[key] = self
            old = dict_.pop(key, None)
            if impl.collection and old is not None:
                impl._invalidate_collection(old)

        self.manager.dispatch.expire(self, None)

    def _expire_attributes(self, dict_, attribute_names):
        pending = self.__dict__.get('_pending_mutations', None)

        for key in attribute_names:
            impl = self.manager[key].impl
            if impl.accepts_scalar_loader:
                self.callables[key] = self
            old = dict_.pop(key, None)
            if impl.collection and old is not None:
                impl._invalidate_collection(old)

            self.committed_state.pop(key, None)
            if pending:
                pending.pop(key, None)

        self.manager.dispatch.expire(self, attribute_names)

    def __call__(self, passive):
        """__call__ allows the InstanceState to act as a deferred
        callable for loading expired attributes, which is also
        serializable (picklable).

        """

        if not passive & SQL_OK:
            return PASSIVE_NO_RESULT

        toload = self.expired_attributes.\
                        intersection(self.unmodified)

        self.manager.deferred_scalar_loader(self, toload)

        # if the loader failed, or this
        # instance state didn't have an identity,
        # the attributes still might be in the callables
        # dict.  ensure they are removed.
        for k in toload.intersection(self.callables):
            del self.callables[k]

        return ATTR_WAS_SET

    @property
    def unmodified(self):
        """Return the set of keys which have no uncommitted changes"""

        return set(self.manager).difference(self.committed_state)

    def unmodified_intersection(self, keys):
        """Return self.unmodified.intersection(keys)."""

        return set(keys).intersection(self.manager).\
                    difference(self.committed_state)

    @property
    def unloaded(self):
        """Return the set of keys which do not have a loaded value.

        This includes expired attributes and any other attribute that
        was never populated or modified.

        """
        return set(self.manager).\
                    difference(self.committed_state).\
                    difference(self.dict)

    @property
    def expired_attributes(self):
        """Return the set of keys which are 'expired' to be loaded by
           the manager's deferred scalar loader, assuming no pending
           changes.

           see also the ``unmodified`` collection which is intersected
           against this set when a refresh operation occurs.

        """
        return set([k for k, v in self.callables.items() if v is self])

    def _instance_dict(self):
        return None

    def _modified_event(self, dict_, attr, previous, collection=False):
        if attr.key not in self.committed_state:
            if collection:
                if previous is NEVER_SET:
                    if attr.key in dict_:
                        previous = dict_[attr.key]

                if previous not in (None, NO_VALUE, NEVER_SET):
                    previous = attr.copy(previous)

            self.committed_state[attr.key] = previous

        # assert self._strong_obj is None or self.modified

        if (self.session_id and self._strong_obj is None) \
                or not self.modified:
            instance_dict = self._instance_dict()
            if instance_dict:
                instance_dict._modified.add(self)

            # only create _strong_obj link if attached
            # to a session

            inst = self.obj()
            if self.session_id:
                self._strong_obj = inst

            if inst is None:
                raise orm_exc.ObjectDereferencedError(
                        "Can't emit change event for attribute '%s' - "
                        "parent object of type %s has been garbage "
                        "collected."
                        % (
                            self.manager[attr.key],
                            orm_util.state_class_str(self)
                        ))
            self.modified = True

    def _commit(self, dict_, keys):
        """Commit attributes.

        This is used by a partial-attribute load operation to mark committed
        those attributes which were refreshed from the database.

        Attributes marked as "expired" can potentially remain "expired" after
        this step if a value was not populated in state.dict.

        """
        for key in keys:
            self.committed_state.pop(key, None)

        self.expired = False

        for key in set(self.callables).\
                            intersection(keys).\
                            intersection(dict_):
            del self.callables[key]

    def _commit_all(self, dict_, instance_dict=None):
        """commit all attributes unconditionally.

        This is used after a flush() or a full load/refresh
        to remove all pending state from the instance.

         - all attributes are marked as "committed"
         - the "strong dirty reference" is removed
         - the "modified" flag is set to False
         - any "expired" markers/callables for attributes loaded are removed.

        Attributes marked as "expired" can potentially remain
        "expired" after this step if a value was not populated in state.dict.

        """
        self._commit_all_states([(self, dict_)], instance_dict)

    @classmethod
    def _commit_all_states(self, iter, instance_dict=None):
        """Mass version of commit_all()."""

        for state, dict_ in iter:
            state.committed_state.clear()
            InstanceState._pending_mutations._reset(state)

            callables = state.callables
            for key in list(callables):
                if key in dict_ and callables[key] is state:
                    del callables[key]

            if instance_dict and state.modified:
                instance_dict._modified.discard(state)

            state.modified = state.expired = False
            state._strong_obj = None


class AttributeState(object):
    """Provide an inspection interface corresponding
    to a particular attribute on a particular mapped object.

    The :class:`.AttributeState` object is accessed
    via the :attr:`.InstanceState.attr` collection
    of a particular :class:`.InstanceState`::

        from sqlalchemy import inspect

        insp = inspect(some_mapped_object)
        attr_state = insp.attr.some_attribute

    """

    def __init__(self, state, key):
        self.state = state
        self.key = key

    @property
    def loaded_value(self):
        """The current value of this attribute as loaded from the database.

        If the value has not been loaded, or is otherwise not present
        in the object's dictionary, returns NO_VALUE.

        """
        return self.state.dict.get(self.key, NO_VALUE)

    @property
    def value(self):
        """Return the value of this attribute.

        This operation is equivalent to accessing the object's
        attribute directly or via ``getattr()``, and will fire
        off any pending loader callables if needed.

        """
        return self.state.manager[self.key].__get__(
                        self.state.obj(), self.state.class_)

    @property
    def history(self):
        """Return the current pre-flush change history for
        this attribute, via the :class:`.History` interface.

        """
        return self.state.get_history(self.key,
                    PASSIVE_NO_INITIALIZE)


class PendingCollection(object):
    """A writable placeholder for an unloaded collection.

    Stores items appended to and removed from a collection that has not yet
    been loaded. When the collection is loaded, the changes stored in
    PendingCollection are applied to it to produce the final result.

    """
    def __init__(self):
        self.deleted_items = util.IdentitySet()
        self.added_items = util.OrderedIdentitySet()

    def append(self, value):
        if value in self.deleted_items:
            self.deleted_items.remove(value)
        else:
            self.added_items.add(value)

    def remove(self, value):
        if value in self.added_items:
            self.added_items.remove(value)
        else:
            self.deleted_items.add(value)
