/*
 * Copyright (C) 2016 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.tradefed.config.Option;
import com.android.tradefed.config.Option.Importance;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
import com.android.tradefed.result.ITestInvocationListener;
import com.android.tradefed.result.TestDescription;
import com.android.tradefed.testtype.IDeviceTest;
import com.android.tradefed.testtype.IRemoteTest;
import com.android.tradefed.util.AbiFormatter;
import com.android.tradefed.util.SimplePerfResult;
import com.android.tradefed.util.SimplePerfUtil;
import com.android.tradefed.util.SimplePerfUtil.SimplePerfType;
import com.android.tradefed.util.SimpleStats;
import com.android.tradefed.util.proto.TfMetricProtoUtil;

import org.junit.Assert;

import java.text.NumberFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** This test is targeting eMMC performance on read/ write. */
public class EmmcPerformanceTest implements IDeviceTest, IRemoteTest {
    private enum TestType {
        DD,
        RANDOM;
    }

    private static final String RUN_KEY = "emmc_performance_tests";

    private static final String SEQUENTIAL_READ_KEY = "sequential_read";
    private static final String SEQUENTIAL_WRITE_KEY = "sequential_write";
    private static final String RANDOM_READ_KEY = "random_read";
    private static final String RANDOM_WRITE_KEY = "random_write";
    private static final String PERF_RANDOM = "/data/local/tmp/rand_emmc_perf|#ABI32#|";

    private static final Pattern DD_PATTERN =
            Pattern.compile("\\d+ bytes transferred in \\d+\\.\\d+ secs \\((\\d+) bytes/sec\\)");

    private static final Pattern EMMC_RANDOM_PATTERN =
            Pattern.compile("(\\d+) (\\d+)byte iops/sec");
    private static final int BLOCK_SIZE = 1048576;
    private static final int SEQ_COUNT = 200;

    @Option(name = "cpufreq", description = "The path to the cpufreq directory on the DUT.")
    private String mCpufreq = "/sys/devices/system/cpu/cpu0/cpufreq";

    @Option(
            name = "auto-discover-cache-info",
            description =
                    "Indicate if test should attempt auto discover cache path and partition size "
                            + "from the test device. Default to be false, ie. manually set "
                            + "cache-device and cache-partition-size, or use default."
                            + " If fail to discover, it will fallback to what is set in "
                            + "cache-device")
    private boolean mAutoDiscoverCacheInfo = false;

    @Option(
            name = "cache-device",
            description =
                    "The path to the cache block device on the DUT."
                            + "  Nakasi: /dev/block/platform/sdhci-tegra.3/by-name/CAC\n"
                            + "  Prime: /dev/block/platform/omap/omap_hsmmc.0/by-name/cache\n"
                            + "  Stingray: /dev/block/platform/sdhci-tegra.3/by-name/cache\n"
                            + "  Crespo: /dev/block/platform/s3c-sdhci.0/by-name/userdata\n",
            importance = Importance.IF_UNSET)
    private String mCache = null;

    @Option(name = "iterations", description = "The number of iterations to run")
    private int mIterations = 100;

    @Option(
            name = AbiFormatter.FORCE_ABI_STRING,
            description = AbiFormatter.FORCE_ABI_DESCRIPTION,
            importance = Importance.IF_UNSET)
    private String mForceAbi = null;

    @Option(name = "cache-partition-size", description = "Cache partiton size in MB")
    private static int mCachePartitionSize = 100;

    @Option(
            name = "simpleperf-mode",
            description = "Whether use simpleperf to get low level metrics")
    private boolean mSimpleperfMode = false;

    @Option(name = "simpleperf-argu", description = "simpleperf arguments")
    private List<String> mSimpleperfArgu = new ArrayList<>();

    ITestDevice mTestDevice = null;
    SimplePerfUtil mSpUtil = null;

