from typing import Dict

import transaction
from pyparsing import ParseException
from sqlalchemy import String, and_, orm
from sqlalchemy.exc import InvalidRequestError
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import ColumnProperty, Mapper, RelationshipProperty
from sqlalchemy.orm.base import NOT_EXTENSION
from sqlalchemy.orm.exc import NoResultFound, StaleDataError
from sqlalchemy.orm.util import AliasedClass

from pyramid.httpexceptions import (HTTPConflict, HTTPNotFound, HTTPOk,
                                    HTTPServerError)
from pyramid.request import Request
from pyramid.settings import asbool

from .interfaces import JsonGuardProvider
from .json_decoder import JSONDecoder
from .json_encoder import JSONEncoder
from .monkeypatch import (_polymorphic_constructor, coerce_value,
                          patch_sqlalchemy_base_class)
from .parser import hints_parser, route_parser

# Returns a renderer for pyramid; base_type (SQLAlchemy declarative base) is
# needed to detect sqlalchemy object instances
# Sample usage:
#     config.add_renderer('json', pyramid_json_renderer_factory(Base))

def pyramid_json_renderer_factory(base_type=None, wsgi_iter=False,
                                  separators=(',', ':')):
    def _json_renderer(info):
        def _render(value, system):
            request = system.get('request')
            if request is not None:
                # this is optimal as we will stream our JSON from the iterator
                response = request.response
                response.content_type = 'application/json'
                response.charset = 'utf8'

                json_encoder = JSONEncoder(request, base_type=base_type,
                                           separators=separators,
                                           check_circular=False)

                # solution 1: write to stream from renderer
                if not wsgi_iter:
                    for chunk in json_encoder.iterencode(value):
                        response.write(chunk)
                    return None

                # solution 2: provide iterable to wsgi layer
                else:
                    def _iterencode(val):
                        for chunk in json_encoder.iterencode(val):
                            yield chunk.encode()

                    response.app_iter = _iterencode(value)
                    return None
            else:
                # fallback for direct calls?
                json_encoder = JSONEncoder(base_type=base_type,
                                           separators=separators)
                return json_encoder.encode(value)
        return _render
    return _json_renderer


# Sample usage:
#     config.add_request_method(pyramid_json_decoder, 'json', reify=True)
def pyramid_json_decoder(request):
    return JSONDecoder(encoding=request.charset).decode(request.body)


# exception to be thrown when a
class VersionCheckError(RuntimeError):
    pass


