/*
 * 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.indexer.IndexerMaintenanceConfig.APP_OPEN_EVENT_INDEXER;

import android.annotation.NonNull;
import android.app.appsearch.AppSearchEnvironmentFactory;
import android.app.appsearch.exceptions.AppSearchException;
import android.content.Context;
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.indexer.IndexerForceUpdateConfig;
import com.android.server.appsearch.indexer.IndexerMaintenanceService;

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.TimeUnit;

/**
 * App Open Event Indexer for a single user.
 *
 * <p>It reads the updated opened apps from UsageStatsManager and syncs the changes into AppSearch
 *
 * <p>This class is thread safe.
 *
 * @hide
 */
public final class AppOpenEventIndexerUserInstance {

    // Arbitrarily selected time to prevent indexer from running too frequently. It is scheduled to
    // run once daily.
    private static final long MIN_TIME_BETWEEN_UPDATES_MILLIS = 60 * 60 * 1000; // 1 hour

    private static final String TAG = "AppSearchAppOpenEventIn";
    private final AppOpenEventIndexerImpl mAppOpenEventIndexerImpl;
    private final AppOpenEventIndexerSettings mAppOpenEventIndexerSettings;

    // While IndexerSettings 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 File mDataDir;
    private final Context mContext;

    private final AppOpenEventIndexerConfig mAppOpenEventIndexerConfig;
    private final IndexerForceUpdateConfig mAppOpenEventIndexerForceUpdateConfig;
    private OnPropertiesChangedListener mOnDeviceConfigChangedListener;

    private final InternalAppSearchLogger mLogger;

    /**
     * Single threaded executor to make sure there is only one active sync per {@link
     * BaseIndexerUserInstance}. 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;

    /**
     * Constructs and initializes a {@link AppOpenEventIndexerUserInstance}.
     *
     * <p>Heavy operations such as connecting to AppSearch are performed asynchronously.
     *
     * @param appOpenEventIndexerDir directory for AppOpenEventIndexer.
     */
    @NonNull
    public static AppOpenEventIndexerUserInstance createInstance(
            @NonNull Context userContext,
            @NonNull File appOpenEventIndexerDir,
            @NonNull AppOpenEventIndexerConfig appOpenEventIndexerConfig,
            @NonNull IndexerForceUpdateConfig appOpenEventIndexerForceUpdateConfig)
            throws AppSearchException {
        Objects.requireNonNull(userContext);
        Objects.requireNonNull(appOpenEventIndexerDir);
        Objects.requireNonNull(appOpenEventIndexerConfig);
        Objects.requireNonNull(appOpenEventIndexerForceUpdateConfig);

        ExecutorService singleThreadedExecutor =
                AppSearchEnvironmentFactory.getEnvironmentInstance().createSingleThreadExecutor();
        return createInstance(
                userContext,
                appOpenEventIndexerDir,
                appOpenEventIndexerConfig,
                appOpenEventIndexerForceUpdateConfig,
                singleThreadedExecutor);
    }

    @VisibleForTesting
    @NonNull
    static AppOpenEventIndexerUserInstance createInstance(
            @NonNull Context context,
            @NonNull File appOpenEventIndexerDir,
            @NonNull AppOpenEventIndexerConfig appOpenEventIndexerConfig,
            @NonNull IndexerForceUpdateConfig appOpenEventIndexerForceUpdateConfig,
            @NonNull ExecutorService executorService)
            throws AppSearchException {
        Objects.requireNonNull(context);
        Objects.requireNonNull(appOpenEventIndexerDir);
        Objects.requireNonNull(appOpenEventIndexerConfig);
        Objects.requireNonNull(appOpenEventIndexerForceUpdateConfig);
        Objects.requireNonNull(executorService);
        AppOpenEventIndexerImpl appOpenEventIndexerImpl =
                new AppOpenEventIndexerImpl(context, appOpenEventIndexerConfig);

        AppOpenEventIndexerUserInstance indexer =
                new AppOpenEventIndexerUserInstance(
                        appOpenEventIndexerDir,
                        executorService,
                        context,
                        appOpenEventIndexerImpl,
                        new AppOpenEventIndexerSettings(appOpenEventIndexerDir),
                        appOpenEventIndexerConfig,
                        appOpenEventIndexerForceUpdateConfig);
        indexer.loadSettingsAsync();

        return indexer;
    }

