/*
 * Copyright (C) 2011 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.performance.tests;

import com.android.ddmlib.IDevice;
import com.android.ddmlib.MultiLineReceiver;
import com.android.ddmlib.NullOutputReceiver;
import com.android.tradefed.config.Option;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.result.FileInputStreamSource;
import com.android.tradefed.result.ITestInvocationListener;
import com.android.tradefed.result.InputStreamSource;
import com.android.tradefed.result.LogDataType;
import com.android.tradefed.testtype.IDeviceTest;
import com.android.tradefed.testtype.IRemoteTest;
import com.android.tradefed.util.FileUtil;
import com.android.tradefed.util.StreamUtil;
import com.android.tradefed.util.proto.TfMetricProtoUtil;

import junit.framework.TestCase;

import org.junit.Assert;

import java.io.File;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * Runs the FIO benchmarks.
 *
 * <p>This test pushes the FIO executable to the device and runs several benchmarks. Running each
 * benchmark consists of creating a config file, creating one or more data files, clearing the disk
 * cache and then running FIO. The test runs a variety of different configurations including a
 * simple benchmark with a single thread, a storage benchmark with 4 threads, a media server
 * emulator, and a media scanner emulator.
 */
public class FioBenchmarkTest implements IDeviceTest, IRemoteTest {
    // TODO: Refactor this to only pick out fields we care about.
    private static final String[] FIO_V0_RESULT_FIELDS = {
        "jobname",
        "groupid",
        "error",
        // Read stats
        "read-kb-io",
        "read-bandwidth",
        "read-runtime",
        "read-slat-min",
        "read-slat-max",
        "read-slat-mean",
        "read-slat-stddev",
        "read-clat-min",
        "read-clat-max",
        "read-clat-mean",
        "read-clat-stddev",
        "read-bandwidth-min",
        "read-bandwidth-max",
        "read-bandwidth-percent",
        "read-bandwidth-mean",
        "read-bandwidth-stddev",
        // Write stats
        "write-kb-io",
        "write-bandwidth",
        "write-runtime",
        "write-slat-min",
        "write-slat-max",
        "write-slat-mean",
        "write-slat-stddev",
        "write-clat-min",
        "write-clat-max",
        "write-clat-mean",
        "write-clat-stddev",
        "write-bandwidth-min",
        "write-bandwidth-max",
        "write-bandwidth-percent",
        "write-bandwidth-mean",
        "write-bandwidth-stddev",
        // CPU stats
        "cpu-user",
        "cpu-system",
        "cpu-context-switches",
        "cpu-major-page-faults",
        "cpu-minor-page-faults",
        // IO depth stats
        "io-depth-1",
        "io-depth-2",
        "io-depth-4",
        "io-depth-8",
        "io-depth-16",
        "io-depth-32",
        "io-depth-64",
        // IO lat stats
        "io-lat-2-ms",
        "io-lat-4-ms",
        "io-lat-10-ms",
        "io-lat-20-ms",
        "io-lat-50-ms",
        "io-lat-100-ms",
        "io-lat-250-ms",
        "io-lat-500-ms",
        "io-lat-750-ms",
        "io-lat-1000-ms",
        "io-lat-2000-ms"
    };
    private static final String[] FIO_V3_RESULT_FIELDS = {
        "terse-version",
        "fio-version",
        "jobname",
        "groupid",
        "error",
        // Read stats
        "read-kb-io",
        "read-bandwidth",
        "read-iops",
        "read-runtime",
        "read-slat-min",
        "read-slat-max",
        "read-slat-mean",
        "read-slat-stddev",
        "read-clat-min",
        "read-clat-max",
        "read-clat-mean",
        "read-clat-stddev",
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        "read-lat-min",
        "read-lat-max",
        "read-lat-mean",
        "read-lat-stddev",
        "read-bandwidth-min",
        "read-bandwidth-max",
        "read-bandwidth-percent",
        "read-bandwidth-mean",
        "read-bandwidth-stddev",
        // Write stats
        "write-kb-io",
        "write-bandwidth",
        "write-iops",
        "write-runtime",
        "write-slat-min",
        "write-slat-max",
        "write-slat-mean",
        "write-slat-stddev",
        "write-clat-min",
        "write-clat-max",
        "write-clat-mean",
        "write-clat-stddev",
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        null,
        "write-lat-min",
        "write-lat-max",
        "write-lat-mean",
        "write-lat-stddev",
        "write-bandwidth-min",
        "write-bandwidth-max",
        "write-bandwidth-percent",
        "write-bandwidth-mean",
        "write-bandwidth-stddev",
        // CPU stats
        "cpu-user",
        "cpu-system",
        "cpu-context-switches",
        "cpu-major-page-faults",
        "cpu-minor-page-faults",
        // IO depth stats
        "io-depth-1",
        "io-depth-2",
        "io-depth-4",
        "io-depth-8",
        "io-depth-16",
        "io-depth-32",
        "io-depth-64",
        // IO lat stats
        "io-lat-2-us",
        "io-lat-4-us",
        "io-lat-10-us",
        "io-lat-20-us",
        "io-lat-50-us",
        "io-lat-100-us",
        "io-lat-250-us",
        "io-lat-500-us",
        "io-lat-750-us",
        "io-lat-1000-us",
        "io-lat-2-ms",
        "io-lat-4-ms",
        "io-lat-10-ms",
        "io-lat-20-ms",
        "io-lat-50-ms",
        "io-lat-100-ms",
        "io-lat-250-ms",
        "io-lat-500-ms",
        "io-lat-750-ms",
        "io-lat-1000-ms",
        "io-lat-2000-ms",
        "io-lat-greater"
    };

