// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.android_webview.services;

import android.app.Service;
import android.app.job.JobInfo;
import android.app.job.JobScheduler;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.os.ResultReceiver;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.android_webview.common.services.ServiceNames;
import org.chromium.base.ContextUtils;
import org.chromium.base.FileUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.components.background_task_scheduler.TaskIds;
import org.chromium.components.component_updater.IComponentsProviderService;

import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;

/**
 * A Service to fetch Components files in WebView and WebLayer.
 */
public class ComponentsProviderService extends Service {
    // Result receiver constants.
    public static final int RESULT_OK = 0;
    public static final int RESULT_FAILED = 1;
    public static final String KEY_RESULT = "RESULT";

    // Histogram names.
    public static final String HISTOGRAM_GET_FILES_RESULT =
            "Android.WebView.ComponentUpdater.GetFilesResult";
    public static final String HISTOGRAM_GET_FILES_DURATION =
            "Android.WebView.ComponentUpdater.GetFilesDuration";

    private static final String TAG = "AW_CPS";

    // This should be greater than or equal to the native component updater service interval.
    @VisibleForTesting
    public static final long UPDATE_INTERVAL_MS = TimeUnit.HOURS.toMillis(5);
    private static final int JOB_ID = TaskIds.WEBVIEW_COMPONENT_UPDATE_JOB_ID;
    private static final int JOB_BACKOFF_POLICY = JobInfo.BACKOFF_POLICY_EXPONENTIAL;
    private static final long JOB_INITIAL_BACKOFF_TIME_IN_MS = TimeUnit.MINUTES.toMillis(5);

    private static final String SHARED_PREFERENCES_NAME = "ComponentsProviderServicePreferences";
    private static final String LAST_SCHEDULED_UPDATE_JOB_TIME = "last_scheduled_update_job_time";

    // UMA constants. These values are persisted to logs. Entries can't be reordered and numbers
    // can't be reused.
    @IntDef({GetFilesResultCode.SUCCESS, GetFilesResultCode.FAILED_NOT_INSTALLED,
            GetFilesResultCode.FAILED_NO_VERSIONS, GetFilesResultCode.FAILED_NO_FDS,
            GetFilesResultCode.FAILED_OPENING_FDS,
            GetFilesResultCode.FAILED_COMPONENT_UPDATER_SAFEMODE_ENABLED})
    private @interface GetFilesResultCode {
        int SUCCESS = 0;
        int FAILED_NOT_INSTALLED = 1;
        int FAILED_NO_VERSIONS = 2;
        int FAILED_NO_FDS = 3;
        int FAILED_OPENING_FDS = 4;
        int FAILED_COMPONENT_UPDATER_SAFEMODE_ENABLED = 5;
        // Keep this one at the end and increment appropriately when adding new entries.
        int COUNT = 6;
    }

    /**
     * A mockable clock. Returns the current time in ms since the unix epoch. For reference, the
     * default implementation is {@code System.currentTimeMillis()}.
     */
    @VisibleForTesting
    public static interface Clock {
        long currentTimeMillis();
    }
    private static Clock sClockForTesting;

    private File mDirectory;
    private FutureTask<Void> mDeleteTask;

