/**
 * Copyright 2011-2021 Asakusa Framework Team.
 *
 * 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 com.asakusafw.dmdl.java.emitter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import com.asakusafw.dmdl.java.Configuration;
import com.asakusafw.dmdl.java.spi.JavaDataModelDriver;
import com.asakusafw.dmdl.java.util.JavaName;
import com.asakusafw.dmdl.semantics.DmdlSemantics;
import com.asakusafw.dmdl.semantics.ModelDeclaration;
import com.asakusafw.dmdl.semantics.PropertyDeclaration;
import com.asakusafw.dmdl.semantics.PropertyReferenceDeclaration;
import com.asakusafw.dmdl.semantics.PropertySymbol;
import com.asakusafw.runtime.model.DataModel;
import com.asakusafw.runtime.value.ValueOptionList;
import com.asakusafw.runtime.value.ValueOptionMap;
import com.asakusafw.utils.java.model.syntax.Attribute;
import com.asakusafw.utils.java.model.syntax.Expression;
import com.asakusafw.utils.java.model.syntax.FieldDeclaration;
import com.asakusafw.utils.java.model.syntax.FormalParameterDeclaration;
import com.asakusafw.utils.java.model.syntax.MethodDeclaration;
import com.asakusafw.utils.java.model.syntax.ModelFactory;
import com.asakusafw.utils.java.model.syntax.SimpleName;
import com.asakusafw.utils.java.model.syntax.Statement;
import com.asakusafw.utils.java.model.syntax.Type;
import com.asakusafw.utils.java.model.syntax.TypeBodyDeclaration;
import com.asakusafw.utils.java.model.syntax.UnaryOperator;
import com.asakusafw.utils.java.model.util.AttributeBuilder;
import com.asakusafw.utils.java.model.util.ExpressionBuilder;
import com.asakusafw.utils.java.model.util.JavadocBuilder;
import com.asakusafw.utils.java.model.util.Models;
import com.asakusafw.utils.java.model.util.TypeBuilder;

/**
 * Abstract super class which emits a record/joined/summarized model as a Java model class.
 * @since 0.2.0
 * @version 0.9.2
 */
public class ConcreteModelEmitter {

    private final ModelDeclaration model;

    private final EmitContext context;

    private final JavaDataModelDriver driver;

    private final ModelFactory f;

    /**
     * Creates and returns a new instance.
     * @param semantics the semantic model root
     * @param config the current configuration
     * @param model the model to emit
     * @param driver the emitter driver
     * @throws IllegalArgumentException if some parameters were {@code null}
     */
    public ConcreteModelEmitter(
            DmdlSemantics semantics,
            Configuration config,
            ModelDeclaration model,
            JavaDataModelDriver driver) {
        if (semantics == null) {
            throw new IllegalArgumentException("semantics must not be null"); //$NON-NLS-1$
        }
        if (config == null) {
            throw new IllegalArgumentException("config must not be null"); //$NON-NLS-1$
        }
        if (driver == null) {
            throw new IllegalArgumentException("driver must not be null"); //$NON-NLS-1$
        }
        if (model == null) {
            throw new IllegalArgumentException("model must not be null"); //$NON-NLS-1$
        }
        this.model = model;
        this.context = new EmitContext(
                semantics,
                config,
                model,
                NameConstants.CATEGORY_DATA_MODEL,
                NameConstants.PATTERN_DATA_MODEL);
        this.driver = driver;
        this.f = config.getFactory();
    }

    /**
     * Emits the projective model.
     * @throws IOException if failed to emit a source program
     */
    public void emit() throws IOException {
        driver.generateResources(context, model);
        context.emit(f.newClassDeclaration(
                new JavadocBuilder(f)
                    .text(Messages.getString("ConcreteModelEmitter.javadocClass"), //$NON-NLS-1$
                            context.getDescription(model))
                    .toJavadoc(),
                createModifiers(),
                context.getTypeName(),
                null,
                createSuperInterfaces(),
                createMembers()));
    }

    private List<Attribute> createModifiers() throws IOException {
        List<Attribute> results = new ArrayList<>();
        results.addAll(driver.getTypeAnnotations(context, model));
        results.addAll(new AttributeBuilder(f)
            .Public()
            .toAttributes());
        return results;
    }

