/*
 * 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.apache.camel.maven;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;

import org.apache.camel.maven.model.AutowireData;
import org.apache.camel.maven.model.SpringBootGroupData;
import org.apache.camel.maven.model.SpringBootPropertyData;
import org.apache.camel.support.IntrospectionSupport;
import org.apache.camel.support.PatternHelper;
import org.apache.camel.util.IOHelper;
import org.apache.camel.util.OrderedProperties;
import org.apache.camel.util.StringHelper;
import org.apache.camel.util.json.Jsoner;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.plugins.annotations.ResolutionScope;
import org.jboss.forge.roaster.Roaster;
import org.jboss.forge.roaster.model.Extendable;
import org.jboss.forge.roaster.model.JavaType;
import org.jboss.forge.roaster.model.source.Importer;
import org.jboss.forge.roaster.model.source.JavaClassSource;
import org.jboss.forge.roaster.model.source.MethodHolderSource;
import org.jboss.forge.roaster.model.source.MethodSource;

import static org.apache.camel.maven.GenerateHelper.sanitizeDescription;
import static org.apache.camel.util.StringHelper.camelCaseToDash;

/**
 * Pre scans your project and prepares autowiring and spring-boot tooling support by classpath scanning.
 */
@Mojo(name = "generate", defaultPhase = LifecyclePhase.PROCESS_CLASSES, threadSafe = true, requiresDependencyResolution = ResolutionScope.COMPILE)
public class GenerateMojo extends AbstractMainMojo {

    /**
     * Whether generating autowiring is enabled.
     */
    @Parameter(property = "camel.autowireEnabled", defaultValue = "true")
    protected boolean autowireEnabled;

    /**
     * Whether generating spring boot tooling support is enabled.
     */
    @Parameter(property = "camel.springBootEnabled", defaultValue = "true")
    protected boolean springBootEnabled;

    /**
     * Whether to allow downloading -source JARs when generating spring boot tooling to include
     * javadoc as description for discovered options.
     */
    @Parameter(property = "camel.downloadSourceJars", defaultValue = "true")
    protected boolean downloadSourceJars;

    /**
     * When autowiring has detected multiple implementations (2 or more) of a given interface, which
     * cannot be mapped, should they be logged so you can see and add manual mapping if needed.
     */
    @Parameter(property = "camel.logUnmapped", defaultValue = "false")
    protected boolean logUnmapped;

    /**
     * The output directory for generated autowire file
     */
    @Parameter(readonly = true, defaultValue = "${project.build.directory}/classes/META-INF/services/org/apache/camel/")
    protected File outAutowireFolder;

    /**
     * The output directory for generated spring boot tooling file
     */
    @Parameter(readonly = true, defaultValue = "${project.build.directory}/../src/main/resources/META-INF/")
    protected File outSpringBootFolder;

    /**
     * To exclude autowiring specific properties with these key names.
     * You can also configure a single entry and separate the excludes with comma
     */
    @Parameter(property = "camel.exclude")
    protected String[] exclude;

    /**
     * To include autowiring specific properties or component with these key names.
     * You can also configure a single entry and separate the includes with comma
     */
    @Parameter(property = "camel.include")
    protected String[] include;

    /**
     * To setup special mappings between known types as key=value pairs.
     * You can also configure a single entry and separate the mappings with comma
     */
    @Parameter(property = "camel.mappings")
    protected String[] mappings;

    /**
     * Optional mappings file loaded from classpath, with mapping that override any default mappings
     */
    @Parameter(defaultValue = "${project.build.directory}/classes/camel-main-mappings.properties")
    protected File mappingsFile;


