/*
 * Copyright 2002-2008 the original author or authors.
 *
 * Licensed 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.springframework.config.java.internal.model;


import static java.lang.String.format;
import static org.springframework.config.java.internal.util.AnnotationExtractionUtils.extractClassAnnotation;

import java.lang.annotation.Annotation;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;

import org.springframework.config.java.annotation.Bean;
import org.springframework.config.java.annotation.Configuration;
import org.springframework.config.java.model.ModelClass;
import org.springframework.config.java.model.ModelMethod;
import org.springframework.config.java.plugin.Plugin;
import org.springframework.util.Assert;


/**
 * Abstract representation of a user-defined {@link Configuration @Configuration} class. Includes a
 * set of Bean methods, AutoBean methods, ExternalBean methods, ExternalValue methods, etc. Includes
 * all such methods defined in the ancestry of the class, in a 'flattened-out' manner. Note that
 * each BeanMethod representation does still contain source information about where it was
 * originally detected (for the purpose of tooling with Spring IDE).
 *
 * <p>Like the rest of the {@link org.springframework.config.java.internal.model model} package,
 * this class follows the fluent interface / builder pattern such that a model can be built up
 * easily by method chaining.</p>
 *
 * @author  Chris Beams
 */
public class ConfigurationClass extends ModelClass {

    /**
     * Used as metadata on {@link org.springframework.beans.factory.config.BeanDefinition} to
     * indicate that a bean is a {@link Configuration @Configuration} class and therefore a
     * candidate for enhancement.
     *
     * @see  org.springframework.beans.factory.config.BeanDefinition
     * @see  org.springframework.core.AttributeAccessor#getAttribute(String)
     * @see  org.springframework.config.java.internal.parsing.ConfigurationParser
     */
    public static final String IS_CONFIGURATION_CLASS = "IS_CONFIGURATION_CLASS";

    private static final Configuration DEFAULT_METADATA = extractClassAnnotation(Configuration.class, Prototype.class);

    private String id;

    private int modifiers;

    private Configuration metadata;

    /** set is used because order does not matter. see {@link #add(BeanMethod)} */
    private HashSet<BeanMethod> beanMethods = new HashSet<BeanMethod>();

    /** set is used because order does not matter. see {@link #add(ExternalBeanMethod)} */
    private HashSet<ExternalBeanMethod> externalBeanMethods = new HashSet<ExternalBeanMethod>();

    private HashSet<ExternalValueMethod> externalValueMethods = new HashSet<ExternalValueMethod>();

    private HashSet<AutoBeanMethod> autoBeanMethods = new HashSet<AutoBeanMethod>();

    /**
     * Used for ensuring mutual exclusivity between Bean/AutoBean/ExternalBean annotations on methods.
     * LinkedHashSet is important when tracking methods in order to ensure that polymorphic methods
     * don't cause a false negative, i.e.: two {@code @Bean} methods being mutually exclusive with one another.
     * Example: during parsing, a child class Bean method may be added and if the parent class implementation
     * also declares that same Bean method, we want the set to simply overwrite the entry, not add a new one.
     * LinkedHashSet is important for preserving insertion order, resulting in useful error messages.
     * Finally, this field is transient in order to keep it from being considered in equals() and hashCode()
     * calculations.  Its value is always internally derived from more fundamental fields.
     */
    private transient HashMap<String, LinkedHashSet<JavaConfigMethod>> javaConfigMethods = new HashMap<String, LinkedHashSet<JavaConfigMethod>>();

    private HashSet<NonJavaConfigMethod> nonJavaConfigMethods = new HashSet<NonJavaConfigMethod>();

    private HashSet<Annotation> pluginAnnotations = new HashSet<Annotation>();

    // TODO: may need to be a LinkedHashSet to avoid duplicates while preserving original insertion
    // order problem: LinkedHashSet#equals() does not respect insertion order.
    private ArrayList<ConfigurationClass> importedClasses = new ArrayList<ConfigurationClass>();

