# coding=utf-8
"""
Class based views
"""
from __future__ import absolute_import
import logging

import sqlalchemy as sa
from flask import (
    g, request, render_template, redirect, url_for, current_app,
    flash, abort
)
from werkzeug.exceptions import NotFound

from abilian.i18n import _, _l
from abilian.services.security import READ, WRITE
from abilian.core.signals import activity
from abilian.core.entities import ValidationError

from .. import nav, csrf, forms
from ..action import ButtonAction, Endpoint, actions
from .base import View, JSONView

logger = logging.getLogger(__name__)


class BaseObjectView(View):
  """
  Base class common to all database objects views
  """
  #: form title
  title = None

  #: Model class
  Model = None

  #: primary key name to look for in url arguments
  pk = 'object_id'

  #: object instance for this view
  obj = None

  #: template to render
  template = None

  #: default templates inherit from "base_template". This allows to use generic
  #: templates with a custom base
  base_template = "base.html"

  def __init__(self, Model=None, pk=None, base_template=None, *args, **kwargs):
    View.__init__(self, *args, **kwargs)
    cls = self.__class__
    self.pk = pk if pk is not None else cls.pk
    self.Model = Model if Model is not None else cls.Model
    self.base_template = (base_template
                          if base_template is not None
                          else cls.base_template)

  def prepare_args(self, args, kwargs):
    args, kwargs = self.init_object(args, kwargs)

    if self.obj is None:
      raise NotFound()

    return args, kwargs

  def breadcrumb(self):
    """
    Return :class:`..nav.BreadcrumbItem` instance for this object.

    This method may return a list of BreadcrumbItem instances. Return
    `None` if nothing.
    """
    return None

  def init_object(self, args, kwargs):
    """
    This method is reponsible for setting :attr:`obj`. It is called during
    :meth:`prepare_args`.
    """
    object_id = kwargs.pop(self.pk, None)
    if object_id is not None:
      self.obj = self.Model.query.get(object_id)
      actions.context['object'] = self.obj

    return args, kwargs

  def get(self, *args, **kwargs):
    bc = self.breadcrumb()
    if bc is not None:
      bc = [bc] if isinstance(bc, nav.BreadcrumbItem) else list(bc)
      assert all(isinstance(b, nav.BreadcrumbItem) for b in bc)
      g.breadcrumb.extend(bc)

    kwargs = {'base_template': self.base_template}
    kwargs.update(self.template_kwargs)
    # forbid override "view" and "form"
    kwargs.update(dict(view=self, form=self.form))
    return render_template(self.template, **kwargs)

  @property
  def template_kwargs(self):
    """
    Template render arguments. You can override `base_template` for
    instance. Only `view` and `form` cannot be overriden.
    """
    return {}


class ObjectView(BaseObjectView):
  """
  View objects
  """

  #: html template
  template = 'default/object_view.html'

  #: View form. Form object used to show objects fields
  Form = None

  #: required permission. Must be an instance of
  #: :class:`abilian.services.security.Permission`
  permission = READ

  #: form instance for this view
  form = None

  def __init__(self, Model=None, pk=None, Form=None, template=None,
               *args, **kwargs):
    super(ObjectView, self).__init__(Model, pk, *args, **kwargs)
    cls = self.__class__
    self.Form = Form if Form is not None else cls.Form
    self.template = template if template is not None else cls.template

  def prepare_args(self, args, kwargs):
    """
    :attr:`form` is initialized here. See also :meth:`View.prepare_args`.
    """
    args, kwargs = super(ObjectView, self).prepare_args(args, kwargs)
    self.form = self.Form(**self.get_form_kwargs())
    return args, kwargs

  def get_form_kwargs(self):
    kw = dict(obj=self.obj)
    if issubclass(self.Form, forms.Form) and self.permission:
      kw['permission'] = self.permission
    return kw

  def index_url(self):
    return url_for('.index')

  def redirect_to_index(self):
    return redirect(self.index_url())


CANCEL_BUTTON = ButtonAction(
  'form', 'cancel', title=_l(u'Cancel'),
  btn_class='default cancel'  # .cancel: if jquery.validate is used it will
)                              # properly skip validation

EDIT_BUTTON = ButtonAction('form', 'edit', btn_class='primary',
                           title=_l(u'Save'))