    private List<Type> createSuperInterfaces() throws IOException {
        List<Type> results = new ArrayList<>();
        results.add(f.newParameterizedType(
                context.resolve(DataModel.class),
                context.resolve(context.getQualifiedTypeName())));
        results.addAll(driver.getInterfaces(context, model));
        return results;
    }

    private List<TypeBodyDeclaration> createMembers() throws IOException {
        List<TypeBodyDeclaration> results = new ArrayList<>();
        results.addAll(createPropertyFields());
        results.addAll(createReferenceFields());
        results.addAll(driver.getFields(context, model));
        results.addAll(createDataModelMethods());
        results.addAll(createPropertyAccessors());
        results.addAll(createReferenceAccessors());
        results.addAll(driver.getMethods(context, model));
        return results;
    }

    private List<FieldDeclaration> createPropertyFields() {
        List<FieldDeclaration> results = new ArrayList<>();
        for (PropertyDeclaration property : model.getDeclaredProperties()) {
            Type type = context.getFieldType(property);
            SimpleName name = context.getFieldName(property);
            results.add(f.newFieldDeclaration(
                    null,
                    new AttributeBuilder(f)
                        .Private()
                        .Final()
                        .toAttributes(),
                    type,
                    name,
                    context.getFieldInitializer(property)));
        }
        return results;
    }

    private List<FieldDeclaration> createReferenceFields() {
        List<FieldDeclaration> results = new ArrayList<>();
        for (PropertyReferenceDeclaration reference : model.getDeclaredPropertyReferences()) {
            switch (reference.getReference().getKind()) {
            case LIST:
                results.add(createReferenceListField(reference));
                break;
            case MAP:
                results.add(createReferenceMapKeyField(reference));
                results.add(createReferenceMapField(reference));
                break;
            default:
                throw new AssertionError(reference.getReference().getKind());
            }
        }
        return results;
    }

    private FieldDeclaration createReferenceMapKeyField(PropertyReferenceDeclaration reference) {
        Map<String, PropertySymbol> map = reference.getReference().asMap();
        List<Expression> keys = map.keySet().stream()
                .sequential()
                .map(it -> Models.toLiteral(f, it))
                .collect(Collectors.toList());
        return f.newFieldDeclaration(
                null,
                new AttributeBuilder(f)
                    .Static()
                    .Final()
                    .toAttributes(),
                f.newParameterizedType(context.resolve(Set.class), context.resolve(String.class)),
                f.newSimpleName(getKeysFieldName(reference)),
                new TypeBuilder(f, context.resolve(ValueOptionMap.class))
                    .method("keys", keys) //$NON-NLS-1$
                    .toExpression());
    }

    private static String getKeysFieldName(PropertyReferenceDeclaration reference) {
        // -> KEYSET_XXX
        JavaName name = JavaName.of(reference.getName());
        name.addFirst("keys"); //$NON-NLS-1$
        name.addFirst("list"); //$NON-NLS-1$
        return name.toConstantName();
    }

    private FieldDeclaration createReferenceListField(PropertyReferenceDeclaration reference) {
        return f.newFieldDeclaration(
                null,
                new AttributeBuilder(f)
                    .Private()
                    .Final()
                    .toAttributes(),
                context.getContainerType(reference),
                context.getFieldName(reference),
                new TypeBuilder(f, context.resolve(ValueOptionList.class))
                    .parameterize(context.getElementType(reference))
                    .newObject(Collections.emptyList(), f.newClassBody(Arrays.asList(
                            createReferenceListGetMethod(reference),
                            createReferenceListSizeMethod(reference))))
                    .toExpression());
    }

    private FieldDeclaration createReferenceMapField(PropertyReferenceDeclaration reference) {
        return f.newFieldDeclaration(
                null,
                new AttributeBuilder(f)
                    .Private()
                    .Final()
                    .toAttributes(),
                context.getContainerType(reference),
                context.getFieldName(reference),
                new TypeBuilder(f, context.resolve(ValueOptionMap.class))
                    .parameterize(context.resolve(String.class), context.getElementType(reference))
                    .newObject(Collections.emptyList(), f.newClassBody(Arrays.asList(
                            createReferenceMapGetMethod(reference),
                            createReferenceMapKeySetMethod(reference))))
                    .toExpression());
    }

