/*
 * 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.ignite.configuration.processor.internal;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.util.Elements;
import javax.tools.Diagnostic;
import com.squareup.javapoet.ArrayTypeName;
import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.CodeBlock;
import com.squareup.javapoet.FieldSpec;
import com.squareup.javapoet.JavaFile;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.ParameterizedTypeName;
import com.squareup.javapoet.TypeName;
import com.squareup.javapoet.TypeSpec;
import com.squareup.javapoet.TypeVariableName;
import com.squareup.javapoet.WildcardTypeName;
import org.apache.ignite.configuration.NamedConfigurationTree;
import org.apache.ignite.configuration.annotation.Config;
import org.apache.ignite.configuration.annotation.ConfigValue;
import org.apache.ignite.configuration.annotation.ConfigurationRoot;
import org.apache.ignite.configuration.annotation.NamedConfigValue;
import org.apache.ignite.configuration.annotation.Value;
import org.apache.ignite.configuration.internal.NamedListConfiguration;
import org.apache.ignite.configuration.storage.ConfigurationType;
import org.apache.ignite.configuration.tree.ConfigurationSource;
import org.apache.ignite.configuration.tree.ConfigurationVisitor;
import org.apache.ignite.configuration.tree.InnerNode;
import org.apache.ignite.configuration.tree.NamedListChange;
import org.apache.ignite.configuration.tree.NamedListNode;
import org.apache.ignite.configuration.tree.NamedListView;

import static javax.lang.model.element.Modifier.ABSTRACT;
import static javax.lang.model.element.Modifier.FINAL;
import static javax.lang.model.element.Modifier.PRIVATE;
import static javax.lang.model.element.Modifier.PUBLIC;
import static javax.lang.model.element.Modifier.STATIC;
import static org.apache.ignite.configuration.processor.internal.Utils.suppressWarningsUnchecked;

/**
 * Annotation processor that produces configuration classes.
 */
public class Processor extends AbstractProcessor {
    /** Java file padding. */
    private static final String INDENT = "    ";

    /** Wildcard (?) TypeName. */
    private static final TypeName WILDCARD = WildcardTypeName.subtypeOf(Object.class);

    /** Inherit doc javadoc. */
    private static final String INHERIT_DOC = "{@inheritDoc}";

    /** */
    private static final ClassName ROOT_KEY_CLASSNAME = ClassName.get("org.apache.ignite.configuration", "RootKey");

    /**
     * Constructor.
     */
    public Processor() {
    }