# Extend this and decorate accordingly; methods list, get, insert, update,
# delete should be decorated with pyramid @view_config
class CRUDView(object):
    filters = dict()
    accept_order = dict()
    # json_hints = dict()
    # update_hints = dict()
    context = None
    target_type = object
    target_name = "object"
    request = None
    update_lock = False
    # this means accept_order will be rewritten
    use_subquery_after_filter = False

    def __init__(self, request):
        """:type request: Request"""
        self.request = request
        self.context = request.context
        # self.context._json_hints = self.json_hints
        assert(isinstance(self.request, Request))

    @property
    def identity_filter(self):
        return False

    def sanitize_input(self):
        return self.request.json[self.target_name]

    @property
    def context_filter(self):
        return True

    def get_one_from_query(self, query):
        return query.filter(self.context_filter, self.identity_filter).one()

    def get_by_id(self, update_lock=False):
        try:
            query = self.get_identity_base()
            if update_lock:
                # (partially a) SQLAlchemy bug: Postgres doesn't allow
                # qualified table names in FOR UPDATE OF statements, so
                # whenever targeted locks are required an alias is necessary,
                # for us at least since we use schemas for all tables
                if isinstance(self.target_type, AliasedClass):
                    query = query.with_for_update(of=self.target_type)
                else:
                    query = query.with_for_update()
            return self.get_one_from_query(query)
        except NoResultFound:
            raise HTTPNotFound()

    def get_query_filters(self, exclude=None):
        tmp = [self.filters[key](self.request.GET[key]) for key in self.filters
               if key in self.request.GET and
               (exclude is None or key not in exclude)]
        return [i for i in tmp if not isinstance(i, (list, tuple))
                and i is not None] + \
               [i for j in tmp if isinstance(j, (list, tuple))
                and j is not None for i in j if i is not None] + \
            self.runtime_filters()

    def runtime_filters(self):
        return []

    @property
    def query_filters(self):
        return self.get_query_filters()

    @property
    def order_clauses(self):
        if 'order' not in self.request.GET or self.request.GET['order'] == "":
            return None
        orders = [(item[:-5], True) if item.endswith(' desc')
                  else(item, False)
                  for item in self.request.GET['order'].split(",")]
        if any([order[0] not in self.accept_order for order in orders]):
            invalid_order = [order[0] for order in orders
                             if order[0] not in self.accept_order]
            raise HTTPServerError(
                f'not implemented, order by {", ".join(invalid_order)}'
            )

        selected = [(self.accept_order[order[0]], order[1])
                    for order in orders]
        for item in selected:
            if isinstance(item[0], list):
                for elem in item[0]:
                    yield elem.desc() if item[1] else elem
            else:
                yield item[0].desc() if item[1] else item[0]

    @property
    def pager_slice(self):
        if 'pageSize' not in self.request.GET:
            return False
        page_size = int(self.request.GET['pageSize'])
        if page_size <= 0:
            return False
        page = int(self.request.GET['page']
                   ) if 'page' in self.request.GET else 0
        return slice(page * page_size, page * page_size + page_size)

    def get_base_query(self):
        return self.request.dbsession.query(self.target_type)

    def get_identity_base(self):
        return self.get_base_query()

    def get_list_base(self):
        return self.get_base_query()

    def get_search_results(self, query=None):
        if query is None:
            query = self.get_list_base().filter(self.context_filter)
        query = query.filter(*self.query_filters)
        pager = self.pager_slice
        count = query.count()

        if self.use_subquery_after_filter:
            query = query.subquery()
            self.accept_order = dict(query.c)
            query = self.request.dbsession.query(
                *query.c)

        order_clauses = self.order_clauses
        if order_clauses is not None:
            # reset and apply order_by
            query = query.order_by(None).order_by(*order_clauses)
        return query[pager] if pager else query.all(), count

    def get(self):
        return {self.target_name: self.get_by_id()}

    def list(self):
        items, count = self.get_search_results()
        return dict(items=items, total=count)

    def apply_changes(self, obj, data, for_update=True):
        obj.apply_changes(data, context=self.context, for_update=for_update)

    def update(self):
        try:
            with transaction.manager, self.request.dbsession.no_autoflush:
                old = self.get_by_id(update_lock=self.update_lock)
                values = self.sanitize_input()
                try:
                    self.apply_changes(old, values, True)
                except VersionCheckError as ex:
                    raise HTTPConflict(str(ex))
        except StaleDataError as ex:
            raise HTTPConflict(str(ex))
        return self.request.dbsession.merge(old)

    def insert(self):
        values = self.sanitize_input()
        obj = _polymorphic_constructor(self.target_type, values)
        with transaction.manager:
            self.request.dbsession.add(obj)
            self.apply_changes(obj, values, False)
        obj = self.request.dbsession.merge(obj)
        return {self.target_name: obj}

    def delete(self):
        with transaction.manager:
            old = self.get_by_id()
            self.request.dbsession.delete(old)
        return HTTPOk()

    @classmethod
    def auto_fields(cls, target):
        return [item for item in inspect(target).all_orm_descriptors.values()
                if item.is_attribute and item.extension_type == NOT_EXTENSION
                and isinstance(item.property, ColumnProperty)]

    @classmethod
    def auto_filters(cls, target=None, prefix=None):
        if target is None:
            target = cls.target_type

        def coerce_func(x, attr):
            return coerce_value(target, attr.property.columns[0], x, False)

        ret = dict()

        for item in cls.auto_fields(target):
            key = prefix + item.key if prefix is not None else item.key
            ret[key] = lambda x, attr=item: attr == coerce_func(x, attr)
            if isinstance(item.property.columns[0].type, String):
                ret[f'{key}_like'] = \
                    lambda x, attr=item: attr.ilike('%' + x + '%')
            ret[f'{key}_gt'] = \
                lambda x, attr=item: attr > coerce_func(x, attr)
            ret[f'{key}_ge'] = \
                lambda x, attr=item: attr >= coerce_func(x, attr)
            ret[f'{key}_lt'] = \
                lambda x, attr=item: attr < coerce_func(x, attr)
            ret[f'{key}_le'] = \
                lambda x, attr=item: attr <= coerce_func(x, attr)
            ret[f'{key}_isnull'] = \
                lambda x, attr=item: attr.is_(None) if asbool(x) else \
                attr.isnot(None)
        return ret

    @classmethod
    def auto_order(cls, target=None, prefix=None):
        if target is None:
            target = cls.target_type
        ret = dict()
        for attr in cls.auto_fields(target):
            key = prefix + attr.key if prefix is not None else attr.key
            ret[key] = attr
        return ret


