/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package org.netbeans.modules.apisupport.hints;

import com.sun.source.tree.AnnotationTree;
import com.sun.source.tree.AssignmentTree;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.ModifiersTree;
import com.sun.source.tree.NewArrayTree;
import com.sun.source.tree.Tree;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.RunnableFuture;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.swing.text.Document;
import org.netbeans.api.annotations.common.CheckForNull;
import org.netbeans.api.annotations.common.CheckReturnValue;
import org.netbeans.api.annotations.common.NullAllowed;
import org.netbeans.api.java.classpath.ClassPath;
import org.netbeans.api.java.source.JavaSource;
import org.netbeans.api.java.source.Task;
import org.netbeans.api.java.source.TreeMaker;
import org.netbeans.api.java.source.WorkingCopy;
import org.netbeans.modules.apisupport.project.api.LayerHandle;
import org.netbeans.spi.editor.hints.ChangeInfo;
import org.netbeans.spi.editor.hints.ErrorDescription;
import org.netbeans.spi.editor.hints.ErrorDescriptionFactory;
import org.netbeans.spi.editor.hints.Fix;
import org.netbeans.spi.editor.hints.Severity;
import org.openide.DialogDisplayer;
import org.openide.NotifyDescriptor;
import org.openide.NotifyDescriptor.Message;
import org.openide.cookies.SaveCookie;
import org.openide.filesystems.FileObject;
import org.openide.loaders.DataObject;
import static org.netbeans.modules.apisupport.hints.Bundle.*;
import org.openide.util.NbBundle.Messages;

/**
 * One category of hint.
 * Register implementation into global lookup.
 */
public interface Hinter {

    /**
     * Check for hints.
     * @param ctx context of a single layer entry
     * @throws Exception in case of problem
     */
    void process(Context ctx) throws Exception;

    /**
     * Context supplied to a {@link Hinter}.
     */
    class Context {

        private static final Logger LOG = Logger.getLogger(Hinter.class.getName());

        private final Document doc;
        private final LayerHandle layer;
        private final FileObject file;
        private final RunnableFuture<Map<String,Integer>> lines;
        private final List<? super ErrorDescription> errors;

        Context(Document doc, LayerHandle layer, FileObject file, RunnableFuture<Map<String,Integer>> lines, List<? super ErrorDescription> errors) {
            this.doc = doc;
            this.layer = layer;
            this.file = file;
            this.lines = lines;
            this.errors = errors;
        }

        /**
         * Gets the layer entry you may offer hints for.
         * File attribute names like {@code literal:instanceCreate} may return values like {@code new:pkg.Clazz} or {@code method:pkg.Clazz.factory}.
         * @return a file (or folder) in the project's layer
         */
        public FileObject file() {
            return file;
        }

        /**
         * @return standard description to pass to {@link #addHint}
         */
        @Messages("Hinter.description=Use of layer entry where annotation is available")
        public String standardAnnotationDescription() {
            return Hinter_description();
        }

        /**
         * @return standard fix description to pass to {@link #addHint}
         */
        @Messages("Hinter.fix.description=Convert registration to Java annotation")
        public String standardAnnotationFixDescription() {
            return Hinter_fix_description();
        }

        /**
         * Add a hint.
         * @param severity whether to treat as a warning, etc.
         * @param description description of hint
         * @param fixes any fixes to offer
         * @see #addStandardAnnotationHint
         */
        public void addHint(Severity severity, String description, Fix... fixes) {
            Integer line = null;
            try {
                lines.run();
                line = lines.get().get(file.getPath());
            } catch (Exception x) {
                LOG.log(Level.INFO, null, x);
            }
            if (line != null) {
                errors.add(ErrorDescriptionFactory.createErrorDescription(severity, description, Arrays.asList(fixes), doc, line));
            } else {
                LOG.log(Level.WARNING, "no line found for {0}", file);
            }
        }

        /**
         * Add an annotation-oriented warning hint following the standard pattern.
         * @param fix what to do for a fix (see e.g. {@link #findAndModifyDeclaration}); no change info
         * @see #addHint
         * @see #standardAnnotationDescription
         * @see #standardAnnotationFixDescription
         */
        public void addStandardAnnotationHint(final Callable<Void> fix) {
            addHint(Severity.WARNING, standardAnnotationDescription(), new Fix() {
                public @Override String getText() {
                    return standardAnnotationFixDescription();
                }
                public @Override ChangeInfo implement() throws Exception {
                    fix.call();
                    return null;
                }
            });
        }

