/*
 * Copyright (C) 2014 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.manifmerger;

import static com.android.manifmerger.Actions.ActionType;

import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;

import org.w3c.dom.Node;

import java.util.Collection;
import java.util.List;
import java.util.Map;

/**
 * Validator that runs post merging activities and verifies that all "tools:" instructions
 * triggered an action by the merging tool.
 * <p>
 *
 * This is primarily to catch situations like a user entered a tools:remove="foo" directory on one
 * of its elements and that particular attribute was never removed during the merges possibly
 * indicating an unforeseen change of configuration.
 * <p>
 *
 * Most of the output from this validation should be warnings.
 */
public class PostValidator {

    /**
     * Post validation of the merged document. This will essentially check that all merging
     * instructions were applied at least once.
     *
     * @param xmlDocument merged document to check.
     * @param mergingReport report for errors and warnings.
     */
    public static void validate(
            @NonNull XmlDocument xmlDocument,
            @NonNull MergingReport.Builder mergingReport) {

        Preconditions.checkNotNull(xmlDocument);
        Preconditions.checkNotNull(mergingReport);
        enforceAndroidNamespaceDeclaration(xmlDocument);
        reOrderElements(xmlDocument.getRootNode());
        validate(xmlDocument.getRootNode(),
                mergingReport.getActionRecorder().build(),
                mergingReport);
    }

    /**
     * Enforces {@link com.android.SdkConstants#ANDROID_URI} declaration in the top level element.
     * It is possible that the original manifest file did not contain any attribute declaration,
     * therefore not requiring a xmlns: declaration. Yet the implicit elements handling may have
     * added attributes requiring the namespace declaration.
     */
    private static void enforceAndroidNamespaceDeclaration(@NonNull XmlDocument xmlDocument) {
        XmlElement manifest = xmlDocument.getRootNode();
        for (XmlAttribute xmlAttribute : manifest.getAttributes()) {
            if (xmlAttribute.getXml().getName().startsWith(SdkConstants.XMLNS) &&
                    SdkConstants.ANDROID_URI.equals(xmlAttribute.getValue())) {
                return;
            }
        }
        // if we are here, we did not find the namespace declaration, add it.
        manifest.getXml().setAttribute(SdkConstants.XMLNS + ":" + "android",
                SdkConstants.ANDROID_URI);
    }

    /**
     * Reorder child elements :
     * <li>
     *     <ul> <application> is moved last in the list of children
     * of the <manifest> element.
     *     <ul> uses-sdk is moved first in the list of children of the <manifest> element </ul>
     * </li>
     * @param xmlElement the root element of the manifest document.
     */
    private static void reOrderElements(@NonNull XmlElement xmlElement) {

        reOrderApplication(xmlElement);
        reOrderUsesSdk(xmlElement);
    }

    /**
     * Reorder application element
     *
     * @param xmlElement the root element of the manifest document.
     */
    private static void reOrderApplication(@NonNull XmlElement xmlElement) {

        // look up application element.
        Optional<XmlElement> element = xmlElement
                .getNodeByTypeAndKey(ManifestModel.NodeTypes.APPLICATION, null);
        if (!element.isPresent()) {
            return;
        }
        XmlElement applicationElement = element.get();

        List<Node> comments = XmlElement.getLeadingComments(applicationElement.getXml());

        // move the application's comments if any.
        for (Node comment : comments) {
            xmlElement.getXml().removeChild(comment);
            xmlElement.getXml().appendChild(comment);
        }
        // remove the application element and add it back, it will be automatically placed last.
        xmlElement.getXml().removeChild(applicationElement.getXml());
        xmlElement.getXml().appendChild(applicationElement.getXml());
    }

    /**
     * Reorder uses-sdk element
     *
     * @param xmlElement the root element of the manifest document.
     */
    private static void reOrderUsesSdk(@NonNull XmlElement xmlElement) {

        // look up application element.
        Optional<XmlElement> element = xmlElement
                .getNodeByTypeAndKey(ManifestModel.NodeTypes.USES_SDK, null);
        if (!element.isPresent()) {
            return;
        }

        XmlElement usesSdk = element.get();
        Node firstChild = xmlElement.getXml().getFirstChild();
        // already the first element ?
        if (firstChild == usesSdk.getXml()) {
            return;
        }

        List<Node> comments = XmlElement.getLeadingComments(usesSdk.getXml());

        // move the application's comments if any.
        for (Node comment : comments) {
            xmlElement.getXml().removeChild(comment);
            xmlElement.getXml().insertBefore(comment, firstChild);
        }
        // remove the application element and add it back, it will be automatically placed last.
        xmlElement.getXml().removeChild(usesSdk.getXml());
        xmlElement.getXml().insertBefore(usesSdk.getXml(), firstChild);
    }