    private MethodDeclaration createReferenceListGetMethod(PropertyReferenceDeclaration reference) {
        SimpleName index = f.newSimpleName("_i"); //$NON-NLS-1$
        List<Statement> cases = new ArrayList<>();
        int caseIndex = 0;
        for (PropertySymbol ref : reference.getReference().getAllReferences()) {
            PropertyDeclaration decl = ref.findDeclaration();
            assert decl != null;
            cases.add(f.newSwitchCaseLabel(Models.toLiteral(f, caseIndex++)));
            cases.add(new TypeBuilder(f, f.newNamedType(context.getTypeName()))
                    .dotThis()
                    .method(context.getOptionGetterName(decl))
                    .toReturnStatement());
        }
        cases.add(f.newSwitchDefaultLabel());
        cases.add(new TypeBuilder(f, context.resolve(IndexOutOfBoundsException.class))
                .newObject()
                .toThrowStatement());
        return f.newMethodDeclaration(
                null,
                new AttributeBuilder(f)
                    .annotation(context.resolve(Override.class))
                    .Public()
                    .toAttributes(),
                context.getElementType(reference),
                f.newSimpleName("get"), //$NON-NLS-1$
                Arrays.asList(f.newFormalParameterDeclaration(context.resolve(int.class), index)),
                Arrays.asList(f.newSwitchStatement(index, cases)));
    }

    private MethodDeclaration createReferenceListSizeMethod(PropertyReferenceDeclaration reference) {
        int size = reference.getReference().getAllReferences().size();
        return f.newMethodDeclaration(
                null,
                new AttributeBuilder(f)
                    .annotation(context.resolve(Override.class))
                    .Public()
                    .toAttributes(),
                context.resolve(int.class),
                f.newSimpleName("size"), //$NON-NLS-1$
                Arrays.asList(),
                Arrays.asList(new ExpressionBuilder(f, Models.toLiteral(f, size))
                        .toReturnStatement()));
    }

    private MethodDeclaration createReferenceMapKeySetMethod(PropertyReferenceDeclaration reference) {
        return f.newMethodDeclaration(
                null,
                new AttributeBuilder(f)
                    .annotation(context.resolve(Override.class))
                    .Public()
                    .toAttributes(),
                f.newParameterizedType(context.resolve(Set.class), context.resolve(String.class)),
                f.newSimpleName("keySet"), //$NON-NLS-1$
                Arrays.asList(),
                Arrays.asList(new TypeBuilder(f, f.newNamedType(context.getTypeName()))
                        .field(getKeysFieldName(reference))
                        .toReturnStatement()));
    }

    private MethodDeclaration createReferenceMapGetMethod(PropertyReferenceDeclaration reference) {
        SimpleName key = f.newSimpleName("_k"); //$NON-NLS-1$
        List<Statement> cases = new ArrayList<>();
        reference.getReference().asMap().forEach((k, v) -> {
            PropertyDeclaration decl = v.findDeclaration();
            assert decl != null;
            cases.add(f.newSwitchCaseLabel(Models.toLiteral(f, k)));
            cases.add(new TypeBuilder(f, f.newNamedType(context.getTypeName()))
                    .dotThis()
                    .method(context.getOptionGetterName(decl))
                    .toReturnStatement());
        });
        cases.add(f.newSwitchDefaultLabel());
        cases.add(f.newReturnStatement(Models.toNullLiteral(f)));
        List<Statement> statements = new ArrayList<>();
        statements.add(f.newIfStatement(
                f.newUnaryExpression(UnaryOperator.NOT, f.newInstanceofExpression(key, context.resolve(String.class))),
                f.newBlock(f.newReturnStatement(Models.toNullLiteral(f)))));
        statements.add(f.newSwitchStatement(
                f.newCastExpression(context.resolve(String.class), key),
                cases));
        return f.newMethodDeclaration(
                null,
                new AttributeBuilder(f)
                    .annotation(context.resolve(Override.class))
                    .Public()
                    .toAttributes(),
                context.getElementType(reference),
                f.newSimpleName("get"), //$NON-NLS-1$
                Arrays.asList(f.newFormalParameterDeclaration(context.resolve(Object.class), key)),
                statements);
    }

