/*
 * 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.layout.refactoring;

import static com.android.ide.common.layout.LayoutConstants.ANDROID_NS_NAME;
import static com.android.ide.common.layout.LayoutConstants.ANDROID_URI;
import static com.android.ide.common.layout.LayoutConstants.ANDROID_WIDGET_PREFIX;
import static com.android.ide.common.layout.LayoutConstants.ATTR_ID;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_HEIGHT;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_PREFIX;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_WIDTH;
import static com.android.ide.common.layout.LayoutConstants.ID_PREFIX;
import static com.android.ide.common.layout.LayoutConstants.NEW_ID_PREFIX;
import static com.android.ide.eclipse.adt.internal.editors.descriptors.XmlnsAttributeDescriptor.XMLNS;
import static com.android.ide.eclipse.adt.internal.editors.descriptors.XmlnsAttributeDescriptor.XMLNS_COLON;

import com.android.annotations.VisibleForTesting;
import com.android.ide.eclipse.adt.AdtPlugin;
import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor;
import com.android.ide.eclipse.adt.internal.editors.formatting.XmlFormatPreferences;
import com.android.ide.eclipse.adt.internal.editors.formatting.XmlFormatStyle;
import com.android.ide.eclipse.adt.internal.editors.formatting.XmlPrettyPrinter;
import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditor;
import com.android.ide.eclipse.adt.internal.editors.layout.configuration.ConfigurationComposite;
import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.ViewElementDescriptor;
import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo;
import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities;
import com.android.ide.eclipse.adt.internal.editors.layout.gle2.GraphicalEditorPart;
import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode;
import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode;
import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs;
import com.android.ide.eclipse.adt.internal.sdk.AndroidTargetData;
import com.android.util.Pair;

import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.core.runtime.Path;
import org.eclipse.core.runtime.QualifiedName;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.ITextSelection;
import org.eclipse.jface.viewers.ITreeSelection;
import org.eclipse.jface.viewers.TreePath;
import org.eclipse.ltk.core.refactoring.Change;
import org.eclipse.ltk.core.refactoring.ChangeDescriptor;
import org.eclipse.ltk.core.refactoring.CompositeChange;
import org.eclipse.ltk.core.refactoring.Refactoring;
import org.eclipse.ltk.core.refactoring.RefactoringChangeDescriptor;
import org.eclipse.ltk.core.refactoring.RefactoringDescriptor;
import org.eclipse.ltk.core.refactoring.RefactoringStatus;
import org.eclipse.text.edits.DeleteEdit;
import org.eclipse.text.edits.InsertEdit;
import org.eclipse.text.edits.MalformedTreeException;
import org.eclipse.text.edits.MultiTextEdit;
import org.eclipse.text.edits.ReplaceEdit;
import org.eclipse.text.edits.TextEdit;
import org.eclipse.ui.IEditorPart;
import org.eclipse.ui.PartInitException;
import org.eclipse.ui.ide.IDE;
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.regions.DOMRegionContext;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Parent class for the various visual refactoring operations; contains shared
 * implementations needed by most of them
 */
@SuppressWarnings("restriction") // XML model
public abstract class VisualRefactoring extends Refactoring {
    private static final String KEY_FILE = "file";                      //$NON-NLS-1$
    private static final String KEY_PROJECT = "proj";                   //$NON-NLS-1$
    private static final String KEY_SEL_START = "sel-start";            //$NON-NLS-1$
    private static final String KEY_SEL_END = "sel-end";                //$NON-NLS-1$

    protected final IFile mFile;
    protected final LayoutEditor mEditor;
    protected final IProject mProject;
    protected int mSelectionStart = -1;
    protected int mSelectionEnd = -1;
    protected final List<Element> mElements;
    protected final ITreeSelection mTreeSelection;
    protected final ITextSelection mSelection;
    /** Same as {@link #mSelectionStart} but not adjusted to element edges */
    protected int mOriginalSelectionStart = -1;
    /** Same as {@link #mSelectionEnd} but not adjusted to element edges */
    protected int mOriginalSelectionEnd = -1;

    protected final Map<Element, String> mGeneratedIdMap = new HashMap<Element, String>();
    protected final Set<String> mGeneratedIds = new HashSet<String>();

    protected List<Change> mChanges;
    private String mAndroidNamespacePrefix;

    /**
     * This constructor is solely used by {@link VisualRefactoringDescriptor},
     * to replay a previous refactoring.
     * @param arguments argument map created by #createArgumentMap.
     */
    VisualRefactoring(Map<String, String> arguments) {
        IPath path = Path.fromPortableString(arguments.get(KEY_PROJECT));
        mProject = (IProject) ResourcesPlugin.getWorkspace().getRoot().findMember(path);
        path = Path.fromPortableString(arguments.get(KEY_FILE));
        mFile = (IFile) ResourcesPlugin.getWorkspace().getRoot().findMember(path);
        mSelectionStart = Integer.parseInt(arguments.get(KEY_SEL_START));
        mSelectionEnd = Integer.parseInt(arguments.get(KEY_SEL_END));
        mOriginalSelectionStart = mSelectionStart;
        mOriginalSelectionEnd = mSelectionEnd;
        mEditor = null;
        mElements = null;
        mSelection = null;
        mTreeSelection = null;
    }