class ConvertMatchdictPredicate:
    def __init__(self, args, config):
        assert len(args) >= 1 and (type(args[0]) == tuple or
                                   callable(args[0]))
        self.args = args

    def text(self):
        return f'convert_matchdict={self.args!r}'

    phash = text

    def __call__(self, context, request):
        args = self.args if type(self.args[0]) == tuple else(self.args,)
        match = request.matchdict
        for argList in args:
            argType = argList[0]
            assert callable(argType)
            for arg in (argList[1:]):
                if arg in match:
                    try:
                        match[arg] = argType(match[arg])
                    except (TypeError, ValueError):
                        return False
        return True


def _get_assert_one(q):
    return q.one()


def _get_by_pkey(pkey):
    def _impl(q):
        return q.get(pkey)
    return _impl

def _get_by_combinedfilter(pkey_filter):
    def _impl(q):
        return q.filter(*pkey_filter).one()
    return _impl

# TODO: reverse order for negative indexes (?)
def _get_by_index(index):
    def _impl(q):
        ret = q[index:index + 1]
        if len(ret) == 0:
            raise NoResultFound()
        return ret[0]
    return _impl


class CatchallTarget:
    _cls = None
    profiles = dict()
    filters = []

    def __init__(self, cls, profiles=dict(), filters=None):
        self._cls, self.profiles, self.filters = cls, profiles, \
            filters

    def __repr__(self):
        return f"(cls:{self._cls!r}, profiles:{self.profiles!r}, filters:{self.filters!r}"


