/*
 * Copyright (C) 2016 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.data.epg;

import android.app.job.JobInfo;
import android.app.job.JobParameters;
import android.app.job.JobScheduler;
import android.app.job.JobService;
import android.content.ComponentName;
import android.content.Context;
import android.database.Cursor;
import android.media.tv.TvContract;
import android.media.tv.TvInputInfo;
import android.net.TrafficStats;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.AnyThread;
import android.support.annotation.MainThread;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.support.annotation.WorkerThread;
import android.text.TextUtils;
import android.util.Log;

import com.android.tv.TvSingletons;
import com.android.tv.common.BuildConfig;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.common.buildtype.HasBuildType;
import com.android.tv.common.dagger.annotations.ApplicationContext;
import com.android.tv.common.util.Clock;
import com.android.tv.common.util.CommonUtils;
import com.android.tv.common.util.LocationUtils;
import com.android.tv.common.util.NetworkTrafficTags;
import com.android.tv.common.util.PermissionUtils;
import com.android.tv.common.util.PostalCodeUtils;
import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.ChannelImpl;
import com.android.tv.data.ChannelLogoFetcher;
import com.android.tv.data.Lineup;
import com.android.tv.data.api.Channel;
import com.android.tv.data.api.Program;
import com.android.tv.data.epg.EpgReader.EpgChannel;
import com.android.tv.features.TvFeatures;
import com.android.tv.perf.EventNames;
import com.android.tv.perf.PerformanceMonitor;
import com.android.tv.perf.TimerEvent;
import com.android.tv.util.Utils;

import com.google.android.tv.partner.support.EpgInput;
import com.google.android.tv.partner.support.EpgInputs;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;

import com.android.tv.common.flags.BackendKnobsFlags;

import java.io.IOException;
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.TimeUnit;

import javax.inject.Inject;

/**
 * The service class to fetch EPG routinely or on-demand during channel scanning
 *
 * <p>Since the default executor of {@link AsyncTask} is {@link AsyncTask#SERIAL_EXECUTOR}, only one
 * task can run at a time. Because fetching EPG takes long time, the fetching task shouldn't run on
 * the serial executor. Instead, it should run on the {@link AsyncTask#THREAD_POOL_EXECUTOR}.
 */
public class EpgFetcherImpl implements EpgFetcher {
    private static final String TAG = "EpgFetcherImpl";
    private static final boolean DEBUG = false;

    private static final int EPG_ROUTINELY_FETCHING_JOB_ID = 101;

    private static final long INITIAL_BACKOFF_MS = TimeUnit.SECONDS.toMillis(10);

    @VisibleForTesting static final int REASON_EPG_READER_NOT_READY = 1;
    @VisibleForTesting static final int REASON_LOCATION_INFO_UNAVAILABLE = 2;
    @VisibleForTesting static final int REASON_LOCATION_PERMISSION_NOT_GRANTED = 3;
    @VisibleForTesting static final int REASON_NO_EPG_DATA_RETURNED = 4;
    @VisibleForTesting static final int REASON_NO_NEW_EPG = 5;
    @VisibleForTesting static final int REASON_ERROR = 6;
    @VisibleForTesting static final int REASON_CLOUD_EPG_FAILURE = 7;
    @VisibleForTesting static final int REASON_NO_BUILT_IN_CHANNELS = 8;

    private static final long FETCH_DURING_SCAN_WAIT_TIME_MS = TimeUnit.SECONDS.toMillis(10);

    private static final long FETCH_DURING_SCAN_DURATION_SEC = TimeUnit.HOURS.toSeconds(3);
    private static final long FAST_FETCH_DURATION_SEC = TimeUnit.DAYS.toSeconds(2);

    private static final long DEFAULT_ROUTINE_INTERVAL_HOUR = 4;

    private static final int MSG_PREPARE_FETCH_DURING_SCAN = 1;
    private static final int MSG_CHANNEL_UPDATED_DURING_SCAN = 2;
    private static final int MSG_FINISH_FETCH_DURING_SCAN = 3;
    private static final int MSG_RETRY_PREPARE_FETCH_DURING_SCAN = 4;