    @VisibleForTesting
    VisualRefactoring(List<Element> elements, LayoutEditor editor) {
        mElements = elements;
        mEditor = editor;

        mFile = editor != null ? editor.getInputFile() : null;
        mProject = editor != null ? editor.getProject() : null;
        mSelectionStart = 0;
        mSelectionEnd = 0;
        mOriginalSelectionStart = 0;
        mOriginalSelectionEnd = 0;
        mSelection = null;
        mTreeSelection = null;

        int end = Integer.MIN_VALUE;
        int start = Integer.MAX_VALUE;
        for (Element element : elements) {
            if (element instanceof IndexedRegion) {
                IndexedRegion region = (IndexedRegion) element;
                start = Math.min(start, region.getStartOffset());
                end = Math.max(end, region.getEndOffset());
            }
        }
        if (start >= 0) {
            mSelectionStart = start;
            mSelectionEnd = end;
            mOriginalSelectionStart = start;
            mOriginalSelectionEnd = end;
        }
    }

    public VisualRefactoring(IFile file, LayoutEditor editor, ITextSelection selection,
            ITreeSelection treeSelection) {
        mFile = file;
        mEditor = editor;
        mProject = file.getProject();
        mSelection = selection;
        mTreeSelection = treeSelection;

        // Initialize mSelectionStart and mSelectionEnd based on the selection context, which
        // is either a treeSelection (when invoked from the layout editor or the outline), or
        // a selection (when invoked from an XML editor)
        if (treeSelection != null) {
            int end = Integer.MIN_VALUE;
            int start = Integer.MAX_VALUE;
            for (TreePath path : treeSelection.getPaths()) {
                Object lastSegment = path.getLastSegment();
                if (lastSegment instanceof CanvasViewInfo) {
                    CanvasViewInfo viewInfo = (CanvasViewInfo) lastSegment;
                    UiViewElementNode uiNode = viewInfo.getUiViewNode();
                    if (uiNode == null) {
                        continue;
                    }
                    Node xmlNode = uiNode.getXmlNode();
                    if (xmlNode instanceof IndexedRegion) {
                        IndexedRegion region = (IndexedRegion) xmlNode;

                        start = Math.min(start, region.getStartOffset());
                        end = Math.max(end, region.getEndOffset());
                    }
                }
            }
            if (start >= 0) {
                mSelectionStart = start;
                mSelectionEnd = end;
                mOriginalSelectionStart = mSelectionStart;
                mOriginalSelectionEnd = mSelectionEnd;
            }
            if (selection != null) {
                mOriginalSelectionStart = selection.getOffset();
                mOriginalSelectionEnd = mOriginalSelectionStart + selection.getLength();
            }
        } else if (selection != null) {
            // TODO: update selection to boundaries!
            mSelectionStart = selection.getOffset();
            mSelectionEnd = mSelectionStart + selection.getLength();
            mOriginalSelectionStart = mSelectionStart;
            mOriginalSelectionEnd = mSelectionEnd;
        }

        mElements = initElements();
    }

    protected abstract List<Change> computeChanges(IProgressMonitor monitor);

    @Override
    public RefactoringStatus checkFinalConditions(IProgressMonitor monitor) throws CoreException,
            OperationCanceledException {
        RefactoringStatus status = new RefactoringStatus();
        mChanges = new ArrayList<Change>();
        try {
            monitor.beginTask("Checking post-conditions...", 5);

            // Reset state for each computeChanges call, in case the user goes back
            // and forth in the refactoring wizard
            mGeneratedIdMap.clear();
            mGeneratedIds.clear();
            List<Change> changes = computeChanges(monitor);
            mChanges.addAll(changes);

            monitor.worked(1);
        } finally {
            monitor.done();
        }

        return status;
    }

    @Override
    public Change createChange(IProgressMonitor monitor) throws CoreException,
            OperationCanceledException {
        try {
            monitor.beginTask("Applying changes...", 1);

            CompositeChange change = new CompositeChange(
                    getName(),
                    mChanges.toArray(new Change[mChanges.size()])) {
                @Override
                public ChangeDescriptor getDescriptor() {
                    VisualRefactoringDescriptor desc = createDescriptor();
                    return new RefactoringChangeDescriptor(desc);
                }
            };

            monitor.worked(1);
            return change;

        } finally {
            monitor.done();
        }
    }

    protected abstract VisualRefactoringDescriptor createDescriptor();

    protected Map<String, String> createArgumentMap() {
        HashMap<String, String> args = new HashMap<String, String>();
        args.put(KEY_PROJECT, mProject.getFullPath().toPortableString());
        args.put(KEY_FILE, mFile.getFullPath().toPortableString());
        args.put(KEY_SEL_START, Integer.toString(mSelectionStart));
        args.put(KEY_SEL_END, Integer.toString(mSelectionEnd));

        return args;
    }

    // ---- Shared functionality ----


    protected void openFile(IFile file) {
        GraphicalEditorPart graphicalEditor = mEditor.getGraphicalEditor();
        IFile leavingFile = graphicalEditor.getEditedFile();

        try {
            // Duplicate the current state into the newly created file
            QualifiedName qname = ConfigurationComposite.NAME_CONFIG_STATE;
            String state = AdtPlugin.getFileProperty(leavingFile, qname);

            // TODO: Look for a ".NoTitleBar.Fullscreen" theme version of the current
            // theme to show.

            file.setSessionProperty(GraphicalEditorPart.NAME_INITIAL_STATE, state);
        } catch (CoreException e) {
            // pass
        }

        /* TBD: "Show Included In" if supported.
         * Not sure if this is a good idea.
        if (graphicalEditor.renderingSupports(Capability.EMBEDDED_LAYOUT)) {
            try {
                Reference include = Reference.create(graphicalEditor.getEditedFile());
                file.setSessionProperty(GraphicalEditorPart.NAME_INCLUDE, include);
            } catch (CoreException e) {
                // pass - worst that can happen is that we don't start with inclusion
            }
        }
        */

        try {
            IEditorPart part = IDE.openEditor(mEditor.getEditorSite().getPage(), file);
            if (part instanceof AndroidXmlEditor && AdtPrefs.getPrefs().getFormatGuiXml()) {
                AndroidXmlEditor newEditor = (AndroidXmlEditor) part;
                newEditor.reformatDocument();
            }
        } catch (PartInitException e) {
            AdtPlugin.log(e, "Can't open new included layout");
        }
    }


