/*
 * Copyright 2017-2020 original 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
 *
 * https://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 io.micronaut.data.runtime.mapper.sql;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.beans.BeanIntrospection;
import io.micronaut.core.beans.BeanProperty;
import io.micronaut.core.beans.BeanWrapper;
import io.micronaut.core.reflect.exception.InstantiationException;
import io.micronaut.core.type.Argument;
import io.micronaut.core.util.ArgumentUtils;
import io.micronaut.core.util.ArrayUtils;
import io.micronaut.core.util.CollectionUtils;
import io.micronaut.data.annotation.MappedProperty;
import io.micronaut.data.annotation.Relation;
import io.micronaut.data.annotation.TypeDef;
import io.micronaut.data.exceptions.DataAccessException;
import io.micronaut.data.model.Association;
import io.micronaut.data.model.DataType;
import io.micronaut.data.model.Embedded;
import io.micronaut.data.model.PersistentEntity;
import io.micronaut.data.model.query.JoinPath;
import io.micronaut.data.model.runtime.RuntimePersistentEntity;
import io.micronaut.data.model.runtime.RuntimePersistentProperty;
import io.micronaut.data.runtime.mapper.ResultReader;
import io.micronaut.http.codec.MediaTypeCodec;

/**
 * A {@link io.micronaut.data.runtime.mapper.TypeMapper} that can take a {@link RuntimePersistentEntity} and a {@link ResultReader} and materialize an instance using
 * using column naming conventions mapped by the entity.
 *
 * @param <RS> The result set type
 * @param <R> The result type
 */
@Internal
public final class SqlResultEntityTypeMapper<RS, R> implements SqlTypeMapper<RS, R> {

    private final RuntimePersistentEntity<R> entity;
    private final ResultReader<RS, String> resultReader;
    private final Map<String, JoinPath> joinPaths;
    private final String startingPrefix;
    private final MediaTypeCodec jsonCodec;
    private boolean callNext = true;

    /**
     * Default constructor.
     * @param entity The entity
     * @param resultReader The result reader
     * @param jsonCodec The json codec
     */
    public SqlResultEntityTypeMapper(
            @NonNull RuntimePersistentEntity<R> entity,
            @NonNull ResultReader<RS, String> resultReader,
            @Nullable MediaTypeCodec jsonCodec) {
        this(entity, resultReader, Collections.emptySet(), jsonCodec);
    }

    /**
     * Default constructor.
     * @param prefix The prefix to startup from.
     * @param entity The entity
     * @param resultReader The result reader
     * @param jsonCodec The JSON codec
     */
    public SqlResultEntityTypeMapper(
            String prefix,
            @NonNull RuntimePersistentEntity<R> entity,
            @NonNull ResultReader<RS, String> resultReader,
            @Nullable MediaTypeCodec jsonCodec) {
        this(entity, resultReader, Collections.emptySet(), prefix, jsonCodec);
    }

    /**
     * Constructor used to customize the join paths.
     * @param entity The entity
     * @param resultReader The result reader
     * @param joinPaths The join paths
     * @param jsonCodec The JSON codec
     */
    public SqlResultEntityTypeMapper(
            @NonNull RuntimePersistentEntity<R> entity,
            @NonNull ResultReader<RS, String> resultReader,
            @Nullable Set<JoinPath> joinPaths,
            @Nullable MediaTypeCodec jsonCodec) {
        this(entity, resultReader, joinPaths, null, jsonCodec);
    }