    /**
     * Validate an xml element and recursively its children elements, ensuring that all merging
     * instructions were applied.
     *
     * @param xmlElement xml element to validate.
     * @param actions the actions recorded during the merging activities.
     * @param mergingReport report for errors and warnings.
     * instructions were applied once or {@link MergingReport.Result#WARNING} otherwise.
     */
    private static void validate(
            @NonNull XmlElement xmlElement,
            @NonNull Actions actions,
            @NonNull MergingReport.Builder mergingReport) {

        NodeOperationType operationType = xmlElement.getOperationType();
        switch (operationType) {
            case REPLACE:
                // we should find at least one rejected twin.
                if (!isNodeOperationPresent(xmlElement, actions, ActionType.REJECTED)) {
                    xmlElement.addMessage(mergingReport, MergingReport.Record.Severity.WARNING,
                            String.format(
                                    "%1$s was tagged at %2$s:%3$d to replace another declaration "
                                            + "but no other declaration present",
                                    xmlElement.getId(),
                                    xmlElement.getDocument().getSourceFile().print(true),
                                    xmlElement.getPosition().getStartLine() + 1
                            ));
                }
                break;
            case REMOVE:
            case REMOVE_ALL:
                // we should find at least one rejected twin.
                if (!isNodeOperationPresent(xmlElement, actions, ActionType.REJECTED)) {
                    xmlElement.addMessage(mergingReport, MergingReport.Record.Severity.WARNING,
                            String.format(
                                    "%1$s was tagged at %2$s:%3$d to remove other declarations "
                                            + "but no other declaration present",
                                    xmlElement.getId(),
                                    xmlElement.getDocument().getSourceFile().print(true),
                                    xmlElement.getPosition().getStartLine() + 1
                            ));
                }
                break;
        }
        validateAttributes(xmlElement, actions, mergingReport);
        validateAndroidAttributes(xmlElement, mergingReport);
        for (XmlElement child : xmlElement.getMergeableElements()) {
            validate(child, actions, mergingReport);
        }
    }


    /**
     * Verifies that all merging attributes on a passed xml element were applied.
     */
    private static void validateAttributes(
            @NonNull XmlElement xmlElement,
            @NonNull Actions actions,
            @NonNull MergingReport.Builder mergingReport) {

        @NonNull Collection<Map.Entry<XmlNode.NodeName, AttributeOperationType>> attributeOperations
                = xmlElement.getAttributeOperations();
        for (Map.Entry<XmlNode.NodeName, AttributeOperationType> attributeOperation :
                attributeOperations) {
            switch (attributeOperation.getValue()) {
                case REMOVE:
                    if (!isAttributeOperationPresent(
                            xmlElement, attributeOperation, actions, ActionType.REJECTED)) {
                        xmlElement.addMessage(mergingReport, MergingReport.Record.Severity.WARNING,
                                String.format(
                                        "%1$s@%2$s was tagged at %3$s:%4$d to remove other"
                                                + " declarations but no other declaration present",
                                        xmlElement.getId(),
                                        attributeOperation.getKey(),
                                        xmlElement.getDocument().getSourceFile().print(true),
                                        xmlElement.getPosition().getStartLine() + 1
                                ));
                    }
                    break;
                case REPLACE:
                    if (!isAttributeOperationPresent(
                            xmlElement, attributeOperation, actions, ActionType.REJECTED)) {
                        xmlElement.addMessage(mergingReport, MergingReport.Record.Severity.WARNING,
                                String.format(
                                        "%1$s@%2$s was tagged at %3$s:%4$d to replace other"
                                                + " declarations but no other declaration present",
                                        xmlElement.getId(),
                                        attributeOperation.getKey(),
                                        xmlElement.getDocument().getSourceFile().print(true),
                                        xmlElement.getPosition().getStartLine() + 1
                                ));
                    }
                    break;
            }
        }

    }

    /**
     * Check in our list of applied actions that a particular
     * {@link com.android.manifmerger.Actions.ActionType} action was recorded on the passed element.
     * @return true if it was applied, false otherwise.
     */
    private static boolean isNodeOperationPresent(@NonNull XmlElement xmlElement,
            @NonNull Actions actions,
            ActionType action) {

        for (Actions.NodeRecord nodeRecord : actions.getNodeRecords(xmlElement.getId())) {
            if (nodeRecord.getActionType() == action) {
                return true;
            }
        }
        return false;
    }

    /**
     * Check in our list of attribute actions that a particular
     * {@link com.android.manifmerger.Actions.ActionType} action was recorded on the passed element.
     * @return true if it was applied, false otherwise.
     */
    private static boolean isAttributeOperationPresent(@NonNull XmlElement xmlElement,
            @NonNull Map.Entry<XmlNode.NodeName, AttributeOperationType> attributeOperation,
            @NonNull Actions actions,
            ActionType action) {

        for (Actions.AttributeRecord attributeRecord : actions.getAttributeRecords(
                xmlElement.getId(), attributeOperation.getKey())) {
            if (attributeRecord.getActionType() == action) {
                return true;
            }
        }
        return false;
    }

    /**
     * Validates all {@link com.android.manifmerger.XmlElement} attributes belonging to the
     * {@link com.android.SdkConstants#ANDROID_URI} namespace.
     *
     * @param xmlElement xml element to check the attributes from.
     * @param mergingReport report for errors and warnings.
     */
    private static void validateAndroidAttributes(@NonNull XmlElement xmlElement,
            @NonNull MergingReport.Builder mergingReport) {

        for (XmlAttribute xmlAttribute : xmlElement.getAttributes()) {
            if (xmlAttribute.getModel() != null) {
                AttributeModel.Validator onWriteValidator = xmlAttribute.getModel()
                        .getOnWriteValidator();
                if (onWriteValidator != null) {
                    onWriteValidator.validates(
                            mergingReport, xmlAttribute, xmlAttribute.getValue());
                }
            }
        }
    }
}