    @Override
    public void execute() throws MojoExecutionException, MojoFailureException {
        // load default mappings
        Properties mappingProperties = loadDefaultMappings();
        getLog().debug("Loaded default-mappings: " + mappingProperties);
        // add extra mappings
        if (this.mappings != null) {
            for (String m : this.mappings) {
                String key = StringHelper.before(m, "=");
                String value = StringHelper.after(m, "=");
                if (key != null && value != null) {
                    mappingProperties.setProperty(key, value);
                    getLog().debug("Added mapping from pom.xml: " + key + "=" + value);
                }
            }
        }
        Properties mappingFileProperties = loadMappingsFile();
        if (!mappingFileProperties.isEmpty()) {
            getLog().debug("Loaded mappings file: " + mappingsFile + " with mappings: " + mappingFileProperties);
            mappingProperties.putAll(mappingFileProperties);
        }

        final List<AutowireData> autowireData = new ArrayList<>();
        final List<SpringBootPropertyData> propertyData = new ArrayList<>();
        final List<SpringBootGroupData> groupData = new ArrayList<>();

        String existingText = null;
        if (springBootEnabled) {
            // is this a new component
            File file = new File(outSpringBootFolder, "spring-configuration-metadata.json");
            if (file.exists()) {
                try {
                    InputStream is = new FileInputStream(file);
                    existingText = IOHelper.loadText(is);
                    IOHelper.close(is);
                } catch (FileNotFoundException e) {
                    // ignore
                } catch (IOException e) {
                    throw new MojoExecutionException("Error loading " + outSpringBootFolder + "/spring-configuration-metadata.json file");
                }
            }
        }
        final String springBootExistingText = existingText;
        final AtomicBoolean springBootChanged = new AtomicBoolean(existingText == null);

        ComponentCallback callback = (componentName, componentJavaType, componentDescription,
                                      name, type, javaType, description, defaultValue, deprecated) -> {

            // gather spring boot data
            // we want to use dash in the name
            String dash = camelCaseToDash(name);
            String key = "camel.component." + componentName + "." + dash;
            if (springBootEnabled) {
                getLog().debug("Spring Boot option: " + key);
                propertyData.add(new SpringBootPropertyData(key, springBootJavaType(javaType), description, componentJavaType, defaultValue, deprecated));
                // avoid duplicate groups (it has equal/hashCode contract on name only)
                SpringBootGroupData group = new SpringBootGroupData("camel.component." + componentName, componentDescription, componentJavaType);
                if (!groupData.contains(group)) {
                    groupData.add(group);
                }
                if (springBootExistingText != null && !springBootExistingText.contains(group.getName())) {
                    springBootChanged.set(true);
                }
            }

            // check if we can do automatic autowire to complex singleton objects from classes in the classpath
            if (autowireEnabled && "object".equals(type)) {
                if (!isValidAutowirePropertyName(componentName, name)) {
                    getLog().debug("Skipping property name: " + name);
                    return;
                }
                try {
                    Class clazz = classLoader.loadClass(javaType);
                    if (clazz.isInterface() && isComplexUserType(clazz)) {
                        Set<Class<?>> classes = reflections.getSubTypesOf(clazz);
                        // filter classes (must not be interfaces, must be public, must not be abstract, must be top level) and also a valid autowire class
                        classes = classes.stream().filter(
                            c -> !c.isInterface()
                            && Modifier.isPublic(c.getModifiers())
                            && !Modifier.isAbstract(c.getModifiers())
                            && c.getEnclosingClass() == null
                            && isValidAutowireClass(c))
                            .collect(Collectors.toSet());
                        Class best = chooseBestKnownType(componentName, name, clazz, classes, mappingProperties);
                        if (best != null) {
                            key = "camel.component." + componentName + "." + dash;
                            String value = "#class:" + best.getName();
                            getLog().debug("Autowire: " + key + "=" + value);
                            autowireData.add(new AutowireData(key, value));

                            if (springBootEnabled && springBootChanged.get()) {
                                // gather additional spring boot data for this class
                                // we dont have documentation or default values
                                List<Method> setters = new ArrayList<>();
                                extraSetterMethods(best, setters);
                                // sort the setters
                                setters.sort((o1, o2) -> o1.getName().compareToIgnoreCase(o2.getName()));

                                JavaClassSource javaClassSource = null;
                                if (downloadSourceJars && !setters.isEmpty()) {
                                    String path = best.getName().replace('.', '/') + ".java";
                                    getLog().debug("Loading Java source: " + path);

                                    InputStream is = getSourcesClassLoader().getResourceAsStream(path);
                                    if (is != null) {
                                        try {
                                            String text = IOHelper.loadText(is);
                                            IOHelper.close(is);
                                            javaClassSource = (JavaClassSource) Roaster.parse(text);
                                        } catch (IOException e) {
                                            // ignore
                                            getLog().warn("Cannot load Java source: " + path + " from classpath due " + e.getMessage());
                                        }
                                    }
                                    getLog().debug("Loaded source code: " + clazz);
                                }

                                for (Method m : setters) {
                                    String shortHand = IntrospectionSupport.getSetterShorthandName(m);
                                    String bootName = camelCaseToDash(shortHand);
                                    String bootKey = "camel.component." + componentName + "." + dash + "." + bootName;
                                    String bootJavaType = m.getParameterTypes()[0].getName();
                                    String sourceType = best.getName();
                                    boolean bootDeprecated = m.getAnnotation(Deprecated.class) != null;
                                    getLog().debug("Spring Boot option: " + bootKey);

                                    // find the setter method and grab the javadoc
                                    String desc = extractJavaDocFromMethod(javaClassSource, m);
                                    desc = sanitizeDescription(desc, false);
                                    if (desc == null) {
                                        desc = "";
                                    } else {
                                        if (desc.endsWith(".")) {
                                            desc += " ";
                                        } else {
                                            desc += ". ";
                                        }
                                    }
                                    desc += "Auto discovered option from class: " + best.getName() + " to set the option via setter: " + m.getName();

                                    propertyData.add(new SpringBootPropertyData(bootKey, springBootJavaType(bootJavaType), desc, sourceType, null, bootDeprecated));
                                }
                            }
                        }
                    }

                } catch (Exception e) {
                    // ignore
                    getLog().debug("Cannot load class: " + name, e);
                }
            }
        };

        // execute with the callback
        doExecute(callback);

        // write the output files
        writeAutowireFile(autowireData);
        if (springBootChanged.get()) {
            writeSpringBootFile(propertyData, groupData);
        }
    }

