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

import com.android.ddmlib.testrunner.TestResult.TestStatus;
import com.android.tradefed.config.Option;
import com.android.tradefed.config.OptionClass;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.invoker.IInvocationContext;
import com.android.tradefed.invoker.TestInformation;
import com.android.tradefed.invoker.logger.CurrentInvocation;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.result.CollectingTestListener;
import com.android.tradefed.result.FileInputStreamSource;
import com.android.tradefed.result.ITestInvocationListener;
import com.android.tradefed.result.LogDataType;
import com.android.tradefed.result.TestDescription;
import com.android.tradefed.result.TestResult;
import com.android.tradefed.result.proto.FileProtoResultReporter;
import com.android.tradefed.result.proto.TestRecordProto;
import com.android.tradefed.testtype.IRemoteTest;
import com.android.tradefed.testtype.ITestFilterReceiver;
import com.android.tradefed.util.CommandResult;
import com.android.tradefed.util.FileUtil;
import com.android.tradefed.util.IRunUtil;
import com.android.tradefed.util.MultiMap;
import com.android.tradefed.util.RunUtil;
import com.android.tradefed.util.TestRecordInterpreter;
import com.android.tradefed.util.proto.TestRecordProtoUtil;

import com.google.common.annotations.VisibleForTesting;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.annotation.Nullable;

/**
 * Runs pre-recorded Android UIConductor tests in Tradefed. Each provided JSON file is treated as a
 * test case. Supports automatic retries, including file-based retries across invocations using
 * {@link UiConductorTest.ResultReporter}. See XML configurations in res/config/uicd for examples.
 *
 * <p>See Also: https://github.com/google/android-uiconductor
 * https://console.cloud.google.com/storage/browser/uicd-deps
 */
@OptionClass(alias = "uicd")
public class UiConductorTest implements IRemoteTest, ITestFilterReceiver {

    static final String MODULE_NAME = UiConductorTest.class.getSimpleName();
    static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(30L);
    static final String DEFAULT_OUTPUT_PATH = "uicd_results.pb";

    static final String INPUT_OPTION = "--input";
    static final String OUTPUT_OPTION = "--output";
    static final String DEVICES_OPTION = "--devices";
    static final String MODE_OPTION = "--mode";
    static final String GLOBAL_VARIABLE_OPTION = "--global_variable";

    static final String TEST_RESULT_PATH = "result/action_execution_result";

    /** Testing mode. */
    public enum PlayMode {
        SINGLE,
        MULTIDEVICE,
        PLAYALL,
    }

    /** Test case information, contains the test file and its metadata. */
    private static class UiConductorTestCase {
        private final String mId;
        private final String mKey;
        private final File mFile;
        private final TestDescription mDesc;

        private UiConductorTestCase(String id, String key, File file) {
            mId = id;
            mKey = key;
            mFile = file;
            mDesc = new TestDescription(MODULE_NAME, mId);
        }
    }

    @Option(name = "work-dir", description = "Optional work directory to use")
    private File mWorkDir;

    @Option(
            name = "uicd-cli-jar",
            description = "UICD CLI jar to use when running tests",
            mandatory = true)
    private File mCliJar;

    @Option(
            name = "commandline-action-executable",
            description = "Additional binaries needed by command line actions. Can be repeated.")
    private Collection<File> mBinaries = new ArrayList<>();

    @Option(
            name = "global-variables",
            description = "Global variable (uicd_key1=value1,uicd_key2=value2)")
    private MultiMap<String, String> mGlobalVariables = new MultiMap<>();

    @Option(name = "play-mode", description = "Play mode (SINGLE|MULTIDEVICE|PLAYALL)")
    private PlayMode mPlayMode = PlayMode.SINGLE;

    // Same key can have multiple test files because global-variables can be referenced using the
    // that particular key and shared across different tests.
    // Refer res/config/uicd/uiconductor-globalvariable-sample.xml for more information.
    @Option(
            name = "uicd-test",
            description = "JSON test file or directory of JSON test files to run. Can be repeated.",
            mandatory = true)
    private MultiMap<String, File> mTests = new MultiMap<>();

