/*
 * Copyright (C) 2009 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.refactorings.extractstring;

import static com.android.ide.common.layout.LayoutConstants.STRING_PREFIX;

import com.android.ide.eclipse.adt.AdtConstants;
import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor;
import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor;
import com.android.ide.eclipse.adt.internal.editors.descriptors.ReferenceAttributeDescriptor;
import com.android.ide.eclipse.adt.internal.editors.resources.descriptors.ResourcesDescriptors;
import com.android.ide.eclipse.adt.internal.editors.uimodel.UiAttributeNode;
import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode;
import com.android.ide.eclipse.adt.internal.project.AndroidManifestHelper;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.sdklib.SdkConstants;
import com.android.sdklib.xml.ManifestData;

import org.eclipse.core.resources.IContainer;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IFolder;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.ResourceAttributes;
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.SubMonitor;
import org.eclipse.jdt.core.IBuffer;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IPackageFragment;
import org.eclipse.jdt.core.IPackageFragmentRoot;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jdt.core.ToolFactory;
import org.eclipse.jdt.core.compiler.IScanner;
import org.eclipse.jdt.core.compiler.ITerminalSymbols;
import org.eclipse.jdt.core.compiler.InvalidInputException;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
import org.eclipse.jface.text.ITextSelection;
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.RefactoringStatus;
import org.eclipse.ltk.core.refactoring.TextEditChangeGroup;
import org.eclipse.ltk.core.refactoring.TextFileChange;
import org.eclipse.text.edits.InsertEdit;
import org.eclipse.text.edits.MultiTextEdit;
import org.eclipse.text.edits.ReplaceEdit;
import org.eclipse.text.edits.TextEdit;
import org.eclipse.text.edits.TextEditGroup;
import org.eclipse.ui.IEditorPart;
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.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.Node;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;

/**
 * This refactoring extracts a string from a file and replaces it by an Android resource ID
 * such as R.string.foo.
 * <p/>
 * There are a number of scenarios, which are not all supported yet. The workflow works as
 * such:
 * <ul>
 * <li> User selects a string in a Java and invokes the {@link ExtractStringAction}.
 * <li> The action finds the {@link ICompilationUnit} being edited as well as the current
 *      {@link ITextSelection}. The action creates a new instance of this refactoring as
 *      well as an {@link ExtractStringWizard} and runs the operation.
 * <li> Step 1 of the refactoring is to check the preliminary conditions. Right now we check
 *      that the java source is not read-only and is in sync. We also try to find a string under
 *      the selection. If this fails, the refactoring is aborted.
 * <li> On success, the wizard is shown, which lets the user input the new ID to use.
 * <li> The wizard sets the user input values into this refactoring instance, e.g. the new string
 *      ID, the XML file to update, etc. The wizard does use the utility method
 *      {@link XmlStringFileHelper#valueOfStringId(IProject, String, String)} to check whether
 *      the new ID is already defined in the target XML file.
 * <li> Once Preview or Finish is selected in the wizard, the
 *      {@link #checkFinalConditions(IProgressMonitor)} is called to double-check the user input
 *      and compute the actual changes.
 * <li> When all changes are computed, {@link #createChange(IProgressMonitor)} is invoked.
 * </ul>
 *
 * The list of changes are:
 * <ul>
 * <li> If the target XML does not exist, create it with the new string ID.
 * <li> If the target XML exists, find the <resources> node and add the new string ID right after.
 *      If the node is <resources/>, it needs to be opened.
 * <li> Create an AST rewriter to edit the source Java file and replace all occurrences by the
 *      new computed R.string.foo. Also need to rewrite imports to import R as needed.
 *      If there's already a conflicting R included, we need to insert the FQCN instead.
 * <li> TODO: Have a pref in the wizard: [x] Change other XML Files
 * <li> TODO: Have a pref in the wizard: [x] Change other Java Files
 * </ul>
 */
@SuppressWarnings("restriction")
public class ExtractStringRefactoring extends Refactoring {

    public enum Mode {
        /**
         * the Extract String refactoring is called on an <em>existing</em> source file.
         * Its purpose is then to get the selected string of the source and propose to
         * change it by an XML id. The XML id may be a new one or an existing one.
         */
        EDIT_SOURCE,
        /**
         * The Extract String refactoring is called without any source file.
         * Its purpose is then to create a new XML string ID or select/modify an existing one.
         */
        SELECT_ID,
        /**
         * The Extract String refactoring is called without any source file.
         * Its purpose is then to create a new XML string ID. The ID must not already exist.
         */
        SELECT_NEW_ID
    }

    /** The {@link Mode} of operation of the refactoring. */
    private final Mode mMode;
    /** Non-null when editing an Android Resource XML file: identifies the attribute name
     * of the value being edited. When null, the source is an Android Java file. */
    private String mXmlAttributeName;
    /** The file model being manipulated.
     * Value is null when not on {@link Mode#EDIT_SOURCE} mode. */
    private final IFile mFile;
    /** The editor. Non-null when invoked from {@link ExtractStringAction}. Null otherwise. */
    private final IEditorPart mEditor;
    /** The project that contains {@link #mFile} and that contains the target XML file to modify. */
    private final IProject mProject;
    /** The start of the selection in {@link #mFile}.
     * Value is -1 when not on {@link Mode#EDIT_SOURCE} mode. */
    private final int mSelectionStart;
    /** The end of the selection in {@link #mFile}.
     * Value is -1 when not on {@link Mode#EDIT_SOURCE} mode. */
    private final int mSelectionEnd;

    /** The compilation unit, only defined if {@link #mFile} points to a usable Java source file. */
    private ICompilationUnit mUnit;
    /** The actual string selected, after UTF characters have been escaped, good for display.
     * Value is null when not on {@link Mode#EDIT_SOURCE} mode. */
    private String mTokenString;

    /** The XML string ID selected by the user in the wizard. */
    private String mXmlStringId;
    /** The XML string value. Might be different than the initial selected string. */
    private String mXmlStringValue;
    /** The path of the XML file that will define {@link #mXmlStringId}, selected by the user
     *  in the wizard. This is relative to the project, e.g. "/res/values/string.xml" */
    private String mTargetXmlFileWsPath;
    /** True if we should find & replace in all Java files. */
    private boolean mReplaceAllJava;
    /** True if we should find & replace in all XML files of the same name in other res configs
     * (other than the main {@link #mTargetXmlFileWsPath}.) */
    private boolean mReplaceAllXml;

    /** The list of changes computed by {@link #checkFinalConditions(IProgressMonitor)} and
     *  used by {@link #createChange(IProgressMonitor)}. */
    private ArrayList<Change> mChanges;

    private XmlStringFileHelper mXmlHelper = new XmlStringFileHelper();

    private static final String KEY_MODE = "mode";                      //$NON-NLS-1$
    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$
    private static final String KEY_TOK_ESC = "tok-esc";                //$NON-NLS-1$
    private static final String KEY_XML_ATTR_NAME = "xml-attr-name";    //$NON-NLS-1$
    private static final String KEY_RPLC_ALL_JAVA = "rplc-all-java";    //$NON-NLS-1$
    private static final String KEY_RPLC_ALL_XML  = "rplc-all-xml";     //$NON-NLS-1$

