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

import android.annotation.TargetApi;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.OperationApplicationException;
import android.media.tv.TvContract;
import android.media.tv.TvInputInfo;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Handler;
import android.os.RemoteException;
import android.support.annotation.AnyThread;
import android.support.annotation.MainThread;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.support.annotation.WorkerThread;
import android.util.Log;
import android.util.Range;

import com.android.tv.TvSingletons;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.common.feature.CommonFeatures;
import com.android.tv.common.util.CommonUtils;
import com.android.tv.data.api.Channel;
import com.android.tv.data.api.Program;
import com.android.tv.dvr.DvrDataManager.OnRecordedProgramLoadFinishedListener;
import com.android.tv.dvr.DvrDataManager.RecordedProgramListener;
import com.android.tv.dvr.DvrScheduleManager.OnInitializeListener;
import com.android.tv.dvr.data.RecordedProgram;
import com.android.tv.dvr.data.ScheduledRecording;
import com.android.tv.dvr.data.SeriesRecording;
import com.android.tv.util.AsyncDbTask;
import com.android.tv.util.Utils;

import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.Executor;

/**
 * DVR manager class to add and remove recordings. UI can modify recording list through this class,
 * instead of modifying them directly through {@link DvrDataManager}.
 */
@MainThread
@TargetApi(Build.VERSION_CODES.N)
public class DvrManager {
    private static final String TAG = "DvrManager";
    private static final boolean DEBUG = false;

    private final WritableDvrDataManager mDataManager;
    private final DvrScheduleManager mScheduleManager;
    // @GuardedBy("mListener")
    private final Map<Listener, Handler> mListener = new HashMap<>();
    private final Context mAppContext;
    private final Executor mDbExecutor;

