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

import static com.google.common.truth.Truth.assertThat;

import com.android.tradefed.invoker.TestInformation;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import java.io.File;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

/** A helper class that can mutate the device state and restore it afterwards. */
public class DeviceState {
    private static final String TEST_JAR_RESOURCE_NAME = "/art-gtest-jars-Main.jar";
    private static final String PHENOTYPE_FLAG_NAMESPACE = "runtime_native_boot";
    private static final String ART_APEX_DALVIK_CACHE_BACKUP_DIRNAME =
            OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME + ".bak";

    private final TestInformation mTestInfo;
    private final OdsignTestUtils mTestUtils;

    private Set<String> mTempFiles = new HashSet<>();
    private Set<String> mMountPoints = new HashSet<>();
    private Map<String, String> mMutatedProperties = new HashMap<>();
    private Set<String> mMutatedPhenotypeFlags = new HashSet<>();
    private Map<String, String> mDeletedFiles = new HashMap<>();
    private boolean mHasArtifactsBackup = false;

    public DeviceState(TestInformation testInfo) throws Exception {
        mTestInfo = testInfo;
        mTestUtils = new OdsignTestUtils(testInfo);
    }

    /** Restores the device state. */
    public void restore() throws Exception {
        for (String mountPoint : mMountPoints) {
            mTestInfo.getDevice().executeShellV2Command(String.format("umount '%s'", mountPoint));
        }

        for (String tempFile : mTempFiles) {
            mTestInfo.getDevice().deleteFile(tempFile);
        }

        for (var entry : mMutatedProperties.entrySet()) {
            mTestInfo.getDevice().setProperty(
                    entry.getKey(), entry.getValue() != null ? entry.getValue() : "");
        }

        for (String flag : mMutatedPhenotypeFlags) {
            mTestInfo.getDevice().executeShellV2Command(String.format(
                    "device_config delete '%s' '%s'", PHENOTYPE_FLAG_NAMESPACE, flag));
        }

        if (!mMutatedPhenotypeFlags.isEmpty()) {
            mTestInfo.getDevice().executeShellV2Command(
                    "device_config set_sync_disabled_for_tests none");
        }

        for (var entry : mDeletedFiles.entrySet()) {
            mTestInfo.getDevice().executeShellV2Command(
                    String.format("cp '%s' '%s'", entry.getValue(), entry.getKey()));
            mTestInfo.getDevice().executeShellV2Command(String.format("rm '%s'", entry.getValue()));
            mTestInfo.getDevice().executeShellV2Command(
                    String.format("restorecon '%s'", entry.getKey()));
        }

        if (mHasArtifactsBackup) {
            mTestInfo.getDevice().executeShellV2Command(
                    String.format("rm -rf '%s'", OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME));
            mTestInfo.getDevice().executeShellV2Command(
                    String.format("mv '%s' '%s'", ART_APEX_DALVIK_CACHE_BACKUP_DIRNAME,
                            OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME));
        }
    }

    /** Simulates that the ART APEX has been upgraded. */
    public void simulateArtApexUpgrade() throws Exception {
        updateApexInfo("com.android.art", false /* isFactory */);
    }

    /**
     * Simulates that the new ART APEX has been uninstalled (i.e., the ART module goes back to the
     * factory version).
     */
    public void simulateArtApexUninstall() throws Exception {
        updateApexInfo("com.android.art", true /* isFactory */);
    }

    /**
     * Simulates that an APEX has been upgraded. We could install a real APEX, but that would
     * introduce an extra dependency to this test, which we want to avoid.
     */
    public void simulateApexUpgrade() throws Exception {
        updateApexInfo("com.android.wifi", false /* isFactory */);
    }

    /**
     * Simulates that the new APEX has been uninstalled (i.e., the module goes back to the factory
     * version).
     */
    public void simulateApexUninstall() throws Exception {
        updateApexInfo("com.android.wifi", true /* isFactory */);
    }

    private void updateApexInfo(String moduleName, boolean isFactory) throws Exception {
        try (var xmlMutator = new XmlMutator(OdsignTestUtils.APEX_INFO_FILE)) {
            NodeList list = xmlMutator.getDocument().getElementsByTagName("apex-info");
            for (int i = 0; i < list.getLength(); i++) {
                Element node = (Element) list.item(i);
                if (node.getAttribute("moduleName").equals(moduleName)
                        && node.getAttribute("isActive").equals("true")) {
                    node.setAttribute("isFactory", String.valueOf(isFactory));
                    node.setAttribute(
                            "lastUpdateMillis", String.valueOf(System.currentTimeMillis()));
                }
            }
        }
    }

    /** Simulates that there is an OTA that updates a boot classpath jar. */
    public void simulateBootClasspathOta() throws Exception {
        File localFile = mTestUtils.copyResourceToFile(TEST_JAR_RESOURCE_NAME);
        pushAndBindMount(localFile, "/system/framework/framework.jar");
    }

