/*
 * Copyright (C) 2023 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.devicelockcontroller.policy;

import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
import static android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED;

import static androidx.work.WorkInfo.State.CANCELLED;
import static androidx.work.WorkInfo.State.FAILED;
import static androidx.work.WorkInfo.State.SUCCEEDED;

import static com.android.devicelockcontroller.common.DeviceLockConstants.EXTRA_KIOSK_PACKAGE;
import static com.android.devicelockcontroller.policy.ProvisionStateController.ProvisionEvent.PROVISION_KIOSK;
import static com.android.devicelockcontroller.policy.ProvisionStateController.ProvisionEvent.PROVISION_PAUSE;
import static com.android.devicelockcontroller.provision.worker.IsDeviceInApprovedCountryWorker.KEY_IS_IN_APPROVED_COUNTRY;

import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager.NameNotFoundException;
import android.database.sqlite.SQLiteException;
import android.net.NetworkRequest;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.LifecycleOwner;
import androidx.work.BackoffPolicy;
import androidx.work.Constraints;
import androidx.work.Data;
import androidx.work.ExistingWorkPolicy;
import androidx.work.ListenableWorker;
import androidx.work.NetworkType;
import androidx.work.OneTimeWorkRequest;
import androidx.work.Operation;
import androidx.work.OutOfQuotaPolicy;
import androidx.work.WorkInfo;
import androidx.work.WorkManager;
import androidx.work.WorkRequest;

import com.android.devicelockcontroller.PlayInstallPackageTaskClassProvider;
import com.android.devicelockcontroller.activities.DeviceLockNotificationManager;
import com.android.devicelockcontroller.activities.ProvisioningProgress;
import com.android.devicelockcontroller.activities.ProvisioningProgressController;
import com.android.devicelockcontroller.common.DeviceLockConstants.ProvisionFailureReason;
import com.android.devicelockcontroller.provision.worker.IsDeviceInApprovedCountryWorker;
import com.android.devicelockcontroller.provision.worker.PauseProvisioningWorker;
import com.android.devicelockcontroller.provision.worker.ReportDeviceProvisionStateWorker;
import com.android.devicelockcontroller.receivers.ResumeProvisionReceiver;
import com.android.devicelockcontroller.schedule.DeviceLockControllerScheduler;
import com.android.devicelockcontroller.schedule.DeviceLockControllerSchedulerProvider;
import com.android.devicelockcontroller.stats.StatsLogger;
import com.android.devicelockcontroller.stats.StatsLoggerProvider;
import com.android.devicelockcontroller.storage.GlobalParametersClient;
import com.android.devicelockcontroller.storage.SetupParametersClient;
import com.android.devicelockcontroller.util.LogUtil;

import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

/**
 * An implementation of {@link ProvisionHelper}.
 */
public final class ProvisionHelperImpl implements ProvisionHelper {
    private static final String TAG = "ProvisionHelperImpl";
    private static final String FILENAME = "device-lock-controller-provisioning-preferences";
    private static final String USE_PREINSTALLED_KIOSK_PREF =
            "debug.devicelock.usepreinstalledkiosk";
    private static volatile SharedPreferences sSharedPreferences;
    // For Play Install exponential backoff due to Play being updated, use a short delay of
    // 10 seconds since the situation should resolve relatively quickly.
    private static final Duration PLAY_INSTALL_BACKOFF_DELAY =
            Duration.ofMillis(WorkRequest.MIN_BACKOFF_MILLIS);
    private static final long IS_DEVICE_IN_APPROVED_COUNTRY_NETWORK_TIMEOUT_MS = 60_000;

    @VisibleForTesting
    static synchronized SharedPreferences getSharedPreferences(Context context) {
        if (sSharedPreferences == null) {
            sSharedPreferences = context.createDeviceProtectedStorageContext().getSharedPreferences(
                    FILENAME, Context.MODE_PRIVATE);
        }
        return sSharedPreferences;
    }

    private final Context mContext;
    private final ProvisionStateController mStateController;
    private final Executor mExecutor;
    private final DeviceLockControllerScheduler mScheduler;

    public ProvisionHelperImpl(Context context, ProvisionStateController stateController) {
        this(context, stateController, Executors.newCachedThreadPool());
    }

    @VisibleForTesting
    ProvisionHelperImpl(Context context, ProvisionStateController stateController,
            Executor executor) {
        mContext = context;
        mStateController = stateController;
        DeviceLockControllerSchedulerProvider schedulerProvider =
                (DeviceLockControllerSchedulerProvider) mContext.getApplicationContext();
        mScheduler = schedulerProvider.getDeviceLockControllerScheduler();
        mExecutor = executor;
    }