    protected void writeSpringBootFile(List<SpringBootPropertyData> propertyData, List<SpringBootGroupData> groupData) throws MojoFailureException {
        if (!propertyData.isEmpty()) {
            List options = new ArrayList();

            for (SpringBootPropertyData row : propertyData) {
                Map p = new LinkedHashMap();
                p.put("name", row.getName());
                p.put("type", row.getJavaType());
                p.put("sourceType", row.getSourceType());
                p.put("description", row.getDescription());
                if (row.getDefaultValue() != null) {
                    p.put("defaultValue", row.getDefaultValue());
                }
                if (row.isDeprecated()) {
                    p.put("deprecated", true);
                    p.put("deprecation", Collections.EMPTY_MAP);
                }
                options.add(p);
            }

            // okay then add the components into the main json at the end so they get merged together
            // load camel-main metadata
            String mainJson = loadCamelMainConfigurationMetadata();
            if (mainJson == null) {
                getLog().warn("Cannot load camel-main-configuration-metadata.json from within the camel-main JAR from the classpath."
                        + " Not possible to build spring boot configuration file for this project");
                return;
            }

            String json;
            try {
                Map map = (Map) Jsoner.deserialize(mainJson);
                List props = (List) map.get("properties");
                props.addAll(options);

                // include groups
                List groups = (List) map.get("groups");
                groupData.forEach(g -> {
                    Map group = new LinkedHashMap();
                    group.put("name", g.getName());
                    group.put("description", g.getDescription());
                    group.put("sourceType", g.getSourceType());
                    groups.add(group);
                });

                json = Jsoner.serialize(map);
                json = Jsoner.prettyPrint(json);
            } catch (Throwable e) {
                throw new MojoFailureException("Cannot serialize or deserialize json due " + e.getMessage(), e);
            }

            outSpringBootFolder.mkdirs();
            File file = new File(outSpringBootFolder, "spring-configuration-metadata.json");
            try {
                FileOutputStream fos = new FileOutputStream(file, false);
                fos.write(json.getBytes());
                IOHelper.close(fos);
                getLog().info("Created file: " + file);
            } catch (Throwable e) {
                throw new MojoFailureException("Cannot write to file " + file + " due " + e.getMessage(), e);
            }
        }
    }

