# Copyright (C) 2013 Google Inc., authors, and contributors <see AUTHORS file>
# Licensed under http://www.apache.org/licenses/LICENSE-2.0 <see LICENSE file>
# Created By: david@reciprocitylabs.com
# Maintained By: david@reciprocitylabs.com


"""gGRC Collection REST services implementation. Common to all gGRC collection
resources.
"""

import datetime
import hashlib
import logging
import time
from exceptions import TypeError
from wsgiref.handlers import format_date_time
from urllib import urlencode

from blinker import Namespace
from flask import url_for, request, current_app, g, has_request_context
from flask.views import View
from sqlalchemy import and_, or_
from sqlalchemy.exc import IntegrityError
import sqlalchemy.orm.exc
from werkzeug.exceptions import BadRequest, Forbidden
from sqlalchemy.orm.properties import RelationshipProperty

import ggrc.builder.json
from flask.ext.sqlalchemy import Pagination
from ggrc import db, utils
from ggrc.utils import as_json, UnicodeSafeJsonWrapper, benchmark
from ggrc.fulltext import get_indexer
from ggrc.fulltext.recordbuilder import fts_record_for
from ggrc.login import get_current_user_id, get_current_user
from ggrc.models.cache import Cache
from ggrc.models.event import Event
from ggrc.models.revision import Revision
from ggrc.models.exceptions import ValidationError, translate_message
from ggrc.rbac import permissions, context_query_filter
from .attribute_query import AttributeQueryBuilder
from ggrc.models.background_task import BackgroundTask, create_task
from ggrc import settings


CACHE_EXPIRY_COLLECTION = 60

def get_oauth_credentials():
  from flask import session
  if session.has_key('oauth_credentials'):
    return session['oauth_credentials']
  else:
    return None

def _get_cache_manager():
  from ggrc.cache import CacheManager, MemCache
  cache_manager = CacheManager()
  cache_manager.initialize(MemCache())
  return cache_manager


def get_cache_key(obj, type=None, id=None):
  """Returns a string identifier for the specified object or stub.

  `obj` can be:
    <db.Model> -- declarative model instance
    (type, id) -- tuple
    { 'type': type, 'id': id } -- dict
  """
  if isinstance(obj, tuple):
    type, id = obj
  elif isinstance(obj, dict):
    type = obj.get('type', None)
    id = obj.get('id', None)
  if isinstance(type, (str, unicode)):
    model = ggrc.models.get_model(type)
    assert model is not None, "Invalid model name: {}".format(type)
    type = ggrc.models.get_model(type)._inflector.table_plural
  if not isinstance(obj, (tuple, dict)):
    if type is None:
      type = obj._inflector.table_plural
    if id is None:
      id = obj.id
  return 'collection:{type}:{id}'.format(type=type, id=id)


def get_cache_class(obj):
  return obj.__class__.__name__


def get_related_keys_for_expiration(context, o):
  cls = get_cache_class(o)
  keys = []
  mappings = context.cache_manager.supported_mappings.get(cls, [])
  if len(mappings) > 0:
    for (cls, attr, polymorph) in mappings:
      if polymorph:
        key = get_cache_key(
            None,
            type=getattr(o, '{0}_type'.format(attr)),
            id=getattr(o, '{0}_id'.format(attr)))
        keys.append(key)
      else:
        obj = getattr(o, attr, None)
        if obj:
          if isinstance(obj, list):
            for inner_o in obj:
              key = get_cache_key(inner_o)
              keys.append(key)
          else:
            key = get_cache_key(obj)
            keys.append(key)
  return keys


def set_ids_for_new_custom_attribute_values(objects, obj):
  """
  When we are creating custom attribute values for
  POST requests, obj.id is not yet defined. This is why we update
  custom attribute values at this point and set the correct attributable_id

  Args:
    objects: newly created objects (we update only the ones that are CustomAttributeValue
    obj: parent object to be set as attributable

  Returns:
    None
  """

  from ggrc.models.custom_attribute_value import CustomAttributeValue
  for object in objects:
    if not isinstance(object, CustomAttributeValue):
      continue
    object.attributable_id = obj.id
    # Disable state updating so that a newly create object doesn't go straight
    # from Draft to Modified.
    if hasattr(object, '_skip_os_state_update'):
      object.skip_os_state_update()
    db.session.add(object)
  db.session.flush()


