/*
 * 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.ide.eclipse.adt.internal.editors.formatting;

import static com.android.ide.eclipse.adt.internal.editors.AndroidXmlAutoEditStrategy.findLineStart;
import static com.android.ide.eclipse.adt.internal.editors.AndroidXmlAutoEditStrategy.findTextStart;
import static org.eclipse.jface.text.formatter.FormattingContextProperties.CONTEXT_MEDIUM;
import static org.eclipse.jface.text.formatter.FormattingContextProperties.CONTEXT_PARTITION;
import static org.eclipse.jface.text.formatter.FormattingContextProperties.CONTEXT_REGION;
import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_EMPTY_TAG_CLOSE;
import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_END_TAG_OPEN;
import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_TAG_CLOSE;
import static org.eclipse.wst.xml.core.internal.regions.DOMRegionContext.XML_TAG_OPEN;

import com.android.ide.eclipse.adt.AdtPlugin;
import com.android.ide.eclipse.adt.AdtUtils;
import com.android.ide.eclipse.adt.internal.editors.AndroidXmlAutoEditStrategy;
import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities;
import com.android.ide.eclipse.adt.internal.editors.resources.descriptors.ResourcesDescriptors;
import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs;
import com.android.sdklib.SdkConstants;

import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.TextUtilities;
import org.eclipse.jface.text.TypedPosition;
import org.eclipse.jface.text.formatter.ContextBasedFormattingStrategy;
import org.eclipse.jface.text.formatter.IFormattingContext;
import org.eclipse.text.edits.MultiTextEdit;
import org.eclipse.text.edits.ReplaceEdit;
import org.eclipse.text.edits.TextEdit;
import org.eclipse.ui.texteditor.ITextEditor;
import org.eclipse.wst.sse.core.StructuredModelManager;
import org.eclipse.wst.sse.core.internal.provisional.IModelManager;
import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel;
import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion;
import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument;
import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion;
import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion;
import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegionList;
import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel;
import org.eclipse.wst.xml.core.internal.provisional.document.IDOMNode;
import org.eclipse.wst.xml.ui.internal.XMLFormattingStrategy;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.Text;

import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Queue;

/**
 * Formatter which formats XML content according to the established Android coding
 * conventions. It performs the format by computing the smallest set of DOM nodes
 * overlapping the formatted region, then it pretty-prints that XML region
 * using the {@link XmlPrettyPrinter}, and then it replaces the affected region
 * by the pretty-printed region.
 * <p>
 * This strategy is also used for delegation. If the user has chosen to use the
 * standard Eclipse XML formatter, this strategy simply delegates to the
 * default XML formatting strategy in WTP.
 */
@SuppressWarnings("restriction")
public class AndroidXmlFormattingStrategy extends ContextBasedFormattingStrategy {
    private IRegion mRegion;
    private final Queue<IDocument> mDocuments = new LinkedList<IDocument>();
    private final LinkedList<TypedPosition> mPartitions = new LinkedList<TypedPosition>();
    private ContextBasedFormattingStrategy mDelegate = null;

    public AndroidXmlFormattingStrategy() {
    }

    private ContextBasedFormattingStrategy getDelegate() {
        if (!AdtPrefs.getPrefs().getUseCustomXmlFormatter()) {
            if (mDelegate == null) {
                mDelegate = new XMLFormattingStrategy();
            }

            return mDelegate;
        }

        return null;
    }

    @Override
    public void format() {
        // Use Eclipse XML formatter instead?
        ContextBasedFormattingStrategy delegate = getDelegate();
        if (delegate != null) {
            delegate.format();
            return;
        }

        super.format();

        IDocument document = mDocuments.poll();
        TypedPosition partition = mPartitions.poll();

        if (document != null && partition != null && mRegion != null) {
            try {
                if (document instanceof IStructuredDocument) {
                    IStructuredDocument structuredDocument = (IStructuredDocument) document;
                    IModelManager modelManager = StructuredModelManager.getModelManager();
                    IStructuredModel model = modelManager.getModelForEdit(structuredDocument);
                    if (model != null) {
                        try {
                            TextEdit edit = format(model, mRegion.getOffset(),
                                    mRegion.getLength());
                            if (edit != null) {
                                try {
                                    model.aboutToChangeModel();
                                    edit.apply(document);
                                }
                                finally {
                                    model.changedModel();
                                }
                            }
                        }
                        finally {
                            model.releaseFromEdit();
                        }
                    }
                }
            }
            catch (BadLocationException e) {
                AdtPlugin.log(e, "Formatting error");
            }
        }
    }