    protected void writeAutowireFile(List<AutowireData> autowireData) throws MojoFailureException {
        if (!autowireData.isEmpty()) {
            outAutowireFolder.mkdirs();
            File file = new File(outAutowireFolder, "autowire.properties");

            String existingText = null;
            if (file.exists()) {
                try {
                    InputStream is = new FileInputStream(file);
                    existingText = IOHelper.loadText(is);
                    IOHelper.close(is);
                } catch (Exception e) {
                    // ignore
                }
            }
            final String autowireExisting = existingText;

            // only write the file if its changed
            boolean matches = autowireExisting != null && autowireData.stream().allMatch(d -> autowireExisting.contains(d.getKey() + "=" + d.getValue()));
            if (!matches) {
                try {
                    FileOutputStream fos = new FileOutputStream(file, false);
                    fos.write("# Generated by camel build tools\n".getBytes());
                    for (AutowireData data : autowireData) {
                        fos.write(data.getKey().getBytes());
                        fos.write('=');
                        fos.write(data.getValue().getBytes());
                        fos.write('\n');
                    }
                    IOHelper.close(fos);
                    getLog().info("Created file: " + file + " (autowire by classpath: " + autowireData.size() + ")");
                } catch (Throwable e) {
                    throw new MojoFailureException("Cannot write to file " + file + " due " + e.getMessage(), e);
                }
            }
        }
    }

    protected Properties loadDefaultMappings() throws MojoFailureException {
        Properties mappings = new OrderedProperties();
        try {
            InputStream is = GenerateMojo.class.getResourceAsStream("/default-mappings.properties");
            if (is != null) {
                mappings.load(is);
            }
        } catch (IOException e) {
            throw new MojoFailureException("Cannot load default-mappings.properties from classpath");
        }
        return mappings;
    }

    protected Properties loadMappingsFile() throws MojoFailureException {
        Properties mappings = new OrderedProperties();
        if (mappingsFile.exists() && mappingsFile.isFile()) {
            try {
                InputStream is = new FileInputStream(mappingsFile);
                mappings.load(is);
            } catch (IOException e) {
                throw new MojoFailureException("Cannot load file: " + mappingsFile);
            }
        }
        return mappings;
    }

    protected Class chooseBestKnownType(String componentName, String optionName, Class type, Set<Class<?>> candidates, Properties knownTypes) {
        String known = knownTypes.getProperty(type.getName());
        if (known != null) {
            for (String k : known.split(";")) {
                // special as we should skip this option
                if ("#skip#".equals(k)) {
                    return null;
                }
                // jump to after the leading prefix to have the classname
                if (k.startsWith("#class:")) {
                    k = k.substring(7);
                } else if (k.startsWith("#type:")) {
                    k = k.substring(6);
                }
                final String candiateName = k;
                Class found = candidates.stream().filter(c -> c.getName().equals(candiateName)).findFirst().orElse(null);
                if (found != null) {
                    return found;
                }
            }
        }

        if (candidates.size() == 1) {
            return candidates.iterator().next();
        } else if (candidates.size() > 1) {
            if (logUnmapped) {
                getLog().debug("Cannot chose best type: " + type.getName() + " among " + candidates.size() + " implementations: " + candidates);
                getLog().info("Cannot autowire option camel.component." + componentName + "." + optionName
                        + " as the interface: " + type.getName() + " has " + candidates.size() + " implementations in the classpath:");
                for (Class c : candidates) {
                    getLog().info("\t\t" + c.getName());
                }
            }
        }
        return null;
    }

    protected boolean isValidAutowirePropertyName(String componentName, String name) {
        // we want to regard names as the same if they are using dash or not, and also to be case insensitive.
        String prefix = "camel.component." + componentName + ".";
        name = StringHelper.dashToCamelCase(name);

        if (exclude != null && exclude.length > 0) {
            // works on components too
            for (String pattern : exclude) {
                pattern = pattern.trim();
                pattern = StringHelper.dashToCamelCase(pattern);
                if (PatternHelper.matchPattern(componentName, pattern)) {
                    return false;
                }
                if (PatternHelper.matchPattern(name, pattern) || PatternHelper.matchPattern(prefix + name, pattern)) {
                    return false;
                }
            }
        }

        if (include != null && include.length > 0) {
            for (String pattern : include) {
                pattern = pattern.trim();
                pattern = StringHelper.dashToCamelCase(pattern);
                if (PatternHelper.matchPattern(componentName, pattern)) {
                    return true;
                }
                if (PatternHelper.matchPattern(name, pattern) || PatternHelper.matchPattern(prefix + name, pattern)) {
                    return true;
                }
            }
            // we have include enabled and none matched so it should be false
            return false;
        }

        return true;
    }