    private List<TestInfo> mTestCases = null;

    /**
     * Holds info about a job. The job translates into a job in the FIO config file. Contains the
     * job name and a map from keys to values.
     */
    private static class JobInfo {
        public String mJobName = null;
        public Map<String, String> mParameters = new HashMap<>();

        /**
         * Gets the job as a string.
         *
         * @return a string of the job formatted for the config file.
         */
        public String createJob() {
            if (mJobName == null) {
                return "";
            }
            StringBuilder sb = new StringBuilder();
            sb.append(String.format("[%s]\n", mJobName));
            for (Entry<String, String> parameter : mParameters.entrySet()) {
                if (parameter.getValue() == null) {
                    sb.append(String.format("%s\n", parameter.getKey()));
                } else {
                    sb.append(String.format("%s=%s\n", parameter.getKey(), parameter.getValue()));
                }
            }
            return sb.toString();
        }
    }

    /**
     * Holds info about a file used in the benchmark. Because of limitations in FIO on Android, the
     * file needs to be created before the tests are run. Contains the file name and size in kB.
     */
    private static class TestFileInfo {
        public String mFileName = null;
        public int mSize = -1;
    }

    /** Holds info about the perf metric that are cared about for a given job. */
    private static class PerfMetricInfo {
        public enum ResultType {
            STRING,
            INT,
            FLOAT,
            PERCENT;

            String value(String input) {
                switch (this) {
                    case STRING:
                    case INT:
                    case FLOAT:
                        return input;
                    case PERCENT:
                        if (input.length() < 2 || !input.endsWith("%")) {
                            return null;
                        }
                        try {
                            return String.format(
                                    "%f",
                                    Double.parseDouble(input.substring(0, input.length() - 1))
                                            / 100);
                        } catch (NumberFormatException e) {
                            return null;
                        }
                    default:
                        return null;
                }
            }
        }

        public String mJobName = null;
        public String mFieldName = null;
        public String mPostKey = null;
        public ResultType mType = ResultType.STRING;
    }

    /**
     * Holds the info associated with a test.
     *
     * <p>Contains the test name, key, a list of {@link JobInfo}, a set of {@link TestFileInfo}, and
     * a set of {@link PerfMetricInfo}.
     */
    private static class TestInfo {
        public String mTestName = null;
        public String mKey = null;
        public List<JobInfo> mJobs = new LinkedList<>();
        public Set<TestFileInfo> mTestFiles = new HashSet<>();
        public Set<PerfMetricInfo> mPerfMetrics = new HashSet<>();

        /**
         * Gets the config file.
         *
         * @return a string containing the contents of the config file needed to run the benchmark.
         */
        private String createConfig() {
            StringBuilder sb = new StringBuilder();
            for (JobInfo job : mJobs) {
                sb.append(String.format("%s\n", job.createJob()));
            }
            return sb.toString();
        }
    }

    /**
     * Parses the output of the FIO and allows the values to be looked up by job name and property.
     */
    private static class FioParser extends MultiLineReceiver {
        public Map<String, Map<String, String>> mResults = new HashMap<>();

        /**
         * Gets the result for a job and property, or null if the job or the property do not exist.
         *
         * @param job the name of the job.
         * @param property the name of the property. See {@code FIO_RESULT_FIELDS}.
         * @return the fio results for the job and property or null if it does not exist.
         */
        public String getResult(String job, String property) {
            if (!mResults.containsKey(job)) {
                return null;
            }
            return mResults.get(job).get(property);
        }

        /** {@inheritDoc} */
        @Override
        public void processNewLines(String[] lines) {
            for (String line : lines) {
                CLog.d(line);
                String[] fields = line.split(";");
                if (fields.length < FIO_V0_RESULT_FIELDS.length) {
                    continue;
                }
                if (fields.length < FIO_V3_RESULT_FIELDS.length) {
                    Map<String, String> r = new HashMap<>();
                    for (int i = 0; i < FIO_V0_RESULT_FIELDS.length; i++) {
                        r.put(FIO_V0_RESULT_FIELDS[i], fields[i]);
                    }
                    mResults.put(fields[0], r); // Job name is index 0
                } else if ("3".equals(fields[0])) {
                    Map<String, String> r = new HashMap<>();
                    for (int i = 0; i < FIO_V3_RESULT_FIELDS.length; i++) {
                        r.put(FIO_V3_RESULT_FIELDS[i], fields[i]);
                    }
                    mResults.put(fields[2], r); // Job name is index 2
                } else {
                    Assert.fail("Unknown fio terse output version");
                }
            }
        }