    private TextEdit format(IStructuredModel model, int start, int length) {
        int end = start + length;

        TextEdit edit = new MultiTextEdit();
        IStructuredDocument document = model.getStructuredDocument();

        Node startNode = null;
        Node endNode = null;
        Document domDocument = null;

        if (model instanceof IDOMModel) {
            IDOMModel domModel = (IDOMModel) model;
            domDocument = domModel.getDocument();
        } else {
            // This should not happen
            return edit;
        }

        IStructuredDocumentRegion startRegion = document.getRegionAtCharacterOffset(start);
        if (startRegion != null) {
            int startOffset = startRegion.getStartOffset();
            IndexedRegion currentIndexedRegion = model.getIndexedRegion(startOffset);
            if (currentIndexedRegion instanceof IDOMNode) {
                IDOMNode currentDOMNode = (IDOMNode) currentIndexedRegion;
                startNode = currentDOMNode;
            }
        }

        boolean isOpenTagOnly = false;
        int openTagEnd = -1;

        IStructuredDocumentRegion endRegion = document.getRegionAtCharacterOffset(end);
        if (endRegion != null) {
            int endOffset = Math.max(endRegion.getStartOffset(),
                    endRegion.getEndOffset() - 1);
            IndexedRegion currentIndexedRegion = model.getIndexedRegion(endOffset);

            // If you place the caret right on the right edge of an element, such as this:
            //     <foo name="value">|
            // then the DOM model will consider the region containing the caret to be
            // whatever nodes FOLLOWS the element, usually a text node.
            // Detect this case, and look into the previous range.
            if (currentIndexedRegion instanceof Text
                    && currentIndexedRegion.getStartOffset() == end && end > 0) {
                end--;
                currentIndexedRegion = model.getIndexedRegion(end);
                endRegion = document.getRegionAtCharacterOffset(
                        currentIndexedRegion.getStartOffset());
            }

            if (currentIndexedRegion instanceof IDOMNode) {
                IDOMNode currentDOMNode = (IDOMNode) currentIndexedRegion;
                endNode = currentDOMNode;

                // See if this range is fully within the opening tag
                if (endNode == startNode && endRegion == startRegion) {
                    ITextRegion subRegion = endRegion.getRegionAtCharacterOffset(end);
                    ITextRegionList regions = endRegion.getRegions();
                    int index = regions.indexOf(subRegion);
                    if (index != -1) {
                        // Skip past initial occurrence of close tag if we place the caret
                        // right on a >
                        subRegion = regions.get(index);
                        String type = subRegion.getType();
                        if (type == XML_TAG_CLOSE || type == XML_EMPTY_TAG_CLOSE) {
                            index--;
                        }
                    }
                    for (; index >= 0; index--) {
                        subRegion = regions.get(index);
                        String type = subRegion.getType();
                        if (type == XML_TAG_OPEN) {
                            isOpenTagOnly = true;
                        } else if (type == XML_EMPTY_TAG_CLOSE || type == XML_TAG_CLOSE
                                || type == XML_END_TAG_OPEN) {
                            break;
                        }
                    }

                    int max = regions.size();
                    for (index = Math.max(0, index); index < max; index++) {
                        subRegion = regions.get(index);
                        String type = subRegion.getType();
                        if (type == XML_EMPTY_TAG_CLOSE || type == XML_TAG_CLOSE) {
                            openTagEnd = subRegion.getEnd() + endRegion.getStartOffset();
                        }
                    }

                    if (openTagEnd == -1) {
                        isOpenTagOnly = false;
                    }
                }
            }
        }

        String[] indentationLevels = null;
        Node root = null;
        int initialDepth = 0;
        int replaceStart;
        int replaceEnd;
        if (startNode == null || endNode == null) {
            // Process the entire document
            root = domDocument;
            // both document and documentElement should be <= 0
            initialDepth = -1;
            startNode = root;
            endNode = root;
            replaceStart = 0;
            replaceEnd = document.getLength();
        } else {
            root = DomUtilities.getCommonAncestor(startNode, endNode);
            initialDepth = DomUtilities.getDepth(root) - 1;

            // Regions must be non-null since the DOM nodes are non null, but Eclipse null
            // analysis doesn't realize it:
            assert startRegion != null && endRegion != null;

            replaceStart = ((IndexedRegion) startNode).getStartOffset();
            if (isOpenTagOnly) {
                replaceEnd = openTagEnd;
            } else {
                replaceEnd = ((IndexedRegion) endNode).getEndOffset();
            }

            // Look up the indentation level of the start node, if it is an element
            // and it starts on its own line
            if (startNode.getNodeType() == Node.ELEMENT_NODE) {
                // Measure the indentation of the start node such that we can indent
                // the reformatted version of the node exactly in place and it should blend
                // in if the surrounding content does not use the same indentation size etc.
                // However, it's possible for the start node to have deeper depth than other
                // content we're formatting, as in the following scenario for example:
                //      <foo>
                //         <bar/>
                //      </foo>
                //   <baz/>
                // If you select this text range, we want <foo> to be formatted at whatever
                // level it is, and we also need to know the indentation level to use
                // for </baz>. We don't measure the depth of <bar/>, a child of the start node,
                // since from the initial indentation level and on down we want to normalize
                // the output.
                IndentationMeasurer m = new IndentationMeasurer(startNode, endNode, document);
                indentationLevels = m.measure(initialDepth, root);

                // Wipe out any levels deeper than the start node's level
                // (which may not be the smallest level, e.g. where you select a child
                // and the end of its parent etc).
                // (Since we're ONLY measuring the node and its parents, you might wonder
                // why this is doing a full subtree traversal instead of just walking up
                // the parent chain and looking up the indentation for each. The reason for
                // this is that some of theses nodes, which have not yet been formatted,
                // may be sharing lines with other nodes, and we disregard indentation for
                // any nodes that don't start a line since the indentation may only be correct
                // for the first element, so therefore we look for other nodes at the same
                // level that do have indentation info at the front of the line.
                int depth = DomUtilities.getDepth(startNode) - 1;
                for (int i = depth + 1; i < indentationLevels.length; i++) {
                    indentationLevels[i] = null;
                }
            }
        }

        XmlFormatStyle style = guessStyle(model, domDocument);
        XmlFormatPreferences prefs = XmlFormatPreferences.create();
        String delimiter = TextUtilities.getDefaultLineDelimiter(document);
        XmlPrettyPrinter printer = new XmlPrettyPrinter(prefs, style, delimiter);

        if (indentationLevels != null) {
            printer.setIndentationLevels(indentationLevels);
        }

        StringBuilder sb = new StringBuilder(length);
        printer.prettyPrint(initialDepth, root, startNode, endNode, sb, isOpenTagOnly);

        String formatted = sb.toString();
        ReplaceEdit replaceEdit = createReplaceEdit(document, replaceStart, replaceEnd, formatted,
                prefs);
        if (replaceEdit != null) {
            edit.addChild(replaceEdit);
        }

        // Attempt to fix the selection range since otherwise, with the document shifting
        // under it, you end up selecting a "random" portion of text now shifted into the
        // old positions of the formatted text:
        if (replaceEdit != null && replaceStart != 0 && replaceEnd != document.getLength()) {
            ITextEditor editor = AdtUtils.getActiveTextEditor();
            if (editor != null) {
                editor.setHighlightRange(replaceEdit.getOffset(), replaceEdit.getText().length(),
                        false /*moveCursor*/);
            }
        }

        return edit;
    }