    /**
     * Constructor used to customize the join paths.
     * @param entity The entity
     * @param resultReader The result reader
     * @param joinPaths The join paths
     */
    private SqlResultEntityTypeMapper(
            @NonNull RuntimePersistentEntity<R> entity,
            @NonNull ResultReader<RS, String> resultReader,
            @Nullable Set<JoinPath> joinPaths,
            String startingPrefix,
            @Nullable MediaTypeCodec jsonCodec) {
        ArgumentUtils.requireNonNull("entity", entity);
        ArgumentUtils.requireNonNull("resultReader", resultReader);
        this.entity = entity;
        this.jsonCodec = jsonCodec;
        this.resultReader = resultReader;
        if (CollectionUtils.isNotEmpty(joinPaths)) {
            this.joinPaths = new HashMap<>(joinPaths.size());
            for (JoinPath joinPath : joinPaths) {
                this.joinPaths.put(joinPath.getPath(), joinPath);
            }
        } else {
            this.joinPaths = Collections.emptyMap();
        }
        this.startingPrefix = startingPrefix;
    }

    /**
     * @return The entity to be materialized
     */
    public @NonNull RuntimePersistentEntity<R> getEntity() {
        return entity;
    }

    /**
     * @return The result reader instance.
     */
    public @NonNull ResultReader<RS, String> getResultReader() {
        return resultReader;
    }

    @NonNull
    @Override
    public R map(@NonNull RS object, @NonNull Class<R> type) throws DataAccessException {
        return readEntity(startingPrefix, null, object, entity, false, null, null, null);
    }

    @Nullable
    @Override
    public Object read(@NonNull RS resultSet, @NonNull String name) {
        RuntimePersistentProperty<R> property = entity.getPropertyByName(name);
        if (property == null) {
            throw new DataAccessException("DTO projection defines a property [" + name + "] that doesn't exist on root entity: " + entity.getName());
        }

        DataType dataType = property.getDataType();
        String columnName = property.getPersistedName();
        return resultReader.readDynamic(
            resultSet,
            columnName,
            dataType
        );
    }

    @Nullable
    @Override
    public Object read(@NonNull RS resultSet, @NonNull Argument<?> argument) {
        RuntimePersistentProperty<R> property = entity.getPropertyByName(argument.getName());
        DataType dataType;
        String columnName;
        if (property == null) {
            dataType = argument.getAnnotationMetadata()
                    .enumValue(TypeDef.class, "type", DataType.class)
                    .orElseGet(() -> DataType.forType(argument.getType()));
            columnName = argument.getName();
        } else {
            dataType = property.getDataType();
            columnName = property.getPersistedName();
        }

        return resultReader.readDynamic(
                resultSet,
                columnName,
                dataType
        );
    }

    @Override
    public boolean hasNext(RS resultSet) {
        if (callNext) {
            return resultReader.next(resultSet);
        } else {
            try {
                return true;
            } finally {
                callNext = true;
            }
        }
    }