    /** Simulates that there is an OTA that updates a system server jar. */
    public void simulateSystemServerOta() throws Exception {
        File localFile = mTestUtils.copyResourceToFile(TEST_JAR_RESOURCE_NAME);
        pushAndBindMount(localFile, "/system/framework/services.jar");
    }

    public void makeDex2oatFail() throws Exception {
        setProperty("dalvik.vm.boot-dex2oat-threads", "-1");
    }

    /** Sets a system property. */
    public void setProperty(String key, String value) throws Exception {
        if (!mMutatedProperties.containsKey(key)) {
            // Backup the original value.
            mMutatedProperties.put(key, mTestInfo.getDevice().getProperty(key));
        }

        mTestInfo.getDevice().setProperty(key, value);
    }

    /** Sets a phenotype flag. */
    public void setPhenotypeFlag(String key, String value) throws Exception {
        if (!mMutatedPhenotypeFlags.contains(key)) {
            // Tests assume that phenotype flags are initially not set. Check if the assumption is
            // true.
            assertThat(mTestUtils.assertCommandSucceeds(String.format(
                               "device_config get '%s' '%s'", PHENOTYPE_FLAG_NAMESPACE, key)))
                    .isEqualTo("null");
            mMutatedPhenotypeFlags.add(key);
        }

        // Disable phenotype flag syncing. Potentially, we can set `set_sync_disabled_for_tests` to
        // `until_reboot`, but setting it to `persistent` prevents unrelated system crashes/restarts
        // from affecting the test. `set_sync_disabled_for_tests` is reset in `restore` anyway.
        mTestUtils.assertCommandSucceeds("device_config set_sync_disabled_for_tests persistent");

        if (value != null) {
            mTestUtils.assertCommandSucceeds(String.format(
                    "device_config put '%s' '%s' '%s'", PHENOTYPE_FLAG_NAMESPACE, key, value));
        } else {
            mTestUtils.assertCommandSucceeds(
                    String.format("device_config delete '%s' '%s'", PHENOTYPE_FLAG_NAMESPACE, key));
        }
    }

    public void backupAndDeleteFile(String remotePath) throws Exception {
        String tempFile = "/data/local/tmp/odsign_e2e_tests_" + UUID.randomUUID() + ".tmp";
        // Backup the file before deleting it.
        mTestUtils.assertCommandSucceeds(String.format("cp '%s' '%s'", remotePath, tempFile));
        mTestUtils.assertCommandSucceeds(String.format("rm '%s'", remotePath));
        mDeletedFiles.put(remotePath, tempFile);
    }

    public void backupArtifacts() throws Exception {
        mTestInfo.getDevice().executeShellV2Command(
                String.format("rm -rf '%s'", ART_APEX_DALVIK_CACHE_BACKUP_DIRNAME));
        mTestUtils.assertCommandSucceeds(
                String.format("cp -r '%s' '%s'", OdsignTestUtils.ART_APEX_DALVIK_CACHE_DIRNAME,
                        ART_APEX_DALVIK_CACHE_BACKUP_DIRNAME));
        mHasArtifactsBackup = true;
    }

    /**
     * Pushes the file to a temporary location and bind-mount it at the given path. This is useful
     * when the path is readonly.
     */
    private void pushAndBindMount(File localFile, String remotePath) throws Exception {
        String tempFile = "/data/local/tmp/odsign_e2e_tests_" + UUID.randomUUID() + ".tmp";
        assertThat(mTestInfo.getDevice().pushFile(localFile, tempFile)).isTrue();
        mTempFiles.add(tempFile);

        // If the path has already been bind-mounted by this method before, unmount it first.
        if (mMountPoints.contains(remotePath)) {
            mTestUtils.assertCommandSucceeds(String.format("umount '%s'", remotePath));
            mMountPoints.remove(remotePath);
        }

        mTestUtils.assertCommandSucceeds(
                String.format("mount --bind '%s' '%s'", tempFile, remotePath));
        mMountPoints.add(remotePath);
        mTestUtils.assertCommandSucceeds(String.format("restorecon '%s'", remotePath));
    }

    /** A helper class for mutating an XML file. */
    private class XmlMutator implements AutoCloseable {
        private final Document mDocument;
        private final String mRemoteXmlFile;
        private final File mLocalFile;

        public XmlMutator(String remoteXmlFile) throws Exception {
            // Load the XML file.
            mRemoteXmlFile = remoteXmlFile;
            mLocalFile = mTestInfo.getDevice().pullFile(remoteXmlFile);
            assertThat(mLocalFile).isNotNull();
            DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
            mDocument = builder.parse(mLocalFile);
        }

        @Override
        public void close() throws Exception {
            // Save the XML file.
            Transformer transformer = TransformerFactory.newInstance().newTransformer();
            transformer.transform(new DOMSource(mDocument), new StreamResult(mLocalFile));
            pushAndBindMount(mLocalFile, mRemoteXmlFile);
        }

        /** Returns a mutable XML document. */
        public Document getDocument() {
            return mDocument;
        }
    }
}