class CatchallPredicate:
    targets: Dict[str, CatchallTarget] = None

    def __init__(self, targets, config):
        def _adapt(obj) -> CatchallTarget:
            if isinstance(obj, CatchallTarget):
                return obj
            if type(obj) not in (tuple, dict, list):
                return CatchallTarget(obj)
            if type(obj) is dict:
                return CatchallTarget(**obj)
            return CatchallTarget(*obj)

        self.targets = {
            key: _adapt(obj) for key, obj in targets.items()
        }

    def text(self):
        return f'catchall={self.targets!r}'

    phash = text

    def __call__(self, context, request):
        match = request.matchdict

        # convert target to type
        if 'catchall' not in match:
            return False

        try:
            route = route_parser.parseString(match['catchall'], True)
        except ParseException:
            return False

        if route['verb'] not in self.targets:
            return False

        target = self.targets[route['verb']]
        getter = None

        insp = inspect(target._cls)
        if 'cast' in route:
            if insp.polymorphic_on is None:
                return False
            try:
                value = coerce_value(target._cls, insp.polymorphic_on,
                                     route['cast'])
            except ValueError:
                return False
            if value not in insp.polymorphic_map:
                return False
            insp = insp.polymorphic_map[value]
            target = CatchallTarget(insp.class_, target.profiles,
                                    target.filters)

        query = request.dbsession.query(target._cls)

        if target.filters is not None:
            filters = target.filters
            if callable(filters):
                filters = filters(request)
            if type(filters) not in (list, tuple):
                filters = (filters,)

            query = query.filter(*filters)

        # convert pkey
        if 'pkey' in route:
            pkey = route['pkey']
            if len(pkey) != len(insp.primary_key):
                return False
            try:
                pkey = tuple(coerce_value(target._cls, col, val)
                             for col, val in zip(insp.primary_key, pkey))
            except ValueError:
                return False
            if query.whereclause is None:
                # no implicit/context filters
                getter = _get_by_pkey(pkey)
            else:
                filters = map(
                    lambda x: x[0]==x[1], 
                    zip(insp.primary_key, pkey)
                    )
                getter = _get_by_combinedfilter(filters)

        if 'drilldown' in route:
            # cannot drilldown property if result is list
            if getter is None:
                return False

            drilldown = route['drilldown']
            try:
                prop = insp.get_property(drilldown)
            except InvalidRequestError:
                return False

            if isinstance(context, JsonGuardProvider):
                if not context.guardDrilldown(prop.class_attribute):
                    return False

            if not isinstance(prop, RelationshipProperty):
                return False

            # target switch in drilldown
            target = CatchallTarget(prop.entity.class_)

            if prop.lazy == 'dynamic':
                if 'drilldown_cast' in route:
                    # we cannot apply polymorphic casting here
                    return False
                # special drilldown love for dynamic props
                query = getattr(getter(query), prop.key)
                # getter no longer useful
                getter = None
            else:
                # manually construct drilldown query for non-dynamic props
                pkey_filter = (col == val for col, val in
                               zip(insp.primary_key, pkey))
                if 'drilldown_cast' in route:
                    insp = inspect(target._cls)
                    if insp.polymorphic_on is None:
                        return False
                    try:
                        value = coerce_value(target._cls, insp.polymorphic_on,
                                             route['drilldown_cast'])
                    except ValueError:
                        return False
                    if value not in insp.polymorphic_map:
                        return False
                    insp = insp.polymorphic_map[value]
                    target = CatchallTarget(insp.class_)

                query = request.dbsession.query(target._cls) \
                    .select_from(prop.parent) \
                    .join(prop.class_attribute) \
                    .filter(and_(*pkey_filter))
                getter = None

            if not prop.uselist:
                # target is single item fk
                getter = _get_assert_one

        if 'slice' in route:
            # cannot slice if pkey or single item drilldown encountered
            if getter is not None:
                return False

            slicer = route['slice']
            if 'index' in slicer:
                getter = _get_by_index(int(slicer['index']))
            else:
                match['slicer'] = slicer

        profile = None
        if 'profile' in route:
            if route['profile'] not in target.profiles:
                return False
            profile = target.profiles[route['profile']]
            profile = hints_parser.parseString(profile, True)
            hints = self.get_hints(profile, target._cls, context=context)
            query = query.options(*hints)

        # decode hints
        if 'hints' in route:
            try:
                hints = self.get_hints(route['hints'], target._cls,
                                       context=context)
                query = query.options(*hints)
            except AssertionError:
                return False

        match['query'] = query
        match['getter'] = getter

        return True

    @staticmethod
    def get_hints(value, cls, base=orm, context=None):
        # hints structure: [atom(,atom)+]
        # atom is one of:
        #  +field_name: undefer field (include)
        #  -field_name: defer field (exclude)
        #  TODO if field_name is "ALL", defer or undefer all fields
        #   (DOES NOT AFFECT COLLECTIONS, see below)
        #  *collection(-x)?(\(hints\))?:
        #   eager load collection (-x marks subquery laod preference);
        #   hints in parantheses recursively apply to elements of
        #   the collection

        if type(cls) is Mapper:
            # caller passed us a mapper (drilldown)
            insp = cls
            cls = insp.class_
        else:
            insp = inspect(cls)

        hints = {}
        ret = []
        for item in value:
            op = item['op']
            if op == '!':
                if insp.polymorphic_on is None:
                    raise AssertionError("invalid cast")
                try:
                    _type = coerce_value(cls, insp.polymorphic_on,
                                         item['type'])
                except ValueError:
                    raise AssertionError("invalid cast")
                if _type not in insp.polymorphic_map:
                    raise AssertionError("invalid cast")

                ret.extend(
                    CatchallPredicate.get_hints(
                        item['children'],
                        insp.polymorphic_map[_type],
                        base, context=context
                    ))
                continue

            name = item['name']
            prop = insp.get_property(name)
            hints[prop.class_attribute] = (op, item.get('children'))
        if isinstance(context, JsonGuardProvider):
            context.guardHints(cls, hints)
        for attr, (op, children) in hints.items():
            prop = attr.prop
            hint = None
            if isinstance(prop, ColumnProperty):
                if op == '+':
                    hint = base.undefer(attr)
                elif op == '-':
                    hint = base.defer(attr)
                else:
                    raise AssertionError("invalid op")
            elif isinstance(prop, RelationshipProperty):
                if op == '-':
                    hint = base.lazyload(attr)
                else:
                    if op == '*':
                        hint = base.selectinload(attr)
                    else:
                        hint = base.joinedload(attr)
                    if children:
                        CatchallPredicate.get_hints(children, attr.entity,
                                                    hint, context=context)
            ret.append(hint)
        return ret


