/*
 * 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.annotation.SuppressLint;
import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import android.media.tv.TvContract;
import android.media.tv.TvInputInfo;
import android.media.tv.TvInputManager;
import android.media.tv.TvInputManager.TvInputCallback;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.MainThread;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.WorkerThread;
import android.util.Log;

import com.android.tv.TvSingletons;
import com.android.tv.common.WeakHandler;
import com.android.tv.common.util.PermissionUtils;
import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.ProgramImpl;
import com.android.tv.data.WatchedHistoryManager;
import com.android.tv.data.api.Channel;
import com.android.tv.data.api.Program;
import com.android.tv.util.TvUriMatcher;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

/** Manages teh data need to make recommendations. */
public class RecommendationDataManager implements WatchedHistoryManager.Listener {
    private static final String TAG = "RecommendationDataManag";
    private static final int MSG_START = 1000;
    private static final int MSG_STOP = 1001;
    private static final int MSG_UPDATE_CHANNELS = 1002;
    private static final int MSG_UPDATE_WATCH_HISTORY = 1003;
    private static final int MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED = 1004;
    private static final int MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED = 1005;

    private static final int MSG_FIRST = MSG_START;
    private static final int MSG_LAST = MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED;

    private static RecommendationDataManager sManager;
    private final ContentObserver mContentObserver;
    private final Map<Long, ChannelRecord> mChannelRecordMap = new ConcurrentHashMap<>();
    private final Map<Long, ChannelRecord> mAvailableChannelRecordMap = new ConcurrentHashMap<>();

    private final Context mContext;
    private boolean mStarted;
    private boolean mCancelLoadTask;
    private boolean mChannelRecordMapLoaded;
    private int mIndexWatchChannelId = -1;
    private int mIndexProgramTitle = -1;
    private int mIndexProgramStartTime = -1;
    private int mIndexProgramEndTime = -1;
    private int mIndexWatchStartTime = -1;
    private int mIndexWatchEndTime = -1;
    private TvInputManager mTvInputManager;
    private final Set<String> mInputs = new HashSet<>();

    private final HandlerThread mHandlerThread;
    private final Handler mHandler;
    private final Handler mMainHandler;
    @Nullable private WatchedHistoryManager mWatchedHistoryManager;
    private final ChannelDataManager mChannelDataManager;
    private final ChannelDataManager.Listener mChannelDataListener =
            new ChannelDataManager.Listener() {
                @Override
                @MainThread
                public void onLoadFinished() {
                    updateChannelData();
                }

                @Override
                @MainThread
                public void onChannelListUpdated() {
                    updateChannelData();
                }

                @Override
                @MainThread
                public void onChannelBrowsableChanged() {
                    updateChannelData();
                }
            };

    // For thread safety, this variable is handled only on main thread.
    private final List<Listener> mListeners = new ArrayList<>();

    /**
     * Gets instance of RecommendationDataManager, and adds a {@link Listener}. The listener methods
     * will be called in the same thread as its caller of the method. Note that {@link
     * #release(Listener)} should be called when this manager is not needed any more.
     */
    public static synchronized RecommendationDataManager acquireManager(
            Context context, @NonNull Listener listener) {
        if (sManager == null) {
            sManager = new RecommendationDataManager(context);
        }
        sManager.addListener(listener);
        return sManager;
    }

    private final TvInputCallback mInternalCallback =
            new TvInputCallback() {
                @Override
                public void onInputStateChanged(String inputId, int state) {}

                @Override
                public void onInputAdded(String inputId) {
                    if (!mStarted) {
                        return;
                    }
                    mInputs.add(inputId);
                    if (!mChannelRecordMapLoaded) {
                        return;
                    }
                    boolean channelRecordMapChanged = false;
                    for (ChannelRecord channelRecord : mChannelRecordMap.values()) {
                        if (channelRecord.getChannel().getInputId().equals(inputId)) {
                            channelRecord.setInputRemoved(false);
                            mAvailableChannelRecordMap.put(
                                    channelRecord.getChannel().getId(), channelRecord);
                            channelRecordMapChanged = true;
                        }
                    }
                    if (channelRecordMapChanged
                            && !mHandler.hasMessages(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED)) {
                        mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED);
                    }
                }