    @Option(name = "test-timeout", description = "Timeout for each test case")
    private Duration mTestTimeout = DEFAULT_TIMEOUT;

    @Option(name = "include-filter", description = "Regex filters used to find tests to include")
    private Set<String> mIncludeFilters = new HashSet<>();

    @Option(name = "exclude-filter", description = "Regex filters used to find tests to exclude")
    private Set<String> mExcludeFilters = new HashSet<>();

    @Option(name = "previous-results", description = "Previous output file to load when retrying")
    private File mPreviousResults;

    private IRunUtil mRunUtil;
    private Path mOutputDir;

    @Override
    public void addIncludeFilter(String filter) {
        mIncludeFilters.add(filter);
    }

    @Override
    public void addAllIncludeFilters(Set<String> filters) {
        mIncludeFilters.addAll(filters);
    }

    @Override
    public void addExcludeFilter(String filter) {
        mExcludeFilters.add(filter);
    }

    @Override
    public void addAllExcludeFilters(Set<String> filters) {
        mExcludeFilters.addAll(filters);
    }

    @Override
    public Set<String> getIncludeFilters() {
        return mIncludeFilters;
    }

    @Override
    public Set<String> getExcludeFilters() {
        return mExcludeFilters;
    }

    @Override
    public void clearIncludeFilters() {
        mIncludeFilters.clear();
    }

    @Override
    public void clearExcludeFilters() {
        mExcludeFilters.clear();
    }

    @Override
    public void run(TestInformation testInfo, ITestInvocationListener listener)
            throws DeviceNotAvailableException {
        if (!mCliJar.isFile()) {
            throw new IllegalArgumentException(
                    String.format("UICD CLI jar %s not found", mCliJar.getAbsolutePath()));
        }

        // Load and process previous results
        CollectingTestListener previousResults = this.parsePreviousResults();
        if (previousResults != null) {
            CLog.i("Loading previous results from %s", mPreviousResults);
            this.loadPreviousResults(listener, previousResults);
        }

        // Find test cases to execute
        List<UiConductorTestCase> testCases = new ArrayList<>();
        for (Map.Entry<String, File> entry : mTests.entries()) {
            String key = entry.getKey();
            File file = entry.getValue();
            testCases.addAll(getTestCases(key, file));
        }

        // Create work directory and copy binaries into it
        if (mWorkDir == null) {
            mWorkDir = createWorkDir().toFile();
        }
        mRunUtil = createRunUtil();
        mRunUtil.setWorkingDir(mWorkDir);
        for (File binary : mBinaries) {
            Path copiedBinary = copyFile(binary.toPath(), mWorkDir.toPath());
            copiedBinary.toFile().setExecutable(true);
        }
        mOutputDir = mWorkDir.toPath().resolve("output");

        // Execute test cases
        for (UiConductorTestCase testCase : testCases) {
            if (!shouldRunTestCase(testCase)) {
                CLog.d("Skipping %s", testCase.mDesc);
                continue;
            }
            // TODO(b/186141354): Revert to one module once ATS supports detailed proto results
            long runStartTime = System.currentTimeMillis();
            listener.testRunStarted(testCase.mDesc.toString(), 1);
            runTestCase(listener, testCase, testInfo.getDevices());
            listener.testRunEnded(System.currentTimeMillis() - runStartTime, Map.of());
        }
    }

    /** @return {@link IRunUtil} instance to use */
    @VisibleForTesting
    IRunUtil createRunUtil() {
        return new RunUtil();
    }

