/*
 *  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.causeway.core.metamodel._testing;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Predicate;

import jakarta.annotation.PostConstruct;
import jakarta.inject.Inject;

import org.springframework.beans.factory.InjectionPoint;
import org.springframework.core.MethodParameter;

import org.apache.causeway.applib.exceptions.unrecoverable.MetaModelException;
import org.apache.causeway.applib.services.inject.ServiceInjector;
import org.apache.causeway.applib.services.registry.ServiceRegistry;
import org.apache.causeway.commons.internal._Constants;
import org.apache.causeway.commons.internal.base._NullSafe;
import org.apache.causeway.commons.internal.collections._Arrays;
import org.apache.causeway.commons.internal.collections._Collections;
import org.apache.causeway.commons.internal.collections._Maps;
import org.apache.causeway.commons.internal.reflection._Generics;
import org.apache.causeway.commons.internal.reflection._Reflect;
import org.apache.causeway.core.config.CausewayConfiguration;
import org.apache.causeway.core.metamodel.commons.ToString;

import org.jspecify.annotations.NonNull;
import lombok.extern.log4j.Log4j2;

@Log4j2
final class _ServiceInjectorLegacy implements ServiceInjector {

    @SuppressWarnings("unused")
    @Inject private CausewayConfiguration configuration;
    @Inject private ServiceRegistry serviceRegistry;
    @Inject private _InjectorMethodEvaluator injectorMethodEvaluator;

    private final Map<Class<?>, Method[]> methodsByClassCache = _Maps.newConcurrentHashMap();
    private final Map<Class<?>, Field[]> fieldsByClassCache = _Maps.newConcurrentHashMap();

    @Override
    public <T> T injectServicesInto(final T domainObject) {
        injectServices(domainObject, injectionPoint->{

            var injectionPointName = injectionPoint.toString();
            var requiredType = injectionPoint.getDeclaredType();
            var msg = String
                    .format("Could not resolve injection point [%s] in target '%s' of required type '%s'",
                            injectionPointName,
                            domainObject.getClass().getName(),
                            requiredType);
            throw new NoSuchElementException(msg);
        });
        return domainObject;
    }

    @PostConstruct
    public void init() {
        autowireSetters = false;
        autowireInject = true;
    }

    // -- HELPERS

    boolean autowireSetters;
    boolean autowireInject;

    private void injectServices(final Object targetPojo, final Consumer<InjectionPoint> onNotResolvable) {

        var type = targetPojo.getClass();

//XXX check is too slow
//        if(serviceRegistry.isResolvableBean(type)) {
//            log.warn("Skipping call injectServices() on an already managed bean {}.", type);
//            return; // already managed
//        }

        injectToFields(targetPojo, type, onNotResolvable);

        if(autowireSetters) {
            injectViaPrefixedMethods(targetPojo, type, "set", onNotResolvable);
        }
        if(autowireInject) {
            injectViaPrefixedMethods(targetPojo, type, "inject", onNotResolvable);
        }
    }

    private void injectToFields(final Object targetPojo, final Class<?> cls, final Consumer<InjectionPoint> onNotResolvable) {

        _NullSafe.stream(fieldsByClassCache.computeIfAbsent(cls, __->cls.getDeclaredFields()))
        .filter(isAnnotatedForInjection())
        .forEach(field->injectToField(targetPojo, field, onNotResolvable));

        // recurse up the object's class hierarchy
        final Class<?> superclass = cls.getSuperclass();
        if(superclass != null) {
            injectToFields(targetPojo, superclass, onNotResolvable);
        }
    }

    private void injectToField(final Object targetPojo,
            final Field field,
            final Consumer<InjectionPoint> onNotResolvable) {

        final Class<?> typeToBeInjected = field.getType();
        // don't think that type can ever be null,
        // but Javadoc for java.lang.reflect.Field doesn't say
        if(typeToBeInjected == null) {
            return;
        }

        // inject matching services into a field of type Collection<T> if a generic type T is present
        final Class<?> elementType = inferElementType(field)
                .orElse(null);
        if(elementType!=null) {
            injectToField_nonScalar(targetPojo, field, elementType, onNotResolvable);
            return;
        }

        var beans = serviceRegistry.select(typeToBeInjected, field.getAnnotations());

        if(beans.isEmpty()) {
            onNotResolvable.accept(new InjectionPoint(field));
        } else if(beans.isCardinalityOne()) {
            var bean = beans.getSingleton().get();
            invokeInjectorField(field, targetPojo, bean);
        } else {

            var requiredAnnotations = _Arrays.combineWithExplicitType(
                    Annotation.class,
                    _Constants.ANNOTATION_PRIMARY,
                    field.getAnnotations());

            // look for primary
            var primaryBean = serviceRegistry.select(typeToBeInjected, requiredAnnotations);
            if(!primaryBean.isEmpty()) {
                var bean = primaryBean.getFirst().get();
                invokeInjectorField(field, targetPojo, bean);
                return;
            }

            // fallback: pick first in list
            var bean = beans.getFirst().get();
            invokeInjectorField(field, targetPojo, bean);
        }

    }

    /**
     * Optionally returns the inferred element type for given {@code field}, based on whether
     * it represents a collection and inference is possible.
     */
    private static Optional<Class<?>> inferElementType(final @NonNull Field field) {
        var fieldType = field.getType();
        return _Collections.isCollectionType(fieldType)
                || _Collections.isImmutableCollectionType(fieldType)
                ? _Generics.streamGenericTypeArgumentsOfField(field)
                        .findFirst()
                        : Optional.empty();
    }

    @SuppressWarnings("unchecked")
    private void injectToField_nonScalar(
            final Object targetPojo,
            final Field field,
            final Class<?> elementType,
            final Consumer<InjectionPoint> onNotResolvable) {

        final Class<? extends Collection<Object>> collectionTypeToBeInjected =
                (Class<? extends Collection<Object>>) field.getType();

        var beans = serviceRegistry.select(elementType, field.getAnnotations());
        if(!beans.isEmpty()) {
            final Collection<Object> collectionOfServices = beans.stream()
                    .filter(isOfType(elementType))
                    // javac does require an explicit type argument here,
                    // while eclipse compiler does not ...
                    .collect(_Collections.<Object>toUnmodifiableOfType(collectionTypeToBeInjected));

            invokeInjectorField(field, targetPojo, collectionOfServices);
        } else {
            onNotResolvable.accept(new InjectionPoint(field));
        }

    }

    private void injectViaPrefixedMethods(
            final Object targetPojo,
            final Class<?> cls,
            final String prefix,
            final Consumer<InjectionPoint> onNotResolvable) {

        _NullSafe.stream(methodsByClassCache.computeIfAbsent(cls, __->cls.getMethods()))
        .filter(nameStartsWith(prefix))
        .forEach(prefixedMethod->injectIntoSetter(targetPojo, prefixedMethod, onNotResolvable));
    }

    private void injectIntoSetter(
            final Object targetPojo,
            final Method setter,
            final Consumer<InjectionPoint> onNotResolvable) {

        final Class<?> typeToBeInjected = injectorMethodEvaluator.getTypeToBeInjected(setter);
        if(typeToBeInjected == null) {
            return;
        }

        var instance = serviceRegistry.select(typeToBeInjected, setter.getAnnotations());
        if(instance.isCardinalityOne()) {
            var bean = instance.getSingleton().get();
            invokeInjectorMethod(setter, targetPojo, bean);
        } else {
            onNotResolvable.accept(new InjectionPoint(new MethodParameter(setter, 0)));
        }

    }

    private static void invokeMethod(final Method method, final Object target, final Object[] parameters) {
        try {
            method.invoke(target, parameters);
        } catch (final SecurityException | IllegalAccessException e) {
            throw new MetaModelException(String.format("Cannot access the %s method in %s", method.getName(), target.getClass().getName()));
        } catch (final IllegalArgumentException e1) {
            throw new MetaModelException(e1);
        } catch (final InvocationTargetException e) {
            final Throwable targetException = e.getTargetException();
            if (targetException instanceof RuntimeException) {
                throw (RuntimeException) targetException;
            } else {
                throw new MetaModelException(targetException);
            }
        }
    }

    private static void invokeInjectorField(final Field field, final Object target, final Object parameter) {
        try {
            _Reflect.setFieldOn(field, target, parameter);
        } catch (final IllegalArgumentException e) {
            throw new MetaModelException(e);
        } catch (final IllegalAccessException e) {
            throw new MetaModelException(String.format("Cannot access the %s field in %s", field.getName(), target.getClass().getName()));
        }
        if (log.isDebugEnabled()) {
            log.debug("injected {} into {}", parameter, new ToString(target));
        }
    }

    private static void invokeInjectorMethod(final Method method, final Object target, final Object parameter) {
        final Object[] parameters = new Object[] { parameter };
        invokeMethod(method, target, parameters);
        if (log.isDebugEnabled()) {
            log.debug("injected {} into {}", parameter, new ToString(target));
        }
    }

    // -- REFLECTIVE PREDICATES

    private static final Predicate<Object> isOfType(final Class<?> cls) {
        return obj->cls.isAssignableFrom(obj.getClass());
    }

    private static final Predicate<Method> nameStartsWith(final String prefix) {
        return method->method.getName().startsWith(prefix);
    }

    private static final Predicate<Field> isAnnotatedForInjection() {
        return field->field.getAnnotation(jakarta.inject.Inject.class) != null;
    }

    // -- TESTING

    /**
     * JUnit Test support.
     */
    public static _ServiceInjectorLegacy getInstanceAndInit(
            final CausewayConfiguration configuration,
            final ServiceRegistry serviceRegistry,
            final _InjectorMethodEvaluator injectorMethodEvaluator) {
        var instance = new _ServiceInjectorLegacy();

        instance.configuration = configuration;
        instance.serviceRegistry = serviceRegistry;
        instance.injectorMethodEvaluator = injectorMethodEvaluator;

        instance.init();

        return instance;
    }

}