                @Override
                public void onInputRemoved(String inputId) {
                    if (!mStarted) {
                        return;
                    }
                    mInputs.remove(inputId);
                    if (!mChannelRecordMapLoaded) {
                        return;
                    }
                    boolean channelRecordMapChanged = false;
                    for (ChannelRecord channelRecord : mChannelRecordMap.values()) {
                        if (channelRecord.getChannel().getInputId().equals(inputId)) {
                            channelRecord.setInputRemoved(true);
                            mAvailableChannelRecordMap.remove(channelRecord.getChannel().getId());
                            channelRecordMapChanged = true;
                        }
                    }
                    if (channelRecordMapChanged
                            && !mHandler.hasMessages(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED)) {
                        mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED);
                    }
                }

                @Override
                public void onInputUpdated(String inputId) {}
            };

    private RecommendationDataManager(Context context) {
        mContext = context.getApplicationContext();
        mHandlerThread = new HandlerThread("RecommendationDataManager");
        mHandlerThread.start();
        mHandler = new RecommendationHandler(mHandlerThread.getLooper(), this);
        mMainHandler = new RecommendationMainHandler(Looper.getMainLooper(), this);
        mContentObserver = new RecommendationContentObserver(mHandler);
        mChannelDataManager = TvSingletons.getSingletons(mContext).getChannelDataManager();
        runOnMainThread(this::start);
    }

    /**
     * Removes the {@link Listener}, and releases RecommendationDataManager if there are no
     * listeners remained.
     */
    public void release(@NonNull final Listener listener) {
        runOnMainThread(
                () -> {
                    removeListener(listener);
                    if (mListeners.size() == 0) {
                        stop();
                    }
                });
    }

    /** Returns a {@link ChannelRecord} corresponds to the channel ID {@code ChannelId}. */
    public ChannelRecord getChannelRecord(long channelId) {
        return mAvailableChannelRecordMap.get(channelId);
    }

    /** Returns the number of channels registered in ChannelRecord map. */
    public int getChannelRecordCount() {
        return mAvailableChannelRecordMap.size();
    }

    /** Returns a Collection of ChannelRecords. */
    public Collection<ChannelRecord> getChannelRecords() {
        return Collections.unmodifiableCollection(mAvailableChannelRecordMap.values());
    }

    @MainThread
    private void start() {
        mHandler.sendEmptyMessage(MSG_START);
        mChannelDataManager.addListener(mChannelDataListener);
        if (mChannelDataManager.isDbLoadFinished()) {
            updateChannelData();
        }
    }

    @MainThread
    private void stop() {
        if (mWatchedHistoryManager != null) {
            mWatchedHistoryManager.setListener(null);
        }
        for (int what = MSG_FIRST; what <= MSG_LAST; ++what) {
            mHandler.removeMessages(what);
        }
        mChannelDataManager.removeListener(mChannelDataListener);
        mHandler.sendEmptyMessage(MSG_STOP);
        mHandlerThread.quitSafely();
        mMainHandler.removeCallbacksAndMessages(null);
        sManager = null;
    }

    @MainThread
    private void updateChannelData() {
        mHandler.removeMessages(MSG_UPDATE_CHANNELS);
        mHandler.obtainMessage(MSG_UPDATE_CHANNELS, mChannelDataManager.getBrowsableChannelList())
                .sendToTarget();
    }

    private void addListener(Listener listener) {
        runOnMainThread(() -> mListeners.add(listener));
    }

    @MainThread
    private void removeListener(Listener listener) {
        mListeners.remove(listener);
    }

    private void onStart() {
        if (!mStarted) {
            mStarted = true;
            mCancelLoadTask = false;
            if (!PermissionUtils.hasAccessWatchedHistory(mContext)) {
                mWatchedHistoryManager = new WatchedHistoryManager(mContext);
                mWatchedHistoryManager.setListener(this);
                mWatchedHistoryManager.start();
            } else {
                mContext.getContentResolver()
                        .registerContentObserver(
                                TvContract.WatchedPrograms.CONTENT_URI, true, mContentObserver);
                mHandler.obtainMessage(
                                MSG_UPDATE_WATCH_HISTORY, TvContract.WatchedPrograms.CONTENT_URI)
                        .sendToTarget();
            }
            mTvInputManager = (TvInputManager) mContext.getSystemService(Context.TV_INPUT_SERVICE);
            mTvInputManager.registerCallback(mInternalCallback, mHandler);
            for (TvInputInfo input : mTvInputManager.getTvInputList()) {
                mInputs.add(input.getId());
            }
        }
        if (mChannelRecordMapLoaded) {
            mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED);
        }
    }

    private void onStop() {
        mContext.getContentResolver().unregisterContentObserver(mContentObserver);
        mCancelLoadTask = true;
        mChannelRecordMap.clear();
        mAvailableChannelRecordMap.clear();
        mInputs.clear();
        mTvInputManager.unregisterCallback(mInternalCallback);
        mStarted = false;
    }

    @WorkerThread
    private void onUpdateChannels(List<Channel> channels) {
        boolean isChannelRecordMapChanged = false;
        Set<Long> removedChannelIdSet = new HashSet<>(mChannelRecordMap.keySet());
        // Builds removedChannelIdSet.
        for (Channel channel : channels) {
            if (updateChannelRecordMapFromChannel(channel)) {
                isChannelRecordMapChanged = true;
            }
            removedChannelIdSet.remove(channel.getId());
        }

        if (!removedChannelIdSet.isEmpty()) {
            for (Long channelId : removedChannelIdSet) {
                mChannelRecordMap.remove(channelId);
                if (mAvailableChannelRecordMap.remove(channelId) != null) {
                    isChannelRecordMapChanged = true;
                }
            }
        }
        if (isChannelRecordMapChanged
                && mChannelRecordMapLoaded
                && !mHandler.hasMessages(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED)) {
            mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED);
        }
    }

    @WorkerThread
    private void onLoadWatchHistory(Uri uri) {
        List<WatchedProgram> history = new ArrayList<>();
        try (Cursor cursor = mContext.getContentResolver().query(uri, null, null, null, null)) {
            if (cursor != null && cursor.moveToLast()) {
                do {
                    if (mCancelLoadTask) {
                        return;
                    }
                    history.add(createWatchedProgramFromWatchedProgramCursor(cursor));
                } while (cursor.moveToPrevious());
            }
        } catch (Exception e) {
            Log.e(TAG, "Error trying to load watch history from " + uri, e);
            return;
        }
        for (WatchedProgram watchedProgram : history) {
            final ChannelRecord channelRecord =
                    updateChannelRecordFromWatchedProgram(watchedProgram);
            if (mChannelRecordMapLoaded && channelRecord != null) {
                runOnMainThread(
                        () -> {
                            for (Listener l : mListeners) {
                                l.onNewWatchLog(channelRecord);
                            }
                        });
            }
        }
        if (!mChannelRecordMapLoaded) {
            mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED);
        }
    }

    private WatchedProgram convertFromWatchedHistoryManagerRecords(
            WatchedHistoryManager.WatchedRecord watchedRecord) {
        long endTime = watchedRecord.watchedStartTime + watchedRecord.duration;
        Program program =
                new ProgramImpl.Builder()
                        .setChannelId(watchedRecord.channelId)
                        .setTitle("")
                        .setStartTimeUtcMillis(watchedRecord.watchedStartTime)
                        .setEndTimeUtcMillis(endTime)
                        .build();
        return new WatchedProgram(program, watchedRecord.watchedStartTime, endTime);
    }

    @Override
    public void onLoadFinished() {
        for (WatchedHistoryManager.WatchedRecord record :
                mWatchedHistoryManager.getWatchedHistory()) {
            updateChannelRecordFromWatchedProgram(convertFromWatchedHistoryManagerRecords(record));
        }
        mHandler.sendEmptyMessage(MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED);
    }

    @Override
    public void onNewRecordAdded(WatchedHistoryManager.WatchedRecord watchedRecord) {
        final ChannelRecord channelRecord =
                updateChannelRecordFromWatchedProgram(
                        convertFromWatchedHistoryManagerRecords(watchedRecord));
        if (mChannelRecordMapLoaded && channelRecord != null) {
            runOnMainThread(
                    () -> {
                        for (Listener l : mListeners) {
                            l.onNewWatchLog(channelRecord);
                        }
                    });
        }
    }

    private WatchedProgram createWatchedProgramFromWatchedProgramCursor(Cursor cursor) {
        // Have to initiate the indexes of WatchedProgram Columns.
        if (mIndexWatchChannelId == -1) {
            mIndexWatchChannelId =
                    cursor.getColumnIndex(TvContract.WatchedPrograms.COLUMN_CHANNEL_ID);
            mIndexProgramTitle = cursor.getColumnIndex(TvContract.WatchedPrograms.COLUMN_TITLE);
            mIndexProgramStartTime =
                    cursor.getColumnIndex(TvContract.WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS);
            mIndexProgramEndTime =
                    cursor.getColumnIndex(TvContract.WatchedPrograms.COLUMN_END_TIME_UTC_MILLIS);
            mIndexWatchStartTime =
                    cursor.getColumnIndex(
                            TvContract.WatchedPrograms.COLUMN_WATCH_START_TIME_UTC_MILLIS);
            mIndexWatchEndTime =
                    cursor.getColumnIndex(
                            TvContract.WatchedPrograms.COLUMN_WATCH_END_TIME_UTC_MILLIS);
        }

        Program program =
                new ProgramImpl.Builder()
                        .setChannelId(cursor.getLong(mIndexWatchChannelId))
                        .setTitle(cursor.getString(mIndexProgramTitle))
                        .setStartTimeUtcMillis(cursor.getLong(mIndexProgramStartTime))
                        .setEndTimeUtcMillis(cursor.getLong(mIndexProgramEndTime))
                        .build();

        return new WatchedProgram(
                program, cursor.getLong(mIndexWatchStartTime), cursor.getLong(mIndexWatchEndTime));
    }

    private void onNotifyChannelRecordMapLoaded() {
        mChannelRecordMapLoaded = true;
        runOnMainThread(
                () -> {
                    for (Listener l : mListeners) {
                        l.onChannelRecordLoaded();
                    }
                });
    }

    private void onNotifyChannelRecordMapChanged() {
        runOnMainThread(
                () -> {
                    for (Listener l : mListeners) {
                        l.onChannelRecordChanged();
                    }
                });
    }

    /** Returns true if ChannelRecords are added into mChannelRecordMap or removed from it. */
    private boolean updateChannelRecordMapFromChannel(Channel channel) {
        if (!channel.isBrowsable()) {
            mChannelRecordMap.remove(channel.getId());
            return mAvailableChannelRecordMap.remove(channel.getId()) != null;
        }
        ChannelRecord channelRecord = mChannelRecordMap.get(channel.getId());
        boolean inputRemoved = !mInputs.contains(channel.getInputId());
        if (channelRecord == null) {
            ChannelRecord record = new ChannelRecord(mContext, channel, inputRemoved);
            mChannelRecordMap.put(channel.getId(), record);
            if (!inputRemoved) {
                mAvailableChannelRecordMap.put(channel.getId(), record);
                return true;
            }
            return false;
        }
        boolean oldInputRemoved = channelRecord.isInputRemoved();
        channelRecord.setChannel(channel, inputRemoved);
        return oldInputRemoved != inputRemoved;
    }

    private ChannelRecord updateChannelRecordFromWatchedProgram(WatchedProgram program) {
        ChannelRecord channelRecord = null;
        if (program != null && program.getWatchEndTimeMs() != 0L) {
            channelRecord = mChannelRecordMap.get(program.getProgram().getChannelId());
            if (channelRecord != null
                    && channelRecord.getLastWatchEndTimeMs() < program.getWatchEndTimeMs()) {
                channelRecord.logWatchHistory(program);
            }
        }
        return channelRecord;
    }

    private class RecommendationContentObserver extends ContentObserver {
        public RecommendationContentObserver(Handler handler) {
            super(handler);
        }

        @SuppressLint("SwitchIntDef")
        @Override
        public void onChange(final boolean selfChange, final Uri uri) {
            switch (TvUriMatcher.match(uri)) {
                case TvUriMatcher.MATCH_WATCHED_PROGRAM_ID:
                    if (!mHandler.hasMessages(
                            MSG_UPDATE_WATCH_HISTORY, TvContract.WatchedPrograms.CONTENT_URI)) {
                        mHandler.obtainMessage(MSG_UPDATE_WATCH_HISTORY, uri).sendToTarget();
                    }
                    break;
            }
        }
    }

    private void runOnMainThread(Runnable r) {
        if (Looper.myLooper() == Looper.getMainLooper()) {
            r.run();
        } else {
            mMainHandler.post(r);
        }
    }

    /** A listener interface to receive notification about the recommendation data. @MainThread */
    public interface Listener {
        /**
         * Called when loading channel record map from database is finished. It will be called after
         * RecommendationDataManager.start() is finished.
         *
         * <p>Note that this method is called on the main thread.
         */
        void onChannelRecordLoaded();

        /**
         * Called when a new watch log is added into the corresponding channelRecord.
         *
         * <p>Note that this method is called on the main thread.
         *
         * @param channelRecord The channel record corresponds to the new watch log.
         */
        void onNewWatchLog(ChannelRecord channelRecord);

        /**
         * Called when the channel record map changes.
         *
         * <p>Note that this method is called on the main thread.
         */
        void onChannelRecordChanged();
    }

    private static class RecommendationHandler extends WeakHandler<RecommendationDataManager> {
        public RecommendationHandler(@NonNull Looper looper, RecommendationDataManager ref) {
            super(looper, ref);
        }

        @Override
        public void handleMessage(Message msg, @NonNull RecommendationDataManager dataManager) {
            switch (msg.what) {
                case MSG_START:
                    dataManager.onStart();
                    break;
                case MSG_STOP:
                    if (dataManager.mStarted) {
                        dataManager.onStop();
                    }
                    break;
                case MSG_UPDATE_CHANNELS:
                    if (dataManager.mStarted) {
                        dataManager.onUpdateChannels((List<Channel>) msg.obj);
                    }
                    break;
                case MSG_UPDATE_WATCH_HISTORY:
                    if (dataManager.mStarted) {
                        dataManager.onLoadWatchHistory((Uri) msg.obj);
                    }
                    break;
                case MSG_NOTIFY_CHANNEL_RECORD_MAP_LOADED:
                    if (dataManager.mStarted) {
                        dataManager.onNotifyChannelRecordMapLoaded();
                    }
                    break;
                case MSG_NOTIFY_CHANNEL_RECORD_MAP_CHANGED:
                    if (dataManager.mStarted) {
                        dataManager.onNotifyChannelRecordMapChanged();
                    }
                    break;
            }
        }
    }

    private static class RecommendationMainHandler extends WeakHandler<RecommendationDataManager> {
        public RecommendationMainHandler(@NonNull Looper looper, RecommendationDataManager ref) {
            super(looper, ref);
        }

        @Override
        protected void handleMessage(Message msg, @NonNull RecommendationDataManager referent) {}
    }
}