    /** {@inheritDoc} */
    @Override
    public void run(ITestInvocationListener listener) throws DeviceNotAvailableException {
        try {
            setUp();

            listener.testRunStarted(RUN_KEY, 5);
            long beginTime = System.currentTimeMillis();
            Map<String, String> metrics = new HashMap<>();

            runSequentialRead(mIterations, listener, metrics);
            runSequentialWrite(mIterations, listener, metrics);
            // FIXME: Figure out cache issues with random read and reenable test.
            // runRandomRead(mIterations, listener, metrics);
            // runRandomWrite(mIterations, listener, metrics);

            CLog.d("Metrics: %s", metrics.toString());
            listener.testRunEnded(
                    (System.currentTimeMillis() - beginTime),
                    TfMetricProtoUtil.upgradeConvert(metrics));
        } finally {
            cleanUp();
        }
    }

    /** Run the sequential read test. */
    private void runSequentialRead(
            int iterations, ITestInvocationListener listener, Map<String, String> metrics)
            throws DeviceNotAvailableException {
        String command =
                String.format(
                        "dd if=%s of=/dev/null bs=%d count=%d", mCache, BLOCK_SIZE, SEQ_COUNT);
        runTest(SEQUENTIAL_READ_KEY, command, TestType.DD, true, iterations, listener, metrics);
    }

    /** Run the sequential write test. */
    private void runSequentialWrite(
            int iterations, ITestInvocationListener listener, Map<String, String> metrics)
            throws DeviceNotAvailableException {
        String command =
                String.format(
                        "dd if=/dev/zero of=%s bs=%d count=%d", mCache, BLOCK_SIZE, SEQ_COUNT);
        runTest(SEQUENTIAL_WRITE_KEY, command, TestType.DD, false, iterations, listener, metrics);
    }

    /** Run the random read test. */
    @SuppressWarnings("unused")
    private void runRandomRead(
            int iterations, ITestInvocationListener listener, Map<String, String> metrics)
            throws DeviceNotAvailableException {
        String command =
                String.format(
                        "%s -r %d %s",
                        AbiFormatter.formatCmdForAbi(PERF_RANDOM, mForceAbi),
                        mCachePartitionSize,
                        mCache);
        runTest(RANDOM_READ_KEY, command, TestType.RANDOM, true, iterations, listener, metrics);
    }

    /** Run the random write test with OSYNC disabled. */
    private void runRandomWrite(
            int iterations, ITestInvocationListener listener, Map<String, String> metrics)
            throws DeviceNotAvailableException {
        String command =
                String.format(
                        "%s -w %d %s",
                        AbiFormatter.formatCmdForAbi(PERF_RANDOM, mForceAbi),
                        mCachePartitionSize,
                        mCache);
        runTest(RANDOM_WRITE_KEY, command, TestType.RANDOM, false, iterations, listener, metrics);
    }

    /**
     * Run a test for a number of iterations.
     *
     * @param testKey the key used to report metrics.
     * @param command the command to be run on the device.
     * @param type the {@link TestType}, which determines how each iteration should be run.
     * @param dropCache whether to drop the cache before starting each iteration.
     * @param iterations the number of iterations to run.
     * @param listener the {@link ITestInvocationListener}.
     * @param metrics the map to store metrics of.
     * @throws DeviceNotAvailableException If the device was not available.
     */
    private void runTest(
            String testKey,
            String command,
            TestType type,
            boolean dropCache,
            int iterations,
            ITestInvocationListener listener,
            Map<String, String> metrics)
            throws DeviceNotAvailableException {
        CLog.i("Starting test %s", testKey);

        TestDescription id = new TestDescription(RUN_KEY, testKey);
        listener.testStarted(id);

        Map<String, SimpleStats> simpleperfMetricsMap = new HashMap<>();
        SimpleStats stats = new SimpleStats();
        for (int i = 0; i < iterations; i++) {
            if (dropCache) {
                dropCache();
            }

            Double kbps = null;
            switch (type) {
                case DD:
                    kbps = runDdIteration(command, simpleperfMetricsMap);
                    break;
                case RANDOM:
                    kbps = runRandomIteration(command, simpleperfMetricsMap);
                    break;
            }

            if (kbps != null) {
                CLog.i("Result for %s, iteration %d: %f KBps", testKey, i + 1, kbps);
                stats.add(kbps);
            } else {
                CLog.w("Skipping %s, iteration %d", testKey, i + 1);
            }
        }

        if (stats.mean() != null) {
            metrics.put(testKey, Double.toString(stats.median()));
            for (Map.Entry<String, SimpleStats> entry : simpleperfMetricsMap.entrySet()) {
                metrics.put(
                        String.format("%s_%s", testKey, entry.getKey()),
                        Double.toString(entry.getValue().median()));
            }
        } else {
            listener.testFailed(id, "No metrics to report (see log)");
        }
        CLog.i(
                "Test %s finished: mean=%f, stdev=%f, samples=%d",
                testKey, stats.mean(), stats.stdev(), stats.size());
        listener.testEnded(id, new HashMap<String, Metric>());
    }