        /** {@inheritDoc} */
        @Override
        public boolean isCancelled() {
            return false;
        }
    }

    ITestDevice mTestDevice = null;

    private String mFioDir = null;
    private String mFioBin = null;
    private String mFioConfig = null;

    @Option(
            name = "fio-location",
            description =
                    "The path to the precompiled FIO executable. If "
                            + "unset, try to use fio from the system image.")
    private String mFioLocation = null;

    @Option(name = "tmp-dir", description = "The directory used for interal benchmarks.")
    private String mTmpDir = "/data/tmp/fio";

    @Option(name = "internal-test-dir", description = "The directory used for interal benchmarks.")
    private String mInternalTestDir = "/data/fio/data";

    @Option(name = "media-test-dir", description = "The directory used for media benchmarks.")
    private String mMediaTestDir = "${EXTERNAL_STORAGE}/fio";

    @Option(name = "external-test-dir", description = "The directory used for external benchmarks.")
    private String mExternalTestDir = "${EXTERNAL_STORAGE}/fio";

    @Option(name = "collect-yaffs-logs", description = "Collect yaffs logs before and after tests")
    private Boolean mCollectYaffsLogs = false;

    @Option(name = "run-simple-internal-test", description = "Run the simple internal benchmark.")
    private Boolean mRunSimpleInternalTest = true;

    @Option(
            name = "simple-internal-file-size",
            description = "The file size of the simple internal benchmark in MB.")
    private int mSimpleInternalFileSize = 256;

    @Option(name = "run-simple-external-test", description = "Run the simple external benchmark.")
    private Boolean mRunSimpleExternalTest = false;

    @Option(
            name = "simple-external-file-size",
            description = "The file size of the simple external benchmark in MB.")
    private int mSimpleExternalFileSize = 256;

    @Option(name = "run-storage-internal-test", description = "Run the storage internal benchmark.")
    private Boolean mRunStorageInternalTest = true;

    @Option(
            name = "storage-internal-file-size",
            description = "The file size of the storage internal benchmark in MB.")
    private int mStorageInternalFileSize = 256;

    @Option(
            name = "storage-internal-job-count",
            description = "The number of jobs for the storage internal benchmark.")
    private int mStorageInternalJobCount = 4;

    @Option(name = "run-storage-external-test", description = "Run the storage external benchmark.")
    private Boolean mRunStorageExternalTest = false;

    @Option(
            name = "storage-external-file-size",
            description = "The file size of the storage external benchmark in MB.")
    private int mStorageExternalFileSize = 256;

    @Option(
            name = "storage-external-job-count",
            description = "The number of jobs for the storage external benchmark.")
    private int mStorageExternalJobCount = 4;

    @Option(name = "run-media-server-test", description = "Run the media server benchmark.")
    private Boolean mRunMediaServerTest = false;

    @Option(
            name = "media-server-duration",
            description = "The duration of the media server benchmark in secs.")
    private long mMediaServerDuration = 30;

    @Option(
            name = "media-server-media-file-size",
            description = "The media file size of the media server benchmark in MB.")
    private int mMediaServerMediaFileSize = 256;

    @Option(
            name = "media-server-worker-file-size",
            description = "The worker file size of the media server benchmark in MB.")
    private int mMediaServerWorkerFileSize = 256;

    @Option(
            name = "media-server-worker-job-count",
            description = "The number of worker jobs for the media server benchmark.")
    private int mMediaServerWorkerJobCount = 4;

    @Option(name = "run-media-scanner-test", description = "Run the media scanner benchmark.")
    private Boolean mRunMediaScannerTest = false;

    @Option(
            name = "media-scanner-media-file-size",
            description = "The media file size of the media scanner benchmark in kB.")
    private int mMediaScannerMediaFileSize = 8;

    @Option(
            name = "media-scanner-media-file-count",
            description = "The number of media files to scan.")
    private int mMediaScannerMediaFileCount = 256;

    @Option(
            name = "media-scanner-worker-file-size",
            description = "The worker file size of the media scanner benchmark in MB.")
    private int mMediaScannerWorkerFileSize = 256;

    @Option(
            name = "media-scanner-worker-job-count",
            description = "The number of worker jobs for the media server benchmark.")
    private int mMediaScannerWorkerJobCount = 4;

    @Option(
            name = "key-suffix",
            description = "The suffix to add to the reporting key in order to override the default")
    private String mKeySuffix = null;