    private static boolean isComplexUserType(Class type) {
        // lets consider all non java, as complex types
        return type != null && !type.isPrimitive() && !type.getName().startsWith("java.");
    }

    private static boolean isValidAutowireClass(Class clazz) {
        // skip all from Apache Camel and regular JDK as they would be default anyway
        return !clazz.getName().startsWith("org.apache.camel");
    }

    private String loadCamelMainConfigurationMetadata() throws MojoFailureException {
        try {
            InputStream is = classLoader.getResourceAsStream("META-INF/camel-main-configuration-metadata.json");
            String text = IOHelper.loadText(is);
            IOHelper.close(is);
            return text;
        } catch (Throwable e) {
            throw new MojoFailureException("Error during discovering camel-main from classpath due " + e.getMessage(), e);
        }
    }

    private static String springBootJavaType(String javaType) {
        if ("boolean".equalsIgnoreCase(javaType)) {
            return "java.lang.Boolean";
        } else if ("int".equalsIgnoreCase(javaType)) {
            return "java.lang.Integer";
        } else if ("long".equalsIgnoreCase(javaType)) {
            return "java.lang.Long";
        } else if ("string".equalsIgnoreCase(javaType)) {
            return "java.lang.String";
        }
        return javaType;
    }

    private static boolean springBootDefaultValueQuotes(String javaType) {
        if ("java.lang.Boolean".equalsIgnoreCase(javaType)) {
            return false;
        } else if ("java.lang.Integer".equalsIgnoreCase(javaType)) {
            return false;
        } else if ("java.lang.Long".equalsIgnoreCase(javaType)) {
            return false;
        }
        return true;
    }

    private static void extraSetterMethods(Class<?> clazz, List<Method> answer) {
        if (clazz == null || clazz == Object.class) {
            return;
        }
        Method[] methods = clazz.getMethods();
        for (Method m : methods) {
            if (IntrospectionSupport.isSetter(m)) {
                answer.add(m);
            }
        }
    }

    private String extractJavaDocFromMethod(Object input, Method method) {
        if (input == null) {
            return null;
        }

        String answer = "";

        // input can be both a class or interface so we need to split this up into several pieces
        MethodHolderSource mhs = (MethodHolderSource) input;
        JavaType jt = (JavaType) input;
        Extendable ext = null;
        if (input instanceof Extendable) {
            ext = (Extendable) input;
        }
        Importer importer = null;
        if (input instanceof Importer) {
            importer = (Importer) input;
        }
        MethodSource ms = mhs.getMethod(method.getName(), method.getParameterTypes()[0]);
        if (ms != null) {
            answer = ms.getJavaDoc().getText();
        }

        String st = null;
        if (answer.isEmpty()) {
            // special for activemq-artemis
            if ("org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory".equals(jt.getQualifiedName())) {
                st = "org.apache.activemq.artemis.api.core.client.ServerLocator";
            } else {
                // maybe its from the super class
                st = ext != null ? ext.getSuperType() : null;
            }
        }

        if (answer.isEmpty() && st != null && !"java.lang.Object".equals(st) && !"Object".equals(st)) {
            st = importer != null ? importer.resolveType(st) : st;
            // find this file cia classloader
            String path = st.replace('.', '/') + ".java";
            InputStream is = sourcesClassLoader.getResourceAsStream(path);
            if (is != null) {
                String text;
                try {
                    text = IOHelper.loadText(is);
                    IOHelper.close(is);
                    input = Roaster.parse(text);
                    getLog().debug("Loaded source code: " + input);
                    answer = extractJavaDocFromMethod(input, method);
                } catch (IOException e) {
                    // ignore
                    getLog().warn("Cannot load Java source: " + path + " from classpath due " + e.getMessage());
                }
            }
        }

        return answer;
    }

}