        /**
         * Checks whether a given API class can be accessed from the current classpath.
         * This can be used to control whether or not a given hint or fix is enabled.
         * @param api binary name of an API class
         * @return true if it is visible
         */
        public boolean canAccess(String api) {
            ClassPath cp = ClassPath.getClassPath(layer.getLayerFile(), ClassPath.COMPILE);
            if (cp == null) {
                return false;
            }
            return cp.findResource(api.replace('.', '/') + ".class") != null;
        }

        /**
         * Locate the declaration of an object declared as a newvalue or methodvalue attribute.
         * @param instanceAttribute the result of {@link FileObject#getAttribute} on a {@code literal:*} key
         * @return the source file containing the corresponding declaration, or null if not found
         */
        private @CheckForNull FileObject findDeclaringSource(@NullAllowed Object instanceAttribute) {
            if (!(instanceAttribute instanceof String)) {
                return null;
            }
            // XXX this will not find classes in a sister module; maybe look in ClassPath.EXECUTE, then use SFBQ to find it?
            ClassPath src = ClassPath.getClassPath(layer.getLayerFile(), ClassPath.SOURCE); // should work even for Maven src/main/resources/.../layer.xml
            if (src == null) {
                return null;
            }
            String attr = (String) instanceAttribute;
            if (attr.startsWith("new:")) {
                return src.findResource(attr.substring(4).replaceFirst("[$][^.]+$", "").replace('.', '/') + ".java");
            } else if (attr.startsWith("method:")) {
                return src.findResource(attr.substring(7, attr.lastIndexOf('.')).replaceFirst("[$][^.]+$", "").replace('.', '/') + ".java");
            } else {
                return null;
            }
        }

        /**
         * Locate the declaration of an object declared as a newvalue or methodvalue attribute.
         * @param wc context of a modification task
         * @param instanceAttribute the result of {@link FileObject#getAttribute} on a {@code literal:*} key
         * @return the corresponding declaration, or null if not found
         */
        private @CheckForNull Element findDeclaration(WorkingCopy wc, @NullAllowed Object instanceAttribute) {
            if (!(instanceAttribute instanceof String)) {
                return null;
            }
            String attr = (String) instanceAttribute;
            if (attr.startsWith("new:")) {
                return wc.getElements().getTypeElement(attr.substring(4).replace('$', '.'));
            } else if (attr.startsWith("method:")) {
                int dot = attr.lastIndexOf('.');
                TypeElement type = wc.getElements().getTypeElement(attr.substring(7, dot).replace('$', '.'));
                if (type != null) {
                    String meth = attr.substring(dot + 1);
                    for (Element check : type.getEnclosedElements()) {
                        if (check.getKind() == ElementKind.METHOD && check.getSimpleName().contentEquals(meth) && ((ExecutableElement) check).getParameters().isEmpty()) {
                            return check;
                        }
                    }
                }
            }
            return null;
        }

        /**
         * Saves the layer after some modifications to {@link #file()}.
         * @throws IOException if the layer could not be saved
         */
        public void saveLayer() throws IOException {
            layer.save();
        }

        /**
         * Task to be used from {@link #findAndModifyDeclaration}.
         */
        interface ModifyDeclarationTask {
            /**
             * Modify the original declaration.
             * @param wc Java source information
             * @param declaration the {@link TypeElement} or {@link ExecutableElement} that the instance attribute corresponds to
             * @param modifiers modifiers of the declaration that you might wish to add annotations to
             * @throws Exception in case of problem
             */
            void run(WorkingCopy wc, Element declaration, ModifiersTree modifiers) throws Exception;
        }