    /** Sets up all the benchmarks. */
    private void setupTests() {
        if (mTestCases != null) {
            // assume already set up
            return;
        }

        mTestCases = new LinkedList<>();

        if (mRunSimpleInternalTest) {
            addSimpleTest("read", "sync", true);
            addSimpleTest("write", "sync", true);
            addSimpleTest("randread", "sync", true);
            addSimpleTest("randwrite", "sync", true);
            addSimpleTest("randread", "mmap", true);
            addSimpleTest("randwrite", "mmap", true);
        }

        if (mRunSimpleExternalTest) {
            addSimpleTest("read", "sync", false);
            addSimpleTest("write", "sync", false);
            addSimpleTest("randread", "sync", false);
            addSimpleTest("randwrite", "sync", false);
            addSimpleTest("randread", "mmap", false);
            addSimpleTest("randwrite", "mmap", false);
        }

        if (mRunStorageInternalTest) {
            addStorageTest("read", "sync", true);
            addStorageTest("write", "sync", true);
            addStorageTest("randread", "sync", true);
            addStorageTest("randwrite", "sync", true);
            addStorageTest("randread", "mmap", true);
            addStorageTest("randwrite", "mmap", true);
        }

        if (mRunStorageExternalTest) {
            addStorageTest("read", "sync", false);
            addStorageTest("write", "sync", false);
            addStorageTest("randread", "sync", false);
            addStorageTest("randwrite", "sync", false);
            addStorageTest("randread", "mmap", false);
            addStorageTest("randwrite", "mmap", false);
        }

        if (mRunMediaServerTest) {
            addMediaServerTest("read");
            addMediaServerTest("write");
        }

        if (mRunMediaScannerTest) {
            addMediaScannerTest();
        }
    }

    /**
     * Sets up the simple FIO benchmark.
     *
     * <p>The test consists of a single process reading or writing to a file.
     *
     * @param rw the type of IO pattern. One of {@code read}, {@code write}, {@code randread}, or
     *     {@code randwrite}.
     * @param ioengine defines how the job issues I/O. Such as {@code sync}, {@code vsync}, {@code
     *     mmap}, or {@code cpuio} and others.
     * @param internal whether the test should be run on the internal (/data) partition or the
     *     external partition.
     */
    private void addSimpleTest(String rw, String ioengine, boolean internal) {
        String fileName = "testfile";
        String jobName = "job";

        String directory;
        int fileSize;

        TestInfo t = new TestInfo();
        if (internal) {
            t.mTestName = String.format("SimpleBenchmark-int-%s-%s", ioengine, rw);
            t.mKey = "fio_simple_int_benchmark";
            directory = mInternalTestDir;
            fileSize = mSimpleInternalFileSize;
        } else {
            t.mTestName = String.format("SimpleBenchmark-ext-%s-%s", ioengine, rw);
            t.mKey = "fio_simple_ext_benchmark";
            directory = mExternalTestDir;
            fileSize = mSimpleExternalFileSize;
        }

        TestFileInfo f = new TestFileInfo();
        f.mFileName = new File(directory, fileName).getAbsolutePath();
        f.mSize = fileSize * 1024; // fileSize is in MB but we want it in kB.
        t.mTestFiles.add(f);

        JobInfo j = new JobInfo();
        j.mJobName = jobName;
        j.mParameters.put("directory", directory);
        j.mParameters.put("filename", fileName);
        j.mParameters.put("fsync", "1024");
        j.mParameters.put("ioengine", ioengine);
        j.mParameters.put("rw", rw);
        j.mParameters.put("size", String.format("%dM", fileSize));
        t.mJobs.add(j);

        PerfMetricInfo m = new PerfMetricInfo();
        m.mJobName = jobName;
        if ("sync".equals(ioengine)) {
            m.mPostKey = String.format("%s_bandwidth", rw);
        } else {
            m.mPostKey = String.format("%s_%s_bandwidth", rw, ioengine);
        }
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        if (rw.endsWith("read")) {
            m.mFieldName = "read-bandwidth-mean";
        } else if (rw.endsWith("write")) {
            m.mFieldName = "write-bandwidth-mean";
        }
        t.mPerfMetrics.add(m);

        mTestCases.add(t);
    }