    private static final int MINIMUM_CHANNELS_TO_DECIDE_LINEUP = 3;

    private final Context mContext;
    private final ChannelDataManager mChannelDataManager;
    private final EpgReader mEpgReader;
    private final PerformanceMonitor mPerformanceMonitor;
    private final EpgInputWhiteList mEpgInputWhiteList;
    private final BackendKnobsFlags mBackendKnobsFlags;
    private final HasBuildType.BuildType mBuildType;
    private FetchAsyncTask mFetchTask;
    private FetchDuringScanHandler mFetchDuringScanHandler;
    private long mEpgTimeStamp;
    private List<Lineup> mPossibleLineups;
    private final Object mPossibleLineupsLock = new Object();
    private final Object mFetchDuringScanHandlerLock = new Object();
    // A flag to block the re-entrance of onChannelScanStarted and onChannelScanFinished.
    private boolean mScanStarted;

    private Clock mClock;

    @Inject
    public EpgFetcherImpl(
            @ApplicationContext Context context,
            EpgInputWhiteList epgInputWhiteList,
            ChannelDataManager channelDataManager,
            EpgReader epgReader,
            PerformanceMonitor performanceMonitor,
            Clock clock,
            BackendKnobsFlags backendKnobsFlags,
            HasBuildType.BuildType buildType) {
        mContext = context;
        mChannelDataManager = channelDataManager;
        mEpgReader = epgReader;
        mPerformanceMonitor = performanceMonitor;
        mClock = clock;
        mEpgInputWhiteList = epgInputWhiteList;
        mBackendKnobsFlags = backendKnobsFlags;
        mBuildType = buildType;
    }

    private long getFastFetchDurationSec() {
        return FAST_FETCH_DURATION_SEC + getRoutineIntervalMs() / 1000;
    }

    private long getEpgDataExpiredTimeLimitMs() {
        return getRoutineIntervalMs() * 2;
    }

    private long getRoutineIntervalMs() {
        long routineIntervalHours = mBackendKnobsFlags.epgFetcherIntervalHour();
        return routineIntervalHours <= 0
                ? TimeUnit.HOURS.toMillis(DEFAULT_ROUTINE_INTERVAL_HOUR)
                : TimeUnit.HOURS.toMillis(routineIntervalHours);
    }

    private static Set<Channel> getExistingChannelsForMyPackage(Context context) {
        HashSet<Channel> channels = new HashSet<>();
        String selection = null;
        String[] selectionArgs = null;
        String myPackageName = context.getPackageName();
        if (PermissionUtils.hasAccessAllEpg(context)) {
            selection = "package_name=?";
            selectionArgs = new String[] {myPackageName};
        }
        try (Cursor c =
                context.getContentResolver()
                        .query(
                                TvContract.Channels.CONTENT_URI,
                                ChannelImpl.PROJECTION,
                                selection,
                                selectionArgs,
                                null)) {
            if (c != null) {
                while (c.moveToNext()) {
                    Channel channel = ChannelImpl.fromCursor(c);
                    if (DEBUG) Log.d(TAG, "Found " + channel);
                    if (myPackageName.equals(channel.getPackageName())) {
                        channels.add(channel);
                    }
                }
            }
        }
        if (DEBUG)
            Log.d(TAG, "Found " + channels.size() + " channels for package " + myPackageName);
        return channels;
    }

    @Override
    @MainThread
    public void startRoutineService() {
        JobScheduler jobScheduler =
                (JobScheduler) mContext.getSystemService(Context.JOB_SCHEDULER_SERVICE);
        for (JobInfo job : jobScheduler.getAllPendingJobs()) {
            if (job.getId() == EPG_ROUTINELY_FETCHING_JOB_ID) {
                return;
            }
        }
        JobInfo job =
                new JobInfo.Builder(
                                EPG_ROUTINELY_FETCHING_JOB_ID,
                                new ComponentName(mContext, EpgFetchService.class))
                        .setPeriodic(getRoutineIntervalMs())
                        .setBackoffCriteria(INITIAL_BACKOFF_MS, JobInfo.BACKOFF_POLICY_EXPONENTIAL)
                        .setPersisted(true)
                        .build();
        jobScheduler.schedule(job);
        Log.i(TAG, "EPG fetching routine service started.");
    }