    private ConfigurationClass declaringClass;


    public ConfigurationClass() { }

    /**
     * Creates a new ConfigurationClass named <var>className.</var>
     *
     * @param  name  fully-qualified Configuration class being represented
     *
     * @see    #setClassName(String)
     */
    ConfigurationClass(String name) { this(name, null, DEFAULT_METADATA, 0); }

    ConfigurationClass(String name, Configuration metadata) { this(name, null, metadata, 0); }

    ConfigurationClass(String name, int modifiers) { this(name, null, DEFAULT_METADATA, modifiers); }

    /**
     * Creates a new ConfigurationClass object.
     *
     * @param  name       Fully qualified name of the class being represented
     * @param  id         Bean name/id (if any) of this configuration class. used only in the case
     *                    of XML integration where {@link Configuration} beans may have a
     *                    user-specified id.
     * @param  metadata   Configuration annotation resident on this class. May be null indicating
     *                    that the user specified this class to be processed but failed to properly
     *                    annotate it.
     * @param  modifiers  Per {@link java.lang.reflect.Modifier}
     */
    public ConfigurationClass(String name, String id, Configuration metadata, int modifiers) {
        super(name);
        Assert.hasText(name, "Configuration class name must have text");

        setId(id);
        setMetadata(metadata);
        setModifiers(modifiers);
    }


    /**
     * bean methods may be locally declared within this class, or discovered in a superclass. order
     * is insignificant.
     */
    public ConfigurationClass add(BeanMethod method) {
        beanMethods.add(method);
        addJavaConfigMethod(method);
        method.setDeclaringClass(this);
        return this;
    }

    public ConfigurationClass add(ExternalBeanMethod method) {
        externalBeanMethods.add(method);
        addJavaConfigMethod(method);
        method.setDeclaringClass(this);
        return this;
    }

    public ConfigurationClass add(ExternalValueMethod method) {
        externalValueMethods.add(method);
        addJavaConfigMethod(method);
        method.setDeclaringClass(this);
        return this;
    }

    public ConfigurationClass add(AutoBeanMethod method) {
        autoBeanMethods.add(method);
        addJavaConfigMethod(method);
        method.setDeclaringClass(this);
        return this;
    }

    public ConfigurationClass add(NonJavaConfigMethod method) {
        nonJavaConfigMethods.add(method);
        method.setDeclaringClass(this);
        return this;
    }

    private void addJavaConfigMethod(JavaConfigMethod method) {
        LinkedHashSet<JavaConfigMethod> configMethods = javaConfigMethods.get(method.getName());

        if(configMethods == null)
            javaConfigMethods.put(method.getName(), configMethods = new LinkedHashSet<JavaConfigMethod>());

        configMethods.add(method);
    }

    public String getId() { 
        return id == null ? getName() : id;
    }

    public ConfigurationClass setId(String id) {
        this.id = id;
        return this;
    }

    public BeanMethod[] getBeanMethods() {
        return beanMethods.toArray(new BeanMethod[beanMethods.size()]);
    }

    public AutoBeanMethod[] getAutoBeanMethods() {
        return autoBeanMethods.toArray(new AutoBeanMethod[autoBeanMethods.size()]);
    }

    public Annotation[] getPluginAnnotations() {
        return pluginAnnotations.toArray(new Annotation[pluginAnnotations.size()]);
    }

    public BeanMethod[] getFinalBeanMethods() {
        ArrayList<BeanMethod> finalBeanMethods = new ArrayList<BeanMethod>();
        for (BeanMethod beanMethod : beanMethods)
            if (beanMethod.getMetadata().allowOverriding() == false)
                finalBeanMethods.add(beanMethod);

        return finalBeanMethods.toArray(new BeanMethod[finalBeanMethods.size()]);
    }