class ObjectEdit(ObjectView):
  """
  Edit objects
  """
  template = 'default/object_edit.html'
  decorators = (csrf.support_graceful_failure,)
  permission = WRITE

  #: :class:ButtonAction instance to show on form
  _buttons = ()

  #: submitted form data
  data = None

  #: action name from form data
  action = None

  #: button clicked, corresponding to :attr:`action`.
  button = None

  #: verb used to describe activity
  activity_verb = 'update'

  #: UI flash message
  _message_success = _l(u"Entity successfully edited")

  def __init__(self, Model=None, pk=None, Form=None, template=None,
               view_endpoint=None, message_success=None, *args, **kwargs):
    ObjectView.__init__(self, Model, pk, Form, *args, **kwargs)
    self.view_endpoint = (view_endpoint
                          if view_endpoint is not None
                          else '.{}_view'.format(self.Model.__name__))
    if message_success:
      self._message_success = message_success

  def post(self, *args, **kwargs):
    # conservative: no action submitted -> cancel
    action = self.data.get('__action', u'cancel')
    if action == u'cancel':
      return self.cancel()

    return self.handle_action(action)

  def put(self):
    return self.post()

  def prepare_args(self, args, kwargs):
    args, kwargs = super(ObjectEdit, self).prepare_args(args, kwargs)
    self._buttons = self.get_form_buttons(*args, **kwargs)
    self.data = request.form
    return args, kwargs

  def get_form_buttons(self, *args, **kwargs):
    return [EDIT_BUTTON, CANCEL_BUTTON]

  @property
  def buttons(self):
    return (button for button in self._buttons
            if button.available(actions.context))

  def view_url(self):
    kw = { self.pk: self.obj.id }
    return url_for(self.view_endpoint, **kw)

  def redirect_to_view(self):
    if self.button:
      url = self.button.url(actions.context)
      if url:
        return redirect(url)
    return redirect(self.view_url())

  def message_success(self):
    return unicode(self._message_success)

  # actions
  def handle_action(self, action):
    for button in self._buttons:
      if action == button.name:
        if not button.available(dict(view=self)):
          raise ValueError('Action "{}" not available'
                           ''.format(action.encode('utf-8')))
        break
    else:
      raise ValueError('Unknown action: "{}"'.format(action.encode('utf-8')))

    self.action = action
    self.button = button
    return getattr(self, action)()

  def cancel(self):
    return self.redirect_to_view()

  def edit(self):
    if self.validate():
      return self.form_valid()
    else:
      if request.csrf_failed:
        errors = self.form.errors
        csrf_failed = errors.pop('csrf_token', False)
        if csrf_failed and not errors:
          # failed only because of invalid/expired csrf, no error on form
          return self.form_csrf_invalid()

      resp = self.form_invalid()
      if resp:
        return resp

      flash(_(u"Please fix the error(s) below"), "error")

    # if we end here then something wrong has happened: show form with error
    # messages
    return self.get()

  def before_populate_obj(self):
    """
    This method is called after form has been validated and before calling
    `form.populate_obj()`. Sometimes one may want to remove a field from
    the form because it's non-sense to store it on edited object, and use it in
    a specific manner, for example::

        image = form.image
        del form.image
        store_image(image)
    """
    pass

  def after_populate_obj(self):
    """
    Called after `self.obj` values have been updated, and `self.obj`
    attached to an ORM session.
    """
    pass

  def handle_commit_exception(self, exc):
    """
    hook point to handle exception that may happen during commit.

    It is the responsability of this method to perform a rollback if it is
    required for handling `exc`. If the method does not handle `exc` if should
    do nothing and return None.

    :returns: * a valid :class:`Response` if exception is handled.
              * `None` if exception is not handled. Default handling happens.
    """
    return None

  def commit_success(self):
    """
    Called after object has been successfully saved to database
    """

  def validate(self):
    return self.form.validate()

  def form_valid(self):
    """
    Save object.

    Called when form is validated.
    """
    session = current_app.db.session()
    self.before_populate_obj()
    self.form.populate_obj(self.obj)
    session.add(self.obj)
    self.after_populate_obj()

    try:
      session.flush()
      activity.send(self,
                    actor=g.user,
                    verb=self.activity_verb,
                    object=self.obj,
                    target=self.activity_target)
      session.commit()
    except ValidationError, e:
      rv = self.handle_commit_exception(e)
      if rv is not None:
        return rv
      session.rollback()
      flash(e.message, "error")
      return self.get()
    except sa.exc.IntegrityError, e:
      rv = self.handle_commit_exception(e)
      if rv is not None:
        return rv
      session.rollback()
      logger.error(e)
      flash(_(u"An entity with this name already exists in the database."),
            "error")
      return self.get()
    else:
      self.commit_success()
      flash(self.message_success(), "success")
      return self.redirect_to_view()

  def form_invalid(self):
    """
    When a form doesn't validate this method is called.

    It may return a :class:`Flask.Response` instance, to handle specific
    errors in custom screens.

    Else the edit form screen is returned with error(s) highlighted.

    This method is useful for detecting edition conflict using hidden fields
    and show a specific screen to help resolve the conflict.
    """
    return None

  def form_csrf_invalid(self):
    """
    Called when a form doesn't validate *only* because of csrf token expiration.

    This works only if form is an instance of :class:`flask_wtf.form.SecureForm`.
    Else default CSRF protection (before request) will take place.

    It must return a valid :class:`Flask.Response` instance. By default it
    returns to edit form screen with an informative message.
    """
    current_app.extensions['csrf-handler'].flash_csrf_failed_message()
    return self.get()

  @property
  def activity_target(self):
    """
    Return `target` to use when creating activity.
    """
    return None


CREATE_BUTTON = ButtonAction('form', 'create', btn_class='primary', title=_l(u'Create'))
CHAIN_CREATE_BUTTON = ButtonAction(
    'form', 'chain_create', btn_class='primary',
    title=_l(u'Create and add new'),
    endpoint=lambda ctx: Endpoint(request.endpoint, **request.view_args),
    condition=lambda ctx: getattr(ctx['view'], 'chain_create_allowed', False)
)


