/*
 * Copyright (C) 2024 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.server.appsearch.appsindexer;

import static com.android.server.appsearch.appsindexer.AppIndexerVersions.CURR_APP_INDEXER_VERSION;
import static com.android.server.appsearch.indexer.IndexerMaintenanceConfig.APPS_INDEXER;

import android.annotation.NonNull;
import android.annotation.WorkerThread;
import android.app.appsearch.AppSearchEnvironmentFactory;
import android.app.appsearch.exceptions.AppSearchException;
import android.content.Context;
import android.os.SystemClock;
import android.util.Log;
import android.util.Slog;

import com.android.appsearch.flags.Flags;
import com.android.internal.annotations.VisibleForTesting;
import com.android.server.appsearch.indexer.IndexerMaintenanceService;
import com.android.server.appsearch.stats.AppSearchStatsLog;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

/**
 * Apps Indexer for a single user.
 *
 * <p>It reads the updated/newly-inserted/deleted apps from PackageManager, and syncs the changes
 * into AppSearch.
 *
 * <p>This class is thread safe.
 *
 * @hide
 */
public final class AppsIndexerUserInstance {

    private static final String TAG = "AppSearchAppsIndexerUserInst";

    private final File mDataDir;
    // While AppsIndexerSettings is not thread safe, it is only accessed through a single-threaded
    // executor service. It will be read and updated before the next scheduled task accesses it.
    private final AppsIndexerSettings mSettings;

    // Used for handling the app change notification so we won't schedule too many updates. At any
    // time, only two threads can run an update. But since we use a single-threaded executor, it
    // means that at most one thread can be running, and another thread can be waiting to run. This
    // will happen in the case that an update is requested while another is running.
    private final Semaphore mRunningOrScheduledSemaphore = new Semaphore(2);

    private AppsIndexerImpl mAppsIndexerImpl;

    /**
     * Single threaded executor to make sure there is only one active sync for this {@link
     * AppsIndexerUserInstance}. Background tasks should be scheduled using {@link
     * #executeOnSingleThreadedExecutor(Runnable)} which ensures that they are not executed if the
     * executor is shutdown during {@link #shutdown()}.
     *
     * <p>Note that this executor is used as both work and callback executors which is fine because
     * AppSearch should be able to handle exceptions thrown by them.
     */
    private final ExecutorService mSingleThreadedExecutor;

    private final Context mContext;
    private final AppsIndexerConfig mAppsIndexerConfig;

    /**
     * Constructs and initializes a {@link AppsIndexerUserInstance}.
     *
     * <p>Heavy operations such as connecting to AppSearch are performed asynchronously.
     *
     * @param appsDir data directory for AppsIndexer.
     */
    @NonNull
    public static AppsIndexerUserInstance createInstance(
            @NonNull Context userContext,
            @NonNull File appsDir,
            @NonNull AppsIndexerConfig appsIndexerConfig)
            throws AppSearchException {
        Objects.requireNonNull(userContext);
        Objects.requireNonNull(appsDir);
        Objects.requireNonNull(appsIndexerConfig);

        ExecutorService singleThreadedExecutor =
                AppSearchEnvironmentFactory.getEnvironmentInstance().createSingleThreadExecutor();
        return createInstance(userContext, appsDir, appsIndexerConfig, singleThreadedExecutor);
    }

    @VisibleForTesting
    @NonNull
    static AppsIndexerUserInstance createInstance(
            @NonNull Context context,
            @NonNull File appsDir,
            @NonNull AppsIndexerConfig appsIndexerConfig,
            @NonNull ExecutorService executorService)
            throws AppSearchException {
        Objects.requireNonNull(context);
        Objects.requireNonNull(appsDir);
        Objects.requireNonNull(appsIndexerConfig);
        Objects.requireNonNull(executorService);

        AppsIndexerUserInstance indexer =
                new AppsIndexerUserInstance(appsDir, executorService, context, appsIndexerConfig);
        indexer.loadSettingsAsync();
        indexer.mAppsIndexerImpl = new AppsIndexerImpl(context, appsIndexerConfig);

        return indexer;
    }

    /**
     * Constructs a {@link AppsIndexerUserInstance}.
     *
     * @param dataDir data directory for storing apps indexer state.
     * @param singleThreadedExecutor an {@link ExecutorService} with at most one thread to ensure
     *     the thread safety of this class.
     * @param context Context object passed from {@link AppsIndexerManagerService}
     */
    private AppsIndexerUserInstance(
            @NonNull File dataDir,
            @NonNull ExecutorService singleThreadedExecutor,
            @NonNull Context context,
            @NonNull AppsIndexerConfig appsIndexerConfig) {
        mDataDir = Objects.requireNonNull(dataDir);
        mSettings = new AppsIndexerSettings(mDataDir);
        mSingleThreadedExecutor = Objects.requireNonNull(singleThreadedExecutor);
        mContext = Objects.requireNonNull(context);
        mAppsIndexerConfig = Objects.requireNonNull(appsIndexerConfig);
    }

