/*
 * 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.search;

import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.media.tv.TvContentRating;
import android.media.tv.TvContract;
import android.media.tv.TvContract.Channels;
import android.media.tv.TvContract.Programs;
import android.media.tv.TvContract.WatchedPrograms;
import android.media.tv.TvInputInfo;
import android.media.tv.TvInputManager;
import android.net.Uri;
import android.os.SystemClock;
import android.support.annotation.WorkerThread;
import android.text.TextUtils;
import android.util.Log;
import com.android.tv.common.TvContentRatingCache;
import com.android.tv.common.util.PermissionUtils;
import com.android.tv.search.LocalSearchProvider.SearchResult;
import com.android.tv.util.Utils;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/** An implementation of {@link SearchInterface} to search query from TvProvider directly. */
public class TvProviderSearch implements SearchInterface {
    private static final String TAG = "TvProviderSearch";
    private static final boolean DEBUG = false;

    private static final long SEARCH_TIME_FRAME_MS = TimeUnit.DAYS.toMillis(14);

    private static final int NO_LIMIT = 0;

    private final Context mContext;
    private final ContentResolver mContentResolver;
    private final TvInputManager mTvInputManager;
    private final TvContentRatingCache mTvContentRatingCache = TvContentRatingCache.getInstance();

    TvProviderSearch(Context context) {
        mContext = context;
        mContentResolver = context.getContentResolver();
        mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE);
    }

    /**
     * Search channels, inputs, or programs from TvProvider. This assumes that parental control
     * settings will not be change while searching.
     *
     * @param action One of {@link #ACTION_TYPE_SWITCH_CHANNEL}, {@link #ACTION_TYPE_SWITCH_INPUT},
     *     or {@link #ACTION_TYPE_AMBIGUOUS},
     */
    @Override
    @WorkerThread
    public List<SearchResult> search(String query, int limit, int action) {
        // TODO(b/72499463): add a test.
        List<SearchResult> results = new ArrayList<>();
        if (!PermissionUtils.hasAccessAllEpg(mContext)) {
            // TODO: support this feature for non-system LC app. b/23939816
            return results;
        }
        Set<Long> channelsFound = new HashSet<>();
        if (action == ACTION_TYPE_SWITCH_CHANNEL) {
            results.addAll(searchChannels(query, channelsFound, limit));
        } else if (action == ACTION_TYPE_SWITCH_INPUT) {
            results.addAll(searchInputs(query, limit));
        } else {
            // Search channels first.
            results.addAll(searchChannels(query, channelsFound, limit));
            if (results.size() >= limit) {
                return results;
            }

            // In case the user wanted to perform the action "switch to XXX", which is indicated by
            // setting the limit to 1, search inputs.
            if (limit == 1) {
                results.addAll(searchInputs(query, limit));
                if (!results.isEmpty()) {
                    return results;
                }
            }

            // Lastly, search programs.
            limit -= results.size();
            results.addAll(
                    searchPrograms(
                            query,
                            null,
                            new String[] {Programs.COLUMN_TITLE, Programs.COLUMN_SHORT_DESCRIPTION},
                            channelsFound,
                            limit));
        }
        return results;
    }

    private void appendSelectionString(
            StringBuilder sb, String[] columnForExactMatching, String[] columnForPartialMatching) {
        boolean firstColumn = true;
        if (columnForExactMatching != null) {
            for (String column : columnForExactMatching) {
                if (!firstColumn) {
                    sb.append(" OR ");
                } else {
                    firstColumn = false;
                }
                sb.append(column).append("=?");
            }
        }
        if (columnForPartialMatching != null) {
            for (String column : columnForPartialMatching) {
                if (!firstColumn) {
                    sb.append(" OR ");
                } else {
                    firstColumn = false;
                }
                sb.append(column).append(" LIKE ?");
            }
        }
    }

    private void insertSelectionArgumentStrings(
            String[] selectionArgs,
            int pos,
            String query,
            String[] columnForExactMatching,
            String[] columnForPartialMatching) {
        if (columnForExactMatching != null) {
            int until = pos + columnForExactMatching.length;
            for (; pos < until; ++pos) {
                selectionArgs[pos] = query;
            }
        }
        String selectionArg = "%" + query + "%";
        if (columnForPartialMatching != null) {
            int until = pos + columnForPartialMatching.length;
            for (; pos < until; ++pos) {
                selectionArgs[pos] = selectionArg;
            }
        }
    }

    @WorkerThread
    private List<SearchResult> searchChannels(String query, Set<Long> channels, int limit) {
        if (DEBUG) Log.d(TAG, "Searching channels: '" + query + "'");
        long time = SystemClock.elapsedRealtime();
        List<SearchResult> results = new ArrayList<>();
        if (TextUtils.isDigitsOnly(query)) {
            results.addAll(
                    searchChannels(
                            query,
                            new String[] {Channels.COLUMN_DISPLAY_NUMBER},
                            null,
                            channels,
                            NO_LIMIT));
            if (results.size() > 1) {
                Collections.sort(results, new ChannelComparatorWithSameDisplayNumber());
            }
        }
        if (results.size() < limit) {
            results.addAll(
                    searchChannels(
                            query,
                            null,
                            new String[] {
                                Channels.COLUMN_DISPLAY_NAME, Channels.COLUMN_DESCRIPTION
                            },
                            channels,
                            limit - results.size()));
        }
        if (results.size() > limit) {
            results = results.subList(0, limit);
        }
        for (int i = 0; i < results.size(); i++) {
            results.set(i, fillProgramInfo(results.get(i)));
        }
        if (DEBUG) {
            Log.d(
                    TAG,
                    "Found "
                            + results.size()
                            + " channels. Elapsed time for searching"
                            + " channels: "
                            + (SystemClock.elapsedRealtime() - time)
                            + "(msec)");
        }
        return results;
    }

    @WorkerThread
    private List<SearchResult> searchChannels(
            String query,
            String[] columnForExactMatching,
            String[] columnForPartialMatching,
            Set<Long> channelsFound,
            int limit) {
        String[] projection = {
            Channels._ID,
            Channels.COLUMN_DISPLAY_NUMBER,
            Channels.COLUMN_DISPLAY_NAME,
            Channels.COLUMN_DESCRIPTION
        };

        StringBuilder sb = new StringBuilder();
        sb.append(Channels.COLUMN_BROWSABLE)
                .append("=1 AND ")
                .append(Channels.COLUMN_SEARCHABLE)
                .append("=1");
        if (mTvInputManager.isParentalControlsEnabled()) {
            sb.append(" AND ").append(Channels.COLUMN_LOCKED).append("=0");
        }
        sb.append(" AND (");
        appendSelectionString(sb, columnForExactMatching, columnForPartialMatching);
        sb.append(")");
        String selection = sb.toString();

        int len =
                (columnForExactMatching == null ? 0 : columnForExactMatching.length)
                        + (columnForPartialMatching == null ? 0 : columnForPartialMatching.length);
        String[] selectionArgs = new String[len];
        insertSelectionArgumentStrings(
                selectionArgs, 0, query, columnForExactMatching, columnForPartialMatching);

        List<SearchResult> searchResults = new ArrayList<>();

        try (Cursor c =
                mContentResolver.query(
                        Channels.CONTENT_URI, projection, selection, selectionArgs, null)) {
            if (c != null) {
                int count = 0;
                while (c.moveToNext()) {
                    long id = c.getLong(0);
                    // Filter out the channel which has been already searched.
                    if (channelsFound.contains(id)) {
                        continue;
                    }
                    channelsFound.add(id);

                    SearchResult.Builder result = SearchResult.builder();
                    result.setChannelId(id);
                    result.setChannelNumber(c.getString(1));
                    result.setTitle(c.getString(2));
                    result.setDescription(c.getString(3));
                    result.setImageUri(TvContract.buildChannelLogoUri(id).toString());
                    result.setIntentAction(Intent.ACTION_VIEW);
                    result.setIntentData(buildIntentData(id));
                    result.setContentType(Programs.CONTENT_ITEM_TYPE);
                    result.setIsLive(true);
                    result.setProgressPercentage(SearchInterface.PROGRESS_PERCENTAGE_HIDE);

                    searchResults.add(result.build());

                    if (limit != NO_LIMIT && ++count >= limit) {
                        break;
                    }
                }
            }
        }
        return searchResults;
    }

    /**
     * Replaces the channel information - title, description, channel logo - with the current
     * program information of the channel if the current program information exists and it is not
     * blocked.
     */
    @WorkerThread
    private SearchResult fillProgramInfo(SearchResult result) {
        long now = System.currentTimeMillis();
        Uri uri = TvContract.buildProgramsUriForChannel(result.getChannelId(), now, now);
        String[] projection =
                new String[] {
                    Programs.COLUMN_TITLE,
                    Programs.COLUMN_POSTER_ART_URI,
                    Programs.COLUMN_CONTENT_RATING,
                    Programs.COLUMN_VIDEO_WIDTH,
                    Programs.COLUMN_VIDEO_HEIGHT,
                    Programs.COLUMN_START_TIME_UTC_MILLIS,
                    Programs.COLUMN_END_TIME_UTC_MILLIS
                };

        try (Cursor c = mContentResolver.query(uri, projection, null, null, null)) {
            if (c != null && c.moveToNext() && !isRatingBlocked(c.getString(2))) {
                String channelName = result.getTitle();
                String channelNumber = result.getChannelNumber();
                SearchResult.Builder builder = result.toBuilder();
                long startUtcMillis = c.getLong(5);
                long endUtcMillis = c.getLong(6);
                builder.setTitle(c.getString(0));
                builder.setDescription(
                        buildProgramDescription(
                                channelNumber, channelName, startUtcMillis, endUtcMillis));
                String imageUri = c.getString(1);
                if (imageUri != null) {
                    builder.setImageUri(imageUri);
                }
                builder.setVideoWidth(c.getInt(3));
                builder.setVideoHeight(c.getInt(4));
                builder.setDuration(endUtcMillis - startUtcMillis);
                builder.setProgressPercentage(getProgressPercentage(startUtcMillis, endUtcMillis));
                return builder.build();
            }
        }
        return result;
    }

    private String buildProgramDescription(
            String channelNumber,
            String channelName,
            long programStartUtcMillis,
            long programEndUtcMillis) {
        return Utils.getDurationString(mContext, programStartUtcMillis, programEndUtcMillis, false)
                + System.lineSeparator()
                + channelNumber
                + " "
                + channelName;
    }

    private int getProgressPercentage(long startUtcMillis, long endUtcMillis) {
        long current = System.currentTimeMillis();
        if (startUtcMillis > current || endUtcMillis <= current) {
            return SearchInterface.PROGRESS_PERCENTAGE_HIDE;
        }
        return (int) (100 * (current - startUtcMillis) / (endUtcMillis - startUtcMillis));
    }

    @WorkerThread
    private List<SearchResult> searchPrograms(
            String query,
            String[] columnForExactMatching,
            String[] columnForPartialMatching,
            Set<Long> channelsFound,
            int limit) {
        if (DEBUG) Log.d(TAG, "Searching programs: '" + query + "'");
        long time = SystemClock.elapsedRealtime();
        String[] projection = {
            Programs.COLUMN_CHANNEL_ID,
            Programs.COLUMN_TITLE,
            Programs.COLUMN_POSTER_ART_URI,
            Programs.COLUMN_CONTENT_RATING,
            Programs.COLUMN_VIDEO_WIDTH,
            Programs.COLUMN_VIDEO_HEIGHT,
            Programs.COLUMN_START_TIME_UTC_MILLIS,
            Programs.COLUMN_END_TIME_UTC_MILLIS,
            Programs._ID
        };

        StringBuilder sb = new StringBuilder();
        // Search among the programs which are now being on the air.
        sb.append(Programs.COLUMN_START_TIME_UTC_MILLIS).append("<=? AND ");
        sb.append(Programs.COLUMN_END_TIME_UTC_MILLIS).append(">=? AND (");
        appendSelectionString(sb, columnForExactMatching, columnForPartialMatching);
        sb.append(")");
        String selection = sb.toString();

        int len =
                (columnForExactMatching == null ? 0 : columnForExactMatching.length)
                        + (columnForPartialMatching == null ? 0 : columnForPartialMatching.length);
        String[] selectionArgs = new String[len + 2];
        long now = System.currentTimeMillis();
        selectionArgs[0] = String.valueOf(now + SEARCH_TIME_FRAME_MS);
        selectionArgs[1] = String.valueOf(now);
        insertSelectionArgumentStrings(
                selectionArgs, 2, query, columnForExactMatching, columnForPartialMatching);

        List<SearchResult> searchResults = new ArrayList<>();

        try (Cursor c =
                mContentResolver.query(
                        Programs.CONTENT_URI, projection, selection, selectionArgs, null)) {
            if (c != null) {
                int count = 0;
                while (c.moveToNext()) {
                    long id = c.getLong(0);
                    // Filter out the program whose channel is already searched.
                    if (channelsFound.contains(id)) {
                        continue;
                    }
                    channelsFound.add(id);

                    // Don't know whether the channel is searchable or not.
                    String[] channelProjection = {
                        Channels._ID, Channels.COLUMN_DISPLAY_NUMBER, Channels.COLUMN_DISPLAY_NAME
                    };
                    sb = new StringBuilder();
                    sb.append(Channels._ID)
                            .append("=? AND ")
                            .append(Channels.COLUMN_BROWSABLE)
                            .append("=1 AND ")
                            .append(Channels.COLUMN_SEARCHABLE)
                            .append("=1");
                    if (mTvInputManager.isParentalControlsEnabled()) {
                        sb.append(" AND ").append(Channels.COLUMN_LOCKED).append("=0");
                    }
                    String selectionChannel = sb.toString();
                    try (Cursor cChannel =
                            mContentResolver.query(
                                    Channels.CONTENT_URI,
                                    channelProjection,
                                    selectionChannel,
                                    new String[] {String.valueOf(id)},
                                    null)) {
                        if (cChannel != null
                                && cChannel.moveToNext()
                                && !isRatingBlocked(c.getString(3))) {
                            long startUtcMillis = c.getLong(6);
                            long endUtcMillis = c.getLong(7);
                            SearchResult.Builder result = SearchResult.builder();
                            result.setChannelId(c.getLong(0));
                            result.setTitle(c.getString(1));
                            result.setDescription(
                                    buildProgramDescription(
                                            cChannel.getString(1),
                                            cChannel.getString(2),
                                            startUtcMillis,
                                            endUtcMillis));
                            result.setImageUri(c.getString(2));
                            result.setIntentAction(Intent.ACTION_VIEW);
                            result.setIntentData(buildIntentData(id));
                            result.setIntentExtraData(
                                    TvContract.buildProgramUri(c.getLong(8)).toString());
                            result.setContentType(Programs.CONTENT_ITEM_TYPE);
                            result.setIsLive(true);
                            result.setVideoWidth(c.getInt(4));
                            result.setVideoHeight(c.getInt(5));
                            result.setDuration(endUtcMillis - startUtcMillis);
                            result.setProgressPercentage(
                                    getProgressPercentage(startUtcMillis, endUtcMillis));
                            searchResults.add(result.build());

                            if (limit != NO_LIMIT && ++count >= limit) {
                                break;
                            }
                        }
                    }
                }
            }
        }
        if (DEBUG) {
            Log.d(
                    TAG,
                    "Found "
                            + searchResults.size()
                            + " programs. Elapsed time for searching"
                            + " programs: "
                            + (SystemClock.elapsedRealtime() - time)
                            + "(msec)");
        }
        return searchResults;
    }

    private String buildIntentData(long channelId) {
        return TvContract.buildChannelUri(channelId).toString();
    }

    private boolean isRatingBlocked(String ratings) {
        if (TextUtils.isEmpty(ratings) || !mTvInputManager.isParentalControlsEnabled()) {
            return false;
        }
        ImmutableList<TvContentRating> ratingArray = mTvContentRatingCache.getRatings(ratings);
        if (ratingArray != null) {
            for (TvContentRating r : ratingArray) {
                if (mTvInputManager.isRatingBlocked(r)) {
                    return true;
                }
            }
        }
        return false;
    }

    private List<SearchResult> searchInputs(String query, int limit) {
        if (DEBUG) Log.d(TAG, "Searching inputs: '" + query + "'");
        long time = SystemClock.elapsedRealtime();

        query = canonicalizeLabel(query);
        List<TvInputInfo> inputList = mTvInputManager.getTvInputList();
        List<SearchResult> results = new ArrayList<>();

        // Find exact matches first.
        for (TvInputInfo input : inputList) {
            if (input.getType() == TvInputInfo.TYPE_TUNER) {
                continue;
            }
            String label = canonicalizeLabel(input.loadLabel(mContext));
            String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext));
            if (TextUtils.equals(query, label) || TextUtils.equals(query, customLabel)) {
                results.add(buildSearchResultForInput(input.getId()));
                if (results.size() >= limit) {
                    if (DEBUG) {
                        Log.d(
                                TAG,
                                "Found "
                                        + results.size()
                                        + " inputs. Elapsed time for"
                                        + " searching inputs: "
                                        + (SystemClock.elapsedRealtime() - time)
                                        + "(msec)");
                    }
                    return results;
                }
            }
        }

        // Then look for partial matches.
        for (TvInputInfo input : inputList) {
            if (input.getType() == TvInputInfo.TYPE_TUNER) {
                continue;
            }
            String label = canonicalizeLabel(input.loadLabel(mContext));
            String customLabel = canonicalizeLabel(input.loadCustomLabel(mContext));
            if ((label != null && label.contains(query))
                    || (customLabel != null && customLabel.contains(query))) {
                results.add(buildSearchResultForInput(input.getId()));
                if (results.size() >= limit) {
                    if (DEBUG) {
                        Log.d(
                                TAG,
                                "Found "
                                        + results.size()
                                        + " inputs. Elapsed time for"
                                        + " searching inputs: "
                                        + (SystemClock.elapsedRealtime() - time)
                                        + "(msec)");
                    }
                    return results;
                }
            }
        }
        if (DEBUG) {
            Log.d(
                    TAG,
                    "Found "
                            + results.size()
                            + " inputs. Elapsed time for searching"
                            + " inputs: "
                            + (SystemClock.elapsedRealtime() - time)
                            + "(msec)");
        }
        return results;
    }

    private String canonicalizeLabel(CharSequence cs) {
        Locale locale = mContext.getResources().getConfiguration().locale;
        return cs != null ? cs.toString().replaceAll("[ -]", "").toLowerCase(locale) : null;
    }

    private SearchResult buildSearchResultForInput(String inputId) {
        SearchResult.Builder result = SearchResult.builder();
        result.setIntentAction(Intent.ACTION_VIEW);
        result.setIntentData(TvContract.buildChannelUriForPassthroughInput(inputId).toString());
        return result.build();
    }

    @WorkerThread
    private class ChannelComparatorWithSameDisplayNumber implements Comparator<SearchResult> {
        private final Map<Long, Long> mMaxWatchStartTimeMap = new HashMap<>();

        @Override
        public int compare(SearchResult lhs, SearchResult rhs) {
            // Show recently watched channel first
            Long lhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(lhs.getChannelId());
            if (lhsMaxWatchStartTime == null) {
                lhsMaxWatchStartTime = getMaxWatchStartTime(lhs.getChannelId());
                mMaxWatchStartTimeMap.put(lhs.getChannelId(), lhsMaxWatchStartTime);
            }
            Long rhsMaxWatchStartTime = mMaxWatchStartTimeMap.get(rhs.getChannelId());
            if (rhsMaxWatchStartTime == null) {
                rhsMaxWatchStartTime = getMaxWatchStartTime(rhs.getChannelId());
                mMaxWatchStartTimeMap.put(rhs.getChannelId(), rhsMaxWatchStartTime);
            }
            if (!Objects.equals(lhsMaxWatchStartTime, rhsMaxWatchStartTime)) {
                return Long.compare(rhsMaxWatchStartTime, lhsMaxWatchStartTime);
            }
            // Show recently added channel first if there's no watch history.
            return Long.compare(rhs.getChannelId(), lhs.getChannelId());
        }

        private long getMaxWatchStartTime(long channelId) {
            Uri uri = WatchedPrograms.CONTENT_URI;
            String[] projections =
                    new String[] {
                        "MAX("
                                + WatchedPrograms.COLUMN_START_TIME_UTC_MILLIS
                                + ") AS max_watch_start_time"
                    };
            String selection = WatchedPrograms.COLUMN_CHANNEL_ID + "=?";
            String[] selectionArgs = new String[] {Long.toString(channelId)};
            try (Cursor c =
                    mContentResolver.query(uri, projections, selection, selectionArgs, null)) {
                if (c != null && c.moveToNext()) {
                    return c.getLong(0);
                }
            }
            return -1;
        }
    }
}