    /**
     * Sets up the storage FIO benchmark.
     *
     * <p>The test consists of several processes reading or writing to a file.
     *
     * @param rw the type of IO pattern. One of {@code read}, {@code write}, {@code randread}, or
     *     {@code randwrite}.
     * @param ioengine defines how the job issues I/O. Such as {@code sync}, {@code vsync}, {@code
     *     mmap}, or {@code cpuio} and others.
     * @param internal whether the test should be run on the internal (/data) partition or the
     *     external partition.
     */
    private void addStorageTest(String rw, String ioengine, boolean internal) {
        String fileName = "testfile";
        String jobName = "workers";

        String directory;
        int fileSize;
        int jobCount;

        TestInfo t = new TestInfo();
        if (internal) {
            t.mTestName = String.format("StorageBenchmark-int-%s-%s", ioengine, rw);
            t.mKey = "fio_storage_int_benchmark";
            directory = mInternalTestDir;
            fileSize = mStorageInternalFileSize;
            jobCount = mStorageInternalJobCount;
        } else {
            t.mTestName = String.format("StorageBenchmark-ext-%s-%s", ioengine, rw);
            t.mKey = "fio_storage_ext_benchmark";
            directory = mExternalTestDir;
            fileSize = mStorageExternalFileSize;
            jobCount = mStorageExternalJobCount;
        }

        TestFileInfo f = new TestFileInfo();
        f.mFileName = new File(directory, fileName).getAbsolutePath();
        f.mSize = fileSize * 1024; // fileSize is in MB but we want it in kB.
        t.mTestFiles.add(f);

        JobInfo j = new JobInfo();
        j.mJobName = jobName;
        j.mParameters.put("directory", directory);
        j.mParameters.put("filename", fileName);
        j.mParameters.put("fsync", "1024");
        j.mParameters.put("group_reporting", null);
        j.mParameters.put("ioengine", ioengine);
        j.mParameters.put("new_group", null);
        j.mParameters.put("numjobs", String.format("%d", jobCount));
        j.mParameters.put("rw", rw);
        j.mParameters.put("size", String.format("%dM", fileSize));
        t.mJobs.add(j);

        PerfMetricInfo m = new PerfMetricInfo();
        m.mJobName = jobName;
        if ("sync".equals(ioengine)) {
            m.mPostKey = String.format("%s_bandwidth", rw);
        } else {
            m.mPostKey = String.format("%s_%s_bandwidth", rw, ioengine);
        }
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        if (rw.endsWith("read")) {
            m.mFieldName = "read-bandwidth-mean";
        } else if (rw.endsWith("write")) {
            m.mFieldName = "write-bandwidth-mean";
        }
        t.mPerfMetrics.add(m);

        m = new PerfMetricInfo();
        m.mJobName = jobName;
        if ("sync".equals(ioengine)) {
            m.mPostKey = String.format("%s_latency", rw);
        } else {
            m.mPostKey = String.format("%s_%s_latency", rw, ioengine);
        }
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        if (rw.endsWith("read")) {
            m.mFieldName = "read-clat-mean";
        } else if (rw.endsWith("write")) {
            m.mFieldName = "write-clat-mean";
        }
        t.mPerfMetrics.add(m);

        mTestCases.add(t);
    }

    /**
     * Sets up the media server benchmark.
     *
     * <p>The test consists of a single process at a higher priority reading or writing to a file
     * while several other worker processes read and write to a different file.
     *
     * @param rw the type of IO pattern. One of {@code read}, {@code write}
     */
    private void addMediaServerTest(String rw) {
        String mediaJob = "media-server";
        String mediaFile = "mediafile";
        String workerJob = "workers";
        String workerFile = "workerfile";

        TestInfo t = new TestInfo();
        t.mTestName = String.format("MediaServerBenchmark-%s", rw);
        t.mKey = "fio_media_server_benchmark";

        TestFileInfo f = new TestFileInfo();
        f.mFileName = new File(mMediaTestDir, mediaFile).getAbsolutePath();
        f.mSize = mMediaServerMediaFileSize * 1024; // File size is in MB but we want it in kB.
        t.mTestFiles.add(f);

        f = new TestFileInfo();
        f.mFileName = new File(mMediaTestDir, workerFile).getAbsolutePath();
        f.mSize = mMediaServerWorkerFileSize * 1024; // File size is in MB but we want it in kB.
        t.mTestFiles.add(f);

        JobInfo j = new JobInfo();
        j.mJobName = "global";
        j.mParameters.put("directory", mMediaTestDir);
        j.mParameters.put("fsync", "1024");
        j.mParameters.put("ioengine", "sync");
        j.mParameters.put("runtime", String.format("%d", mMediaServerDuration));
        j.mParameters.put("time_based", null);
        t.mJobs.add(j);

        j = new JobInfo();
        j.mJobName = mediaJob;
        j.mParameters.put("filename", mediaFile);
        j.mParameters.put("iodepth", "32");
        j.mParameters.put("nice", "-16");
        j.mParameters.put("rate", "6m");
        j.mParameters.put("rw", rw);
        j.mParameters.put("size", String.format("%dM", mMediaServerMediaFileSize));
        t.mJobs.add(j);

        j = new JobInfo();
        j.mJobName = workerJob;
        j.mParameters.put("filename", workerFile);
        j.mParameters.put("group_reporting", null);
        j.mParameters.put("new_group", null);
        j.mParameters.put("nice", "0");
        j.mParameters.put("numjobs", String.format("%d", mMediaServerWorkerJobCount));
        j.mParameters.put("rw", "randrw");
        j.mParameters.put("size", String.format("%dM", mMediaServerWorkerFileSize));
        t.mJobs.add(j);

        PerfMetricInfo m = new PerfMetricInfo();
        m.mJobName = mediaJob;
        m.mPostKey = String.format("%s_media_bandwidth", rw);
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        if (rw.endsWith("read")) {
            m.mFieldName = "read-bandwidth-mean";
        } else if (rw.endsWith("write")) {
            m.mFieldName = "write-bandwidth-mean";
        }
        t.mPerfMetrics.add(m);

        m = new PerfMetricInfo();
        m.mJobName = mediaJob;
        m.mPostKey = String.format("%s_media_latency", rw);
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        if (rw.endsWith("read")) {
            m.mFieldName = "read-clat-mean";
        } else if (rw.endsWith("write")) {
            m.mFieldName = "write-clat-mean";
        }
        t.mPerfMetrics.add(m);

        m = new PerfMetricInfo();
        m.mJobName = workerJob;
        m.mPostKey = String.format("%s_workers_read_bandwidth", rw);
        m.mFieldName = "read-bandwidth-mean";
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        t.mPerfMetrics.add(m);

        m = new PerfMetricInfo();
        m.mJobName = workerJob;
        m.mPostKey = String.format("%s_workers_write_bandwidth", rw);
        m.mFieldName = "write-bandwidth-mean";
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        t.mPerfMetrics.add(m);

        mTestCases.add(t);
    }