def update_memcache_before_commit(context, modified_objects, expiry_time):
  """
  Preparing the memccache entries to be updated before DB commit
  Also update the memcache to indicate the status cache operation 'InProgress' waiting for DB commit
  Raises Exception on failures, cannot proceed with DB commit

  Args:
    context: POST/PUT/DELETE HTTP request or import Converter contextual object
    modified_objects:  objects in cache maintained prior to commiting to DB
    expiry_time: Expiry time specified for memcache ADD and DELETE
  Returns:
    None

  """
  if getattr(settings, 'MEMCACHE_MECHANISM', False) is False:
    return

  context.cache_manager = _get_cache_manager()

  if len(modified_objects.new) > 0:
    items_to_add = modified_objects.new.items()
    for o, json_obj in items_to_add:
      cls = get_cache_class(o)
      if context.cache_manager.supported_classes.has_key(cls):
        key = get_cache_key(o)
        context.cache_manager.marked_for_delete.append(key)
        context.cache_manager.marked_for_delete.extend(
            get_related_keys_for_expiration(context, o))

  if len(modified_objects.dirty) > 0:
    items_to_update = modified_objects.dirty.items()
    for o, json_obj in items_to_update:
      cls = get_cache_class(o)
      if context.cache_manager.supported_classes.has_key(cls):
        key = get_cache_key(o)
        context.cache_manager.marked_for_delete.append(key)
        context.cache_manager.marked_for_delete.extend(
            get_related_keys_for_expiration(context, o))

  if len(modified_objects.deleted) > 0:
    items_to_delete = modified_objects.deleted.items()
    for o, json_obj in items_to_delete:
      cls = get_cache_class(o)
      if context.cache_manager.supported_classes.has_key(cls):
        # FIXME: is explicit `id=...` *required* here to avoid querying the
        #   database for a possibly-deleted object?
        key = get_cache_key(o)#, id=json_obj['id'])
        context.cache_manager.marked_for_delete.append(key)
        context.cache_manager.marked_for_delete.extend(
            get_related_keys_for_expiration(context, o))

  status_entries = {}
  for key in context.cache_manager.marked_for_delete:
    build_cache_status(status_entries, 'DeleteOp:' + key, expiry_time, 'InProgress')
  if len(status_entries) > 0:
    current_app.logger.info("CACHE: status entries: " + str(status_entries))
    ret = context.cache_manager.bulk_add(status_entries, expiry_time)
    if ret is not None and len(ret) == 0:
      pass
    else:
      current_app.logger.error('CACHE: Unable to add status for newly created entries in memcache ' + str(ret))


def update_memcache_after_commit(context):
  """
  The memccache entries is updated after DB commit
  Logs error if there are errors in updating entries in cache

  Args:
    context: POST/PUT/DELETE HTTP request or import Converter contextual object
    modified_objects:  objects in cache maintained prior to commiting to DB
  Returns:
    None

  """
  if getattr(settings, 'MEMCACHE_MECHANISM', False) is False:
    return

  if context.cache_manager is None:
    current_app.logger.error("CACHE: Error in initiaizing cache manager")
    return

  cache_manager = context.cache_manager

  # TODO(dan): check for duplicates in marked_for_delete
  if len(cache_manager.marked_for_delete) > 0:
    #current_app.logger.info("CACHE: Bulk Delete: " + str(cache_manager.marked_for_delete))
    delete_result = cache_manager.bulk_delete(cache_manager.marked_for_delete, 0)
    # TODO(dan): handling failure including network errors, currently we log errors
    if delete_result is not True:
      current_app.logger.error("CACHE: Failed to remoe collection from cache")

  status_entries = []
  for key in cache_manager.marked_for_delete:
    status_entries.append('DeleteOp:' + str(key))
  if len(status_entries) > 0:
    delete_result = cache_manager.bulk_delete(status_entries, 0)
    # TODO(dan): handling failure including network errors, currently we log errors
    if delete_result is not True:
      current_app.logger.error("CACHE: Failed to remove status entries from cache")

  cache_manager.clear_cache()

