# coding=utf-8
"""Objects to schema adapters."""
from __future__ import absolute_import, division, print_function, \
    unicode_literals

import logging
from abc import ABCMeta, abstractmethod
from operator import attrgetter

import sqlalchemy as sa
from six import add_metaclass, string_types
from whoosh.fields import TEXT

from abilian.core.extensions import db

from .schema import accent_folder

__all__ = ["SchemaAdapter", "SAAdapter"]

logger = logging.getLogger(__name__)


@add_metaclass(ABCMeta)
class SchemaAdapter(object):
    """Abstract base class for objects to schema adapter.

    The purpose of adapters is that given an object they return kwargs
    for document.
    """

    def __init__(self, model_class, schema):
        """
        :param:model_class: class of objects instances to be adapted
        :param:schema: :class:`whoosh.fields.Schema` instance
        """
        pass

    @staticmethod
    def can_adapt(obj_cls):
        """Return True if this class can adapt objects of class `obj_cls`."""
        raise NotImplementedError

    @abstractmethod
    def retrieve(self, pk, **data):
        """Returns an object instance given its identifier and optional data
        kwargs.

        :returns: None if not object instance can be returned
        """
        raise NotImplementedError

    @abstractmethod
    def get_document(self, obj):
        raise NotImplementedError


class SAAdapter(SchemaAdapter):
    """Adapts an sqlalchemy model.

    A column on the model is indexed if `"searchable"` is set to `True` in
    its `info` field (see :meth:`sqlalchemy.schema.Column.__init__`)

    Additionnaly info can also contains `index_to`. It's expected to be an
    iterable of field names or tuple of (field name, whoosh field
    instance). The default the whoosh field is a
    :class:`whoosh.fields.TEXT` instance.

    When `index_to` is used the column name will *not* be indexed on the same
    field name unless specified in `index_to`.

    If multiple field names are passed to `index_to` the values will be
    concatenated with a space.

    `index_to` is useful for::

    * index many attributes to on field names, for exemple index `title`
    and `description` to a `_full_text` field
    * define new fields on schema
    """

    @staticmethod
    def can_adapt(obj_cls):
        return issubclass(obj_cls, db.Model)

    def __init__(self, model_class, schema):
        """
        :param:model_class: a sqlalchemy model class
        :param:schema: :class:`whoosh.fields.Schema` instance
        """
        assert issubclass(model_class, db.Model)
        self.model_class = model_class
        self.indexable = getattr(model_class, "__indexable__", False)
        self.index_to = self.get_index_to(model_class)
        self.doc_attrs = {}
        if self.indexable:
            self._build_doc_attrs(model_class, schema)

    def get_index_to(self, model_class):
        result = []
        classes = model_class.mro()
        for cls in classes:
            if hasattr(cls, "__index_to__"):
                result += cls.__index_to__
        return tuple(result)

    def _build_doc_attrs(self, model_class, schema):
        mapper = sa.inspect(model_class)

        args = self.doc_attrs
        # any field not in schema will be stored here. After all field have been
        # discovered we add missing ones
        field_definitions = {}

        def setup_field(attr_name, field_name):
            field_def = False
            if not isinstance(field_name, string_types):
                field_name, field_def = field_name

            if field_name not in schema:
                if (
                    field_name not in field_definitions
                    or field_definitions[field_name] is False
                ):
                    field_definitions[field_name] = field_def

            # attrgetter offers dotted name support. Useful for attributes on
            # related objects.
            args.setdefault(field_name, {})[name] = attrgetter(name)

        # model level definitions
        for name, field_names in self.index_to:
            if isinstance(field_names, string_types):
                field_names = (field_names,)
            for field_name in field_names:
                setup_field(name, field_name)

        # per column definitions
        for col in mapper.columns:
            name = col.name
            info = col.info

            if not info.get("searchable"):
                continue

            index_to = info.get("index_to", (name,))
            if isinstance(index_to, string_types):
                index_to = (index_to,)

            for field_name in index_to:
                setup_field(name, field_name)

        # add missing fields to schema
        for field_name, field_def in field_definitions.items():
            if field_name in schema:
                continue

            if field_def is False:
                field_def = TEXT(stored=True, analyzer=accent_folder)

            logger.debug(
                "Adding field to schema:\n" "  Model: %s\n" '  Field: "%s" %s',
                model_class._object_type(),
                field_name,
                field_def,
            )
            schema.add(field_name, field_def)

    def retrieve(self, pk, _session=None, **data):
        if _session is None:
            _session = db.session()
        return _session.query(self.model_class).get(pk)

    def get_document(self, obj):
        kwargs = {}
        if not self.indexable:
            return kwargs

        # cache because the same attribute may be needed by many fields, i.e
        # "title" on "title" field and "full_text" field for example
        cached = {}
        # negative cache. Might be used especially with dotted names
        missed = set()

        for field, attrs in self.doc_attrs.items():
            values = []

            for a, getter in attrs.items():
                if a in missed:
                    continue

                if a not in cached:
                    try:
                        val = getter(obj)
                    except AttributeError:
                        missed.add(a)
                        continue
                    else:
                        cached[a] = val

                val = cached[a]
                if val is not None:
                    if isinstance(val, (list, tuple)):
                        val = " ".join(val).strip()
                    values.append(val)

            values = [v for v in values if v]

            if values:
                if len(values) == 1:
                    kwargs[field] = values[0]
                else:
                    kwargs[field] = " ".join(values)

        return kwargs