    private List<MethodDeclaration> createDataModelMethods() {
        List<MethodDeclaration> results = new ArrayList<>();
        results.add(createResetMethod());
        results.add(createCopyMethod());
        return results;
    }

    private MethodDeclaration createResetMethod() {
        List<Statement> statements = new ArrayList<>();
        for (PropertyDeclaration property : model.getDeclaredProperties()) {
            statements.add(new ExpressionBuilder(f, f.newThis())
                .field(context.getFieldName(property))
                .method("setNull") //$NON-NLS-1$
                .toStatement());
        }
        return f.newMethodDeclaration(
                null,
                new AttributeBuilder(f)
                    .annotation(context.resolve(Override.class))
                    .annotation(context.resolve(SuppressWarnings.class),
                            Models.toLiteral(f, "deprecation")) //$NON-NLS-1$
                    .Public()
                    .toAttributes(),
                context.resolve(void.class),
                f.newSimpleName("reset"), //$NON-NLS-1$
                Collections.emptyList(),
                statements);
    }

    private MethodDeclaration createCopyMethod() {
        SimpleName other = context.createVariableName("other"); //$NON-NLS-1$
        List<Statement> statements = new ArrayList<>();
        for (PropertyDeclaration property : model.getDeclaredProperties()) {
            statements.add(new ExpressionBuilder(f, f.newThis())
                .field(context.getFieldName(property))
                .method("copyFrom", new ExpressionBuilder(f, other) //$NON-NLS-1$
                    .field(context.getFieldName(property))
                    .toExpression())
                .toStatement());
        }
        return f.newMethodDeclaration(
                null,
                new AttributeBuilder(f)
                    .annotation(context.resolve(Override.class))
                    .annotation(context.resolve(SuppressWarnings.class),
                            Models.toLiteral(f, "deprecation")) //$NON-NLS-1$
                    .Public()
                    .toAttributes(),
                context.resolve(void.class),
                f.newSimpleName("copyFrom"), //$NON-NLS-1$
                Collections.singletonList(f.newFormalParameterDeclaration(
                        context.resolve(context.getQualifiedTypeName()),
                        other)),
                statements);
    }

    private List<MethodDeclaration> createPropertyAccessors() throws IOException {
        List<MethodDeclaration> results = new ArrayList<>();
        for (PropertyDeclaration property : model.getDeclaredProperties()) {
            results.add(createValueGetter(property));
            results.add(createValueSetter(property));
            results.add(createOptionGetter(property));
            results.add(createOptionSetter(property));
        }
        return results;
    }

    private List<MethodDeclaration> createReferenceAccessors() throws IOException {
        List<MethodDeclaration> results = new ArrayList<>();
        for (PropertyReferenceDeclaration reference : model.getDeclaredPropertyReferences()) {
            results.add(createReferenceGetter(reference));
        }
        return results;
    }

    private MethodDeclaration createValueGetter(PropertyDeclaration property) {
        assert property != null;
        List<Attribute> attributes = new ArrayList<>();
        attributes.addAll(new AttributeBuilder(f)
            .Public()
            .toAttributes());

        return f.newMethodDeclaration(
                new JavadocBuilder(f)
                    .text(Messages.getString("ConcreteModelEmitter.javadocGetter"), //$NON-NLS-1$
                            context.getDescription(property))
                    .returns()
                        .text(context.getDescription(property))
                    .exception(context.resolve(NullPointerException.class))
                        .text(Messages.getString(
                                "ConcreteModelEmitter.javadocGetterNullPointerException"), //$NON-NLS-1$
                                context.getDescription(property))
                    .toJavadoc(),
                attributes,
                context.getValueType(property),
                context.getValueGetterName(property),
                Collections.emptyList(),
                Collections.singletonList(new ExpressionBuilder(f, f.newThis())
                    .field(context.getFieldName(property))
                    .method("get") //$NON-NLS-1$
                    .toReturnStatement()));
    }

