/*
 * Copyright (C) 2007 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.ide.common.xml;

import com.android.SdkConstants;
import com.android.ide.common.xml.ManifestData.Activity;
import com.android.ide.common.xml.ManifestData.Instrumentation;
import com.android.ide.common.xml.ManifestData.SupportsScreens;
import com.android.ide.common.xml.ManifestData.UsesConfiguration;
import com.android.ide.common.xml.ManifestData.UsesFeature;
import com.android.ide.common.xml.ManifestData.UsesLibrary;
import com.android.io.IAbstractFile;
import com.android.io.IAbstractFolder;
import com.android.io.StreamException;
import com.android.resources.Keyboard;
import com.android.resources.Navigation;
import com.android.resources.TouchScreen;
import com.android.xml.AndroidManifest;
import com.google.common.io.Closeables;

import org.xml.sax.Attributes;
import org.xml.sax.ErrorHandler;
import org.xml.sax.InputSource;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import org.xml.sax.helpers.DefaultHandler;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.Locale;

import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

public class AndroidManifestParser {

    private static final int LEVEL_TOP = 0;
    private static final int LEVEL_INSIDE_MANIFEST = 1;
    private static final int LEVEL_INSIDE_APPLICATION = 2;
    private static final int LEVEL_INSIDE_APP_COMPONENT = 3;
    private static final int LEVEL_INSIDE_INTENT_FILTER = 4;

    private static final String ACTION_MAIN = "android.intent.action.MAIN"; //$NON-NLS-1$
    private static final String CATEGORY_LAUNCHER = "android.intent.category.LAUNCHER"; //$NON-NLS-1$

    public interface ManifestErrorHandler extends ErrorHandler {
        /**
         * Handles a parsing error and an optional line number.
         */
        void handleError(Exception exception, int lineNumber);

        /**
         * Checks that a class is valid and can be used in the Android Manifest.
         * <p>
         * Errors are put as {@code org.eclipse.core.resources.IMarker} on the manifest file.
         *
         * @param locator
         * @param className the fully qualified name of the class to test.
         * @param superClassName the fully qualified name of the class it is supposed to extend.
         * @param testVisibility if <code>true</code>, the method will check the visibility of
         * the class or of its constructors.
         */
        void checkClass(Locator locator, String className, String superClassName,
                boolean testVisibility);
    }

    /**
     * XML error and data handler used when parsing the AndroidManifest.xml file.
     * <p>
     * During parsing this will fill up the {@link ManifestData} object given to the constructor
     * and call out errors to the given {@link ManifestErrorHandler}.
     */
    private static class ManifestHandler extends DefaultHandler {

        //--- temporary data/flags used during parsing
        private final ManifestData mManifestData;
        private final ManifestErrorHandler mErrorHandler;
        private int mCurrentLevel = 0;
        private int mValidLevel = 0;
        private Activity mCurrentActivity = null;
        private Locator mLocator;

        /**
         * Creates a new {@link ManifestHandler}.
         *
         * @param manifestFile The manifest file being parsed. Can be null.
         * @param manifestData Class containing the manifest info obtained during the parsing.
         * @param errorHandler An optional error handler.
         */
        ManifestHandler(IAbstractFile manifestFile, ManifestData manifestData,
                ManifestErrorHandler errorHandler) {
            super();
            mManifestData = manifestData;
            mErrorHandler = errorHandler;
        }

        /* (non-Javadoc)
         * @see org.xml.sax.helpers.DefaultHandler#setDocumentLocator(org.xml.sax.Locator)
         */
        @Override
        public void setDocumentLocator(Locator locator) {
            mLocator = locator;
            super.setDocumentLocator(locator);
        }

        /* (non-Javadoc)
         * @see org.xml.sax.helpers.DefaultHandler#startElement(java.lang.String, java.lang.String,
         * java.lang.String, org.xml.sax.Attributes)
         */
        @Override
        public void startElement(String uri, String localName, String name, Attributes attributes)
                throws SAXException {
            try {
                if (mManifestData == null) {
                    return;
                }

                // if we're at a valid level
                if (mValidLevel == mCurrentLevel) {
                    String value;
                    switch (mValidLevel) {
                        case LEVEL_TOP:
                            if (AndroidManifest.NODE_MANIFEST.equals(localName)) {
                                // lets get the package name.
                                mManifestData.mPackage = getAttributeValue(attributes,
                                        AndroidManifest.ATTRIBUTE_PACKAGE,
                                        false /* hasNamespace */);

                                // and the versionCode
                                String tmp = getAttributeValue(attributes,
                                        AndroidManifest.ATTRIBUTE_VERSIONCODE, true);
                                if (tmp != null) {
                                    try {
                                        mManifestData.mVersionCode = Integer.valueOf(tmp);
                                    } catch (NumberFormatException e) {
                                        // keep null in the field.
                                    }
                                }
                                mValidLevel++;
                            }
                            break;
                        case LEVEL_INSIDE_MANIFEST:
                            if (AndroidManifest.NODE_APPLICATION.equals(localName)) {
                                value = getAttributeValue(attributes,
                                        AndroidManifest.ATTRIBUTE_PROCESS,
                                        true /* hasNamespace */);
                                if (value != null) {
                                    mManifestData.addProcessName(value);
                                }

                                value = getAttributeValue(attributes,
                                        AndroidManifest.ATTRIBUTE_DEBUGGABLE,
                                        true /* hasNamespace*/);
                                if (value != null) {
                                    mManifestData.mDebuggable = Boolean.parseBoolean(value);
                                }

                                mValidLevel++;
                            } else if (AndroidManifest.NODE_USES_SDK.equals(localName)) {
                                mManifestData.setMinSdkVersionString(getAttributeValue(attributes,
                                        AndroidManifest.ATTRIBUTE_MIN_SDK_VERSION,
                                        true /* hasNamespace */));
                                mManifestData.setTargetSdkVersionString(getAttributeValue(attributes,
                                        AndroidManifest.ATTRIBUTE_TARGET_SDK_VERSION,
                                        true /* hasNamespace */));
                            } else if (AndroidManifest.NODE_INSTRUMENTATION.equals(localName)) {
                                processInstrumentationNode(attributes);

                            } else if (AndroidManifest.NODE_SUPPORTS_SCREENS.equals(localName)) {
                                processSupportsScreensNode(attributes);

                            } else if (AndroidManifest.NODE_USES_CONFIGURATION.equals(localName)) {
                                processUsesConfiguration(attributes);

                            } else if (AndroidManifest.NODE_USES_FEATURE.equals(localName)) {
                                UsesFeature feature = new UsesFeature();

                                // get the name
                                value = getAttributeValue(attributes,
                                        AndroidManifest.ATTRIBUTE_NAME,
                                        true /* hasNamespace */);
                                if (value != null) {
                                    feature.mName = value;
                                }

                                // read the required attribute
                                value = getAttributeValue(attributes,
                                        AndroidManifest.ATTRIBUTE_REQUIRED,
                                        true /*hasNamespace*/);
                                if (value != null) {
                                    Boolean b = Boolean.valueOf(value);
                                    if (b != null) {
                                        feature.mRequired = b;
                                    }
                                }

                                // read the gl es attribute
                                value = getAttributeValue(attributes,
                                        AndroidManifest.ATTRIBUTE_GLESVERSION,
                                        true /*hasNamespace*/);
                                if (value != null) {
                                    try {
                                        int version = Integer.decode(value);
                                        feature.mGlEsVersion = version;
                                    } catch (NumberFormatException e) {
                                        // ignore
                                    }

                                }

                                mManifestData.mFeatures.add(feature);
                            }
                            break;
                        case LEVEL_INSIDE_APPLICATION:
                            if (AndroidManifest.NODE_ACTIVITY.equals(localName)
                                    || AndroidManifest.NODE_ACTIVITY_ALIAS.equals(localName)) {
                                processActivityNode(attributes);
                                mValidLevel++;
                            } else if (AndroidManifest.NODE_SERVICE.equals(localName)) {
                                processNode(attributes, SdkConstants.CLASS_SERVICE);
                                mValidLevel++;
                            } else if (AndroidManifest.NODE_RECEIVER.equals(localName)) {
                                processNode(attributes, SdkConstants.CLASS_BROADCASTRECEIVER);
                                mValidLevel++;
                            } else if (AndroidManifest.NODE_PROVIDER.equals(localName)) {
                                processNode(attributes, SdkConstants.CLASS_CONTENTPROVIDER);
                                mValidLevel++;
                            } else if (AndroidManifest.NODE_USES_LIBRARY.equals(localName)) {
                                value = getAttributeValue(attributes,
                                        AndroidManifest.ATTRIBUTE_NAME,
                                        true /* hasNamespace */);
                                if (value != null) {
                                    UsesLibrary library = new UsesLibrary();
                                    library.mName = value;

                                    // read the required attribute
                                    value = getAttributeValue(attributes,
                                            AndroidManifest.ATTRIBUTE_REQUIRED,
                                            true /*hasNamespace*/);
                                    if (value != null) {
                                        Boolean b = Boolean.valueOf(value);
                                        if (b != null) {
                                            library.mRequired = b;
                                        }
                                    }

                                    mManifestData.mLibraries.add(library);
                                }
                            }
                            break;
                        case LEVEL_INSIDE_APP_COMPONENT:
                            // only process this level if we are in an activity
                            if (mCurrentActivity != null &&
                                    AndroidManifest.NODE_INTENT.equals(localName)) {
                                mCurrentActivity.resetIntentFilter();
                                mValidLevel++;
                            }
                            break;
                        case LEVEL_INSIDE_INTENT_FILTER:
                            if (mCurrentActivity != null) {
                                if (AndroidManifest.NODE_ACTION.equals(localName)) {
                                    // get the name attribute
                                    String action = getAttributeValue(attributes,
                                            AndroidManifest.ATTRIBUTE_NAME,
                                            true /* hasNamespace */);
                                    if (action != null) {
                                        mCurrentActivity.setHasAction(true);
                                        mCurrentActivity.setHasMainAction(
                                                ACTION_MAIN.equals(action));
                                    }
                                } else if (AndroidManifest.NODE_CATEGORY.equals(localName)) {
                                    String category = getAttributeValue(attributes,
                                            AndroidManifest.ATTRIBUTE_NAME,
                                            true /* hasNamespace */);
                                    if (CATEGORY_LAUNCHER.equals(category)) {
                                        mCurrentActivity.setHasLauncherCategory(true);
                                    }
                                }

                                // no need to increase mValidLevel as we don't process anything
                                // below this level.
                            }
                            break;
                    }
                }

                mCurrentLevel++;
            } finally {
                super.startElement(uri, localName, name, attributes);
            }
        }

        /* (non-Javadoc)
         * @see org.xml.sax.helpers.DefaultHandler#endElement(java.lang.String, java.lang.String,
         * java.lang.String)
         */
        @Override
        public void endElement(String uri, String localName, String name) throws SAXException {
            try {
                if (mManifestData == null) {
                    return;
                }

                // decrement the levels.
                if (mValidLevel == mCurrentLevel) {
                    mValidLevel--;
                }
                mCurrentLevel--;

                // if we're at a valid level
                // process the end of the element
                if (mValidLevel == mCurrentLevel) {
                    switch (mValidLevel) {
                        case LEVEL_INSIDE_APPLICATION:
                            mCurrentActivity = null;
                            break;
                        case LEVEL_INSIDE_APP_COMPONENT:
                            // if we found both a main action and a launcher category, this is our
                            // launcher activity!
                            if (mManifestData.mLauncherActivity == null &&
                                    mCurrentActivity != null &&
                                    mCurrentActivity.isHomeActivity() &&
                                    mCurrentActivity.isExported()) {
                                mManifestData.mLauncherActivity = mCurrentActivity;
                            }
                            break;
                        default:
                            break;
                    }

                }
            } finally {
                super.endElement(uri, localName, name);
            }
        }

        /* (non-Javadoc)
         * @see org.xml.sax.helpers.DefaultHandler#error(org.xml.sax.SAXParseException)
         */
        @Override
        public void error(SAXParseException e) {
            if (mErrorHandler != null) {
                mErrorHandler.handleError(e, e.getLineNumber());
            }
        }

        /* (non-Javadoc)
         * @see org.xml.sax.helpers.DefaultHandler#fatalError(org.xml.sax.SAXParseException)
         */
        @Override
        public void fatalError(SAXParseException e) {
            if (mErrorHandler != null) {
                mErrorHandler.handleError(e, e.getLineNumber());
            }
        }

        /* (non-Javadoc)
         * @see org.xml.sax.helpers.DefaultHandler#warning(org.xml.sax.SAXParseException)
         */
        @Override
        public void warning(SAXParseException e) throws SAXException {
            if (mErrorHandler != null) {
                mErrorHandler.warning(e);
            }
        }

        /**
         * Processes the activity node.
         * @param attributes the attributes for the activity node.
         */
        private void processActivityNode(Attributes attributes) {
            // lets get the activity name, and add it to the list
            String activityName = getAttributeValue(attributes, AndroidManifest.ATTRIBUTE_NAME,
                    true /* hasNamespace */);
            if (activityName != null) {
                activityName = AndroidManifest.combinePackageAndClassName(mManifestData.mPackage,
                        activityName);

                // get the exported flag.
                String exportedStr = getAttributeValue(attributes,
                        AndroidManifest.ATTRIBUTE_EXPORTED, true);
                boolean exported = exportedStr == null ||
                        exportedStr.toLowerCase(Locale.US).equals("true"); //$NON-NLS-1$
                mCurrentActivity = new Activity(activityName, exported);
                mManifestData.mActivities.add(mCurrentActivity);

                if (mErrorHandler != null) {
                    mErrorHandler.checkClass(mLocator, activityName, SdkConstants.CLASS_ACTIVITY,
                            true /* testVisibility */);
                }
            } else {
                // no activity found! Aapt will output an error,
                // so we don't have to do anything
                mCurrentActivity = null;
            }

            String processName = getAttributeValue(attributes, AndroidManifest.ATTRIBUTE_PROCESS,
                    true /* hasNamespace */);
            if (processName != null) {
                mManifestData.addProcessName(processName);
            }
        }

        /**
         * Processes the service/receiver/provider nodes.
         * @param attributes the attributes for the activity node.
         * @param superClassName the fully qualified name of the super class that this
         * node is representing
         */
        private void processNode(Attributes attributes, String superClassName) {
            // lets get the class name, and check it if required.
            String serviceName = getAttributeValue(attributes, AndroidManifest.ATTRIBUTE_NAME,
                    true /* hasNamespace */);
            if (serviceName != null) {
                serviceName = AndroidManifest.combinePackageAndClassName(mManifestData.mPackage,
                        serviceName);

                if (mErrorHandler != null) {
                    mErrorHandler.checkClass(mLocator, serviceName, superClassName,
                            false /* testVisibility */);
                }
            }

            String processName = getAttributeValue(attributes, AndroidManifest.ATTRIBUTE_PROCESS,
                    true /* hasNamespace */);
            if (processName != null) {
                mManifestData.addProcessName(processName);
            }
        }

        /**
         * Processes the instrumentation node.
         * @param attributes the attributes for the instrumentation node.
         */
        private void processInstrumentationNode(Attributes attributes) {
            // lets get the class name, and check it if required.
            String instrumentationName = getAttributeValue(attributes,
                    AndroidManifest.ATTRIBUTE_NAME,
                    true /* hasNamespace */);
            if (instrumentationName != null) {
                String instrClassName = AndroidManifest.combinePackageAndClassName(
                        mManifestData.mPackage, instrumentationName);
                String targetPackage = getAttributeValue(attributes,
                        AndroidManifest.ATTRIBUTE_TARGET_PACKAGE,
                        true /* hasNamespace */);
                mManifestData.mInstrumentations.add(
                        new Instrumentation(instrClassName, targetPackage));
                if (mErrorHandler != null) {
                    mErrorHandler.checkClass(mLocator, instrClassName,
                            SdkConstants.CLASS_INSTRUMENTATION, true /* testVisibility */);
                }
            }
        }

        /**
         * Processes the supports-screens node.
         * @param attributes the attributes for the supports-screens node.
         */
        private void processSupportsScreensNode(Attributes attributes) {
            mManifestData.mSupportsScreensFromManifest = new SupportsScreens();

            mManifestData.mSupportsScreensFromManifest.setResizeable(getAttributeBooleanValue(
                    attributes, AndroidManifest.ATTRIBUTE_RESIZEABLE, true /*hasNamespace*/));

            mManifestData.mSupportsScreensFromManifest.setAnyDensity(getAttributeBooleanValue(
                    attributes, AndroidManifest.ATTRIBUTE_ANYDENSITY, true /*hasNamespace*/));

            mManifestData.mSupportsScreensFromManifest.setSmallScreens(getAttributeBooleanValue(
                    attributes, AndroidManifest.ATTRIBUTE_SMALLSCREENS, true /*hasNamespace*/));

            mManifestData.mSupportsScreensFromManifest.setNormalScreens(getAttributeBooleanValue(
                    attributes, AndroidManifest.ATTRIBUTE_NORMALSCREENS, true /*hasNamespace*/));

            mManifestData.mSupportsScreensFromManifest.setLargeScreens(getAttributeBooleanValue(
                    attributes, AndroidManifest.ATTRIBUTE_LARGESCREENS, true /*hasNamespace*/));
        }

        /**
         * Processes the supports-screens node.
         * @param attributes the attributes for the supports-screens node.
         */
        private void processUsesConfiguration(Attributes attributes) {
            mManifestData.mUsesConfiguration = new UsesConfiguration();

            mManifestData.mUsesConfiguration.mReqFiveWayNav = getAttributeBooleanValue(
                    attributes,
                    AndroidManifest.ATTRIBUTE_REQ_5WAYNAV, true /*hasNamespace*/);
            mManifestData.mUsesConfiguration.mReqNavigation = Navigation.getEnum(
                    getAttributeValue(attributes,
                            AndroidManifest.ATTRIBUTE_REQ_NAVIGATION, true /*hasNamespace*/));
            mManifestData.mUsesConfiguration.mReqHardKeyboard = getAttributeBooleanValue(
                    attributes,
                    AndroidManifest.ATTRIBUTE_REQ_HARDKEYBOARD, true /*hasNamespace*/);
            mManifestData.mUsesConfiguration.mReqKeyboardType = Keyboard.getEnum(
                    getAttributeValue(attributes,
                            AndroidManifest.ATTRIBUTE_REQ_KEYBOARDTYPE, true /*hasNamespace*/));
            mManifestData.mUsesConfiguration.mReqTouchScreen = TouchScreen.getEnum(
                    getAttributeValue(attributes,
                            AndroidManifest.ATTRIBUTE_REQ_TOUCHSCREEN, true /*hasNamespace*/));
        }

        /**
         * Searches through the attributes list for a particular one and returns its value.
         * @param attributes the attribute list to search through
         * @param attributeName the name of the attribute to look for.
         * @param hasNamespace Indicates whether the attribute has an android namespace.
         * @return a String with the value or null if the attribute was not found.
         * @see SdkConstants#NS_RESOURCES
         */
        private String getAttributeValue(Attributes attributes, String attributeName,
                boolean hasNamespace) {
            int count = attributes.getLength();
            for (int i = 0 ; i < count ; i++) {
                if (attributeName.equals(attributes.getLocalName(i)) &&
                        ((hasNamespace &&
                                SdkConstants.NS_RESOURCES.equals(attributes.getURI(i))) ||
                                (hasNamespace == false && attributes.getURI(i).isEmpty()))) {
                    return attributes.getValue(i);
                }
            }

            return null;
        }

        /**
         * Searches through the attributes list for a particular one and returns its value as a
         * Boolean. If the attribute is not present, this will return null.
         * @param attributes the attribute list to search through
         * @param attributeName the name of the attribute to look for.
         * @param hasNamespace Indicates whether the attribute has an android namespace.
         * @return a String with the value or null if the attribute was not found.
         * @see SdkConstants#NS_RESOURCES
         */
        private Boolean getAttributeBooleanValue(Attributes attributes, String attributeName,
                boolean hasNamespace) {
            int count = attributes.getLength();
            for (int i = 0 ; i < count ; i++) {
                if (attributeName.equals(attributes.getLocalName(i)) &&
                        ((hasNamespace &&
                                SdkConstants.NS_RESOURCES.equals(attributes.getURI(i))) ||
                                (hasNamespace == false && attributes.getURI(i).isEmpty()))) {
                    String attr = attributes.getValue(i);
                    if (attr != null) {
                        return Boolean.valueOf(attr);
                    } else {
                        return null;
                    }
                }
            }

            return null;
        }

    }

    private static final SAXParserFactory sParserFactory;

    static {
        sParserFactory = SAXParserFactory.newInstance();
        sParserFactory.setNamespaceAware(true);
    }

    /**
     * Parses the Android Manifest, and returns a {@link ManifestData} object containing the
     * result of the parsing.
     *
     * @param manifestFile the {@link IAbstractFile} representing the manifest file.
     * @param gatherData indicates whether the parsing will extract data from the manifest. If false
     * the method will always return null.
     * @param errorHandler an optional errorHandler.
     * @return A class containing the manifest info obtained during the parsing, or null on error.
     *
     * @throws StreamException
     * @throws IOException
     * @throws SAXException
     * @throws ParserConfigurationException
     */
    public static ManifestData parse(
            IAbstractFile manifestFile,
            boolean gatherData,
            ManifestErrorHandler errorHandler)
                throws SAXException, IOException, StreamException, ParserConfigurationException {
        if (manifestFile != null) {
            SAXParser parser = sParserFactory.newSAXParser();

            ManifestData data = null;
            if (gatherData) {
                data = new ManifestData();
            }

            ManifestHandler manifestHandler = new ManifestHandler(manifestFile,
                    data, errorHandler);
            InputStream is = manifestFile.getContents();
            try {
                parser.parse(new InputSource(is), manifestHandler);
            } finally {
                try {
                    Closeables.close(is, true /* swallowIOException */);
                } catch (IOException e) {
                    // cannot happen
                }
            }

            return data;
        }

        return null;
    }

    /**
     * Parses the Android Manifest, and returns an object containing the result of the parsing.
     *
     * <p>
     * This is the equivalent of calling <pre>parse(manifestFile, true, null)</pre>
     *
     * @param manifestFile the manifest file to parse.
     *
     * @throws ParserConfigurationException
     * @throws StreamException
     * @throws IOException
     * @throws SAXException
     */
    public static ManifestData parse(IAbstractFile manifestFile)
            throws SAXException, IOException, StreamException, ParserConfigurationException {
        return parse(manifestFile, true, null);
    }

    public static ManifestData parse(IAbstractFolder projectFolder)
            throws SAXException, IOException, StreamException, ParserConfigurationException {
        IAbstractFile manifestFile = AndroidManifest.getManifest(projectFolder);
        if (manifestFile == null) {
            throw new FileNotFoundException();
        }

        return parse(manifestFile, true, null);
    }

    /**
     * Parses the Android Manifest from an {@link InputStream}, and returns a {@link ManifestData}
     * object containing the result of the parsing.
     *
     * @param manifestFileStream the {@link InputStream} representing the manifest file.
     * @return A class containing the manifest info obtained during the parsing or null on error.
     *
     * @throws StreamException
     * @throws IOException
     * @throws SAXException
     * @throws ParserConfigurationException
     */
    public static ManifestData parse(InputStream manifestFileStream)
            throws SAXException, IOException, StreamException, ParserConfigurationException {
        if (manifestFileStream != null) {
            SAXParser parser = sParserFactory.newSAXParser();

            ManifestData data = new ManifestData();

            ManifestHandler manifestHandler = new ManifestHandler(null, data, null);
            parser.parse(new InputSource(manifestFileStream), manifestHandler);

            return data;
        }

        return null;
    }
}