    /**
     * Sets up the media scanner benchmark.
     *
     * <p>The test consists of a single process reading several small files while several other
     * worker processes read and write to a different file.
     */
    private void addMediaScannerTest() {
        String mediaJob = "media-server";
        String mediaFile = "mediafile.%d";
        String workerJob = "workers";
        String workerFile = "workerfile";

        TestInfo t = new TestInfo();
        t.mTestName = "MediaScannerBenchmark";
        t.mKey = "fio_media_scanner_benchmark";

        TestFileInfo f;
        for (int i = 0; i < mMediaScannerMediaFileCount; i++) {
            f = new TestFileInfo();
            f.mFileName = new File(mMediaTestDir, String.format(mediaFile, i)).getAbsolutePath();
            f.mSize = mMediaScannerMediaFileSize; // File size is already in kB so do nothing.
            t.mTestFiles.add(f);
        }

        f = new TestFileInfo();
        f.mFileName = new File(mMediaTestDir, workerFile).getAbsolutePath();
        f.mSize = mMediaScannerWorkerFileSize * 1024; // File size is in MB but we want it in kB.
        t.mTestFiles.add(f);

        JobInfo j = new JobInfo();
        j.mJobName = "global";
        j.mParameters.put("directory", mMediaTestDir);
        j.mParameters.put("fsync", "1024");
        j.mParameters.put("ioengine", "sync");
        t.mJobs.add(j);

        j = new JobInfo();
        j.mJobName = mediaJob;
        StringBuilder fileNames = new StringBuilder();
        fileNames.append(String.format(mediaFile, 0));
        for (int i = 1; i < mMediaScannerMediaFileCount; i++) {
            fileNames.append(String.format(":%s", String.format(mediaFile, i)));
        }
        j.mParameters.put("filename", fileNames.toString());
        j.mParameters.put("exitall", null);
        j.mParameters.put("openfiles", "4");
        j.mParameters.put("rw", "read");
        t.mJobs.add(j);

        j = new JobInfo();
        j.mJobName = workerJob;
        j.mParameters.put("filename", workerFile);
        j.mParameters.put("group_reporting", null);
        j.mParameters.put("new_group", null);
        j.mParameters.put("numjobs", String.format("%d", mMediaScannerWorkerJobCount));
        j.mParameters.put("rw", "randrw");
        j.mParameters.put("size", String.format("%dM", mMediaScannerWorkerFileSize));
        t.mJobs.add(j);

        PerfMetricInfo m = new PerfMetricInfo();
        m.mJobName = mediaJob;
        m.mPostKey = "media_bandwidth";
        m.mFieldName = "read-bandwidth-mean";
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        t.mPerfMetrics.add(m);

        m = new PerfMetricInfo();
        m.mJobName = mediaJob;
        m.mPostKey = "media_latency";
        m.mFieldName = "read-clat-mean";
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        t.mPerfMetrics.add(m);

        m = new PerfMetricInfo();
        m.mJobName = workerJob;
        m.mPostKey = "workers_read_bandwidth";
        m.mFieldName = "read-bandwidth-mean";
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        t.mPerfMetrics.add(m);

        m = new PerfMetricInfo();
        m.mJobName = workerJob;
        m.mPostKey = "workers_write_bandwidth";
        m.mFieldName = "write-bandwidth-mean";
        m.mType = PerfMetricInfo.ResultType.FLOAT;
        t.mPerfMetrics.add(m);

        mTestCases.add(t);
    }

    /**
     * Creates the directories needed to run FIO, pushes the FIO executable to the device, and stops
     * the runtime.
     *
     * @throws DeviceNotAvailableException if the device is not available.
     */
    private void setupDevice() throws DeviceNotAvailableException {
        mTestDevice.executeShellCommand("stop");
        mTestDevice.executeShellCommand(String.format("mkdir -p %s", mFioDir));
        mTestDevice.executeShellCommand(String.format("mkdir -p %s", mTmpDir));
        mTestDevice.executeShellCommand(String.format("mkdir -p %s", mInternalTestDir));
        mTestDevice.executeShellCommand(String.format("mkdir -p %s", mMediaTestDir));
        if (mExternalTestDir != null) {
            mTestDevice.executeShellCommand(String.format("mkdir -p %s", mExternalTestDir));
        }
        if (mFioLocation != null) {
            mTestDevice.pushFile(new File(mFioLocation), mFioBin);
            mTestDevice.executeShellCommand(String.format("chmod 755 %s", mFioBin));
        }
    }