def build_cache_status(data, key, expiry_timeout, status):
  """
  Build the dictionary for storing operational status of cache

  Args:
    data: dictionary to update
    key: key to dictionary
    expiry_timeout: timeout for expiry cache
    status: Update status entry, e.g.InProgress
  Returns:
    None
  """
  data[key] = {'expiry': expiry_timeout, 'status': status}

def inclusion_filter(obj):
  return permissions.is_allowed_read(obj.__class__.__name__, obj.id, obj.context_id)

def get_cache(create=False):
  """
  Retrieves the cache from the Flask global object. The create arg
  indicates if a new cache should be created if none exists. If we
  are not in a request context, no cache is created (return None).
  """
  if has_request_context():
    cache = getattr(g, 'cache', None)
    if cache is None and create:
      cache = g.cache = Cache()
    return cache
  else:
    logging.warning("No request context - no cache created")
    return None

def get_modified_objects(session):
  session.flush()
  cache = get_cache()
  if cache:
    return cache.copy()
  else:
    return None

def update_index(session, cache):
  if cache:
    indexer = get_indexer()
    for obj in cache.new:
      indexer.create_record(fts_record_for(obj), commit=False)
    for obj in cache.dirty:
      indexer.update_record(fts_record_for(obj), commit=False)
    for obj in cache.deleted:
      indexer.delete_record(obj.id, obj.__class__.__name__, commit=False)
    session.commit()

def log_event(session, obj=None, current_user_id=None):
  revisions = []
  session.flush()
  if current_user_id is None:
    current_user_id = get_current_user_id()
  cache = get_cache()
  for o, log_json in cache.dirty.items():
    revision = Revision(o, current_user_id, 'modified', log_json)
    revisions.append(revision)
  for o, log_json in cache.deleted.items():
    revision = Revision(o, current_user_id, 'deleted', log_json)
    revisions.append(revision)
  for o, log_json in cache.new.items():
    revision = Revision(o, current_user_id, 'created', log_json)
    revisions.append(revision)
  if obj is None:
    resource_id = 0
    resource_type = None
    action = 'IMPORT'
    context_id = 0
  else:
    resource_id = obj.id
    resource_type = str(obj.__class__.__name__)
    action = request.method
    context_id = obj.context_id
  if revisions:
    event = Event(
        modified_by_id=current_user_id,
        action=action,
        resource_id=resource_id,
        resource_type=resource_type,
        context_id=context_id)
    event.revisions = revisions
    session.add(event)

