/*
 * 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.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Rect;
import android.media.tv.TvInputInfo;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.UiThread;
import android.text.TextUtils;
import android.util.Log;
import android.util.SparseLongArray;
import android.view.View;

import com.android.tv.MainActivityWrapper.OnCurrentChannelChangeListener;
import com.android.tv.R;
import com.android.tv.Starter;
import com.android.tv.TvSingletons;
import com.android.tv.common.CommonConstants;
import com.android.tv.common.WeakHandler;
import com.android.tv.data.api.Channel;
import com.android.tv.data.api.Program;
import com.android.tv.util.TvInputManagerHelper;
import com.android.tv.util.Utils;
import com.android.tv.util.images.BitmapUtils;
import com.android.tv.util.images.BitmapUtils.ScaledBitmapInfo;
import com.android.tv.util.images.ImageLoader;

import java.util.ArrayList;
import java.util.List;

/** A local service for notify recommendation at home launcher. */
public class NotificationService extends Service
        implements Recommender.Listener, OnCurrentChannelChangeListener {
    private static final String TAG = "NotificationService";
    private static final boolean DEBUG = false;

    public static final String ACTION_SHOW_RECOMMENDATION =
            CommonConstants.BASE_PACKAGE + ".notification.ACTION_SHOW_RECOMMENDATION";
    public static final String ACTION_HIDE_RECOMMENDATION =
            CommonConstants.BASE_PACKAGE + ".notification.ACTION_HIDE_RECOMMENDATION";

    /**
     * Recommendation intent has an extra data for the recommendation type. It'll be also sent to a
     * TV input as a tune parameter.
     */
    public static final String TUNE_PARAMS_RECOMMENDATION_TYPE =
            CommonConstants.BASE_PACKAGE + ".recommendation_type";

    private static final String TYPE_RANDOM_RECOMMENDATION = "random";
    private static final String TYPE_ROUTINE_WATCH_RECOMMENDATION = "routine_watch";
    private static final String TYPE_ROUTINE_WATCH_AND_FAVORITE_CHANNEL_RECOMMENDATION =
            "routine_watch_and_favorite";

    private static final String NOTIFY_TAG = "tv_recommendation";
    // TODO: find out proper number of notifications and whether to make it dynamically
    // configurable from system property or etc.
    private static final int NOTIFICATION_COUNT = 3;

    private static final int MSG_INITIALIZE_RECOMMENDER = 1000;
    private static final int MSG_SHOW_RECOMMENDATION = 1001;
    private static final int MSG_UPDATE_RECOMMENDATION = 1002;
    private static final int MSG_HIDE_RECOMMENDATION = 1003;

    private static final long RECOMMENDATION_RETRY_TIME_MS = 5 * 60 * 1000; // 5 min
    private static final long RECOMMENDATION_THRESHOLD_LEFT_TIME_MS = 10 * 60 * 1000; // 10 min
    private static final int RECOMMENDATION_THRESHOLD_PROGRESS = 90; // 90%
    private static final int MAX_PROGRAM_UPDATE_COUNT = 20;

    private TvInputManagerHelper mTvInputManagerHelper;
    private Recommender mRecommender;
    private boolean mShowRecommendationAfterRecommenderReady;
    private NotificationManager mNotificationManager;
    private HandlerThread mHandlerThread;
    private Handler mHandler;
    private final String mRecommendationType;
    private int mCurrentNotificationCount;
    private long[] mNotificationChannels;

    private Channel mPlayingChannel;

    private float mNotificationCardMaxWidth;
    private float mNotificationCardHeight;
    private int mCardImageHeight;
    private int mCardImageMaxWidth;
    private int mCardImageMinWidth;
    private int mChannelLogoMaxWidth;
    private int mChannelLogoMaxHeight;
    private int mLogoPaddingStart;
    private int mLogoPaddingBottom;

    public NotificationService() {
        mRecommendationType = TYPE_ROUTINE_WATCH_AND_FAVORITE_CHANNEL_RECOMMENDATION;
    }

    @Override
    public void onCreate() {
        if (DEBUG) Log.d(TAG, "onCreate");
        Starter.start(this);
        super.onCreate();
        mCurrentNotificationCount = 0;
        mNotificationChannels = new long[NOTIFICATION_COUNT];
        for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
            mNotificationChannels[i] = Channel.INVALID_ID;
        }
        mNotificationCardMaxWidth =
                getResources().getDimensionPixelSize(R.dimen.notif_card_img_max_width);
        mNotificationCardHeight =
                getResources().getDimensionPixelSize(R.dimen.notif_card_img_height);
        mCardImageHeight = getResources().getDimensionPixelSize(R.dimen.notif_card_img_height);
        mCardImageMaxWidth = getResources().getDimensionPixelSize(R.dimen.notif_card_img_max_width);
        mCardImageMinWidth = getResources().getDimensionPixelSize(R.dimen.notif_card_img_min_width);
        mChannelLogoMaxWidth =
                getResources().getDimensionPixelSize(R.dimen.notif_ch_logo_max_width);
        mChannelLogoMaxHeight =
                getResources().getDimensionPixelSize(R.dimen.notif_ch_logo_max_height);
        mLogoPaddingStart =
                getResources().getDimensionPixelOffset(R.dimen.notif_ch_logo_padding_start);
        mLogoPaddingBottom =
                getResources().getDimensionPixelOffset(R.dimen.notif_ch_logo_padding_bottom);

        mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
        TvSingletons tvSingletons = TvSingletons.getSingletons(this);
        mTvInputManagerHelper = tvSingletons.getTvInputManagerHelper();
        mHandlerThread = new HandlerThread("tv notification");
        mHandlerThread.start();
        mHandler = new NotificationHandler(mHandlerThread.getLooper(), this);
        mHandler.sendEmptyMessage(MSG_INITIALIZE_RECOMMENDER);

        // Just called for early initialization.
        tvSingletons.getChannelDataManager();
        tvSingletons.getProgramDataManager();
        tvSingletons.getMainActivityWrapper().addOnCurrentChannelChangeListener(this);
    }

    @UiThread
    @Override
    public void onCurrentChannelChange(@Nullable Channel channel) {
        if (DEBUG) Log.d(TAG, "onCurrentChannelChange");
        mPlayingChannel = channel;
        mHandler.removeMessages(MSG_SHOW_RECOMMENDATION);
        mHandler.sendEmptyMessage(MSG_SHOW_RECOMMENDATION);
    }

    private void handleInitializeRecommender() {
        mRecommender = new Recommender(NotificationService.this, NotificationService.this, true);
        if (TYPE_RANDOM_RECOMMENDATION.equals(mRecommendationType)) {
            mRecommender.registerEvaluator(new RandomEvaluator());
        } else if (TYPE_ROUTINE_WATCH_RECOMMENDATION.equals(mRecommendationType)) {
            mRecommender.registerEvaluator(new RoutineWatchEvaluator());
        } else if (TYPE_ROUTINE_WATCH_AND_FAVORITE_CHANNEL_RECOMMENDATION.equals(
                mRecommendationType)) {
            mRecommender.registerEvaluator(new FavoriteChannelEvaluator(), 0.5, 0.5);
            mRecommender.registerEvaluator(new RoutineWatchEvaluator(), 1.0, 1.0);
        } else {
            throw new IllegalStateException(
                    "Undefined recommendation type: " + mRecommendationType);
        }
    }

    private void handleShowRecommendation() {
        if (mRecommender == null) {
            return;
        }
        if (!mRecommender.isReady()) {
            mShowRecommendationAfterRecommenderReady = true;
        } else {
            showRecommendation();
        }
    }

    private void handleUpdateRecommendation(int notificationId, Channel channel) {
        if (mNotificationChannels[notificationId] == Channel.INVALID_ID
                || !sendNotification(channel.getId(), notificationId)) {
            changeRecommendation(notificationId);
        }
    }

    private void handleHideRecommendation() {
        if (mRecommender == null) {
            return;
        }
        if (!mRecommender.isReady()) {
            mShowRecommendationAfterRecommenderReady = false;
        } else {
            hideAllRecommendation();
        }
    }

    @Override
    public void onDestroy() {
        TvSingletons.getSingletons(this)
                .getMainActivityWrapper()
                .removeOnCurrentChannelChangeListener(this);
        if (mRecommender != null) {
            mRecommender.release();
            mRecommender = null;
        }
        if (mHandlerThread != null) {
            mHandlerThread.quit();
            mHandlerThread = null;
            mHandler = null;
        }
        super.onDestroy();
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (DEBUG) Log.d(TAG, "onStartCommand");
        if (intent != null) {
            String action = intent.getAction();
            if (ACTION_SHOW_RECOMMENDATION.equals(action)) {
                mHandler.removeMessages(MSG_SHOW_RECOMMENDATION);
                mHandler.removeMessages(MSG_HIDE_RECOMMENDATION);
                mHandler.obtainMessage(MSG_SHOW_RECOMMENDATION).sendToTarget();
            } else if (ACTION_HIDE_RECOMMENDATION.equals(action)) {
                mHandler.removeMessages(MSG_SHOW_RECOMMENDATION);
                mHandler.removeMessages(MSG_UPDATE_RECOMMENDATION);
                mHandler.removeMessages(MSG_HIDE_RECOMMENDATION);
                mHandler.obtainMessage(MSG_HIDE_RECOMMENDATION).sendToTarget();
            }
        }
        return START_STICKY;
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public void onRecommenderReady() {
        if (DEBUG) Log.d(TAG, "onRecommendationReady");
        if (mShowRecommendationAfterRecommenderReady) {
            mHandler.removeMessages(MSG_SHOW_RECOMMENDATION);
            mHandler.sendEmptyMessage(MSG_SHOW_RECOMMENDATION);
            mShowRecommendationAfterRecommenderReady = false;
        }
    }

    @Override
    public void onRecommendationChanged() {
        if (DEBUG) Log.d(TAG, "onRecommendationChanged");
        // Update recommendation on the handler thread.
        mHandler.removeMessages(MSG_SHOW_RECOMMENDATION);
        mHandler.sendEmptyMessage(MSG_SHOW_RECOMMENDATION);
    }

    private void showRecommendation() {
        if (DEBUG) Log.d(TAG, "showRecommendation");
        SparseLongArray notificationChannels = new SparseLongArray();
        for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
            if (mNotificationChannels[i] == Channel.INVALID_ID) {
                continue;
            }
            notificationChannels.put(i, mNotificationChannels[i]);
        }
        List<Channel> channels = recommendChannels();
        for (Channel c : channels) {
            int index = notificationChannels.indexOfValue(c.getId());
            if (index >= 0) {
                notificationChannels.removeAt(index);
            }
        }
        // Cancel notification whose channels are not recommended anymore.
        if (notificationChannels.size() > 0) {
            for (int i = 0; i < notificationChannels.size(); ++i) {
                int notificationId = notificationChannels.keyAt(i);
                mNotificationManager.cancel(NOTIFY_TAG, notificationId);
                mNotificationChannels[notificationId] = Channel.INVALID_ID;
                --mCurrentNotificationCount;
            }
        }
        for (Channel c : channels) {
            if (mCurrentNotificationCount >= NOTIFICATION_COUNT) {
                break;
            }
            if (!isNotifiedChannel(c.getId())) {
                sendNotification(c.getId(), getAvailableNotificationId());
            }
        }
        if (mCurrentNotificationCount < NOTIFICATION_COUNT) {
            mHandler.sendEmptyMessageDelayed(MSG_SHOW_RECOMMENDATION, RECOMMENDATION_RETRY_TIME_MS);
        }
    }

    private void changeRecommendation(int notificationId) {
        if (DEBUG) Log.d(TAG, "changeRecommendation");
        List<Channel> channels = recommendChannels();
        if (mNotificationChannels[notificationId] != Channel.INVALID_ID) {
            mNotificationChannels[notificationId] = Channel.INVALID_ID;
            --mCurrentNotificationCount;
        }
        for (Channel c : channels) {
            if (!isNotifiedChannel(c.getId())) {
                if (sendNotification(c.getId(), notificationId)) {
                    return;
                }
            }
        }
        mNotificationManager.cancel(NOTIFY_TAG, notificationId);
    }

    private List<Channel> recommendChannels() {
        List channels = mRecommender.recommendChannels();
        if (channels.contains(mPlayingChannel)) {
            channels = new ArrayList<>(channels);
            channels.remove(mPlayingChannel);
        }
        return channels;
    }

    private void hideAllRecommendation() {
        for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
            if (mNotificationChannels[i] != Channel.INVALID_ID) {
                mNotificationChannels[i] = Channel.INVALID_ID;
                mNotificationManager.cancel(NOTIFY_TAG, i);
            }
        }
        mCurrentNotificationCount = 0;
    }

    private boolean sendNotification(final long channelId, final int notificationId) {
        final ChannelRecord cr = mRecommender.getChannelRecord(channelId);
        if (cr == null) {
            return false;
        }
        final Channel channel = cr.getChannel();
        if (DEBUG) {
            Log.d(
                    TAG,
                    "sendNotification (channelName="
                            + channel.getDisplayName()
                            + " notifyId="
                            + notificationId
                            + ")");
        }

        // TODO: Move some checking logic into TvRecommendation.
        String inputId = Utils.getInputIdForChannel(this, channel.getId());
        if (TextUtils.isEmpty(inputId)) {
            return false;
        }
        TvInputInfo inputInfo = mTvInputManagerHelper.getTvInputInfo(inputId);
        if (inputInfo == null) {
            return false;
        }

        final Program program = Utils.getCurrentProgram(this, channel.getId());
        if (program == null) {
            return false;
        }
        final long programDurationMs =
                program.getEndTimeUtcMillis() - program.getStartTimeUtcMillis();
        long programLeftTimsMs = program.getEndTimeUtcMillis() - System.currentTimeMillis();
        final int programProgress =
                (programDurationMs <= 0)
                        ? -1
                        : 100 - (int) (programLeftTimsMs * 100 / programDurationMs);

        // We recommend those programs that meet the condition only.
        if (programProgress >= RECOMMENDATION_THRESHOLD_PROGRESS
                && programLeftTimsMs <= RECOMMENDATION_THRESHOLD_LEFT_TIME_MS) {
            return false;
        }

        // We don't trust TIS to provide us with proper sized image
        ScaledBitmapInfo posterArtBitmapInfo =
                BitmapUtils.decodeSampledBitmapFromUriString(
                        this,
                        program.getPosterArtUri(),
                        (int) mNotificationCardMaxWidth,
                        (int) mNotificationCardHeight);
        if (posterArtBitmapInfo == null) {
            Log.e(TAG, "Failed to decode poster image for " + program.getPosterArtUri());
            return false;
        }
        final Bitmap posterArtBitmap = posterArtBitmapInfo.bitmap;

        channel.loadBitmap(
                this,
                Channel.LOAD_IMAGE_TYPE_CHANNEL_LOGO,
                mChannelLogoMaxWidth,
                mChannelLogoMaxHeight,
                createChannelLogoCallback(this, notificationId, channel, program, posterArtBitmap));

        if (mNotificationChannels[notificationId] == Channel.INVALID_ID) {
            ++mCurrentNotificationCount;
        }
        mNotificationChannels[notificationId] = channel.getId();

        return true;
    }

    private void sendNotification(
            int notificationId,
            Bitmap channelLogo,
            Channel channel,
            Bitmap posterArtBitmap,
            Program program) {
        final long programDurationMs =
                program.getEndTimeUtcMillis() - program.getStartTimeUtcMillis();
        long programLeftTimsMs = program.getEndTimeUtcMillis() - System.currentTimeMillis();
        final int programProgress =
                (programDurationMs <= 0)
                        ? -1
                        : 100 - (int) (programLeftTimsMs * 100 / programDurationMs);
        Intent intent = new Intent(Intent.ACTION_VIEW, channel.getUri());
        intent.putExtra(TUNE_PARAMS_RECOMMENDATION_TYPE, mRecommendationType);
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        final PendingIntent notificationIntent = PendingIntent.getActivity(this, 0, intent,
                PendingIntent.FLAG_IMMUTABLE);

        // This callback will run on the main thread.
        Bitmap largeIconBitmap =
                (channelLogo == null)
                        ? posterArtBitmap
                        : overlayChannelLogo(channelLogo, posterArtBitmap);
        String channelDisplayName = channel.getDisplayName();
        Notification notification =
                new Notification.Builder(this)
                        .setContentIntent(notificationIntent)
                        .setContentTitle(program.getTitle())
                        .setContentText(
                                TextUtils.isEmpty(channelDisplayName)
                                        ? channel.getDisplayNumber()
                                        : channelDisplayName)
                        .setContentInfo(channelDisplayName)
                        .setAutoCancel(true)
                        .setLargeIcon(largeIconBitmap)
                        .setSmallIcon(R.drawable.ic_launcher_s)
                        .setCategory(Notification.CATEGORY_RECOMMENDATION)
                        .setProgress((programProgress > 0) ? 100 : 0, programProgress, false)
                        .setSortKey(mRecommender.getChannelSortKey(channel.getId()))
                        .build();
        notification.color = getResources().getColor(R.color.recommendation_card_background, null);
        if (!TextUtils.isEmpty(program.getThumbnailUri())) {
            notification.extras.putString(
                    Notification.EXTRA_BACKGROUND_IMAGE_URI, program.getThumbnailUri());
        }
        mNotificationManager.notify(NOTIFY_TAG, notificationId, notification);
        Message msg = mHandler.obtainMessage(MSG_UPDATE_RECOMMENDATION, notificationId, 0, channel);
        mHandler.sendMessageDelayed(msg, programDurationMs / MAX_PROGRAM_UPDATE_COUNT);
    }

    @NonNull
    private static ImageLoader.ImageLoaderCallback<NotificationService> createChannelLogoCallback(
            NotificationService service,
            final int notificationId,
            final Channel channel,
            final Program program,
            final Bitmap posterArtBitmap) {
        return new ImageLoader.ImageLoaderCallback<NotificationService>(service) {
            @Override
            public void onBitmapLoaded(NotificationService service, Bitmap channelLogo) {
                service.sendNotification(
                        notificationId, channelLogo, channel, posterArtBitmap, program);
            }
        };
    }

    private Bitmap overlayChannelLogo(Bitmap logo, Bitmap background) {
        Bitmap result =
                BitmapUtils.getScaledMutableBitmap(background, Integer.MAX_VALUE, mCardImageHeight);
        Bitmap scaledLogo =
                BitmapUtils.scaleBitmap(logo, mChannelLogoMaxWidth, mChannelLogoMaxHeight);
        Canvas canvas;
        try {
            canvas = new Canvas(result);
        } catch (Exception e) {
            Log.w(TAG, "Failed to create Canvas", e);
            return background;
        }
        canvas.drawBitmap(result, new Matrix(), null);
        Rect rect = new Rect();
        int startPadding;
        if (result.getWidth() < mCardImageMinWidth) {
            // TODO: check the positions.
            startPadding = mLogoPaddingStart;
            rect.bottom = result.getHeight() - mLogoPaddingBottom;
            rect.top = rect.bottom - scaledLogo.getHeight();
        } else if (result.getWidth() < mCardImageMaxWidth) {
            startPadding = mLogoPaddingStart;
            rect.bottom = result.getHeight() - mLogoPaddingBottom;
            rect.top = rect.bottom - scaledLogo.getHeight();
        } else {
            int marginStart = (result.getWidth() - mCardImageMaxWidth) / 2;
            startPadding = mLogoPaddingStart + marginStart;
            rect.bottom = result.getHeight() - mLogoPaddingBottom;
            rect.top = rect.bottom - scaledLogo.getHeight();
        }
        if (getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_LTR) {
            rect.left = startPadding;
            rect.right = startPadding + scaledLogo.getWidth();
        } else {
            rect.right = result.getWidth() - startPadding;
            rect.left = rect.right - scaledLogo.getWidth();
        }
        Paint paint = new Paint();
        paint.setAlpha(getResources().getInteger(R.integer.notif_card_ch_logo_alpha));
        canvas.drawBitmap(scaledLogo, null, rect, paint);
        return result;
    }

    private boolean isNotifiedChannel(long channelId) {
        for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
            if (mNotificationChannels[i] == channelId) {
                return true;
            }
        }
        return false;
    }

    private int getAvailableNotificationId() {
        for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
            if (mNotificationChannels[i] == Channel.INVALID_ID) {
                return i;
            }
        }
        return -1;
    }

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

        @Override
        public void handleMessage(Message msg, @NonNull NotificationService notificationService) {
            switch (msg.what) {
                case MSG_INITIALIZE_RECOMMENDER:
                    {
                        notificationService.handleInitializeRecommender();
                        break;
                    }
                case MSG_SHOW_RECOMMENDATION:
                    {
                        notificationService.handleShowRecommendation();
                        break;
                    }
                case MSG_UPDATE_RECOMMENDATION:
                    {
                        int notificationId = msg.arg1;
                        Channel channel = ((Channel) msg.obj);
                        notificationService.handleUpdateRecommendation(notificationId, channel);
                        break;
                    }
                case MSG_HIDE_RECOMMENDATION:
                    {
                        notificationService.handleHideRecommendation();
                        break;
                    }
                default:
                    {
                        super.handleMessage(msg);
                    }
            }
        }
    }
}