    /** @return temporary working directory to use if none is provided */
    private Path createWorkDir() {
        try {
            return FileUtil.createTempDir(MODULE_NAME, CurrentInvocation.getWorkFolder()).toPath();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    /** @return true if the test case should be executed */
    private boolean shouldRunTestCase(UiConductorTestCase testCase) {
        String testId = testCase.mDesc.toString();
        if (mExcludeFilters.stream().anyMatch(testId::matches)) {
            return false;
        }
        return mIncludeFilters.isEmpty() || mIncludeFilters.stream().anyMatch(testId::matches);
    }

    /** Execute a test case using the UICD CLI and parses the result. */
    private void runTestCase(
            ITestInvocationListener listener,
            UiConductorTestCase testCase,
            List<ITestDevice> devices) {
        listener.testStarted(testCase.mDesc, System.currentTimeMillis());

        // Execute the UICD command and handle the result
        String[] command = buildCommand(testCase, devices);
        CLog.i("Running %s (command: %s)", testCase.mDesc, Arrays.asList(command));
        CommandResult result = mRunUtil.runTimedCmd(mTestTimeout.toMillis(), command);
        switch (result.getStatus()) {
            case SUCCESS:
                CLog.i(
                        "Command succeeded, stdout = [%s], stderr = [%s].",
                        result.getStdout(), result.getStderr());
                Path resultFile = mOutputDir.resolve(testCase.mId).resolve(TEST_RESULT_PATH);
                verifyTestResultFile(listener, testCase, resultFile.toFile());
                break;
            case FAILED:
            case EXCEPTION:
                CLog.e(
                        "Command failed, stdout = [%s], stderr = [%s].",
                        result.getStdout(), result.getStderr());
                listener.testFailed(testCase.mDesc, "Command failed");
                break;
            case TIMED_OUT:
                CLog.e(
                        "Command timed out, stdout = [%s], stderr = [%s].",
                        result.getStdout(), result.getStderr());
                listener.testFailed(testCase.mDesc, "Command timed out");
                break;
        }

        listener.testEnded(testCase.mDesc, System.currentTimeMillis(), Map.of());
    }

    /** Parse a test result file and report test failures. */
    private void verifyTestResultFile(
            ITestInvocationListener listener, UiConductorTestCase testCase, File resultFile) {
        if (!resultFile.isFile()) {
            listener.testFailed(
                    testCase.mDesc, String.format("Test result file %s not found", resultFile));
            return;
        }

        try {
            String resultContent = FileUtil.readStringFromFile(resultFile);
            List<String> errors = parseTestResultJson(new JSONObject(resultContent));
            if (!errors.isEmpty()) {
                listener.testFailed(testCase.mDesc, String.join("\n", errors));
            }
        } catch (IOException | JSONException e) {
            CLog.e("Failed to parse test result file", e);
            listener.testFailed(
                    testCase.mDesc,
                    String.format("Failed to parse test result file: %s", e.getMessage()));
        }
        try (FileInputStreamSource inputStream = new FileInputStreamSource(resultFile)) {
            listener.testLog(testCase.mId + "_result", LogDataType.TEXT, inputStream);
        }
    }

    /** Recursively parses the test result JSON, looking for failures. */
    private List<String> parseTestResultJson(JSONObject result) {
        if (result == null) {
            return List.of();
        }

        List<String> errors = new ArrayList<>();
        JSONArray childrenResult = result.optJSONArray("childrenResult");
        if (childrenResult != null) {
            for (int i = 0; i < childrenResult.length(); i++) {
                errors.addAll(parseTestResultJson(childrenResult.optJSONObject(i)));
            }
        }
        if ("FAIL".equalsIgnoreCase(result.optString("playStatus"))) {
            String error =
                    String.format(
                            "%s (%s): %s",
                            result.optString("actionId"),
                            result.optString("content"),
                            result.optString("validationDetails"));
            errors.add(error);
        }
        return errors;
    }

    /**
     * Copy a file into a directory.
     *
     * @param srcFile file to copy
     * @param destDir directory to copy into
     * @return copied file
     */
    private Path copyFile(Path srcFile, Path destDir) {
        try {
            Files.createDirectories(destDir);
            Path destFile = destDir.resolve(srcFile.getFileName());
            return Files.copy(srcFile, destFile);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    /**
     * Find all test cases in the specified file or directory.
     *
     * @param key test key to associate with test cases
     * @param file file or directory to look in
     * @return list of test cases
     */
    private List<UiConductorTestCase> getTestCases(String key, File file) {
        if (!file.exists()) {
            throw new IllegalArgumentException(
                    String.format("Test file %s not found", file.getAbsolutePath()));
        }
        if (file.isDirectory()) {
            try {
                // Find all nested regular files and use their relative paths as IDs
                Path dirPath = file.toPath().toAbsolutePath();
                try (Stream<Path> stream = Files.walk(dirPath)) {
                    return stream.filter(Files::isRegularFile)
                            .sorted()
                            .map(
                                    filePath -> {
                                        String id =
                                                dirPath.getParent().relativize(filePath).toString();
                                        return new UiConductorTestCase(id, key, filePath.toFile());
                                    })
                            .collect(Collectors.toList());
                }
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }
        // Normal file, use filename as ID
        return List.of(new UiConductorTestCase(file.getName(), key, file));
    }

    /** Constructs the command to execute for a test case. */
    private String[] buildCommand(UiConductorTestCase testCase, List<ITestDevice> devices) {
        List<String> command = new ArrayList<>();
        command.add("java");
        command.add("-jar");
        command.add(mCliJar.getAbsolutePath());
        // Add input file path
        command.add(INPUT_OPTION);
        command.add(testCase.mFile.getAbsolutePath());
        // Add output directory path
        command.add(OUTPUT_OPTION);
        command.add(mOutputDir.resolve(testCase.mId).toString());
        // Add play mode
        command.add(MODE_OPTION);
        command.add(mPlayMode.name());
        // Add device serial numbers (comma separated list)
        command.add(DEVICES_OPTION);
        String serials =
                devices.stream().map(ITestDevice::getSerialNumber).collect(Collectors.joining(","));
        command.add(serials);
        // Add global variables if applicable
        if (mGlobalVariables.containsKey(testCase.mKey)) {
            command.add(GLOBAL_VARIABLE_OPTION);
            command.add(String.join(",", mGlobalVariables.get(testCase.mKey)));
        }
        return command.toArray(new String[] {});
    }

    /**
     * Try to locate and parse an existing output file.
     *
     * @return listener containing the results or {@code null} if not found.
     */
    @Nullable
    private CollectingTestListener parsePreviousResults() {
        if (mPreviousResults == null) {
            return null;
        }
        if (!mPreviousResults.isFile()) {
            throw new IllegalArgumentException(
                    String.format(
                            "Previous results %s not found", mPreviousResults.getAbsolutePath()));
        }

        try {
            TestRecordProto.TestRecord record = TestRecordProtoUtil.readFromFile(mPreviousResults);
            return TestRecordInterpreter.interpreteRecord(record);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    /** Iterate over previous results to add them to the current run and exclude passed tests. */
    private void loadPreviousResults(
            ITestInvocationListener listener, CollectingTestListener results) {
        results.getMergedTestRunResults().stream()
                .filter(module -> module.getName().startsWith(MODULE_NAME + '#'))
                .forEach(
                        module -> {
                            // Found a previous result for this module, replay it
                            Map<TestDescription, TestResult> tests = module.getTestResults();
                            listener.testRunStarted(module.getName(), tests.size());
                            tests.forEach(
                                    (test, result) -> {
                                        listener.testStarted(test, result.getStartTime());
                                        if (result.getStatus() == TestStatus.FAILURE) {
                                            listener.testFailed(test, result.getStackTrace());
                                        } else {
                                            // Only the PASSED and FAILURE test statuses are used,
                                            // so exclude all non-FAILURE tests.
                                            this.addExcludeFilter(test.toString());
                                        }
                                        listener.testEnded(test, result.getEndTime(), Map.of());
                                    });
                            listener.testRunEnded(module.getElapsedTime(), Map.of());
                        });
    }

    /** Writes results to a uicd_results.pb file which can be used for file-based retries. */
    @OptionClass(alias = "uicd")
    public static class ResultReporter extends FileProtoResultReporter {

        @Option(name = "output-path", description = "Output file path, can be used for retries")
        private String mOutputPath = DEFAULT_OUTPUT_PATH;

        private File mOutputFile;

        @Override
        public void processStartInvocation(
                TestRecordProto.TestRecord record, IInvocationContext context) {
            mOutputFile = new File(mOutputPath + ".tmp").getAbsoluteFile();
            setOutputFile(mOutputFile);
            super.processStartInvocation(record, context);
        }

        @Override
        public void processFinalProto(TestRecordProto.TestRecord record) {
            super.processFinalProto(record);
            mOutputFile.renameTo(new File(mOutputPath));
        }
    }
}