        /**
         * Tries to find the Java declaration of an instance attribute, and if successful, runs a task to modify it.
         * @param instanceAttribute the result of {@link FileObject#getAttribute} on a {@code literal:*} key (or {@link #instanceAttribute})
         * @param task a task to run (may modify Java sources and layer objects; all will be saved for you)
         * @throws IOException in case of problem (will instead show a message and return early if the type could not be found)
         */
        @Messages({
            "# {0} - layer attribute", "Hinter.missing_instance_class=Could not find Java source corresponding to {0}.",
            "Hinter.do_not_edit_layer=Do not edit layer.xml until the hint has had a chance to run."
        })
        public void findAndModifyDeclaration(@NullAllowed final Object instanceAttribute, final ModifyDeclarationTask task) throws IOException {
            FileObject java = findDeclaringSource(instanceAttribute);
            if (java == null) {
                DialogDisplayer.getDefault().notify(new Message(Hinter_missing_instance_class(instanceAttribute), NotifyDescriptor.WARNING_MESSAGE));
                return;
            }
            JavaSource js = JavaSource.forFileObject(java);
            if (js == null) {
                throw new IOException("No source info for " + java);
            }
            js.runModificationTask(new Task<WorkingCopy>() {
                public @Override void run(WorkingCopy wc) throws Exception {
                    wc.toPhase(JavaSource.Phase.RESOLVED);
                    if (DataObject.find(layer.getLayerFile()).isModified()) { // #207077
                        DialogDisplayer.getDefault().notify(new Message(Hinter_do_not_edit_layer(), NotifyDescriptor.WARNING_MESSAGE));
                        return;
                    }
                    Element decl = findDeclaration(wc, instanceAttribute);
                    if (decl == null) {
                        DialogDisplayer.getDefault().notify(new Message(Hinter_missing_instance_class(instanceAttribute), NotifyDescriptor.WARNING_MESSAGE));
                        return;
                    }
                    ModifiersTree mods;
                    if (decl.getKind() == ElementKind.CLASS) {
                        mods = wc.getTrees().getTree((TypeElement) decl).getModifiers();
                    } else {
                        mods = wc.getTrees().getTree((ExecutableElement) decl).getModifiers();
                    }
                    task.run(wc, decl, mods);
                    saveLayer();
                }
            }).commit();
            SaveCookie sc = DataObject.find(java).getLookup().lookup(SaveCookie.class);
            if (sc != null) {
                sc.save();
            }
        }

        /**
         * Tries to find the instance associated with a file.
         * If it is a folder, or not a {@code *.instance} file, null is returned.
         * If it has an {@code instanceCreate} attribute, that is used, else
         * {@code instanceClass}, and finally the class implied by the filename.
         * @param file any file
         * @return a methodvalue or newvalue attribute, or null
         * @see #findAndModifyDeclaration
         */
        public @CheckForNull Object instanceAttribute(FileObject file) {
            if (!file.isData() || !file.hasExt("instance")) {
                return null; // not supporting *.settings etc. for now
            }
            Object instanceCreate = file.getAttribute("literal:instanceCreate");
            if (instanceCreate != null) {
                return instanceCreate;
            }
            Object clazz = file.getAttribute("instanceClass");
            if (clazz != null) {
                return "new:" + clazz;
            }
            return "new:" + file.getName().replace('-', '.');
        }

        /**
         * Converts a (possibly) localized string attribute in a layer into a value suitable for {@link org.openide.filesystems.annotations.LayerBuilder.File#bundlevalue(String,String)}.
         * @param attribute the result of {@link FileObject#getAttribute} on a {@code literal:*} key
         * @param declaration the declaring element (used to calculate package)
         * @return a string referring to the same (possibly) localized value (may be null)
         */
        public @CheckForNull String bundlevalue(@NullAllowed Object attribute, Element declaration) {
            if (attribute instanceof String) {
                String val = (String) attribute;
                if (val.startsWith("bundle:")) {
                    PackageElement pkg = findPackage(declaration);
                    if (pkg != null) {
                        String expected = "bundle:" + pkg.getQualifiedName() + ".Bundle#";
                        if (val.startsWith(expected)) {
                            return val.substring(expected.length() - 1); // keep '#'
                        }
                    }
                    return val.substring(7);
                }
                return val;
            } else {
                return null;
            }
        }
        private PackageElement findPackage(Element e) {
            if (e.getKind() == ElementKind.PACKAGE) {
                return ((PackageElement) e);
            }
            Element parent = e.getEnclosingElement();
            if (parent == null) {
                return null;
            }
            return findPackage(parent);
        }