    @Override
    @MainThread
    public void fetchImmediatelyIfNeeded() {
        if (CommonUtils.isRunningInTest()) {
            // Do not run EpgFetcher in test.
            return;
        }
        new AsyncTask<Void, Void, Long>() {
            @Override
            protected Long doInBackground(Void... args) {
                return EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext);
            }

            @Override
            protected void onPostExecute(Long result) {
                if (mClock.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext)
                        > getEpgDataExpiredTimeLimitMs()) {
                    Log.i(TAG, "EPG data expired. Start fetching immediately.");
                    fetchImmediately();
                }
            }
        }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    @Override
    @MainThread
    public void fetchImmediately() {
        if (DEBUG) Log.d(TAG, "fetchImmediately");
        if (!mChannelDataManager.isDbLoadFinished()) {
            mChannelDataManager.addListener(
                    new ChannelDataManager.Listener() {
                        @Override
                        public void onLoadFinished() {
                            mChannelDataManager.removeListener(this);
                            executeFetchTaskIfPossible(null, null);
                        }

                        @Override
                        public void onChannelListUpdated() {}

                        @Override
                        public void onChannelBrowsableChanged() {}
                    });
        } else {
            executeFetchTaskIfPossible(null, null);
        }
    }

    @Override
    @MainThread
    public void onChannelScanStarted() {
        if (mScanStarted || !TvFeatures.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) {
            return;
        }
        mScanStarted = true;
        stopFetchingJob();
        synchronized (mFetchDuringScanHandlerLock) {
            if (mFetchDuringScanHandler == null) {
                HandlerThread thread = new HandlerThread("EpgFetchDuringScan");
                thread.start();
                mFetchDuringScanHandler = new FetchDuringScanHandler(thread.getLooper());
            }
            mFetchDuringScanHandler.sendEmptyMessage(MSG_PREPARE_FETCH_DURING_SCAN);
        }
        Log.i(TAG, "EPG fetching on channel scanning started.");
    }

    @Override
    @MainThread
    public void onChannelScanFinished() {
        if (!mScanStarted) {
            return;
        }
        mScanStarted = false;
        mFetchDuringScanHandler.sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN);
    }

    @MainThread
    @Override
    public void stopFetchingJob() {
        if (DEBUG) Log.d(TAG, "Try to stop routinely fetching job...");
        if (mFetchTask != null) {
            mFetchTask.cancel(true);
            mFetchTask = null;
            Log.i(TAG, "EPG routinely fetching job stopped.");
        }
    }

    @MainThread
    @Override
    public boolean executeFetchTaskIfPossible(JobService service, JobParameters params) {
        if (DEBUG) Log.d(TAG, "executeFetchTaskIfPossible");
        SoftPreconditions.checkState(mChannelDataManager.isDbLoadFinished());
        if (!CommonUtils.isRunningInTest() && checkFetchPrerequisite()) {
            mFetchTask = createFetchTask(service, params);
            mFetchTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
            return true;
        }
        return false;
    }

    @VisibleForTesting
    FetchAsyncTask createFetchTask(JobService service, JobParameters params) {
        return new FetchAsyncTask(service, params);
    }

    @MainThread
    private boolean checkFetchPrerequisite() {
        if (DEBUG) Log.d(TAG, "Check prerequisite of routinely fetching job.");
        if (!TvFeatures.ENABLE_CLOUD_EPG_REGION.isEnabled(mContext)) {
            Log.i(
                    TAG,
                    "Cannot start routine service: country not supported: "
                            + LocationUtils.getCurrentCountry(mContext));
            return false;
        }
        if (mFetchTask != null) {
            // Fetching job is already running or ready to run, no need to start again.
            return false;
        }
        if (mFetchDuringScanHandler != null) {
            if (DEBUG) Log.d(TAG, "Cannot start routine service: scanning channels.");
            return false;
        }
        if (!TvFeatures.CLOUD_EPG_FOR_3RD_PARTY.isEnabled(mContext)
                && mBuildType != HasBuildType.BuildType.AOSP) {
            if (getTunerChannelCount() == 0) {
                if (DEBUG) Log.d(TAG, "Cannot start routine service: no internal tuner channels.");
                return false;
            }
            if (!TextUtils.isEmpty(EpgFetchHelper.getLastLineupId(mContext))) {
                return true;
            }
            if (!TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) {
                return true;
            }
        }
        return true;
    }

    @MainThread
    private int getTunerChannelCount() {
        for (TvInputInfo input :
                TvSingletons.getSingletons(mContext)
                        .getTvInputManagerHelper()
                        .getTvInputInfos(true, true)) {
            String inputId = input.getId();
            if (Utils.isInternalTvInput(mContext, inputId)) {
                return mChannelDataManager.getChannelCountForInput(inputId);
            }
        }
        return 0;
    }

    @AnyThread
    private void clearUnusedLineups(@Nullable String lineupId) {
        synchronized (mPossibleLineupsLock) {
            if (mPossibleLineups == null) {
                return;
            }
            for (Lineup lineup : mPossibleLineups) {
                if (!TextUtils.equals(lineupId, lineup.getId())) {
                    mEpgReader.clearCachedChannels(lineup.getId());
                }
            }
            mPossibleLineups = null;
        }
    }

    @WorkerThread
    private Integer prepareFetchEpg(boolean forceUpdatePossibleLineups) {
        if (!mEpgReader.isAvailable()) {
            Log.i(TAG, "EPG reader is temporarily unavailable.");
            return REASON_EPG_READER_NOT_READY;
        }
        // Checks the EPG Timestamp.
        mEpgTimeStamp = mEpgReader.getEpgTimestamp();
        if (mEpgTimeStamp <= EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext)) {
            if (DEBUG) Log.d(TAG, "No new EPG.");
            return REASON_NO_NEW_EPG;
        }
        // Updates postal code.
        boolean postalCodeChanged = false;
        try {
            postalCodeChanged = PostalCodeUtils.updatePostalCode(mContext);
        } catch (IOException e) {
            if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e);
            if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) {
                return REASON_LOCATION_INFO_UNAVAILABLE;
            }
        } catch (SecurityException e) {
            Log.w(TAG, "No permission to get the current location.");
            if (TextUtils.isEmpty(PostalCodeUtils.getLastPostalCode(mContext))) {
                return REASON_LOCATION_PERMISSION_NOT_GRANTED;
            }
        } catch (PostalCodeUtils.NoPostalCodeException e) {
            Log.i(TAG, "Cannot get address or postal code.");
            return REASON_LOCATION_INFO_UNAVAILABLE;
        }
        // Updates possible lineups if necessary.
        SoftPreconditions.checkState(mPossibleLineups == null, TAG, "Possible lineups not reset.");
        if (postalCodeChanged
                || forceUpdatePossibleLineups
                || EpgFetchHelper.getLastLineupId(mContext) == null) {
            // To prevent main thread being blocked, though theoretically it should not happen.
            String lastPostalCode = PostalCodeUtils.getLastPostalCode(mContext);
            List<Lineup> possibleLineups = mEpgReader.getLineups(lastPostalCode);
            if (possibleLineups.isEmpty()) {
                Log.i(TAG, "No lineups found for " + lastPostalCode);
                return REASON_NO_EPG_DATA_RETURNED;
            }
            for (Lineup lineup : possibleLineups) {
                mEpgReader.preloadChannels(lineup.getId());
            }
            synchronized (mPossibleLineupsLock) {
                mPossibleLineups = possibleLineups;
            }
            EpgFetchHelper.setLastLineupId(mContext, null);
        }
        return null;
    }

    @WorkerThread
    private void batchFetchEpg(Set<EpgReader.EpgChannel> epgChannels, long durationSec) {
        Log.i(TAG, "Start batch fetching (" + durationSec + ")...." + epgChannels.size());
        if (epgChannels.size() == 0) {
            return;
        }
        int batchSize = (int) Math.max(1, mBackendKnobsFlags.epgFetcherChannelsPerProgramFetch());
        for (Iterable<EpgChannel> batch : Iterables.partition(epgChannels, batchSize)) {
            batchUpdateEpg(mEpgReader.getPrograms(ImmutableSet.copyOf(batch), durationSec));
        }
    }

    @WorkerThread
    private void batchUpdateEpg(Map<EpgReader.EpgChannel, Collection<Program>> allPrograms) {
        for (Map.Entry<EpgReader.EpgChannel, Collection<Program>> entry : allPrograms.entrySet()) {
            List<Program> programs = new ArrayList<>(entry.getValue());
            if (programs == null) {
                continue;
            }
            Collections.sort(programs);
            Log.i(
                    TAG,
                    "Batch fetched " + programs.size() + " programs for channel " + entry.getKey());
            EpgFetchHelper.updateEpgData(
                    mContext, mClock, entry.getKey().getChannel().getId(), programs);
        }
    }

    @Nullable
    @WorkerThread
    private String pickBestLineupId(Set<Channel> currentChannels) {
        String maxLineupId = null;
        synchronized (mPossibleLineupsLock) {
            if (mPossibleLineups == null) {
                return null;
            }
            int maxCount = 0;
            for (Lineup lineup : mPossibleLineups) {
                int count = getMatchedChannelCount(lineup.getId(), currentChannels);
                Log.i(TAG, lineup.getName() + " (" + lineup.getId() + ") - " + count + " matches");
                if (count > maxCount) {
                    maxCount = count;
                    maxLineupId = lineup.getId();
                }
            }
        }
        return maxLineupId;
    }

    @WorkerThread
    private int getMatchedChannelCount(String lineupId, Set<Channel> currentChannels) {
        // Construct a list of display numbers for existing channels.
        if (currentChannels.isEmpty()) {
            if (DEBUG) Log.d(TAG, "No existing channel to compare");
            return 0;
        }
        List<String> numbers = new ArrayList<>(currentChannels.size());
        for (Channel channel : currentChannels) {
            // We only support channels from internal tuner inputs.
            if (Utils.isInternalTvInput(mContext, channel.getInputId())) {
                numbers.add(channel.getDisplayNumber());
            }
        }
        numbers.retainAll(mEpgReader.getChannelNumbers(lineupId));
        return numbers.size();
    }

    private boolean isInputInWhiteList(EpgInput epgInput) {
        if (mBuildType == HasBuildType.BuildType.AOSP) {
            return false;
        }
        return (BuildConfig.ENG
                        && epgInput.getInputId()
                                .equals(
                                        "com.example.partnersupportsampletvinput/.SampleTvInputService"))
                || mEpgInputWhiteList.isInputWhiteListed(epgInput.getInputId());
    }

    @VisibleForTesting
    class FetchAsyncTask extends AsyncTask<Void, Void, Integer> {
        private final JobService mService;
        private final JobParameters mParams;
        private Set<Channel> mCurrentChannels;
        private TimerEvent mTimerEvent;

        private FetchAsyncTask(JobService service, JobParameters params) {
            mService = service;
            mParams = params;
        }

        @Override
        protected void onPreExecute() {
            mTimerEvent = mPerformanceMonitor.startTimer();
            mCurrentChannels = new HashSet<>(mChannelDataManager.getChannelList());
        }

        @Override
        protected Integer doInBackground(Void... args) {
            final int oldTag = TrafficStats.getThreadStatsTag();
            TrafficStats.setThreadStatsTag(NetworkTrafficTags.EPG_FETCH);
            try {
                if (DEBUG) Log.d(TAG, "Start EPG routinely fetching.");
                Integer builtInResult = fetchEpgForBuiltInTuner();
                boolean anyCloudEpgFailure = false;
                boolean anyCloudEpgSuccess = false;
                if (TvFeatures.CLOUD_EPG_FOR_3RD_PARTY.isEnabled(mContext)
                        && mBuildType != HasBuildType.BuildType.AOSP) {
                    for (EpgInput epgInput : getEpgInputs()) {
                        if (DEBUG) Log.d(TAG, "Start EPG fetch for " + epgInput);
                        if (isCancelled()) {
                            break;
                        }
                        if (isInputInWhiteList(epgInput)) {
                            // TODO(b/66191312) check timestamp and result code and decide if update
                            // is needed.
                            Set<Channel> channels = getExistingChannelsFor(epgInput.getInputId());
                            Integer result = fetchEpgFor(epgInput.getLineupId(), channels);
                            anyCloudEpgFailure = anyCloudEpgFailure || result != null;
                            anyCloudEpgSuccess = anyCloudEpgSuccess || result == null;
                            updateCloudEpgInput(epgInput, result);
                        } else {
                            Log.w(
                                    TAG,
                                    "Fetching the EPG for "
                                            + epgInput.getInputId()
                                            + " is not supported.");
                        }
                    }
                }
                if (builtInResult == null || builtInResult == REASON_NO_BUILT_IN_CHANNELS) {
                    return anyCloudEpgFailure
                            ? ((Integer) REASON_CLOUD_EPG_FAILURE)
                            : anyCloudEpgSuccess ? null : builtInResult;
                }
                clearUnusedLineups(null);
                return builtInResult;
            } finally {
                TrafficStats.setThreadStatsTag(oldTag);
            }
        }

        private void updateCloudEpgInput(EpgInput unusedEpgInput, Integer unusedResult) {
            // TODO(b/66191312) write the result and timestamp to the input table
        }

        private Set<Channel> getExistingChannelsFor(String inputId) {
            Set<Channel> result = new HashSet<>();
            try (Cursor cursor =
                    mContext.getContentResolver()
                            .query(
                                    TvContract.buildChannelsUriForInput(inputId),
                                    ChannelImpl.PROJECTION,
                                    null,
                                    null,
                                    null)) {
                if (cursor != null) {
                    while (cursor.moveToNext()) {
                        result.add(ChannelImpl.fromCursor(cursor));
                    }
                }
                return result;
            }
        }

        private Set<EpgInput> getEpgInputs() {
            if (mBuildType == HasBuildType.BuildType.AOSP) {
                return ImmutableSet.of();
            }
            Set<EpgInput> epgInputs = EpgInputs.queryEpgInputs(mContext.getContentResolver());
            if (DEBUG) Log.d(TAG, "getEpgInputs " + epgInputs);
            return epgInputs;
        }

        private Integer fetchEpgForBuiltInTuner() {
            try {
                Integer failureReason = prepareFetchEpg(false);
                // InterruptedException might be caught by RPC, we should check it here.
                if (failureReason != null || this.isCancelled()) {
                    return failureReason;
                }
                String lineupId = EpgFetchHelper.getLastLineupId(mContext);
                lineupId = lineupId == null ? pickBestLineupId(mCurrentChannels) : lineupId;
                if (lineupId != null) {
                    Log.i(TAG, "Selecting the lineup " + lineupId);
                    // During normal fetching process, the lineup ID should be confirmed since all
                    // channels are known, clear up possible lineups to save resources.
                    EpgFetchHelper.setLastLineupId(mContext, lineupId);
                    clearUnusedLineups(lineupId);
                } else {
                    Log.i(TAG, "Failed to get lineup id");
                    return REASON_NO_EPG_DATA_RETURNED;
                }
                Set<Channel> existingChannelsForMyPackage =
                        getExistingChannelsForMyPackage(mContext);
                if (existingChannelsForMyPackage.isEmpty()) {
                    return REASON_NO_BUILT_IN_CHANNELS;
                }
                return fetchEpgFor(lineupId, existingChannelsForMyPackage);
            } catch (Exception e) {
                Log.w(TAG, "Failed to update EPG for builtin tuner", e);
                return REASON_ERROR;
            }
        }

        @Nullable
        private Integer fetchEpgFor(String lineupId, Set<Channel> existingChannels) {
            if (DEBUG) {
                Log.d(
                        TAG,
                        "Starting Fetching EPG is for "
                                + lineupId
                                + " with  channelCount "
                                + existingChannels.size());
            }
            final Set<EpgReader.EpgChannel> channels =
                    mEpgReader.getChannels(existingChannels, lineupId);
            // InterruptedException might be caught by RPC, we should check it here.
            if (this.isCancelled()) {
                return null;
            }
            if (channels.isEmpty()) {
                Log.i(TAG, "Failed to get EPG channels for " + lineupId);
                return REASON_NO_EPG_DATA_RETURNED;
            }
            EpgFetchHelper.updateNetworkAffiliation(mContext, channels);
            if (mClock.currentTimeMillis() - EpgFetchHelper.getLastEpgUpdatedTimestamp(mContext)
                    > getEpgDataExpiredTimeLimitMs()) {
                batchFetchEpg(channels, getFastFetchDurationSec());
            }
            new Handler(mContext.getMainLooper())
                    .post(
                            () ->
                                    ChannelLogoFetcher.startFetchingChannelLogos(
                                            mContext, asChannelList(channels)));
            for (EpgReader.EpgChannel epgChannel : channels) {
                if (this.isCancelled()) {
                    return null;
                }
                List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(epgChannel));
                // InterruptedException might be caught by RPC, we should check it here.
                Collections.sort(programs);
                Log.i(
                        TAG,
                        "Fetched "
                                + programs.size()
                                + " programs for channel "
                                + epgChannel.getChannel());
                EpgFetchHelper.updateEpgData(
                        mContext, mClock, epgChannel.getChannel().getId(), programs);
            }
            EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, mEpgTimeStamp);
            if (DEBUG) Log.d(TAG, "Fetching EPG is for " + lineupId);
            return null;
        }

        @Override
        protected void onPostExecute(Integer failureReason) {
            mFetchTask = null;
            if (failureReason == null
                    || failureReason == REASON_LOCATION_PERMISSION_NOT_GRANTED
                    || failureReason == REASON_NO_NEW_EPG) {
                jobFinished(false);
            } else {
                // Applies back-off policy
                jobFinished(true);
            }
            mPerformanceMonitor.stopTimer(mTimerEvent, EventNames.FETCH_EPG_TASK);
            mPerformanceMonitor.recordMemory(EventNames.FETCH_EPG_TASK);
        }

        @Override
        protected void onCancelled(Integer failureReason) {
            clearUnusedLineups(null);
            jobFinished(false);
        }

        private void jobFinished(boolean reschedule) {
            if (mService != null && mParams != null) {
                // Task is executed from JobService, need to report jobFinished.
                mService.jobFinished(mParams, reschedule);
            }
        }
    }

    private List<Channel> asChannelList(Set<EpgReader.EpgChannel> epgChannels) {
        List<Channel> result = new ArrayList<>(epgChannels.size());
        for (EpgReader.EpgChannel epgChannel : epgChannels) {
            result.add(epgChannel.getChannel());
        }
        return result;
    }

    @WorkerThread
    private class FetchDuringScanHandler extends Handler {
        private final Set<Long> mFetchedChannelIdsDuringScan = new HashSet<>();
        private String mPossibleLineupId;

        private final ChannelDataManager.Listener mDuringScanChannelListener =
                new ChannelDataManager.Listener() {
                    @Override
                    public void onLoadFinished() {
                        if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()");
                        if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP
                                && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) {
                            Message.obtain(
                                            FetchDuringScanHandler.this,
                                            MSG_CHANNEL_UPDATED_DURING_SCAN,
                                            getExistingChannelsForMyPackage(mContext))
                                    .sendToTarget();
                        }
                    }

                    @Override
                    public void onChannelListUpdated() {
                        if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()");
                        if (getTunerChannelCount() >= MINIMUM_CHANNELS_TO_DECIDE_LINEUP
                                && !hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) {
                            Message.obtain(
                                            FetchDuringScanHandler.this,
                                            MSG_CHANNEL_UPDATED_DURING_SCAN,
                                            getExistingChannelsForMyPackage(mContext))
                                    .sendToTarget();
                        }
                    }

                    @Override
                    public void onChannelBrowsableChanged() {
                        // Do nothing
                    }
                };

        @AnyThread
        private FetchDuringScanHandler(Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MSG_PREPARE_FETCH_DURING_SCAN:
                case MSG_RETRY_PREPARE_FETCH_DURING_SCAN:
                    onPrepareFetchDuringScan();
                    break;
                case MSG_CHANNEL_UPDATED_DURING_SCAN:
                    if (!hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) {
                        onChannelUpdatedDuringScan((Set<Channel>) msg.obj);
                    }
                    break;
                case MSG_FINISH_FETCH_DURING_SCAN:
                    removeMessages(MSG_RETRY_PREPARE_FETCH_DURING_SCAN);
                    if (hasMessages(MSG_CHANNEL_UPDATED_DURING_SCAN)) {
                        sendEmptyMessage(MSG_FINISH_FETCH_DURING_SCAN);
                    } else {
                        onFinishFetchDuringScan();
                    }
                    break;
                default:
                    // do nothing
            }
        }

        private void onPrepareFetchDuringScan() {
            Integer failureReason = prepareFetchEpg(true);
            if (failureReason != null) {
                sendEmptyMessageDelayed(
                        MSG_RETRY_PREPARE_FETCH_DURING_SCAN, FETCH_DURING_SCAN_WAIT_TIME_MS);
                return;
            }
            mChannelDataManager.addListener(mDuringScanChannelListener);
        }

        private void onChannelUpdatedDuringScan(Set<Channel> currentChannels) {
            String lineupId = pickBestLineupId(currentChannels);
            Log.i(TAG, "Fast fetch channels for lineup ID: " + lineupId);
            if (TextUtils.isEmpty(lineupId)) {
                if (TextUtils.isEmpty(mPossibleLineupId)) {
                    return;
                }
            } else if (!TextUtils.equals(lineupId, mPossibleLineupId)) {
                mFetchedChannelIdsDuringScan.clear();
                mPossibleLineupId = lineupId;
            }
            List<Long> currentChannelIds = new ArrayList<>();
            for (Channel channel : currentChannels) {
                currentChannelIds.add(channel.getId());
            }
            mFetchedChannelIdsDuringScan.retainAll(currentChannelIds);
            Set<EpgReader.EpgChannel> newChannels = new HashSet<>();
            for (EpgReader.EpgChannel epgChannel :
                    mEpgReader.getChannels(currentChannels, mPossibleLineupId)) {
                if (!mFetchedChannelIdsDuringScan.contains(epgChannel.getChannel().getId())) {
                    newChannels.add(epgChannel);
                    mFetchedChannelIdsDuringScan.add(epgChannel.getChannel().getId());
                }
            }
            if (!newChannels.isEmpty()) {
                EpgFetchHelper.updateNetworkAffiliation(mContext, newChannels);
            }
            batchFetchEpg(newChannels, FETCH_DURING_SCAN_DURATION_SEC);
        }

        private void onFinishFetchDuringScan() {
            mChannelDataManager.removeListener(mDuringScanChannelListener);
            EpgFetchHelper.setLastLineupId(mContext, mPossibleLineupId);
            clearUnusedLineups(null);
            mFetchedChannelIdsDuringScan.clear();
            synchronized (mFetchDuringScanHandlerLock) {
                if (!hasMessages(MSG_PREPARE_FETCH_DURING_SCAN)) {
                    removeCallbacksAndMessages(null);
                    getLooper().quit();
                    mFetchDuringScanHandler = null;
                }
            }
            // Clear timestamp to make routine service start right away.
            EpgFetchHelper.setLastEpgUpdatedTimestamp(mContext, 0);
            Log.i(TAG, "EPG Fetching during channel scanning finished.");
            new Handler(Looper.getMainLooper()).post(EpgFetcherImpl.this::fetchImmediately);
        }
    }
}