    private R readEntity(
            String prefix,
            String path,
            RS rs,
            RuntimePersistentEntity<R> persistentEntity,
            boolean isEmbedded,
            @Nullable Association association,
            @Nullable Object parent,
            @Nullable Object resolveId) {
        BeanIntrospection<R> introspection = persistentEntity.getIntrospection();
        RuntimePersistentProperty<R>[] constructorArguments = persistentEntity.getConstructorArguments();
        try {
            R entity;
            Object id = resolveId;
            RuntimePersistentProperty<R> identity = persistentEntity.getIdentity();
            boolean hasPrefix = prefix != null;
            boolean hasPath = path != null;
            final boolean isAssociation = association != null;
            final boolean nullableEmbedded = association instanceof Embedded && association.isOptional();
            if (id == null && identity != null) {
                if (identity instanceof Embedded) {
                    PersistentEntity embeddedEntity = ((Embedded) identity).getAssociatedEntity();
                    id = readEntity(
                            identity.getPersistedName() + "_",
                             (hasPath ? path : "") + identity.getName() + '.',
                             rs,
                            (RuntimePersistentEntity<R>) embeddedEntity,
                            true,
                            null,
                            null,
                            null);
                } else {
                    String columnName = resolveColumnName(
                            identity,
                            prefix,
                            isEmbedded,
                            hasPrefix
                    );

                    id = resultReader.readDynamic(rs, columnName, DataType.OBJECT);
                    if (id == null) {
                        return null;
                    }
                }
            }

            if (ArrayUtils.isEmpty(constructorArguments)) {
                entity = introspection.instantiate();
            } else {
                int len = constructorArguments.length;
                Object[] args = new Object[len];
                for (int i = 0; i < len; i++) {
                    RuntimePersistentProperty<R> prop = constructorArguments[i];
                    if (prop != null) {
                        if (prop instanceof Association) {
                            final Association constructorAssociation = (Association) prop;
                            final boolean isInverse = parent != null && isAssociation &&
                                    association.getOwner() == constructorAssociation.getAssociatedEntity();
                            final Relation.Kind kind = constructorAssociation.getKind();
                            if (isInverse && kind.isSingleEnded()) {
                                args[i] = parent;
                            } else {
                                Object resolvedId = null;
                                if (!constructorAssociation.isForeignKey() && !(constructorAssociation instanceof Embedded)) {
                                    String columnName = resolveColumnName(
                                            prop,
                                            prefix,
                                            isEmbedded,
                                            hasPrefix
                                    );
                                    resolvedId = resultReader.readDynamic(
                                            rs,
                                            columnName,
                                            prop.getDataType()
                                    );
                                }
                                if (kind.isSingleEnded()) {
                                    args[i] = readAssociation(
                                            parent,
                                            hasPrefix ? prefix : "",
                                            (hasPath ? path : ""), rs,
                                            constructorAssociation,
                                            resolvedId,
                                            hasPrefix
                                    );
                                }
                            }

                        } else {
                            Object v;
                            if (resolveId != null && prop.equals(identity)) {
                                v = resolveId;
                            } else {
                                String columnName = resolveColumnName(
                                        prop,
                                        prefix,
                                        isEmbedded,
                                        hasPrefix
                                );
                                v = resultReader.readDynamic(
                                        rs,
                                        columnName,
                                        prop.getDataType()
                                );
                                if (v == null) {
                                    if (!prop.isOptional() && !nullableEmbedded) {
                                        throw new DataAccessException("Null value read for non-null constructor argument [" + prop.getName() + "] of type: " + persistentEntity.getName());
                                    } else {
                                        args[i] = null;
                                        continue;
                                    }
                                }
                            }

                            Class<?> t = prop.getType();
                            args[i] = t.isInstance(v) ? v : resultReader.convertRequired(v, t);
                        }
                    } else {
                        throw new DataAccessException("Constructor argument [" + constructorArguments[i].getName() + "] must have an associated getter.");
                    }
                }

                if (nullableEmbedded && args.length > 0 && Arrays.stream(args).allMatch(Objects::isNull)) {
                    return null;
                } else {
                    entity = introspection.instantiate(args);
                }
            }

            if (identity != null && id != null) {
                BeanProperty<R, Object> idProperty = (BeanProperty<R, Object>) identity.getProperty();
                if (!idProperty.isReadOnly()) {
                    id = convertAndSet(entity, identity, idProperty, id, identity.getDataType());
                }
            }
            Map<Association, List<Object>> toManyJoins = null;
            for (RuntimePersistentProperty<R> rpp : persistentEntity.getPersistentProperties()) {
                if (rpp.isReadOnly()) {
                    continue;
                } else if (rpp.isConstructorArgument()) {
                    if (rpp instanceof Association) {
                        Association a = (Association) rpp;
                        final Relation.Kind kind = a.getKind();
                        if (kind.isSingleEnded()) {
                            continue;
                        }
                    } else {
                        continue;
                    }
                }
                BeanProperty<R, Object> property = (BeanProperty<R, Object>) rpp.getProperty();
                if (rpp instanceof Association) {
                    Association entityAssociation = (Association) rpp;
                    if (!entityAssociation.isForeignKey()) {
                        if (!(entityAssociation instanceof Embedded)) {

                            String columnName = resolveColumnName(
                                    rpp,
                                    prefix,
                                    isEmbedded,
                                    hasPrefix
                            );
                            Object resolvedId = resultReader.readDynamic(
                                    rs,
                                    columnName,
                                    rpp.getDataType()
                            );
                            if (resolvedId != null) {
                                Object associated = readAssociation(
                                        entity,
                                        prefix,
                                        (hasPath ? path : ""),
                                        rs,
                                        entityAssociation,
                                        resolvedId,
                                        hasPrefix
                                );
                                if (associated != null) {
                                    property.set(entity, associated);
                                }
                            }
                        } else {
                            Object associated = readAssociation(
                                    entity,
                                    prefix,
                                    (hasPath ? path : ""),
                                    rs,
                                    entityAssociation,
                                    null,
                                    hasPrefix
                            );
                            if (associated != null) {
                                property.set(entity, associated);
                            }
                        }

                    } else {
                        boolean hasJoin = joinPaths.containsKey((hasPath ? path : "") + entityAssociation.getName());
                        if (hasJoin) {
                            Relation.Kind kind = entityAssociation.getKind();
                            if (kind == Relation.Kind.ONE_TO_ONE && entityAssociation.isForeignKey()) {
                                Object associated = readAssociation(
                                        entity,
                                        prefix, (hasPath ? path : ""),
                                        rs,
                                        entityAssociation,
                                        null,
                                        hasPrefix
                                );
                                if (associated != null) {
                                    property.set(entity, associated);
                                }
                            } else if (id != null && (kind == Relation.Kind.ONE_TO_MANY || kind == Relation.Kind.MANY_TO_MANY)) {
                                if (toManyJoins == null) {
                                    toManyJoins = new HashMap<>(3);
                                }
                                toManyJoins.put(entityAssociation, new ArrayList<>());
                            }
                        }
                    }
                } else {
                    String columnName = resolveColumnName(
                            rpp,
                            prefix,
                            isEmbedded,
                            hasPrefix
                    );
                    final DataType dataType = rpp.getDataType();
                    Object v = resultReader.readDynamic(
                            rs,
                            columnName,
                            dataType
                    );

                    if (v != null) {
                        convertAndSet(entity, rpp, property, v, dataType);
                    }
                }
            }

            if (id != null) {
                Object currentId = id;
                if (CollectionUtils.isNotEmpty(toManyJoins)) {

                    while (id.equals(currentId)) {
                        for (Map.Entry<Association, List<Object>> entry : toManyJoins.entrySet()) {
                            Object associated = readAssociation(
                                    entity,
                                    hasPrefix ? prefix : "", (hasPath ? path : ""),
                                    rs,
                                    entry.getKey(),
                                    null,
                                    hasPrefix
                            );
                            if (associated != null) {
                                entry.getValue().add(associated);
                            }
                        }

                        String columnName = resolveColumnName(
                                identity,
                                prefix,
                                isEmbedded,
                                hasPrefix
                        );

                        currentId = nextId(identity, rs, columnName);
                    }

                    if (currentId != null) {
                        this.callNext = false;
                    }

                    toManyJoins.forEach((key, value) -> {
                        RuntimePersistentProperty<R> joinAssociation = (RuntimePersistentProperty<R>) key;
                        BeanProperty<R, Object> property = (BeanProperty<R, Object>) joinAssociation.getProperty();
                        convertAndSet(entity, joinAssociation, property, value, joinAssociation.getDataType());
                    });
                }
            }
            return entity;
        } catch (InstantiationException e) {
            throw new DataAccessException("Error instantiating entity [" + persistentEntity.getName() + "]: " + e.getMessage(), e);
        }
    }