    @Override
    public void pauseProvision() {
        Futures.addCallback(Futures.transformAsync(
                        GlobalParametersClient.getInstance().setProvisionForced(true),
                        unused -> mStateController.setNextStateForEvent(PROVISION_PAUSE),
                        mExecutor),
                new FutureCallback<>() {
                    @Override
                    public void onSuccess(Void unused) {
                        createNotification();
                        WorkManager workManager = WorkManager.getInstance(mContext);
                        PauseProvisioningWorker.reportProvisionPausedByUser(workManager);
                        mScheduler.scheduleResumeProvisionAlarm();
                    }

                    @Override
                    public void onFailure(Throwable t) {
                        throw new RuntimeException("Failed to delay setup", t);
                    }
                }, mExecutor);
    }

    @Override
    public void scheduleKioskAppInstallation(LifecycleOwner owner,
            ProvisioningProgressController progressController, boolean isMandatory) {
        LogUtil.v(TAG, "Schedule installation work");
        progressController.setProvisioningProgress(ProvisioningProgress.GETTING_DEVICE_READY);
        WorkManager workManager = WorkManager.getInstance(mContext);
        OneTimeWorkRequest isDeviceInApprovedCountryWork = getIsDeviceInApprovedCountryWork();

        final ListenableFuture<Operation.State.SUCCESS> enqueueResult =
                workManager.enqueueUniqueWork(IsDeviceInApprovedCountryWorker.class.getSimpleName(),
                ExistingWorkPolicy.REPLACE, isDeviceInApprovedCountryWork).getResult();
        Futures.addCallback(enqueueResult, new FutureCallback<Operation.State.SUCCESS>() {
                    @Override
                    public void onSuccess(Operation.State.SUCCESS result) {
                        // Enqueued
                    }

                    @Override
                    public void onFailure(Throwable t) {
                        LogUtil.e(TAG, "Failed to enqueue 'device in approved country' work",
                                t);
                        if (t instanceof SQLiteException) {
                            mStateController.getDevicePolicyController().wipeDevice();
                        } else {
                            LogUtil.e(TAG, "Not wiping device (non SQL exception)");
                        }
                    }
                }, mExecutor);

        FutureCallback<String> isInApprovedCountryCallback = new FutureCallback<>() {
            @Override
            public void onSuccess(String kioskPackage) {
                progressController.setProvisioningProgress(
                        ProvisioningProgress.INSTALLING_KIOSK_APP);
                if (getPreinstalledKioskAllowed(mContext)) {
                    try {
                        mContext.getPackageManager().getPackageInfo(kioskPackage,
                                ApplicationInfo.FLAG_INSTALLED);
                        LogUtil.i(TAG, "Kiosk app is pre-installed");
                        progressController.setProvisioningProgress(
                                ProvisioningProgress.OPENING_KIOSK_APP);
                        ReportDeviceProvisionStateWorker.reportSetupCompleted(workManager);
                        mStateController.postSetNextStateForEventRequest(PROVISION_KIOSK);
                    } catch (NameNotFoundException e) {
                        LogUtil.i(TAG, "Kiosk app is not pre-installed");
                        installFromPlay(owner, kioskPackage, isMandatory, progressController);
                    }
                } else {
                    installFromPlay(owner, kioskPackage, isMandatory, progressController);
                }
            }

            @Override
            public void onFailure(Throwable t) {
                LogUtil.w(TAG, "Failed to install kiosk app!", t);
                handleFailure(ProvisionFailureReason.PLAY_INSTALLATION_FAILED, isMandatory,
                        progressController);
            }
        };

        UUID isDeviceInApprovedCountryWorkId = isDeviceInApprovedCountryWork.getId();
        workManager.getWorkInfoByIdLiveData(isDeviceInApprovedCountryWorkId)
                .observe(owner, workInfo -> {
                    if (workInfo == null) return;
                    WorkInfo.State state = workInfo.getState();
                    LogUtil.d(TAG, "WorkInfo changed: " + workInfo);
                    if (state == SUCCEEDED) {
                        if (workInfo.getOutputData().getBoolean(KEY_IS_IN_APPROVED_COUNTRY,
                                false)) {
                            Futures.addCallback(
                                    SetupParametersClient.getInstance().getKioskPackage(),
                                    isInApprovedCountryCallback, mExecutor);
                        } else {
                            LogUtil.i(TAG, "Not in eligible country");
                            handleFailure(ProvisionFailureReason.NOT_IN_ELIGIBLE_COUNTRY,
                                    isMandatory, progressController);
                        }
                    } else if (state == FAILED || state == CANCELLED) {
                        LogUtil.w(TAG, "Failed to get country eligibility!");
                        handleFailure(ProvisionFailureReason.COUNTRY_INFO_UNAVAILABLE, isMandatory,
                                progressController);
                    }
                });

        // If the network is not available while checking if the device is in an approved country,
        // wait for a finite amount of time for the network to come back up, to avoid blocking
        // indefinitely.
        Handler handler = new Handler(Looper.getMainLooper());
        handler.postDelayed(() -> {
            ListenableFuture<WorkInfo> workInfoFuture =
                    WorkManager.getInstance(mContext)
                            .getWorkInfoById(isDeviceInApprovedCountryWorkId);
            Futures.addCallback(workInfoFuture, new FutureCallback<>() {
                @Override
                public void onSuccess(WorkInfo workInfo) {
                    WorkInfo.State state = workInfo.getState();
                    if (!(state == WorkInfo.State.SUCCEEDED
                            || state == WorkInfo.State.FAILED
                            || state == WorkInfo.State.CANCELLED)) {
                        LogUtil.e(TAG, "Cannot determine if device "
                                + "is in an approved country, cancelling job");
                        WorkManager.getInstance(mContext)
                                .cancelWorkById(isDeviceInApprovedCountryWorkId);
                    }
                }

                @Override
                public void onFailure(Throwable t) {
                    LogUtil.e(TAG, "Cannot determine work state for device in approved "
                            + "country", t);
                }
            }, mExecutor);
        }, IS_DEVICE_IN_APPROVED_COUNTRY_NETWORK_TIMEOUT_MS);
    }

