/*
 * 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.Build;
import android.os.LocaleList;
import android.os.SystemClock;
import android.provider.DeviceConfig;
import android.provider.DeviceConfig.OnPropertiesChangedListener;
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.AppSearchComponentFactory;
import com.android.server.appsearch.InternalAppSearchLogger;
import com.android.server.appsearch.appsindexer.appsearchtypes.MobileApplication;
import com.android.server.appsearch.indexer.IndexerForceUpdateConfig;
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.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
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 = "AppSearchAppIndxrUsrInst";

    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);

    /**
     * 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;
    private final IndexerForceUpdateConfig mAppsIndexerForceUpdateConfig;
    private final InternalAppSearchLogger mLogger;

    private AppsIndexerImpl mAppsIndexerImpl;
    private OnPropertiesChangedListener mOnDeviceConfigChangedListener;

    /**
     * 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,
            @NonNull IndexerForceUpdateConfig appsIndexerForceUpdateConfig)
            throws AppSearchException {
        Objects.requireNonNull(userContext);
        Objects.requireNonNull(appsDir);
        Objects.requireNonNull(appsIndexerConfig);
        Objects.requireNonNull(appsIndexerForceUpdateConfig);

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

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

        AppsIndexerUserInstance indexer =
                new AppsIndexerUserInstance(
                        appsDir,
                        executorService,
                        context,
                        appsIndexerConfig,
                        appsIndexerForceUpdateConfig);
        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,
            @NonNull IndexerForceUpdateConfig appsIndexerForceUpdateConfig) {
        mDataDir = Objects.requireNonNull(dataDir);
        mSettings = new AppsIndexerSettings(mDataDir);
        mSingleThreadedExecutor = Objects.requireNonNull(singleThreadedExecutor);
        mContext = Objects.requireNonNull(context);
        mAppsIndexerConfig = Objects.requireNonNull(appsIndexerConfig);
        mAppsIndexerForceUpdateConfig = Objects.requireNonNull(appsIndexerForceUpdateConfig);
        // TODO: b/444057344 - Use the logger created by AppSearchUserInstance.
        mLogger =
                AppSearchComponentFactory.createLoggerInstance(
                        mContext,
                        AppSearchComponentFactory.getConfigInstance(
                                mSingleThreadedExecutor, mContext));
    }

    @VisibleForTesting
    public AppsIndexerSettings getSettings() {
        return mSettings;
    }

    /** Initialize a Device Config Listener */
    public void startAsync() {
        startAsync(() -> {});
    }

    /**
     * Initializes a listener for {@link AppsIndexerUserInstance}
     *
     * <p>This method sets up a listener for device configuration changes related to force updates
     * for the indexer. When a relevant configuration change occurs, check if the required
     * conditions are met to schedule a force update.
     *
     * @param callback A {@link Runnable} to be executed when a force update is complete.
     */
    @VisibleForTesting
    public void startAsync(@NonNull Runnable callback) {
        if (Flags.enableIndexerForceUpdate()) {
            mOnDeviceConfigChangedListener =
                    IndexerForceUpdateConfig.addListener(
                            mSingleThreadedExecutor,
                            () -> {
                                handleForceUpdateConfigChanged(callback);
                            });
        }
    }

    /** Shuts down the AppsIndexerUserInstance */
    public void shutdown() throws InterruptedException {
        if (Flags.enableIndexerForceUpdate()) {
            if (mOnDeviceConfigChangedListener != null) {
                executeOnSingleThreadedExecutor(
                        () -> {
                            DeviceConfig.removeOnPropertiesChangedListener(
                                    mOnDeviceConfigChangedListener);
                        });
            }
        }
        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) {
        executeOnSingleThreadedExecutor(() -> dumpInternal(pw));
    }

    /**
     * Dumps the internal state of this {@link AppsIndexerUserInstance}. This will be ran on the
     * single threaded executor.
     */
    private void dumpInternal(@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: "
                        + formatTimestamp(mSettings.getLastUpdateTimestampMillis()));
        pw.println(
                "last_app_update_timestamp_millis: "
                        + formatTimestamp(mSettings.getLastAppUpdateTimestampMillis()));
        pw.println(
                "last_partitions_fingerprint_sorted_by_partition_name"
                        + Arrays.toString(
                                mSettings.getLastPartitionFingerprintsSortedByPartitionName()));
        try (AppSearchHelper appSearchHelper = new AppSearchHelper(mContext)) {
            Map<String, MobileApplication> appsMap =
                    appSearchHelper
                            .getAppsLastUpdatedTimeAndAppFunctionServiceEnabledFromAppSearch();
            pw.println("Indexed Apps:");
            for (Map.Entry<String, MobileApplication> appLastUpdatedEntry : appsMap.entrySet()) {
                pw.println(
                        "    packageName: "
                                + appLastUpdatedEntry.getKey()
                                + " lastUpdatedTimestamp: "
                                + formatTimestamp(
                                        appLastUpdatedEntry.getValue().getUpdatedTimestamp()));
            }
        } catch (AppSearchException e) {
            pw.println("Error in dumping indexed applications");
        }
        pw.println("Apps Indexer Logs:");
        Collection<String> logs = mSettings.getLogLines();
        if (logs.isEmpty()) {
            pw.println("  No logs available.");
        } else {
            for (String log : logs) {
                pw.println("  " + log);
            }
        }
    }

    private static String formatTimestamp(long timestampInMillis) {
        LocalDateTime dateTime =
                LocalDateTime.ofInstant(
                        Instant.ofEpochMilli(timestampInMillis), ZoneId.systemDefault());
        return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
    }

    /**
     * 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.
     * @param isForceUpdateTriggered indicates if a force update is triggered.
     */
    public void updateAsync(boolean firstRun, boolean isForceUpdateTriggered) {
        AppsUpdateStats appsUpdateStats = new AppsUpdateStats();
        long updateLatencyStartTimestampMillis = SystemClock.elapsedRealtime();
        appsUpdateStats.mUpdateStartTimestampMillis = System.currentTimeMillis();
        appsUpdateStats.mUpdateType = AppsUpdateStats.FULL_UPDATE;
        appsUpdateStats.mForceUpdateTriggered = isForceUpdateTriggered;

        // 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;
                    if (Flags.enableAppsIndexerPlatformLogger()) {
                        mLogger.logStats(appsUpdateStats);
                    } else {
                        logStats(appsUpdateStats);
                    }
                });

        if (Flags.enableIndexerForceUpdate()) {
            mSettings.setIndexerForceUpdateEmergencyCounter(
                mAppsIndexerForceUpdateConfig.getIndexerForceUpdateEmergencyCounter());
            try {
                mSettings.persist();
            } catch (IOException e) {
                Log.w(TAG, "Failed to save settings to disk", e);
            }
        }
    }

    /**
     * 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();

            List<Build.Partition> currentFingerprintedPartitions =
                    Build.getFingerprintedPartitions();
            List<Build.Partition> sortedFingerprintedPartitions =
                    new ArrayList<>(currentFingerprintedPartitions);
            sortedFingerprintedPartitions.sort(Comparator.comparing(Build.Partition::getName));

            boolean isOtaUpdate = checkForOtaUpdate(sortedFingerprintedPartitions);

            boolean isFullUpdateRequired = isAppIndexerUpdated || isOtaUpdate;

            if (Flags.enableAppsIndexerLocaleChangeFullUpdate()) {
                LocaleList localeList = mContext.getResources().getConfiguration().getLocales();
                if (!localeList.isEmpty()) {
                    // https://developer.android.com/reference/android/content/res/Configuration:
                    // If only the primary locale is needed, getLocales().get(0) is now the
                    // preferred accessor
                    String currentLocaleCode = localeList.get(0).getLanguage();
                    String previousLocaleCode = mSettings.getPreviousLocaleCode();
                    if (previousLocaleCode != null
                            && !previousLocaleCode.equals(currentLocaleCode)) {
                        // Only if previousLocaleCode is not empty will we initiate a locale change
                        // triggered update
                        isFullUpdateRequired = true;
                    }

                    if (currentLocaleCode != null) {
                        // Always save locale to settings if current locale is not null, as it is
                        // similar in nature to timestamp
                        mSettings.setPreviousLocaleCode(currentLocaleCode);
                    }
                }
            }

            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 or system image wasn't
                // updated since.
                if (mSettings.getLastUpdateTimestampMillis() != 0 && !isFullUpdateRequired) {
                    return;
                }
            }

            mAppsIndexerImpl.doUpdateIncrementalPut(
                    mSettings, appsUpdateStats, isFullUpdateRequired);
            if (isOtaUpdate) {
                mSettings.setLastPartitionFingerprintsSortedByPartitionName(
                        sortedFingerprintedPartitions);
            }

            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 an OTA update has occurred by comparing the current partition fingerprints against
     * the last known fingerprints stored in settings.
     *
     * @param fingerprintedPartitions the current list of fingerprinted partitions, sorted by {@link
     *     Build.Partition#getName()}
     * @return true if an OTA update is detected (i.e., at least one fingerprint has changed), false
     *     otherwise
     */
    private boolean checkForOtaUpdate(List<Build.Partition> fingerprintedPartitions) {
        String[] oldFingerprintedPartitions =
                mSettings.getLastPartitionFingerprintsSortedByPartitionName();

        if (oldFingerprintedPartitions == null
                || fingerprintedPartitions.size() != oldFingerprintedPartitions.length) {
            // Either first time check or the number of partitions has changed => OTA likely
            // occurred
            return true;
        }

        for (int i = 0; i < fingerprintedPartitions.size(); ++i) {
            if (!Objects.equals(
                    fingerprintedPartitions.get(i).getFingerprint(),
                    oldFingerprintedPartitions[i])) {
                return true;
            }
        }

        return false;
    }

    /**
     * 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,
                appsUpdateStats.mForceUpdateTriggered);
    }

    /**
     * Handles the force update logic for the apps indexer.
     *
     * <p>This method checks if the force update feature is enabled and if the emergency counter
     * from the Device Configuration has increased compared to the stored setting. If both
     * conditions are met, it triggers {@link #updateAsync} & updates the settings emergency counter
     *
     * @param callback A {@link Runnable} to be executed when the force update is complete.
     */
    private void handleForceUpdateConfigChanged(@NonNull Runnable callback) {
        try {
            if (!mAppsIndexerForceUpdateConfig.isIndexerForceUpdateEnabled()) {
                return;
            }
            if (mAppsIndexerForceUpdateConfig.getIndexerForceUpdateEmergencyCounter()
                    > mSettings.getIndexerForceUpdateEmergencyCounter()) {
                updateAsync(/* firstRun= */ true, /*isForceUpdateTriggered= */ true);
            }
        } catch (RuntimeException e) {
            Slog.wtf(TAG, "AppsIndexerUserInstance.handleForceUpdateConfigChanged() failed ", e);
        } finally {
            callback.run();
        }
    }
}