class ModelView(View):
  DEFAULT_PAGE_SIZE = 20
  MAX_PAGE_SIZE = 100
  pk = 'id'
  pk_type = 'int'

  _model = None

  # Simple accessor properties
  @property
  def request(self):
    return request

  @property
  def model(self):
    return self._model

  @property
  def modified_attr_name(self):
    return 'updated_at'

  @property
  def modified_attr(self):
    """Return the model attribute to be used for Last-Modified header and
    sorting collection elements.
    """
    return getattr(self.model, self.modified_attr_name)

  def modified_at(self, obj):
    return getattr(obj, self.modified_attr_name)

  def _get_type_select_column(self, model):
    mapper = model._sa_class_manager.mapper
    if mapper.polymorphic_on is None:
    #if len(mapper.self_and_descendants) == 1:
      type_column = sqlalchemy.literal(mapper.class_.__name__)
    else:
      # Handle polymorphic types with CASE
      type_column = sqlalchemy.case(
          value=mapper.polymorphic_on,
          whens={
            val: m.class_.__name__
              for val, m in mapper.polymorphic_map.items()
            })
    return type_column

  def _get_type_where_clause(self, model):
    mapper = model._sa_class_manager.mapper
    if mapper.polymorphic_on is None:
      return True
    else:
      mappers = list(mapper.self_and_descendants)
      polymorphic_on_values = list(
          val
          for val, m in mapper.polymorphic_map.items()
          if m in mappers)
      return mapper.polymorphic_on.in_(polymorphic_on_values)

  #def _get_polymorphic_column(self, model):
  #  mapper = model._sa_class_manager.mapper
  #  if len(mapper.self_and_descendants) > 1:
  #    return mapper.polymorphic_on
  #  else:
  #    return sqlalchemy.literal(mapper.class_.__name__)

  def _get_matching_types(self, model):
    mapper = model._sa_class_manager.mapper
    if len(list(mapper.self_and_descendants)) == 1:
      return mapper.class_.__name__
    else:
      # FIXME: Actually needs to use 'self_and_descendants'
      return [m.class_.__name__ for m in mapper.self_and_descendants]

  def get_match_columns(self, model):
    mapper = model._sa_class_manager.mapper
    columns = []
    columns.append(mapper.primary_key[0].label('id'))
    #columns.append(model.id.label('id'))
    columns.append(self._get_type_select_column(model).label('type'))
    if hasattr(mapper.c, 'context_id'):
      columns.append(mapper.c.context_id.label('context_id'))
    if hasattr(mapper.c, 'updated_at'):
      columns.append(mapper.c.updated_at.label('updated_at'))
    #columns.append(self._get_polymorphic_column(model))
    return columns

  def get_collection_matches(self, model, filter_by_contexts=True):
    columns = self.get_match_columns(self.model)
    query = db.session.query(*columns).filter(
        self._get_type_where_clause(model))
    return self.filter_query_by_request(
      query, filter_by_contexts=filter_by_contexts)

  def get_resource_match_query(self, model, id):
    columns = self.get_match_columns(model)
    query = db.session.query(*columns).filter(
        and_(
          self._get_type_where_clause(model),
          columns[0] == id))
    return query

  # Default model/DB helpers
  def get_collection(self, filter_by_contexts=True):
    if '__stubs_only' not in request.args and \
        hasattr(self.model, 'eager_query'):
      query = self.model.eager_query()
    else:
      query = db.session.query(self.model)
    return self.filter_query_by_request(
        query, filter_by_contexts=filter_by_contexts)

  def filter_query_by_request(self, query, filter_by_contexts=True):
    joinlist = []
    if request.args:
      querybuilder = AttributeQueryBuilder(self.model)
      filter, joinlist, options = querybuilder.collection_filters(request.args)
      if filter is not None:
        for j in joinlist:
          query = query.join(j)
        query = query.filter(filter)
    if filter_by_contexts:
      contexts = permissions.read_contexts_for(self.model.__name__)
      resources = permissions.read_resources_for(self.model.__name__)
      filter_expr = context_query_filter(self.model.context_id, contexts)
      if resources:
        filter_expr = or_(filter_expr, self.model.id.in_(resources))
      query = query.filter(filter_expr)
      for j in joinlist:
        j_class = j.property.mapper.class_
        j_contexts = permissions.read_contexts_for(j_class.__name__)
        j_resources = permissions.read_resources_for(j_class.__name__)
        if j_contexts is not None:
          j_filter_expr = context_query_filter(j_class.context_id, j_contexts)
          if resources:
            j_filter_expr = or_(j_filter_expr, self.model.id.in_(j_resources))
          query = query.filter(j_filter_expr)
        elif resources:
          query = query.filter(self.model.id.in_(resources))
    if '__search' in request.args:
      terms = request.args['__search']
      types = self._get_matching_types(self.model)
      indexer = get_indexer()
      models = indexer._get_grouped_types(types)
      search_query = indexer._get_type_query(models, 'read', None)
      search_query = and_(search_query, indexer._get_filter_query(terms))
      search_query = db.session.query(indexer.record_type.key).filter(search_query)
      if '__mywork' in request.args:
        search_query = indexer._add_owner_query(
            search_query, models, get_current_user_id())
      search_subquery = search_query.subquery()
      query = query.filter(self.model.id.in_(search_subquery))
    order_properties = []
    if '__sort' in request.args:
      sort_attrs = request.args['__sort'].split(",")
      sort_desc = request.args.get('__sort_desc', False)
      for sort_attr in sort_attrs:
        attr_desc = sort_desc
        if sort_attr.startswith('-'):
          attr_desc = not sort_desc
          sort_attr = sort_attr[1:]
        order_property = getattr(self.model, sort_attr, None)
        if order_property and hasattr(order_property, 'desc'):
          if attr_desc:
            order_property = order_property.desc()
          order_properties.append(order_property)
        else:
          # Possibly throw an exception instead, if sorting by invalid attribute?
          pass
    order_properties.append(self.modified_attr.desc())
    order_properties.append(self.model.id.desc())
    query = query.order_by(*order_properties)
    if '__limit' in request.args:
      try:
        limit = int(request.args['__limit'])
        query = query.limit(limit)
      except (TypeError, ValueError):
        pass
    query = query.distinct()
    return query

  def get_object(self, id):
    # This could also use `self.pk`
    # .one() is required as long as any .eager_load() adds joins using
    #   'contains_eager()' to the core query, because 'LIMIT 1' breaks up
    #   that JOIN result (e.g. Categorizable)
    try:
      return self.get_collection(filter_by_contexts=False)\
          .filter(self.model.id == id).one()
    except sqlalchemy.orm.exc.NoResultFound:
      return None

  def not_found_message(self):
    return '{0} not found.'.format(self.model._inflector.title_singular)

  def not_found_response(self):
    return current_app.make_response((self.not_found_message(), 404, []))

  def collection_last_modified(self):
    """Calculate the last time a member of the collection was modified. This
    method relies on the fact that the collection table has an `updated_at` or
    other column with a relevant timestamp; services for models that don't have
    this field **MUST** override this method.
    """
    result = db.session.query(
        self.modified_attr).order_by(self.modified_attr.desc()).first()
    if result is not None:
      return self.modified_at(result)
    return datetime.datetime.now()

  # Routing helpers
  @classmethod
  def endpoint_name(cls):
    return cls.__name__

  @classmethod
  def url_for_preserving_querystring(cls, *args, **kwargs):
    url = cls.url_for(*args, **kwargs)
    # preserve original query string
    idx = request.url.find('?')
    querystring = '' if idx < 0 else '?' + request.url[idx+1:]
    return url + querystring

  @classmethod
  def base_url_for(cls, _memoized_base_url={}):
    if cls not in _memoized_base_url:
      _memoized_base_url[cls] = url_for(cls.endpoint_name())
    return _memoized_base_url[cls]

  @classmethod
  def url_for(cls, *args, **kwargs):
    url = cls.base_url_for()
    if len(args) > 0:
      arg = args[0]
      id = arg if not isinstance(arg, db.Model) else arg.id
      url = url + '/' + str(id)
    if 'id' in kwargs:
      url = url + '/' + str(kwargs['id'])
      del kwargs['id']
    if len(kwargs) > 0:
      url = url + '?' + urlencode(kwargs)
    return url

  @classmethod
  def decorate_view_func(cls, view_func, decorators):
    if not isinstance(decorators, (list, tuple)):
      decorators = (decorators,)
    for decorator in reversed(decorators):
      view_func = decorator(view_func)
    return view_func


