/*
 * Copyright (C) 2010 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.tradefed.config;

import com.android.tradefed.invoker.tracing.CloseableTraceScope;
import com.android.tradefed.result.error.InfraErrorIdentifier;

import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

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

/**
 * Parses a configuration.xml file.
 * <p/>
 * See TODO for expected format
 */
class ConfigurationXmlParser {
    /**
     * SAX callback object. Handles parsing data from the xml tags.
     */
    static class ConfigHandler extends DefaultHandler {

        private static final String OBJECT_TAG = "object";
        private static final String OPTION_TAG = "option";
        private static final String INCLUDE_TAG = "include";
        private static final String TEMPLATE_INCLUDE_TAG = "template-include";
        private static final String CONFIG_TAG = "configuration";
        private static final String DEVICE_TAG = "device";
        private static final String IS_FAKE_ATTR = "isFake";

        /** Note that this simply hasn't been implemented; it is not intentionally forbidden. */
        static final String INNER_TEMPLATE_INCLUDE_ERROR =
                "Configurations which contain a <template-include> tag, not having a 'default' " +
                "attribute, may not be the target of any <include> or <template-include> tag. " +
                "However, configuration '%s' attempted to include configuration '%s', which " +
                "contains a <template-include> tag without a 'default' attribute.";

        // Settings
        private final IConfigDefLoader mConfigDefLoader;
        private final ConfigurationDef mConfigDef;
        private final Map<String, String> mTemplateMap;
        private final Set<String> mTemplateSeen;
        private final String mName;
        private final boolean mInsideParentDeviceTag;

        // State-holding members
        private String mCurrentConfigObject;
        private String mCurrentObjectType;
        private String mCurrentDeviceObject;
        private List<String> mListDevice = new ArrayList<String>();
        private List<String> mOutsideTag = new ArrayList<String>();

        private Boolean isLocalConfig = null;

        ConfigHandler(
                ConfigurationDef def,
                String name,
                IConfigDefLoader loader,
                String parentDeviceObject,
                Map<String, String> templateMap,
                Set<String> templateSeen) {
            mName = name;
            mConfigDef = def;
            mConfigDefLoader = loader;
            mCurrentDeviceObject = parentDeviceObject;
            mInsideParentDeviceTag = (parentDeviceObject != null) ? true : false;

            if (templateMap == null) {
                mTemplateMap = Collections.<String, String>emptyMap();
            } else {
                mTemplateMap = templateMap;
            }
            if (templateSeen == null) {
                mTemplateSeen = new HashSet<>();
            } else {
                mTemplateSeen = templateSeen;
            }
        }