    private String resolveColumnName(RuntimePersistentProperty<R> identity, String prefix, boolean isEmbedded, boolean hasPrefix) {
        final String persistedName = identity.getPersistedName();
        final String columnName;
        if (hasPrefix) {
            if (isEmbedded && identity.getAnnotationMetadata().stringValue(MappedProperty.class).isPresent()) {
                columnName = persistedName;
            } else {
                columnName = prefix + persistedName;
            }
        } else {
            columnName = persistedName;
        }
        return columnName;
    }

    /**
     * Resolve the ID of the next row.
     * @param identity The identity
     * @param resultSet The result set
     * @param columnName The column name
     * @return The ID
     */
    Object nextId(@NonNull RuntimePersistentProperty<R> identity, @NonNull RS resultSet, @NonNull String columnName) {
        if (hasNext(resultSet)) {
            final Object id = resultReader.readDynamic(resultSet, columnName, identity.getDataType());
            final Class<?> isType = identity.getType();
            return isType.isInstance(id) ? id : resultReader.convertRequired(id, isType);
        }
        return null;
    }

    private Object convertAndSet(
            R entity,
            RuntimePersistentProperty<R> rpp,
            BeanProperty<R, Object> property,
            Object v,
            DataType dataType) {
        Class<?> propertyType = rpp.getType();
        final Object r;
        if (propertyType.isInstance(v)) {
            r = v;
        } else {
            if (dataType == DataType.JSON && jsonCodec != null) {
                r = jsonCodec.decode(rpp.getArgument(), v.toString());
            } else {
                r = resultReader.convertRequired(v, rpp.getArgument());
            }
        }
        property.set(entity, r);

        return r;
    }