    /** Shuts down the AppsIndexerUserInstance */
    public void shutdown() throws InterruptedException {
        mAppsIndexerImpl.close();
        IndexerMaintenanceService.cancelUpdateJobIfScheduled(
                mContext, mContext.getUser(), APPS_INDEXER);
        synchronized (mSingleThreadedExecutor) {
            mSingleThreadedExecutor.shutdown();
        }
        boolean unused = mSingleThreadedExecutor.awaitTermination(30L, TimeUnit.SECONDS);
    }

    /** Dumps the internal state of this {@link AppsIndexerUserInstance}. */
    public void dump(@NonNull PrintWriter pw) {
        // Those timestamps are not protected by any lock since in AppsIndexerUserInstance
        // we only have one thread to handle all the updates. It is possible we might run into
        // race condition if there is an update running while those numbers are being printed.
        // This is acceptable though for debug purpose, so still no lock here.
        pw.println("last_update_timestamp_millis: " + mSettings.getLastUpdateTimestampMillis());
        pw.println(
                "last_app_update_timestamp_millis: " + mSettings.getLastAppUpdateTimestampMillis());
    }

    /**
     * Schedule an update. No new update can be scheduled if there are two updates already scheduled
     * or currently being run.
     *
     * @param firstRun boolean indicating if this is a first run and that settings should be checked
     *     for the last update timestamp.
     */
    public void updateAsync(boolean firstRun) {
        AppsUpdateStats appsUpdateStats = new AppsUpdateStats();
        long updateLatencyStartTimestampMillis = SystemClock.elapsedRealtime();
        appsUpdateStats.mUpdateStartTimestampMillis = System.currentTimeMillis();
        appsUpdateStats.mUpdateType = AppsUpdateStats.FULL_UPDATE;
        // Try to acquire a permit.
        if (!mRunningOrScheduledSemaphore.tryAcquire()) {
            // If there are none available, that means an update is running and we have ALREADY
            // received a change mid-update. The third update request was received during the first
            // update, and will be handled by the scheduled update.
            return;
        }
        // If there is a permit available, that cold mean there is one update running right now
        // with none scheduled. Since we use a single threaded executor, calling execute on it
        // right now will run the requested update after the current update. It could also mean
        // there is no update running right now, so we can just call execute and run the update
        // right now.
        executeOnSingleThreadedExecutor(
                () -> {
                    doUpdate(firstRun, appsUpdateStats);
                    IndexerMaintenanceService.scheduleUpdateJob(
                            mContext,
                            mContext.getUser(),
                            APPS_INDEXER,
                            /* periodic= */ true,
                            /* intervalMillis= */ mAppsIndexerConfig
                                    .getAppsMaintenanceUpdateIntervalMillis());
                    appsUpdateStats.mTotalLatencyMillis =
                            SystemClock.elapsedRealtime() - updateLatencyStartTimestampMillis;
                    logStats(appsUpdateStats);
                });
    }

    /**
     * Does the update. It also releases a permit from {@link #mRunningOrScheduledSemaphore}
     *
     * @param firstRun when set to true, that means this was called from onUserUnlocking. If we
     *     didn't have this check, the apps indexer would run every time the phone got unlocked. It
     *     should only run the first time this happens.
     * @param appsUpdateStats contains stats about the apps indexer update. This method will
     *     populate the fields of this {@link AppsUpdateStats} structure.
     */
    @VisibleForTesting
    @WorkerThread
    void doUpdate(boolean firstRun, @NonNull AppsUpdateStats appsUpdateStats) {
        try {
            Objects.requireNonNull(appsUpdateStats);
            // Check if there was a prior run
            boolean isAppIndexerUpdated =
                    Flags.enableAllPackageIndexingOnIndexerUpdate()
                            && checkAndUpdateIndexerVersion();
            if (firstRun) {
                if (Flags.enableAppsIndexerCheckPriorAttempt()) {
                    // Special "firstRun" case.
                    long now = System.currentTimeMillis();
                    long lastRun = mSettings.getLastAttemptedUpdateTimestampMillis();
                    long timeSinceLastRun = now - lastRun;

                    // If timeSinceLastRun is somehow negative, it means that the system clock
                    // must've turned back since the last run. We'll run the update in this case
                    if (timeSinceLastRun >= 0
                            && timeSinceLastRun
                                    < mAppsIndexerConfig.getMinTimeBetweenFirstSyncsMillis()) {
                        // Last firstRun was too recent, skip and leave timestamps alone
                        return;
                    }

                    mSettings.setLastAttemptedUpdateTimestampMillis(now);
                    mSettings.persist();
                }

                // Check if there was a previous successful run and AppSearch wasn't updated since.
                if (mSettings.getLastUpdateTimestampMillis() != 0 && !isAppIndexerUpdated) {
                    return;
                }
            }
            if (Flags.enableAppsIndexerIncrementalPut()) {
                mAppsIndexerImpl.doUpdateIncrementalPut(
                        mSettings,
                        appsUpdateStats,
                        /* isFullUpdateRequired= */ isAppIndexerUpdated);
            } else {
                // TODO(b/367410454): Remove this method and related code paths once
                //  enable_apps_indexer_incremental_put flag is rolled out.
                mAppsIndexerImpl.doUpdate(mSettings, appsUpdateStats);
            }
            mSettings.persist();
        } catch (IOException e) {
            Log.w(TAG, "Failed to save settings to disk", e);
        } catch (AppSearchException e) {
            Log.e(TAG, "Failed to sync Apps to AppSearch", e);
        } finally {
            // Finish a update. If there were no permits available, the update that was requested
            // mid-update will run. If there was one permit available, we won't run another update.
            // This happens if no updates were scheduled during the update.
            mRunningOrScheduledSemaphore.release();
        }
    }