    /**
     * Run a single iteration of the dd (sequential) test.
     *
     * @param command the command to run on the device.
     * @param simpleperfMetricsMap the map contain simpleperf metrics aggregated results
     * @return The speed of the test in KBps or null if there was an error running or parsing the
     *     test.
     * @throws DeviceNotAvailableException If the device was not available.
     */
    private Double runDdIteration(String command, Map<String, SimpleStats> simpleperfMetricsMap)
            throws DeviceNotAvailableException {
        String[] output;
        SimplePerfResult spResult = null;
        if (mSimpleperfMode) {
            spResult = mSpUtil.executeCommand(command);
            output = spResult.getCommandRawOutput().split("\n");
        } else {
            output = mTestDevice.executeShellCommand(command).split("\n");
        }
        String line = output[output.length - 1].trim();

        Matcher m = DD_PATTERN.matcher(line);
        if (m.matches()) {
            simpleperfResultAggregation(spResult, simpleperfMetricsMap);
            return convertBpsToKBps(Double.parseDouble(m.group(1)));
        } else {
            CLog.w("Line \"%s\" did not match expected output, ignoring", line);
            return null;
        }
    }

    /**
     * Run a single iteration of the random test.
     *
     * @param command the command to run on the device.
     * @param simpleperfMetricsMap the map contain simpleperf metrics aggregated results
     * @return The speed of the test in KBps or null if there was an error running or parsing the
     *     test.
     * @throws DeviceNotAvailableException If the device was not available.
     */
    private Double runRandomIteration(String command, Map<String, SimpleStats> simpleperfMetricsMap)
            throws DeviceNotAvailableException {
        String output;
        SimplePerfResult spResult = null;
        if (mSimpleperfMode) {
            spResult = mSpUtil.executeCommand(command);
            output = spResult.getCommandRawOutput();
        } else {
            output = mTestDevice.executeShellCommand(command);
        }
        Matcher m = EMMC_RANDOM_PATTERN.matcher(output.trim());
        if (m.matches()) {
            simpleperfResultAggregation(spResult, simpleperfMetricsMap);
            return convertIopsToKBps(Double.parseDouble(m.group(1)));
        } else {
            CLog.w("Line \"%s\" did not match expected output, ignoring", output);
            return null;
        }
    }

    /**
     * Helper function to aggregate simpleperf results
     *
     * @param spResult object that holds simpleperf results
     * @param simpleperfMetricsMap map holds aggregated simpleperf results
     */
    private void simpleperfResultAggregation(
            SimplePerfResult spResult, Map<String, SimpleStats> simpleperfMetricsMap) {
        if (mSimpleperfMode) {
            Assert.assertNotNull("simpleperf result is null object", spResult);
            for (Map.Entry<String, String> entry : spResult.getBenchmarkMetrics().entrySet()) {
                try {
                    Double metricValue =
                            NumberFormat.getNumberInstance(Locale.US)
                                    .parse(entry.getValue())
                                    .doubleValue();
                    if (!simpleperfMetricsMap.containsKey(entry.getKey())) {
                        SimpleStats newStat = new SimpleStats();
                        simpleperfMetricsMap.put(entry.getKey(), newStat);
                    }
                    simpleperfMetricsMap.get(entry.getKey()).add(metricValue);
                } catch (ParseException e) {
                    CLog.e("Simpleperf metrics parse failure: " + e.toString());
                }
            }
        }
    }