        @Override
        public void startElement(String uri, String localName, String name, Attributes attributes)
                throws SAXException {
            if (OBJECT_TAG.equals(localName)) {
                final String objectTypeName = attributes.getValue("type");
                if (objectTypeName == null) {
                    throw new SAXException(new ConfigurationException(
                            "<object> must have a 'type' attribute"));
                }
                if (GlobalConfiguration.isBuiltInObjType(objectTypeName) ||
                        Configuration.isBuiltInObjType(objectTypeName)) {
                    throw new SAXException(new ConfigurationException(String.format("<object> "
                            + "cannot be type '%s' this is a reserved type.", objectTypeName)));
                }
                addObject(objectTypeName, attributes);
            } else if (DEVICE_TAG.equals(localName)) {
                if (mCurrentDeviceObject != null) {
                    throw new SAXException(new ConfigurationException(
                            "<device> tag cannot be included inside another device"));
                }
                // tag is a device tag (new format) for multi device definition.
                String deviceName = attributes.getValue("name");
                if (deviceName == null) {
                    throw new SAXException(
                            new ConfigurationException("device tag requires a name value"));
                }
                if (deviceName.equals(ConfigurationDef.DEFAULT_DEVICE_NAME)) {
                    throw new SAXException(new ConfigurationException(String.format("device name "
                            + "cannot be reserved name: '%s'",
                            ConfigurationDef.DEFAULT_DEVICE_NAME)));
                }
                if (deviceName.contains(String.valueOf(OptionSetter.NAMESPACE_SEPARATOR))) {
                    throw new SAXException(new ConfigurationException(String.format("device name "
                            + "cannot contain reserved character: '%s'",
                            OptionSetter.NAMESPACE_SEPARATOR)));
                }
                mConfigDef.setMultiDeviceMode(true);
                mCurrentDeviceObject = deviceName;
                addObject(localName, attributes);
            } else if (Configuration.isBuiltInObjType(localName)) {
                mCurrentObjectType = localName;
                // tag is a built in local config object
                if (isLocalConfig == null) {
                    isLocalConfig = true;
                } else if (!isLocalConfig) {
                    throwException(String.format(
                            "Attempted to specify local object '%s' for global config!",
                            localName));
                }
                // Prevent a TF object from being inside another one
                if (mCurrentConfigObject != null) {
                    throwException(
                            String.format(
                                    "Declared '%s' object inside %s is not valid.",
                                    localName, mCurrentConfigObject));
                }

                if (mCurrentDeviceObject == null &&
                        Configuration.doesBuiltInObjSupportMultiDevice(localName)) {
                    // Keep track of all the BuildInObj outside of device tag for final check
                    // if it turns out we are in multi mode, we will throw an exception.
                    mOutsideTag.add(localName);
                }
                // if we are inside a device object, some tags are not allowed.
                if (mCurrentDeviceObject != null) {
                    if (!Configuration.doesBuiltInObjSupportMultiDevice(localName)) {
                        // Prevent some tags to be inside of a device in multi device mode.
                        throw new SAXException(new ConfigurationException(
                                String.format("Tag %s should not be included in a <device> tag.",
                                        localName)));
                    }
                }
                addObject(localName, attributes);
            } else if (GlobalConfiguration.isBuiltInObjType(localName)) {
                // tag is a built in global config object
                if (isLocalConfig == null) {
                    // FIXME: config type should be explicit rather than inferred
                    isLocalConfig = false;
                } else if (isLocalConfig) {
                    throwException(String.format(
                            "Attempted to specify global object '%s' for local config!",
                            localName));
                }
                addObject(localName, attributes);
            } else if (OPTION_TAG.equals(localName)) {
                String optionName = attributes.getValue("name");
                if (optionName == null) {
                    throwException("Missing 'name' attribute for option");
                }

                String optionKey = attributes.getValue("key");
                // Key is optional at this stage.  If it's actually required, another stage in the
                // configuration validation will throw an exception.

                String optionValue = attributes.getValue("value");
                if (optionValue == null) {
                    throwException("Missing 'value' attribute for option '" + optionName + "'");
                }
                if (mCurrentConfigObject != null) {
                    // option is declared within a config object - namespace it with object class
                    // name
                    optionName = String.format("%s%c%s", mCurrentConfigObject,
                            OptionSetter.NAMESPACE_SEPARATOR, optionName);
                }
                if (mCurrentDeviceObject != null) {
                    // preprend the device name in extra if inside a device config object.
                    optionName = String.format("{%s}%s", mCurrentDeviceObject, optionName);
                }
                mConfigDef.addOptionDef(
                        optionName, optionKey, optionValue, mName, mCurrentObjectType);
            } else if (CONFIG_TAG.equals(localName)) {
                String description = attributes.getValue("description");
                if (description != null) {
                    // Ensure that we only set the description the first time and not when it is
                    // loading the <include> configuration.
                    if (mConfigDef.getDescription() == null ||
                            mConfigDef.getDescription().isEmpty()) {
                        mConfigDef.setDescription(description);
                    }
                }
            } else if (INCLUDE_TAG.equals(localName)) {
                String includeName = attributes.getValue("name");
                if (includeName == null) {
                    throwException("Missing 'name' attribute for include");
                }
                if (attributes.getLength() > 1) {
                    throwException("<include> tag only expect a 'name' attribute.");
                }
                try {
                    mConfigDefLoader.loadIncludedConfiguration(
                            mConfigDef,
                            mName,
                            includeName,
                            mCurrentDeviceObject,
                            mTemplateMap,
                            mTemplateSeen);
                } catch (ConfigurationException e) {
                    if (e instanceof TemplateResolutionError) {
                        throwException(String.format(INNER_TEMPLATE_INCLUDE_ERROR,
                                mConfigDef.getName(), includeName));
                    }
                    throw new SAXException(e);
                }
            } else if (TEMPLATE_INCLUDE_TAG.equals(localName)) {
                final String templateName = attributes.getValue("name");
                if (templateName == null) {
                    throwException("Missing 'name' attribute for template-include");
                }
                if (mCurrentDeviceObject != null) {
                    // TODO: Add this use case.
                    throwException("<template> inside device object currently not supported.");
                }
                if (mTemplateSeen.contains(templateName)) {
                    throwException(
                            String.format(
                                    "Template named '%s' appeared more than once.", templateName));
                }
                String includeName = mTemplateMap.get(templateName);
                if (includeName == null) {
                    includeName = attributes.getValue("default");
                }
                if (includeName == null) {
                    throwTemplateException(mConfigDef.getName(), templateName);
                }
                mTemplateSeen.add(templateName);
                // Removing the used template from the map to avoid re-using it.
                mTemplateMap.remove(templateName);
                try {
                    mConfigDefLoader.loadIncludedConfiguration(
                            mConfigDef, mName, includeName, null, mTemplateMap, mTemplateSeen);
                } catch (ConfigurationException e) {
                    if (e instanceof TemplateResolutionError) {
                        throwException(String.format(INNER_TEMPLATE_INCLUDE_ERROR,
                                mConfigDef.getName(), includeName));
                    }
                    throw new SAXException(e);
                }
            } else {
                throw new SAXException(String.format(
                        "Unrecognized tag '%s' in configuration", localName));
            }
        }