    /**
     * This constructor is solely used by {@link ExtractStringDescriptor},
     * to replay a previous refactoring.
     * <p/>
     * To create a refactoring from code, please use one of the two other constructors.
     *
     * @param arguments A map previously created using {@link #createArgumentMap()}.
     * @throws NullPointerException
     */
    public ExtractStringRefactoring(Map<String, String> arguments) throws NullPointerException {

        mReplaceAllJava = Boolean.parseBoolean(arguments.get(KEY_RPLC_ALL_JAVA));
        mReplaceAllXml  = Boolean.parseBoolean(arguments.get(KEY_RPLC_ALL_XML));
        mMode = Mode.valueOf(arguments.get(KEY_MODE));

        IPath path = Path.fromPortableString(arguments.get(KEY_PROJECT));
        mProject = (IProject) ResourcesPlugin.getWorkspace().getRoot().findMember(path);

        if (mMode == Mode.EDIT_SOURCE) {
            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));
            mTokenString      = arguments.get(KEY_TOK_ESC);
            mXmlAttributeName = arguments.get(KEY_XML_ATTR_NAME);
        } else {
            mFile = null;
            mSelectionStart = mSelectionEnd = -1;
            mTokenString = null;
            mXmlAttributeName = null;
        }

        mEditor = null;
    }

    private Map<String, String> createArgumentMap() {
        HashMap<String, String> args = new HashMap<String, String>();
        args.put(KEY_RPLC_ALL_JAVA, Boolean.toString(mReplaceAllJava));
        args.put(KEY_RPLC_ALL_XML,  Boolean.toString(mReplaceAllXml));
        args.put(KEY_MODE,      mMode.name());
        args.put(KEY_PROJECT,   mProject.getFullPath().toPortableString());
        if (mMode == Mode.EDIT_SOURCE) {
            args.put(KEY_FILE,      mFile.getFullPath().toPortableString());
            args.put(KEY_SEL_START, Integer.toString(mSelectionStart));
            args.put(KEY_SEL_END,   Integer.toString(mSelectionEnd));
            args.put(KEY_TOK_ESC,   mTokenString);
            args.put(KEY_XML_ATTR_NAME, mXmlAttributeName);
        }
        return args;
    }

    /**
     * Constructor to use when the Extract String refactoring is called on an
     * *existing* source file. Its purpose is then to get the selected string of
     * the source and propose to change it by an XML id. The XML id may be a new one
     * or an existing one.
     *
     * @param file The source file to process. Cannot be null. File must exist in workspace.
     * @param editor The editor.
     * @param selection The selection in the source file. Cannot be null or empty.
     */
    public ExtractStringRefactoring(IFile file, IEditorPart editor, ITextSelection selection) {
        mMode = Mode.EDIT_SOURCE;
        mFile = file;
        mEditor = editor;
        mProject = file.getProject();
        mSelectionStart = selection.getOffset();
        mSelectionEnd = mSelectionStart + Math.max(0, selection.getLength() - 1);
    }

    /**
     * Constructor to use when the Extract String refactoring is called without
     * any source file. Its purpose is then to create a new XML string ID.
     * <p/>
     * For example this is currently invoked by the ResourceChooser when
     * the user wants to create a new string rather than select an existing one.
     *
     * @param project The project where the target XML file to modify is located. Cannot be null.
     * @param enforceNew If true the XML ID must be a new one.
     *                   If false, an existing ID can be used.
     */
    public ExtractStringRefactoring(IProject project, boolean enforceNew) {
        mMode = enforceNew ? Mode.SELECT_NEW_ID : Mode.SELECT_ID;
        mFile = null;
        mEditor = null;
        mProject = project;
        mSelectionStart = mSelectionEnd = -1;
    }

    /**
     * Sets the replacement string ID. Used by the wizard to set the user input.
     */
    public void setNewStringId(String newStringId) {
        mXmlStringId = newStringId;
    }

    /**
     * Sets the replacement string ID. Used by the wizard to set the user input.
     */
    public void setNewStringValue(String newStringValue) {
        mXmlStringValue = newStringValue;
    }

    /**
     * Sets the target file. This is a project path, e.g. "/res/values/strings.xml".
     * Used by the wizard to set the user input.
     */
    public void setTargetFile(String targetXmlFileWsPath) {
        mTargetXmlFileWsPath = targetXmlFileWsPath;
    }

    public void setReplaceAllJava(boolean replaceAllJava) {
        mReplaceAllJava = replaceAllJava;
    }

    public void setReplaceAllXml(boolean replaceAllXml) {
        mReplaceAllXml = replaceAllXml;
    }

    /**
     * @see org.eclipse.ltk.core.refactoring.Refactoring#getName()
     */
    @Override
    public String getName() {
        if (mMode == Mode.SELECT_ID) {
            return "Create or Use Android String";
        } else if (mMode == Mode.SELECT_NEW_ID) {
            return "Create New Android String";
        }

        return "Extract Android String";
    }

    public Mode getMode() {
        return mMode;
    }

    /**
     * Gets the actual string selected, after UTF characters have been escaped,
     * good for display. Value can be null.
     */
    public String getTokenString() {
        return mTokenString;
    }

    /** Returns the XML string ID selected by the user in the wizard. */
    public String getXmlStringId() {
        return mXmlStringId;
    }

    /**
     * Step 1 of 3 of the refactoring:
     * Checks that the current selection meets the initial condition before the ExtractString
     * wizard is shown. The check is supposed to be lightweight and quick. Note that at that
     * point the wizard has not been created yet.
     * <p/>
     * Here we scan the source buffer to find the token matching the selection.
     * The check is successful is a Java string literal is selected, the source is in sync
     * and is not read-only.
     * <p/>
     * This is also used to extract the string to be modified, so that we can display it in
     * the refactoring wizard.
     *
     * @see org.eclipse.ltk.core.refactoring.Refactoring#checkInitialConditions(org.eclipse.core.runtime.IProgressMonitor)
     *
     * @throws CoreException
     */
    @Override
    public RefactoringStatus checkInitialConditions(IProgressMonitor monitor)
            throws CoreException, OperationCanceledException {

        mUnit = null;
        mTokenString = null;

        RefactoringStatus status = new RefactoringStatus();

        try {
            monitor.beginTask("Checking preconditions...", 6);

            if (mMode != Mode.EDIT_SOURCE) {
                monitor.worked(6);
                return status;
            }

            if (!checkSourceFile(mFile, status, monitor)) {
                return status;
            }

            // Try to get a compilation unit from this file. If it fails, mUnit is null.
            try {
                mUnit = JavaCore.createCompilationUnitFrom(mFile);

                // Make sure the unit is not read-only, e.g. it's not a class file or inside a Jar
                if (mUnit.isReadOnly()) {
                    status.addFatalError("The file is read-only, please make it writeable first.");
                    return status;
                }

                // This is a Java file. Check if it contains the selection we want.
                if (!findSelectionInJavaUnit(mUnit, status, monitor)) {
                    return status;
                }

            } catch (Exception e) {
                // That was not a Java file. Ignore.
            }

            if (mUnit != null) {
                monitor.worked(1);
                return status;
            }

            // Check this a Layout XML file and get the selection and its context.
            if (mFile != null && AdtConstants.EXT_XML.equals(mFile.getFileExtension())) {

                // Currently we only support Android resource XML files, so they must have a path
                // similar to
                //    project/res/<type>[-<configuration>]/*.xml
                //    project/AndroidManifest.xml
                // There is no support for sub folders, so the segment count must be 4 or 2.
                // We don't need to check the type folder name because a/ we only accept
                // an AndroidXmlEditor source and b/ aapt generates a compilation error for
                // unknown folders.

                IPath path = mFile.getFullPath();
                if ((path.segmentCount() == 4 &&
                     path.segment(1).equalsIgnoreCase(SdkConstants.FD_RESOURCES)) ||
                    (path.segmentCount() == 2 &&
                     path.segment(1).equalsIgnoreCase(SdkConstants.FN_ANDROID_MANIFEST_XML))) {
                    if (!findSelectionInXmlFile(mFile, status, monitor)) {
                        return status;
                    }
                }
            }

            if (!status.isOK()) {
                status.addFatalError(
                        "Selection must be inside a Java source or an Android Layout XML file.");
            }

        } finally {
            monitor.done();
        }

        return status;
    }

    /**
     * Try to find the selected Java element in the compilation unit.
     *
     * If selection matches a string literal, capture it, otherwise add a fatal error
     * to the status.
     *
     * On success, advance the monitor by 3.
     * Returns status.isOK().
     */
    private boolean findSelectionInJavaUnit(ICompilationUnit unit,
            RefactoringStatus status, IProgressMonitor monitor) {
        try {
            IBuffer buffer = unit.getBuffer();

            IScanner scanner = ToolFactory.createScanner(
                    false, //tokenizeComments
                    false, //tokenizeWhiteSpace
                    false, //assertMode
                    false  //recordLineSeparator
                    );
            scanner.setSource(buffer.getCharacters());
            monitor.worked(1);

            for(int token = scanner.getNextToken();
                    token != ITerminalSymbols.TokenNameEOF;
                    token = scanner.getNextToken()) {
                if (scanner.getCurrentTokenStartPosition() <= mSelectionStart &&
                        scanner.getCurrentTokenEndPosition() >= mSelectionEnd) {
                    // found the token, but only keep if the right type
                    if (token == ITerminalSymbols.TokenNameStringLiteral) {
                        mTokenString = new String(scanner.getCurrentTokenSource());
                    }
                    break;
                } else if (scanner.getCurrentTokenStartPosition() > mSelectionEnd) {
                    // scanner is past the selection, abort.
                    break;
                }
            }
        } catch (JavaModelException e1) {
            // Error in unit.getBuffer. Ignore.
        } catch (InvalidInputException e2) {
            // Error in scanner.getNextToken. Ignore.
        } finally {
            monitor.worked(1);
        }

        if (mTokenString != null) {
            // As a literal string, the token should have surrounding quotes. Remove them.
            // Note: unquoteAttrValue technically removes either " or ' paired quotes, whereas
            // the Java token should only have " quotes. Since we know the type to be a string
            // literal, there should be no confusion here.
            mTokenString = unquoteAttrValue(mTokenString);

            // We need a non-empty string literal
            if (mTokenString.length() == 0) {
                mTokenString = null;
            }
        }

        if (mTokenString == null) {
            status.addFatalError("Please select a Java string literal.");
        }

        monitor.worked(1);
        return status.isOK();
    }

    /**
     * Try to find the selected XML element. This implementation replies on the refactoring
     * originating from an Android Layout Editor. We rely on some internal properties of the
     * Structured XML editor to retrieve file content to avoid parsing it again. We also rely
     * on our specific Android XML model to get element & attribute descriptor properties.
     *
     * If selection matches a string literal, capture it, otherwise add a fatal error
     * to the status.
     *
     * On success, advance the monitor by 1.
     * Returns status.isOK().
     */
    private boolean findSelectionInXmlFile(IFile file,
            RefactoringStatus status,
            IProgressMonitor monitor) {

        try {
            if (!(mEditor instanceof AndroidXmlEditor)) {
                status.addFatalError("Only the Android XML Editor is currently supported.");
                return status.isOK();
            }

            AndroidXmlEditor editor = (AndroidXmlEditor) mEditor;
            IStructuredModel smodel = null;
            Node node = null;
            String currAttrName = null;

            try {
                // See the portability note in AndroidXmlEditor#getModelForRead() javadoc.
                smodel = editor.getModelForRead();
                if (smodel != null) {
                    // The structured model gives the us the actual XML Node element where the
                    // offset is. By using this Node, we can find the exact UiElementNode of our
                    // model and thus we'll be able to get the properties of the attribute -- to
                    // check if it accepts a string reference. This does not however tell us if
                    // the selection is actually in an attribute value, nor which attribute is
                    // being edited.
                    for(int offset = mSelectionStart; offset >= 0 && node == null; --offset) {
                        node = (Node) smodel.getIndexedRegion(offset);
                    }

                    if (node == null) {
                        status.addFatalError(
                                "The selection does not match any element in the XML document.");
                        return status.isOK();
                    }

                    if (node.getNodeType() != Node.ELEMENT_NODE) {
                        status.addFatalError("The selection is not inside an actual XML element.");
                        return status.isOK();
                    }

                    IStructuredDocument sdoc = smodel.getStructuredDocument();
                    if (sdoc != null) {
                        // Portability note: all the structured document implementation is
                        // under wst.sse.core.internal.provisional so we can expect it to change in
                        // a distant future if they start cleaning their codebase, however unlikely
                        // that is.

                        int selStart = mSelectionStart;
                        IStructuredDocumentRegion region =
                            sdoc.getRegionAtCharacterOffset(selStart);
                        if (region != null &&
                                DOMRegionContext.XML_TAG_NAME.equals(region.getType())) {
                            // Find if any sub-region representing an attribute contains the
                            // selection. If it does, returns the name of the attribute in
                            // currAttrName and returns the value in the field mTokenString.
                            currAttrName = findSelectionInRegion(region, selStart);

                            if (mTokenString == null) {
                                status.addFatalError(
                                    "The selection is not inside an actual XML attribute value.");
                            }
                        }
                    }

                    if (mTokenString != null && node != null && currAttrName != null) {

                        // Validate that the attribute accepts a string reference.
                        // This sets mTokenString to null by side-effect when it fails and
                        // adds a fatal error to the status as needed.
                        validateSelectedAttribute(editor, node, currAttrName, status);

                    } else {
                        // We shouldn't get here: we're missing one of the token string, the node
                        // or the attribute name. All of them have been checked earlier so don't
                        // set any specific error.
                        mTokenString = null;
                    }
                }
            } catch (Throwable t) {
                // Since we use some internal APIs, use a broad catch-all to report any
                // unexpected issue rather than crash the whole refactoring.
                status.addFatalError(
                        String.format("XML parsing error: %1$s", t.getMessage()));
            } finally {
                if (smodel != null) {
                    smodel.releaseFromRead();
                }
            }

        } finally {
            monitor.worked(1);
        }

        return status.isOK();
    }

    /**
     * The region gives us the textual representation of the XML element
     * where the selection starts, split using sub-regions. We now just
     * need to iterate through the sub-regions to find which one
     * contains the actual selection. We're interested in an attribute
     * value however when we find one we want to memorize the attribute
     * name that was defined just before.
     *
     * @return When the cursor is on a valid attribute name or value, returns the string of
     * attribute name. As a side-effect, returns the value of the attribute in {@link #mTokenString}
     */
    private String findSelectionInRegion(IStructuredDocumentRegion region, int selStart) {

        String currAttrName = null;

        int startInRegion = selStart - region.getStartOffset();

        int nb = region.getNumberOfRegions();
        ITextRegionList list = region.getRegions();
        String currAttrValue = null;

        for (int i = 0; i < nb; i++) {
            ITextRegion subRegion = list.get(i);
            String type = subRegion.getType();

            if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) {
                currAttrName = region.getText(subRegion);

                // I like to select the attribute definition and invoke
                // the extract string wizard. So if the selection is on
                // the attribute name part, find the value that is just
                // after and use it as if it were the selection.

                if (subRegion.getStart() <= startInRegion &&
                        startInRegion < subRegion.getTextEnd()) {
                    // A well-formed attribute is composed of a name,
                    // an equal sign and the value. There can't be any space
                    // in between, which makes the parsing a lot easier.
                    if (i <= nb - 3 &&
                            DOMRegionContext.XML_TAG_ATTRIBUTE_EQUALS.equals(
                                                   list.get(i + 1).getType())) {
                        subRegion = list.get(i + 2);
                        type = subRegion.getType();
                        if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(
                                type)) {
                            currAttrValue = region.getText(subRegion);
                        }
                    }
                }

            } else if (subRegion.getStart() <= startInRegion &&
                    startInRegion < subRegion.getTextEnd() &&
                    DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) {
                currAttrValue = region.getText(subRegion);
            }

            if (currAttrValue != null) {
                // We found the value. Only accept it if not empty
                // and if we found an attribute name before.
                String text = currAttrValue;

                // The attribute value contains XML quotes. Remove them.
                text = unquoteAttrValue(text);
                if (text.length() > 0 && currAttrName != null) {
                    // Setting mTokenString to non-null marks the fact we
                    // accept this attribute.
                    mTokenString = text;
                }

                break;
            }
        }

        return currAttrName;
    }

    /**
     * Attribute values found as text for {@link DOMRegionContext#XML_TAG_ATTRIBUTE_VALUE}
     * contain XML quotes. This removes the quotes (either single or double quotes).
     *
     * @param attrValue The attribute value, as extracted by
     *                  {@link IStructuredDocumentRegion#getText(ITextRegion)}.
     *                  Must not be null.
     * @return The attribute value, without quotes. Whitespace is not trimmed, if any.
     *         String may be empty, but not null.
     */
    static String unquoteAttrValue(String attrValue) {
        int len = attrValue.length();
        int len1 = len - 1;
        if (len >= 2 &&
                attrValue.charAt(0) == '"' &&
                attrValue.charAt(len1) == '"') {
            attrValue = attrValue.substring(1, len1);
        } else if (len >= 2 &&
                attrValue.charAt(0) == '\'' &&
                attrValue.charAt(len1) == '\'') {
            attrValue = attrValue.substring(1, len1);
        }

        return attrValue;
    }

    /**
     * Validates that the attribute accepts a string reference.
     * This sets mTokenString to null by side-effect when it fails and
     * adds a fatal error to the status as needed.
     */
    private void validateSelectedAttribute(AndroidXmlEditor editor, Node node,
            String attrName, RefactoringStatus status) {
        UiElementNode rootUiNode = editor.getUiRootNode();
        UiElementNode currentUiNode =
            rootUiNode == null ? null : rootUiNode.findXmlNode(node);
        ReferenceAttributeDescriptor attrDesc = null;

        if (currentUiNode != null) {
            // remove any namespace prefix from the attribute name
            String name = attrName;
            int pos = name.indexOf(':');
            if (pos > 0 && pos < name.length() - 1) {
                name = name.substring(pos + 1);
            }

            for (UiAttributeNode attrNode : currentUiNode.getAllUiAttributes()) {
                if (attrNode.getDescriptor().getXmlLocalName().equals(name)) {
                    AttributeDescriptor desc = attrNode.getDescriptor();
                    if (desc instanceof ReferenceAttributeDescriptor) {
                        attrDesc = (ReferenceAttributeDescriptor) desc;
                    }
                    break;
                }
            }
        }

        // The attribute descriptor is a resource reference. It must either accept
        // of any resource type or specifically accept string types.
        if (attrDesc != null &&
                (attrDesc.getResourceType() == null ||
                 attrDesc.getResourceType() == ResourceType.STRING)) {
            // We have one more check to do: is the current string value already
            // an Android XML string reference? If so, we can't edit it.
            if (mTokenString != null && mTokenString.startsWith("@")) {                             //$NON-NLS-1$
                int pos1 = 0;
                if (mTokenString.length() > 1 && mTokenString.charAt(1) == '+') {
                    pos1++;
                }
                int pos2 = mTokenString.indexOf('/');
                if (pos2 > pos1) {
                    String kind = mTokenString.substring(pos1 + 1, pos2);
                    if (ResourceType.STRING.getName().equals(kind)) {
                        mTokenString = null;
                        status.addFatalError(String.format(
                                "The attribute %1$s already contains a %2$s reference.",
                                attrName,
                                kind));
                    }
                }
            }

            if (mTokenString != null) {
                // We're done with all our checks. mTokenString contains the
                // current attribute value. We don't memorize the region nor the
                // attribute, however we memorize the textual attribute name so
                // that we can offer replacement for all its occurrences.
                mXmlAttributeName = attrName;
            }

        } else {
            mTokenString = null;
            status.addFatalError(String.format(
                    "The attribute %1$s does not accept a string reference.",
                    attrName));
        }
    }

    /**
     * Tests from org.eclipse.jdt.internal.corext.refactoringChecks#validateEdit()
     * Might not be useful.
     *
     * On success, advance the monitor by 2.
     *
     * @return False if caller should abort, true if caller should continue.
     */
    private boolean checkSourceFile(IFile file,
            RefactoringStatus status,
            IProgressMonitor monitor) {
        // check whether the source file is in sync
        if (!file.isSynchronized(IResource.DEPTH_ZERO)) {
            status.addFatalError("The file is not synchronized. Please save it first.");
            return false;
        }
        monitor.worked(1);

        // make sure we can write to it.
        ResourceAttributes resAttr = file.getResourceAttributes();
        if (resAttr == null || resAttr.isReadOnly()) {
            status.addFatalError("The file is read-only, please make it writeable first.");
            return false;
        }
        monitor.worked(1);

        return true;
    }

    /**
     * Step 2 of 3 of the refactoring:
     * Check the conditions once the user filled values in the refactoring wizard,
     * then prepare the changes to be applied.
     * <p/>
     * In this case, most of the sanity checks are done by the wizard so essentially this
     * should only be called if the wizard positively validated the user input.
     *
     * Here we do check that the target resource XML file either does not exists or
     * is not read-only.
     *
     * @see org.eclipse.ltk.core.refactoring.Refactoring#checkFinalConditions(IProgressMonitor)
     *
     * @throws CoreException
     */
    @Override
    public RefactoringStatus checkFinalConditions(IProgressMonitor monitor)
            throws CoreException, OperationCanceledException {
        RefactoringStatus status = new RefactoringStatus();

        try {
            monitor.beginTask("Checking post-conditions...", 5);

            if (mXmlStringId == null || mXmlStringId.length() <= 0) {
                // this is not supposed to happen
                status.addFatalError("Missing replacement string ID");
            } else if (mTargetXmlFileWsPath == null || mTargetXmlFileWsPath.length() <= 0) {
                // this is not supposed to happen
                status.addFatalError("Missing target xml file path");
            }
            monitor.worked(1);

            // Either that resource must not exist or it must be a writable file.
            IResource targetXml = getTargetXmlResource(mTargetXmlFileWsPath);
            if (targetXml != null) {
                if (targetXml.getType() != IResource.FILE) {
                    status.addFatalError(
                            String.format("XML file '%1$s' is not a file.", mTargetXmlFileWsPath));
                } else {
                    ResourceAttributes attr = targetXml.getResourceAttributes();
                    if (attr != null && attr.isReadOnly()) {
                        status.addFatalError(
                                String.format("XML file '%1$s' is read-only.",
                                        mTargetXmlFileWsPath));
                    }
                }
            }
            monitor.worked(1);

            if (status.hasError()) {
                return status;
            }

            mChanges = new ArrayList<Change>();


            // Prepare the change to create/edit the String ID in the res/values XML file.
            if (!mXmlStringValue.equals(
                    mXmlHelper.valueOfStringId(mProject, mTargetXmlFileWsPath, mXmlStringId))) {
                // We actually change it only if the ID doesn't exist yet or has a different value
                Change change = createXmlChanges((IFile) targetXml, mXmlStringId, mXmlStringValue,
                        status, SubMonitor.convert(monitor, 1));
                if (change != null) {
                    mChanges.add(change);
                }
            }

            if (status.hasError()) {
                return status;
            }

            if (mMode == Mode.EDIT_SOURCE) {
                List<Change> changes = null;
                if (mXmlAttributeName != null) {
                    // Prepare the change to the Android resource XML file
                    changes = computeXmlSourceChanges(mFile,
                            mXmlStringId,
                            mTokenString,
                            mXmlAttributeName,
                            true, // allConfigurations
                            status,
                            monitor);

                } else if (mUnit != null) {
                    // Prepare the change to the Java compilation unit
                    changes = computeJavaChanges(mUnit, mXmlStringId, mTokenString,
                            status, SubMonitor.convert(monitor, 1));
                }
                if (changes != null) {
                    mChanges.addAll(changes);
                }
            }

            if (mReplaceAllJava) {
                String currentIdentifier = mUnit != null ? mUnit.getHandleIdentifier() : ""; //$NON-NLS-1$

                SubMonitor submon = SubMonitor.convert(monitor, 1);
                for (ICompilationUnit unit : findAllJavaUnits()) {
                    // Only process Java compilation units that exist, are not derived
                    // and are not read-only.
                    if (unit == null || !unit.exists()) {
                        continue;
                    }
                    IResource resource = unit.getResource();
                    if (resource == null || resource.isDerived()) {
                        continue;
                    }

                    // Ensure that we don't process the current compilation unit (processed
                    // as mUnit above) twice
                    if (currentIdentifier.equals(unit.getHandleIdentifier())) {
                        continue;
                    }

                    ResourceAttributes attrs = resource.getResourceAttributes();
                    if (attrs != null && attrs.isReadOnly()) {
                        continue;
                    }

                    List<Change> changes = computeJavaChanges(
                            unit, mXmlStringId, mTokenString,
                            status, SubMonitor.convert(submon, 1));
                    if (changes != null) {
                        mChanges.addAll(changes);
                    }
                }
            }

            if (mReplaceAllXml) {
                SubMonitor submon = SubMonitor.convert(monitor, 1);
                for (IFile xmlFile : findAllResXmlFiles()) {
                    if (xmlFile != null) {
                        List<Change> changes = computeXmlSourceChanges(xmlFile,
                                mXmlStringId,
                                mTokenString,
                                mXmlAttributeName,
                                false, // allConfigurations
                                status,
                                SubMonitor.convert(submon, 1));
                        if (changes != null) {
                            mChanges.addAll(changes);
                        }
                    }
                }
            }

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

        return status;
    }

    // --- XML changes ---

    /**
     * Returns a foreach-compatible iterator over all XML files in the project's
     * /res folder, excluding the target XML file (the one where we'll write/edit
     * the string id).
     */
    private Iterable<IFile> findAllResXmlFiles() {
        return new Iterable<IFile>() {
            public Iterator<IFile> iterator() {
                return new Iterator<IFile>() {
                    final Queue<IFile> mFiles = new LinkedList<IFile>();
                    final Queue<IResource> mFolders = new LinkedList<IResource>();
                    IPath mFilterPath1 = null;
                    IPath mFilterPath2 = null;
                    {
                        // Filter out the XML file where we'll be writing the XML string id.
                        IResource filterRes = mProject.findMember(mTargetXmlFileWsPath);
                        if (filterRes != null) {
                            mFilterPath1 = filterRes.getFullPath();
                        }
                        // Filter out the XML source file, if any (e.g. typically a layout)
                        if (mFile != null) {
                            mFilterPath2 = mFile.getFullPath();
                        }

                        // We want to process the manifest
                        IResource man = mProject.findMember("AndroidManifest.xml"); // TODO find a constant
                        if (man.exists() && man instanceof IFile && !man.equals(mFile)) {
                            mFiles.add((IFile) man);
                        }

                        // Add all /res folders (technically we don't need to process /res/values
                        // XML files that contain resources/string elements, but it's easier to
                        // not filter them out.)
                        IFolder f = mProject.getFolder(AdtConstants.WS_RESOURCES);
                        if (f.exists()) {
                            try {
                                mFolders.addAll(
                                        Arrays.asList(f.members(IContainer.EXCLUDE_DERIVED)));
                            } catch (CoreException e) {
                                // pass
                            }
                        }
                    }

                    public boolean hasNext() {
                        if (!mFiles.isEmpty()) {
                            return true;
                        }

                        while (!mFolders.isEmpty()) {
                            IResource res = mFolders.poll();
                            if (res.exists() && res instanceof IFolder) {
                                IFolder f = (IFolder) res;
                                try {
                                    getFileList(f);
                                    if (!mFiles.isEmpty()) {
                                        return true;
                                    }
                                } catch (CoreException e) {
                                    // pass
                                }
                            }
                        }
                        return false;
                    }

                    private void getFileList(IFolder folder) throws CoreException {
                        for (IResource res : folder.members(IContainer.EXCLUDE_DERIVED)) {
                            // Only accept file resources which are not derived and actually exist
                            if (res.exists() && !res.isDerived() && res instanceof IFile) {
                                IFile file = (IFile) res;
                                // Must have an XML extension
                                if (AdtConstants.EXT_XML.equals(file.getFileExtension())) {
                                    IPath p = file.getFullPath();
                                    // And not be either paths we want to filter out
                                    if ((mFilterPath1 != null && mFilterPath1.equals(p)) ||
                                            (mFilterPath2 != null && mFilterPath2.equals(p))) {
                                        continue;
                                    }
                                    mFiles.add(file);
                                }
                            }
                        }
                    }

                    public IFile next() {
                        IFile file = mFiles.poll();
                        hasNext();
                        return file;
                    }

                    public void remove() {
                        throw new UnsupportedOperationException(
                            "This iterator does not support removal");  //$NON-NLS-1$
                    }
                };
            }
        };
    }

    /**
     * Internal helper that actually prepares the {@link Change} that adds the given
     * ID to the given XML File.
     * <p/>
     * This does not actually modify the file.
     *
     * @param targetXml The file resource to modify.
     * @param xmlStringId The new ID to insert.
     * @param tokenString The old string, which will be the value in the XML string.
     * @return A new {@link TextEdit} that describes how to change the file.
     */
    private Change createXmlChanges(IFile targetXml,
            String xmlStringId,
            String tokenString,
            RefactoringStatus status,
            SubMonitor monitor) {

        TextFileChange xmlChange = new TextFileChange(getName(), targetXml);
        xmlChange.setTextType(AdtConstants.EXT_XML);

        String error = "";                  //$NON-NLS-1$
        TextEdit edit = null;
        TextEditGroup editGroup = null;

        try {
            if (!targetXml.exists()) {
                // Kludge: use targetXml==null as a signal this is a new file being created
                targetXml = null;
            }

            edit = createXmlReplaceEdit(targetXml, xmlStringId, tokenString, status,
                    SubMonitor.convert(monitor, 1));
        } catch (IOException e) {
            error = e.toString();
        } catch (CoreException e) {
            // Failed to read file. Ignore. Will handle error below.
            error = e.toString();
        }

        if (edit == null) {
            status.addFatalError(String.format("Failed to modify file %1$s%2$s",
                    targetXml == null ? "" : targetXml.getFullPath(),   //$NON-NLS-1$
                    error == null ? "" : ": " + error));                //$NON-NLS-1$
            return null;
        }

        editGroup = new TextEditGroup(targetXml == null ? "Create <string> in new XML file"
                                                        : "Insert <string> in XML file",
                                      edit);

        xmlChange.setEdit(edit);
        // The TextEditChangeGroup let the user toggle this change on and off later.
        xmlChange.addTextEditChangeGroup(new TextEditChangeGroup(xmlChange, editGroup));

        monitor.worked(1);
        return xmlChange;
    }

    /**
     * Scan the XML file to find the best place where to insert the new string element.
     * <p/>
     * This handles a variety of cases, including replacing existing ids in place,
     * adding the top resources element if missing and the XML PI if not present.
     * It tries to preserve indentation when adding new elements at the end of an existing XML.
     *
     * @param file The XML file to modify, that must be present in the workspace.
     *             Pass null to create a change for a new file that doesn't exist yet.
     * @param xmlStringId The new ID to insert.
     * @param tokenString The old string, which will be the value in the XML string.
     * @param status The in-out refactoring status. Used to log a more detailed error if the
     *          XML has a top element that is not a resources element.
     * @param monitor A monitor to track progress.
     * @return A new {@link TextEdit} for either a replace or an insert operation, or null in case
     *          of error.
     * @throws CoreException - if the file's contents or description can not be read.
     * @throws IOException   - if the file's contents can not be read or its detected encoding does
     *                         not support its contents.
     */
    private TextEdit createXmlReplaceEdit(IFile file,
            String xmlStringId,
            String tokenString,
            RefactoringStatus status,
            SubMonitor monitor)
                throws IOException, CoreException {

        IModelManager modelMan = StructuredModelManager.getModelManager();

        final String NODE_RESOURCES = ResourcesDescriptors.ROOT_ELEMENT;
        final String NODE_STRING = "string";    //$NON-NLS-1$ //TODO find or create constant
        final String ATTR_NAME = "name";        //$NON-NLS-1$ //TODO find or create constant


        // Scan the source to find the best insertion point.

        // 1- The most common case we need to handle is the one of inserting at the end
        //    of a valid XML document, respecting the whitespace last used.
        //
        // Ideally we have this structure:
        // <xml ...>
        // <resource>
        // ...ws1...<string>blah</string>...ws2...
        // </resource>
        //
        // where ws1 and ws2 are the whitespace respectively before and after the last element
        // just before the closing </resource>.
        // In this case we want to generate the new string just before ws2...</resource> with
        // the same whitespace as ws1.
        //
        // 2- Another expected case is there's already an existing string which "name" attribute
        //    equals to xmlStringId and we just want to replace its value.
        //
        // Other cases we need to handle:
        // 3- There is no element at all -> create a full new <resource>+<string> content.
        // 4- There is <resource/>, that is the tag is not opened. This can be handled as the
        //    previous case, generating full content but also replacing <resource/>.
        // 5- There is a top element that is not <resource>. That's a fatal error and we abort.

        IStructuredModel smodel = null;

        // Single and double quotes must be escaped in the <string>value</string> declaration
        tokenString = escapeString(tokenString);

        try {
            IStructuredDocument sdoc = null;
            boolean checkTopElement = true;
            boolean replaceStringContent = false;
            boolean hasPiXml = false;
            int newResStart = 0;
            int newResLength = 0;
            String lineSep = "\n";                  //$NON-NLS-1$

            if (file != null) {
                smodel = modelMan.getExistingModelForRead(file);
                if (smodel != null) {
                    sdoc = smodel.getStructuredDocument();
                } else if (smodel == null) {
                    // The model is not currently open.
                    if (file.exists()) {
                        sdoc = modelMan.createStructuredDocumentFor(file);
                    } else {
                        sdoc = modelMan.createNewStructuredDocumentFor(file);
                    }
                }
            }

            if (sdoc == null && file != null) {
                // Get a document matching the actual saved file
                sdoc = modelMan.createStructuredDocumentFor(file);
            }

            if (sdoc != null) {
                String wsBefore = "";   //$NON-NLS-1$
                String lastWs = null;

                lineSep = sdoc.getLineDelimiter();
                if (lineSep == null || lineSep.length() == 0) {
                    // That wasn't too useful, let's go back to a reasonable default
                    lineSep = "\n"; //$NON-NLS-1$
                }

                for (IStructuredDocumentRegion regions : sdoc.getStructuredDocumentRegions()) {
                    String type = regions.getType();

                    if (DOMRegionContext.XML_CONTENT.equals(type)) {

                        if (replaceStringContent) {
                            // Generate a replacement for a <string> value matching the string ID.
                            return new ReplaceEdit(
                                    regions.getStartOffset(), regions.getLength(), tokenString);
                        }

                        // Otherwise capture what should be whitespace content
                        lastWs = regions.getFullText();
                        continue;

                    } else if (DOMRegionContext.XML_PI_OPEN.equals(type) && !hasPiXml) {

                        int nb = regions.getNumberOfRegions();
                        ITextRegionList list = regions.getRegions();
                        for (int i = 0; i < nb; i++) {
                            ITextRegion region = list.get(i);
                            type = region.getType();
                            if (DOMRegionContext.XML_TAG_NAME.equals(type)) {
                                String name = regions.getText(region);
                                if ("xml".equals(name)) {   //$NON-NLS-1$
                                    hasPiXml = true;
                                    break;
                                }
                            }
                        }
                        continue;

                    } else if (!DOMRegionContext.XML_TAG_NAME.equals(type)) {
                        // ignore things which are not a tag nor text content (such as comments)
                        continue;
                    }

                    int nb = regions.getNumberOfRegions();
                    ITextRegionList list = regions.getRegions();

                    String name = null;
                    String attrName = null;
                    String attrValue = null;
                    boolean isEmptyTag = false;
                    boolean isCloseTag = false;

                    for (int i = 0; i < nb; i++) {
                        ITextRegion region = list.get(i);
                        type = region.getType();

                        if (DOMRegionContext.XML_END_TAG_OPEN.equals(type)) {
                            isCloseTag = true;
                        } else if (DOMRegionContext.XML_EMPTY_TAG_CLOSE.equals(type)) {
                            isEmptyTag = true;
                        } else if (DOMRegionContext.XML_TAG_NAME.equals(type)) {
                            name = regions.getText(region);
                        } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type) &&
                                NODE_STRING.equals(name)) {
                            // Record the attribute names into a <string> element.
                            attrName = regions.getText(region);
                        } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type) &&
                                ATTR_NAME.equals(attrName)) {
                            // Record the value of a <string name=...> attribute
                            attrValue = regions.getText(region);

                            if (attrValue != null &&
                                    unquoteAttrValue(attrValue).equals(xmlStringId)) {
                                // We found a <string name=> matching the string ID to replace.
                                // We'll generate a replacement when we process the string value
                                // (that is the next XML_CONTENT region.)
                                replaceStringContent = true;
                            }
                        }
                    }

                    if (checkTopElement) {
                        // Check the top element has a resource name
                        checkTopElement = false;
                        if (!NODE_RESOURCES.equals(name)) {
                            status.addFatalError(
                                    String.format("XML file lacks a <resource> tag: %1$s",
                                            mTargetXmlFileWsPath));
                            return null;

                        }

                        if (isEmptyTag) {
                            // The top element is an empty "<resource/>" tag. We need to do
                            // a full new resource+string replacement.
                            newResStart = regions.getStartOffset();
                            newResLength = regions.getLength();
                        }
                    }

                    if (NODE_RESOURCES.equals(name)) {
                        if (isCloseTag) {
                            // We found the </resource> tag and we want
                            // to insert just before this one.

                            StringBuilder content = new StringBuilder();
                            content.append(wsBefore)
                                   .append("<string name=\"")                   //$NON-NLS-1$
                                   .append(xmlStringId)
                                   .append("\">")                               //$NON-NLS-1$
                                   .append(tokenString)
                                   .append("</string>");                        //$NON-NLS-1$

                            // Backup to insert before the whitespace preceding </resource>
                            IStructuredDocumentRegion insertBeforeReg = regions;
                            while (true) {
                                IStructuredDocumentRegion previous = insertBeforeReg.getPrevious();
                                if (previous != null &&
                                        DOMRegionContext.XML_CONTENT.equals(previous.getType()) &&
                                        previous.getText().trim().length() == 0) {
                                    insertBeforeReg = previous;
                                } else {
                                    break;
                                }
                            }
                            if (insertBeforeReg == regions) {
                                // If we have not found any whitespace before </resources>,
                                // at least add a line separator.
                                content.append(lineSep);
                            }

                            return new InsertEdit(insertBeforeReg.getStartOffset(),
                                                  content.toString());
                        }
                    } else {
                        // For any other tag than <resource>, capture whitespace before and after.
                        if (!isCloseTag) {
                            wsBefore = lastWs;
                        }
                    }
                }
            }

            // We reach here either because there's no XML content at all or because
            // there's an empty <resource/>.
            // Provide a full new resource+string replacement.
            StringBuilder content = new StringBuilder();
            if (!hasPiXml) {
                content.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>"); //$NON-NLS-1$
                content.append(lineSep);
            } else if (newResLength == 0 && sdoc != null) {
                // If inserting at the end, check if the last region is some whitespace.
                // If there's no newline, insert one ourselves.
                IStructuredDocumentRegion lastReg = sdoc.getLastStructuredDocumentRegion();
                if (lastReg != null && lastReg.getText().indexOf('\n') == -1) {
                    content.append('\n');
                }
            }

            // FIXME how to access formatting preferences to generate the proper indentation?
            content.append("<resources>").append(lineSep);                  //$NON-NLS-1$
            content.append("    <string name=\"")                           //$NON-NLS-1$
                   .append(xmlStringId)
                   .append("\">")                                           //$NON-NLS-1$
                   .append(tokenString)
                   .append("</string>")                                     //$NON-NLS-1$
                   .append(lineSep);
            content.append("</resources>").append(lineSep);                 //$NON-NLS-1$

            if (newResLength > 0) {
                // Replace existing piece
                return new ReplaceEdit(newResStart, newResLength, content.toString());
            } else {
                // Insert at the end.
                int offset = sdoc == null ? 0 : sdoc.getLength();
                return new InsertEdit(offset, content.toString());
            }
        } catch (IOException e) {
            // This is expected to happen and is properly reported to the UI.
            throw e;
        } catch (CoreException e) {
            // This is expected to happen and is properly reported to the UI.
            throw e;
        } catch (Throwable t) {
            // Since we use some internal APIs, use a broad catch-all to report any
            // unexpected issue rather than crash the whole refactoring.
            status.addFatalError(
                    String.format("XML replace error: %1$s", t.getMessage()));
        } finally {
            if (smodel != null) {
                smodel.releaseFromRead();
            }
        }

        return null;
    }

    /**
     * Escape a string value to be placed in a string resource file such that it complies with
     * the escaping rules described here:
     *   http://developer.android.com/guide/topics/resources/string-resource.html
     * More examples of the escaping rules can be found here:
     *   http://androidcookbook.com/Recipe.seam?recipeId=2219&recipeFrom=ViewTOC
     * This method assumes that the String is not escaped already.
     *
     * Rules:
     * <ul>
     * <li>Double quotes are needed if string starts or ends with at least one space.
     * <li>{@code @, ?} at beginning of string have to be escaped with a backslash.
     * <li>{@code ', ", \} have to be escaped with a backslash.
     * <li>{@code <, >, &} have to be replaced by their predefined xml entity.
     * <li>{@code \n, \t} have to be replaced by a backslash and the appropriate character.
     * </ul>
     * @param s the string to be escaped
     * @return the escaped string as it would appear in the XML text in a values file
     */
    public static String escapeString(String s) {
        int n = s.length();
        if (n == 0) {
            return "";
        }

        StringBuilder sb = new StringBuilder(s.length() * 2);
        boolean hasSpace = s.charAt(0) == ' ' || s.charAt(n - 1) == ' ';

        if (hasSpace) {
            sb.append('"');
        } else if (s.charAt(0) == '@' || s.charAt(0) == '?') {
            sb.append('\\');
        }

        for (int i = 0; i < n; ++i) {
            char c = s.charAt(i);
            switch (c) {
                case '\'':
                    if (!hasSpace) {
                        sb.append('\\');
                    }
                    sb.append(c);
                    break;
                case '"':
                case '\\':
                    sb.append('\\');
                    sb.append(c);
                    break;
                case '<':
                    sb.append("&lt;"); //$NON-NLS-1$
                    break;
                case '&':
                    sb.append("&amp;"); //$NON-NLS-1$
                    break;
                case '\n':
                    sb.append("\\n"); //$NON-NLS-1$
                    break;
                case '\t':
                    sb.append("\\t"); //$NON-NLS-1$
                    break;
                default:
                    sb.append(c);
                    break;
            }
        }

        if (hasSpace) {
            sb.append('"');
        }

        return sb.toString();
    }

    /**
     * Computes the changes to be made to the source Android XML file and
     * returns a list of {@link Change}.
     * <p/>
     * This function scans an XML file, looking for an attribute value equals to
     * <code>tokenString</code>. If non null, <code>xmlAttrName</code> limit the search
     * to only attributes that have that name.
     * If found, a change is made to replace each occurrence of <code>tokenString</code>
     * by a new "@string/..." using the new <code>xmlStringId</code>.
     *
     * @param sourceFile The file to process.
     *          A status error will be generated if it does not exists.
     *          Must not be null.
     * @param tokenString The string to find. Must not be null or empty.
     * @param xmlAttrName Optional attribute name to limit the search. Can be null.
     * @param allConfigurations True if this function should can all XML files with the same
     *          name and the same resource type folder but with different configurations.
     * @param status Status used to report fatal errors.
     * @param monitor Used to log progress.
     */
    private List<Change> computeXmlSourceChanges(IFile sourceFile,
            String xmlStringId,
            String tokenString,
            String xmlAttrName,
            boolean allConfigurations,
            RefactoringStatus status,
            IProgressMonitor monitor) {

        if (!sourceFile.exists()) {
            status.addFatalError(String.format("XML file '%1$s' does not exist.",
                    sourceFile.getFullPath().toOSString()));
            return null;
        }

        // We shouldn't be trying to replace a null or empty string.
        assert tokenString != null && tokenString.length() > 0;
        if (tokenString == null || tokenString.length() == 0) {
            return null;
        }

        // Note: initially this method was only processing files using a pattern
        //   /project/res/<type>-<configuration>/<filename.xml>
        // However the last version made that more generic to be able to process any XML
        // files. We should probably revisit and simplify this later.
        HashSet<IFile> files = new HashSet<IFile>();
        files.add(sourceFile);

        if (allConfigurations && AdtConstants.EXT_XML.equals(sourceFile.getFileExtension())) {
            IPath path = sourceFile.getFullPath();
            if (path.segmentCount() == 4 && path.segment(1).equals(SdkConstants.FD_RESOURCES)) {
                IProject project = sourceFile.getProject();
                String filename = path.segment(3);
                String initialTypeName = path.segment(2);
                ResourceFolderType type = ResourceFolderType.getFolderType(initialTypeName);

                IContainer res = sourceFile.getParent().getParent();
                if (type != null && res != null && res.getType() == IResource.FOLDER) {
                    try {
                        for (IResource r : res.members()) {
                            if (r != null && r.getType() == IResource.FOLDER) {
                                String name = r.getName();
                                // Skip the initial folder name, it's already in the list.
                                if (!name.equals(initialTypeName)) {
                                    // Only accept the same folder type (e.g. layout-*)
                                    ResourceFolderType t =
                                        ResourceFolderType.getFolderType(name);
                                    if (type.equals(t)) {
                                        // recompute the path
                                        IPath p = res.getProjectRelativePath().append(name).
                                                                               append(filename);
                                        IResource f = project.findMember(p);
                                        if (f != null && f instanceof IFile) {
                                            files.add((IFile) f);
                                        }
                                    }
                                }
                            }
                        }
                    } catch (CoreException e) {
                        // Ignore.
                    }
                }
            }
        }

        SubMonitor subMonitor = SubMonitor.convert(monitor, Math.min(1, files.size()));

        ArrayList<Change> changes = new ArrayList<Change>();

        // Portability note: getModelManager is part of wst.sse.core however the
        // interface returned is part of wst.sse.core.internal.provisional so we can
        // expect it to change in a distant future if they start cleaning their codebase,
        // however unlikely that is.
        IModelManager modelManager = StructuredModelManager.getModelManager();

        for (IFile file : files) {

            IStructuredModel smodel = null;
            MultiTextEdit multiEdit = null;
            TextFileChange xmlChange = null;
            ArrayList<TextEditGroup> editGroups = null;

            try {
                IStructuredDocument sdoc = null;

                smodel = modelManager.getExistingModelForRead(file);
                if (smodel != null) {
                    sdoc = smodel.getStructuredDocument();
                } else if (smodel == null) {
                    // The model is not currently open.
                    if (file.exists()) {
                        sdoc = modelManager.createStructuredDocumentFor(file);
                    } else {
                        sdoc = modelManager.createNewStructuredDocumentFor(file);
                    }
                }

                if (sdoc == null) {
                    status.addFatalError("XML structured document not found");     //$NON-NLS-1$
                    continue;
                }

                multiEdit = new MultiTextEdit();
                editGroups = new ArrayList<TextEditGroup>();
                xmlChange = new TextFileChange(getName(), file);
                xmlChange.setTextType("xml");   //$NON-NLS-1$

                String quotedReplacement = quotedAttrValue(STRING_PREFIX + xmlStringId);

                // Prepare the change set
                for (IStructuredDocumentRegion regions : sdoc.getStructuredDocumentRegions()) {
                    // Only look at XML "top regions"
                    if (!DOMRegionContext.XML_TAG_NAME.equals(regions.getType())) {
                        continue;
                    }

                    int nb = regions.getNumberOfRegions();
                    ITextRegionList list = regions.getRegions();
                    String lastAttrName = null;

                    for (int i = 0; i < nb; i++) {
                        ITextRegion subRegion = list.get(i);
                        String type = subRegion.getType();

                        if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) {
                            // Memorize the last attribute name seen
                            lastAttrName = regions.getText(subRegion);

                        } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) {
                            // Check this is the attribute and the original string
                            String text = regions.getText(subRegion);

                            // Remove " or ' quoting present in the attribute value
                            text = unquoteAttrValue(text);

                            if (tokenString.equals(text) &&
                                    (xmlAttrName == null || xmlAttrName.equals(lastAttrName))) {

                                // Found an occurrence. Create a change for it.
                                TextEdit edit = new ReplaceEdit(
                                        regions.getStartOffset() + subRegion.getStart(),
                                        subRegion.getTextLength(),
                                        quotedReplacement);
                                TextEditGroup editGroup = new TextEditGroup(
                                        "Replace attribute string by ID",
                                        edit);

                                multiEdit.addChild(edit);
                                editGroups.add(editGroup);
                            }
                        }
                    }
                }
            } catch (Throwable t) {
                // Since we use some internal APIs, use a broad catch-all to report any
                // unexpected issue rather than crash the whole refactoring.
                status.addFatalError(
                        String.format("XML refactoring error: %1$s", t.getMessage()));
            } finally {
                if (smodel != null) {
                    smodel.releaseFromRead();
                }

                if (multiEdit != null &&
                        xmlChange != null &&
                        editGroups != null &&
                        multiEdit.hasChildren()) {
                    xmlChange.setEdit(multiEdit);
                    for (TextEditGroup group : editGroups) {
                        xmlChange.addTextEditChangeGroup(
                                new TextEditChangeGroup(xmlChange, group));
                    }
                    changes.add(xmlChange);
                }
                subMonitor.worked(1);
            }
        } // for files

        if (changes.size() > 0) {
            return changes;
        }
        return null;
    }

    /**
     * Returns a quoted attribute value suitable to be placed after an attributeName=
     * statement in an XML stream.
     *
     * According to http://www.w3.org/TR/2008/REC-xml-20081126/#NT-AttValue
     * the attribute value can be either quoted using ' or " and the corresponding
     * entities &apos; or &quot; must be used inside.
     */
    private String quotedAttrValue(String attrValue) {
        if (attrValue.indexOf('"') == -1) {
            // no double-quotes inside, use double-quotes around.
            return '"' + attrValue + '"';
        }
        if (attrValue.indexOf('\'') == -1) {
            // no single-quotes inside, use single-quotes around.
            return '\'' + attrValue + '\'';
        }
        // If we get here, there's a mix. Opt for double-quote around and replace
        // inner double-quotes.
        attrValue = attrValue.replace("\"", "&quot;");  //$NON-NLS-1$ //$NON-NLS-2$
        return '"' + attrValue + '"';
    }

    // --- Java changes ---

    /**
     * Returns a foreach compatible iterator over all ICompilationUnit in the project.
     */
    private Iterable<ICompilationUnit> findAllJavaUnits() {
        final IJavaProject javaProject = JavaCore.create(mProject);

        return new Iterable<ICompilationUnit>() {
            public Iterator<ICompilationUnit> iterator() {
                return new Iterator<ICompilationUnit>() {
                    final Queue<ICompilationUnit> mUnits = new LinkedList<ICompilationUnit>();
                    final Queue<IPackageFragment> mFragments = new LinkedList<IPackageFragment>();
                    {
                        try {
                            IPackageFragment[] tmpFrags = javaProject.getPackageFragments();
                            if (tmpFrags != null && tmpFrags.length > 0) {
                                mFragments.addAll(Arrays.asList(tmpFrags));
                            }
                        } catch (JavaModelException e) {
                            // pass
                        }
                    }

                    public boolean hasNext() {
                        if (!mUnits.isEmpty()) {
                            return true;
                        }

                        while (!mFragments.isEmpty()) {
                            try {
                                IPackageFragment fragment = mFragments.poll();
                                if (fragment.getKind() == IPackageFragmentRoot.K_SOURCE) {
                                    ICompilationUnit[] tmpUnits = fragment.getCompilationUnits();
                                    if (tmpUnits != null && tmpUnits.length > 0) {
                                        mUnits.addAll(Arrays.asList(tmpUnits));
                                        return true;
                                    }
                                }
                            } catch (JavaModelException e) {
                                // pass
                            }
                        }
                        return false;
                    }

                    public ICompilationUnit next() {
                        ICompilationUnit unit = mUnits.poll();
                        hasNext();
                        return unit;
                    }

                    public void remove() {
                        throw new UnsupportedOperationException(
                                "This iterator does not support removal");  //$NON-NLS-1$
                    }
                };
            }
        };
    }

    /**
     * Computes the changes to be made to Java file(s) and returns a list of {@link Change}.
     * <p/>
     * This function scans a Java compilation unit using {@link ReplaceStringsVisitor}, looking
     * for a string literal equals to <code>tokenString</code>.
     * If found, a change is made to replace each occurrence of <code>tokenString</code> by
     * a piece of Java code that somehow accesses R.string.<code>xmlStringId</code>.
     *
     * @param unit The compilated unit to process. Must not be null.
     * @param tokenString The string to find. Must not be null or empty.
     * @param status Status used to report fatal errors.
     * @param monitor Used to log progress.
     */
    private List<Change> computeJavaChanges(ICompilationUnit unit,
            String xmlStringId,
            String tokenString,
            RefactoringStatus status,
            SubMonitor monitor) {

        // We shouldn't be trying to replace a null or empty string.
        assert tokenString != null && tokenString.length() > 0;
        if (tokenString == null || tokenString.length() == 0) {
            return null;
        }

        // Get the Android package name from the Android Manifest. We need it to create
        // the FQCN of the R class.
        String packageName = null;
        String error = null;
        IResource manifestFile = mProject.findMember(SdkConstants.FN_ANDROID_MANIFEST_XML);
        if (manifestFile == null || manifestFile.getType() != IResource.FILE) {
            error = "File not found";
        } else {
            ManifestData manifestData = AndroidManifestHelper.parseForData((IFile) manifestFile);
            if (manifestData == null) {
                error = "Invalid content";
            } else {
                packageName = manifestData.getPackage();
                if (packageName == null) {
                    error = "Missing package definition";
                }
            }
        }

        if (error != null) {
            status.addFatalError(
                    String.format("Failed to parse file %1$s: %2$s.",
                            manifestFile == null ? "" : manifestFile.getFullPath(),  //$NON-NLS-1$
                            error));
            return null;
        }

        // Right now the changes array will contain one TextFileChange at most.
        ArrayList<Change> changes = new ArrayList<Change>();

        // This is the unit that will be modified.
        TextFileChange change = new TextFileChange(getName(), (IFile) unit.getResource());
        change.setTextType("java"); //$NON-NLS-1$

        // Create an AST for this compilation unit
        ASTParser parser = ASTParser.newParser(AST.JLS3);
        parser.setProject(unit.getJavaProject());
        parser.setSource(unit);
        parser.setResolveBindings(true);
        ASTNode node = parser.createAST(monitor.newChild(1));

        // The ASTNode must be a CompilationUnit, by design
        if (!(node instanceof CompilationUnit)) {
            status.addFatalError(String.format("Internal error: ASTNode class %s",  //$NON-NLS-1$
                    node.getClass()));
            return null;
        }

        // ImportRewrite will allow us to add the new type to the imports and will resolve
        // what the Java source must reference, e.g. the FQCN or just the simple name.
        ImportRewrite importRewrite = ImportRewrite.create((CompilationUnit) node, true);
        String Rqualifier = packageName + ".R"; //$NON-NLS-1$
        Rqualifier = importRewrite.addImport(Rqualifier);

        // Rewrite the AST itself via an ASTVisitor
        AST ast = node.getAST();
        ASTRewrite astRewrite = ASTRewrite.create(ast);
        ArrayList<TextEditGroup> astEditGroups = new ArrayList<TextEditGroup>();
        ReplaceStringsVisitor visitor = new ReplaceStringsVisitor(
                ast, astRewrite, astEditGroups,
                tokenString, Rqualifier, xmlStringId);
        node.accept(visitor);

        // Finally prepare the change set
        try {
            MultiTextEdit edit = new MultiTextEdit();

            // Create the edit to change the imports, only if anything changed
            TextEdit subEdit = importRewrite.rewriteImports(monitor.newChild(1));
            if (subEdit.hasChildren()) {
                edit.addChild(subEdit);
            }

            // Create the edit to change the Java source, only if anything changed
            subEdit = astRewrite.rewriteAST();
            if (subEdit.hasChildren()) {
                edit.addChild(subEdit);
            }

            // Only create a change set if any edit was collected
            if (edit.hasChildren()) {
                change.setEdit(edit);

                // Create TextEditChangeGroups which let the user turn changes on or off
                // individually. This must be done after the change.setEdit() call above.
                for (TextEditGroup editGroup : astEditGroups) {
                    TextEditChangeGroup group = new TextEditChangeGroup(change, editGroup);
                    if (editGroup instanceof EnabledTextEditGroup) {
                        group.setEnabled(((EnabledTextEditGroup) editGroup).isEnabled());
                    }
                    change.addTextEditChangeGroup(group);
                }

                changes.add(change);
            }

            monitor.worked(1);

            if (changes.size() > 0) {
                return changes;
            }

        } catch (CoreException e) {
            // ImportRewrite.rewriteImports failed.
            status.addFatalError(e.getMessage());
        }
        return null;
    }

    // ----

    /**
     * Step 3 of 3 of the refactoring: returns the {@link Change} that will be able to do the
     * work and creates a descriptor that can be used to replay that refactoring later.
     *
     * @see org.eclipse.ltk.core.refactoring.Refactoring#createChange(org.eclipse.core.runtime.IProgressMonitor)
     *
     * @throws CoreException
     */
    @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() {

                    String comment = String.format(
                            "Extracts string '%1$s' into R.string.%2$s",
                            mTokenString,
                            mXmlStringId);

                    ExtractStringDescriptor desc = new ExtractStringDescriptor(
                            mProject.getName(), //project
                            comment, //description
                            comment, //comment
                            createArgumentMap());

                    return new RefactoringChangeDescriptor(desc);
                }
            };

            monitor.worked(1);

            return change;

        } finally {
            monitor.done();
        }

    }

    /**
     * Given a file project path, returns its resource in the same project than the
     * compilation unit. The resource may not exist.
     */
    private IResource getTargetXmlResource(String xmlFileWsPath) {
        IResource resource = mProject.getFile(xmlFileWsPath);
        return resource;
    }
}