    /** Drop the disk cache on the device. */
    private void dropCache() throws DeviceNotAvailableException {
        mTestDevice.executeShellCommand("echo 3 > /proc/sys/vm/drop_caches");
    }

    /** Convert bytes / sec reported by the dd tests into KBps. */
    private double convertBpsToKBps(double bps) {
        return bps / 1024;
    }

    /**
     * Convert the iops reported by the random tests into KBps.
     *
     * <p>The iops is number of 4kB block reads/writes per sec. This makes the conversion factor 4.
     */
    private double convertIopsToKBps(double iops) {
        return 4 * iops;
    }

    /** Setup the device for tests by unmounting partitions and maxing the cpu speed. */
    private void setUp() throws DeviceNotAvailableException {
        if (mAutoDiscoverCacheInfo) {
            discoverCacheInfo();
        }
        mTestDevice.executeShellCommand("umount /sdcard");
        mTestDevice.executeShellCommand("umount /data");
        mTestDevice.executeShellCommand("umount /cache");

        mTestDevice.executeShellCommand(
                String.format("cat %s/cpuinfo_max_freq > %s/scaling_max_freq", mCpufreq, mCpufreq));
        mTestDevice.executeShellCommand(
                String.format("cat %s/cpuinfo_max_freq > %s/scaling_min_freq", mCpufreq, mCpufreq));

        if (mSimpleperfMode) {
            mSpUtil = SimplePerfUtil.newInstance(mTestDevice, SimplePerfType.STAT);
            if (mSimpleperfArgu.size() == 0) {
                mSimpleperfArgu.add("-e cpu-cycles:k,cpu-cycles:u");
            }
            mSpUtil.setArgumentList(mSimpleperfArgu);
        }
    }

    /** Attempt to detect cache path and cache partition size automatically */
    private void discoverCacheInfo() throws DeviceNotAvailableException {
        // Expected output look similar to the following:
        //
        // > ... vdc dump | grep cache
        // 0 4123 /dev/block/platform/soc/7824900.sdhci/by-name/cache /cache ext4 rw, \
        // seclabel,nosuid,nodev,noatime,discard,data=ordered 0 0
        if (mTestDevice.enableAdbRoot()) {
            String output = mTestDevice.executeShellCommand("vdc dump | grep cache");
            CLog.d("Output from shell command 'vdc dump | grep cache':\n%s", output);
            String[] segments = output.split("\\s+");
            if (segments.length >= 3) {
                mCache = segments[2];
            } else {
                CLog.w("Fail to detect cache path. Fall back to use '%s'", mCache);
            }
        } else {
            CLog.d(
                    "Cannot get cache path because device %s is not rooted.",
                    mTestDevice.getSerialNumber());
        }

        // Expected output looks similar to the following:
        //
        // > ... df cache
        // Filesystem            1K-blocks Used Available Use% Mounted on
        // /dev/block/mmcblk0p34     60400   56     60344   1% /cache
        String output = mTestDevice.executeShellCommand("df cache");
        CLog.d(String.format("Output from shell command 'df cache':\n%s", output));
        String[] lines = output.split("\r?\n");
        if (lines.length >= 2) {
            String[] segments = lines[1].split("\\s+");
            if (segments.length >= 2) {
                if (lines[0].toLowerCase().contains("1k-blocks")) {
                    mCachePartitionSize = Integer.parseInt(segments[1]) / 1024;
                } else {
                    throw new IllegalArgumentException("Unknown unit for the cache size.");
                }
            }
        }

        CLog.d("cache-device is set to %s ...", mCache);
        CLog.d("cache-partition-size is set to %d ...", mCachePartitionSize);
    }

    /** Clean up the device by formatting a new cache partition. */
    private void cleanUp() throws DeviceNotAvailableException {
        mTestDevice.executeShellCommand(String.format("mke2fs %s", mCache));
    }

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

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