    @Nullable
    private Object readAssociation(
            Object parent,
            String prefix,
            String path,
            RS resultSet,
            @NonNull Association association,
            @Nullable Object resolvedId,
            boolean hasPrefix) {
        RuntimePersistentEntity<R> associatedEntity = (RuntimePersistentEntity<R>) association.getAssociatedEntity();
        Object associated = null;
        String associationName = association.getName();
        if (association instanceof Embedded) {
            associated = readEntity(
                    association.getPersistedName() + "_",
                    path + associationName + '.',
                    resultSet,
                    associatedEntity,
                    true,
                    association,
                    null,
                    null);
        } else {
            String persistedName = association.getPersistedName();
            RuntimePersistentProperty<R> identity = associatedEntity.getIdentity();
            String joinPath = path + associationName;
            if (joinPaths.containsKey(joinPath)) {
                JoinPath jp = joinPaths.get(joinPath);
                String newPrefix = jp.getAlias().orElse(
                        !hasPrefix ? association.getAliasName() : prefix + association.getAliasName()
                );
                associated = readEntity(
                        newPrefix,
                        path + associationName + '.',
                        resultSet,
                        associatedEntity,
                        false,
                        association,
                        parent,
                        resolvedId
                );
            } else {

                BeanIntrospection<R> associatedIntrospection = associatedEntity.getIntrospection();
                Argument[] constructorArgs = associatedIntrospection.getConstructorArguments();
                if (constructorArgs.length == 0) {
                    associated = associatedIntrospection.instantiate();
                    if (identity != null) {
                        String columnToRead = hasPrefix ? prefix + persistedName : persistedName;

                        Object v = resultReader.readDynamic(
                                resultSet,
                                columnToRead,
                                identity.getDataType()
                        );
                        BeanWrapper.getWrapper(associated).setProperty(
                                identity.getName(),
                                v
                        );
                    }
                } else {
                    if (constructorArgs.length == 1 && identity != null) {
                        Argument arg = constructorArgs[0];
                        if (arg.getName().equals(identity.getName()) && arg.getType() == identity.getType()) {
                            Object v = resultReader.readDynamic(resultSet, hasPrefix ? prefix + persistedName : persistedName, identity.getDataType());
                            associated = associatedIntrospection.instantiate(resultReader.convertRequired(v, identity.getType()));
                        }
                    }
                }
            }
        }
        return associated;
    }
}