    /**
     * Constructs a {@link AppOpenEventIndexerUserInstance}.
     *
     * @param dataDir data directory for storing app open event indexer state.
     * @param singleThreadedExecutor an {@link ExecutorService} with at most one thread to ensure
     *     the thread safety of this class.
     */
    private AppOpenEventIndexerUserInstance(
            @NonNull File dataDir,
            @NonNull ExecutorService singleThreadedExecutor,
            @NonNull Context context,
            @NonNull AppOpenEventIndexerImpl appOpenEventIndexerImpl,
            @NonNull AppOpenEventIndexerSettings appOpenEventIndexerSettings,
            @NonNull AppOpenEventIndexerConfig appOpenEventIndexerConfig,
            @NonNull IndexerForceUpdateConfig appOpenEventIndexerForceUpdateConfig) {
        mDataDir = Objects.requireNonNull(dataDir);
        mSingleThreadedExecutor = Objects.requireNonNull(singleThreadedExecutor);
        mContext = Objects.requireNonNull(context);
        // TODO: b/444057344 - Use the logger created by AppSearchUserInstance.
        mLogger =
                AppSearchComponentFactory.createLoggerInstance(
                        mContext,
                        AppSearchComponentFactory.getConfigInstance(
                                mSingleThreadedExecutor, mContext));

        mAppOpenEventIndexerImpl = Objects.requireNonNull(appOpenEventIndexerImpl);
        mAppOpenEventIndexerSettings = Objects.requireNonNull(appOpenEventIndexerSettings);
        mAppOpenEventIndexerConfig = Objects.requireNonNull(appOpenEventIndexerConfig);
        mAppOpenEventIndexerForceUpdateConfig =
                Objects.requireNonNull(appOpenEventIndexerForceUpdateConfig);
    }

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

    /**
     * Initializes a listener for {@link AppOpenEventIndexerUserInstance}
     *
     * <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
    void startAsync(@NonNull Runnable callback) {
        if (Flags.enableIndexerForceUpdate()) {
            mOnDeviceConfigChangedListener =
                    IndexerForceUpdateConfig.addListener(
                            mSingleThreadedExecutor,
                            () ->
                                handleForceUpdateConfigChanged(callback));
        }
    }

    /** Shuts down the AppOpenEventIndexerUserInstance */
    public void shutdown() throws InterruptedException {
        if (Flags.enableIndexerForceUpdate()) {
            if (mOnDeviceConfigChangedListener != null) {
                executeOnSingleThreadedExecutor(
                        () ->
                            DeviceConfig.removeOnPropertiesChangedListener(
                                    mOnDeviceConfigChangedListener)
                        );
            }
        }
        mAppOpenEventIndexerImpl.close();
        IndexerMaintenanceService.cancelUpdateJobIfScheduled(
                mContext, mContext.getUser(), APP_OPEN_EVENT_INDEXER);
        synchronized (mSingleThreadedExecutor) {
            mSingleThreadedExecutor.shutdown();
        }
        boolean unused = mSingleThreadedExecutor.awaitTermination(30L, TimeUnit.SECONDS);
    }