    /**
     * Create a {@link ReplaceEdit} which replaces the text in the given document with the
     * given new formatted content. The replaceStart and replaceEnd parameters point to
     * the equivalent unformatted text in the document, but the actual edit range may be
     * adjusted (for example to make the edit smaller if the beginning and/or end is
     * identical, and so on)
     */
    private ReplaceEdit createReplaceEdit(IStructuredDocument document, int replaceStart,
            int replaceEnd, String formatted, XmlFormatPreferences prefs) {
        // If replacing a node somewhere in the middle, start the replacement at the
        // beginning of the current line
        int index = replaceStart;
        try {
            while (index > 0) {
                char c = document.getChar(index - 1);
                if (c == '\n') {
                    if (index < replaceStart) {
                        replaceStart = index;
                    }
                    break;
                } else if (!Character.isWhitespace(c)) {
                    // The replaced node does not start on its own line; in that case,
                    // remove the initial indentation in the reformatted element
                    for (int i = 0; i < formatted.length(); i++) {
                        if (!Character.isWhitespace(formatted.charAt(i))) {
                            formatted = formatted.substring(i);
                            break;
                        }
                    }
                    break;
                }
                index--;
            }
        } catch (BadLocationException e) {
            AdtPlugin.log(e, null);
        }

        // If there are multiple blank lines before the insert position, collapse them down
        // to one
        int prevNewlineIndex = -1;
        boolean beginsWithNewline = false;
        for (int i = 0, n = formatted.length(); i < n; i++) {
            char c = formatted.charAt(i);
            if (c == '\n') {
                beginsWithNewline = true;
                break;
            } else if (!Character.isWhitespace(c)) {
                break;
            }
        }
        try {
            for (index = replaceStart - 1; index > 0; index--) {
                char c = document.getChar(index);
                if (c == '\n') {
                    if (prevNewlineIndex != -1) {
                        replaceStart = prevNewlineIndex;
                    }
                    prevNewlineIndex = index;
                } else if (!Character.isWhitespace(c)) {
                    break;
                }
            }
        } catch (BadLocationException e) {
            AdtPlugin.log(e, null);
        }
        if (prefs.removeEmptyLines && prevNewlineIndex != -1 && beginsWithNewline) {
            replaceStart = prevNewlineIndex + 1;
        }

        // Search forwards too
        prevNewlineIndex = -1;
        try {
            int max = document.getLength();
            for (index = replaceEnd; index < max; index++) {
                char c = document.getChar(index);
                if (c == '\n') {
                    if (prevNewlineIndex != -1) {
                        replaceEnd = prevNewlineIndex + 1;
                    }
                    prevNewlineIndex = index;
                } else if (!Character.isWhitespace(c)) {
                    break;
                }
            }
        } catch (BadLocationException e) {
            AdtPlugin.log(e, null);
        }

        boolean endsWithNewline = false;
        for (int i = formatted.length() - 1; i >= 0; i--) {
            char c = formatted.charAt(i);
            if (c == '\n') {
                endsWithNewline = true;
                break;
            } else if (!Character.isWhitespace(c)) {
                break;
            }
        }

        if (prefs.removeEmptyLines && prevNewlineIndex != -1 && endsWithNewline) {
            replaceEnd = prevNewlineIndex + 1;
        }

        // Figure out how much of the before and after strings are identical and narrow
        // the replacement scope
        boolean foundDifference = false;
        int firstDifference = 0;
        int lastDifference = formatted.length();
        try {
            for (int i = 0, j = replaceStart; i < formatted.length() && j < replaceEnd; i++, j++) {
                if (formatted.charAt(i) != document.getChar(j)) {
                    firstDifference = i;
                    foundDifference = true;
                    break;
                }
            }

            if (!foundDifference) {
                // No differences - the document is already formatted, nothing to do
                return null;
            }

            lastDifference = firstDifference + 1;
            for (int i = formatted.length() - 1, j = replaceEnd - 1;
                    i > firstDifference && j > replaceStart;
                    i--, j--) {
                if (formatted.charAt(i) != document.getChar(j)) {
                    lastDifference = i + 1;
                    break;
                }
            }
        } catch (BadLocationException e) {
            AdtPlugin.log(e, null);
        }

        replaceStart += firstDifference;
        replaceEnd -= (formatted.length() - lastDifference);
        replaceEnd = Math.max(replaceStart, replaceEnd);
        formatted = formatted.substring(firstDifference, lastDifference);

        ReplaceEdit replaceEdit = new ReplaceEdit(replaceStart, replaceEnd - replaceStart,
                formatted);
        return replaceEdit;
    }