# View base class for Views handling
#   - /resources (GET, POST)
#   - /resources/<pk:pk_type> (GET, PUT, POST, DELETE)
class Resource(ModelView):
  """View base class for Views handling.  Will typically be registered with an
  application following a collection style for routes. Collection `GET` and
  `POST` will have a route like `/resources` while collection member
  resource routes will have routes likej `/resources/<pk:pk_type>`.

  To register a Resource subclass FooCollection with a Flask application:

  ..

     FooCollection.add_to(app, '/foos')

  By default will only support the `application/json` content-type.
  """

  signals = Namespace()
  model_posted = signals.signal(
      'Model POSTed',
      """
      Indicates that a model object was received via POST and will be committed
      to the database. The sender in the signal will be the model class of the
      POSTed resource. The following arguments will be sent along with the
      signal:

        :obj: The model instance created from the POSTed JSON.
        :src: The original POSTed JSON dictionary.
        :service: The instance of Resource handling the POST request.
      """,)
  model_put = signals.signal(
      'Model PUT',
      """
      Indicates that a model object update was received via PUT and will be
      updated in the database. The sender in the signal will be the model class
      of the PUT resource. The following arguments will be sent along with the
      signal:

        :obj: The model instance updated from the PUT JSON.
        :src: The original PUT JSON dictionary.
        :service: The instance of Resource handling the PUT request.
      """,)
  model_deleted = signals.signal(
      'Model DELETEd',
      """
      Indicates that a model object was DELETEd and will be removed from the
      databse. The sender in the signal will be the model class of the DELETEd
      resource. The followin garguments will be sent along with the signal:

        :obj: The model instance removed.
        :service: The instance of Resource handling the DELETE request.
      """,)

  def dispatch_request(self, *args, **kwargs):