# -*- coding: utf-8 -*-

# Copyright (C) 2014-2015 Avencall
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>


import logging
from json import loads

import sqlalchemy as sa
from sqlalchemy import sql
from sqlalchemy.dialects import postgresql
from sqlalchemy.sql import or_, func
from sqlalchemy.sql.functions import ReturnTypeFromArgs
from xivo.util import tokenize

from xivo_dao.helpers import errors

logger = logging.getLogger(__name__)


class unaccent(ReturnTypeFromArgs):
    pass


class SearchConfig(object):

    def __init__(self, **config):
        self.table = config['table']
        self.columns = config['columns']
        self._raw_config = config
        self._unique_column = None
        self._similarity_threshold = None

    def all_search_columns(self):
        search = self._raw_config.get('search', list())
        if search:
            return [self.columns[s] for s in search]
        return list(self.columns.values())

    def select_search_columns(self, column_list):
        results = [self.columns.get(_) for _ in column_list if self.columns.get(_)] if column_list else list()
        return results or self.all_search_columns()

    def column_for_searching(self, column_name):
        return self.columns.get(column_name)

    def column_for_sorting(self, name=None):
        return self.columns.get(self._get_sort_column_name(name))

    def _get_sort_column_name(self, name=None):
        name = name or self._raw_config.get('default_sort')
        if name is None:
            return None
        sort_columns = self._raw_config.get('sort') or list(self.columns.keys())

        if name not in sort_columns:
            raise errors.invalid_ordering(name)

        return name

    def search_columns_and_weights(self):
        search = self._raw_config.get('search', list())
        weights = self._raw_config.get('search_weights') or {_: 1 for _ in search}
        return [
            (self.columns[k], v) for k, v in weights.items()
        ]

    @property
    def unique_column(self):
        if self._unique_column:
            return self._unique_column

        self._unique_column = self._raw_config.get('unique_column')
        if not self._unique_column or self._unique_column not in self.columns.keys():
            raise errors.not_found('Unique column')

        return self._unique_column

    @property
    def similarity_threshold(self):
        if self._similarity_threshold:
            return self._similarity_threshold

        self._similarity_threshold = self._raw_config.get('similarity_threshold')
        if not self._similarity_threshold:
            raise errors.not_found('Definition of similarity threshold for fuzzy search')
        return self._similarity_threshold


class Searcher(object):
    id_text = 'Common Search (%like%)'

    SORT_DIRECTIONS = {
        'asc': sql.asc,
        'desc': sql.desc,
    }

    DEFAULTS = {
        'search': None,
        'order': None,
        'direction': 'asc',
        'limit': None,
        'offset': 0
    }

    def __init__(self, config):
        self.config = config

    def search_from_query(self, query, parameters=None):
        parameters = self._populate_parameters(parameters)
        self._validate_parameters(parameters)
        return self._search_from_query(query, parameters)

    def _search_from_query(self, query, parameters=None):
        query = self._filter(query, parameters)
        query = self._filter_exact_match(query, parameters)
        sorted_query = self._sort(query, parameters['order'], parameters['direction'])
        paginated_query = self._paginate(sorted_query, parameters['limit'], parameters['offset'])

        logger.debug(str(query.statement.compile(compile_kwargs={"literal_binds": True}, dialect=postgresql.dialect())))

        return paginated_query.all(), sorted_query.count()

    def _populate_parameters(self, parameters=None):
        new_params = dict(self.DEFAULTS)
        if parameters:
            parameters['offset'] = parameters.pop('skip', parameters.get('offset', self.DEFAULTS['offset']))
            new_params.update(parameters)

        return new_params

    def _validate_parameters(self, parameters):
        if parameters['offset'] < 0:
            raise errors.wrong_type('offset', 'positive number')

        if parameters['limit'] is not None and parameters['limit'] <= 0:
            raise errors.wrong_type('limit', 'positive number')

        if parameters['direction'] not in list(self.SORT_DIRECTIONS.keys()):
            raise errors.invalid_direction(parameters['direction'])

    def _filter(self, query, parameters):
        term_filter = self._term_filter(parameters)
        if term_filter is not None:
            return query.filter(term_filter)
        else:
            return query

    def _term_filter(self, parameters):
        term = parameters.get('search')
        if not term:
            return None

        search_columns = self.config.select_search_columns(parameters.get('search_columns'))
        criteria = []
        for column in search_columns:
            criteria.append(sql.cast(column, sa.String).ilike('%%%s%%' % term))

        return sql.or_(*criteria)

    def _filter_exact_match(self, query, parameters):
        for column_name, value in parameters.items():
            column = self.config.column_for_searching(column_name)
            if column is not None:
                query = query.filter(column == value)

        return query

    def _sort(self, query, order=None, direction='asc'):
        column = self.config.column_for_sorting(order)
        if column is None:
            raise errors.invalid_ordering('None')

        direction = self.SORT_DIRECTIONS[direction]
        return query.order_by(direction(column))

    def _paginate(self, query, limit=None, offset=0):
        if offset > 0:
            query = query.offset(offset)

        if limit:
            query = query.limit(limit)

        return query

    def list_from_query(self, query, unique_ids):
        if isinstance(unique_ids, str):
            try:
                unique_ids = loads(unique_ids)
            except ValueError:
                logger.warning(
                    'Unique IDs (%s) was passed in wierd format. Either json string or list of ids is expected.'
                    + ' Passed value is used as single id. Result can be ... unexpected.'
                )
                unique_ids = [unique_ids]

        query = query.filter(self.config.columns.get(self.config.unique_column).in_(unique_ids))
        return query.all(), query.count()