    public boolean containsBeanMethod(String beanMethodName) {
        Assert.hasText(beanMethodName, "beanMethodName must be non-empty");
        for (BeanMethod beanMethod : beanMethods)
            if (beanMethod.getName().equals(beanMethodName))
                return true;

        return false;
    }

    public ConfigurationClass addImportedClass(ConfigurationClass importedClass) {
        importedClasses.add(importedClass);
        return this;
    }

    /**
     * Add a {@link Plugin @Plugin}-annotated annotation to this configuration class.
     * 
     * @param pluginAnno type-level <code>Plugin</code> annotation
     */
    public ConfigurationClass addPluginAnnotation(Annotation pluginAnno) {
        pluginAnnotations.add(pluginAnno);
        return this;
    }


    /**
     * Returns a properly-ordered collection of this configuration class and all classes it imports,
     * recursively. Ordering is depth-first, then breadth such that in the case of a configuration
     * class M that imports classes A and Y and A imports B and Y imports Z, the contents and order
     * of the resulting collection will be [B, A, Z, Y, M]. This ordering is significant, because
     * the most specific class (where M is most specific) should have precedence when it comes to
     * bean overriding. In the example above, if M implements a bean (method) named 'foo' and A
     * implements the same, M's method should 'win' in terms of which bean will actually resolve
     * upon a call to getBean().
     */
    public Collection<ConfigurationClass> getSelfAndAllImports() {
        ArrayList<ConfigurationClass> selfAndAllImports = new ArrayList<ConfigurationClass>();

        for (ConfigurationClass importedClass : importedClasses)
            selfAndAllImports.addAll(importedClass.getSelfAndAllImports());

        selfAndAllImports.add(this);
        return selfAndAllImports;
    }

    public ConfigurationClass setDeclaringClass(ConfigurationClass configurationClass) {
        this.declaringClass = configurationClass;
        return this;
    }

    public ConfigurationClass getDeclaringClass() {
        return declaringClass;
    }


    public int getModifiers() {
        return modifiers;
    }

    public ConfigurationClass setModifiers(int modifiers) {
        Assert.isTrue(modifiers >= 0, "modifiers must be non-negative");
        this.modifiers = modifiers;
        return this;
    }

    public Configuration getMetadata() {
        return this.metadata;
    }

    public ConfigurationClass setMetadata(Configuration configAnno) {
        this.metadata = configAnno;
        return this;
    }

    public void detectUsageErrors(List<UsageError> errors) {
        // cascade through all imported classes
        for (ConfigurationClass importedClass : importedClasses)
            importedClass.detectUsageErrors(errors);

        // configuration classes must be annotated with @Configuration
        if (metadata == null)
            errors.add(new NonAnnotatedConfigurationError());

        // a configuration class may not be final (CGLIB limitation)
        if (Modifier.isFinal(modifiers))
            errors.add(new FinalConfigurationError());

        // check through all of the methods to make sure that there is only one annotation that defines
        // how the bean/value is constructed.  Bean/AutoBean/ExternalBean/etc should be mutually exclusive.
        for (LinkedHashSet<JavaConfigMethod> multiAnnotatedMethod : this.javaConfigMethods.values()) {
            if (multiAnnotatedMethod.size() <= 1)
                // this method was annotated zero or one times -> no issues here
                continue;

            // otherwise, we probably have a situation where the method was annotated with more than
            // one JavaConfig annotation, such as both @Bean and @ExternalBean.
            JavaConfigMethod primaryAnnotatedMethod = null;
            for (JavaConfigMethod method : multiAnnotatedMethod) {
                if (primaryAnnotatedMethod == null)
                    // in the case of a method that was annotated @Bean @ExternalBean, assign @Bean as
                    // the 'primary annotation'.  This will help in presenting an effective error message,
                    // as we assume that the users' intent was to mark this as a @Bean moreso than @ExternalBean
                    primaryAnnotatedMethod = method;
                else
                    // we're dealing with the second (or later) annotation in order now.  Add an error
                    // that lets the user know this annotation is mutually exclusive with relation to the
                    // primary annotation. e.g.: "@Bean method 'foo' cannot also have @ExternalBean annotation".
                    errors.add(primaryAnnotatedMethod.new IncompatibleAnnotationError(method.getMetadata()));
            }
        }

        // cascade through all declared @Bean methods
        for (BeanMethod method : beanMethods)
            method.detectUsageErrors(errors);

        // cascade through all declared @ExternalBean methods
        for (ExternalBeanMethod method : externalBeanMethods)
            method.detectUsageErrors(errors);

        // cascade through all declared @ExternalValue methods
        for (ExternalValueMethod method : externalValueMethods)
            method.detectUsageErrors(errors);

        // cascade through all declared @AutoBean methods
        for (AutoBeanMethod method : autoBeanMethods)
            method.detectUsageErrors(errors);

        // cascade through all remaning (non-javaconfig) methods
        for (NonJavaConfigMethod method : nonJavaConfigMethods)
            method.detectUsageErrors(errors);
    }


