/*
 * Copyright 2012-2014 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.boot.configurationprocessor;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.Processor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.AnnotationValue;
import javax.lang.model.element.Element;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.TypeKind;
import javax.lang.model.util.Elements;
import javax.tools.Diagnostic.Kind;
import javax.tools.FileObject;
import javax.tools.StandardLocation;

import org.springframework.boot.configurationprocessor.fieldvalues.FieldValuesParser;
import org.springframework.boot.configurationprocessor.fieldvalues.javac.JavaCompilerFieldValuesParser;
import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata;
import org.springframework.boot.configurationprocessor.metadata.ItemMetadata;
import org.springframework.boot.configurationprocessor.metadata.JsonMarshaller;

/**
 * Annotation {@link Processor} that writes meta-data file for
 * {@code @ConfigurationProperties}.
 *
 * @author Stephane Nicoll
 * @author Phillip Webb
 * @since 1.2.0
 */
@SupportedAnnotationTypes({ "*" })
@SupportedSourceVersion(SourceVersion.RELEASE_6)
public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor {

	static final String CONFIGURATION_PROPERTIES_ANNOTATION = "org.springframework.boot."
			+ "context.properties.ConfigurationProperties";

	static final String NESTED_CONFIGURATION_PROPERTY_ANNOTATION = "org.springframework.boot."
			+ "context.properties.NestedConfigurationProperty";

	private ConfigurationMetadata metadata;

	private TypeUtils typeUtils;

	private FieldValuesParser fieldValuesParser;

	protected String configurationPropertiesAnnotation() {
		return CONFIGURATION_PROPERTIES_ANNOTATION;
	}

	protected String nestedConfigurationPropertyAnnotation() {
		return NESTED_CONFIGURATION_PROPERTY_ANNOTATION;
	}

	@Override
	public synchronized void init(ProcessingEnvironment env) {
		super.init(env);
		this.metadata = new ConfigurationMetadata();
		this.typeUtils = new TypeUtils(env);
		try {
			this.fieldValuesParser = new JavaCompilerFieldValuesParser(env);
		}
		catch (Throwable ex) {
			this.fieldValuesParser = FieldValuesParser.NONE;
			logWarning("Field value processing of @ConfigurationProperty meta-data is "
					+ "not supported");
		}
	}

	private void logWarning(String msg) {
		this.processingEnv.getMessager().printMessage(Kind.WARNING, msg);
	}

	@Override
	public boolean process(Set<? extends TypeElement> annotations,
			RoundEnvironment roundEnv) {
		Elements elementUtils = this.processingEnv.getElementUtils();
		for (Element element : roundEnv.getElementsAnnotatedWith(elementUtils
				.getTypeElement(configurationPropertiesAnnotation()))) {
			processElement(element);
		}
		if (roundEnv.processingOver()) {
			writeMetaData(this.metadata);
		}
		return false;
	}

	private void processElement(Element element) {
		AnnotationMirror annotation = getAnnotation(element,
				configurationPropertiesAnnotation());
		String prefix = getPrefix(annotation);
		if (annotation != null) {
			if (element instanceof TypeElement) {
				processAnnotatedTypeElement(prefix, (TypeElement) element);
			}
			else if (element instanceof ExecutableElement) {
				processExecutableElement(prefix, (ExecutableElement) element);
			}
		}
	}

	private void processAnnotatedTypeElement(String prefix, TypeElement element) {
		String type = this.typeUtils.getType(element);
		this.metadata.add(ItemMetadata.newGroup(prefix, type, type, null));
		processTypeElement(prefix, element);
	}

	private void processExecutableElement(String prefix, ExecutableElement element) {
		if (element.getModifiers().contains(Modifier.PUBLIC)
				&& (TypeKind.VOID != element.getReturnType().getKind())) {
			Element returns = this.processingEnv.getTypeUtils().asElement(
					element.getReturnType());
			if (returns instanceof TypeElement) {
				this.metadata.add(ItemMetadata.newGroup(prefix,
						this.typeUtils.getType(returns),
						this.typeUtils.getType(element.getEnclosingElement()),
						element.toString()));
				processTypeElement(prefix, (TypeElement) returns);
			}
		}
	}

	private void processTypeElement(String prefix, TypeElement element) {
		TypeElementMembers members = new TypeElementMembers(this.processingEnv, element);
		Map<String, Object> fieldValues = getFieldValues(element);
		processSimpleTypes(prefix, element, members, fieldValues);
		processNestedTypes(prefix, element, members);
	}

	private Map<String, Object> getFieldValues(TypeElement element) {
		try {
			return this.fieldValuesParser.getFieldValues(element);
		}
		catch (Exception ex) {
			return Collections.emptyMap();
		}
	}

	private void processSimpleTypes(String prefix, TypeElement element,
			TypeElementMembers members, Map<String, Object> fieldValues) {
		for (Map.Entry<String, ExecutableElement> entry : members.getPublicGetters()
				.entrySet()) {
			String name = entry.getKey();
			ExecutableElement getter = entry.getValue();
			ExecutableElement setter = members.getPublicSetters().get(name);
			VariableElement field = members.getFields().get(name);
			boolean isNested = getAnnotation(field,
					nestedConfigurationPropertyAnnotation()) != null;
			boolean isCollection = this.typeUtils.isCollectionOrMap(getter
					.getReturnType());
			if (!isNested && (setter != null || isCollection)) {
				String dataType = this.typeUtils.getType(getter.getReturnType());
				String sourceType = this.typeUtils.getType(element);
				String description = this.typeUtils.getJavaDoc(field);
				Object defaultValue = fieldValues.get(name);
				this.metadata.add(ItemMetadata.newProperty(prefix, name, dataType,
						sourceType, null, description, defaultValue));
			}
		}
	}

	private void processNestedTypes(String prefix, TypeElement element,
			TypeElementMembers members) {
		for (Map.Entry<String, ExecutableElement> entry : members.getPublicGetters()
				.entrySet()) {
			String name = entry.getKey();
			ExecutableElement getter = entry.getValue();
			VariableElement field = members.getFields().get(name);
			Element returnType = this.processingEnv.getTypeUtils().asElement(
					getter.getReturnType());
			AnnotationMirror annotation = getAnnotation(getter,
					configurationPropertiesAnnotation());
			boolean isNested = getAnnotation(field,
					nestedConfigurationPropertyAnnotation()) != null;
			if (returnType != null && returnType instanceof TypeElement
					&& annotation == null) {
				TypeElement returns = (TypeElement) returnType;
				if (this.typeUtils.isEnclosedIn(returnType, element) || isNested) {
					String nestedPrefix = ConfigurationMetadata
							.nestedPrefix(prefix, name);
					this.metadata.add(ItemMetadata.newGroup(nestedPrefix,
							this.typeUtils.getType(returns),
							this.typeUtils.getType(element), getter.toString()));
					processTypeElement(nestedPrefix, returns);
				}
			}
		}
	}

	private AnnotationMirror getAnnotation(Element element, String type) {
		if (element != null) {
			for (AnnotationMirror annotation : element.getAnnotationMirrors()) {
				if (type.equals(annotation.getAnnotationType().toString())) {
					return annotation;
				}
			}
		}
		return null;
	}

	private String getPrefix(AnnotationMirror annotation) {
		Map<String, Object> elementValues = getAnnotationElementValues(annotation);
		Object prefix = elementValues.get("prefix");
		if (prefix != null && !"".equals(prefix)) {
			return (String) prefix;
		}
		Object value = elementValues.get("value");
		if (value != null && !"".equals(value)) {
			return (String) value;
		}
		return null;
	}

	private Map<String, Object> getAnnotationElementValues(AnnotationMirror annotation) {
		Map<String, Object> values = new LinkedHashMap<String, Object>();
		for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> entry : annotation
				.getElementValues().entrySet()) {
			values.put(entry.getKey().getSimpleName().toString(), entry.getValue()
					.getValue());
		}
		return values;
	}

	protected void writeMetaData(ConfigurationMetadata metadata) {
		metadata = mergeManualMetadata(metadata);
		if (!metadata.getItems().isEmpty()) {
			try {
				FileObject resource = this.processingEnv.getFiler().createResource(
						StandardLocation.CLASS_OUTPUT, "",
						"META-INF/spring-configuration-metadata.json");
				OutputStream outputStream = resource.openOutputStream();
				try {
					new JsonMarshaller().write(metadata, outputStream);
				}
				finally {
					outputStream.close();
				}
			}
			catch (Exception ex) {
				throw new IllegalStateException(ex);
			}
		}
	}

	private ConfigurationMetadata mergeManualMetadata(ConfigurationMetadata metadata) {
		try {
			FileObject manualMetadata = this.processingEnv.getFiler().getResource(
					StandardLocation.CLASS_PATH, "",
					"META-INF/additional-spring-configuration-metadata.json");
			if (!"file".equals(manualMetadata.toUri().getScheme())) {
				// We only want local files, not any classpath jars
				return metadata;
			}
			InputStream inputStream = manualMetadata.openInputStream();
			try {
				ConfigurationMetadata merged = new ConfigurationMetadata(metadata);
				try {
					merged.addAll(new JsonMarshaller().read(inputStream));
				}
				catch (Exception ex) {
					throw new IllegalStateException(ex);
				}
				return merged;
			}
			finally {
				inputStream.close();
			}
		}
		catch (IOException ex) {
			return metadata;
		}
	}

}
