/*
 * Copyright (C) 2020 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.internal.util.test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;

import org.junit.Assert;
import org.junit.ClassRule;
import org.junit.rules.ExternalResource;
import org.junit.rules.TemporaryFolder;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;

import javax.annotation.Nullable;

/**
 * Allows pushing files onto the device and various options for rebooting. Useful for installing
 * APKs/files to system partitions which otherwise wouldn't be easily changed.
 *
 * It's strongly recommended to pass in a {@link ClassRule} annotated {@link TestRuleDelegate} to
 * do a full reboot at the end of a test to ensure the device is in a valid state, assuming the
 * default {@link RebootStrategy#FULL} isn't used.
 */
public class SystemPreparer extends ExternalResource {
    private static final long OVERLAY_ENABLE_TIMEOUT_MS = 30000;

    // The paths of the files pushed onto the device through this rule to be removed after.
    private ArrayList<String> mPushedFiles = new ArrayList<>();

    // The package names of packages installed through this rule.
    private ArrayList<String> mInstalledPackages = new ArrayList<>();

    private final TemporaryFolder mHostTempFolder;
    private final DeviceProvider mDeviceProvider;
    private final RebootStrategy mRebootStrategy;
    private final TearDownRule mTearDownRule;

    // When debugging, it may be useful to run a test case without rebooting the device afterwards,
    // to manually verify the device state.
    private boolean mDebugSkipAfterReboot;

    public SystemPreparer(TemporaryFolder hostTempFolder, DeviceProvider deviceProvider) {
        this(hostTempFolder, RebootStrategy.FULL, null, deviceProvider);
    }

    public SystemPreparer(TemporaryFolder hostTempFolder, RebootStrategy rebootStrategy,
            @Nullable TestRuleDelegate testRuleDelegate, DeviceProvider deviceProvider) {
        this(hostTempFolder, rebootStrategy, testRuleDelegate, false, deviceProvider);
    }

    public SystemPreparer(TemporaryFolder hostTempFolder, RebootStrategy rebootStrategy,
            @Nullable TestRuleDelegate testRuleDelegate, boolean debugSkipAfterReboot,
            DeviceProvider deviceProvider) {
        mHostTempFolder = hostTempFolder;
        mDeviceProvider = deviceProvider;
        mRebootStrategy = rebootStrategy;
        mTearDownRule = new TearDownRule(mDeviceProvider);
        if (testRuleDelegate != null) {
            testRuleDelegate.setDelegate(mTearDownRule);
        }
        mDebugSkipAfterReboot = debugSkipAfterReboot;
    }

    /** Copies a file within the host test jar to a path on device. */
    public SystemPreparer pushResourceFile(String filePath, String outputPath)
            throws DeviceNotAvailableException, IOException {
        final ITestDevice device = mDeviceProvider.getDevice();
        remount();
        assertTrue(device.pushFile(copyResourceToTemp(filePath), outputPath));
        addPushedFile(device, outputPath);
        return this;
    }

    /** Copies a file directly from the host file system to a path on device. */
    public SystemPreparer pushFile(File file, String outputPath)
            throws DeviceNotAvailableException {
        final ITestDevice device = mDeviceProvider.getDevice();
        remount();
        assertTrue(device.pushFile(file, outputPath));
        addPushedFile(device, outputPath);
        return this;
    }

    private void addPushedFile(ITestDevice device, String outputPath)
            throws DeviceNotAvailableException {
        Path pathCreated = Paths.get(outputPath);

        // Find the top most parent that is new to the device
        while (pathCreated.getParent() != null
                && !device.doesFileExist(pathCreated.getParent().toString())) {
            pathCreated = pathCreated.getParent();
        }

        mPushedFiles.add(pathCreated.toString());
    }

    /** Deletes the given path from the device */
    public SystemPreparer deleteFile(String file) throws DeviceNotAvailableException {
        final ITestDevice device = mDeviceProvider.getDevice();
        remount();
        device.deleteFile(file);
        return this;
    }

    /** Installs an APK within the host test jar onto the device. */
    public SystemPreparer installResourceApk(String resourcePath, String packageName)
            throws DeviceNotAvailableException, IOException {
        final ITestDevice device = mDeviceProvider.getDevice();
        final File tmpFile = copyResourceToTemp(resourcePath);
        final String result = device.installPackage(tmpFile, true /* reinstall */);
        Assert.assertNull(result);
        mInstalledPackages.add(packageName);
        return this;
    }

