// Copyright 2020 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.content.Intent;
import android.os.Binder;
import android.os.IBinder;
import android.os.Process;

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

import com.google.protobuf.InvalidProtocolBufferException;

import org.chromium.android_webview.common.services.IMetricsBridgeService;
import org.chromium.android_webview.proto.MetricsBridgeRecords.HistogramRecord;
import org.chromium.android_webview.proto.MetricsBridgeRecords.HistogramRecord.RecordType;
import org.chromium.base.Log;
import org.chromium.base.PathUtils;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskRunner;
import org.chromium.base.task.TaskTraits;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

/**
 * Service that keeps record of UMA method calls in nonembedded WebView processes.
 */
public final class MetricsBridgeService extends Service {
    private static final String TAG = "MetricsBridgeService";

    // Max histograms this service will store, arbitrarily chosen
    private static final int MAX_HISTOGRAM_COUNT = 512;

    private static final String LOG_FILE_NAME = "webview_metrics_bridge_logs";

    private final File mLogFile;

    // Not guarded by a lock because it should only be accessed in a SequencedTaskRunner.
    private FileOutputStream mFileOutputStream;
    private List<byte[]> mRecordsList = new ArrayList<>();

    // To avoid any potential synchronization issues as well as avoid blocking the caller thread
    // (e.g when the caller is a thread from the same process.), we post all read/write operations
    // to be run serially using a SequencedTaskRunner instead of using a lock.
    private static final TaskRunner sSequencedTaskRunner =
            PostTask.createSequencedTaskRunner(TaskTraits.BEST_EFFORT_MAY_BLOCK);

    // These values are persisted to logs. Entries should not be renumbered and
    // numeric values should never be reused.
    @VisibleForTesting
    @IntDef({ParsingLogResult.SUCCESS, ParsingLogResult.MALFORMED_PROTOBUF,
            ParsingLogResult.IO_EXCEPTION})
    public @interface ParsingLogResult {
        int SUCCESS = 0;
        int MALFORMED_PROTOBUF = 1;
        int IO_EXCEPTION = 2;
        int COUNT = 3;
    }

    // Adding a histogram record to list and not calling base.metrics.RecordHistogram to avoid the
    // service calling itself.
    private void logParsingLogResult(@ParsingLogResult int sample) {
        // Similar to calling RecordHistogram.recordEnumeratedHistogram(
        //        "Android.WebView.NonEmbeddedMetrics.ParsingLogResult", sample,
        //        ParsingLogResult.COUNT);
        HistogramRecord record =
                HistogramRecord.newBuilder()
                        .setRecordType(RecordType.HISTOGRAM_LINEAR)
                        .setHistogramName("Android.WebView.NonEmbeddedMetrics.ParsingLogResult")
                        .setSample(sample)
                        .setMin(1)
                        .setMax(ParsingLogResult.COUNT)
                        .setNumBuckets(ParsingLogResult.COUNT + 1)
                        .build();
        // Add to the in-memory list but never written to file to avoid filling up the record list
        // and file with redundant records. However, this means when this record is sent to embedded
        // WebView it represents the parsing result for the most recent service start only.
        mRecordsList.add(record.toByteArray());
    }

    // These values are persisted to logs. Entries should not be renumbered and
    // numeric values should never be reused.
    @VisibleForTesting
    @IntDef({RetrieveMetricsTaskStatus.SUCCESS, RetrieveMetricsTaskStatus.EXECUTION_EXCEPTION,
            RetrieveMetricsTaskStatus.INTERRUPTED_EXCEPTION})
    public @interface RetrieveMetricsTaskStatus {
        int SUCCESS = 0;
        int EXECUTION_EXCEPTION = 1;
        int INTERRUPTED_EXCEPTION = 2;
        int COUNT = 3;
    }

    // Build a histogram record synchronously so it can be included in the batch of records sent to
    // the client instead of calling the base.metrics.RecordHistogram API (which is async and will
    // log in the next batch of records). This histogram captures errors that might happen when the
    // service is unable to send the current batch to the client. That's why this has to be added to
    // the current batch being sent.
    private static byte[] logRetrieveMetricsTaskStatus(@RetrieveMetricsTaskStatus int sample) {
        // Similar to calling RecordHistogram.recordEnumeratedHistogram(
        //        "Android.WebView.NonEmbeddedMetrics.RetrieveMetricsTaskStatus", sample,
        //        RetrieveMetricsTaskStatus.COUNT);
        HistogramRecord record =
                HistogramRecord.newBuilder()
                        .setRecordType(RecordType.HISTOGRAM_LINEAR)
                        .setHistogramName(
                                "Android.WebView.NonEmbeddedMetrics.RetrieveMetricsTaskStatus")
                        .setSample(sample)
                        .setMin(1)
                        .setMax(RetrieveMetricsTaskStatus.COUNT)
                        .setNumBuckets(ParsingLogResult.COUNT + 1)
                        .build();
        return record.toByteArray();
    }