    /**
     * Checks if the current App Indexer versionCode differs from the previously stored versionCode
     * in {@link AppsIndexerSettings} and updates the stored versionCode if necessary.
     *
     * @return {@code true} if the versionCode has changed, {@code false} otherwise.
     */
    private boolean checkAndUpdateIndexerVersion() {
        if (mSettings.getPreviousIndexerVersionCode() == CURR_APP_INDEXER_VERSION) {
            return false;
        }

        mSettings.setPreviousIndexerVersionCode(CURR_APP_INDEXER_VERSION);
        return true;
    }

    /**
     * Loads the persisted data from disk.
     *
     * <p>It doesn't throw here. If it fails to load file, AppsIndexer would always use the
     * timestamps persisted in the memory.
     */
    private void loadSettingsAsync() {
        executeOnSingleThreadedExecutor(
                () -> {
                    try {
                        // If the directory already exists, this returns false. That is fine as it
                        // might not be the first sync. If this returns true, that is fine as it is
                        // the first run and we want to make a new directory.
                        mDataDir.mkdirs();
                    } catch (SecurityException e) {
                        Log.e(TAG, "Failed to create settings directory on disk.", e);
                        return;
                    }

                    try {
                        mSettings.load();
                    } catch (IOException e) {
                        // Ignore file not found errors (bootstrap case)
                        if (!(e instanceof FileNotFoundException)) {
                            Log.e(TAG, "Failed to load settings from disk", e);
                        }
                    }
                });
    }

    /**
     * Executes the given command on {@link #mSingleThreadedExecutor} if it is still alive.
     *
     * <p>If the {@link #mSingleThreadedExecutor} has been shutdown, this method doesn't execute the
     * given command, and returns silently. Specifically, it does not throw {@link
     * java.util.concurrent.RejectedExecutionException}.
     *
     * @param command the runnable task
     */
    private void executeOnSingleThreadedExecutor(Runnable command) {
        synchronized (mSingleThreadedExecutor) {
            if (mSingleThreadedExecutor.isShutdown()) {
                Log.w(TAG, "Executor is shutdown, not executing task");
                return;
            }
            mSingleThreadedExecutor.execute(
                    () -> {
                        try {
                            command.run();
                        } catch (RuntimeException e) {
                            Slog.wtf(
                                    TAG,
                                    "AppsIndexerUserInstance"
                                            + ".executeOnSingleThreadedExecutor() failed ",
                                    e);
                        }
                    });
        }
    }

    private void logStats(@NonNull AppsUpdateStats appsUpdateStats) {
        Objects.requireNonNull(appsUpdateStats);
        int[] updateStatusArr = new int[appsUpdateStats.mUpdateStatusCodes.size()];
        int updateIdx = 0;
        for (int updateStatus : appsUpdateStats.mUpdateStatusCodes) {
            updateStatusArr[updateIdx] = updateStatus;
            ++updateIdx;
        }
        AppSearchStatsLog.write(
                AppSearchStatsLog.APP_SEARCH_APPS_INDEXER_STATS_REPORTED,
                appsUpdateStats.mUpdateType,
                updateStatusArr,
                appsUpdateStats.mNumberOfAppsAdded,
                appsUpdateStats.mNumberOfAppsRemoved,
                appsUpdateStats.mNumberOfAppsUpdated,
                appsUpdateStats.mNumberOfAppsUnchanged,
                appsUpdateStats.mTotalLatencyMillis,
                appsUpdateStats.mPackageManagerLatencyMillis,
                appsUpdateStats.mAppSearchGetLatencyMillis,
                appsUpdateStats.mAppSearchSetSchemaLatencyMillis,
                appsUpdateStats.mAppSearchPutLatencyMillis,
                appsUpdateStats.mUpdateStartTimestampMillis,
                appsUpdateStats.mLastAppUpdateTimestampMillis,
                appsUpdateStats.mNumberOfFunctionsAdded,
                appsUpdateStats.mApproximateNumberOfFunctionsRemoved,
                appsUpdateStats.mNumberOfFunctionsUpdated,
                appsUpdateStats.mApproximateNumberOfFunctionsUnchanged,
                appsUpdateStats.mAppSearchRemoveLatencyMillis);
    }
}