    @Override
    public String toString() {
        return format("%s; modifiers=%d; beanMethods=%s; externalBeanMethods=%s;"
                      + "externalValueMethods=%s; autoBeanMethods=%s",
                      super.toString(), modifiers, beanMethods, externalBeanMethods,
                      externalValueMethods, autoBeanMethods);
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = super.hashCode();
        result = prime * result + ((autoBeanMethods == null) ? 0 : autoBeanMethods.hashCode());
        result = prime * result + ((beanMethods == null) ? 0 : beanMethods.hashCode());
        result = prime * result + ((declaringClass == null) ? 0 : declaringClass.hashCode());
        result = prime * result + ((externalBeanMethods == null) ? 0 : externalBeanMethods.hashCode());
        result = prime * result + ((externalValueMethods == null) ? 0 : externalValueMethods.hashCode());
        result = prime * result + ((id == null) ? 0 : id.hashCode());
        result = prime * result + ((importedClasses == null) ? 0 : importedClasses.hashCode());
        result = prime * result + ((metadata == null) ? 0 : metadata.hashCode());
        result = prime * result + modifiers;
        result = prime * result + ((nonJavaConfigMethods == null) ? 0 : nonJavaConfigMethods.hashCode());
        result = prime * result + ((pluginAnnotations == null) ? 0 : pluginAnnotations.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (!super.equals(obj))
            return false;
        if (getClass() != obj.getClass())
            return false;
        ConfigurationClass other = (ConfigurationClass) obj;
        if (autoBeanMethods == null) {
            if (other.autoBeanMethods != null)
                return false;
        } else if (!autoBeanMethods.equals(other.autoBeanMethods))
            return false;
        if (beanMethods == null) {
            if (other.beanMethods != null)
                return false;
        } else if (!beanMethods.equals(other.beanMethods))
            return false;
        if (declaringClass == null) {
            if (other.declaringClass != null)
                return false;
        } else if (!declaringClass.equals(other.declaringClass))
            return false;
        if (externalBeanMethods == null) {
            if (other.externalBeanMethods != null)
                return false;
        } else if (!externalBeanMethods.equals(other.externalBeanMethods))
            return false;
        if (externalValueMethods == null) {
            if (other.externalValueMethods != null)
                return false;
        } else if (!externalValueMethods.equals(other.externalValueMethods))
            return false;
        if (id == null) {
            if (other.id != null)
                return false;
        } else if (!id.equals(other.id))
            return false;
        if (importedClasses == null) {
            if (other.importedClasses != null)
                return false;
        } else if (!importedClasses.equals(other.importedClasses))
            return false;
        if (metadata == null) {
            if (other.metadata != null)
                return false;
        } else if (!metadata.equals(other.metadata))
            return false;
        if (modifiers != other.modifiers)
            return false;
        if (nonJavaConfigMethods == null) {
            if (other.nonJavaConfigMethods != null)
                return false;
        } else if (!nonJavaConfigMethods.equals(other.nonJavaConfigMethods))
            return false;
        if (pluginAnnotations == null) {
            if (other.pluginAnnotations != null)
                return false;
        } else if (!pluginAnnotations.equals(other.pluginAnnotations))
            return false;
        return true;
    }

    @Configuration
    private class Prototype { }

    /** Configuration classes must be annotated with {@link Configuration @Configuration}. */
    public class NonAnnotatedConfigurationError extends UsageError {
        public NonAnnotatedConfigurationError() {
            super(ConfigurationClass.this, -1);
        }

        @Override
        public String getDescription() {
            return format("%s was provided as a Java Configuration class but was not annotated with @%s. "
                          + "Update the class definition to continue.",
                          getSimpleName(), Configuration.class.getSimpleName());
        }
    }

    /** Configuration classes must be non-final to accommodate CGLIB subclassing. */
    public class FinalConfigurationError extends UsageError {
        public FinalConfigurationError() {
            super(ConfigurationClass.this, -1);
        }

        @Override
        public String getDescription() {
            return format("@%s class may not be final. Remove the final modifier to continue.",
                          Configuration.class.getSimpleName());
        }
    }


    public class InvalidPluginException extends UsageError {

        private final Annotation invalidPluginAnnotation;

        public InvalidPluginException(Annotation invalidPluginAnnotation) {
            super(ConfigurationClass.this, -1);
            this.invalidPluginAnnotation = invalidPluginAnnotation;
        }

        @Override
        public String getDescription() {
            return format("Annotation [%s] was not annotated with @Plugin", invalidPluginAnnotation);
        }

    }

    /**
     * Error raised when a Bean marked as 'allowOverriding=false' is attempted to be overridden by
     * another bean definition.
     *
     * @see  Bean#allowOverriding()
     */
    public class IllegalBeanOverrideError extends UsageError {
        private final ConfigurationClass authoritativeClass;
        private final BeanMethod finalMethodInQuestion;

        /**
         * Creates a new IllegalBeanOverrideError object.
         *
         * @param  violatingClass         class attempting an illegal override. null value signifies
         *                                that the violating class is unknown or that there is no
         *                                class to speak of (in the case of an XML bean definition
         *                                doing the illegal overriding)
         * @param  finalMethodInQuestion  the method that has been marked 'allowOverriding=false'
         */
        public IllegalBeanOverrideError(ConfigurationClass violatingClass,
                                        BeanMethod finalMethodInQuestion) {
            super(violatingClass, -1);
            this.authoritativeClass = ConfigurationClass.this;
            this.finalMethodInQuestion = finalMethodInQuestion;
        }

        @Override
        public String getDescription() {
            return format("Illegal attempt by '%s' to override bean definition originally "
                          + "specified by %s.%s. Consider removing 'allowOverride=false' from original method.",
                          finalMethodInQuestion.getName(), authoritativeClass.getSimpleName(),
                          finalMethodInQuestion.getName());
        }
    }

    public boolean hasMethod(String methodName) {
        return getMethod(methodName) != null;
    }

    public ModelMethod getMethod(String methodName) {
        for(BeanMethod method : beanMethods)
            if(methodName.equals(method.getName()))
                return method;

        for(ExternalBeanMethod method : externalBeanMethods)
            if(methodName.equals(method.getName()))
                return method;

        for(ExternalValueMethod method : externalValueMethods)
            if(methodName.equals(method.getName()))
                return method;

        for(AutoBeanMethod method : autoBeanMethods)
            if(methodName.equals(method.getName()))
                return method;

        for(NonJavaConfigMethod method : nonJavaConfigMethods)
            if(methodName.equals(method.getName()))
                return method;

        return null;
    }

}