class SearcherFulltextAnd(Searcher):
    SEARCH_TERM_CONCAT_CHAR = '&'
    id_text = 'Fulltext Search "&"'

    def _search_from_query(self, query, parameters=None):
        self.term = parameters.get('search')
        return super(SearcherFulltextAnd, self)._search_from_query(query, parameters)

    def _transform_term(self, term):
        return (' %s ' % self.SEARCH_TERM_CONCAT_CHAR).join(tokenize(term))

    def _filter(self, query, parameters):
        return query.filter(or_(*[
            _.match(self._transform_term(parameters.get('search', '')), postgresql_regconfig='pg_catalog.simple')
            for _ in self.config.select_search_columns(parameters.get('search_columns'))
        ]))

    def _sort(self, query, order=None, direction='asc'):
        sort_def = self.config.search_columns_and_weights()
        if not sort_def:
            return query

        return query.order_by(self.SORT_DIRECTIONS[direction](sum(
            [
                func.ts_rank(func.to_tsvector(column), func.to_tsquery(self._transform_term(self.term))) * weight
                for column, weight in sort_def
            ]
        )))


class SearcherFulltextOr(SearcherFulltextAnd):
    SEARCH_TERM_CONCAT_CHAR = '|'
    id_text = 'Fulltext Search "|"'


class SearcherFuzzy(Searcher):
    id_text = 'Fuzzy Search'

    def _search_from_query(self, query, parameters=None):
        self.term = parameters.get('search')
        self.search_columns = self.config.select_search_columns(parameters.get('search_columns'))
        return super(SearcherFuzzy, self)._search_from_query(query, parameters)

    def _similarities(self):
        return [func.similarity(sql.cast(_, sa.String), self.term) for _ in self.search_columns]

    def _filter(self, query, parameters):
        similarity_threshold = self.config.similarity_threshold
        return query.filter(or_(*[_ > similarity_threshold for _ in self._similarities()]))

    def _sort(self, query, order=None, direction='asc'):
        return query.order_by(func.greatest(*self._similarities()).desc())


class SearcherCombined(SearcherFulltextAnd):
    id_text = 'Combined Search'

    def _filter(self, query, parameters):
        term_filter = self._term_filter(parameters)
        if term_filter is not None:
            return query.filter(term_filter)
        else:
            return query


class SearcherNG(Searcher):
    def _term_filter(self, parameters):
        term = parameters.get('search')
        if not term:
            return None

        search_columns = self.config.select_search_columns(parameters.get('search_columns'))
        conditions = tokenize(term)
        criteria = []
        for column in search_columns:
            criteria.append(sql.and_(*[
                unaccent(sql.cast(column, sa.String)).ilike(unaccent('%%%s%%' % _)) for _ in conditions
            ]))

        return sql.or_(*criteria)


class SearcherCombinedNG(SearcherNG, SearcherCombined):
    id_text = 'Combined Search NG'
