/*
 * Copyright (C) 2011 The Android Open Source Project
 *
 * Licensed under the Eclipse Public License, Version 1.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.eclipse.org/org/documents/epl-v10.php
 *
 * 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.manifmerger;

import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.manifmerger.IMergerLog.FileAndLine;
import com.android.sdklib.mock.MockLog;

import junit.framework.Test;
import junit.framework.TestCase;
import junit.framework.TestSuite;

import org.w3c.dom.Document;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Some utilities to reduce repetitions in the {@link ManifestMergerTest}s.
 * <p/>
 * See {@link #loadTestData(String)} for an explanation of the data file format.
 */
public class ManifestMergerTest extends TestCase {

    /**
     * Delimiter that indicates the test must fail.
     * An XML output and errors are still generated and checked.
     */
    private static final String DELIM_FAILS  = "fails";
    /**
     * Delimiter that starts a library XML content.
     * The delimiter name must be in the form {@code @libSomeName} and it will be
     * used as the base for the test file name. Using separate lib names is encouraged
     * since it makes the error output easier to read.
     */
    private static final String DELIM_LIB    = "lib";
    /**
     * Delimiter that starts the main manifest XML content.
     */
    private static final String DELIM_MAIN   = "main";
    /**
     * Delimiter that starts an overlay XML content.
     * The delimiter must follow the same rules as {@link #DELIM_LIB}
     */
    private static final String DELIM_OVERLAY = "overlay";
    /**
     * Delimiter that starts the resulting XML content, whatever is generated by the merge.
     */
    private static final String DELIM_RESULT = "result";
    /**
     * Delimiter that starts the SdkLog output.
     * The logger prints each entry on its lines, prefixed with E for errors,
     * W for warnings and P for regular printfs.
     */
    private static final String DELIM_ERRORS = "errors";
    /**
     * Delimiter for starts a section that declares how to inject an attribute.
     * The section is composed of one or more lines with the
     * syntax: "/node/node|attr-URI attrName=attrValue".
     * This is essentially a pseudo XPath-like expression that is described in
     * {@link ManifestMerger#process(Document, File[], Map, String)}.
     */
    private static final String DELIM_INJECT_ATTR   = "inject";
    /**
     * Delimiter for a section that declares how to toggle a ManifMerger option.
     * The section is composed of one or more lines with the
     * syntax: "functionName=false|true".
     */
    private static final String DELIM_FEATURES      = "features";

    /**
     * Delimiter for a section that declares how to override the package.
     * The section is composed of one line containing the new package name.
     */
    private static final String DELIM_PACKAGE       = "package";


    /*
     * Wait, I hear you, where are the tests?
     *
     * processTestFiles() uses loadTestData(), which uses one of the data filename
     * indicated below.
     *
     * We could simplify this even further by dynamically finding the data
     * files to use; however there's some value in having tests break when out
     * of sync with the known data file set.
     */
    private static String[] sDataFiles = new String[] {
        "00_noop",
        "01_ignore_app_attr",
        "02_ignore_instrumentation",
        "03_inject_attributes",
        "04_inject_attributes",
        "05_inject_package",
        "10_activity_merge",
        "11_activity_dup",
        "12_alias_dup",
        "13_service_dup",
        "14_receiver_dup",
        "15_provider_dup",
        "16_fqcn_merge",
        "17_fqcn_conflict",
        "20_uses_lib_merge",
        "21_uses_lib_errors",
        "25_permission_merge",
        "26_permission_dup",
        "28_uses_perm_merge",
        "30_uses_sdk_ok",
        "32_uses_sdk_minsdk_ok",
        "33_uses_sdk_minsdk_conflict",
        "36_uses_sdk_targetsdk_warning",
        "40_uses_feat_merge",
        "41_uses_feat_errors",
        "45_uses_feat_gles_once",
        "47_uses_feat_gles_conflict",
        "50_uses_conf_warning",
        "52_support_screens_warning",
        "54_compat_screens_warning",
        "56_support_gltext_warning",
        "60_merge_order",
        "65_override_app",
        "66_remove_app",
        "67_override_activities",
        "68_override_uses",
        "69_remove_uses",
        "70_expand_fqcns",
        "71_extract_package_prefix",
        "75_app_metadata_merge",
        "76_app_metadata_ignore",
        "77_app_metadata_conflict",
    };

