import logging

from flask import request, flash, abort, Response

from flask.ext.admin import expose
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView
from flask.ext.admin._compat import iteritems, string_types

import mongoengine
import gridfs
from mongoengine.connection import get_db
from bson.objectid import ObjectId

from flask.ext.admin.actions import action
from .filters import FilterConverter, BaseMongoEngineFilter
from .form import get_form, CustomModelConverter
from .typefmt import DEFAULT_FORMATTERS
from .tools import parse_like_term
from .helpers import format_error
from .ajax import process_ajax_references, create_ajax_loader
from .subdoc import convert_subdocuments


# Set up logger
log = logging.getLogger("flask-admin.mongo")


SORTABLE_FIELDS = set((
    mongoengine.StringField,
    mongoengine.IntField,
    mongoengine.FloatField,
    mongoengine.BooleanField,
    mongoengine.DateTimeField,
    mongoengine.ComplexDateTimeField,
    mongoengine.ObjectIdField,
    mongoengine.DecimalField,
    mongoengine.ReferenceField,
    mongoengine.EmailField,
    mongoengine.UUIDField,
    mongoengine.URLField
))


class ModelView(BaseModelView):
    """
        MongoEngine model scaffolding.
    """

    column_filters = None
    """
        Collection of the column filters.

        Can contain either field names or instances of
        :class:`flask.ext.admin.contrib.mongoengine.filters.BaseFilter`
        classes.

        For example::

            class MyModelView(BaseModelView):
                column_filters = ('user', 'email')

        or::

            class MyModelView(BaseModelView):
                column_filters = (BooleanEqualFilter(User.name, 'Name'))
    """

    model_form_converter = CustomModelConverter
    """
        Model form conversion class. Use this to implement custom
        field conversion logic.

        Custom class should be derived from the
        `flask.ext.admin.contrib.mongoengine.form.CustomModelConverter`.

        For example::

            class MyModelConverter(AdminModelConverter):
                pass


            class MyAdminView(ModelView):
                model_form_converter = MyModelConverter
    """

    object_id_converter = ObjectId
    """
        Mongodb ``_id`` value conversion function. Default is `bson.ObjectId`.
        Use this if you are using String, Binary and etc.

        For example::

            class MyModelView(BaseModelView):
                object_id_converter = int

        or::

            class MyModelView(BaseModelView):
                object_id_converter = str
    """

    filter_converter = FilterConverter()
    """
        Field to filter converter.

        Override this attribute to use a non-default converter.
    """

    column_type_formatters = DEFAULT_FORMATTERS
    """
        Customized type formatters for MongoEngine backend
    """

    allowed_search_types = (mongoengine.StringField,
                            mongoengine.URLField,
                            mongoengine.EmailField)
    """
        List of allowed search field types.
    """

    form_subdocuments = None
    """
        Subdocument configuration options.

        This field accepts dictionary, where key is field name and value is either dictionary or instance of the
        `flask.ext.admin.contrib.EmbeddedForm`.

        Consider following example::

            class Comment(db.EmbeddedDocument):
                name = db.StringField(max_length=20, required=True)
                value = db.StringField(max_length=20)

            class Post(db.Document):
                text = db.StringField(max_length=30)
                data = db.EmbeddedDocumentField(Comment)

            class MyAdmin(ModelView):
                form_subdocuments = {
                    'data': {
                        'form_columns': ('name',)
                    }
                }

        In this example, `Post` model has child `Comment` subdocument. When generating form for `Comment` embedded
        document, Flask-Admin will only create `name` field.

        It is also possible to use class-based embedded document configuration::

            class CommentEmbed(EmbeddedForm):
                form_columns = ('name',)

            class MyAdmin(ModelView):
                form_subdocuments = {
                    'data': CommentEmbed()
                }

        Arbitrary depth nesting is supported::

            class SomeEmbed(EmbeddedForm):
                form_excluded_columns = ('test',)

            class CommentEmbed(EmbeddedForm):
                form_columns = ('name',)
                form_subdocuments = {
                    'inner': SomeEmbed()
                }

            class MyAdmin(ModelView):
                form_subdocuments = {
                    'data': CommentEmbed()
                }

        There's also support for forms embedded into `ListField`. All you have
        to do is to create nested rule with `None` as a name. Even though it
        is slightly confusing, but that's how Flask-MongoEngine creates
        form fields embedded into ListField::

            class Comment(db.EmbeddedDocument):
                name = db.StringField(max_length=20, required=True)
                value = db.StringField(max_length=20)

            class Post(db.Document):
                text = db.StringField(max_length=30)
                data = db.ListField(db.EmbeddedDocumentField(Comment))

            class MyAdmin(ModelView):
                form_subdocuments = {
                    'data': {
                        'form_subdocuments': {
                            None: {
                                'form_columns': ('name',)
                            }
                        }

                    }
                }
    """

    def __init__(self, model, name=None,
                 category=None, endpoint=None, url=None,
                 menu_class_name=None, menu_icon_type=None, menu_icon_value=None):
        """
            Constructor

            :param model:
                Model class
            :param name:
                Display name
            :param category:
                Display category
            :param endpoint:
                Endpoint
            :param url:
                Custom URL
            :param menu_class_name:
                Optional class name for the menu item.
            :param menu_icon_type:
                Optional icon. Possible icon types:

                 - `flask.ext.admin.consts.ICON_TYPE_GLYPH` - Bootstrap glyph icon
                 - `flask.ext.admin.consts.ICON_TYPE_IMAGE` - Image relative to Flask static directory
                 - `flask.ext.admin.consts.ICON_TYPE_IMAGE_URL` - Image with full URL

            :param menu_icon_value:
                Icon glyph name or URL, depending on `menu_icon_type` setting
        """
        self._search_fields = []

        super(ModelView, self).__init__(model, name, category, endpoint, url,
                                        menu_class_name=menu_class_name,
                                        menu_icon_type=menu_icon_type,
                                        menu_icon_value=menu_icon_value)

        self._primary_key = self.scaffold_pk()

    def _refresh_cache(self):
        """
            Refresh cache.
        """
        # Process subdocuments
        if self.form_subdocuments is None:
            self.form_subdocuments = {}

        self._form_subdocuments = convert_subdocuments(self.form_subdocuments)

        # Cache other properties
        super(ModelView, self)._refresh_cache()

    def _process_ajax_references(self):
        """
            AJAX endpoint is exposed by top-level admin view class, but
            subdocuments might have AJAX references too.

            This method will recursively go over subdocument configuration
            and will precompute AJAX references for them ensuring that
            subdocuments can also use AJAX to populate their ReferenceFields.
        """
        references = super(ModelView, self)._process_ajax_references()
        return process_ajax_references(references, self)

    def _get_model_fields(self, model=None):
        """
            Inspect model and return list of model fields

            :param model:
                Model to inspect
        """
        if model is None:
            model = self.model

        return sorted(iteritems(model._fields), key=lambda n: n[1].creation_counter)

    def scaffold_pk(self):
        # MongoEngine models have predefined 'id' as a key
        return 'id'

    def get_pk_value(self, model):
        """
            Return the primary key value from the model instance

            :param model:
                Model instance
        """
        return model.pk

    def scaffold_list_columns(self):
        """
            Scaffold list columns
        """
        columns = []

        for n, f in self._get_model_fields():
            # Verify type
            field_class = type(f)

            if (field_class == mongoengine.ListField and
                isinstance(f.field, mongoengine.EmbeddedDocumentField)):
                continue

            if field_class == mongoengine.EmbeddedDocumentField:
                continue

            if self.column_display_pk or field_class != mongoengine.ObjectIdField:
                columns.append(n)

        return columns

    def scaffold_sortable_columns(self):
        """
            Return a dictionary of sortable columns (name, field)
        """
        columns = {}

        for n, f in self._get_model_fields():
            if type(f) in SORTABLE_FIELDS:
                if self.column_display_pk or type(f) != mongoengine.ObjectIdField:
                    columns[n] = f

        return columns

    def init_search(self):
        """
            Init search
        """
        if self.column_searchable_list:
            for p in self.column_searchable_list:
                if isinstance(p, string_types):
                    p = self.model._fields.get(p)

                if p is None:
                    raise Exception('Invalid search field')

                field_type = type(p)

                # Check type
                if (field_type not in self.allowed_search_types):
                        raise Exception('Can only search on text columns. ' +
                                        'Failed to setup search for "%s"' % p)

                self._search_fields.append(p)

        return bool(self._search_fields)

    def scaffold_filters(self, name):
        """
            Return filter object(s) for the field

            :param name:
                Either field name or field instance
        """
        if isinstance(name, string_types):
            attr = self.model._fields.get(name)
        else:
            attr = name

        if attr is None:
            raise Exception('Failed to find field for filter: %s' % name)

        # Find name
        visible_name = None

        if not isinstance(name, string_types):
            visible_name = self.get_column_name(attr.name)

        if not visible_name:
            visible_name = self.get_column_name(name)

        # Convert filter
        type_name = type(attr).__name__
        flt = self.filter_converter.convert(type_name,
                                            attr,
                                            visible_name)

        return flt

    def is_valid_filter(self, filter):
        """
            Validate if the provided filter is a valid MongoEngine filter

            :param filter:
                Filter object
        """
        return isinstance(filter, BaseMongoEngineFilter)

    def scaffold_form(self):
        """
            Create form from the model.
        """
        form_class = get_form(self.model,
                              self.model_form_converter(self),
                              base_class=self.form_base_class,
                              only=self.form_columns,
                              exclude=self.form_excluded_columns,
                              field_args=self.form_args,
                              extra_fields=self.form_extra_fields)

        return form_class

    # AJAX foreignkey support
    def _create_ajax_loader(self, name, opts):
        return create_ajax_loader(self.model, name, name, opts)

    def get_query(self):
        """
        Returns the QuerySet for this view.  By default, it returns all the
        objects for the current model.
        """
        return self.model.objects

    def get_list(self, page, sort_column, sort_desc, search, filters,
                 execute=True):
        """
            Get list of objects from MongoEngine

            :param page:
                Page number
            :param sort_column:
                Sort column
            :param sort_desc:
                Sort descending
            :param search:
                Search criteria
            :param filters:
                List of applied filters
            :param execute:
                Run query immediately or not
        """
        query = self.get_query()

        # Filters
        if self._filters:
            for flt, value in filters:
                f = self._filters[flt]
                query = f.apply(query, value)

        # Search
        if self._search_supported and search:
            # TODO: Unfortunately, MongoEngine contains bug which
            # prevents running complex Q queries and, as a result,
            # Flask-Admin does not support per-word searching like
            # in other backends
            op, term = parse_like_term(search)

            criteria = None

            for field in self._search_fields:
                flt = {'%s__%s' % (field.name, op): term}
                q = mongoengine.Q(**flt)

                if criteria is None:
                    criteria = q
                else:
                    criteria |= q

            query = query.filter(criteria)

        # Get count
        count = query.count()

        # Sorting
        if sort_column:
            query = query.order_by('%s%s' % ('-' if sort_desc else '', sort_column))
        else:
            order = self._get_default_order()

            if order:
                query = query.order_by('%s%s' % ('-' if order[1] else '', order[0]))

        # Pagination
        if page is not None:
            query = query.skip(page * self.page_size)

        query = query.limit(self.page_size)

        if execute:
            query = query.all()

        return count, query

    def get_one(self, id):
        """
            Return a single model instance by its ID

            :param id:
                Model ID
        """
        try:
            return self.get_query().filter(pk=id).first()
        except mongoengine.ValidationError as ex:
            flash(gettext('Failed to get model. %(error)s',
                          error=format_error(ex)),
                  'error')
            return None

    def create_model(self, form):
        """
            Create model helper

            :param form:
                Form instance
        """
        try:
            model = self.model()
            form.populate_obj(model)
            self._on_model_change(form, model, True)
            model.save()
        except Exception as ex:
            if not self.handle_view_exception(ex):
                flash(gettext('Failed to create model. %(error)s',
                              error=format_error(ex)),
                      'error')
                log.exception('Failed to create model')

            return False
        else:
            self.after_model_change(form, model, True)

        return True

    def update_model(self, form, model):
        """
            Update model helper

            :param form:
                Form instance
            :param model:
                Model instance to update
        """
        try:
            form.populate_obj(model)
            self._on_model_change(form, model, False)
            model.save()
        except Exception as ex:
            if not self.handle_view_exception(ex):
                flash(gettext('Failed to update model. %(error)s',
                              error=format_error(ex)),
                      'error')
                log.exception('Failed to update model')

            return False
        else:
            self.after_model_change(form, model, False)

        return True

    def delete_model(self, model):
        """
            Delete model helper

            :param model:
                Model instance
        """
        try:
            self.on_model_delete(model)
            model.delete()
            return True
        except Exception as ex:
            if not self.handle_view_exception(ex):
                flash(gettext('Failed to delete model. %(error)s',
                              error=format_error(ex)),
                      'error')
                log.exception('Failed to delete model')

            return False

    # FileField access API
    @expose('/api/file/')
    def api_file_view(self):
        pk = request.args.get('id')
        coll = request.args.get('coll')
        db = request.args.get('db', 'default')

        if not pk or not coll or not db:
            abort(404)

        fs = gridfs.GridFS(get_db(db), coll)

        data = fs.get(self.object_id_converter(pk))
        if not data:
            abort(404)

        return Response(data.read(),
                        content_type=data.content_type,
                        headers={
                            'Content-Length': data.length
                        })

    # Default model actions
    def is_action_allowed(self, name):
        # Check delete action permission
        if name == 'delete' and not self.can_delete:
            return False

        return super(ModelView, self).is_action_allowed(name)

    @action('delete',
            lazy_gettext('Delete'),
            lazy_gettext('Are you sure you want to delete selected models?'))
    def action_delete(self, ids):
        try:
            count = 0

            all_ids = [self.object_id_converter(pk) for pk in ids]
            for obj in self.get_query().in_bulk(all_ids).values():
                count += self.delete_model(obj)

            flash(ngettext('Model was successfully deleted.',
                           '%(count)s models were successfully deleted.',
                           count,
                           count=count))
        except Exception as ex:
            if not self.handle_view_exception(ex):
                flash(gettext('Failed to delete models. %(error)s', error=str(ex)),
                      'error')
