/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.tv.recommendation;

import android.content.Context;
import android.support.annotation.VisibleForTesting;
import android.util.Log;
import android.util.Pair;

import com.android.tv.data.api.Channel;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class Recommender implements RecommendationDataManager.Listener {
    private static final String TAG = "Recommender";

    @VisibleForTesting static final String INVALID_CHANNEL_SORT_KEY = "INVALID";
    private static final long MINIMUM_RECOMMENDATION_UPDATE_PERIOD = TimeUnit.MINUTES.toMillis(5);
    private static final Comparator<Pair<Channel, Double>> mChannelScoreComparator =
            new Comparator<Pair<Channel, Double>>() {
                @Override
                public int compare(Pair<Channel, Double> lhs, Pair<Channel, Double> rhs) {
                    // Sort the scores with descending order.
                    return rhs.second.compareTo(lhs.second);
                }
            };

    private final List<EvaluatorWrapper> mEvaluators = new ArrayList<>();
    private final boolean mIncludeRecommendedOnly;
    private final Listener mListener;

    private final Map<Long, String> mChannelSortKey = new HashMap<>();
    private final RecommendationDataManager mDataManager;
    private List<Channel> mPreviousRecommendedChannels = new ArrayList<>();
    private long mLastRecommendationUpdatedTimeUtcMillis;
    private boolean mChannelRecordLoaded;

    /**
     * Create a recommender object.
     *
     * @param includeRecommendedOnly true to include only recommended results, or false.
     */
    public Recommender(Context context, Listener listener, boolean includeRecommendedOnly) {
        mListener = listener;
        mIncludeRecommendedOnly = includeRecommendedOnly;
        mDataManager = RecommendationDataManager.acquireManager(context, this);
    }

    @VisibleForTesting
    Recommender(
            Listener listener,
            boolean includeRecommendedOnly,
            RecommendationDataManager dataManager) {
        mListener = listener;
        mIncludeRecommendedOnly = includeRecommendedOnly;
        mDataManager = dataManager;
    }

    public boolean isReady() {
        return mChannelRecordLoaded;
    }

    public void release() {
        mDataManager.release(this);
    }

    public void registerEvaluator(Evaluator evaluator) {
        registerEvaluator(
                evaluator, EvaluatorWrapper.DEFAULT_BASE_SCORE, EvaluatorWrapper.DEFAULT_WEIGHT);
    }

    /**
     * Register the evaluator used in recommendation.
     *
     * <p>The range of evaluated scores by this evaluator will be between {@code baseScore} and
     * {@code baseScore} + {@code weight} (inclusive).
     *
     * @param evaluator The evaluator to register inside this recommender.
     * @param baseScore Base(Minimum) score of the score evaluated by {@code evaluator}.
     * @param weight Weight value to rearrange the score evaluated by {@code evaluator}.
     */
    public void registerEvaluator(Evaluator evaluator, double baseScore, double weight) {
        mEvaluators.add(new EvaluatorWrapper(this, evaluator, baseScore, weight));
    }

    public List<Channel> recommendChannels() {
        return recommendChannels(mDataManager.getChannelRecordCount());
    }

    /**
     * Return the channel list of recommendation up to {@code n} or the number of channels. During
     * the evaluation, this method updates the channel sort key of recommended channels.
     *
     * @param size The number of channels that might be recommended.
     * @return Top {@code size} channels recommended sorted by score in descending order. If {@code
     *     size} is bigger than the number of channels, the number of results could be less than
     *     {@code size}.
     */
    public List<Channel> recommendChannels(int size) {
        List<Pair<Channel, Double>> records = new ArrayList<>();
        Collection<ChannelRecord> channelRecordList = mDataManager.getChannelRecords();
        for (ChannelRecord cr : channelRecordList) {
            double maxScore = Evaluator.NOT_RECOMMENDED;
            for (EvaluatorWrapper evaluator : mEvaluators) {
                double score = evaluator.getScaledEvaluatorScore(cr.getChannel().getId());
                if (score > maxScore) {
                    maxScore = score;
                }
            }
            if (!mIncludeRecommendedOnly || maxScore != Evaluator.NOT_RECOMMENDED) {
                records.add(Pair.create(cr.getChannel(), maxScore));
            }
        }
        if (size > records.size()) {
            size = records.size();
        }
        Collections.sort(records, mChannelScoreComparator);

        List<Channel> results = new ArrayList<>();

        mChannelSortKey.clear();
        String sortKeyFormat = "%0" + String.valueOf(size).length() + "d";
        for (int i = 0; i < size; ++i) {
            // Channel with smaller sort key has higher priority.
            mChannelSortKey.put(records.get(i).first.getId(), String.format(sortKeyFormat, i));
            results.add(records.get(i).first);
        }
        return results;
    }

    /**
     * Returns the {@link Channel} object for a given channel ID from the channel pool that this
     * recommendation engine has.
     *
     * @param channelId The channel ID to retrieve the {@link Channel} object for.
     * @return the {@link Channel} object for the given channel ID, {@code null} if such a channel
     *     is not found.
     */
    public Channel getChannel(long channelId) {
        ChannelRecord record = mDataManager.getChannelRecord(channelId);
        return record == null ? null : record.getChannel();
    }

    /**
     * Returns the {@link ChannelRecord} object for a given channel ID.
     *
     * @param channelId The channel ID to receive the {@link ChannelRecord} object for.
     * @return the {@link ChannelRecord} object for the given channel ID.
     */
    public ChannelRecord getChannelRecord(long channelId) {
        return mDataManager.getChannelRecord(channelId);
    }

    /**
     * Returns the sort key of a given channel Id. Sort key is determined in {@link
     * #recommendChannels()} and getChannelSortKey must be called after that.
     *
     * <p>If getChannelSortKey was called before evaluating the channels or trying to get sort key
     * of non-recommended channel, it returns {@link #INVALID_CHANNEL_SORT_KEY}.
     */
    public String getChannelSortKey(long channelId) {
        String key = mChannelSortKey.get(channelId);
        return key == null ? INVALID_CHANNEL_SORT_KEY : key;
    }

    @Override
    public void onChannelRecordLoaded() {
        mChannelRecordLoaded = true;
        mListener.onRecommenderReady();
        List<ChannelRecord> channels = new ArrayList<>(mDataManager.getChannelRecords());
        for (EvaluatorWrapper evaluator : mEvaluators) {
            evaluator.onChannelListChanged(Collections.unmodifiableList(channels));
        }
    }

    @Override
    public void onNewWatchLog(ChannelRecord channelRecord) {
        for (EvaluatorWrapper evaluator : mEvaluators) {
            evaluator.onNewWatchLog(channelRecord);
        }
        checkRecommendationChanged();
    }

    @Override
    public void onChannelRecordChanged() {
        if (mChannelRecordLoaded) {
            List<ChannelRecord> channels = new ArrayList<>(mDataManager.getChannelRecords());
            for (EvaluatorWrapper evaluator : mEvaluators) {
                evaluator.onChannelListChanged(Collections.unmodifiableList(channels));
            }
        }
        checkRecommendationChanged();
    }

    private void checkRecommendationChanged() {
        long currentTimeUtcMillis = System.currentTimeMillis();
        if (currentTimeUtcMillis - mLastRecommendationUpdatedTimeUtcMillis
                < MINIMUM_RECOMMENDATION_UPDATE_PERIOD) {
            return;
        }
        mLastRecommendationUpdatedTimeUtcMillis = currentTimeUtcMillis;
        List<Channel> recommendedChannels = recommendChannels();
        if (!recommendedChannels.equals(mPreviousRecommendedChannels)) {
            mPreviousRecommendedChannels = recommendedChannels;
            mListener.onRecommendationChanged();
        }
    }

    @VisibleForTesting
    void setLastRecommendationUpdatedTimeUtcMs(long newUpdatedTimeMs) {
        mLastRecommendationUpdatedTimeUtcMillis = newUpdatedTimeMs;
    }

    public abstract static class Evaluator {
        public static final double NOT_RECOMMENDED = -1.0;
        private Recommender mRecommender;

        protected Evaluator() {}

        protected void onChannelRecordListChanged(List<ChannelRecord> channelRecords) {}

        /**
         * This will be called when a new watch log comes into WatchedPrograms table.
         *
         * @param channelRecord The channel record corresponds to the new watch log.
         */
        protected void onNewWatchLog(ChannelRecord channelRecord) {}

        /**
         * The implementation should return the recommendation score for the given channel ID. The
         * return value should be in the range of [0.0, 1.0] or NOT_RECOMMENDED for denoting that it
         * gives up to calculate the score for the channel.
         *
         * @param channelId The channel ID which will be evaluated by this recommender.
         * @return The recommendation score
         */
        protected abstract double evaluateChannel(final long channelId);

        protected void setRecommender(Recommender recommender) {
            mRecommender = recommender;
        }

        protected Recommender getRecommender() {
            return mRecommender;
        }
    }

    private static class EvaluatorWrapper {
        private static final double DEFAULT_BASE_SCORE = 0.0;
        private static final double DEFAULT_WEIGHT = 1.0;

        private final Evaluator mEvaluator;
        // The minimum score of the Recommender unless it gives up to provide the score.
        private final double mBaseScore;
        // The weight of the recommender. The return-value of getScore() will be multiplied by
        // this value.
        private final double mWeight;

        public EvaluatorWrapper(
                Recommender recommender, Evaluator evaluator, double baseScore, double weight) {
            mEvaluator = evaluator;
            evaluator.setRecommender(recommender);
            mBaseScore = baseScore;
            mWeight = weight;
        }

        /**
         * This returns the scaled score for the given channel ID based on the returned value of
         * evaluateChannel().
         *
         * @param channelId The channel ID which will be evaluated by the recommender.
         * @return Returns the scaled score (mBaseScore + score * mWeight) when evaluateChannel() is
         *     in the range of [0.0, 1.0]. If evaluateChannel() returns NOT_RECOMMENDED or any
         *     negative numbers, it returns NOT_RECOMMENDED. If calculateScore() returns more than
         *     1.0, it returns (mBaseScore + mWeight).
         */
        private double getScaledEvaluatorScore(long channelId) {
            double score = mEvaluator.evaluateChannel(channelId);
            if (score < 0.0) {
                if (score != Evaluator.NOT_RECOMMENDED) {
                    Log.w(
                            TAG,
                            "Unexpected score (" + score + ") from the recommender" + mEvaluator);
                }
                // If the recommender gives up to calculate the score, return 0.0
                return Evaluator.NOT_RECOMMENDED;
            } else if (score > 1.0) {
                Log.w(TAG, "Unexpected score (" + score + ") from the recommender" + mEvaluator);
                score = 1.0;
            }
            return mBaseScore + score * mWeight;
        }

        public void onNewWatchLog(ChannelRecord channelRecord) {
            mEvaluator.onNewWatchLog(channelRecord);
        }

        public void onChannelListChanged(List<ChannelRecord> channelRecords) {
            mEvaluator.onChannelRecordListChanged(channelRecords);
        }
    }

    public interface Listener {
        /** Called after channel record map is loaded. */
        void onRecommenderReady();

        /** Called when the recommendation changes. */
        void onRecommendationChanged();
    }
}