    /**
     * This overrides the default test suite created by junit.
     * The test suite is a bland TestSuite with a dedicated name.
     * We inject as many instances of {@link ManifestMergerTest} in the suite
     * as we have declared data files above.
     *
     * @return A new {@link TestSuite}.
     */
    public static Test suite() {
        TestSuite suite = new TestSuite();
        // Give a non-generic name to our test suite, for better unit reports.
        suite.setName("ManifestMergerTestSuite");

        for (String fileName : sDataFiles) {
            suite.addTest(TestSuite.createTest(ManifestMergerTest.class, fileName));
        }

        return suite;
    }


    /**
     * Default constructor invoked by {@link TestSuite#createTest(Class, String)}.
     *
     * @param testName The test name provided to {@code TestSuite.createTest()}.
     *                 This is later accessible via {@link #getName()}.
     */
    public ManifestMergerTest(String testName) {
        super(testName);
    }

    /**
     * Invoked by the test framework to run the specific test which name
     * has been passed to the constructor.
     * Note that we create one instance of this class per test to run in
     * the associated {@link TestSuite}.
     */
    @Override
    protected void runTest() throws Throwable {
        String testName = getName();
        assertNotNull(testName);
        processTestFiles(loadTestData(testName));
    }


    static class TestFiles {
        private final File[] mOverlayFiles;
        private final File mMain;
        private final File[] mLibs;
        private final Map<String, String> mInjectAttributes;
        private final String mPackageOverride;
        private final File mActualResult;
        private final String mExpectedResult;
        private final String mExpectedErrors;
        private final boolean mShouldFail;
        private final Map<String, Boolean> mFeatures;

        /** Files used by a given test case. */
        public TestFiles(
                boolean shouldFail,
                @NonNull File[] overlayFiles,
                @NonNull File main,
                @NonNull File[] libs,
                @NonNull Map<String, Boolean> features,
                @NonNull Map<String, String> injectAttributes,
                @Nullable String packageOverride,
                @Nullable File actualResult,
                @NonNull String expectedResult,
                @NonNull String expectedErrors) {
            mShouldFail = shouldFail;
            mMain = main;
            mLibs = libs;
            mFeatures = features;
            mPackageOverride = packageOverride;
            mInjectAttributes = injectAttributes;
            mActualResult = actualResult;
            mExpectedResult = expectedResult;
            mExpectedErrors = expectedErrors;
            mOverlayFiles = overlayFiles;
        }

        public boolean getShouldFail() {
            return mShouldFail;
        }

        @NonNull
        public File[] getOverlayFiles() {
            return mOverlayFiles;
        }

        @NonNull
        public File getMain() {
            return mMain;
        }

        @NonNull
        public File[] getLibs() {
            return mLibs;
        }

        @NonNull
        public Map<String, Boolean> getFeatures() {
            return mFeatures;
        }

        @NonNull
        public Map<String, String> getInjectAttributes() {
            return mInjectAttributes;
        }

        @Nullable
        public String getPackageOverride() {
            return mPackageOverride;
        }

        @Nullable
        public File getActualResult() {
            return mActualResult;
        }

        @NonNull
        public String getExpectedResult() {
            return mExpectedResult;
        }

        public String getExpectedErrors() {
            return mExpectedErrors;
        }

        // Try to delete any temp file potentially created.
        public void cleanup() {
            if (mMain != null && mMain.isFile()) {
                mMain.delete();
            }

            if (mActualResult != null && mActualResult.isFile()) {
                mActualResult.delete();
            }

            for (File f : mLibs) {
                if (f != null && f.isFile()) {
                    f.delete();
                }
            }
        }
    }