        @Override
        public void endElement (String uri, String localName, String qName) throws SAXException {
            if (OBJECT_TAG.equals(localName) || Configuration.isBuiltInObjType(localName)
                    || GlobalConfiguration.isBuiltInObjType(localName)) {
                mCurrentConfigObject = null;
            }
            if (DEVICE_TAG.equals(localName) && !mInsideParentDeviceTag) {
                // Only unset if it was not the parent device tag.
                mCurrentDeviceObject = null;
            }
            if (mCurrentObjectType != null && mCurrentObjectType.equals(localName)) {
                mCurrentObjectType = null;
            }
        }

        void addObject(String objectTypeName, Attributes attributes) throws SAXException {
            if (Configuration.DEVICE_NAME.equals(objectTypeName)) {
                String isFakeString = attributes.getValue(IS_FAKE_ATTR);
                boolean isFake = false;
                if (isFakeString != null && Boolean.parseBoolean(isFakeString) == true) {
                    isFake = true;
                }
                // We still want to add a standalone device without any inner object.
                String deviceName = attributes.getValue("name");
                if (!mListDevice.contains(deviceName)) {
                    mListDevice.add(deviceName);
                    mConfigDef.addConfigObjectDef(objectTypeName,
                            DeviceConfigurationHolder.class.getCanonicalName());
                }
                String resp = mConfigDef.addExpectedDevice(deviceName, isFake);
                if (resp != null) {
                    throwException(resp);
                }
            } else {
                String className = attributes.getValue("class");
                if (className == null) {
                    throwException(String.format("Missing class attribute for object %s",
                            objectTypeName));
                }
                if (mCurrentDeviceObject != null) {
                    // Add the device name as a namespace to the type
                    objectTypeName = mCurrentDeviceObject + OptionSetter.NAMESPACE_SEPARATOR
                            + objectTypeName;
                }
                int classCount = mConfigDef.addConfigObjectDef(objectTypeName, className);
                mCurrentConfigObject = String.format("%s%c%d", className,
                        OptionSetter.NAMESPACE_SEPARATOR, classCount);
            }
        }

        private void throwException(String reason) throws SAXException {
            throw new SAXException(
                    new ConfigurationException(
                            String.format(
                                    "Failed to parse config xml '%s'. Reason: %s",
                                    mConfigDef.getName(), reason),
                            InfraErrorIdentifier.OPTION_CONFIGURATION_ERROR));
        }

        private void throwTemplateException(String configName, String templateName)
                throws SAXException {
            throw new SAXException(new TemplateResolutionError(configName, templateName));
        }
    }

    private final IConfigDefLoader mConfigDefLoader;
    /**
     * If we are loading a config from inside a <device> tag, this will contain the name of the
     * current device tag to properly load in context.
     */
    private final String mParentDeviceObject;