    private MethodDeclaration createValueSetter(PropertyDeclaration property) {
        assert property != null;
        SimpleName paramName = context.createVariableName("value"); //$NON-NLS-1$
        Type valueType = context.getValueType(property);
        return f.newMethodDeclaration(
                new JavadocBuilder(f)
                    .text(Messages.getString("ConcreteModelEmitter.javadocSetter"), //$NON-NLS-1$
                            context.getDescription(property))
                    .param(paramName)
                        .text(Messages.getString("ConcreteModelEmitter.javadocSetterParameter"), //$NON-NLS-1$
                                context.getDescription(property))
                    .toJavadoc(),
                new AttributeBuilder(f)
                    .annotation(
                            context.resolve(SuppressWarnings.class),
                            Models.toLiteral(f, "deprecation")) //$NON-NLS-1$
                    .Public()
                    .toAttributes(),
                context.resolve(void.class),
                context.getValueSetterName(property),
                Arrays.asList(new FormalParameterDeclaration[] {
                        f.newFormalParameterDeclaration(valueType, paramName)
                }),
                Collections.singletonList(new ExpressionBuilder(f, f.newThis())
                    .field(context.getFieldName(property))
                    .method("modify", paramName) //$NON-NLS-1$
                    .toStatement()));
    }

    private MethodDeclaration createOptionGetter(PropertyDeclaration property) throws IOException {
        assert property != null;
        List<Attribute> attributes = new ArrayList<>();
        attributes.addAll(driver.getMemberAnnotations(context, property));
        attributes.addAll(new AttributeBuilder(f)
            .Public()
            .toAttributes());

        return f.newMethodDeclaration(
                new JavadocBuilder(f)
                    .text(Messages.getString("ConcreteModelEmitter.javadocOptionGetter"), //$NON-NLS-1$
                            context.getDescription(property))
                    .returns()
                        .text(context.getDescription(property))
                    .toJavadoc(),
                attributes,
                context.getFieldType(property),
                context.getOptionGetterName(property),
                Collections.emptyList(),
                Collections.singletonList(new ExpressionBuilder(f, f.newThis())
                    .field(context.getFieldName(property))
                    .toReturnStatement()));
    }

    private MethodDeclaration createOptionSetter(PropertyDeclaration property) {
        assert property != null;
        SimpleName paramName = context.createVariableName("option"); //$NON-NLS-1$
        Type optionType = context.getFieldType(property);
        return f.newMethodDeclaration(
                new JavadocBuilder(f)
                    .text(Messages.getString("ConcreteModelEmitter.javadocOptionSetter"), //$NON-NLS-1$
                            context.getDescription(property))
                    .param(paramName)
                        .text(Messages.getString("ConcreteModelEmitter.javadocOptionSetterParameter"), //$NON-NLS-1$
                                context.getDescription(property))
                    .toJavadoc(),
                new AttributeBuilder(f)
                    .annotation(
                            context.resolve(SuppressWarnings.class),
                            Models.toLiteral(f, "deprecation")) //$NON-NLS-1$
                    .Public()
                    .toAttributes(),
                context.resolve(void.class),
                context.getOptionSetterName(property),
                Arrays.asList(new FormalParameterDeclaration[] {
                        f.newFormalParameterDeclaration(optionType, paramName)
                }),
                Collections.singletonList(new ExpressionBuilder(f, f.newThis())
                    .field(context.getFieldName(property))
                    .method("copyFrom", paramName) //$NON-NLS-1$
                    .toStatement()));
    }

    private MethodDeclaration createReferenceGetter(PropertyReferenceDeclaration reference) throws IOException {
        List<Attribute> attributes = new ArrayList<>();
        attributes.addAll(driver.getMemberAnnotations(context, reference));
        attributes.addAll(new AttributeBuilder(f)
            .Public()
            .toAttributes());
        return f.newMethodDeclaration(
                new JavadocBuilder(f)
                    .text(Messages.getString("ConcreteModelEmitter.javadocReferenceGetter"), //$NON-NLS-1$
                            context.getDescription(reference))
                    .returns()
                        .text(context.getDescription(reference))
                    .toJavadoc(),
                attributes,
                context.getContainerType(reference),
                context.getReferenceGetterName(reference),
                Collections.emptyList(),
                Arrays.asList(new ExpressionBuilder(f, f.newThis())
                        .field(context.getFieldName(reference))
                        .toReturnStatement()));
    }
}