# base class to decorate with a CatchAllPredicate
class CatchallView(CRUDView):
    context = None
    request = None
    query = None
    getter = None
    slicer = None

    def __init__(self, request):
        super().__init__(request)
        self.query = self.request.matchdict['query']
        self.getter = self.request.matchdict.get('getter')

        self.target_type = self.query.column_descriptions[0]['type']
        self.target_name = self.target_type.__table__.name
        self.slicer = self.request.matchdict.get('slicer')

        self.filters = self.auto_filters(target=self.target_type)
        self.accept_order = self.auto_order(target=self.target_type)

    def get_base_query(self):
        return self.query

    def get_one_from_query(self, query):
        if self.getter is None:
            raise HTTPNotFound()
        query = query.filter(*self.query_filters)
        order_clauses = self.order_clauses
        if order_clauses is not None:
            query = query.order_by(None).order_by(*order_clauses)
        return self.getter(query)

    def process(self):
        if self.request.method == 'GET':
            if self.getter is not None:
                return self.get()
            return self.list()
        elif self.request.method == 'POST':
            if self.getter is not None:
                return self.update()
            return self.insert()
        elif self.request.method == 'DELETE':
            if self.getter is None:
                raise HTTPNotFound()
            return self.delete()
        raise HTTPNotFound()

    @ property
    def pager_slice(self):
        if self.slicer is not None:
            return slice(int(self.slicer['start']),
                         int(self.slicer['stop']))
        return super().pager_slice


def includeme_factory(base_class=None, config_json=True, add_predicates=True,
                      wsgi_iter=False, separators=(',', ':')):
    def includeme(config):
        if config_json and base_class is not None:
            config.add_renderer(
                'json',
                pyramid_json_renderer_factory(
                    base_class, wsgi_iter=wsgi_iter,
                    separators=separators))
            config.add_request_method(pyramid_json_decoder, 'json', reify=True)
        if add_predicates:
            config.add_view_predicate(
                'convert_matchdict', ConvertMatchdictPredicate)
            config.add_view_predicate('catchall', CatchallPredicate)
        if base_class is not None:
            patch_sqlalchemy_base_class(base_class)

    return includeme