    /**
     * Calls {@link #loadTestData(String)} by
     * inferring the data filename from the caller's method name.
     * <p/>
     * The caller method name must be composed of "test" + the leaf filename.
     * Extensions ".xml" or ".txt" are implied.
     * <p/>
     * E.g. to use the data file "12_foo.xml", simply call this from a method
     * named "test12_foo".
     *
     * @return A new {@link TestFiles} instance. Never null.
     * @throws Exception when things go wrong.
     * @see #loadTestData(String)
     */
    @NonNull
    TestFiles loadTestData() throws Exception {
        StackTraceElement[] stack = Thread.currentThread().getStackTrace();
        for (int i = 0, n = stack.length; i < n; i++) {
            StackTraceElement caller = stack[i];
            String name = caller.getMethodName();
            if (name.startsWith("test")) {
                return loadTestData(name.substring(4));
            }
        }

        throw new IllegalArgumentException("No caller method found which name started with 'test'");
    }

    /**
     * Returns the relative path the test data directory
     */
    protected String getTestDataDirectory() {
        return "data";
    }

    /**
     * Loads test data for a given test case.
     * The input (main + libs) are stored in temp files.
     * A new destination temp file is created to store the actual result output.
     * The expected result is actually kept in a string.
     * <p/>
     * Data File Syntax:
     * <ul>
     * <li> Lines starting with # are ignored (anywhere, as long as # is the first char).
     * <li> Lines before the first {@code @delimiter} are ignored.
     * <li> Empty lines just after the {@code @delimiter}
     *      and before the first &lt; XML line are ignored.
     * <li> Valid delimiters are {@code @main} for the XML of the main app manifest.
     * <li> Following delimiters are {@code @libXYZ}, read in the order of definition.
     *      The name can be anything as long as it starts with "{@code @lib}".
     * </ul>
     *
     * @param filename The test data filename. If no extension is provided, this will
     *   try with .xml or .txt. Must not be null.
     * @return A new {@link TestFiles} instance. Must not be null.
     * @throws Exception when things fail to load properly.
     */
    @NonNull
    TestFiles loadTestData(@NonNull String filename) throws Exception {

        String resName = getTestDataDirectory() + File.separator + filename;
        InputStream is = null;
        BufferedReader reader = null;
        BufferedWriter writer = null;

        try {
            is = this.getClass().getResourceAsStream(resName);
            if (is == null && !filename.endsWith(".xml")) {
                String resName2 = resName + ".xml";
                is = this.getClass().getResourceAsStream(resName2);
                if (is != null) {
                    filename = resName2;
                }
            }
            if (is == null && !filename.endsWith(".txt")) {
                String resName3 = resName + ".txt";
                is = this.getClass().getResourceAsStream(resName3);
                if (is != null) {
                    filename = resName3;
                }
            }
            assertNotNull("Test data file not found for " + filename, is);

            reader = new BufferedReader(new InputStreamReader(is, "UTF-8"));

            // Get the temporary directory to use. Just create a temp file, extracts its
            // directory and remove the file.
            File tempFile = File.createTempFile(this.getClass().getSimpleName(), ".tmp");
            File tempDir = tempFile.getParentFile();
            if (!tempFile.delete()) {
                tempFile.deleteOnExit();
            }

            String line = null;
            String delimiter = null;
            boolean skipEmpty = true;

            boolean shouldFail = false;
            Map<String, Boolean> features = new HashMap<String, Boolean>();
            String packageOverride = null;
            Map<String, String> injectAttributes = new HashMap<String, String>();
            StringBuilder expectedResult = new StringBuilder();
            StringBuilder expectedErrors = new StringBuilder();
            File mainFile = null;
            File actualResultFile = null;
            List<File> libFiles = new ArrayList<File>();
            List<File> overlayFiles = new ArrayList<File>();
            int tempIndex = 0;

            while ((line = reader.readLine()) != null) {
                if (skipEmpty && line.trim().isEmpty()) {
                    continue;
                }
                if (!line.isEmpty() && line.charAt(0) == '#') {
                    continue;
                }
                if (!line.isEmpty() && line.charAt(0) == '@') {
                    delimiter = line.substring(1);
                    assertTrue(
                        "Unknown delimiter @" + delimiter + " in " + filename,
                        delimiter.startsWith(DELIM_OVERLAY) ||
                        delimiter.startsWith(DELIM_LIB) ||
                        delimiter.equals(DELIM_MAIN)    ||
                        delimiter.equals(DELIM_RESULT)  ||
                        delimiter.equals(DELIM_ERRORS)  ||
                        delimiter.equals(DELIM_FAILS)   ||
                        delimiter.equals(DELIM_FEATURES) ||
                        delimiter.equals(DELIM_INJECT_ATTR) ||
                        delimiter.equals(DELIM_PACKAGE));

                    skipEmpty = true;

                    if (writer != null) {
                        try {
                            writer.close();
                        } catch (IOException ignore) {}
                        writer = null;
                    }

                    if (delimiter.equals(DELIM_FAILS)) {
                        shouldFail = true;

                    } else if (!delimiter.equals(DELIM_ERRORS) &&
                               !delimiter.equals(DELIM_FEATURES) &&
                               !delimiter.equals(DELIM_INJECT_ATTR) &&
                               !delimiter.equals(DELIM_PACKAGE)) {
                        tempFile = new File(tempDir, String.format("%1$s%2$d_%3$s.xml",
                                this.getClass().getSimpleName(),
                                tempIndex++,
                                delimiter.replaceAll("[^a-zA-Z0-9_-]", "")
                                ));
                        tempFile.deleteOnExit();

                        if (delimiter.startsWith(DELIM_OVERLAY)) {
                            overlayFiles.add(tempFile);
                        } else if (delimiter.startsWith(DELIM_LIB)) {
                            libFiles.add(tempFile);

                        } else if (delimiter.equals(DELIM_MAIN)) {
                            mainFile = tempFile;

                        } else if (delimiter.equals(DELIM_RESULT)) {
                            actualResultFile = tempFile;

                        } else {
                            fail("Unexpected data file delimiter @" + delimiter +
                                 " in " + filename);
                        }

                        if (!delimiter.equals(DELIM_RESULT)) {
                            writer = new BufferedWriter(new FileWriter(tempFile));
                        }
                    }

                    continue;
                }
                if (delimiter != null &&
                        skipEmpty &&
                        !line.isEmpty() &&
                        line.charAt(0) != '#' &&
                        line.charAt(0) != '@') {
                    skipEmpty = false;
                }
                if (writer != null) {
                    writer.write(line);
                    writer.write('\n');
                } else if (DELIM_RESULT.equals(delimiter)) {
                    expectedResult.append(line).append('\n');
                } else if (DELIM_ERRORS.equals(delimiter)) {
                    expectedErrors.append(line).append('\n');
                } else if (DELIM_INJECT_ATTR.equals(delimiter)) {
                    String[] in = line.split("=");
                    if (in != null && in.length == 2) {
                        injectAttributes.put(in[0], "null".equals(in[1]) ? null : in[1]);
                    }
                } else if (DELIM_FEATURES.equals(delimiter)) {
                    String[] in = line.split("=");
                    if (in != null && in.length == 2) {
                        features.put(in[0], Boolean.parseBoolean(in[1]));
                    }
                } else if (DELIM_PACKAGE.equals(delimiter)) {
                    if (packageOverride == null) {
                        packageOverride = line;
                    }
                }
            }

            assertNotNull("Missing @" + DELIM_MAIN + " in " + filename, mainFile);

            assert mainFile != null;

            Collections.sort(libFiles);

            return new TestFiles(
                    shouldFail,
                    overlayFiles.toArray(new File[overlayFiles.size()]),
                    mainFile,
                    libFiles.toArray(new File[libFiles.size()]),
                    features,
                    injectAttributes,
                    packageOverride,
                    actualResultFile,
                    expectedResult.toString(),
                    expectedErrors.toString());

        } catch (UnsupportedEncodingException e) {
            // BufferedReader failed to decode UTF-8, O'RLY?
            throw e;

        } finally {
            if (writer != null) {
                try {
                    writer.close();
                } catch (IOException ignore) {}
            }
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException ignore) {}
            }
            if (is != null) {
                try {
                    is.close();
                } catch (IOException ignore) {}
            }
        }
    }