    public DvrManager(Context context) {
        SoftPreconditions.checkFeatureEnabled(context, CommonFeatures.DVR, TAG);
        mAppContext = context.getApplicationContext();
        TvSingletons tvSingletons = TvSingletons.getSingletons(context);
        mDbExecutor = tvSingletons.getDbExecutor();
        mDataManager = (WritableDvrDataManager) tvSingletons.getDvrDataManager();
        mScheduleManager = tvSingletons.getDvrScheduleManager();
        if (mDataManager.isInitialized() && mScheduleManager.isInitialized()) {
            createSeriesRecordingsForRecordedProgramsIfNeeded(mDataManager.getRecordedPrograms());
        } else {
            // No need to handle DVR schedule load finished because schedule manager is initialized
            // after the all the schedules are loaded.
            if (!mDataManager.isRecordedProgramLoadFinished()) {
                mDataManager.addRecordedProgramLoadFinishedListener(
                        new OnRecordedProgramLoadFinishedListener() {
                            @Override
                            public void onRecordedProgramLoadFinished() {
                                mDataManager.removeRecordedProgramLoadFinishedListener(this);
                                if (mDataManager.isInitialized()
                                        && mScheduleManager.isInitialized()) {
                                    createSeriesRecordingsForRecordedProgramsIfNeeded(
                                            mDataManager.getRecordedPrograms());
                                }
                            }
                        });
            }
            if (!mScheduleManager.isInitialized()) {
                mScheduleManager.addOnInitializeListener(
                        new OnInitializeListener() {
                            @Override
                            public void onInitialize() {
                                mScheduleManager.removeOnInitializeListener(this);
                                if (mDataManager.isInitialized()
                                        && mScheduleManager.isInitialized()) {
                                    createSeriesRecordingsForRecordedProgramsIfNeeded(
                                            mDataManager.getRecordedPrograms());
                                }
                            }
                        });
            }
        }
        mDataManager.addRecordedProgramListener(
                new RecordedProgramListener() {
                    @Override
                    public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) {
                        if (!mDataManager.isInitialized() || !mScheduleManager.isInitialized()) {
                            return;
                        }
                        for (RecordedProgram recordedProgram : recordedPrograms) {
                            createSeriesRecordingForRecordedProgramIfNeeded(recordedProgram);
                        }
                    }

                    @Override
                    public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) {}

                    @Override
                    public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) {
                        // Removing series recording is handled in the
                        // SeriesRecordingDetailsFragment.
                    }
                });
    }

    private void createSeriesRecordingsForRecordedProgramsIfNeeded(
            List<RecordedProgram> recordedPrograms) {
        for (RecordedProgram recordedProgram : recordedPrograms) {
            createSeriesRecordingForRecordedProgramIfNeeded(recordedProgram);
        }
    }

    private void createSeriesRecordingForRecordedProgramIfNeeded(RecordedProgram recordedProgram) {
        if (recordedProgram.isEpisodic()) {
            SeriesRecording seriesRecording =
                    mDataManager.getSeriesRecording(recordedProgram.getSeriesId());
            if (seriesRecording == null) {
                addSeriesRecording(recordedProgram);
            }
        }
    }

    /** Schedules a recording for {@code program}. */
    public ScheduledRecording addSchedule(Program program) {
        return addSchedule(program, 0, 0);
    }

    /**
     * Schedules a recording for {@code program} with a early start time and late end time.
     *
     *@param startOffsetMs The extra time in milliseconds to start recording before the program
     *                     starts.
     *@param endOffsetMs The extra time in milliseconds to end recording after the program ends.
     */
    public ScheduledRecording addSchedule(Program program,
                                          long startOffsetMs, long endOffsetMs) {
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return null;
        }
        SeriesRecording seriesRecording = getSeriesRecording(program);
        return addSchedule(
                program,
                seriesRecording == null
                        ? mScheduleManager.suggestNewPriority()
                        : seriesRecording.getPriority(),
                startOffsetMs,
                endOffsetMs);
    }

    /**
     * Schedules a recording for {@code program} with the highest priority so that the schedule can
     * be recorded.
     */
    public ScheduledRecording addScheduleWithHighestPriority(Program program) {
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return null;
        }
        SeriesRecording seriesRecording = getSeriesRecording(program);
        return addSchedule(
                program,
                seriesRecording == null
                        ? mScheduleManager.suggestNewPriority()
                        : mScheduleManager.suggestHighestPriority(
                                seriesRecording.getInputId(),
                                Range.create(
                                        program.getStartTimeUtcMillis(),
                                        program.getEndTimeUtcMillis()),
                                seriesRecording.getPriority()),
                0,
                0);
    }

    private ScheduledRecording addSchedule(Program program, long priority,
                                           long startOffsetMs, long endOffsetMs) {
        TvInputInfo input = Utils.getTvInputInfoForProgram(mAppContext, program);
        if (input == null) {
            Log.e(TAG, "Can't find input for program: " + program);
            return null;
        }
        ScheduledRecording schedule;
        SeriesRecording seriesRecording = getSeriesRecording(program);
        schedule =
                createScheduledRecordingBuilder(input.getId(), program)
                        .setPriority(priority)
                        .setSeriesRecordingId(
                                seriesRecording == null
                                        ? SeriesRecording.ID_NOT_SET
                                        : seriesRecording.getId())
                        .setStartOffsetMs(startOffsetMs)
                        .setEndOffsetMs(endOffsetMs)
                        .build();
        mDataManager.addScheduledRecording(schedule);
        return schedule;
    }

    /** Adds a recording schedule with a time range. */
    public void addSchedule(Channel channel, long startTime, long endTime) {
        Log.i(
                TAG,
                "Adding scheduled recording of channel "
                        + channel
                        + " starting at "
                        + Utils.toTimeString(startTime)
                        + " and ending at "
                        + Utils.toTimeString(endTime));
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return;
        }
        TvInputInfo input = Utils.getTvInputInfoForChannelId(mAppContext, channel.getId());
        if (input == null) {
            Log.e(TAG, "Can't find input for channel: " + channel);
            return;
        }
        addScheduleInternal(input.getId(), channel.getId(), startTime, endTime);
    }

    /** Adds the schedule. */
    public void addSchedule(ScheduledRecording schedule) {
        if (mDataManager.isDvrScheduleLoadFinished()) {
            mDataManager.addScheduledRecording(schedule);
        }
    }

    private void addScheduleInternal(String inputId, long channelId, long startTime, long endTime) {
        mDataManager.addScheduledRecording(
                ScheduledRecording.builder(inputId, channelId, startTime, endTime)
                        .setPriority(mScheduleManager.suggestNewPriority())
                        .build());
    }

    /** Adds a new series recording and schedules for the programs with the initial state. */
    public SeriesRecording addSeriesRecording(
            Program selectedProgram,
            List<Program> programsToSchedule,
            @SeriesRecording.SeriesState int initialState) {
        Log.i(
                TAG,
                "Adding series recording for program "
                        + selectedProgram
                        + ", and schedules: "
                        + programsToSchedule);
        if (!SoftPreconditions.checkState(mDataManager.isInitialized())) {
            return null;
        }
        TvInputInfo input = Utils.getTvInputInfoForProgram(mAppContext, selectedProgram);
        if (input == null) {
            Log.e(TAG, "Can't find input for program: " + selectedProgram);
            return null;
        }
        SeriesRecording seriesRecording =
                SeriesRecording.builder(input.getId(), selectedProgram)
                        .setPriority(mScheduleManager.suggestNewSeriesPriority())
                        .setState(initialState)
                        .build();
        mDataManager.addSeriesRecording(seriesRecording);
        // The schedules for the recorded programs should be added not to create the schedule the
        // duplicate episodes.
        addRecordedProgramToSeriesRecording(seriesRecording);
        addScheduleToSeriesRecording(seriesRecording, programsToSchedule);
        return seriesRecording;
    }

    private void addSeriesRecording(RecordedProgram recordedProgram) {
        SeriesRecording seriesRecording =
                SeriesRecording.builder(recordedProgram.getInputId(), recordedProgram)
                        .setPriority(mScheduleManager.suggestNewSeriesPriority())
                        .setState(SeriesRecording.STATE_SERIES_STOPPED)
                        .build();
        mDataManager.addSeriesRecording(seriesRecording);
        // The schedules for the recorded programs should be added not to create the schedule the
        // duplicate episodes.
        addRecordedProgramToSeriesRecording(seriesRecording);
    }

    private void addRecordedProgramToSeriesRecording(SeriesRecording series) {
        List<ScheduledRecording> toAdd = new ArrayList<>();
        for (RecordedProgram recordedProgram : mDataManager.getRecordedPrograms()) {
            if (series.getSeriesId().equals(recordedProgram.getSeriesId())
                    && !recordedProgram.isClipped()) {
                // Duplicate schedules can exist, but they will be deleted in a few days. And it's
                // also guaranteed that the schedules don't belong to any series recordings because
                // there are no more than one series recordings which have the same program title.
                toAdd.add(
                        ScheduledRecording.builder(recordedProgram)
                                .setPriority(series.getPriority())
                                .setSeriesRecordingId(series.getId())
                                .build());
            }
        }
        if (!toAdd.isEmpty()) {
            mDataManager.addScheduledRecording(ScheduledRecording.toArray(toAdd));
        }
    }

    /**
     * Adds {@link ScheduledRecording}s for the series recording.
     *
     * <p>This method doesn't add the series recording.
     */
    public void addScheduleToSeriesRecording(
            SeriesRecording series, List<Program> programsToSchedule) {
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return;
        }
        TvInputInfo input = Utils.getTvInputInfoForInputId(mAppContext, series.getInputId());
        if (input == null) {
            Log.e(TAG, "Can't find input with ID: " + series.getInputId());
            return;
        }
        List<ScheduledRecording> toAdd = new ArrayList<>();
        List<ScheduledRecording> toUpdate = new ArrayList<>();
        for (Program program : programsToSchedule) {
            ScheduledRecording scheduleWithSameProgram =
                    mDataManager.getScheduledRecordingForProgramId(program.getId());
            if (scheduleWithSameProgram != null) {
                if (scheduleWithSameProgram.isNotStarted()) {
                    ScheduledRecording r =
                            ScheduledRecording.buildFrom(scheduleWithSameProgram)
                                    .setSeriesRecordingId(series.getId())
                                    .build();
                    if (!r.equals(scheduleWithSameProgram)) {
                        toUpdate.add(r);
                    }
                }
            } else {
                toAdd.add(
                        createScheduledRecordingBuilder(input.getId(), program)
                                .setPriority(series.getPriority())
                                .setSeriesRecordingId(series.getId())
                                .build());
            }
        }
        if (!toAdd.isEmpty()) {
            mDataManager.addScheduledRecording(ScheduledRecording.toArray(toAdd));
        }
        if (!toUpdate.isEmpty()) {
            mDataManager.updateScheduledRecording(ScheduledRecording.toArray(toUpdate));
        }
    }

    /** Updates the series recording. */
    public void updateSeriesRecording(SeriesRecording series) {
        if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            SeriesRecording previousSeries = mDataManager.getSeriesRecording(series.getId());
            if (previousSeries != null) {
                // If the channel option of series changed, remove the existing schedules. The new
                // schedules will be added by SeriesRecordingScheduler or by SeriesSettingsFragment.
                if (previousSeries.getChannelOption() != series.getChannelOption()
                        || (previousSeries.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ONE
                                && previousSeries.getChannelId() != series.getChannelId())) {
                    List<ScheduledRecording> schedules =
                            mDataManager.getScheduledRecordings(series.getId());
                    List<ScheduledRecording> schedulesToRemove = new ArrayList<>();
                    for (ScheduledRecording schedule : schedules) {
                        if (schedule.isNotStarted()) {
                            schedulesToRemove.add(schedule);
                        } else if (schedule.isInProgress()
                                && series.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ONE
                                && schedule.getChannelId() != series.getChannelId()) {
                            stopRecording(schedule);
                        }
                    }
                    List<ScheduledRecording> deletedSchedules =
                            new ArrayList<>(mDataManager.getDeletedSchedules());
                    for (ScheduledRecording deletedSchedule : deletedSchedules) {
                        if (deletedSchedule.getSeriesRecordingId() == series.getId()
                                && deletedSchedule.getEndTimeMs() > System.currentTimeMillis()) {
                            schedulesToRemove.add(deletedSchedule);
                        }
                    }
                    mDataManager.removeScheduledRecording(
                            true, ScheduledRecording.toArray(schedulesToRemove));
                }
            }
            mDataManager.updateSeriesRecording(series);
            if (previousSeries == null || previousSeries.getPriority() != series.getPriority()) {
                long priority = series.getPriority();
                List<ScheduledRecording> schedulesToUpdate = new ArrayList<>();
                for (ScheduledRecording schedule :
                        mDataManager.getScheduledRecordings(series.getId())) {
                    if (schedule.isNotStarted() || schedule.isInProgress()) {
                        schedulesToUpdate.add(
                                ScheduledRecording.buildFrom(schedule)
                                        .setPriority(priority)
                                        .build());
                    }
                }
                if (!schedulesToUpdate.isEmpty()) {
                    mDataManager.updateScheduledRecording(
                            ScheduledRecording.toArray(schedulesToUpdate));
                }
            }
        }
    }

    /**
     * Removes the series recording and all the corresponding schedules which are not started yet.
     */
    public void removeSeriesRecording(long seriesRecordingId) {
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return;
        }
        SeriesRecording series = mDataManager.getSeriesRecording(seriesRecordingId);
        if (series == null) {
            return;
        }
        for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) {
            if (schedule.getSeriesRecordingId() == seriesRecordingId) {
                if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
                    stopRecording(schedule);
                    break;
                }
            }
        }
        mDataManager.removeSeriesRecording(series);
    }

    /** Stops the currently recorded program */
    public void stopRecording(final ScheduledRecording recording) {
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return;
        }
        synchronized (mListener) {
            for (final Entry<Listener, Handler> entry : mListener.entrySet()) {
                entry.getValue().post(() -> entry.getKey().onStopRecordingRequested(recording));
            }
        }
    }

    /** Removes scheduled recordings or an existing recordings. */
    public void removeScheduledRecording(ScheduledRecording... schedules) {
        Log.i(TAG, "Removing " + Arrays.asList(schedules));
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return;
        }
        for (ScheduledRecording r : schedules) {
            if (r.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
                stopRecording(r);
            } else {
                mDataManager.removeScheduledRecording(r);
            }
        }
    }

    /** Removes scheduled recordings without changing to the DELETED state. */
    public void forceRemoveScheduledRecording(ScheduledRecording... schedules) {
        Log.i(TAG, "Force removing " + Arrays.asList(schedules));
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return;
        }
        for (ScheduledRecording r : schedules) {
            if (r.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
                stopRecording(r);
            } else {
                mDataManager.removeScheduledRecording(true, r);
            }
        }
    }

    /** Removes the recorded program. It deletes the file if possible. */
    public void removeRecordedProgram(Uri recordedProgramUri, boolean deleteFile) {
        if (!SoftPreconditions.checkState(mDataManager.isInitialized())) {
            return;
        }
        removeRecordedProgram(ContentUris.parseId(recordedProgramUri), deleteFile);
    }

    /** Removes the recorded program. It deletes the file if possible. */
    public void removeRecordedProgram(long recordedProgramId, boolean deleteFile) {
        if (!SoftPreconditions.checkState(mDataManager.isInitialized())) {
            return;
        }
        RecordedProgram recordedProgram = mDataManager.getRecordedProgram(recordedProgramId);
        if (recordedProgram != null) {
            removeRecordedProgram(recordedProgram, deleteFile);
        }
    }

    /** Removes the recorded program. It deletes the file if possible. */
    public void removeRecordedProgram(final RecordedProgram recordedProgram, boolean deleteFile) {
        if (!SoftPreconditions.checkState(mDataManager.isInitialized())) {
            return;
        }
        new AsyncDbTask<Void, Void, Integer>(mDbExecutor) {
            @Override
            protected Integer doInBackground(Void... params) {
                ContentResolver resolver = mAppContext.getContentResolver();
                return resolver.delete(recordedProgram.getUri(), null, null);
            }

            @Override
            protected void onPostExecute(Integer deletedCounts) {
                if (deletedCounts > 0 && deleteFile) {
                    new AsyncTask<Void, Void, Void>() {
                        @Override
                        protected Void doInBackground(Void... params) {
                            removeRecordedData(recordedProgram.getDataUri());
                            return null;
                        }
                    }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
                }
            }
        }.executeOnDbThread();
    }

    public void removeRecordedPrograms(List<Long> recordedProgramIds, boolean deleteFiles) {
        final ArrayList<ContentProviderOperation> dbOperations = new ArrayList<>();
        final List<Uri> dataUris = new ArrayList<>();
        for (Long rId : recordedProgramIds) {
            RecordedProgram r = mDataManager.getRecordedProgram(rId);
            if (r != null) {
                dataUris.add(r.getDataUri());
                dbOperations.add(ContentProviderOperation.newDelete(r.getUri()).build());
            }
        }
        new AsyncDbTask<Void, Void, Boolean>(mDbExecutor) {
            @Override
            protected Boolean doInBackground(Void... params) {
                ContentResolver resolver = mAppContext.getContentResolver();
                try {
                    resolver.applyBatch(TvContract.AUTHORITY, dbOperations);
                } catch (RemoteException | OperationApplicationException e) {
                    Log.w(TAG, "Remove recorded programs from DB failed.", e);
                    return false;
                }
                return true;
            }

            @Override
            protected void onPostExecute(Boolean success) {
                if (success && deleteFiles) {
                    new AsyncTask<Void, Void, Void>() {
                        @Override
                        protected Void doInBackground(Void... params) {
                            for (Uri dataUri : dataUris) {
                                removeRecordedData(dataUri);
                            }
                            return null;
                        }
                    }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
                }
            }
        }.executeOnDbThread();
    }

    /** Updates the scheduled recording. */
    public void updateScheduledRecording(ScheduledRecording recording) {
        if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            mDataManager.updateScheduledRecording(recording);
        }
    }

    /**
     * Returns priority ordered list of all scheduled recordings that will not be recorded if this
     * program is.
     *
     * @see DvrScheduleManager#getConflictingSchedules(Program)
     */
    public List<ScheduledRecording> getConflictingSchedules(Program program) {
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return Collections.emptyList();
        }
        return mScheduleManager.getConflictingSchedules(program);
    }

    /**
     * Returns priority ordered list of all scheduled recordings that will not be recorded if this
     * channel is.
     *
     * @see DvrScheduleManager#getConflictingSchedules(long, long, long)
     */
    public List<ScheduledRecording> getConflictingSchedules(
            long channelId, long startTimeMs, long endTimeMs) {
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return Collections.emptyList();
        }
        return mScheduleManager.getConflictingSchedules(channelId, startTimeMs, endTimeMs);
    }

    /**
     * Checks if the schedule is conflicting.
     *
     * <p>Note that the {@code schedule} should be the existing one. If not, this returns {@code
     * false}.
     */
    public boolean isConflicting(ScheduledRecording schedule) {
        return schedule != null
                && SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())
                && mScheduleManager.isConflicting(schedule);
    }

    /**
     * Returns priority ordered list of all scheduled recording that will not be recorded if this
     * channel is tuned to.
     *
     * @see DvrScheduleManager#getConflictingSchedulesForTune
     */
    public List<ScheduledRecording> getConflictingSchedulesForTune(long channelId) {
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return Collections.emptyList();
        }
        return mScheduleManager.getConflictingSchedulesForTune(channelId);
    }

    /** Sets the highest priority to the schedule. */
    public void setHighestPriority(ScheduledRecording schedule) {
        if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            long newPriority = mScheduleManager.suggestHighestPriority(schedule);
            if (newPriority != schedule.getPriority()) {
                mDataManager.updateScheduledRecording(
                        ScheduledRecording.buildFrom(schedule).setPriority(newPriority).build());
            }
        }
    }

    /** Suggests the higher priority than the schedules which overlap with {@code schedule}. */
    public long suggestHighestPriority(ScheduledRecording schedule) {
        if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return mScheduleManager.suggestHighestPriority(schedule);
        }
        return DvrScheduleManager.DEFAULT_PRIORITY;
    }

    /**
     * Returns {@code true} if the channel can be recorded.
     *
     * <p>Note that this method doesn't check the conflict of the schedule or available tuners. This
     * can be called from the UI before the schedules are loaded.
     */
    public boolean isChannelRecordable(Channel channel) {
        if (!mDataManager.isDvrScheduleLoadFinished() || channel == null) {
            return false;
        }
        if (channel.isRecordingProhibited()) {
            return false;
        }
        TvInputInfo info = Utils.getTvInputInfoForChannelId(mAppContext, channel.getId());
        if (info == null) {
            Log.w(TAG, "Could not find TvInputInfo for " + channel);
            return false;
        }
        if (!info.canRecord()) {
            return false;
        }
        Program program =
                TvSingletons.getSingletons(mAppContext)
                        .getProgramDataManager()
                        .getCurrentProgram(channel.getId());
        return program == null || !program.isRecordingProhibited();
    }

    /**
     * Returns {@code true} if the program can be recorded.
     *
     * <p>Note that this method doesn't check the conflict of the schedule or available tuners. This
     * can be called from the UI before the schedules are loaded.
     */
    public boolean isProgramRecordable(Program program) {
        if (!mDataManager.isInitialized()) {
            return false;
        }
        Channel channel =
                TvSingletons.getSingletons(mAppContext)
                        .getChannelDataManager()
                        .getChannel(program.getChannelId());
        if (channel == null || channel.isRecordingProhibited()) {
            return false;
        }
        TvInputInfo info = Utils.getTvInputInfoForChannelId(mAppContext, channel.getId());
        if (info == null) {
            Log.w(TAG, "Could not find TvInputInfo for " + program);
            return false;
        }
        return info.canRecord() && !program.isRecordingProhibited();
    }

    /**
     * Returns the current recording for the channel.
     *
     * <p>This can be called from the UI before the schedules are loaded.
     */
    public ScheduledRecording getCurrentRecording(long channelId) {
        if (!mDataManager.isDvrScheduleLoadFinished()) {
            return null;
        }
        for (ScheduledRecording recording : mDataManager.getStartedRecordings()) {
            if (recording.getChannelId() == channelId) {
                return recording;
            }
        }
        return null;
    }

    /**
     * Returns schedules which is available (i.e., isNotStarted or isInProgress) and belongs to the
     * series recording {@code seriesRecordingId}.
     */
    public List<ScheduledRecording> getAvailableScheduledRecording(long seriesRecordingId) {
        if (!mDataManager.isDvrScheduleLoadFinished()) {
            return Collections.emptyList();
        }
        List<ScheduledRecording> schedules = new ArrayList<>();
        for (ScheduledRecording schedule : mDataManager.getScheduledRecordings(seriesRecordingId)) {
            if (schedule.isInProgress() || schedule.isNotStarted()) {
                schedules.add(schedule);
            }
        }
        return schedules;
    }

    /** Returns the series recording related to the program. */
    @Nullable
    public SeriesRecording getSeriesRecording(Program program) {
        if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
            return null;
        }
        return mDataManager.getSeriesRecording(program.getSeriesId());
    }

    /**
     * Returns if there are valid items. Valid item contains {@link RecordedProgram}, available
     * {@link ScheduledRecording} and {@link SeriesRecording}.
     */
    public boolean hasValidItems() {
        return !(mDataManager.getRecordedPrograms().isEmpty()
                && mDataManager.getStartedRecordings().isEmpty()
                && mDataManager.getNonStartedScheduledRecordings().isEmpty()
                && mDataManager.getSeriesRecordings().isEmpty());
    }

    @WorkerThread
    @VisibleForTesting
    // Should be public to use mock DvrManager object.
    public void addListener(Listener listener, @NonNull Handler handler) {
        SoftPreconditions.checkNotNull(handler);
        synchronized (mListener) {
            mListener.put(listener, handler);
        }
    }

    @WorkerThread
    @VisibleForTesting
    // Should be public to use mock DvrManager object.
    public void removeListener(Listener listener) {
        synchronized (mListener) {
            mListener.remove(listener);
        }
    }

    /**
     * Returns ScheduledRecording.builder based on {@code program}. If program is already started,
     * recording started time is clipped to the current time.
     */
    private ScheduledRecording.Builder createScheduledRecordingBuilder(
            String inputId, Program program) {
        ScheduledRecording.Builder builder = ScheduledRecording.builder(inputId, program);
        long time = System.currentTimeMillis();
        if (program.getStartTimeUtcMillis() < time && time < program.getEndTimeUtcMillis()) {
            builder.setStartTimeMs(time);
        }
        return builder;
    }

    /** Returns a schedule which matches to the given episode. */
    public ScheduledRecording getScheduledRecording(
            String title, String seasonNumber, String episodeNumber) {
        if (!SoftPreconditions.checkState(mDataManager.isInitialized())
                || title == null
                || seasonNumber == null
                || episodeNumber == null) {
            return null;
        }
        for (ScheduledRecording r : mDataManager.getAllScheduledRecordings()) {
            if (title.equals(r.getProgramTitle())
                    && seasonNumber.equals(r.getSeasonNumber())
                    && episodeNumber.equals(r.getEpisodeNumber())) {
                return r;
            }
        }
        return null;
    }

    /** Returns a recorded program which is the same episode as the given {@code program}. */
    public RecordedProgram getRecordedProgram(
            String title, String seasonNumber, String episodeNumber) {
        if (!SoftPreconditions.checkState(mDataManager.isInitialized())
                || title == null
                || seasonNumber == null
                || episodeNumber == null) {
            return null;
        }
        for (RecordedProgram r : mDataManager.getRecordedPrograms()) {
            if (title.equals(r.getTitle())
                    && seasonNumber.equals(r.getSeasonNumber())
                    && episodeNumber.equals(r.getEpisodeNumber())
                    && !r.isClipped()) {
                return r;
            }
        }
        return null;
    }

    @WorkerThread
    private void removeRecordedData(Uri dataUri) {
        try {
            if (isFile(dataUri)) {
                File recordedProgramPath = new File(dataUri.getPath());
                if (!recordedProgramPath.exists()) {
                    if (DEBUG) Log.d(TAG, "File to delete not exist: " + recordedProgramPath);
                } else {
                    if (CommonUtils.deleteDirOrFile(recordedProgramPath)) {
                        if (DEBUG) {
                            Log.d(
                                    TAG,
                                    "Successfully deleted files of the recorded program: "
                                            + dataUri);
                        }
                    } else {
                        Log.w(TAG, "Unable to delete recording data at " + dataUri);
                    }
                }
            }
        } catch (SecurityException e) {
            Log.w(TAG, "Unable to delete recording data at " + dataUri, e);
        }
    }

    @AnyThread
    public static boolean isFromBundledInput(RecordedProgram mRecordedProgram) {
        return CommonUtils.isInBundledPackageSet(mRecordedProgram.getPackageName());
    }

    @AnyThread
    public static boolean isFile(Uri dataUri) {
        return dataUri != null
                && ContentResolver.SCHEME_FILE.equals(dataUri.getScheme())
                && dataUri.getPath() != null;
    }

    /**
     * Remove all the records related to the input.
     *
     * <p>Note that this should be called after the input was removed.
     */
    public void forgetStorage(String inputId) {
        if (mDataManager != null && mDataManager.isInitialized()) {
            mDataManager.forgetStorage(inputId);
        }
    }

    /**
     * Listener to stop recording request. Should only be internally used inside dvr and its
     * sub-package.
     */
    public interface Listener {
        void onStopRecordingRequested(ScheduledRecording scheduledRecording);
    }
}