    /** Dumps the internal state of this {@link AppOpenEventIndexerUserInstance}. */
    public void dump(@NonNull PrintWriter pw) {
        // Those timestamps are not protected by any lock since in AppOpenEventIndexerUserInstance
        // 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: "
                        + mAppOpenEventIndexerSettings.getLastUpdateTimestampMillis());
    }

    /** Schedule an update on single threaded executor. */
    public void updateAsync() {
        updateAsync(() -> {}, /* isForceUpdateTriggered= */ false);
    }

    /**
     * Schedule an update on a single-threaded executor. Mainly to be used for testing, where the
     * callback is used to wait for the update to complete.
     *
     * @param callback A callback to be invoked after the update is complete.
     * @param isForceUpdateTriggered indicates if force update has been triggered
     */
    @VisibleForTesting
    void updateAsync(@NonNull Runnable callback, boolean isForceUpdateTriggered){
        AppOpenEventStats.Builder appOpenEventStatsBuilder = new AppOpenEventStats.Builder();
        appOpenEventStatsBuilder.setUpdateStartTimestampMillis(System.currentTimeMillis());
        appOpenEventStatsBuilder.setForceUpdateTriggered(isForceUpdateTriggered);
        long startTimeMillis = System.currentTimeMillis();
        executeOnSingleThreadedExecutor(
                () -> {
                    try {
                        doUpdate(appOpenEventStatsBuilder);
                        schedulePeriodicUpdate();
                        appOpenEventStatsBuilder.setTotalLatencyMillis(
                                System.currentTimeMillis() - startTimeMillis);
                        if (Flags.appOpenEventIndexerStatsLoggingEnabled()
                                && mAppOpenEventIndexerConfig.isLoggingEnabled()) {
                            mLogger.logStats(appOpenEventStatsBuilder.build());
                        }
                    } finally {
                        callback.run();
                    }
                });
        if (Flags.enableIndexerForceUpdate()) {
            mAppOpenEventIndexerSettings.setIndexerForceUpdateEmergencyCounter(
                    mAppOpenEventIndexerForceUpdateConfig.getIndexerForceUpdateEmergencyCounter());
            try {
                mAppOpenEventIndexerSettings.persist();
            } catch (IOException e) {
                Log.w(TAG, "Failed to save settings to disk", e);
            }
        }
    }

    /** Loads the persisted data from disk asynchronously. */
    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.
                        // TODO(b/357835538): Consider moving this to
                        // IndexerSettings#ensureFileCreated.  Would need to migrate the same logic
                        // in App and Contact indexers. No real blockers aside from launching it
                        // behind a flag.
                        mDataDir.mkdirs();
                    } catch (SecurityException e) {
                        Log.e(TAG, "Failed to create settings directory on disk.", e);
                        return;
                    }

                    try {
                        mAppOpenEventIndexerSettings.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, "executeOnSingleThreadedExecutor failed", e);
                        }
                    });
        }
    }

    /**
     * Does the update by verifying if it's been run before, syncing the app open events to
     * AppSearch and finally persisting the settings. If the update has been run within the last
     * {@code MIN_TIME_BETWEEN_UPDATES_MILLIS} it will be a no-op.
     */
    @VisibleForTesting
    void doUpdate(@NonNull AppOpenEventStats.Builder appOpenEventStatsBuilder) {
        try {
            Objects.requireNonNull(appOpenEventStatsBuilder);
            if (Flags.enableAppOpenEventsIndexerCheckPriorAttempt()) {
                long now = System.currentTimeMillis();
                long lastRun = mAppOpenEventIndexerSettings.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
                                < mAppOpenEventIndexerConfig.getMinTimeBetweenSyncsMillis()) {
                    // Last run was too recent, skip and leave timestamps alone
                    return;
                }

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

            long lastUpdateMillis = mAppOpenEventIndexerSettings.getLastUpdateTimestampMillis();
            long currentTimeMillis = System.currentTimeMillis();

            if (currentTimeMillis - lastUpdateMillis < MIN_TIME_BETWEEN_UPDATES_MILLIS) {
                Log.w(TAG, "Skipping update because last update was too recent");
                return;
            }
            mAppOpenEventIndexerImpl.doUpdate(
                    mAppOpenEventIndexerSettings, appOpenEventStatsBuilder);
            mAppOpenEventIndexerSettings.persist();
        } catch (IOException e) {
            Log.w(TAG, "Failed to save settings to disk", e);
        } catch (AppSearchException e) {
            Log.e(TAG, "Failed to sync app open events to AppSearch", e);
        }
    }

    /**
     * Schedules the next indexer update job. The {@link IndexerMaintenanceService} deduplicates
     * this by checking the job info (which includes job ID). This ensures only 1 scheduled periodic
     * task per indexer type per user.
     */
    public void schedulePeriodicUpdate() {
        IndexerMaintenanceService.scheduleUpdateJob(
                mContext,
                mContext.getUser(),
                APP_OPEN_EVENT_INDEXER,
                /* periodic= */ true,
                /* intervalMillis= */ mAppOpenEventIndexerConfig
                        .getAppOpenEventMaintenanceUpdateIntervalMillis());
    }

    @VisibleForTesting
    public AppOpenEventIndexerSettings getSettings() {
        return mAppOpenEventIndexerSettings;
    }

    /**
     * Handles the force update logic for the apps open event 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 a force update is complete.
     */
    private void handleForceUpdateConfigChanged(@NonNull Runnable callback) {
        try {
            if (!mAppOpenEventIndexerForceUpdateConfig.isIndexerForceUpdateEnabled()) {
                return;
            }
            if (mAppOpenEventIndexerForceUpdateConfig.getIndexerForceUpdateEmergencyCounter()
                    > mAppOpenEventIndexerSettings.getIndexerForceUpdateEmergencyCounter()) {
                updateAsync(callback, /* isForceUpdateTriggered= */ true);
            }
        } catch (RuntimeException e) {
            Slog.wtf(
                    TAG,
                    "AppOpenEventIndexerUserInstance.handleForceUpdateConfigChanged() failed",
                    e);
        }
    }
}