    private void installFromPlay(LifecycleOwner owner, String kioskPackage, boolean isMandatory,
            ProvisioningProgressController progressController) {
        Context applicationContext = mContext.getApplicationContext();
        final Class<? extends ListenableWorker> playInstallTaskClass =
                ((PlayInstallPackageTaskClassProvider) applicationContext)
                        .getPlayInstallPackageTaskClass();
        if (playInstallTaskClass == null) {
            LogUtil.w(TAG, "Play installation not supported!");
            handleFailure(
                    ProvisionFailureReason.PLAY_TASK_UNAVAILABLE, isMandatory, progressController);
            return;
        }
        OneTimeWorkRequest playInstallPackageTask =
                getPlayInstallPackageTask(playInstallTaskClass, kioskPackage);
        WorkManager workManager = WorkManager.getInstance(mContext);
        final ListenableFuture<Operation.State.SUCCESS> enqueueResult =
                workManager.enqueueUniqueWork(playInstallTaskClass.getSimpleName(),
                        ExistingWorkPolicy.REPLACE, playInstallPackageTask).getResult();
        Futures.addCallback(enqueueResult, new FutureCallback<Operation.State.SUCCESS>() {
            @Override
            public void onSuccess(Operation.State.SUCCESS result) {
                // Enqueued
            }

            @Override
            public void onFailure(Throwable t) {
                LogUtil.e(TAG, "Failed to enqueue 'play install' work", t);
                if (t instanceof SQLiteException) {
                    mStateController.getDevicePolicyController().wipeDevice();
                } else {
                    LogUtil.e(TAG, "Not wiping device (non SQL exception)");
                }
            }
        }, mExecutor);

        mContext.getMainExecutor().execute(
                () -> workManager.getWorkInfoByIdLiveData(playInstallPackageTask.getId())
                        .observe(owner, workInfo -> {
                            if (workInfo == null) return;
                            WorkInfo.State state = workInfo.getState();
                            LogUtil.d(TAG, "WorkInfo changed: " + workInfo);
                            if (state == SUCCEEDED) {
                                progressController.setProvisioningProgress(
                                        ProvisioningProgress.OPENING_KIOSK_APP);
                                ReportDeviceProvisionStateWorker.reportSetupCompleted(workManager);
                                mStateController.postSetNextStateForEventRequest(PROVISION_KIOSK);
                            } else if (state == FAILED) {
                                LogUtil.w(TAG, "Play installation failed!");
                                handleFailure(ProvisionFailureReason.PLAY_INSTALLATION_FAILED,
                                        isMandatory, progressController);
                            }
                        }));
    }