    /**
     * Reverses the actions of {@link #setDevice(ITestDevice)}.
     *
     * @throws DeviceNotAvailableException If the device is not available.
     */
    private void cleanupDevice() throws DeviceNotAvailableException {
        if (mExternalTestDir != null) {
            mTestDevice.executeShellCommand(String.format("rm -r %s", mExternalTestDir));
        }
        mTestDevice.executeShellCommand(String.format("rm -r %s", mMediaTestDir));
        mTestDevice.executeShellCommand(String.format("rm -r %s", mInternalTestDir));
        mTestDevice.executeShellCommand(String.format("rm -r %s", mTmpDir));
        mTestDevice.executeShellCommand(String.format("rm -r %s", mFioDir));
        mTestDevice.executeShellCommand("start");
        mTestDevice.waitForDeviceAvailable();
    }

    /**
     * Runs a single test, including creating the test files, clearing the cache, collecting before
     * and after files, running the benchmark, and reporting the results.
     *
     * @param test the benchmark.
     * @param listener the ITestInvocationListener
     * @throws DeviceNotAvailableException if the device is not available.
     */
    private void runTest(TestInfo test, ITestInvocationListener listener)
            throws DeviceNotAvailableException {
        CLog.i("Running %s benchmark", test.mTestName);
        mTestDevice.executeShellCommand(String.format("rm -r %s/*", mTmpDir));
        mTestDevice.executeShellCommand(String.format("rm -r %s/*", mInternalTestDir));
        mTestDevice.executeShellCommand(String.format("rm -r %s/*", mMediaTestDir));
        if (mExternalTestDir != null) {
            mTestDevice.executeShellCommand(String.format("rm -r %s/*", mExternalTestDir));
        }

        for (TestFileInfo file : test.mTestFiles) {
            CLog.v("Creating file: %s, size: %dkB", file.mFileName, file.mSize);
            String cmd =
                    String.format(
                            "dd if=/dev/urandom of=%s bs=1024 count=%d",
                            file.mFileName, file.mSize);
            int timeout = file.mSize * 2 * 1000; // Timeout is 2 seconds per kB.
            mTestDevice.executeShellCommand(
                    cmd, new NullOutputReceiver(), timeout, TimeUnit.MILLISECONDS, 2);
        }

        CLog.i("Creating config");
        CLog.d("Config file:\n%s", test.createConfig());
        mTestDevice.pushString(test.createConfig(), mFioConfig);

        CLog.i("Dropping cache");
        mTestDevice.executeShellCommand("echo 3 > /proc/sys/vm/drop_caches");

        collectLogs(test, listener, "before");

        CLog.i("Running test");
        FioParser output = new FioParser();
        // Run FIO with a timeout of 1 hour.
        mTestDevice.executeShellCommand(
                String.format("%s --minimal %s", mFioBin, mFioConfig),
                output,
                60 * 60 * 1000,
                TimeUnit.MILLISECONDS,
                2);

        collectLogs(test, listener, "after");

        // Report metrics
        Map<String, String> metrics = new HashMap<>();
        String key = mKeySuffix == null ? test.mKey : test.mKey + mKeySuffix;

        listener.testRunStarted(key, 0);
        for (PerfMetricInfo m : test.mPerfMetrics) {
            if (!output.mResults.containsKey(m.mJobName)) {
                CLog.w("Job name %s was not found in the results", m.mJobName);
                continue;
            }

            String value = output.getResult(m.mJobName, m.mFieldName);
            if (value != null) {
                metrics.put(m.mPostKey, m.mType.value(value));
            } else {
                CLog.w("%s was not in results for the job %s", m.mFieldName, m.mJobName);
            }
        }

        CLog.d("About to report metrics to %s: %s", key, metrics);
        listener.testRunEnded(0, TfMetricProtoUtil.upgradeConvert(metrics));
    }

    private void collectLogs(TestInfo testInfo, ITestInvocationListener listener, String descriptor)
            throws DeviceNotAvailableException {
        if (mCollectYaffsLogs && mTestDevice.doesFileExist("/proc/yaffs")) {
            logFile(
                    "/proc/yaffs",
                    String.format("%s-yaffs-%s", testInfo.mTestName, descriptor),
                    mTestDevice,
                    listener);
        }
    }

    private void logFile(
            String remoteFileName,
            String localFileName,
            ITestDevice testDevice,
            ITestInvocationListener listener)
            throws DeviceNotAvailableException {
        File outputFile = null;
        InputStreamSource outputSource = null;
        try {
            outputFile = testDevice.pullFile(remoteFileName);
            if (outputFile != null) {
                CLog.d("Sending %d byte file %s to logosphere!", outputFile.length(), outputFile);
                outputSource = new FileInputStreamSource(outputFile);
                listener.testLog(localFileName, LogDataType.TEXT, outputSource);
            }
        } finally {
            FileUtil.deleteFile(outputFile);
            StreamUtil.cancel(outputSource);
        }
    }

