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

import time
from math import ceil

from operator import itemgetter
from collections import OrderedDict
import numpy as np

from mdar.recommenders.base import BaseRecommender
from mdar.recommenders.oa import OrderAssociationRecommender
from mdar.recommenders.uh import UserHistoryRecommender
from mdar.recommenders.uh2 import UserHistory2Recommender
from mdar.recommenders.tr import TimeRelatedRecommender
from mdar.data_manager import DataManager

class MDAR(BaseRecommender):
    """Main recommender class - Multidimensional Association Recommender.
    Incorporates different approaches which are weighted  in training phase
    globally and for each user.

    Args:
        config_path(str, optional): path to config file used in DataManager.
        k_fold_size(int, optional): number of k parts for cross-validation.
        Defaults to 3.
        used_approaches(list, optional): list of tuples which holds name of the
        approach(1) and its weight(2) which should be in range [0-1]
    """
    _min_arhr = .5
    _train_time = 0

    model = {}
    user_items = {}
    recommenders = {}

    user_approaches_w = {}

    used_approaches = []
    AVAILABLE_APPROACHES = [
        'order_association',
        'user_history',
        'user_history2',
        'time_related'
    ]

    def __init__(self, config_path=None, k_fold_size=3, used_approaches=None):
        if config_path is not None:
            self._data_manager = DataManager(config_path, k_fold_size)

        self.approaches_order = OrderedDict()
        self.set_used_approaches(used_approaches)

        self.max_user_rpr = 0
        self.max_item_rpr = 0

    def train(self, k=10):
        """Iterate over orders from 'train' dataset and define a model class
        attribute which is used in recommending new items.

        Args:
            k(int): maximum number of recommendations generated by each approach
            during training. Defaults to 10.
        """
        start = time.time()
        self.model = self.get_init_model()
        orders, max_oi_count = self._append_previous_order_items(
            self.data_manager.get_orders('train')
        )

        self._init_recommenders(max_oi_count)
        if self.is_approach_used(self.AVAILABLE_APPROACHES[1:3]):
            self.user_items = {}
            for user_items in self.data_manager.get_user_items(None, 'train'):
                self.user_items[user_items['user']] = user_items['items']

        for order in orders:
            if self.is_approach_used(self.AVAILABLE_APPROACHES[0]) and order['poi']:
                recommendations = self.recommenders['oa'].get_mem_recommendations(
                    order['poi'], k, 2, order['part_of_day'])
                self._test_item_against_recommendations(
                    order['item'], recommendations, self.AVAILABLE_APPROACHES[0],
                    order['user'])

            if self.is_approach_used(self.AVAILABLE_APPROACHES[1]) \
                and self.user_items[order['user']]:
                recommendations = self.recommenders['uh'].get_mem_recommendations(
                    self.user_items[order['user']], k)
                self._test_item_against_recommendations(
                    order['item'], recommendations, self.AVAILABLE_APPROACHES[1],
                    order['user'])

            if self.is_approach_used(self.AVAILABLE_APPROACHES[2]) \
                and self.user_items[order['user']]:
                recommendations = self.recommenders['uh2'].get_recommendations(
                    order['user'], self.user_items[order['user']], k)
                self._test_item_against_recommendations(
                    order['item'], recommendations, self.AVAILABLE_APPROACHES[2],
                    order['user'])

            if self.is_approach_used(self.AVAILABLE_APPROACHES[3]):
                recommendations = self.recommenders['tr'].get_mem_recommendations(
                    order['part_of_day'], order['day_in_week'], None, k)
                self._test_item_against_recommendations(
                    order['item'], recommendations, self.AVAILABLE_APPROACHES[3],
                    order['user'])

        self._calculate_model()
        # print self.model
        self.init_approaches_order()
        self.train_time = time.time() - start

    def recommend(self, k, order, previous_order_items, use_approach_offsets=True):
        """Generate k recommendations for order's user, time attributes, and
        already defined order items.

        Args:
            k(int): expected number of recommendations
            order(dict): should be of following structure
                {
                    'user': int
                    'item': int
                    'cats': list of category IDs(int)
                    'part_of_day': string
                    'day_in_week': string
                    'month': int
                }
            previous_order_items(list): contains dicts of following structure
                {
                    'item': int
                    'cats': list of category IDs(int)
                }
            use_approach_offsets(bool): should the approach's MCV be used as a
            list offset. Defaults to True.

        Returns:
            list: should contain item IDs(int), length of k.
        """
        recommendations = []

        # set the priority of the recommendations algorithms
        approaches_order = self.get_approaches_order_for_user(order['user'])
        k_per_approach = self.get_k_per_approach(k, approaches_order)

        # iterate over approaches and populate recommendations list
        approach_index = 0
        user_items = self.data_manager.get_user_items(order['user'], 'train')

        for approach in approaches_order.keys():
            # approach requirements
            if approach is self.AVAILABLE_APPROACHES[0] and not previous_order_items:
                continue
            elif approach in self.AVAILABLE_APPROACHES[1:3] and not user_items:
                continue

            # recommendation generation
            if approach is self.AVAILABLE_APPROACHES[0]:
                r_temp = self.recommenders['oa'].get_recommendations(
                    previous_order_items, k, 2, order['part_of_day'])
            elif approach is self.AVAILABLE_APPROACHES[1]:
                r_temp = self.recommenders['uh'].get_recommendations(
                    self.user_items[order['user']], k)
            elif approach is self.AVAILABLE_APPROACHES[2]:
                r_temp = self.recommenders['uh2'].get_recommendations(
                    order['user'], self.user_items[order['user']], k)
            elif approach is self.AVAILABLE_APPROACHES[3]:
                r_temp = self.recommenders['tr'].get_recommendations(
                    order['part_of_day'], order['day_in_week'], None, k)

            r_count = len(r_temp)
            if r_count:
                r_slot_index = 0
                for i in range(0, approach_index):
                    r_slot_index += k_per_approach[i]

                # get recommendation offset for current approach
                slots_left = k_per_approach[approach_index]

                offset = 0
                if use_approach_offsets:
                    offset = self._get_approach_offset(
                        'user', order['user'], approach, slots_left, r_count)

                # populate recommendation list
                for i in range(offset, r_count):
                    recommendations.insert(r_slot_index, r_temp[i])
                    r_slot_index += 1
                    slots_left -= 1
                    if len(recommendations) >= k and slots_left <= 0:
                        break

            approach_index += 1
        return recommendations[:k]

    def _append_approach_results(self, subject, subject_id, approach, result):
        """Append given result into model attribute for given approach, subject
        and its ID.

        Args:
            subject(string): 'user' or 'item'
            subject_id(int)
            approach(string)
            result(int)
        """
        if subject_id not in self.model[subject].keys():
            self.model[subject][subject_id] = {}
        if approach not in self.model[subject][subject_id]:
            self.model[subject][subject_id][approach] = []
        self.model[subject][subject_id][approach].append(result)

    def _test_item_against_recommendations(self, item, recommendations, approach, user):
        """Test if given item is in recommendations list and save its index
        or 0 if not found in model attribute.

        Args:
            item(int)
            recommendations(list): contain item IDs(int)
            approach(string)
            user(int)
        """
        result = recommendations.index(item) + 1 if item in recommendations else 0

        self._append_approach_results('item', item, approach, result)
        self._append_approach_results('user', user, approach, result)

        if approach not in self.model['global']:
            self.model['global'][approach] = []
        self.model['global'][approach].append(result)

    def _init_recommenders(self, max_oi_count):
        """Initialize all the recommenders and their data that are defined in
        used approaches.

        Args:
            max_oi_count(int): maximum number of items found in one order
        """

        self.recommenders = {}
        recommenders = [
            ('oa', OrderAssociationRecommender),
            ('uh', UserHistoryRecommender),
            ('uh2', UserHistory2Recommender),
            ('tr', TimeRelatedRecommender)
        ]
        for i in range(0, len(self.AVAILABLE_APPROACHES)):
            if self.is_approach_used(self.AVAILABLE_APPROACHES[i]):
                rec = recommenders[i][1]
                rec_abr = recommenders[i][0]
                self.recommenders[rec_abr] = rec(self.min_support, self.min_confidence)
                self.recommenders[rec_abr].data_manager = self.data_manager

        # just to be on a safe side!
        max_oi_count = 4 if max_oi_count > 4 else max_oi_count
        if self.is_approach_used(self.AVAILABLE_APPROACHES[0]):
            self.recommenders['oa'].set_train_data(
                max_oi_count, use_confidence=True, use_part_of_day=True)
        if self.is_approach_used(self.AVAILABLE_APPROACHES[1]):
            self.recommenders['uh'].set_train_data(max_oi_count, use_confidence=True)
        if self.is_approach_used(self.AVAILABLE_APPROACHES[3]):
            self.recommenders['tr'].set_train_data(True, True, False)

    def is_approach_used(self, questioned_approach):
        """Tests if questioned approach is used by MDAR recommender.

        Args:
            questioned_approach(string)

        Returns:
            bool: True if it's used, False otherwise.
        """
        if isinstance(questioned_approach, list):
            for approach in questioned_approach:
                if approach in self.used_approaches:
                    return True
        elif questioned_approach in self.used_approaches:
            return True

        return False

    def set_used_approaches(self, used_approaches):
        """Define which approaches should be used by this recommender.

        Args:
            used_approaches(list): List of tuples with name of the approach and
            its given weight.
        """
        self.used_approaches = []
        for used_approach in used_approaches:
            self.used_approaches.append(used_approach[0])
            self.user_approaches_w[used_approach[0]] = used_approach[1]

    def _calculate_model(self, calculate_rpr=False):
        """Crunch the model and calucate measures (ARHR and RPR) for each
        subject and approach.

        Args:
            calculate_rpr(bool, optional): should the RPR measure be
            calucated. Defaults to False.
        """
        for subject in ['user', 'item']:
            for subject_id in self.model[subject]:
                for approach in self.model[subject][subject_id]:
                    self.model[subject][subject_id][approach] = \
                        self._get_measure_values(self.model[subject][subject_id][approach])

                    if approach in self.user_approaches_w:
                        self.model[subject][subject_id][approach]['arhr'] *= \
                            self.user_approaches_w[approach]

        for approach in self.model['global']:
            self.model['global'][approach] = \
                self._get_measure_values(self.model['global'][approach])
            if approach in self.user_approaches_w:
                self.model['global'][approach]['arhr'] *= self.user_approaches_w[approach]

        # purchase rates
        if calculate_rpr:
            user_rpr_positive = np.array(self.model['rpr']['user']['positive'])
            item_rpr = np.array(self.model['rpr']['item'])

            self.max_user_rpr = np.percentile(user_rpr_positive, 75)
            self.max_item_rpr = np.percentile(item_rpr, 75)

    def _get_approach_offset(self, subject, user_id, approach_name, \
        slots_left, recommendations_count):
        """Return recommendation list offset for given approach. If offset is x
        then approach recommendations should start filling the list from index x.

        Args:
            subject(string): 'user', 'item' or 'global'.
            user_id(int)
            approach_name(string)
            slots_left(int): number of free recommendations slots.
            recommendations_count(int): number of used recommendations slots.

        Returns:
            int
        """
        if recommendations_count <= slots_left:
            return 0
        else:
            try:
                return self.model[subject][user_id][approach_name]['mcv'] - 1
            except KeyError:
                return self.model['global'][approach_name]['mcv'] - 1

    def init_approaches_order(self):
        """Sort and set approaches."""
        self.approaches_order = OrderedDict()
        for approach in sorted(self.model['global'].items(), key=itemgetter(1), reverse=True):
            self.approaches_order[approach[0]] = approach[1]['arhr']

    def update_rpr_from_recommendations(self, order, recommendations):
        """Update RPR measure in model attribute from given recommendations.

        Args:
            order(dict)
            recommendations(list): should contain item IDs(int)
        """
        if recommendations:
            user_rpr = self.data_manager.get_user_rpr(order['user'], 'train')
            if order['item'] in recommendations:
                rpr = self.data_manager.get_rpr(order['item'], 'train')
                self.model['rpr']['user']['positive'].append(user_rpr)
                self.model['rpr']['item'].append(rpr)
            else:
                self.model['rpr']['user']['negative'].append(user_rpr)

    def get_approaches_order(self, excluded=None):
        """ Return sorted approaches globally.

        Args:
            excluded(OrderedDict, optional): which approaches should be excluded
            from returned OrderedDict.

        Returns:
            OrderedDict: which contain tuples with a name of the approach(0)
            and its weight(1).
        """
        if excluded is None or not excluded:
            return self.approaches_order
        elif len(excluded) == len(self.approaches_order):
            return {}

        approaches = excluded
        for approach_key in self.approaches_order.keys():
            if approach_key in approaches.keys():
                approaches[approach_key] += self.approaches_order[approach_key]
            else:
                approaches[approach_key] = self.approaches_order[approach_key]
        return approaches

    def get_approaches_order_for_user(self, user_id):
        """ Return approaches for a user with a given ID or, if none, for
        a system globally. Approach sorting is based on their weights.

        Args:
            user_id(int)

        Returns:
            OrderedDict: which contain tuples with a name of the approach(0)
            and its weight(1).
        """
        if user_id in self.model['user']:
            approaches_order = OrderedDict()
            approaches = self.model['user'][user_id].items()
            for approach in sorted(approaches, key=itemgetter(1), reverse=True):
                if approach[1] >= self.min_arhr:
                    approaches_order[approach[0]] = approach[1]['arhr']

            approaches_order.update(self.get_approaches_order(approaches_order))
            return approaches_order

        return self.get_approaches_order()

    @staticmethod
    def get_init_model():
        """ Return model's inital structure and values.

        Returns:
            dict
        """
        return {
            'user': {},
            'item': {},
            'global': {},
            'rpr': {
                'item': [],
                'user': {'positive': [], 'negative': []}
            }
        }

    @staticmethod
    def _append_previous_order_items(orders):
        """Populate given list of orders with previous order items by order IDs.
        Given list contains dicts which represent one order item and are sorted
        by the order ID.

        Args:
            orders(list): list of dicts with a following structure:
            {
                'part_of_day': string,
                'timestamp': string,
                'month': int,
                'day_in_week': string,
                'item': int(ID),
                'cats': list of ints(category IDs),
                'user': int(ID),
                'order': int(ID)
            }

        Returns:
            list: same input order dicts with 'poi' key that holds a list of
            previous item IDs or empty if none.
            int: maximum number of items found in one order
        """
        poi = []
        max_oi_count = 0
        current_order_id = -1
        for i in range(0, len(orders)):
            if orders[i]['order'] != current_order_id:
                current_order_id = orders[i]['order']
                poi = []

            if len(poi) - 1 > max_oi_count:
                max_oi_count = len(poi)

            orders[i]['poi'] = poi
            poi.append({'item': orders[i]['item'], 'cats': orders[i]['cats']})

        return orders, max_oi_count

    @staticmethod
    def _get_measure_values(hit_data):
        """Return a dict with calucated ARHR and MVC measures for given hit rates.

        Args:
            hit_data(list): contains index of successful recommendation or 0
            if not found for each test case. Variable length.
        Returns:
            dict: should be of following structure:
            {
                'mcv': int
                'arhr': float
            }
        """
        try:
            data = np.array(hit_data)
            h_data = np.divide(1.0, data)
            h_data[h_data == np.inf] = 0

            mcv = np.bincount(data).argmax()
            if mcv == 0:
                mcv = 1
            return {'mcv': mcv, 'arhr': np.sum(h_data) / len(data)}
        except ZeroDivisionError:
            return {'mcv': 1, 'arhr': 0}

    @staticmethod
    def get_k_per_approach(k, approaches):
        """Return the number of reserved recommendation slots for each approach.

        Args:
            k(int): maximum number of recommendations.
            approaches(OrderedDict)

        Returns:
            list: contains numbers of recommendation slots(int) sorted as same as
            the given approaches.
        """
        k_per_approach = []
        k_left = k
        for approach_weight in approaches.values():
            if k_left > 0 and approach_weight > 0:
                k_for_approach = int(ceil((approach_weight / sum(approaches.values())) * k))
                if k_for_approach > k_left:
                    k_for_approach = k_left
                    k_left = 0
                else:
                    k_left -= k_for_approach
                k_per_approach.append(k_for_approach)
            else:
                k_per_approach.append(0)
        if k_left > 0:
            k_per_approach[0] += k_left

        return k_per_approach

    @property
    def train_time(self):
        """float: time used for training a model."""
        return self.train_time

    @train_time.setter
    def train_time(self, value):
        try:
            self._train_time = float(value)
        except (ValueError, TypeError):
            self._train_time = 0

    @property
    def min_arhr(self):
        """float: Minimal ARHR value used for training."""
        return self._min_arhr

    @min_arhr.setter
    def min_arhr(self, value):
        try:
            self._min_arhr = float(value)
        except (ValueError, TypeError):
            self._min_arhr = 0