    /** Stages multiple APEXs within the host test jar onto the device. */
    public SystemPreparer stageMultiplePackages(String[] resourcePaths, String[] packageNames)
            throws DeviceNotAvailableException, IOException {
        assertEquals(resourcePaths.length, packageNames.length);
        final ITestDevice device = mDeviceProvider.getDevice();
        final String[] adbCommandLine = new String[resourcePaths.length + 2];
        adbCommandLine[0] = "install-multi-package";
        adbCommandLine[1] = "--staged";
        for (int i = 0; i < resourcePaths.length; i++) {
            final File tmpFile = copyResourceToTemp(resourcePaths[i]);
            adbCommandLine[i + 2] = tmpFile.getAbsolutePath();
            mInstalledPackages.add(packageNames[i]);
        }
        final String output = device.executeAdbCommand(adbCommandLine);
        assertTrue(output.contains("Success. Reboot device to apply staged session"));
        return this;
    }

    /** Sets the enable state of an overlay package. */
    public SystemPreparer setOverlayEnabled(String packageName, boolean enabled)
            throws DeviceNotAvailableException {
        final ITestDevice device = mDeviceProvider.getDevice();
        final String enable = enabled ? "enable" : "disable";

        // Wait for the overlay to change its enabled state.
        final long endMillis = System.currentTimeMillis() + OVERLAY_ENABLE_TIMEOUT_MS;
        String result;
        while (System.currentTimeMillis() <= endMillis) {
            device.executeShellCommand(String.format("cmd overlay %s %s", enable, packageName));
            result = device.executeShellCommand("cmd overlay dump isenabled "
                    + packageName);
            if (((enabled) ? "true\n" : "false\n").equals(result)) {
                return this;
            }

            try {
                Thread.sleep(200);
            } catch (InterruptedException ignore) {
            }
        }

        throw new IllegalStateException(String.format("Failed to %s overlay %s:\n%s", enable,
                packageName, device.executeShellCommand("cmd overlay list")));
    }

    /** Restarts the device and waits until after boot is completed. */
    public SystemPreparer reboot() throws DeviceNotAvailableException {
        ITestDevice device = mDeviceProvider.getDevice();
        switch (mRebootStrategy) {
            case FULL:
                device.reboot();
                break;
            case UNTIL_ONLINE:
                device.rebootUntilOnline();
                break;
            case USERSPACE:
                device.rebootUserspace();
                break;
            case USERSPACE_UNTIL_ONLINE:
                device.rebootUserspaceUntilOnline();
                break;
            // TODO(b/159540015): Make this START_STOP instead of default once it's fixed. Can't
            //  currently be done because START_STOP is commented out.
            default:
                device.executeShellCommand("stop");
                device.executeShellCommand("start");
                device.waitForDeviceAvailable();
                break;
        }
        return this;
    }

    public SystemPreparer remount() throws DeviceNotAvailableException {
        mTearDownRule.remount();
        return this;
    }

    private static @Nullable String getFileExtension(@Nullable String path) {
        if (path == null) {
            return null;
        }
        final int lastDot = path.lastIndexOf('.');
        if (lastDot >= 0) {
            return path.substring(lastDot + 1);
        } else {
            return null;
        }
    }

    /** Copies a file within the host test jar to a temporary file on the host machine. */
    private File copyResourceToTemp(String resourcePath) throws IOException {
        final String ext = getFileExtension(resourcePath);
        final File tempFile;
        if (ext != null) {
            tempFile = File.createTempFile("junit", "." + ext, mHostTempFolder.getRoot());
        } else {
            tempFile = mHostTempFolder.newFile();
        }
        final ClassLoader classLoader = getClass().getClassLoader();
        try (InputStream assetIs = classLoader.getResourceAsStream(resourcePath);
             FileOutputStream assetOs = new FileOutputStream(tempFile)) {
            if (assetIs == null) {
                throw new IllegalStateException("Failed to find resource " + resourcePath);
            }

            int b;
            while ((b = assetIs.read()) >= 0) {
                assetOs.write(b);
            }
        }

        return tempFile;
    }

    /** Removes installed packages and files that were pushed to the device. */
    @Override
    public void after() {
        final ITestDevice device = mDeviceProvider.getDevice();
        try {
            remount();
            for (final String file : mPushedFiles) {
                device.deleteFile(file);
            }
            for (final String packageName : mInstalledPackages) {
                device.uninstallPackage(packageName);
            }
            if (!mDebugSkipAfterReboot) {
                reboot();
            }
        } catch (DeviceNotAvailableException e) {
            Assert.fail(e.toString());
        }
    }