    /** Produce a list of edits to replace references to the given id with the given new id */
    protected static List<TextEdit> replaceIds(String androidNamePrefix,
            IStructuredDocument doc, int skipStart, int skipEnd,
            String rootId, String referenceId) {
        if (rootId == null) {
            return Collections.emptyList();
        }

        // We need to search for either @+id/ or @id/
        String match1 = rootId;
        String match2;
        if (match1.startsWith(ID_PREFIX)) {
            match2 = '"' + NEW_ID_PREFIX + match1.substring(ID_PREFIX.length()) + '"';
            match1 = '"' + match1 + '"';
        } else if (match1.startsWith(NEW_ID_PREFIX)) {
            match2 = '"' + ID_PREFIX + match1.substring(NEW_ID_PREFIX.length()) + '"';
            match1 = '"' + match1 + '"';
        } else {
            return Collections.emptyList();
        }

        String namePrefix = androidNamePrefix + ':' + ATTR_LAYOUT_PREFIX;
        List<TextEdit> edits = new ArrayList<TextEdit>();

        IStructuredDocumentRegion region = doc.getFirstStructuredDocumentRegion();
        for (; region != null; region = region.getNext()) {
            ITextRegionList list = region.getRegions();
            int regionStart = region.getStart();

            // Look at all attribute values and look for an id reference match
            String attributeName = ""; //$NON-NLS-1$
            for (int j = 0; j < region.getNumberOfRegions(); j++) {
                ITextRegion subRegion = list.get(j);
                String type = subRegion.getType();
                if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) {
                    attributeName = region.getText(subRegion);
                } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) {
                    // Only replace references in layout attributes
                    if (!attributeName.startsWith(namePrefix)) {
                        continue;
                    }
                    // Skip occurrences in the given skip range
                    int subRegionStart = regionStart + subRegion.getStart();
                    if (subRegionStart >= skipStart && subRegionStart <= skipEnd) {
                        continue;
                    }

                    String attributeValue = region.getText(subRegion);
                    if (attributeValue.equals(match1) || attributeValue.equals(match2)) {
                        int start = subRegionStart + 1; // skip quote
                        int end = start + rootId.length();

                        edits.add(new ReplaceEdit(start, end - start, referenceId));
                    }
                }
            }
        }

        return edits;
    }

    /** Get the id of the root selected element, if any */
    protected String getRootId() {
        Element primary = getPrimaryElement();
        if (primary != null) {
            String oldId = primary.getAttributeNS(ANDROID_URI, ATTR_ID);
            // id null check for https://bugs.eclipse.org/bugs/show_bug.cgi?id=272378
            if (oldId != null && oldId.length() > 0) {
                return oldId;
            }
        }

        return null;
    }

    protected String getAndroidNamespacePrefix() {
        if (mAndroidNamespacePrefix == null) {
            List<Attr> attributeNodes = findNamespaceAttributes();
            for (Node attributeNode : attributeNodes) {
                String prefix = attributeNode.getPrefix();
                if (XMLNS.equals(prefix)) {
                    String name = attributeNode.getNodeName();
                    String value = attributeNode.getNodeValue();
                    if (value.equals(ANDROID_URI)) {
                        mAndroidNamespacePrefix = name;
                        if (mAndroidNamespacePrefix.startsWith(XMLNS_COLON)) {
                            mAndroidNamespacePrefix =
                                mAndroidNamespacePrefix.substring(XMLNS_COLON.length());
                        }
                    }
                }
            }

            if (mAndroidNamespacePrefix == null) {
                mAndroidNamespacePrefix = ANDROID_NS_NAME;
            }
        }

        return mAndroidNamespacePrefix;
    }

    protected static String getAndroidNamespacePrefix(Document document) {
        String nsPrefix = null;
        List<Attr> attributeNodes = findNamespaceAttributes(document);
        for (Node attributeNode : attributeNodes) {
            String prefix = attributeNode.getPrefix();
            if (XMLNS.equals(prefix)) {
                String name = attributeNode.getNodeName();
                String value = attributeNode.getNodeValue();
                if (value.equals(ANDROID_URI)) {
                    nsPrefix = name;
                    if (nsPrefix.startsWith(XMLNS_COLON)) {
                        nsPrefix =
                            nsPrefix.substring(XMLNS_COLON.length());
                    }
                }
            }
        }

        if (nsPrefix == null) {
            nsPrefix = ANDROID_NS_NAME;
        }

        return nsPrefix;
    }

    protected List<Attr> findNamespaceAttributes() {
        Document document = getDomDocument();
        return findNamespaceAttributes(document);
    }

    protected static List<Attr> findNamespaceAttributes(Document document) {
        if (document != null) {
            Element root = document.getDocumentElement();
            return findNamespaceAttributes(root);
        }

        return Collections.emptyList();
    }

    protected static List<Attr> findNamespaceAttributes(Node root) {
        List<Attr> result = new ArrayList<Attr>();
        NamedNodeMap attributes = root.getAttributes();
        for (int i = 0, n = attributes.getLength(); i < n; i++) {
            Node attributeNode = attributes.item(i);

            String prefix = attributeNode.getPrefix();
            if (XMLNS.equals(prefix)) {
                result.add((Attr) attributeNode);
            }
        }

        return result;
    }

    protected List<Attr> findLayoutAttributes(Node root) {
        List<Attr> result = new ArrayList<Attr>();
        NamedNodeMap attributes = root.getAttributes();
        for (int i = 0, n = attributes.getLength(); i < n; i++) {
            Node attributeNode = attributes.item(i);

            String name = attributeNode.getLocalName();
            if (name.startsWith(ATTR_LAYOUT_PREFIX)
                    && ANDROID_URI.equals(attributeNode.getNamespaceURI())) {
                result.add((Attr) attributeNode);
            }
        }

        return result;
    }

    protected String insertNamespace(String xmlText, String namespaceDeclarations) {
        // Insert namespace declarations into the extracted XML fragment
        int firstSpace = xmlText.indexOf(' ');
        int elementEnd = xmlText.indexOf('>');
        int insertAt;
        if (firstSpace != -1 && firstSpace < elementEnd) {
            insertAt = firstSpace;
        } else {
            insertAt = elementEnd;
        }
        xmlText = xmlText.substring(0, insertAt) + namespaceDeclarations
                + xmlText.substring(insertAt);

        return xmlText;
    }

    /** Remove sections of the document that correspond to top level layout attributes;
     * these are placed on the include element instead */
    protected String stripTopLayoutAttributes(Element primary, int start, String xml) {
        if (primary != null) {
            // List of attributes to remove
            List<IndexedRegion> skip = new ArrayList<IndexedRegion>();
            NamedNodeMap attributes = primary.getAttributes();
            for (int i = 0, n = attributes.getLength(); i < n; i++) {
                Node attr = attributes.item(i);
                String name = attr.getLocalName();
                if (name.startsWith(ATTR_LAYOUT_PREFIX)
                        && ANDROID_URI.equals(attr.getNamespaceURI())) {
                    if (name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT)) {
                        // These are special and are left in
                        continue;
                    }

                    if (attr instanceof IndexedRegion) {
                        skip.add((IndexedRegion) attr);
                    }
                }
            }
            if (skip.size() > 0) {
                Collections.sort(skip, new Comparator<IndexedRegion>() {
                    // Sort in start order
                    public int compare(IndexedRegion r1, IndexedRegion r2) {
                        return r1.getStartOffset() - r2.getStartOffset();
                    }
                });

                // Successively cut out the various layout attributes
                // TODO remove adjacent whitespace too (but not newlines, unless they
                // are newly adjacent)
                StringBuilder sb = new StringBuilder(xml.length());
                int nextStart = 0;

                // Copy out all the sections except the skip sections
                for (IndexedRegion r : skip) {
                    int regionStart = r.getStartOffset();
                    // Adjust to string offsets since we've copied the string out of
                    // the document
                    regionStart -= start;

                    sb.append(xml.substring(nextStart, regionStart));

                    nextStart = regionStart + r.getLength();
                }
                if (nextStart < xml.length()) {
                    sb.append(xml.substring(nextStart));
                }

                return sb.toString();
            }
        }

        return xml;
    }

    protected static String getIndent(String line, int max) {
        int i = 0;
        int n = Math.min(max, line.length());
        for (; i < n; i++) {
            char c = line.charAt(i);
            if (!Character.isWhitespace(c)) {
                return line.substring(0, i);
            }
        }

        if (n < line.length()) {
            return line.substring(0, n);
        } else {
            return line;
        }
    }

    protected static String dedent(String xml) {
        String[] lines = xml.split("\n"); //$NON-NLS-1$
        if (lines.length < 2) {
            // The first line never has any indentation since we copy it out from the
            // element start index
            return xml;
        }

        String indentPrefix = getIndent(lines[1], lines[1].length());
        for (int i = 2, n = lines.length; i < n; i++) {
            String line = lines[i];

            // Ignore blank lines
            if (line.trim().length() == 0) {
                continue;
            }

            indentPrefix = getIndent(line, indentPrefix.length());

            if (indentPrefix.length() == 0) {
                return xml;
            }
        }

        StringBuilder sb = new StringBuilder();
        for (String line : lines) {
            if (line.startsWith(indentPrefix)) {
                sb.append(line.substring(indentPrefix.length()));
            } else {
                sb.append(line);
            }
            sb.append('\n');
        }
        return sb.toString();
    }

    protected String getText(int start, int end) {
        try {
            IStructuredDocument document = mEditor.getStructuredDocument();
            return document.get(start, end - start);
        } catch (BadLocationException e) {
            // the region offset was invalid. ignore.
            return null;
        }
    }

    protected List<Element> getElements() {
        return mElements;
    }

    protected List<Element> initElements() {
        List<Element> nodes = new ArrayList<Element>();

        assert mTreeSelection == null || mSelection == null :
            "treeSel= " + mTreeSelection + ", sel=" + mSelection;

        // Initialize mSelectionStart and mSelectionEnd based on the selection context, which
        // is either a treeSelection (when invoked from the layout editor or the outline), or
        // a selection (when invoked from an XML editor)
        if (mTreeSelection != null) {
            int end = Integer.MIN_VALUE;
            int start = Integer.MAX_VALUE;
            for (TreePath path : mTreeSelection.getPaths()) {
                Object lastSegment = path.getLastSegment();
                if (lastSegment instanceof CanvasViewInfo) {
                    CanvasViewInfo viewInfo = (CanvasViewInfo) lastSegment;
                    UiViewElementNode uiNode = viewInfo.getUiViewNode();
                    if (uiNode == null) {
                        continue;
                    }
                    Node xmlNode = uiNode.getXmlNode();
                    if (xmlNode instanceof Element) {
                        Element element = (Element) xmlNode;
                        nodes.add(element);
                        IndexedRegion region = getRegion(element);
                        start = Math.min(start, region.getStartOffset());
                        end = Math.max(end, region.getEndOffset());
                    }
                }
            }
            if (start >= 0) {
                mSelectionStart = start;
                mSelectionEnd = end;
            }
        } else if (mSelection != null) {
            mSelectionStart = mSelection.getOffset();
            mSelectionEnd = mSelectionStart + mSelection.getLength();
            mOriginalSelectionStart = mSelectionStart;
            mOriginalSelectionEnd = mSelectionEnd;

            // Figure out the range of selected nodes from the document offsets
            IStructuredDocument doc = mEditor.getStructuredDocument();
            Pair<Element, Element> range = DomUtilities.getElementRange(doc,
                    mSelectionStart, mSelectionEnd);
            if (range != null) {
                Element first = range.getFirst();
                Element last = range.getSecond();

                // Adjust offsets to get rid of surrounding text nodes (if you happened
                // to select a text range and included whitespace on either end etc)
                mSelectionStart = getRegion(first).getStartOffset();
                mSelectionEnd = getRegion(last).getEndOffset();

                if (mSelectionStart > mSelectionEnd) {
                    int tmp = mSelectionStart;
                    mSelectionStart = mSelectionEnd;
                    mSelectionEnd = tmp;
                }

                if (first == last) {
                    nodes.add(first);
                } else if (first.getParentNode() == last.getParentNode()) {
                    // Add the range
                    Node node = first;
                    while (node != null) {
                        if (node instanceof Element) {
                            nodes.add((Element) node);
                        }
                        if (node == last) {
                            break;
                        }
                        node = node.getNextSibling();
                    }
                } else {
                    // Different parents: this means we have an uneven selection, selecting
                    // elements from different levels. We can't extract ranges like that.
                }
            }
        } else {
            assert false;
        }

        // Make sure that the list of elements is unique
        //Set<Element> seen = new HashSet<Element>();
        //for (Element element : nodes) {
        //   assert !seen.contains(element) : element;
        //   seen.add(element);
        //}

        return nodes;
    }

    protected Element getPrimaryElement() {
        List<Element> elements = getElements();
        if (elements != null && elements.size() == 1) {
            return elements.get(0);
        }

        return null;
    }

    protected Document getDomDocument() {
        if (mEditor.getUiRootNode() != null) {
            return mEditor.getUiRootNode().getXmlDocument();
        } else {
            return getElements().get(0).getOwnerDocument();
        }
    }

    protected List<CanvasViewInfo> getSelectedViewInfos() {
        List<CanvasViewInfo> infos = new ArrayList<CanvasViewInfo>();
        if (mTreeSelection != null) {
            for (TreePath path : mTreeSelection.getPaths()) {
                Object lastSegment = path.getLastSegment();
                if (lastSegment instanceof CanvasViewInfo) {
                    infos.add((CanvasViewInfo) lastSegment);
                }
            }
        }
        return infos;
    }

    protected boolean validateNotEmpty(List<CanvasViewInfo> infos, RefactoringStatus status) {
        if (infos.size() == 0) {
            status.addFatalError("No selection to extract");
            return false;
        }

        return true;
    }

    protected boolean validateNotRoot(List<CanvasViewInfo> infos, RefactoringStatus status) {
        for (CanvasViewInfo info : infos) {
            if (info.isRoot()) {
                status.addFatalError("Cannot refactor the root");
                return false;
            }
        }

        return true;
    }

    protected boolean validateContiguous(List<CanvasViewInfo> infos, RefactoringStatus status) {
        if (infos.size() > 1) {
            // All elements must be siblings (e.g. same parent)
            List<UiViewElementNode> nodes = new ArrayList<UiViewElementNode>(infos
                    .size());
            for (CanvasViewInfo info : infos) {
                UiViewElementNode node = info.getUiViewNode();
                if (node != null) {
                    nodes.add(node);
                }
            }
            if (nodes.size() == 0) {
                status.addFatalError("No selected views");
                return false;
            }

            UiElementNode parent = nodes.get(0).getUiParent();
            for (UiViewElementNode node : nodes) {
                if (parent != node.getUiParent()) {
                    status.addFatalError("The selected elements must be adjacent");
                    return false;
                }
            }
            // Ensure that the siblings are contiguous; no gaps.
            // If we've selected all the children of the parent then we don't need
            // to look.
            List<UiElementNode> siblings = parent.getUiChildren();
            if (siblings.size() != nodes.size()) {
                Set<UiViewElementNode> nodeSet = new HashSet<UiViewElementNode>(nodes);
                boolean inRange = false;
                int remaining = nodes.size();
                for (UiElementNode node : siblings) {
                    boolean in = nodeSet.contains(node);
                    if (in) {
                        remaining--;
                        if (remaining == 0) {
                            break;
                        }
                        inRange = true;
                    } else if (inRange) {
                        status.addFatalError("The selected elements must be adjacent");
                        return false;
                    }
                }
            }
        }

        return true;
    }

    /**
     * Updates the given element with a new name if the current id reflects the old
     * element type. If the name was changed, it will return the new name.
     */
    protected String ensureIdMatchesType(Element element, String newType, MultiTextEdit rootEdit) {
        String oldType = element.getTagName();
        if (oldType.indexOf('.') == -1) {
            oldType = ANDROID_WIDGET_PREFIX + oldType;
        }
        String oldTypeBase = oldType.substring(oldType.lastIndexOf('.') + 1);
        String id = getId(element);
        if (id == null || id.length() == 0
                || id.toLowerCase().contains(oldTypeBase.toLowerCase())) {
            String newTypeBase = newType.substring(newType.lastIndexOf('.') + 1);
            return ensureHasId(rootEdit, element, newTypeBase);
        }

        return null;
    }

    /**
     * Returns the {@link IndexedRegion} for the given node
     *
     * @param node the node to look up the region for
     * @return the corresponding region, or null
     */
    public static IndexedRegion getRegion(Node node) {
        if (node instanceof IndexedRegion) {
            return (IndexedRegion) node;
        }

        return null;
    }

    protected String ensureHasId(MultiTextEdit rootEdit, Element element, String prefix) {
        String id = mGeneratedIdMap.get(element);
        if (id != null) {
            return NEW_ID_PREFIX + id;
        }

        if (!element.hasAttributeNS(ANDROID_URI, ATTR_ID)
                || (prefix != null && !getId(element).startsWith(prefix))) {
            id = DomUtilities.getFreeWidgetId(element, mGeneratedIds, prefix);
            // Make sure we don't use this one again
            mGeneratedIds.add(id);
            mGeneratedIdMap.put(element, id);
            id = NEW_ID_PREFIX + id;
            setAttribute(rootEdit, element,
                    ANDROID_URI, getAndroidNamespacePrefix(), ATTR_ID, id);
            return id;
        }

        return getId(element);
    }

    protected int getFirstAttributeOffset(Element element) {
        IndexedRegion region = getRegion(element);
        if (region != null) {
            int startOffset = region.getStartOffset();
            int endOffset = region.getEndOffset();
            String text = getText(startOffset, endOffset);
            String name = element.getLocalName();
            int nameOffset = text.indexOf(name);
            if (nameOffset != -1) {
                return startOffset + nameOffset + name.length();
            }
        }

        return -1;
    }

    /**
     * Returns the id of the given element
     *
     * @param element the element to look up the id for
     * @return the corresponding id, or an empty string (should not be null
     *         according to the DOM API, but has been observed to be null on
     *         some versions of Eclipse)
     */
    public static String getId(Element element) {
        return element.getAttributeNS(ANDROID_URI, ATTR_ID);
    }

    protected String ensureNewId(String id) {
        if (id != null && id.length() > 0) {
            if (id.startsWith(ID_PREFIX)) {
                id = NEW_ID_PREFIX + id.substring(ID_PREFIX.length());
            } else if (!id.startsWith(NEW_ID_PREFIX)) {
                id = NEW_ID_PREFIX + id;
            }
        } else {
            id = null;
        }

        return id;
    }

    protected String getViewClass(String fqcn) {
        // Don't include android.widget. as a package prefix in layout files
        if (fqcn.startsWith(ANDROID_WIDGET_PREFIX)) {
            fqcn = fqcn.substring(ANDROID_WIDGET_PREFIX.length());
        }

        return fqcn;
    }

    protected void setAttribute(MultiTextEdit rootEdit, Element element,
            String attributeUri,
            String attributePrefix, String attributeName, String attributeValue) {
        int offset = getFirstAttributeOffset(element);
        if (offset != -1) {
            if (element.hasAttributeNS(attributeUri, attributeName)) {
                replaceAttributeDeclaration(rootEdit, offset, element, attributePrefix,
                        attributeUri, attributeName, attributeValue);
            } else {
                addAttributeDeclaration(rootEdit, offset, attributePrefix, attributeName,
                        attributeValue);
            }
        }
    }

    private void addAttributeDeclaration(MultiTextEdit rootEdit, int offset,
            String attributePrefix, String attributeName, String attributeValue) {
        StringBuilder sb = new StringBuilder();
        sb.append(' ');

        if (attributePrefix != null) {
            sb.append(attributePrefix).append(':');
        }
        sb.append(attributeName).append('=').append('"');
        sb.append(attributeValue).append('"');

        InsertEdit setAttribute = new InsertEdit(offset, sb.toString());
        rootEdit.addChild(setAttribute);
    }

    /** Replaces the value declaration of the given attribute */
    private void replaceAttributeDeclaration(MultiTextEdit rootEdit, int offset,
            Element element, String attributePrefix, String attributeUri,
            String attributeName, String attributeValue) {
        // Find attribute value and replace it
        IStructuredModel model = mEditor.getModelForRead();
        try {
            IStructuredDocument doc = model.getStructuredDocument();

            IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(offset);
            ITextRegionList list = region.getRegions();
            int regionStart = region.getStart();

            int valueStart = -1;
            boolean useNextValue = false;
            String targetName = attributePrefix != null
                ? attributePrefix + ':' + attributeName : attributeName;

            // Look at all attribute values and look for an id reference match
            for (int j = 0; j < region.getNumberOfRegions(); j++) {
                ITextRegion subRegion = list.get(j);
                String type = subRegion.getType();
                if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) {
                    // What about prefix?
                    if (targetName.equals(region.getText(subRegion))) {
                        useNextValue = true;
                    }
                } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) {
                    if (useNextValue) {
                        valueStart = regionStart + subRegion.getStart();
                        break;
                    }
                }
            }

            if (valueStart != -1) {
                String oldValue = element.getAttributeNS(attributeUri, attributeName);
                int start = valueStart + 1; // Skip opening "
                ReplaceEdit setAttribute = new ReplaceEdit(start, oldValue.length(),
                        attributeValue);
                try {
                    rootEdit.addChild(setAttribute);
                } catch (MalformedTreeException mte) {
                    AdtPlugin.log(mte, "Could not replace attribute %1$s with %2$s",
                            attributeName, attributeValue);
                    throw mte;
                }
            }
        } finally {
            model.releaseFromRead();
        }
    }

    /** Strips out the given attribute, if defined */
    protected void removeAttribute(MultiTextEdit rootEdit, Element element, String uri,
            String attributeName) {
        if (element.hasAttributeNS(uri, attributeName)) {
            Attr attribute = element.getAttributeNodeNS(uri, attributeName);
            removeAttribute(rootEdit, attribute);
        }
    }

    /** Strips out the given attribute, if defined */
    protected void removeAttribute(MultiTextEdit rootEdit, Attr attribute) {
        IndexedRegion region = getRegion(attribute);
        if (region != null) {
            int startOffset = region.getStartOffset();
            int endOffset = region.getEndOffset();
            DeleteEdit deletion = new DeleteEdit(startOffset, endOffset - startOffset);
            rootEdit.addChild(deletion);
        }
    }


    /**
     * Removes the given element's opening and closing tags (including all of its
     * attributes) but leaves any children alone
     *
     * @param rootEdit the multi edit to add the removal operation to
     * @param element the element to delete the open and closing tags for
     * @param skip a list of elements that should not be modified (for example because they
     *    are targeted for deletion)
     *
     * TODO: Rename this to "unwrap" ? And allow for handling nested deletions.
     */
    protected void removeElementTags(MultiTextEdit rootEdit, Element element, List<Element> skip,
            boolean changeIndentation) {
        IndexedRegion elementRegion = getRegion(element);
        if (elementRegion == null) {
            return;
        }

        // Look for the opening tag
        IStructuredModel model = mEditor.getModelForRead();
        try {
            int startLineInclusive = -1;
            int endLineInclusive = -1;
            IStructuredDocument doc = model.getStructuredDocument();
            if (doc != null) {
                int start = elementRegion.getStartOffset();
                IStructuredDocumentRegion region = doc.getRegionAtCharacterOffset(start);
                ITextRegionList list = region.getRegions();
                int regionStart = region.getStart();
                int startOffset = regionStart;
                for (int j = 0; j < region.getNumberOfRegions(); j++) {
                    ITextRegion subRegion = list.get(j);
                    String type = subRegion.getType();
                    if (DOMRegionContext.XML_TAG_OPEN.equals(type)) {
                        startOffset = regionStart + subRegion.getStart();
                    } else if (DOMRegionContext.XML_TAG_CLOSE.equals(type)) {
                        int endOffset = regionStart + subRegion.getStart() + subRegion.getLength();

                        DeleteEdit deletion = createDeletion(doc, startOffset, endOffset);
                        rootEdit.addChild(deletion);
                        startLineInclusive = doc.getLineOfOffset(endOffset) + 1;
                        break;
                    }
                }

                // Find the close tag
                // Look at all attribute values and look for an id reference match
                region = doc.getRegionAtCharacterOffset(elementRegion.getEndOffset()
                        - element.getTagName().length() - 1);
                list = region.getRegions();
                regionStart = region.getStartOffset();
                startOffset = -1;
                for (int j = 0; j < region.getNumberOfRegions(); j++) {
                    ITextRegion subRegion = list.get(j);
                    String type = subRegion.getType();
                    if (DOMRegionContext.XML_END_TAG_OPEN.equals(type)) {
                        startOffset = regionStart + subRegion.getStart();
                    } else if (DOMRegionContext.XML_TAG_CLOSE.equals(type)) {
                        int endOffset = regionStart + subRegion.getStart() + subRegion.getLength();
                        if (startOffset != -1) {
                            DeleteEdit deletion = createDeletion(doc, startOffset, endOffset);
                            rootEdit.addChild(deletion);
                            endLineInclusive = doc.getLineOfOffset(startOffset) - 1;
                        }
                        break;
                    }
                }
            }

            // Dedent the contents
            if (changeIndentation && startLineInclusive != -1 && endLineInclusive != -1) {
                String indent = AndroidXmlEditor.getIndentAtOffset(doc, getRegion(element)
                        .getStartOffset());
                setIndentation(rootEdit, indent, doc, startLineInclusive, endLineInclusive,
                        element, skip);
            }
        } finally {
            model.releaseFromRead();
        }
    }

    protected void removeIndentation(MultiTextEdit rootEdit, String removeIndent,
            IStructuredDocument doc, int startLineInclusive, int endLineInclusive,
            Element element, List<Element> skip) {
        if (startLineInclusive > endLineInclusive) {
            return;
        }
        int indentLength = removeIndent.length();
        if (indentLength == 0) {
            return;
        }

        try {
            for (int line = startLineInclusive; line <= endLineInclusive; line++) {
                IRegion info = doc.getLineInformation(line);
                int lineStart = info.getOffset();
                int lineLength = info.getLength();
                int lineEnd = lineStart + lineLength;
                if (overlaps(lineStart, lineEnd, element, skip)) {
                    continue;
                }
                String lineText = getText(lineStart,
                        lineStart + Math.min(lineLength, indentLength));
                if (lineText.startsWith(removeIndent)) {
                    rootEdit.addChild(new DeleteEdit(lineStart, indentLength));
                }
            }
        } catch (BadLocationException e) {
            AdtPlugin.log(e, null);
        }
    }

    protected void setIndentation(MultiTextEdit rootEdit, String indent,
            IStructuredDocument doc, int startLineInclusive, int endLineInclusive,
            Element element, List<Element> skip) {
        if (startLineInclusive > endLineInclusive) {
            return;
        }
        int indentLength = indent.length();
        if (indentLength == 0) {
            return;
        }

        try {
            for (int line = startLineInclusive; line <= endLineInclusive; line++) {
                IRegion info = doc.getLineInformation(line);
                int lineStart = info.getOffset();
                int lineLength = info.getLength();
                int lineEnd = lineStart + lineLength;
                if (overlaps(lineStart, lineEnd, element, skip)) {
                    continue;
                }
                String lineText = getText(lineStart, lineStart + lineLength);
                int indentEnd = getFirstNonSpace(lineText);
                rootEdit.addChild(new ReplaceEdit(lineStart, indentEnd, indent));
            }
        } catch (BadLocationException e) {
            AdtPlugin.log(e, null);
        }
    }

    private int getFirstNonSpace(String s) {
        for (int i = 0; i < s.length(); i++) {
            if (!Character.isWhitespace(s.charAt(i))) {
                return i;
            }
        }

        return s.length();
    }

    /** Returns true if the given line overlaps any of the given elements */
    private static boolean overlaps(int startOffset, int endOffset,
            Element element, List<Element> overlaps) {
        for (Element e : overlaps) {
            if (e == element) {
                continue;
            }

            IndexedRegion region = getRegion(e);
            if (region.getEndOffset() >= startOffset && region.getStartOffset() <= endOffset) {
                return true;
            }
        }
        return false;
    }

    protected DeleteEdit createDeletion(IStructuredDocument doc, int startOffset, int endOffset) {
        // Expand to delete the whole line?
        try {
            IRegion info = doc.getLineInformationOfOffset(startOffset);
            int lineBegin = info.getOffset();
            // Is the text on the line leading up to the deletion region,
            // and the text following it, all whitespace?
            boolean deleteLine = true;
            if (lineBegin < startOffset) {
                String prefix = getText(lineBegin, startOffset);
                if (prefix.trim().length() > 0) {
                    deleteLine = false;
                }
            }
            info = doc.getLineInformationOfOffset(endOffset);
            int lineEnd = info.getOffset() + info.getLength();
            if (lineEnd > endOffset) {
                String suffix = getText(endOffset, lineEnd);
                if (suffix.trim().length() > 0) {
                    deleteLine = false;
                }
            }
            if (deleteLine) {
                startOffset = lineBegin;
                endOffset = Math.min(doc.getLength(), lineEnd + 1);
            }
        } catch (BadLocationException e) {
            AdtPlugin.log(e, null);
        }


        return new DeleteEdit(startOffset, endOffset - startOffset);
    }

    /**
     * Rewrite the edits in the given {@link MultiTextEdit} such that same edits are
     * applied, but the resulting range is also formatted
     */
    protected MultiTextEdit reformat(MultiTextEdit edit, XmlFormatStyle style) {
        String xml = mEditor.getStructuredDocument().get();
        return reformat(xml, edit, style);
    }

    /**
     * Rewrite the edits in the given {@link MultiTextEdit} such that same edits are
     * applied, but the resulting range is also formatted
     *
     * @param oldContents the original contents that should be edited by a
     *            {@link MultiTextEdit}
     * @param edit the {@link MultiTextEdit} to be applied to some string
     * @param style the formatting style to use
     * @return a new {@link MultiTextEdit} which performs the same edits as the input edit
     *         but also reformats the text
     */
    public static MultiTextEdit reformat(String oldContents, MultiTextEdit edit,
            XmlFormatStyle style) {
        IDocument document = new org.eclipse.jface.text.Document();
        document.set(oldContents);

        try {
            edit.apply(document);
        } catch (MalformedTreeException e) {
            AdtPlugin.log(e, null);
            return null; // Abort formatting
        } catch (BadLocationException e) {
            AdtPlugin.log(e, null);
            return null; // Abort formatting
        }

        String actual = document.get();

        // TODO: Try to format only the affected portion of the document.
        // To do that we need to find out what the affected offsets are; we know
        // the MultiTextEdit's affected range, but that is referring to offsets
        // in the old document. Use that to compute offsets in the new document.
        //int distanceFromEnd = actual.length() - edit.getExclusiveEnd();
        //IStructuredModel model = DomUtilities.createStructuredModel(actual);
        //int start = edit.getOffset();
        //int end = actual.length() - distanceFromEnd;
        //int length = end - start;
        //TextEdit format = AndroidXmlFormattingStrategy.format(model, start, length);
        XmlFormatPreferences formatPrefs = XmlFormatPreferences.create();
        String formatted = XmlPrettyPrinter.prettyPrint(actual, formatPrefs, style,
                null /*lineSeparator*/);


        // 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();
        int start = 0;
        int end = oldContents.length();

        for (int i = 0, j = start; i < formatted.length() && j < end; i++, j++) {
            if (formatted.charAt(i) != oldContents.charAt(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 = end - 1;
                i > firstDifference && j > start;
                i--, j--) {
            if (formatted.charAt(i) != oldContents.charAt(j)) {
                lastDifference = i + 1;
                break;
            }
        }

        start += firstDifference;
        end -= (formatted.length() - lastDifference);
        end = Math.max(start, end);
        formatted = formatted.substring(firstDifference, lastDifference);

        ReplaceEdit format = new ReplaceEdit(start, end - start,
                formatted);

        MultiTextEdit newEdit = new MultiTextEdit();
        newEdit.addChild(format);

        return newEdit;
    }

    protected ViewElementDescriptor getElementDescriptor(String fqcn) {
        AndroidTargetData data = mEditor.getTargetData();
        if (data != null) {
            return data.getLayoutDescriptors().findDescriptorByClass(fqcn);
        }

        return null;
    }

    /** Create a wizard for this refactoring */
    abstract VisualRefactoringWizard createWizard();

    public abstract static class VisualRefactoringDescriptor extends RefactoringDescriptor {
        private final Map<String, String> mArguments;

        public VisualRefactoringDescriptor(
                String id, String project, String description, String comment,
                Map<String, String> arguments) {
            super(id, project, description, comment, STRUCTURAL_CHANGE | MULTI_CHANGE);
            mArguments = arguments;
        }

        public Map<String, String> getArguments() {
            return mArguments;
        }

        protected abstract Refactoring createRefactoring(Map<String, String> args);

        @Override
        public Refactoring createRefactoring(RefactoringStatus status) throws CoreException {
            try {
                return createRefactoring(mArguments);
            } catch (NullPointerException e) {
                status.addFatalError("Failed to recreate refactoring from descriptor");
                return null;
            }
        }
    }
}