    private void handleFailure(@ProvisionFailureReason int reason, boolean isMandatory,
            ProvisioningProgressController progressController) {
        StatsLogger logger =
                ((StatsLoggerProvider) mContext.getApplicationContext()).getStatsLogger();
        switch (reason) {
            case ProvisionFailureReason.PLAY_TASK_UNAVAILABLE -> {
                logger.logProvisionFailure(
                        StatsLogger.ProvisionFailureReasonStats.PLAY_TASK_UNAVAILABLE);
            }
            case ProvisionFailureReason.PLAY_INSTALLATION_FAILED -> {
                logger.logProvisionFailure(
                        StatsLogger.ProvisionFailureReasonStats.PLAY_INSTALLATION_FAILED);
            }
            case ProvisionFailureReason.COUNTRY_INFO_UNAVAILABLE -> {
                logger.logProvisionFailure(
                        StatsLogger.ProvisionFailureReasonStats.COUNTRY_INFO_UNAVAILABLE);
            }
            case ProvisionFailureReason.NOT_IN_ELIGIBLE_COUNTRY -> {
                logger.logProvisionFailure(
                        StatsLogger.ProvisionFailureReasonStats.NOT_IN_ELIGIBLE_COUNTRY);
            }
            case ProvisionFailureReason.POLICY_ENFORCEMENT_FAILED -> {
                logger.logProvisionFailure(
                        StatsLogger.ProvisionFailureReasonStats.POLICY_ENFORCEMENT_FAILED);
            }
            default -> {
                logger.logProvisionFailure(StatsLogger.ProvisionFailureReasonStats.UNKNOWN);
            }
        }
        if (isMandatory) {
            ReportDeviceProvisionStateWorker.reportSetupFailed(
                    WorkManager.getInstance(mContext), reason);
            progressController.setProvisioningProgress(
                    ProvisioningProgress.getMandatoryProvisioningFailedProgress(reason));
            mScheduler.scheduleMandatoryResetDeviceAlarm();
        } else {
            // For non-mandatory provisioning, failure should only be reported after
            // user exits the provisioning UI; otherwise, it could be reported
            // multiple times if user choose to retry, which can break the
            // 7-days failure flow.
            progressController.setProvisioningProgress(
                    ProvisioningProgress.getNonMandatoryProvisioningFailedProgress(reason));
        }
    }

    @NonNull
    private static OneTimeWorkRequest getIsDeviceInApprovedCountryWork() {
        NetworkRequest request = new NetworkRequest.Builder()
                .addCapability(NET_CAPABILITY_NOT_RESTRICTED)
                .addCapability(NET_CAPABILITY_TRUSTED)
                .addCapability(NET_CAPABILITY_INTERNET)
                .addCapability(NET_CAPABILITY_NOT_VPN)
                .build();
        return new OneTimeWorkRequest.Builder(IsDeviceInApprovedCountryWorker.class)
                .setConstraints(new Constraints.Builder().setRequiredNetworkRequest(
                        request, NetworkType.CONNECTED).build())
                // Set the request as expedited and use a short retry backoff time since the
                // user is in the setup flow while we check if the device is in an approved country
                .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
                .setBackoffCriteria(BackoffPolicy.EXPONENTIAL,
                        Duration.ofMillis(WorkRequest.MIN_BACKOFF_MILLIS))
                .build();
    }

    @NonNull
    private static OneTimeWorkRequest getPlayInstallPackageTask(
            Class<? extends ListenableWorker> playInstallTaskClass, String kioskPackageName) {
        return new OneTimeWorkRequest.Builder(playInstallTaskClass)
                .setInputData(new Data.Builder().putString(
                        EXTRA_KIOSK_PACKAGE, kioskPackageName).build())
                .setConstraints(new Constraints.Builder().setRequiredNetworkType(
                        NetworkType.CONNECTED).build())
                .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, PLAY_INSTALL_BACKOFF_DELAY)
                .build();
    }

    private void createNotification() {
        LogUtil.d(TAG, "createNotification");
        Context context = mContext;

        PendingIntent pendingIntent = PendingIntent.getBroadcast(context,
                /* requestCode= */ 0, new Intent(context, ResumeProvisionReceiver.class),
                PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
        LocalDateTime resumeDateTime = LocalDateTime.now().plusHours(1);
        DeviceLockNotificationManager.getInstance()
                .sendDeferredProvisioningNotification(context, resumeDateTime, pendingIntent);
    }

    /**
     * Sets whether provisioning should skip play install if there is already a preinstalled kiosk
     * app.
     */
    public static void setPreinstalledKioskAllowed(Context context, boolean enabled) {
        getSharedPreferences(context).edit().putBoolean(USE_PREINSTALLED_KIOSK_PREF, enabled)
                .apply();
    }

    /**
     * Returns true if provisioning should skip play install if there is already a preinstalled
     * kiosk app. By default, this returns true for debuggable build.
     */
    private static boolean getPreinstalledKioskAllowed(Context context) {
        return Build.isDebuggable() && getSharedPreferences(context).getBoolean(
                USE_PREINSTALLED_KIOSK_PREF, Build.isDebuggable());
    }
}