    ConfigurationXmlParser(IConfigDefLoader loader, String parentDeviceObject) {
        mConfigDefLoader = loader;
        mParentDeviceObject = parentDeviceObject;
    }

    /**
     * Parses out configuration data contained in given input into the given configdef.
     *
     * <p>Currently performs limited error checking.
     *
     * @param configDef the {@link ConfigurationDef} to load data into
     * @param name the name of the configuration currently being loaded. Used for logging only. Can
     *     be different than configDef.getName in cases of included configs
     * @param xmlInput the configuration xml to parse
     * @param templateMap the current map of template to be loaded.
     * @throws ConfigurationException if input could not be parsed or had invalid format
     */
    void parse(
            ConfigurationDef configDef,
            String name,
            InputStream xmlInput,
            Map<String, String> templateMap)
            throws ConfigurationException {
        parse(configDef, name, xmlInput, templateMap, null);
    }

    /**
     * Parses out configuration data contained in given input into the given configdef.
     *
     * <p>Currently performs limited error checking.
     *
     * @param configDef the {@link ConfigurationDef} to load data into
     * @param name the name of the configuration currently being loaded. Used for logging only. Can
     *     be different than configDef.getName in cases of included configs
     * @param xmlInput the configuration xml to parse
     * @param templateMap the current map of template to be loaded.
     * @param templateSeen Set of name of template placeholder already encountered.
     * @throws ConfigurationException if input could not be parsed or had invalid format
     */
    void parse(
            ConfigurationDef configDef,
            String name,
            InputStream xmlInput,
            Map<String, String> templateMap,
            Set<String> templateSeen)
            throws ConfigurationException {
        try (CloseableTraceScope ignored =
                new CloseableTraceScope("ConfigurationXmlParser#parse")) {
            SAXParserFactory parserFactory = SAXParserFactory.newInstance();
            parserFactory.setNamespaceAware(true);
            SAXParser parser = parserFactory.newSAXParser();
            ConfigHandler configHandler =
                    new ConfigHandler(
                            configDef,
                            name,
                            mConfigDefLoader,
                            mParentDeviceObject,
                            templateMap,
                            templateSeen);
            parser.parse(new InputSource(xmlInput), configHandler);
            // ConfigurationDef holds whether or not the configs are multi-device or not.
            checkValidMultiConfiguration(configHandler, configDef);
        } catch (ParserConfigurationException e) {
            throwConfigException(name, e);
        } catch (SAXException e) {
            throwConfigException(name, e);
        } catch (IOException e) {
            throwConfigException(name, e);
        }
    }

    /**
     * Helper to encapsulate exceptions in a {@link ConfigurationException}
     */
    private void throwConfigException(String configName, Throwable e)
            throws ConfigurationException {
        if (e.getCause() instanceof ConfigurationException) {
            throw (ConfigurationException) e.getCause();
        }
        throw new ConfigurationException(
                String.format("Failed to parse config xml '%s' due to " + "'%s'", configName, e),
                e,
                InfraErrorIdentifier.OPTION_CONFIGURATION_ERROR);
    }

    /**
     * Validate that the configuration is valid from a multi device configuration standpoint: Some
     * tags are not allowed outside the <device> tags.
     */
    private void checkValidMultiConfiguration(
            ConfigHandler configHandler, ConfigurationDef configDef) throws SAXException {
        Map<String, Boolean> expected = configDef.getExpectedDevices();
        Long numDut =
                expected.values()
                        .stream()
                        .filter(value -> (value == false))
                        .collect(Collectors.counting());
        Long numNonDut =
                expected.values()
                        .stream()
                        .filter(value -> (value == true))
                        .collect(Collectors.counting());
        if (numNonDut > 0 && numDut <= 1) {
            // If we only have one DUT device and the rest are non-DUT devices. We need to consider
            // this has an hybrid use case since there is technically only one device. So we cannot
            // validate yet if objects are allowed to be outside <device> tags, it will be validated
            // later during the parsing when we have more information.
            return;
        }

        if (configDef.isMultiDeviceMode() && !configHandler.mOutsideTag.isEmpty()) {
            throw new SAXException(
                    new ConfigurationException(
                            String.format(
                                    "You seem to want a multi-devices configuration but you have "
                                            + "%s tags outside the <device> tags",
                                    configHandler.mOutsideTag)));
        }
    }
}