    /**
     * Guess what style to use to edit the given document - layout, resource, manifest, ... ? */
    private XmlFormatStyle guessStyle(IStructuredModel model, Document domDocument) {
        // The "layout" style is used for most XML resource file types:
        // layouts, color-lists and state-lists, animations, drawables, menus, etc
        XmlFormatStyle style = XmlFormatStyle.LAYOUT;

        // The "resource" style is used for most value-based XML files:
        // strings, dimensions, booleans, colors, integers, plurals,
        // integer-arrays, string-arrays, and typed-arrays
        if (domDocument.getDocumentElement() != null
                && ResourcesDescriptors.ROOT_ELEMENT.equals(domDocument.getDocumentElement()
                        .getTagName())) {
            style = XmlFormatStyle.RESOURCE;
        }

        // The "manifest" style is used for manifest files
        String baseLocation = model.getBaseLocation();
        if (baseLocation != null) {
            if (baseLocation.endsWith(SdkConstants.FN_ANDROID_MANIFEST_XML)) {
                style = XmlFormatStyle.MANIFEST;
            } else {
                int lastSlash = baseLocation.lastIndexOf('/');
                if (lastSlash != -1) {
                    lastSlash = baseLocation.lastIndexOf('/', lastSlash - 1);
                    if (lastSlash != -1 && baseLocation.startsWith("/values", lastSlash)) { //$NON-NLS-1$
                        style = XmlFormatStyle.RESOURCE;
                    }
                }
            }
        }
        return style;
    }