        /**
         * Convenience method to add an annotation to an element.
         * @param wc Java source context
         * @param modifiers the element's modifiers to append to
         * @param type canonical name of the annotation type
         * @param pluralType if not null, canonical name of a plural variant of {@code type}
         * @param parameters simple parameters of String or primitive type or String[] (null values are skipped)
         * @return the expanded modifiers tree
         */
        // XXX ought to also accept CompilationUnitTree
        public @CheckReturnValue ModifiersTree addAnnotation(WorkingCopy wc, ModifiersTree modifiers, String type, @NullAllowed String pluralType, Map<String,Object> parameters) {
            TreeMaker make = wc.getTreeMaker();
            TypeElement ann = wc.getElements().getTypeElement(type);
            if (ann == null) {
                // XXX does this deserve a localized message? generally hint should have been disabled already if missing
                throw new IllegalArgumentException("Could not find " + type + " in classpath");
            }
            List<ExpressionTree> arguments = new ArrayList<ExpressionTree>();
            for (Map.Entry<String,Object> entry : parameters.entrySet()) {
                Object value = entry.getValue();
                ExpressionTree valueTree;
                if (value instanceof Object[]) {
                    Object[] array = (Object[]) value;
                    if (array.length == 1) {
                        valueTree = make.Literal(array[0]);
                    } else {
                        List<ExpressionTree> elements = new ArrayList<ExpressionTree>();
                        for (Object o : array) {
                            elements.add(make.Literal(o));
                        }
                        valueTree = make.NewArray(null, Collections.<ExpressionTree>emptyList(), elements);
                    }
                } else if (value != null) {
                    valueTree = make.Literal(value);
                } else {
                    continue;
                }
                arguments.add(make.Assignment(make.Identifier(entry.getKey()), valueTree));
            }
            AnnotationTree toAdd = make.Annotation(make.QualIdent(ann), arguments);
            if (pluralType != null) {
                List<? extends AnnotationTree> existingAnns = modifiers.getAnnotations();
                for (int i = 0; i < existingAnns.size(); i++) {
                    AnnotationTree existingAnn = existingAnns.get(i);
                    // XXX see UseNbBundleMessages; is there a better way to do this?
                    String existingType = existingAnn.getAnnotationType().toString();
                    // XXX this will not work if type is a nested class
                    if (existingType.equals(type) || existingType.equals(type.replaceFirst(".+[.]", ""))) {
                        return make.insertModifiersAnnotation(make.removeModifiersAnnotation(modifiers, i), i,
                                make.Annotation(make.QualIdent(pluralType),
                                Collections.singletonList(make.Assignment(make.Identifier("value"),
                                make.NewArray(null, Collections.<ExpressionTree>emptyList(), Arrays.asList(existingAnn, toAdd))))));
                    } else if (existingType.equals(pluralType) || existingType.equals(pluralType.replaceFirst(".+[.]", ""))) {
                        List<? extends ExpressionTree> args = existingAnn.getArguments();
                        if (args.size() != 1) {
                            throw new IllegalArgumentException("expecting just one arg for @" + pluralType);
                        }
                        AssignmentTree assign = (AssignmentTree) args.get(0);
                        if (!assign.getVariable().toString().equals("value")) {
                            throw new IllegalArgumentException("expected value=... for @" + pluralType);
                        }
                        ExpressionTree arg = assign.getExpression();
                        NewArrayTree arr;
                        if (arg.getKind() == Tree.Kind.STRING_LITERAL) {
                            arr = make.NewArray(null, Collections.<ExpressionTree>emptyList(), Collections.singletonList(arg));
                        } else if (arg.getKind() == Tree.Kind.NEW_ARRAY) {
                            arr = (NewArrayTree) arg;
                        } else {
                            throw new IllegalArgumentException("unknown arg kind " + arg.getKind() + ": " + arg);
                        }
                        return make.insertModifiersAnnotation(make.removeModifiersAnnotation(modifiers, i), i,
                                make.Annotation(existingAnn.getAnnotationType(),
                                Collections.singletonList(make.Assignment(assign.getVariable(), make.addNewArrayInitializer(arr, toAdd)))));
                    }
                }
            }
            return make.addModifiersAnnotation(modifiers, toAdd);
        }

        /**
         * Deletes an obsolete layer entry.
         * Also deletes empty parent directories.
         * @param entry a file to delete
         * @throws IOException in case of problem
         */
        public void delete(FileObject entry) throws IOException {
            entry.delete();
            FileObject parent = entry.getParent();
            if (parent.getChildren().length == 0 && !parent.getAttributes().hasMoreElements()) {
                if (parent.isRoot()) {
                    // XXX maybe delete the whole layer file! (and its reference in manifest.mf)
                } else {
                    delete(parent);
                }
            }
        }

    }

}
