/*-
 * ~~~~~~licensing~~~~~~
 * atlasmap-entaxy-services
 * ==========
 * Copyright (C) 2020 - 2025 EmDev LLC
 * ==========
 * You may not use this file except in accordance with the License Terms of the Copyright
 * Holder located at: https://entaxy.ru/eula . All copyrights, all intellectual property
 * rights to the Software and any copies are the property of the Copyright Holder. Unless
 * it is explicitly allowed the Copyright Holder, the User is prohibited from using the
 * Software for commercial purposes to provide services to third parties.
 * 
 * The Copyright Holder hereby declares that the Software is provided on an "AS IS".
 * Under no circumstances does the Copyright Holder guarantee or promise that the
 * Software provided by him will be suitable or not suitable for the specific purposes
 * of the User, that the Software will meet all commercial and personal subjective
 * expectations of the User, that the Software will work properly, without technical
 * errors, quickly and uninterruptedly.
 * 
 * Under no circumstances shall the Copyright Holder or its Affiliates is not liable
 * to the User for any direct or indirect losses of the User, his expenses or actual
 * damage, including, downtime; loss of bussines; lost profit; lost earnings; loss
 * or damage to data, property, etc.
 * ~~~~~~/licensing~~~~~~
 */
/*
 * Copyright (C) 2017 Red Hat, Inc.
 *
 * 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 io.atlasmap.core;

import static java.util.Objects.hash;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.atlasmap.api.AtlasConversionException;
import io.atlasmap.spi.AtlasConversionInfo;
import io.atlasmap.spi.AtlasConversionService;
import io.atlasmap.spi.AtlasConverter;
import io.atlasmap.v2.FieldType;

public class DefaultAtlasConversionService implements AtlasConversionService {

    private static final Logger LOG = LoggerFactory.getLogger(DefaultAtlasConversionService.class);
    private static final Set<String> PRIMITIVE_CLASSNAMES = Collections.unmodifiableSet(
            new HashSet<>(Arrays.asList("boolean", "byte", "char", "double", "float", "int", "long", "short")));
    private static final Set<FieldType> PRIMITIVE_FIELDTYPES = Collections.unmodifiableSet(new HashSet<>(
            Arrays.asList(FieldType.BOOLEAN, FieldType.BYTE, FieldType.CHAR, FieldType.DECIMAL, FieldType.DOUBLE,
                    FieldType.FLOAT, FieldType.INTEGER, FieldType.LONG, FieldType.SHORT, FieldType.STRING)));
    private static final Set<String> BOXED_PRIMITIVE_CLASSNAMES = Collections.unmodifiableSet(new HashSet<>(
            Arrays.asList("java.lang.Boolean", "java.lang.Byte", "java.lang.Character", "java.lang.Double",
                    "java.lang.Float", "java.lang.Integer", "java.lang.Long", "java.lang.Short", "java.lang.String")));

    private static volatile DefaultAtlasConversionService instance = null;
    private static final Object SINGLETON_LOCK = new Object();

    private Map<ConverterKey, ConverterMethodHolder> converterMethods = null;
    private Map<ConverterKey, ConverterMethodHolder> customConverterMethods = null;

    // Used as the lookup key in the converter methods map
    private class ConverterKey {
        private String sourceClassName;
        private String targetClassName;

        public ConverterKey(String sourceClassName, String targetClassName) {
            this.sourceClassName = sourceClassName;
            this.targetClassName = targetClassName;
        }

        @Override
        public boolean equals(Object obj) {
            if (obj != null && obj instanceof ConverterKey) {
                ConverterKey s = (ConverterKey) obj;
                return sourceClassName.equals(s.sourceClassName) && targetClassName.equals(s.targetClassName);
            }
            return false;
        }

        @Override
        public int hashCode() {
            return hash(sourceClassName, targetClassName);
        }
    }

    // used to hold converter and method for future reflection use
    private class ConverterMethodHolder {
        private AtlasConverter<?> converter;
        private Method method;
        private boolean staticMethod;
        private boolean containsFormat;

        public ConverterMethodHolder(AtlasConverter<?> converter, Method method, boolean staticMethod,
                boolean containsFormat) {
            this.converter = converter;
            this.method = method;
            this.staticMethod = staticMethod;
            this.containsFormat = containsFormat;
        }

        public AtlasConverter<?> getConverter() {
            return converter;
        }
    }

    private DefaultAtlasConversionService() {
    }

    public static DefaultAtlasConversionService getInstance() {
        DefaultAtlasConversionService result = instance;
        if (result == null) {
            synchronized (SINGLETON_LOCK) {
                result = instance;
                if (result == null) {
                    result = new DefaultAtlasConversionService();
                    result.init();
                    instance = result;
                }
            }
        }
        return result;
    }

    public static Set<String> listPrimitiveClassNames() {
        return PRIMITIVE_CLASSNAMES;
    }

    @Override
    public Optional<AtlasConverter<?>> findMatchingConverter(FieldType source, FieldType target) {

        // get the default types
        Class<?> sourceClass = classFromFieldType(source);
        Class<?> targetClass = classFromFieldType(target);

        if (sourceClass != null && targetClass != null) {
            return findMatchingConverter(sourceClass.getCanonicalName(), targetClass.getCanonicalName());
        }
        return Optional.empty();
    }

    @Override
    public Optional<AtlasConverter<?>> findMatchingConverter(String sourceClassName, String targetClassName) {
        ConverterKey converterKey = new ConverterKey(sourceClassName, targetClassName);
        if (customConverterMethods.containsKey(converterKey)) {
            return Optional.of(customConverterMethods.get(converterKey).getConverter());
        } else if (converterMethods.containsKey(converterKey)) {
            return Optional.of(converterMethods.get(converterKey).getConverter());
        } else {
            return Optional.empty();
        }
    }

    private void init() {
        loadConverters();
    }

    @SuppressWarnings("rawtypes")
    private void loadConverters() {
        ClassLoader classLoader = this.getClass().getClassLoader();
        final ServiceLoader<AtlasConverter> converterServiceLoader = ServiceLoader.load(AtlasConverter.class,
                classLoader);
        final ServiceLoader<io.atlasmap.api.AtlasConverter> compat = ServiceLoader.load(io.atlasmap.api.AtlasConverter.class,
                classLoader);

        // used to load up methods first;
        Map<ConverterKey, ConverterMethodHolder> methodsLoadMap = new LinkedHashMap<>();
        Map<ConverterKey, ConverterMethodHolder> customMethodsLoadMap = new LinkedHashMap<>();

        converterServiceLoader.forEach(atlasConverter -> loadConverterMethod(atlasConverter, methodsLoadMap, customMethodsLoadMap));
        compat.forEach(atlasConverter -> loadConverterMethod(atlasConverter, methodsLoadMap, customMethodsLoadMap));

        if (!methodsLoadMap.isEmpty()) {
            converterMethods = Collections.unmodifiableMap(methodsLoadMap);
        }
        if (!methodsLoadMap.isEmpty()) {
            customConverterMethods = Collections.unmodifiableMap(customMethodsLoadMap);
        }
    }

    private void loadConverterMethod(AtlasConverter<?> atlasConverter,
        Map<ConverterKey, ConverterMethodHolder> methodsLoadMap, Map<ConverterKey, ConverterMethodHolder> customMethodsLoadMap) {
        if (LOG.isDebugEnabled()) {
            LOG.debug("Loading converter : " + atlasConverter.getClass().getCanonicalName());
        }

        boolean inbuiltConverter = atlasConverter.getClass().getPackage().getName().startsWith("io.atlasmap");

        Class<?> klass = atlasConverter.getClass();
        // collect all the specific conversion methods on the class
        while (klass != Object.class) {
            final List<Method> allMethods = new ArrayList<>(Arrays.asList(klass.getDeclaredMethods()));
            for (final Method method : allMethods) {
                // we filter out methods which aren't annotated @AtlasconversionInfo and have to
                // also filter out methods which are synthetic methods to avoid duplicates
                if (method.isAnnotationPresent(AtlasConversionInfo.class) && method.getParameters().length > 0
                        && !method.isSynthetic()) {
                    String sourceClassName = method.getParameters()[0].getType().getCanonicalName();
                    ConverterKey coordinate = new ConverterKey(sourceClassName,
                            method.getReturnType().getCanonicalName());
                    // if the method has three arguments and the last two as strings then they used
                    // as the format attributes
                    boolean containsFormat = false;
                    if (method.getParameters().length == 3 && method.getParameters()[1].getType() == String.class
                            && method.getParameters()[2].getType() == String.class) {
                        containsFormat = true;
                    }

                    boolean staticMethod = Modifier.isStatic(method.getModifiers());
                    ConverterMethodHolder methodHolder = new ConverterMethodHolder(atlasConverter, method, staticMethod,
                            containsFormat);
                    if (inbuiltConverter) {
                        if (!methodsLoadMap.containsKey(coordinate)) {
                            methodsLoadMap.put(coordinate, methodHolder);
                        } else {
                            LOG.warn("Converter between " + sourceClassName + " and "
                                    + method.getReturnType().getCanonicalName() + " aleady exists.");
                        }
                    } else {
                        if (!customMethodsLoadMap.containsKey(coordinate)) {
                            customMethodsLoadMap.put(coordinate, methodHolder);
                        } else {
                            LOG.warn("Custom converter between " + sourceClassName + " and "
                                    + method.getReturnType().getCanonicalName() + " aleady exists.");
                        }
                    }
                }
            }
            // move to the upper class in the hierarchy in search for more methods
            klass = klass.getSuperclass();
        }
    }

    @Override
    public Object copyPrimitive(Object sourceValue) {

        if (sourceValue == null) {
            return null;
        }

        Class<?> clazz = sourceValue.getClass();
        if (clazz == null) {
            return clazz;
        } else if (boolean.class.getName().equals(clazz.getName())) {
            return Boolean.valueOf((boolean) sourceValue);
        } else if (Boolean.class.getName().equals(clazz.getName())) {
            return Boolean.valueOf((Boolean) sourceValue);
        } else if (byte.class.getName().equals(clazz.getName())) {
            return Byte.valueOf((byte) sourceValue);
        } else if (Byte.class.getName().equals(clazz.getName())) {
            return Byte.valueOf((Byte) sourceValue);
        } else if (char.class.getName().equals(clazz.getName())) {
            return Character.valueOf((char) sourceValue);
        } else if (Character.class.getName().equals(clazz.getName())) {
            return Character.valueOf((Character) sourceValue);
        } else if (double.class.getName().equals(clazz.getName())) {
            return Double.valueOf((double) sourceValue);
        } else if (Double.class.getName().equals(clazz.getName())) {
            return Double.valueOf((Double) sourceValue);
        } else if (float.class.getName().equals(clazz.getName())) {
            return Float.valueOf((float) sourceValue);
        } else if (Float.class.getName().equals(clazz.getName())) {
            return Float.valueOf((Float) sourceValue);
        } else if (int.class.getName().equals(clazz.getName())) {
            return Integer.valueOf((int) sourceValue);
        } else if (Integer.class.getName().equals(clazz.getName())) {
            return Integer.valueOf((Integer) sourceValue);
        } else if (long.class.getName().equals(clazz.getName())) {
            return Long.valueOf((long) sourceValue);
        } else if (Long.class.getName().equals(clazz.getName())) {
            return Long.valueOf((Long) sourceValue);
        } else if (short.class.getName().equals(clazz.getName())) {
            return Short.valueOf((short) sourceValue);
        } else if (Short.class.getName().equals(clazz.getName())) {
            return Short.valueOf((Short) sourceValue);
        }

        // can't count on java copy
        return sourceValue;
    }

    @Override
    public Object convertType(Object sourceValue, FieldType origSourceType, FieldType targetType)
            throws AtlasConversionException {

        if (origSourceType == null || targetType == null) {
            throw new AtlasConversionException("FieldTypes must be specified on convertType method.");
        }
        if (isAssignableFieldType(origSourceType, targetType)) {
            return sourceValue;
        }
        return convertType(sourceValue, null, classFromFieldType(targetType), null);
    }

    @Override
    public Object convertType(Object sourceValue, String sourceFormat, FieldType targetType, String targetFormat)
            throws AtlasConversionException {
        return convertType(sourceValue, sourceFormat, classFromFieldType(targetType), targetFormat);
    }

    @Override
    public Object convertType(Object sourceValue, String sourceFormat, Class<?> targetType, String targetFormat)
            throws AtlasConversionException {

        if (sourceValue == null || targetType == null) {
            throw new AtlasConversionException("AutoConversion requires sourceValue and targetType to be specified");
        }

        if (targetType.isInstance(sourceValue)) {
            return sourceValue;
        }

        ConverterMethodHolder methodHolder = getConverter(sourceValue, targetType);
        if (methodHolder != null) {
            try {
                Object target = methodHolder.staticMethod ? null : methodHolder.converter;
                if (methodHolder.containsFormat) {
                    return methodHolder.method.invoke(target, sourceValue, sourceFormat, targetFormat);
                }
                return methodHolder.method.invoke(target, sourceValue);
            } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
                throw new AtlasConversionException("Invoking type convertor failed", e);
            }
        }
        throw new AtlasConversionException("Type Conversion is not supported for sT="
                + sourceValue.getClass().getCanonicalName() + " tT=" + targetType.getCanonicalName());
    }

    @Override
    public boolean isConvertionAvailableFor(Object sourceValue, Class<?> targetType) {
        return targetType.isInstance(sourceValue) || getConverter(sourceValue, targetType) != null;
    }

    private ConverterMethodHolder getConverter(Object sourceValue, Class<?> targetType) {
        Class<?> boxedSourceClass = sourceValue.getClass();
        if (sourceValue.getClass().isPrimitive()) {
            boxedSourceClass = boxOrUnboxPrimitive(boxedSourceClass);
        }
        Class<?> boxedTargetClass = targetType;
        if (targetType.isPrimitive()) {
            boxedTargetClass = boxOrUnboxPrimitive(boxedTargetClass);
        }

        ConverterKey converterKey = new ConverterKey(boxedSourceClass.getCanonicalName(),
                boxedTargetClass.getCanonicalName());
        // use custom converter first
        ConverterMethodHolder methodHolder = customConverterMethods.get(converterKey);
        if (methodHolder == null) {
            // try the inbuilt defaults
            methodHolder = converterMethods.get(converterKey);
        }
        return methodHolder;
    }

    @Override
    public Boolean isPrimitive(String className) {
        if (className == null) {
            return false;
        }
        if (PRIMITIVE_CLASSNAMES.contains(className)) {
            return true;
        }
        return false;
    }

    @Override
    public Boolean isPrimitive(Class<?> clazz) {
        if (clazz == null) {
            return false;
        }
        if (PRIMITIVE_CLASSNAMES.contains(clazz.getCanonicalName())) {
            return true;
        }
        return false;
    }

    @Override
    public Boolean isPrimitive(FieldType fieldType) {
        if (fieldType == null) {
            return false;
        }
        return PRIMITIVE_FIELDTYPES.contains(fieldType);
    }

    @Override
    public Boolean isBoxedPrimitive(Class<?> clazz) {
        if (clazz == null) {
            return false;
        }
        return BOXED_PRIMITIVE_CLASSNAMES.contains(clazz.getCanonicalName());
    }

    @Override
    public Class<?> boxOrUnboxPrimitive(String clazzName) {
        return classFromFieldType(fieldTypeFromClass(clazzName));
    }

    @Override
    public Class<?> boxOrUnboxPrimitive(Class<?> clazz) {
        if (clazz == null) {
            return clazz;
        } else if (boolean.class.getName().equals(clazz.getName())) {
            return Boolean.class;
        } else if (Boolean.class.getName().equals(clazz.getName())) {
            return boolean.class;
        } else if (byte.class.getName().equals(clazz.getName())) {
            return Byte.class;
        } else if (Byte.class.getName().equals(clazz.getName())) {
            return byte.class;
        } else if (char.class.getName().equals(clazz.getName())) {
            return Character.class;
        } else if (Character.class.getName().equals(clazz.getName())) {
            return char.class;
        } else if (double.class.getName().equals(clazz.getName())) {
            return Double.class;
        } else if (Double.class.getName().equals(clazz.getName())) {
            return double.class;
        } else if (float.class.getName().equals(clazz.getName())) {
            return Float.class;
        } else if (Float.class.getName().equals(clazz.getName())) {
            return float.class;
        } else if (int.class.getName().equals(clazz.getName())) {
            return Integer.class;
        } else if (Integer.class.getName().equals(clazz.getName())) {
            return int.class;
        } else if (long.class.getName().equals(clazz.getName())) {
            return Long.class;
        } else if (Long.class.getName().equals(clazz.getName())) {
            return long.class;
        } else if (short.class.getName().equals(clazz.getName())) {
            return Short.class;
        } else if (Short.class.getName().equals(clazz.getName())) {
            return short.class;
        }
        return clazz;
    }

    @Override
    public FieldType fieldTypeFromClass(Class<?> clazz) {
        if (clazz == null) {
            return null;
        }

        return fieldTypeFromClass(clazz.getName());
    }

    @Override
    public FieldType fieldTypeFromClass(String className) {
        if (className == null || className.isEmpty()) {
            return FieldType.NONE;
        }

        switch (className) {
        case "java.lang.Object":
            return FieldType.ANY;
        case "java.math.BigInteger":
            return FieldType.BIG_INTEGER;
        case "boolean":
        case "java.lang.Boolean":
            return FieldType.BOOLEAN;
        case "byte":
        case "java.lang.Byte":
            return FieldType.BYTE;
        case "[B":
        case "[Ljava.lang.Byte":
            return FieldType.BYTE_ARRAY;
        case "char":
            return FieldType.CHAR;
        case "java.lang.Character":
            return FieldType.CHAR;
        case "java.math.BigDecimal":
            return FieldType.DECIMAL;
        case "double":
        case "java.lang.Double":
            return FieldType.DOUBLE;
        case "float":
        case "java.lang.Float":
            return FieldType.FLOAT;
        case "int":
        case "java.lang.Integer":
        case "java.util.concurrent.atomic.AtomicInteger":
            return FieldType.INTEGER;
        case "long":
        case "java.lang.Long":
        case "java.util.concurrent.atomic.AtomicLong":
            return FieldType.LONG;
        case "java.lang.Number":
            return FieldType.NUMBER;
        case "short":
        case "java.lang.Short":
            return FieldType.SHORT;
        case "java.nio.CharBuffer":
        case "java.lang.CharSequence":
        case "java.lang.String":
        case "java.lang.StringBuffer":
        case "java.lang.StringBuilder":
            return FieldType.STRING;
        case "java.sql.Date":
        case "java.time.LocalDate":
        case "java.time.Month":
        case "java.time.MonthDay":
        case "java.time.Year":
        case "java.time.YearMonth":
            return FieldType.DATE;
        case "java.sql.Time":
        case "java.time.LocalTime":
            return FieldType.TIME;
        case "java.sql.Timestamp":
        case "java.time.LocalDateTime":
        case "java.util.Date":
            return FieldType.DATE_TIME;
        case "java.time.ZonedDateTime":
        case "java.util.Calendar":
        case "java.util.GregorianCalendar":
            return FieldType.DATE_TIME_TZ;
        default:
            return FieldType.COMPLEX;
        }
    }

    @Override
    public Class<?> classFromFieldType(FieldType fieldType) {
        if (fieldType == null) {
            return null;
        }

        switch (fieldType) {
        case ANY:
            return Object.class;
        case BIG_INTEGER:
            return BigInteger.class;
        case BOOLEAN:
            return Boolean.class;
        case BYTE:
            return Byte.class;
        case BYTE_ARRAY:
            return Byte[].class;
        case CHAR:
            return java.lang.Character.class;
        case COMPLEX:
            // COMPLEX doesn't have representative class
            return null;
        case DATE:
            return java.time.LocalDate.class;
        case DATE_TIME:
            return Date.class;
        case DATE_TZ:
        case TIME_TZ:
        case DATE_TIME_TZ:
            return java.time.ZonedDateTime.class;
        case DECIMAL:
            return java.math.BigDecimal.class;
        case DOUBLE:
            return java.lang.Double.class;
        case FLOAT:
            return java.lang.Float.class;
        case INTEGER:
            return java.lang.Integer.class;
        case LONG:
            return java.lang.Long.class;
        case NONE:
            return null;
        case NUMBER:
            return java.lang.Number.class;
        case SHORT:
            return java.lang.Short.class;
        case STRING:
            return java.lang.String.class;
        case TIME:
            return java.time.LocalTime.class;
        default:
            throw new IllegalArgumentException(
                    String.format("Unsupported field type '%s': corresponding Java class needs to be added in DefaultAtlasConversionService",
                            fieldType));
        }
    }

    @Override
    public Boolean isAssignableFieldType(FieldType source, FieldType target) {
        if (source == null || target == null) {
            return Boolean.FALSE;
        }
        if (source.equals(target) || target == FieldType.ANY) {
            return Boolean.TRUE;
        }

        // Check umbrella field types
        if (target == FieldType.NUMBER) {
            return source == FieldType.BIG_INTEGER || source == FieldType.BYTE || source == FieldType.DECIMAL
                    || source == FieldType.DOUBLE || source == FieldType.FLOAT || source == FieldType.INTEGER
                    || source == FieldType.LONG || source == FieldType.SHORT;
        }

        return Boolean.FALSE;
    }
}