    /** {@inheritDoc} */
    @Override
    public void run(ITestInvocationListener listener) throws DeviceNotAvailableException {
        Assert.assertNotNull(mTestDevice);

        mFioDir = new File(mTestDevice.getMountPoint(IDevice.MNT_DATA), "fio").getAbsolutePath();
        if (mFioLocation != null) {
            mFioBin = new File(mFioDir, "fio").getAbsolutePath();
        } else {
            mFioBin = "fio";
        }
        mFioConfig = new File(mFioDir, "config.fio").getAbsolutePath();

        setupTests();
        setupDevice();

        for (TestInfo test : mTestCases) {
            runTest(test, listener);
        }

        cleanupDevice();
    }

    /** {@inheritDoc} */
    @Override
    public void setDevice(ITestDevice device) {
        mTestDevice = device;
    }

    /** {@inheritDoc} */
    @Override
    public ITestDevice getDevice() {
        return mTestDevice;
    }

    /** A meta-test to ensure that the bits of FioBenchmarkTest are working properly. */
    public static class MetaTest extends TestCase {

        /** Test that {@link JobInfo#createJob()} properly formats a job. */
        public void testCreateJob() {
            JobInfo j = new JobInfo();
            assertEquals("", j.createJob());
            j.mJobName = "job";
            assertEquals("[job]\n", j.createJob());
            j.mParameters.put("param1", null);
            j.mParameters.put("param2", "value");
            String[] lines = j.createJob().split("\n");
            assertEquals(3, lines.length);
            assertEquals("[job]", lines[0]);
            Set<String> params = new HashSet<>(2);
            params.add(lines[1]);
            params.add(lines[2]);
            assertTrue(params.contains("param1"));
            assertTrue(params.contains("param2=value"));
        }

        /** Test that {@link TestInfo#createConfig()} properly formats a config. */
        public void testCreateConfig() {
            TestInfo t = new TestInfo();
            JobInfo j = new JobInfo();
            j.mJobName = "job1";
            j.mParameters.put("param1", "value1");
            t.mJobs.add(j);

            j = new JobInfo();
            j.mJobName = "job2";
            j.mParameters.put("param2", "value2");
            t.mJobs.add(j);

            j = new JobInfo();
            j.mJobName = "job3";
            j.mParameters.put("param3", "value3");
            t.mJobs.add(j);

            assertEquals(
                    "[job1]\nparam1=value1\n\n"
                            + "[job2]\nparam2=value2\n\n"
                            + "[job3]\nparam3=value3\n\n",
                    t.createConfig());
        }

        /**
         * Test that output lines are parsed correctly by the FioParser, invalid lines are ignored,
         * and that the various fields are accessible with {@link FioParser#getResult(String,
         * String)}.
         */
        public void testFioParser() {
            String[] lines = new String[4];
            // We build the lines up as follows (assuming FIO_RESULTS_FIELDS.length == 58):
            // 0;3;6;...;171
            // 1;4;7;...;172
            // 2;5;8;...;173
            for (int i = 0; i < 3; i++) {
                StringBuilder sb = new StringBuilder();
                sb.append(i);
                for (int j = 1; j < FIO_V0_RESULT_FIELDS.length; j++) {
                    sb.append(";");
                    sb.append(j * 3 + i);
                }
                lines[i] = sb.toString();
            }
            // A line may have an optional description on the end which we don't care about, make
            // sure it still parses.
            lines[2] = lines[2] += ";description";
            // Make sure an invalid output line does not parse.
            lines[3] = "invalid";

            FioParser p = new FioParser();
            p.processNewLines(lines);

            for (int i = 0; i < 3; i++) {
                for (int j = 0; j < FIO_V0_RESULT_FIELDS.length; j++) {
                    assertEquals(
                            String.format("job=%d, field=%s", i, FIO_V0_RESULT_FIELDS[j]),
                            String.format("%d", j * 3 + i),
                            p.getResult(String.format("%d", i), FIO_V0_RESULT_FIELDS[j]));
                }
            }
            assertNull(p.getResult("missing", "jobname"));
            assertNull(p.getResult("invalid", "jobname"));
            assertNull(p.getResult("0", "missing"));
        }

        /**
         * Test that {@link PerfMetricInfo.ResultType#value(String)} correctly transforms strings
         * based on the result type.
         */
        public void testResultTypeValue() {
            assertEquals("test", PerfMetricInfo.ResultType.STRING.value("test"));
            assertEquals("1", PerfMetricInfo.ResultType.INT.value("1"));
            assertEquals("3.14159", PerfMetricInfo.ResultType.FLOAT.value("3.14159"));
            assertEquals(
                    String.format("%f", 0.34567),
                    PerfMetricInfo.ResultType.PERCENT.value("34.567%"));
            assertNull(PerfMetricInfo.ResultType.PERCENT.value(""));
            assertNull(PerfMetricInfo.ResultType.PERCENT.value("34.567"));
            assertNull(PerfMetricInfo.ResultType.PERCENT.value("test%"));
        }
    }
}
