# coding: utf-8
import copy
import logging
import time
import pandas as pd

from itertools import chain
from fireant.slicer.operations import Totals
from fireant.slicer.references import YoY, Delta, DeltaPercentage
from pypika import Query, JoinType
from pypika.terms import ListField

query_logger = logging.getLogger('fireant.query_log$')


class QueryManager(object):
    def query_data(self, database, table, joins=None,
                   metrics=None, dimensions=None,
                   mfilters=None, dfilters=None,
                   references=None, rollup=None):
        """
        Loads a pandas data frame given a table and a description of the request.

        :param database:
            The database interface to use to execute the connection

       :param table:
            Type: pypika.Table
            (Required) The primary table to select data from.  In SQL, this is the table in the FROM clause.

        :param joins:
            Type: list[tuple[pypika.Table, pypika.Criterion, pypika.JoinType]]
            (Optional) A list of tables to join in the query.  If a metric should be selected from a table besides the
            table parameter, the additional table *must* be joined.

            The tuple includes in the following order: Join table, Join criterion, Join type

        :param metrics:
            Type: dict[str: pypika.Field]]
            A dict containing metrics fields to query.  This value is required to contain at least one entry.  The key
            used will match the corresponding column in the returned pd.DataFrame.

        :param dimensions:
            Type: OrderedDict[str: pypika.Field]]
            (Optional) An OrderedDict containing indices to query.  If empty, a pd.Series will be returned containing
            one value per metric.  If given one entry, a pd.DataFrame with a single index will be returned.  If more
            than one value is given, then a pd.DataFrame with a multi-index will be returned.  The key used will match
            the corresponding index level in the returned DataFrame.

            If a dict is used instead of an OrderedDict, then the index levels cannot be guarenteed, so in most cases an
            OrderedDict should be used.

        :param mfilters:
            Type: list[pypika.Criterion]
            (Optional) A list containing criterions to use in the HAVING clause of the query.  This will reduce the data
            to instances where metrics meet the criteria.

        :param dfilters:
            Type: list[pypika.Criterion]
            (Optional) A list containing criterions to use in the WHERE clause of the query.  This will reduce the data
            to instances where dimensions meet the criteria.

        :param references:
            Type: dict[str: str]]
            (Optional) A dict containing comparison operations.  The key must match a valid reference.  The value must
            match the key of a provided dimension.  The value must match a dimensions of the appropriate type, usually a
            Time Series dimension.

            For each value passed, the query will be duplicated containing values for a comparison.

            References can also modify the comparison field to show values such as change.  The format is as follows:

            {key}_{modifier}

            The modifier type is optional and the underscore must be omitted when one is not used.

            Valid references are as follows:

            - *wow*: Week over Week - duplicates the query for values one week prior.
            - *mom*: Month over Month - duplicates the query for values 4 weeks prior.
            - *qoq*: Quarter over Quarter - duplicates the query for values one quarter prior.
            - *yoy*: Year over Year - duplicates the query for values 52 weeks prior.

            Valid modifier types are as follows:

            - *d*: Delta - difference between previous value and current
            - *p*: Delta Percentage - difference between previous value and current as a percentage of the previous
                    value.

            Examples:

            "wow":   Week over Week
            "wow_d": delta Week over Week

        :param rollup:
            Type: list[str]
            (Optional) Lists the dimensions to include for ROLLUP which calculates the totals across groups.  This list
            must be a subset of the keys of the dimensions parameter dict.

            .. note::

                When using rollup for less than all of the dimensions, the dimensions included in the ROLLUP will be
                moved after the non-ROLLUP dimensions.

        :return:
            A pd.DataFrame indexed by the provided dimensions paramaters containing columns for each metrics parameter.
        """
        query = self._build_data_query(
            database, table, joins, metrics, dimensions, dfilters, mfilters, references, rollup
        )
        query_string = str(query)

        start_time = time.time()
        dataframe = database.fetch_dataframe(query_string)

        query_logger.info('[duration: {duration} seconds]: {query}'.format(
            duration=round(time.time() - start_time, 4),
            query=query_string)
        )

        dataframe.columns = [col.decode('utf-8') if isinstance(col, bytes) else col
                             for col in dataframe.columns]

        for column in dataframe.columns:
            # Only replace NaNs for columns of type object. Column types other than that tend to be checked
            # against in the transformers. Which would be a problem when replacing NaNs with a string
            # because that alters the type of the column.
            if dataframe[column].dtype == object:
                dataframe[column] = dataframe[column].fillna('')

        if dimensions:
            dataframe = dataframe.set_index(
                # Removed the reference keys for now
                list(dimensions.keys())  # + ['{1}_{0}'.format(*ref) for ref in references.items()]
            ).sort_index()

        if references:
            dataframe.columns = pd.MultiIndex.from_product([[''] + list(references.keys()), list(metrics.keys())])

        return dataframe.fillna(0)

    def query_dimension_options(self, database, table, joins=None, dimensions=None, filters=None, limit=None):
        """
        Builds and executes a query to retrieve possible dimension options given a set of filters.

        :param database:
            The database interface to use to execute the connection
        :param table:
            See above

        :param joins:
            See above

        :param dimensions:
            See above

        :param filters:
            See above

        :param limit:
            An optional limit to the number of results returned.

        :return:
        """
        query = self._build_dimension_query(table, joins, dimensions, filters, limit)
        results = database.fetch(str(query))
        return [{k: v for k, v in zip(dimensions.keys(), result)}
                for result in results]

    def _build_data_query(self, database, table, joins, metrics, dimensions, dfilters, mfilters, references, rollup):
        args = (table, joins or dict(), metrics or dict(), dimensions or dict(),
                dfilters or dict(), mfilters or dict(), rollup or list())

        query = self._build_query_inner(*args)

        if references:
            return self._build_reference_query(query, database, references, *args)

        return self._add_sorting(query, list(dimensions.values()))

    def _build_reference_query(self, query, database, references, table, joins, metrics, dimensions, dfilters,
                               mfilters, rollup):
        wrapper_query = Query.from_(query).select(*[query.field(key).as_(key)
                                                    for key in list(dimensions.keys()) + list(metrics.keys())])

        for reference_key, schema in references.items():
            dimension_key, interval = schema['dimension'], schema['interval']

            # The interval term from pypika does not take into account leap years, therefore the interval
            # needs to be replaced with a database specific one when appropriate.
            if reference_key in [YoY.key, Delta.generate_key(YoY.key), DeltaPercentage.generate_key(YoY.key)]:
                interval = database.interval(years=1)

            schema['interval'] = interval

            dfilters = self._replace_filters_for_ref(dfilters, schema['definition'], interval)
            ref_query = self._build_query_inner(table, joins, metrics, dimensions, dfilters, mfilters, rollup)
            join_criteria = self._build_reference_join_criteria(dimension_key, dimensions, interval, query, ref_query)

            # Optional modifier function to modify the metric in the reference query. This is for delta and delta
            # percentage references. It is None for normal references and this default should be used
            modifier = schema.get('modifier')

            # Join the reference query and select the reference dimension and all metrics
            # This ignores additional dimensions since they are identical to the primary results
            if join_criteria:
                wrapper_query = wrapper_query.join(ref_query, JoinType.left).on(join_criteria)
            else:
                wrapper_query = wrapper_query.from_(ref_query)

            if modifier:
                get_reference_field = lambda key: modifier(query.field(key), ref_query.field(key))

            else:
                get_reference_field = lambda key: ref_query.field(key)

            wrapper_query = wrapper_query.select(
                *[get_reference_field(key).as_(self._suffix(key, reference_key))
                  for key in metrics.keys()]
            )

        return self._add_sorting(wrapper_query, [query.field(dkey) for dkey in dimensions.keys()])

    def _build_dimension_query(self, table, joins, dimensions, filters, limit=None):
        query = Query.from_(table).distinct()
        query = self._add_joins(joins, query)
        query = self._add_filters(query, filters, [])

        for key, dimension in dimensions.items():
            query = query.select(dimension.as_(key))

        if limit:
            query = query[:limit]

        return query

    def _build_query_inner(self, table, joins, metrics, dimensions, dfilters, mfilters, rollup):
        query = Query.from_(table)
        query = self._add_joins(joins, query)
        query = self._select_dimensions(query, dimensions, rollup)
        query = self._select_metrics(query, metrics)
        return self._add_filters(query, dfilters, mfilters)

    @staticmethod
    def _add_joins(joins, query):
        for join_table, criterion, join_type in joins:
            query = query.join(join_table, how=join_type).on(criterion)
        return query

    @staticmethod
    def _select_dimensions(query, dimensions, rollup):
        dims = [dimension.as_(key)
                for key, dimension in dimensions.items()
                if key not in chain(*rollup)]
        if dims:
            query = query.select(*dims).groupby(*dims)

        rollup_dims = [
            dimension.as_(dimension_key)
            for dimension_key, dimension in dimensions.items()
            for keys in rollup
            if dimension_key in keys
        ]

        # Rollups must take UniqueDimension's two columns (i.e. id, display) as one
        rollup_args = [
            ListField([
                dimension.as_(dimension_key)
                for dimension_key, dimension in dimensions.items()
                if dimension_key in keys
            ])
            for keys in rollup
        ]

        if rollup_dims:
            query = query.select(*rollup_dims).rollup(*rollup_args)

        return query

    @staticmethod
    def _select_metrics(query, metrics):
        return query.select(*[metric.as_(key)
                              for key, metric in metrics.items()])

    @staticmethod
    def _add_filters(query, dfilters, mfilters):
        if dfilters:
            for dfx in dfilters:
                query = query.where(dfx)

        if mfilters:
            for mfx in mfilters:
                query = query.having(mfx)

        return query

    @staticmethod
    def _add_sorting(query, dimensions):
        if dimensions:
            return query.orderby(*dimensions)
        return query

    @staticmethod
    def _suffix(key, suffix):
        return '%s_%s' % (key, suffix) if suffix else key

    @staticmethod
    def _replace_filters_for_ref(dfilters, target_dimension, interval):
        """
        Replaces the dimension used by a reference in the dimension schema and dimension filter schema.

        We do this in order to build the same query with a shifted date instead of the actual date.
        """
        new_dfilters = []
        for dfilter in dfilters:
            try:
                # FIXME this is a bit hacky. Casts the fields to string to see if the filter uses this dimensinon
                # TODO provide a utility in pypika for checking if these are the same
                filter_fields = ''.join(str(f) for f in dfilter.term.fields())
                target_fields = ''.join(str(f) for f in target_dimension.fields())
                if filter_fields == target_fields:
                    dfilter = copy.deepcopy(dfilter)
                    dfilter.term = dfilter.term + interval

            except (AttributeError, IndexError):
                pass  # If the above if-expression cannot be evaluated, then its not the filter we are looking for

            new_dfilters.append(dfilter)

        return new_dfilters

    @staticmethod
    def _build_reference_join_criteria(dimension_key, dimensions, interval, query, ref_query):
        ref_join_criteria = []
        if dimension_key in dimensions:
            ref_join_criteria.append(
                query.field(dimension_key) == ref_query.field(dimension_key) + interval
            )
        ref_join_criteria += [query.field(dkey) == ref_query.field(dkey)
                              for dkey in set(dimensions.keys()) - {dimension_key}]

        # If there are no selected dimensions, this will be an empty list.
        if not ref_join_criteria:
            return None

        ref_criteria = ref_join_criteria.pop(0)
        for criteria in ref_join_criteria:
            ref_criteria &= criteria

        return ref_criteria