    private final IBinder mBinder = new IComponentsProviderService.Stub() {
        @Override
        public void getFilesForComponent(String componentId, ResultReceiver resultReceiver) {
            final long startTime = System.currentTimeMillis();

            if (ComponentUpdaterSafeModeUtils.executeSafeModeIfEnabled(mDirectory)) {
                Log.w(TAG,
                        "Component Updater Reset Mode enabled. Not handing out configs. "
                                + componentId);
                resultReceiver.send(RESULT_FAILED, /* resultData = */ null);
                recordGetFilesResultAndDuration(
                        GetFilesResultCode.FAILED_COMPONENT_UPDATER_SAFEMODE_ENABLED, startTime);
                return;
            }

            // Note that there's no need to sanitize input because this method will check if there
            // is an existing folder under `mDirectory` with a name that equals the received
            // `componentId`. Because `mDirectory` is inside this application's data dir, only
            // WebView can modify it.
            final File[] components = mDirectory.listFiles((dir, name) -> name.equals(componentId));
            if (components == null || components.length == 0) {
                resultReceiver.send(RESULT_FAILED, /* resultData = */ null);
                recordGetFilesResultAndDuration(GetFilesResultCode.FAILED_NOT_INSTALLED, startTime);
                return;
            }
            assert components.length
                    == 1 : "Only one directory should have the name " + componentId;

            final File[] versions =
                    ComponentsProviderPathUtil.getComponentsNewestFirst(components[0]);
            if (versions == null || versions.length == 0) {
                // This can happen if CUS created a parent directory but was killed before it could
                // move content into it. In this case there's nothing old to delete.
                resultReceiver.send(RESULT_FAILED, /* resultData = */ null);
                recordGetFilesResultAndDuration(GetFilesResultCode.FAILED_NO_VERSIONS, startTime);
                return;
            }
            final File versionDirectory = versions[0];

            final HashMap<String, ParcelFileDescriptor> resultMap = new HashMap<>();
            try {
                recursivelyGetParcelFileDescriptors(
                        versionDirectory, versionDirectory.getAbsolutePath() + "/", resultMap);

                if (resultMap.isEmpty()) {
                    Log.w(TAG, "No file descriptors found for " + componentId);
                    resultReceiver.send(RESULT_FAILED, /* resultData = */ null);
                    recordGetFilesResultAndDuration(GetFilesResultCode.FAILED_NO_FDS, startTime);
                    return;
                }

                final Bundle resultData = new Bundle();
                resultData.putSerializable(KEY_RESULT, resultMap);
                resultReceiver.send(RESULT_OK, resultData);
                recordGetFilesResultAndDuration(GetFilesResultCode.SUCCESS, startTime);
            } catch (IOException exception) {
                Log.w(TAG, exception.getMessage(), exception);
                resultReceiver.send(RESULT_FAILED, /* resultData = */ null);
                recordGetFilesResultAndDuration(GetFilesResultCode.FAILED_OPENING_FDS, startTime);
            } finally {
                closeFileDescriptors(resultMap);
            }
        }
    };

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

    @Override
    public void onCreate() {
        mDirectory = new File(ComponentsProviderPathUtil.getComponentsServingDirectoryPath());
        if (ComponentUpdaterSafeModeUtils.executeSafeModeIfEnabled(mDirectory)) {
            JobScheduler jobScheduler =
                    (JobScheduler) ContextUtils.getApplicationContext().getSystemService(
                            Context.JOB_SCHEDULER_SERVICE);
            jobScheduler.cancel(JOB_ID);
            return;
        }

        if (!mDirectory.exists() && !mDirectory.mkdirs()) {
            Log.e(TAG, "Failed to create directory " + mDirectory.getAbsolutePath());
            return;
        }

        cleanupOlderFiles();
        maybeScheduleComponentUpdateService();
    }

    private void cleanupOlderFiles() {
        final File[] components = mDirectory.listFiles();
        if (components == null || components.length == 0) {
            return;
        }
        final List<File> oldFiles = new LinkedList<>();
        for (File component : components) {
            final File[] versions = ComponentsProviderPathUtil.getComponentsNewestFirst(component);
            if (versions == null || versions.length == 0) {
                // This can happen if CUS created a parent directory but was killed before it could
                // move content into it. In this case there's nothing old to delete.
                continue;
            }
            // Add all directories except the newest (index 0) to oldFiles.
            oldFiles.addAll(Arrays.asList(versions).subList(1, versions.length));
        }

        // Delete old files in background.
        mDeleteTask = new FutureTask<>(() -> {
            for (File file : oldFiles) {
                if (!FileUtils.recursivelyDeleteFile(file, null)) {
                    Log.w(TAG, "Failed to delete " + file.getAbsolutePath());
                }
            }
            return null;
        });
        PostTask.postTask(TaskTraits.BEST_EFFORT, mDeleteTask);
    }