//    /**
//     * Loads the data test files using {@link #loadTestData()} and then
//     * invokes {@link #processTestFiles(TestFiles)} to test them.
//     *
//     * @see #loadTestData()
//     * @see #processTestFiles(TestFiles)
//     */
//    void processTestFiles() throws Exception {
//        processTestFiles(loadTestData());
//    }

    /**
     * Processes the data from the given {@link TestFiles} by
     * invoking {@link ManifestMerger#process(File, File, File[], Map, String)}:
     * the given library files are applied consecutively to the main XML
     * document and the output is generated.
     * <p/>
     * Then the expected and actual outputs are loaded into a DOM,
     * dumped again to a String using an XML transform and compared.
     * This makes sure only the structure is checked and that any
     * formatting is ignored in the comparison.
     *
     * @param testFiles The test files to process. Must not be null.
     * @throws Exception when this go wrong.
     */
    void processTestFiles(TestFiles testFiles) throws Exception {
        MockLog log = new MockLog();
        IMergerLog mergerLog = MergerLog.wrapSdkLog(log);
        ManifestMerger merger = new ManifestMerger(mergerLog, new ICallback() {
            @Override
            public int queryCodenameApiLevel(@NonNull String codename) {
                if ("ApiCodename1".equals(codename)) {
                    return 1;
                } else if ("ApiCodename10".equals(codename)) {
                    return 10;
                }
                return ICallback.UNKNOWN_CODENAME;
            }
        });

        for (Entry<String, Boolean> feature : testFiles.getFeatures().entrySet()) {
            Method m = merger.getClass().getMethod(
                        feature.getKey(),
                        new Class<?>[] { boolean.class } );
            m.invoke(merger, new Object[] { feature.getValue() } );
        }

        boolean processOK = merger.process(testFiles.getActualResult(),
                                  testFiles.getMain(),
                                  testFiles.getLibs(),
                                  testFiles.getInjectAttributes(),
                                  testFiles.getPackageOverride());

        // Convert relative pathnames to absolute.
        String expectedErrors = testFiles.getExpectedErrors().trim();
        expectedErrors = expectedErrors.replaceAll(
                Pattern.quote(testFiles.getMain().getName()),
                Matcher.quoteReplacement(testFiles.getMain().getAbsolutePath()));
        for (File file : testFiles.getLibs()) {
            expectedErrors = expectedErrors.replaceAll(
                    Pattern.quote(file.getName()),
                    Matcher.quoteReplacement(file.getAbsolutePath()));
        }

        StringBuilder actualErrors = new StringBuilder();
        for (String s : log.getMessages()) {
            actualErrors.append(s);
            if (!s.endsWith("\n")) {
                actualErrors.append('\n');
            }
        }
        assertEquals("Error generated during merging",
                expectedErrors, actualErrors.toString().trim());

        if (testFiles.getShouldFail()) {
            assertFalse("Merge process() returned true, expected false", processOK);
        } else {
            assertTrue("Merge process() returned false, expected true", processOK);
        }

        // Test result XML. There should always be one created
        // since the process action does not stop on errors.
        log.clear();
        Document document = MergerXmlUtils.parseDocument(testFiles.getActualResult(), mergerLog,
                merger);
        assertNotNull(document);
        assert document != null; // for Eclipse null analysis
        String actual = MergerXmlUtils.printXmlString(document, mergerLog);
        assertEquals("Error parsing actual result XML", "", log.toString());
        log.clear();
        document = MergerXmlUtils.parseDocument(
                testFiles.getExpectedResult(),
                mergerLog,
                new FileAndLine("<expected-result>", 0));
        assertNotNull("Failed to parse result document: " + testFiles.getExpectedResult(),document);
        assert document != null;
        String expected = MergerXmlUtils.printXmlString(document, mergerLog);
        assertEquals("Error parsing expected result XML", "", log.toString());
        assertEquals("Error comparing expected to actual result", expected, actual);

        testFiles.cleanup();
    }

}