    /**
     * A hacky workaround since {@link org.junit.AfterClass} and {@link ClassRule} require static
     * members. Will defer assignment of the actual {@link TestRule} to execute until after any
     * test case has been run.
     *
     * In effect, this makes the {@link ITestDevice} to be accessible after all test cases have
     * been executed, allowing {@link ITestDevice#reboot()} to be used to fully restore the device.
     */
    public static class TestRuleDelegate implements TestRule {

        private boolean mThrowOnNull;

        @Nullable
        private TestRule mTestRule;

        public TestRuleDelegate(boolean throwOnNull) {
            mThrowOnNull = throwOnNull;
        }

        public void setDelegate(TestRule testRule) {
            mTestRule = testRule;
        }

        @Override
        public Statement apply(Statement base, Description description) {
            if (mTestRule == null) {
                if (mThrowOnNull) {
                    throw new IllegalStateException("TestRule delegate was not set");
                } else {
                    return new Statement() {
                        @Override
                        public void evaluate() throws Throwable {
                            base.evaluate();
                        }
                    };
                }
            }

            Statement statement = mTestRule.apply(base, description);
            mTestRule = null;
            return statement;
        }
    }

    /**
     * Forces a full reboot at the end of the test class to restore any device state.
     */
    private static class TearDownRule extends ExternalResource {

        private DeviceProvider mDeviceProvider;
        private boolean mInitialized;
        private boolean mWasVerityEnabled;
        private boolean mWasAdbRoot;
        private boolean mIsVerityEnabled;

        TearDownRule(DeviceProvider deviceProvider) {
            mDeviceProvider = deviceProvider;
        }

        @Override
        protected void before() {
            // This method will never be run
        }

        @Override
        protected void after() {
            try {
                initialize();
                ITestDevice device = mDeviceProvider.getDevice();
                if (mWasVerityEnabled != mIsVerityEnabled) {
                    device.executeShellCommand(
                            mWasVerityEnabled ? "enable-verity" : "disable-verity");
                }
                device.reboot();
                if (!mWasAdbRoot) {
                    device.disableAdbRoot();
                }
            } catch (DeviceNotAvailableException e) {
                Assert.fail(e.toString());
            }
        }

        /**
         * Remount is done inside this class so that the verity state can be tracked.
         */
        public void remount() throws DeviceNotAvailableException {
            initialize();
            ITestDevice device = mDeviceProvider.getDevice();
            device.enableAdbRoot();
            if (mIsVerityEnabled) {
                mIsVerityEnabled = false;
                device.executeShellCommand("disable-verity");
                device.reboot();
            }
            device.enableAdbRoot();
            device.remountSystemWritable();
            device.remountVendorWritable();
            device.waitForDeviceAvailable();
        }

        private void initialize() throws DeviceNotAvailableException {
            if (mInitialized) {
                return;
            }
            mInitialized = true;
            ITestDevice device = mDeviceProvider.getDevice();
            mWasAdbRoot = device.isAdbRoot();
            device.enableAdbRoot();
            String veritySystem = device.getProperty("partition.system.verified");
            String verityVendor = device.getProperty("partition.vendor.verified");
            mWasVerityEnabled = (veritySystem != null && !veritySystem.isEmpty())
                    || (verityVendor != null && !verityVendor.isEmpty());
            mIsVerityEnabled = mWasVerityEnabled;
        }
    }

    public interface DeviceProvider {
        ITestDevice getDevice();
    }

    /**
     * How to reboot the device. Ordered from slowest to fastest.
     */
    @SuppressWarnings("DanglingJavadoc")
    public enum RebootStrategy {
        /** @see ITestDevice#reboot() */
        FULL,

        /** @see ITestDevice#rebootUntilOnline() () */
        UNTIL_ONLINE,

        /** @see ITestDevice#rebootUserspace() */
        USERSPACE,

        /** @see ITestDevice#rebootUserspaceUntilOnline() () */
        USERSPACE_UNTIL_ONLINE,

        /**
         * Uses shell stop && start to "reboot" the device. May leave invalid state after each test.
         * Whether this matters or not depends on what's being tested.
         *
         * TODO(b/159540015): There's a bug with this causing unnecessary disk space usage, which
         *  can eventually lead to an insufficient storage space error.
         *
         * This can be uncommented for local development, but should be left out when merging.
         * It is done this way to hopefully be caught by code review, since merging this will
         * break all of postsubmit. But the nearly 50% reduction in test runtime is worth having
         * this option exist.
         *
         * @deprecated do not use this in merged code until bug is resolved
         */
//        @Deprecated
//        START_STOP
    }
}