    /** {@inheritDoc} */
    @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnvironment) {
        try {
            return process0(roundEnvironment);
        } catch (Throwable t) {
            StringWriter sw = new StringWriter();
            PrintWriter pw = new PrintWriter(sw);
            t.printStackTrace(pw);
            processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Failed to process configuration: " + sw.toString());
        }
        return false;
    }

    /**
     * Processes a set of annotation types on type elements.
     * @param roundEnvironment Processing environment.
     * @return Whether or not the set of annotation types are claimed by this processor.
     */
    private boolean process0(RoundEnvironment roundEnvironment) {
        final Elements elementUtils = processingEnv.getElementUtils();

        Map<TypeName, ConfigurationDescription> props = new HashMap<>();

        List<ConfigurationDescription> roots = new ArrayList<>();

        // All classes annotated with @Config
        final List<TypeElement> annotatedConfigs = roundEnvironment
            .getElementsAnnotatedWithAny(Set.of(ConfigurationRoot.class, Config.class)).stream()
            .filter(element -> element.getKind() == ElementKind.CLASS)
            .map(TypeElement.class::cast)
            .collect(Collectors.toList());

        if (annotatedConfigs.isEmpty())
            return false;

        for (TypeElement clazz : annotatedConfigs) {
            // Get package name of the schema class
            final PackageElement elementPackage = elementUtils.getPackageOf(clazz);
            final String packageName = elementPackage.getQualifiedName().toString();

            // Find all the fields of the schema
            final List<VariableElement> fields = clazz.getEnclosedElements().stream()
                .filter(el -> el.getKind() == ElementKind.FIELD)
                .map(VariableElement.class::cast)
                .collect(Collectors.toList());

            ConfigurationRoot rootAnnotation = clazz.getAnnotation(ConfigurationRoot.class);

            // Is root of the configuration
            final boolean isRoot = rootAnnotation != null;
            // Configuration name
            final String configName = isRoot ? rootAnnotation.rootName() : null;

            final ClassName schemaClassName = ClassName.get(packageName, clazz.getSimpleName().toString());

            // Get name for generated configuration class and it's interface
            final ClassName configClass = Utils.getConfigurationName(schemaClassName);
            final ClassName configInterface = Utils.getConfigurationInterfaceName(schemaClassName);

            ConfigurationDescription configDesc = new ConfigurationDescription(
                configClass,
                configName,
                Utils.getViewName(schemaClassName),
                Utils.getChangeName(schemaClassName)
            );

            // If root, then use it's package as package for Selectors and Keys
            if (isRoot)
                roots.add(configDesc);

            TypeSpec.Builder configurationClassBuilder = TypeSpec.classBuilder(configClass)
                .addSuperinterface(configInterface)
                .addModifiers(FINAL);

            TypeSpec.Builder configurationInterfaceBuilder = TypeSpec.interfaceBuilder(configInterface)
                .addModifiers(PUBLIC);

            CodeBlock.Builder constructorBodyBuilder = CodeBlock.builder();

            for (VariableElement field : fields) {
                if (field.getModifiers().contains(STATIC))
                    continue;

                if (!field.getModifiers().contains(PUBLIC))
                    throw new ProcessorException("Field " + clazz.getQualifiedName() + "." + field + " must be public");

                Element fieldTypeElement = processingEnv.getTypeUtils().asElement(field.asType());

                // Get original field type (must be another configuration schema or "primitive" like String or long)
                final TypeName baseType = TypeName.get(field.asType());

                final String fieldName = field.getSimpleName().toString();

                // Get configuration types (VIEW, INIT, CHANGE and so on)
                final ConfigurationFieldTypes types = getTypes(field);

                TypeName fieldType = types.getFieldType();
                TypeName viewClassType = types.getViewClassType();
                TypeName changeClassType = types.getChangeClassType();

                final ConfigValue confAnnotation = field.getAnnotation(ConfigValue.class);
                if (confAnnotation != null) {
                    if (fieldTypeElement.getAnnotation(Config.class) == null) {
                        throw new ProcessorException(
                            "Class for @ConfigValue field must be defined as @Config: " +
                                clazz.getQualifiedName() + "." + field.getSimpleName()
                        );
                    }

                    // Create DynamicConfiguration (descendant) field
                    final FieldSpec nestedConfigField =
                        FieldSpec
                            .builder(fieldType, fieldName, Modifier.PRIVATE, FINAL)
                            .build();

                    configurationClassBuilder.addField(nestedConfigField);

                    // Constructor statement
                    constructorBodyBuilder.addStatement("add($L = new $T(keys, $S, rootKey, changer))", fieldName, fieldType, fieldName);
                }

                final NamedConfigValue namedConfigAnnotation = field.getAnnotation(NamedConfigValue.class);
                if (namedConfigAnnotation != null) {
                    if (fieldTypeElement.getAnnotation(Config.class) == null) {
                        throw new ProcessorException(
                            "Class for @NamedConfigValue field must be defined as @Config: " +
                                clazz.getQualifiedName() + "." + field.getSimpleName()
                        );
                    }

                    // Create NamedListConfiguration<> field
                    final FieldSpec nestedConfigField = FieldSpec.builder(
                        fieldType,
                        fieldName,
                        Modifier.PRIVATE,
                        FINAL
                    ).build();

                    configurationClassBuilder.addField(nestedConfigField);

                    // Constructor statement
                    constructorBodyBuilder.addStatement(
                        "add($L = new $T(keys, $S, rootKey, changer, (p, k) -> new $T(p, k, rootKey, changer)))",
                        fieldName,
                        fieldType,
                        fieldName,
                        Utils.getConfigurationName((ClassName) baseType)
                    );
                }

                final Value valueAnnotation = field.getAnnotation(Value.class);
                if (valueAnnotation != null) {
                    // Must be a primitive or an array of the primitives (including java.lang.String)
                    if (!isPrimitiveOrArrayOfPrimitives(baseType)) {
                        throw new ProcessorException(
                            "@Value " + clazz.getQualifiedName() + "." + field.getSimpleName() + " field must" +
                                " have one of the following types: boolean, int, long, double, String or an array of " +
                                "aforementioned type."
                        );
                    }

                    // Create value (DynamicProperty<>) field
                    final FieldSpec generatedField = FieldSpec.builder(fieldType, fieldName, Modifier.PRIVATE, FINAL).build();

                    configurationClassBuilder.addField(generatedField);

                    // Constructor statement
                    constructorBodyBuilder.addStatement(
                        "add($L = new $T(keys, $S, rootKey, changer))",
                        fieldName, fieldType, fieldName
                    );
                }

                configDesc.getFields().add(new ConfigurationElement(fieldType, fieldName, viewClassType, changeClassType));

                createGetters(configurationClassBuilder, configurationInterfaceBuilder, fieldName, types);
            }

            props.put(configClass, configDesc);

            // Create VIEW, INIT and CHANGE classes
            createPojoBindings(clazz, fields, schemaClassName, configurationClassBuilder, configurationInterfaceBuilder);

            if (isRoot) {
                ConfigurationType storageType = rootAnnotation.type();
                createRootKeyField(configInterface, configurationInterfaceBuilder, configDesc, storageType, schemaClassName);
            }

            // Create constructors for configuration class
            createConstructors(isRoot, configName, configurationClassBuilder, constructorBodyBuilder);

            // Write configuration interface
            buildClass(packageName, configurationInterfaceBuilder.build());

            buildClass(packageName, configurationClassBuilder.build());
        }

        return true;
    }

    /** */
    private void createRootKeyField(
        ClassName configInterface,
        TypeSpec.Builder configurationClassBuilder,
        ConfigurationDescription configDesc,
        ConfigurationType storageType,
        ClassName schemaClassName
    ) {
        ClassName viewClassName = Utils.getViewName(schemaClassName);

        ParameterizedTypeName fieldTypeName = ParameterizedTypeName.get(ROOT_KEY_CLASSNAME, configInterface, viewClassName);

        ClassName nodeClassName = Utils.getNodeName(schemaClassName);

        ClassName cfgRegistryClassName = ClassName.get("org.apache.ignite.configuration", "ConfigurationRegistry");

        FieldSpec keyField = FieldSpec.builder(fieldTypeName, "KEY", PUBLIC, STATIC, FINAL)
            .initializer(
                "$T.newRootKey($S, $T.$L, $T::new, $T::new)",
                cfgRegistryClassName, configDesc.getName(), ConfigurationType.class, storageType, nodeClassName,
                Utils.getConfigurationName(schemaClassName)
            )
            .build();

        configurationClassBuilder.addField(keyField);
    }

    /**
     * Create getters for configuration class.
     *
     * @param configurationClassBuilder
     * @param configurationInterfaceBuilder
     * @param fieldName
     * @param types
     */
    private void createGetters(
        TypeSpec.Builder configurationClassBuilder,
        TypeSpec.Builder configurationInterfaceBuilder,
        String fieldName,
        ConfigurationFieldTypes types
    ) {
        MethodSpec interfaceGetMethod = MethodSpec.methodBuilder(fieldName)
            .addModifiers(PUBLIC, ABSTRACT)
            .returns(types.getInterfaceGetMethodType())
            .build();
        configurationInterfaceBuilder.addMethod(interfaceGetMethod);

        MethodSpec.Builder getMethodBuilder = MethodSpec.methodBuilder(fieldName)
            .addAnnotation(Override.class)
            .addJavadoc(INHERIT_DOC)
            .addModifiers(PUBLIC, FINAL)
            .returns(types.getInterfaceGetMethodType());

        if (Utils.isNamedConfiguration(types.getFieldType())) {
            getMethodBuilder.addAnnotation(suppressWarningsUnchecked())
                .addStatement("return ($T)$L", NamedConfigurationTree.class, fieldName);
        }
        else
            getMethodBuilder.addStatement("return $L", fieldName);

        configurationClassBuilder.addMethod(getMethodBuilder.build());
    }

    /**
     * Get types for configuration classes generation.
     * @param field
     * @return Bundle with all types for configuration
     */
    private ConfigurationFieldTypes getTypes(final VariableElement field) {
        TypeName fieldType = null;
        TypeName interfaceGetMethodType = null;

        final TypeName baseType = TypeName.get(field.asType());

        TypeName unwrappedType = baseType;
        TypeName viewClassType = baseType;
        TypeName changeClassType = baseType;

        final ConfigValue confAnnotation = field.getAnnotation(ConfigValue.class);
        if (confAnnotation != null) {
            fieldType = Utils.getConfigurationName((ClassName) baseType);
            interfaceGetMethodType = Utils.getConfigurationInterfaceName((ClassName) baseType);

            unwrappedType = fieldType;
            viewClassType = Utils.getViewName((ClassName) baseType);
            changeClassType = Utils.getChangeName((ClassName) baseType);
        }

        final NamedConfigValue namedConfigAnnotation = field.getAnnotation(NamedConfigValue.class);
        if (namedConfigAnnotation != null) {
            ClassName interfaceGetType = Utils.getConfigurationInterfaceName((ClassName) baseType);

            viewClassType = Utils.getViewName((ClassName) baseType);
            changeClassType = Utils.getChangeName((ClassName) baseType);

            fieldType = ParameterizedTypeName.get(ClassName.get(NamedListConfiguration.class), interfaceGetType, viewClassType, changeClassType);
            interfaceGetMethodType = ParameterizedTypeName.get(ClassName.get(NamedConfigurationTree.class), interfaceGetType, viewClassType, changeClassType);
        }

        final Value valueAnnotation = field.getAnnotation(Value.class);
        if (valueAnnotation != null) {
            // It is necessary to use class names without loading classes so that we won't
            // accidentally get NoClassDefFoundError
            ClassName dynPropClass = ClassName.get("org.apache.ignite.configuration.internal", "DynamicProperty");
            ClassName confValueClass = ClassName.get("org.apache.ignite.configuration", "ConfigurationValue");

            TypeName genericType = baseType;

            if (genericType.isPrimitive()) {
                genericType = genericType.box();
            }

            fieldType = ParameterizedTypeName.get(dynPropClass, genericType);
            interfaceGetMethodType = ParameterizedTypeName.get(confValueClass, genericType);
        }

        return new ConfigurationFieldTypes(fieldType, unwrappedType, viewClassType, changeClassType, interfaceGetMethodType);
    }

    /**
     * Wrapper for configuration schema types.
     */
    private static class ConfigurationFieldTypes {
        /** Field get method type. */
        private final TypeName fieldType;

        /** Configuration type (if marked with @ConfigValue or @NamedConfig), or original type (if marked with @Value) */
        private final TypeName unwrappedType;

        /** VIEW object type. */
        private final TypeName viewClassType;

        /** CHANGE object type. */
        private final TypeName changeClassType;

        /** Get method type for public interface. */
        private final TypeName interfaceGetMethodType;

        private ConfigurationFieldTypes(TypeName fieldType, TypeName unwrappedType, TypeName viewClassType, TypeName changeClassType, TypeName interfaceGetMethodType) {
            this.fieldType = fieldType;
            this.unwrappedType = unwrappedType;
            this.viewClassType = viewClassType;
            this.changeClassType = changeClassType;
            this.interfaceGetMethodType = interfaceGetMethodType;
        }

        /** */
        public TypeName getInterfaceGetMethodType() {
            return interfaceGetMethodType;
        }

        /** */
        public TypeName getFieldType() {
            return fieldType;
        }

        /** */
        public TypeName getUnwrappedType() {
            return unwrappedType;
        }

        /** */
        public TypeName getViewClassType() {
            return viewClassType;
        }

        /** */
        public TypeName getChangeClassType() {
            return changeClassType;
        }
    }

    /**
     * Create configuration class constructors.
     *
     * @param isRoot Flag that indincates whether current configuration is root or not.
     * @param configName Name of the root if configuration is root, {@code null} otherwise.
     * @param configurationClassBuilder Configuration class builder.
     * @param constructorBodyBuilder Constructor body.
     */
    private void createConstructors(
        boolean isRoot,
        String configName,
        TypeSpec.Builder configurationClassBuilder,
        CodeBlock.Builder constructorBodyBuilder
    ) {
        MethodSpec.Builder builder = MethodSpec.constructorBuilder().addModifiers(PUBLIC);

        if (!isRoot) {
            builder
                .addParameter(ParameterizedTypeName.get(List.class, String.class), "prefix")
                .addParameter(String.class, "key");
        }

        builder
            .addParameter(ParameterizedTypeName.get(ROOT_KEY_CLASSNAME, WILDCARD, WILDCARD), "rootKey")
            .addParameter(ClassName.get("org.apache.ignite.configuration", "ConfigurationChanger"), "changer");

        if (isRoot)
            builder.addStatement("super($T.emptyList(), $S, rootKey, changer)", Collections.class, configName);
        else
            builder.addStatement("super(prefix, key, rootKey, changer)");

        MethodSpec constructorWithName = builder
            .addCode(constructorBodyBuilder.build())
            .build();

        configurationClassBuilder.addMethod(constructorWithName);
    }

    /**
     * Create VIEW, INIT and CHANGE classes and methods.
     * @param clazz Original class for the schema.
     * @param fields List of configuration fields.
     * @param schemaClassName Class name of schema.
     * @param configurationClassBuilder Configuration class builder.
     */
    private void createPojoBindings(
        TypeElement clazz,
        List<VariableElement> fields,
        ClassName schemaClassName,
        TypeSpec.Builder configurationClassBuilder,
        TypeSpec.Builder configurationInterfaceBuilder
    ) {
        final ClassName viewClassTypeName = Utils.getViewName(schemaClassName);
        final ClassName changeClassName = Utils.getChangeName(schemaClassName);

        ClassName dynConfClass = ClassName.get("org.apache.ignite.configuration.internal", "DynamicConfiguration");
        TypeName dynConfViewClassType = ParameterizedTypeName.get(dynConfClass, viewClassTypeName, changeClassName);

        configurationClassBuilder.superclass(dynConfViewClassType);

        ClassName confTreeInterface = ClassName.get("org.apache.ignite.configuration", "ConfigurationTree");
        TypeName confTreeParameterized = ParameterizedTypeName.get(confTreeInterface, viewClassTypeName, changeClassName);

        configurationInterfaceBuilder.addSuperinterface(confTreeParameterized);

        // This code will be refactored in the future. Right now I don't want to entangle it with existing code
        // generation. It has only a few considerable problems - hardcode and a lack of proper arrays handling.
        // Clone method should be used to guarantee data integrity.
        ClassName viewClsName = Utils.getViewName(schemaClassName);

        ClassName changeClsName = Utils.getChangeName(schemaClassName);

        ClassName nodeClsName = Utils.getNodeName(schemaClassName);

        TypeSpec.Builder viewClsBuilder = TypeSpec.interfaceBuilder(viewClsName)
            .addModifiers(PUBLIC);

        TypeSpec.Builder changeClsBuilder = TypeSpec.interfaceBuilder(changeClsName)
            .addModifiers(PUBLIC);

        TypeSpec.Builder nodeClsBuilder = TypeSpec.classBuilder(nodeClsName)
            .addModifiers(FINAL)
            .superclass(ClassName.get(InnerNode.class))
            .addSuperinterface(viewClsName)
            .addSuperinterface(changeClsName)
            // Cannot use "schemaClassName" here because it can't handle inner static classes.
            .addField(FieldSpec.builder(ClassName.get(clazz), "_spec", PRIVATE, FINAL)
                .initializer("new $T()", ClassName.get(clazz))
                .build()
            );

        TypeVariableName t = TypeVariableName.get("T");

        MethodSpec.Builder traverseChildrenBuilder = MethodSpec.methodBuilder("traverseChildren")
            .addAnnotation(Override.class)
            .addJavadoc(INHERIT_DOC)
            .addModifiers(PUBLIC)
            .addTypeVariable(t)
            .returns(TypeName.VOID)
            .addParameter(ParameterizedTypeName.get(ClassName.get(ConfigurationVisitor.class), t), "visitor");

        MethodSpec.Builder traverseChildBuilder = MethodSpec.methodBuilder("traverseChild")
            .addAnnotation(Override.class)
            .addJavadoc(INHERIT_DOC)
            .addModifiers(PUBLIC)
            .addTypeVariable(t)
            .returns(t)
            .addException(NoSuchElementException.class)
            .addParameter(ClassName.get(String.class), "key")
            .addParameter(ParameterizedTypeName.get(ClassName.get(ConfigurationVisitor.class), t), "visitor")
            .beginControlFlow("switch (key)");

        MethodSpec.Builder constructBuilder = MethodSpec.methodBuilder("construct")
            .addAnnotation(Override.class)
            .addJavadoc(INHERIT_DOC)
            .addModifiers(PUBLIC)
            .returns(TypeName.VOID)
            .addException(NoSuchElementException.class)
            .addParameter(ClassName.get(String.class), "key")
            .addParameter(ClassName.get(ConfigurationSource.class), "src")
            .beginControlFlow("switch (key)");

        MethodSpec.Builder constructDefaultBuilder = MethodSpec.methodBuilder("constructDefault")
            .addAnnotation(Override.class)
            .addJavadoc(INHERIT_DOC)
            .addModifiers(PUBLIC)
            .returns(TypeName.BOOLEAN)
            .addException(NoSuchElementException.class)
            .addParameter(ClassName.get(String.class), "key")
            .beginControlFlow("switch (key)");

        MethodSpec.Builder schemaTypeBuilder = MethodSpec.methodBuilder("schemaType")
            .addAnnotation(Override.class)
            .addJavadoc(INHERIT_DOC)
            .addModifiers(PUBLIC)
            .returns(ParameterizedTypeName.get(ClassName.get(Class.class), WILDCARD))
            .addStatement("return _spec.getClass()");

        ClassName consumerClsName = ClassName.get(Consumer.class);

        for (VariableElement field : fields) {
            Value valAnnotation = field.getAnnotation(Value.class);

            String fieldName = field.getSimpleName().toString();
            TypeName schemaFieldType = TypeName.get(field.asType());

            boolean isArray = schemaFieldType instanceof ArrayTypeName;

            boolean leafField = isPrimitiveOrArrayOfPrimitives(schemaFieldType)
                || !((ClassName)schemaFieldType).simpleName().contains("ConfigurationSchema");

            boolean namedListField = field.getAnnotation(NamedConfigValue.class) != null;

            TypeName viewFieldType = leafField ? schemaFieldType : Utils.getViewName((ClassName)schemaFieldType);

            TypeName changeFieldType = leafField ? schemaFieldType : Utils.getChangeName((ClassName)schemaFieldType);

            TypeName nodeFieldType = leafField ? schemaFieldType.box() : Utils.getNodeName((ClassName)schemaFieldType);

            TypeName namedListParamType = nodeFieldType;

            if (namedListField) {
                viewFieldType = ParameterizedTypeName.get(ClassName.get(NamedListView.class), WildcardTypeName.subtypeOf(viewFieldType));

                nodeFieldType = ParameterizedTypeName.get(ClassName.get(NamedListNode.class), nodeFieldType);

                changeFieldType = ParameterizedTypeName.get(ClassName.get(NamedListChange.class), changeFieldType);
            }

            {
                FieldSpec.Builder nodeFieldBuilder = FieldSpec.builder(nodeFieldType, fieldName, PRIVATE);

                if (namedListField)
                    nodeFieldBuilder.initializer("new $T<>($T::new)", NamedListNode.class, namedListParamType);

                nodeClsBuilder.addField(nodeFieldBuilder.build());
            }

            {
                {
                    MethodSpec.Builder getMtdBuilder = MethodSpec.methodBuilder(fieldName)
                        .addModifiers(PUBLIC, ABSTRACT)
                        .returns(viewFieldType);

                    viewClsBuilder.addMethod(getMtdBuilder.build());
                }

                {
                    CodeBlock getStatement;

                    if (isArray)
                        getStatement = CodeBlock.builder().add("return $L.clone()", fieldName).build();
                    else
                        getStatement = CodeBlock.builder().add("return $L", fieldName).build();

                    MethodSpec.Builder nodeGetMtdBuilder = MethodSpec.methodBuilder(fieldName)
                        .addAnnotation(Override.class)
                        .addModifiers(PUBLIC)
                        .returns(leafField ? viewFieldType : nodeFieldType)
                        .addStatement(getStatement);

                    nodeClsBuilder.addMethod(nodeGetMtdBuilder.build());
                }
            }

            {
                String changeMtdName = "change" + capitalize(fieldName);

                {
                    MethodSpec.Builder changeMtdBuilder = MethodSpec.methodBuilder(changeMtdName)
                        .addModifiers(PUBLIC, ABSTRACT)
                        .returns(changeClsName);

                    if (valAnnotation != null)
                        changeMtdBuilder.addParameter(changeFieldType, fieldName);
                    else
                        changeMtdBuilder.addParameter(ParameterizedTypeName.get(consumerClsName, changeFieldType), fieldName);

                    changeClsBuilder.addMethod(changeMtdBuilder.build());
                }

                {
                    MethodSpec.Builder nodeChangeMtdBuilder = MethodSpec.methodBuilder(changeMtdName)
                        .addAnnotation(Override.class)
                        .addModifiers(PUBLIC)
                        .returns(nodeClsName);

                    if (valAnnotation != null) {
                        CodeBlock changeStatement;

                        if (isArray)
                            changeStatement = CodeBlock.builder().add("this.$L = $L.clone()", fieldName, fieldName).build();
                        else
                            changeStatement = CodeBlock.builder().add("this.$L = $L", fieldName, fieldName).build();

                        nodeChangeMtdBuilder
                            .addParameter(changeFieldType, fieldName)
                            .addStatement(changeStatement);
                    }
                    else {
                        String paramName = fieldName + "Consumer";
                        nodeChangeMtdBuilder.addParameter(ParameterizedTypeName.get(consumerClsName, changeFieldType), paramName);

                        if (!namedListField) {
                            nodeChangeMtdBuilder.addStatement(
                                "if ($L == null) $L = new $T()",
                                fieldName,
                                fieldName,
                                nodeFieldType
                            );
                            nodeChangeMtdBuilder.addStatement("$L.accept($L)", paramName, fieldName);
                        }
                        else {
                            nodeChangeMtdBuilder.addAnnotation(suppressWarningsUnchecked());

                            nodeChangeMtdBuilder.addStatement("$L.accept((NamedListChange)$L)", paramName, fieldName);
                        }
                    }

                    nodeChangeMtdBuilder.addStatement("return this");

                    nodeClsBuilder.addMethod(nodeChangeMtdBuilder.build());
                }
            }

            {
                if (leafField) {
                    traverseChildrenBuilder.addStatement("visitor.visitLeafNode($S, $L)", fieldName, fieldName);

                    traverseChildBuilder
                        .addStatement("case $S: return visitor.visitLeafNode(key, $L)", fieldName, fieldName);
                }
                else if (namedListField) {
                    traverseChildrenBuilder.addStatement("visitor.visitNamedListNode($S, $L)", fieldName, fieldName);

                    traverseChildBuilder
                        .addStatement("case $S: return visitor.visitNamedListNode(key, $L)", fieldName, fieldName);
                }
                else {
                    traverseChildrenBuilder.addStatement("visitor.visitInnerNode($S, $L)", fieldName, fieldName);

                    traverseChildBuilder
                        .addStatement("case $S: return visitor.visitInnerNode(key, $L)", fieldName, fieldName);
                }
            }

            {
                if (leafField) {
                    constructBuilder.addStatement(
                        "case $S: $L = src == null ? null : src.unwrap($T.class)",
                        fieldName,
                        fieldName,
                        schemaFieldType.box()
                    )
                    .addStatement(INDENT + "break");

                    if (valAnnotation.hasDefault()) {
                        constructDefaultBuilder
                            .addStatement("case $S: $L = _spec.$L" + (isArray ? ".clone()" : ""), fieldName, fieldName, fieldName)
                            .addStatement(INDENT + "return true");
                    }
                    else
                        constructDefaultBuilder.addStatement("case $S: return false", fieldName);
                }
                else if (namedListField) {
                    constructBuilder
                        .addStatement(
                            "case $S: if (src == null) $L = new $T<>($T::new)",
                            fieldName,
                            fieldName,
                            NamedListNode.class,
                            namedListParamType
                        )
                        .addStatement(
                            INDENT + "else src.descend($L = $L.copy())",
                            fieldName,
                            fieldName
                        )
                        .addStatement(INDENT + "break");
                }
                else {
                    constructBuilder
                        .addStatement(
                            "case $S: if (src == null) $L = null",
                            fieldName,
                            fieldName
                        )
                        .addStatement(
                            INDENT + "else src.descend($L = ($L == null ? new $T() : ($T)$L.copy()))",
                            fieldName,
                            fieldName,
                            nodeFieldType,
                            nodeFieldType,
                            fieldName
                        )
                        .addStatement(INDENT + "break");
                }
            }
        }

        traverseChildBuilder
            .addStatement("default: throw new $T(key)", NoSuchElementException.class)
            .endControlFlow();

        constructBuilder
            .addStatement("default: throw new $T(key)", NoSuchElementException.class)
            .endControlFlow();

        constructDefaultBuilder
            .addStatement("default: throw new $T(key)", NoSuchElementException.class)
            .endControlFlow();

        nodeClsBuilder
            .addMethod(traverseChildrenBuilder.build())
            .addMethod(traverseChildBuilder.build())
            .addMethod(constructBuilder.build())
            .addMethod(constructDefaultBuilder.build())
            .addMethod(schemaTypeBuilder.build());

        TypeSpec viewCls = viewClsBuilder.build();
        TypeSpec changeCls = changeClsBuilder.build();
        TypeSpec nodeCls = nodeClsBuilder.build();

        buildClass(viewClsName.packageName(), viewCls);
        buildClass(changeClsName.packageName(), changeCls);
        buildClass(nodeClsName.packageName(), nodeCls);
    }

    /** */
    private void buildClass(String packageName, TypeSpec cls) {
        try {
            JavaFile.builder(packageName, cls)
                .indent(INDENT)
                .build()
                .writeTo(processingEnv.getFiler());
        }
        catch (IOException e) {
            throw new ProcessorException("Failed to generate class " + packageName + "." + cls.name, e);
        }
    }

    /** */
    private static String capitalize(String name) {
        return name.substring(0, 1).toUpperCase() + name.substring(1);
    }

    /**
     * Checks whether TypeName is a primitive (or String) or an array of primitives (or Strings)
     * @param typeName TypeName.
     * @return {@code true} if type is primitive or array.
     */
    private boolean isPrimitiveOrArrayOfPrimitives(TypeName typeName) {
        String type = typeName.toString();

        if (typeName instanceof ArrayTypeName)
            type = ((ArrayTypeName) typeName).componentType.toString();

        switch (type) {
            case "boolean":
            case "int":
            case "long":
            case "double":
            case "java.lang.String":
                return true;

            default:
                return false;

        }
    }

    /** {@inheritDoc} */
    @Override public Set<String> getSupportedAnnotationTypes() {
        return Set.of(Config.class.getCanonicalName(), ConfigurationRoot.class.getCanonicalName());
    }

    /** {@inheritDoc} */
    @Override public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.RELEASE_11;
    }
}