    @Override
    public void onCreate() {
        // Restore saved histograms from disk.
        sSequencedTaskRunner.postTask(() -> {
            File file = getMetricsLogFile();
            if (!file.exists()) return;
            try (FileInputStream in = new FileInputStream(file)) {
                HistogramRecord proto;
                while ((proto = HistogramRecord.parseDelimitedFrom(in)) != null) {
                    // The proto message object isn't needed anymore, we will store its byte
                    // serialization.
                    mRecordsList.add(proto.toByteArray());
                }
                logParsingLogResult(ParsingLogResult.SUCCESS);
            } catch (InvalidProtocolBufferException | IllegalStateException e) {
                Log.e(TAG, "Malformed metrics log proto", e);
                logParsingLogResult(ParsingLogResult.MALFORMED_PROTOBUF);
                deleteMetricsLogFile();
            } catch (IOException e) {
                logParsingLogResult(ParsingLogResult.IO_EXCEPTION);
                Log.e(TAG, "Failed reading proto log file", e);
            }
        });
    }

    public MetricsBridgeService() {
        this(new File(PathUtils.getDataDirectory(), LOG_FILE_NAME));
    }

    @VisibleForTesting
    // Inject a logFile for testing.
    public MetricsBridgeService(File logFile) {
        mLogFile = logFile;
    }

    private final IMetricsBridgeService.Stub mBinder = new IMetricsBridgeService.Stub() {
        @Override
        public void recordMetrics(byte[] data) {
            if (Binder.getCallingUid() != Process.myUid()) {
                throw new SecurityException(
                        "recordMetrics() may only be called by non-embedded WebView processes");
            }
            // If this is called within the same process, it will run on the caller thread, so we
            // will always punt this to thread pool.
            sSequencedTaskRunner.postTask(() -> {
                // Make sure that we don't add records indefinitely in case of no embedded
                // WebView connects to the service to retrieve and clear the records.
                if (mRecordsList.size() >= MAX_HISTOGRAM_COUNT) {
                    // TODO(https://crbug.com/1088467) add a histogram to log the number of dropped
                    // histograms.
                    Log.w(TAG, "retained records has reached the max capacity, dropping record");
                    return;
                }
                try {
                    // Parse data to make sure it's valid HistogramRecord byte data.
                    HistogramRecord proto = HistogramRecord.parseFrom(data);
                    mRecordsList.add(data);
                    // Append the histogram record to log file.
                    FileOutputStream out = getMetricsLogOutputStream();
                    proto.writeDelimitedTo(out);
                    // Flush the stream to make sure the bytes are written to file in cases when the
                    // service isn't closed gracefully.
                    out.flush();
                } catch (InvalidProtocolBufferException e) {
                    Log.e(TAG, "Malformed metrics log proto", e);
                } catch (IOException e) {
                    Log.e(TAG, "Failed to write to file", e);
                }
            });
        }

        @Override
        public List<byte[]> retrieveNonembeddedMetrics() {
            FutureTask<List<byte[]>> retrieveFutureTask = new FutureTask<>(() -> {
                List<byte[]> list = mRecordsList;
                mRecordsList = new ArrayList<>();
                deleteMetricsLogFile();
                list.add(logRetrieveMetricsTaskStatus(RetrieveMetricsTaskStatus.SUCCESS));
                return list;
            });
            sSequencedTaskRunner.postTask(retrieveFutureTask);
            try {
                return retrieveFutureTask.get();
            } catch (ExecutionException e) {
                Log.e(TAG, "error executing retrieveNonembeddedMetrics future task", e);
                return Collections.singletonList(logRetrieveMetricsTaskStatus(
                        RetrieveMetricsTaskStatus.EXECUTION_EXCEPTION));
            } catch (InterruptedException e) {
                Log.e(TAG, "retrieveNonembeddedMetrics future task interrupted", e);
                return Collections.singletonList(logRetrieveMetricsTaskStatus(
                        RetrieveMetricsTaskStatus.INTERRUPTED_EXCEPTION));
            }
        }
    };

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

    private File getMetricsLogFile() {
        return mLogFile;
    }

    private FileOutputStream getMetricsLogOutputStream() throws IOException {
        if (mFileOutputStream == null) {
            mFileOutputStream = new FileOutputStream(getMetricsLogFile(), /* append */ true);
        }
        return mFileOutputStream;
    }

    private void closeMetricsLogOutputStream() {
        try {
            if (mFileOutputStream != null) {
                mFileOutputStream.close();
            }
        } catch (IOException e) {
            Log.e(TAG, "Couldn't close file output stream", e);
        } finally {
            mFileOutputStream = null;
        }
    }

    private boolean deleteMetricsLogFile() {
        closeMetricsLogOutputStream();
        return getMetricsLogFile().delete();
    }

    @Override
    public void onDestroy() {
        closeMetricsLogOutputStream();
    }

    /**
     * Add a FutureTask that can be used to block until all the tasks in the local
     * {@code sSequencedTaskRunner} are finished for testing.
     */
    @VisibleForTesting
    public FutureTask addTaskToBlock() {
        FutureTask<Object> blockTask = new FutureTask<Object>(() -> {}, new Object());
        sSequencedTaskRunner.postTask(blockTask);
        return blockTask;
    }
}