    /**
     * This must be called after {@code onCreate()}, otherwise returns a {@code null} object.
     */
    public Future<Void> getDeleteTaskForTesting() {
        return mDeleteTask;
    }

    private void recursivelyGetParcelFileDescriptors(File file, String pathPrefix,
            HashMap<String, ParcelFileDescriptor> resultMap) throws IOException {
        if (file.isDirectory()) {
            File[] files = file.listFiles();
            if (files != null) {
                for (File f : files) {
                    recursivelyGetParcelFileDescriptors(f, pathPrefix, resultMap);
                }
            }
        } else {
            resultMap.put(file.getAbsolutePath().replace(pathPrefix, ""),
                    ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY));
        }
    }

    private void closeFileDescriptors(HashMap<String, ParcelFileDescriptor> map) {
        assert map != null : "closeFileDescriptors called with a null map";

        for (ParcelFileDescriptor fileDescriptor : map.values()) {
            try {
                fileDescriptor.close();
            } catch (IOException exception) {
                Log.w(TAG, exception.getMessage());
            }
        }
    }

    /**
     * Schedule an update job if no job is currently scheduled, and the last time the job was
     * scheduled was more than UPDATE_INTERVAL_MS ago.
     */
    @VisibleForTesting
    public static void maybeScheduleComponentUpdateService() {
        Context context = ContextUtils.getApplicationContext();
        JobScheduler jobScheduler =
                (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);

        if (isJobScheduled(jobScheduler, JOB_ID)) {
            return;
        }

        // TODO(crbug.com/1256948): schedule it as a periodic job.
        final SharedPreferences sharedPreferences =
                ContextUtils.getApplicationContext().getSharedPreferences(
                        SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
        long currentTime = sClockForTesting != null ? sClockForTesting.currentTimeMillis()
                                                    : System.currentTimeMillis();
        long lastJobScheduleTime = sharedPreferences.getLong(LAST_SCHEDULED_UPDATE_JOB_TIME, 0L);
        if (lastJobScheduleTime + UPDATE_INTERVAL_MS > currentTime) {
            return;
        }

        SharedPreferences.Editor editor = sharedPreferences.edit();
        editor.putLong(LAST_SCHEDULED_UPDATE_JOB_TIME, currentTime);
        editor.apply();

        ComponentName componentName =
                new ComponentName(context, ServiceNames.AW_COMPONENT_UPDATE_SERVICE);
        JobInfo.Builder jobBuilder =
                new JobInfo.Builder(JOB_ID, componentName)
                        .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
                        .setBackoffCriteria(JOB_INITIAL_BACKOFF_TIME_IN_MS, JOB_BACKOFF_POLICY);

        if (jobScheduler.schedule(jobBuilder.build()) != JobScheduler.RESULT_SUCCESS) {
            Log.e(TAG, "Failed to schedule job for AwComponentUpdateService");
        }
    }

    // TODO(crbug.com/1189126): move this to utils class
    @VisibleForTesting
    public static boolean isJobScheduled(JobScheduler scheduler, int jobId) {
        return scheduler.getPendingJob(jobId) != null;
    }

    public static void setClockForTesting(Clock clock) {
        sClockForTesting = clock;
        ResettersForTesting.register(() -> sClockForTesting = null);
    }

    public static void clearSharedPrefsForTesting() {
        ContextUtils.getApplicationContext()
                .getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)
                .edit()
                .clear()
                .apply();
    }

    private void recordGetFilesResultAndDuration(@GetFilesResultCode int result, long startTime) {
        RecordHistogram.recordEnumeratedHistogram(
                HISTOGRAM_GET_FILES_RESULT, result, GetFilesResultCode.COUNT);
        RecordHistogram.recordTimesHistogram(
                HISTOGRAM_GET_FILES_DURATION, System.currentTimeMillis() - startTime);
    }
}