class ObjectCreate(ObjectEdit):
  """
  Create a new object
  """
  activity_verb = 'post'
  _message_success = _l(u"Entity successfully added")

  #: set to `True` to show 'Save and add new' button
  chain_create_allowed = False

  def __init__(self, *args, **kwargs):
    chain_create_allowed = kwargs.pop('chain_create_allowed', None)
    if chain_create_allowed is not None:
      self.chain_create_allowed = bool(chain_create_allowed)

    ObjectEdit.__init__(self, *args, **kwargs)

  def init_object(self, args, kwargs):
    self.obj = self.Model()
    return args, kwargs

  def get_form_kwargs(self):
    kw = super(ObjectCreate, self).get_form_kwargs()
    if request.method == 'GET':
      # when GET allow form prefill instead of empty/current object data
      # FIXME: filter allowed parameters on given a field flags (could be
      # 'allow_from_get'?)
      kw['formdata'] = request.args

    return kw

  def get_form_buttons(self, *args, **kwargs):
    return [CREATE_BUTTON, CHAIN_CREATE_BUTTON, CANCEL_BUTTON]

  def breadcrumb(self):
    return nav.BreadcrumbItem(label=CREATE_BUTTON.title)

  # actions
  def create(self):
    return self.edit()

  chain_create = create

  def cancel(self):
    return self.redirect_to_index()


DELETE_BUTTON = ButtonAction('form', 'delete', title=_l(u'Delete'))


class ObjectDelete(ObjectEdit):
  """
  Delete object. Supports DELETE verb.
  """
  activity_verb = 'delete'
  _message_success = _l(u"Entity deleted")

  init_object = BaseObjectView.init_object

  def get_form_buttons(self, *args, **kwargs):
    return [DELETE_BUTTON, CANCEL_BUTTON]

  def delete(self):
    session = current_app.db.session()
    session.delete(self.obj)
    activity.send(self, actor=g.user, verb="delete", object=self.obj)
    session.commit()
    flash(self.message_success(), 'success')
    # FIXME: for DELETE verb response in case of success should be 200, 202
    # (accepted) or 204 (no content)
    return self.redirect_to_index()


class JSONBaseSearch(JSONView):
  Model = None
  minimum_input_length = 2

  def __init__(self, *args, **kwargs):
    Model = kwargs.pop('Model', self.Model)
    minimum_input_length = kwargs.pop('minimum_input_length',
                                      self.minimum_input_length)
    super(JSONBaseSearch, self).__init__(*args, **kwargs)
    self.Model = Model
    self.minimum_input_length = minimum_input_length

  def prepare_args(self, args, kwargs):
    args, kwargs = JSONView.prepare_args(self, args, kwargs)
    kwargs['q'] = kwargs.get("q", u'').replace(u"%", u" ").lower()
    return args, kwargs

  def data(self, q, *args, **kwargs):
    if self.minimum_input_length and len(q) < self.minimum_input_length:
      abort(
        400,
        'Minimum query length is {:d}'.format(self.minimum_input_length),
      )

    results = []
    for obj in self.get_results(q, **kwargs):
      results.append(self.get_item(obj))

    return dict(results=results)

  def get_results(self, q, *args, **kwargs):
    raise NotImplemented

  def get_item(self, obj):
    """
    Return a result item
    :param obj: Instance object
    :returns: a dictionnary with at least `id` and `text` values
    """
    raise NotImplemented


class JSONModelSearch(JSONBaseSearch):
  """
  Base class for json sqlalchemy model search, as used by select2 widgets for example
  """

  def get_results(self, q, *args, **kwargs):
    query = self.Model.query

    query = self.options(query)
    query = self.filter(query, q, **kwargs)
    query = self.order_by(query)
    if not q and not self.minimum_input_length:
      query = query.limit(50)
    return query.all()

  def options(self, query):
    return query.options(sa.orm.noload('*'))

  def filter(self, query, q, **kwargs):
    if not q:
      return query
    return query.filter(sa.func.lower(self.Model.name).like(q + "%"))

  def order_by(self, query):
    return query.order_by(self.Model.name)

  def get_label(self, obj):
    return obj.name

  def get_item(self, obj):
    """
    Return a result item

    :param obj: Instance object
    :returns: a dictionnary with at least `id` and `text` values
    """
    return dict(id=obj.id, text=self.get_label(obj), name=obj.name)


class JSONWhooshSearch(JSONBaseSearch):
  """
  Base class for json whoosh search, as used by select2 widgets for example
  """

  def get_results(self, q, *args, **kwargs):
    svc = current_app.services['indexing']
    search_kwargs = {'limit': 30, 'Models': (self.Model,)}
    results = svc.search(q, **search_kwargs)
    return results

  def get_item(self, hit):
    """
    Return a result item

    :param hit: Hit object from Whoosh
    :returns: a dictionnary with at least `id` and `text` values
    """
    return dict(id=hit['id'], text=hit['name'], name=hit['name'])