    @Override
    public void formatterStarts(final IFormattingContext context) {
        // Use Eclipse XML formatter instead?
        ContextBasedFormattingStrategy delegate = getDelegate();
        if (delegate != null) {
            delegate.formatterStarts(context);

            // We also need the super implementation because it stores items into the
            // map, and we can't override the getPreferences method, so we need for
            // this delegating strategy to supply the correct values when it is called
            // instead of the delegate
            super.formatterStarts(context);

            return;
        }

        super.formatterStarts(context);
        mRegion = (IRegion) context.getProperty(CONTEXT_REGION);
        TypedPosition partition = (TypedPosition) context.getProperty(CONTEXT_PARTITION);
        IDocument document = (IDocument) context.getProperty(CONTEXT_MEDIUM);
        mPartitions.offer(partition);
        mDocuments.offer(document);
    }

    @Override
    public void formatterStops() {
        // Use Eclipse XML formatter instead?
        ContextBasedFormattingStrategy delegate = getDelegate();
        if (delegate != null) {
            delegate.formatterStops();
            // See formatterStarts for an explanation
            super.formatterStops();

            return;
        }

        super.formatterStops();
        mRegion = null;
        mDocuments.clear();
        mPartitions.clear();
    }

    /**
     * Utility class which can measure the indentation strings for various node levels in
     * a given node range
     */
    static class IndentationMeasurer {
        private final Map<Integer, String> mDepth = new HashMap<Integer, String>();
        private final Node mStartNode;
        private final Node mEndNode;
        private final IStructuredDocument mDocument;
        private boolean mDone = false;
        private boolean mInRange = false;
        private int mMaxDepth;

        public IndentationMeasurer(Node mStartNode, Node mEndNode, IStructuredDocument document) {
            super();
            this.mStartNode = mStartNode;
            this.mEndNode = mEndNode;
            mDocument = document;
        }

        /**
         * Measure the various depths found in the range (defined in the constructor)
         * under the given node which should be a common ancestor of the start and end
         * nodes. The result is a string array where each index corresponds to a depth,
         * and the string is either empty, or the complete indentation string to be used
         * to indent to the given depth (note that these strings are not cumulative)
         *
         * @param initialDepth the initial depth to use when visiting
         * @param root the root node to look for depths under
         * @return a string array containing nulls or indentation strings
         */
        public String[] measure(int initialDepth, Node root) {
            visit(initialDepth, root);
            String[] indentationLevels = new String[mMaxDepth + 1];
            for (Map.Entry<Integer, String> entry : mDepth.entrySet()) {
                int depth = entry.getKey();
                String indentation = entry.getValue();
                indentationLevels[depth] = indentation;
            }

            return indentationLevels;
        }

        private void visit(int depth, Node node) {
            // Look up indentation for this level
            if (node.getNodeType() == Node.ELEMENT_NODE && mDepth.get(depth) == null) {
                // Look up the depth
                try {
                    IndexedRegion region = (IndexedRegion) node;
                    int lineStart = findLineStart(mDocument, region.getStartOffset());
                    int textStart = findTextStart(mDocument, lineStart, region.getEndOffset());

                    // Ensure that the text which begins the line is this element, otherwise
                    // we could be measuring the indentation of a parent element which begins
                    // the line
                    if (textStart == region.getStartOffset()) {
                        String indent = mDocument.get(lineStart,
                                Math.max(0, textStart - lineStart));
                        mDepth.put(depth, indent);

                        if (depth > mMaxDepth) {
                            mMaxDepth = depth;
                        }
                    }
                } catch (BadLocationException e) {
                    AdtPlugin.log(e, null);
                }
            }

            NodeList children = node.getChildNodes();
            for (int i = 0, n = children.getLength(); i < n; i++) {
                Node child = children.item(i);
                visit(depth + 1, child);
                if (mDone) {
                    return;
                }
            }

            if (node == mEndNode) {
                mDone = true;
            }
        }
    }
}