/**
 * 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.cxf.cdi;

import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.stream.Stream;

import jakarta.enterprise.context.Dependent;
import jakarta.enterprise.context.spi.CreationalContext;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.inject.spi.AfterBeanDiscovery;
import jakarta.enterprise.inject.spi.AfterDeploymentValidation;
import jakarta.enterprise.inject.spi.Annotated;
import jakarta.enterprise.inject.spi.AnnotatedType;
import jakarta.enterprise.inject.spi.Bean;
import jakarta.enterprise.inject.spi.BeanManager;
import jakarta.enterprise.inject.spi.BeforeBeanDiscovery;
import jakarta.enterprise.inject.spi.BeforeShutdown;
import jakarta.enterprise.inject.spi.Extension;
import jakarta.enterprise.inject.spi.ProcessAnnotatedType;
import jakarta.enterprise.inject.spi.ProcessBean;
import jakarta.enterprise.inject.spi.ProcessProducerField;
import jakarta.enterprise.inject.spi.ProcessProducerMethod;
import jakarta.enterprise.inject.spi.WithAnnotations;
import jakarta.inject.Singleton;
import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Application;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.ext.MessageBodyReader;
import jakarta.ws.rs.ext.MessageBodyWriter;
import jakarta.ws.rs.ext.Provider;
import org.apache.cxf.Bus;
import org.apache.cxf.bus.extension.ExtensionManagerBus;
import org.apache.cxf.cdi.event.DisposableCreationalContext;
import org.apache.cxf.feature.Feature;
import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
import org.apache.cxf.jaxrs.ext.ContextClassProvider;
import org.apache.cxf.jaxrs.ext.JAXRSServerFactoryCustomizationExtension;
import org.apache.cxf.jaxrs.lifecycle.ResourceProvider;
import org.apache.cxf.jaxrs.provider.ServerConfigurableFactory;
import org.apache.cxf.jaxrs.utils.InjectionUtils;
import org.apache.cxf.jaxrs.utils.JAXRSServerFactoryCustomizationUtils;
import org.apache.cxf.jaxrs.utils.ResourceUtils;

import static java.util.Arrays.asList;
import static java.util.Optional.ofNullable;
import static org.apache.cxf.cdi.AbstractCXFBean.DEFAULT;

/**
 * Apache CXF portable CDI extension to support initialization of JAX-RS resources.
 */
public class JAXRSCdiResourceExtension implements Extension {
    private boolean hasBus;
    private Bus bus;

    private Set< Bean< ? > > applicationBeans = new LinkedHashSet< Bean< ? > >();
    private Set< Bean< ? > > serviceBeans = new HashSet< Bean< ? > >();
    private Set< Bean< ? > > providerBeans = new HashSet< Bean< ? > >();
    private Set< Bean< ? extends Feature > > featureBeans = new HashSet< Bean< ? extends Feature > >();
    private Set< Type > contextTypes = new LinkedHashSet<>();

    private final List< CreationalContext< ? > > disposableCreationalContexts =
        new ArrayList<>();
    private final List< Lifecycle > disposableLifecycles =
        new ArrayList<>();

    private final Collection< String > existingStandardClasses = new HashSet<>();

    /**
     * Holder of the classified resource classes, converted to appropriate instance
     * representations.
     */
    private static final class ClassifiedClasses {
        private List< Object > providers = new ArrayList<>();
        private List< Feature > features = new ArrayList<>();
        private List<ResourceProvider> resourceProviders = new ArrayList<>();

        public void addProviders(final Collection< Object > others) {
            this.providers.addAll(others);
        }

        public void addFeatures(final Collection< Feature > others) {
            this.features.addAll(others);
        }

        public void addResourceProvider(final ResourceProvider other) {
            this.resourceProviders.add(other);
        }

        public List< Object > getProviders() {
            return providers;
        }

        public List< Feature > getFeatures() {
            return features;
        }

        public List<ResourceProvider> getResourceProviders() {
            return resourceProviders;
        }
    }

    // observing JAXRSCdiResourceExtension a "container" can customize that value to prevent some instances
    // to be added with the default qualifier
    public Collection<String> getExistingStandardClasses() {
        return existingStandardClasses;
    }

    /**
     * Fires itsels, allows other extensions to modify this one.
     * Typical example can be to modify existingStandardClasses to prevent CXF
     * to own some beans it shouldn't create with default classifier.
     *
     * @param beforeBeanDiscovery the corresponding cdi event.
     * @param beanManager the cdi bean manager.
     */
    void onStartup(@Observes final BeforeBeanDiscovery beforeBeanDiscovery, final BeanManager beanManager) {
        final ClassLoader loader = ofNullable(Thread.currentThread().getContextClassLoader())
                .orElseGet(ClassLoader::getSystemClassLoader);
        boolean webHandled = false;
        try { // OWB
            loader.loadClass("org.apache.webbeans.web.lifecycle.WebContainerLifecycle");
            webHandled = true;
        } catch (final NoClassDefFoundError | ClassNotFoundException e) {
            // ok to keep them all
        }
        if (!webHandled) {
            try { // Weld
                loader.loadClass("org.jboss.weld.module.web.WeldWebModule");
                webHandled = true;
            } catch (final NoClassDefFoundError | ClassNotFoundException e) {
                // ok to keep them all
            }
        }
        if (webHandled) {
            existingStandardClasses.addAll(asList(
                "jakarta.servlet.http.HttpServletRequest",
                "jakarta.servlet.ServletContext"));
        }
        beanManager.getEvent().fire(this);
    }

    /**
     * For any {@link AnnotatedType} that includes a {@link Context} injection point, this method replaces
     * the field with the following code:
     * <pre>{@code
     *     @Inject @ContextResolved T field;
     * }</pre>
     * For any usage of T that is a valid context object in JAX-RS.
     *
     * It also has a side effect of capturing the context object type, in case no
     * {@link org.apache.cxf.jaxrs.ext.ContextClassProvider} was registered for the type.
     *
     * @param processAnnotatedType the annotated type being investigated
     * @param <X> the generic type of that processAnnotatedType
     */
    public <X> void convertContextsToCdi(@Observes @WithAnnotations({Context.class})
                                             ProcessAnnotatedType<X> processAnnotatedType) {
        AnnotatedType<X> annotatedType = processAnnotatedType.getAnnotatedType();
        DelegateContextAnnotatedType<X> type = new DelegateContextAnnotatedType<>(annotatedType);
        contextTypes.addAll(type.getContextFieldTypes());
        processAnnotatedType.setAnnotatedType(type);
    }

    @SuppressWarnings("unchecked")
    public <T> void collect(@Observes final ProcessBean< T > event, final BeanManager beanManager) {
        final Annotated annotated = event.getAnnotated();
        if (isAnnotationPresent(beanManager, annotated, ApplicationPath.class)) {
            applicationBeans.add(event.getBean());
        } else if (isAnnotationPresent(beanManager, annotated, Path.class)) {
            serviceBeans.add(event.getBean());
        } else if (isAnnotationPresent(beanManager, annotated, Provider.class)) {
            providerBeans.add(event.getBean());
        } else if (event.getBean().getTypes().contains(jakarta.ws.rs.core.Feature.class)) {
            providerBeans.add(event.getBean());
        } else if (event.getBean().getTypes().contains(Feature.class)) {
            featureBeans.add((Bean< ? extends Feature >)event.getBean());
        } else if (CdiBusBean.CXF.equals(event.getBean().getName())
                && Bus.class.isAssignableFrom(event.getBean().getBeanClass())) {
            hasBus = true;
        } else if (event.getBean().getQualifiers().contains(DEFAULT)) {
            event.getBean().getTypes().stream()
                .filter(e -> Object.class != e && InjectionUtils.STANDARD_CONTEXT_CLASSES.contains(e.getTypeName()))
                .findFirst()
                .ifPresent(type -> existingStandardClasses.add(type.getTypeName()));
        }
    }
    
    public <T, X> void collect(@Observes final ProcessProducerField< T, X > event) {
        final Type baseType = event.getAnnotatedProducerField().getBaseType();
        processProducer(event, baseType);
    }

    public <T, X> void collect(@Observes final ProcessProducerMethod< T, X > event) {
        final Type baseType = event.getAnnotatedProducerMethod().getBaseType();
        processProducer(event, baseType);
    }

    public void load(@Observes final AfterDeploymentValidation event, final BeanManager beanManager) {
        // no need of creational context, it only works for app scoped instances anyway
        final Bean<?> busBean = beanManager.resolve(beanManager.getBeans(CdiBusBean.CXF));

        bus = (Bus)beanManager.getReference(
                busBean,
                Bus.class,
                beanManager.createCreationalContext(busBean));
        
        // Adding the extension for dynamic providers registration and instantiation
        if (bus.getExtension(ServerConfigurableFactory.class) == null) {
            bus.setExtension(new CdiServerConfigurableFactory(beanManager), ServerConfigurableFactory.class);
        }

        for (final Bean< ? > application: applicationBeans) {
            final Application instance = (Application)beanManager.getReference(
                application,
                application.getBeanClass(),
                createCreationalContext(beanManager, application)
            );

            // If there is an application without any singletons and classes defined, we will
            // create a server factory bean with all services and providers discovered.
            if (instance.getSingletons().isEmpty() && instance.getClasses().isEmpty()) {
                final JAXRSServerFactoryBean factory = createFactoryInstance(instance,
                    loadServices(beanManager, Collections.<Class<?>>emptySet()),
                    loadProviders(beanManager, Collections.<Class<?>>emptySet()),
                    loadFeatures(beanManager, Collections.<Class<?>>emptySet()));
                customize(beanManager, factory);
                factory.init();
            } else {
                // If there is an application with any singletons or classes defined, we will
                // create a server factory bean with only application singletons and classes.
                final JAXRSServerFactoryBean factory = createFactoryInstance(instance, beanManager);
                customize(beanManager, factory);
                factory.init();
            }
        }

        cleanStartupData();
    }

    public void injectBus(@Observes final AfterBeanDiscovery event, final BeanManager beanManager) {
        if (!hasBus) {
            final AnnotatedType< ExtensionManagerBus > busAnnotatedType =
                beanManager.createAnnotatedType(ExtensionManagerBus.class);

            event.addBean(new CdiBusBean(beanManager.getInjectionTargetFactory(busAnnotatedType)));
        }

        if (applicationBeans.isEmpty() && !serviceBeans.isEmpty()) {
            final DefaultApplicationBean applicationBean = new DefaultApplicationBean();
            applicationBeans.add(applicationBean);
            event.addBean(applicationBean);
        } else {
            // otherwise will be ambiguous since we scanned it with default qualifier already
            existingStandardClasses.add(Application.class.getName());
        }

        // always add the standard context classes
        InjectionUtils.STANDARD_CONTEXT_CLASSES.stream()
                .map(this::toClass)
                .filter(Objects::nonNull)
                .forEach(contextTypes::add);
        // add custom contexts
        contextTypes.addAll(getCustomContextClasses());
        // register all of the context types
        contextTypes.forEach(
            t -> event.addBean(new ContextProducerBean(t, !existingStandardClasses.contains(t.getTypeName()))));
    }

    private void cleanStartupData() { // enable gc
        Stream.of(serviceBeans, providerBeans, featureBeans, applicationBeans, contextTypes).forEach(Collection::clear);
        serviceBeans = null;
        providerBeans = null;
        featureBeans = null;
        applicationBeans = null;
        contextTypes = null;
    }

    /**
     * Registers created CreationalContext instances for disposal
     */
    public void registerCreationalContextForDisposal(@Observes final DisposableCreationalContext event) {
        synchronized (disposableCreationalContexts) {
            disposableCreationalContexts.add(event.getContext());
        }
    }
    
    /**
     * Releases created CreationalContext instances
     */
    public void release(@Observes final BeforeShutdown event) {
        synchronized (disposableCreationalContexts) {
            for (final CreationalContext<?> disposableCreationalContext: disposableCreationalContexts) {
                disposableCreationalContext.release();
            }
            disposableCreationalContexts.clear();
        }

        disposableLifecycles.forEach(Lifecycle::destroy);
        disposableLifecycles.clear();
    }

    private Class<?> toClass(String name) {
        try {
            return Class.forName(name);
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * Create the JAXRSServerFactoryBean from the application and all discovered service and provider instances.
     * @param application application instance
     * @param services all discovered services
     * @param providers all discovered providers
     * @return JAXRSServerFactoryBean instance
     */
    private JAXRSServerFactoryBean createFactoryInstance(final Application application, final List< ? > services,
            final List< ? > providers, final List< ? extends Feature > features) {

        final JAXRSServerFactoryBean instance = 
            ResourceUtils.createApplication(application, false, false, false, bus);
        instance.setServiceBeans(new ArrayList<>(services));
        instance.setProviders(providers);
        instance.setProviders(loadExternalProviders());
        instance.setFeatures(features);

        return instance;
    }

    /**
     * Create the JAXRSServerFactoryBean from the objects declared by application itself.
     * @param application application instance
     * @return JAXRSServerFactoryBean instance
     */
    private JAXRSServerFactoryBean createFactoryInstance(final Application application, final BeanManager beanManager) {
        final JAXRSServerFactoryBean instance =
            ResourceUtils.createApplication(application, false, false, false, bus);
        final ClassifiedClasses classified = classes2singletons(application, beanManager);

        instance.setProviders(classified.getProviders());
        instance.getFeatures().addAll(classified.getFeatures());

        for (final ResourceProvider resourceProvider: classified.getResourceProviders()) {
            instance.setResourceProvider(resourceProvider.getResourceClass(), resourceProvider);
        }

        return instance;
    }

    /**
     * JAX-RS application has defined singletons as being classes of any providers, resources and features.
     * In the JAXRSServerFactoryBean, those should be split around several method calls depending on instance
     * type. At the moment, only the Feature is CXF-specific and should be replaced by JAX-RS Feature implementation.
     * @param application the application instance
     * @return classified instances of classes by instance types
     */
    private ClassifiedClasses classes2singletons(final Application application, final BeanManager beanManager) {
        final ClassifiedClasses classified = new ClassifiedClasses();

        // now loop through the classes
        Set<Class<?>> classes = application.getClasses();
        if (!classes.isEmpty()) {
            classified.addProviders(loadProviders(beanManager, classes));
            classified.addFeatures(loadFeatures(beanManager, classes));

            for (final Bean< ? > bean: serviceBeans) {
                if (classes.contains(bean.getBeanClass())) {
                    // normal scoped beans will return us a proxy in getInstance so it is singletons for us,
                    // @Singleton is indeed a singleton
                    // @Dependent should be a request scoped instance but for backward compat we kept it a singleton
                    //
                    // other scopes are considered request scoped (for jaxrs)
                    // and are created per request (getInstance/releaseInstance)
                    final ResourceProvider resourceProvider;
                    if (isCxfSingleton(beanManager, bean)) {
                        final Lifecycle lifecycle = new Lifecycle(beanManager, bean);
                        resourceProvider = new SingletonResourceProvider(lifecycle, bean.getBeanClass());

                        // if not a singleton we manage it per request
                        // if @Singleton the container handles it
                        // so we only need this case here
                        if (Dependent.class == bean.getScope()) {
                            disposableLifecycles.add(lifecycle);
                        }
                    } else {
                        resourceProvider = new PerRequestResourceProvider(
                        () -> new Lifecycle(beanManager, bean), bean.getBeanClass());
                    }
                    classified.addResourceProvider(resourceProvider);
                }
            }
        }

        return classified;
    }

    boolean isCxfSingleton(final BeanManager beanManager, final Bean<?> bean) {
        return beanManager.isNormalScope(bean.getScope()) || isConsideredSingleton(bean.getScope());
    }

    // warn: several impls use @Dependent == request so we should probably add a flag
    private boolean isConsideredSingleton(final Class<?> scope) {
        return Singleton.class == scope || Dependent.class == scope;
    }

    /**
     * Load external providers from service loader
     * @return loaded external providers
     */
    @SuppressWarnings("rawtypes")
    private List< Object > loadExternalProviders() {
        final List< Object > providers = new ArrayList< Object >();

        final ServiceLoader< MessageBodyWriter > writers = ServiceLoader.load(MessageBodyWriter.class);
        for (final MessageBodyWriter< ? > writer: writers) {
            providers.add(writer);
        }

        final ServiceLoader< MessageBodyReader > readers = ServiceLoader.load(MessageBodyReader.class);
        for (final MessageBodyReader< ? > reader: readers) {
            providers.add(reader);
        }

        return providers;
    }

    /**
     * Gets the references for all discovered JAX-RS resources
     * @param beanManager bean manager instance
     * @param limitedClasses not null, if empty ignored.  the set of classes to consider as providers
     * @return the references for all discovered JAX-RS resources
     */
    private List< Object > loadProviders(final BeanManager beanManager, Collection<Class<?>> limitedClasses) {
        return loadBeans(beanManager, limitedClasses, providerBeans);
    }

    /**
     * Gets the references for all discovered JAX-RS providers
     * @param beanManager bean manager instance
     * @param limitedClasses not null, if empty ignored.  the set of classes to consider as providers
     * @return the references for all discovered JAX-RS providers
     */
    private List< Object > loadServices(final BeanManager beanManager, Collection<Class<?>> limitedClasses) {
        return loadBeans(beanManager, limitedClasses, serviceBeans);
    }

    /**
     * Gets references for all beans of a given type
     * @param beanManager bean manager instance
     * @param limitedClasses not null, if empty ignored.  the set of classes to consider as providers
     * @param beans the collection of beans to go through
     * @return the references for all discovered JAX-RS providers
     */
    private List< Object > loadBeans(final BeanManager beanManager, Collection<Class<?>> limitedClasses,
                                     Collection<Bean<?>> beans) {
        final List< Object > instances = new ArrayList<>();

        for (final Bean< ? > bean: beans) {
            if (limitedClasses.isEmpty() || limitedClasses.contains(bean.getBeanClass())) {
                instances.add(
                      beanManager.getReference(
                            bean,
                            Object.class,
                            createCreationalContext(beanManager, bean)
                      )
                );
            }
        }

        return instances;
    }

    /**
     * Gets the references for all discovered CXF-specific features
     * @param beanManager bean manager instance
     * @return the references for all discovered CXF-specific features
     */
    private List< Feature > loadFeatures(final BeanManager beanManager, Collection<Class<?>> limitedClasses) {
        // Use set to account for singletons and application scoped beans
        final Set< Feature > features = new LinkedHashSet<>();

        for (final Bean< ? extends Feature > bean: featureBeans) {
            if (limitedClasses.isEmpty() || limitedClasses.contains(bean.getBeanClass())) {
                features.add(
                        (Feature) beanManager.getReference(
                                bean,
                                Feature.class,
                                createCreationalContext(beanManager, bean)
                        )
                );
            }
        }

        return new ArrayList<>(features);
    }

    /**
     * Look and apply the available JAXRSServerFactoryBean extensions to customize its
     * creation (f.e. add features, providers, assign transport, ...)
     * @param beanManager bean manager
     * @param bean JAX-RS server factory bean about to be created
     */
    private void customize(final BeanManager beanManager, final JAXRSServerFactoryBean bean) {
        JAXRSServerFactoryCustomizationUtils.customize(bean);
        final Collection<Bean<?>> extensionBeans = beanManager.getBeans(JAXRSServerFactoryCustomizationExtension.class);

        for (final Bean<?> extensionBean: extensionBeans) {
            final JAXRSServerFactoryCustomizationExtension extension =
                (JAXRSServerFactoryCustomizationExtension)beanManager.getReference(
                    extensionBean,
                    JAXRSServerFactoryCustomizationExtension.class,
                    createCreationalContext(beanManager, extensionBean)
                );
            extension.customize(bean);
        }
    }

    /**
     * Creates and collects the CreationalContext instances for future releasing.
     * @param beanManager bean manager instance
     * @param bean bean instance to create CreationalContext for
     * @return CreationalContext instance
     */
    private<T> CreationalContext< T > createCreationalContext(final BeanManager beanManager, Bean< T > bean) {
        final CreationalContext< T > creationalContext = beanManager.createCreationalContext(bean);
        
        if (!(bean instanceof DefaultApplicationBean)) {
            synchronized (disposableCreationalContexts) {
                disposableCreationalContexts.add(creationalContext);
            }
        }
        
        return creationalContext;
    }

    /**
     * Extracts relevant beans from producers.
     * @param event process bean event
     * @param baseType base type of the producer
     */
    private <T> void processProducer(final ProcessBean<T> event, final Type baseType) {
        if (baseType instanceof Class<?>) {
            final Class<?> clazz = (Class<?>)baseType;
            if (clazz.isAnnotationPresent(Path.class)) {
                serviceBeans.add(event.getBean());
            } else if (clazz.isAnnotationPresent(Provider.class)) {
                providerBeans.add(event.getBean());
            } else if (clazz.isAnnotationPresent(ApplicationPath.class)) {
                applicationBeans.add(event.getBean());
            } 
        }
    }

    public static Set<Class<?>> getCustomContextClasses() {
        ServiceLoader<ContextClassProvider> classProviders = ServiceLoader.load(ContextClassProvider.class);
        Set<Class<?>> customContextClasses = new LinkedHashSet<>();
        for (ContextClassProvider classProvider : classProviders) {
            customContextClasses.add(classProvider.getContextClass());
        }
        return Collections.unmodifiableSet(customContextClasses);
    }
    
    @SuppressWarnings("unchecked")
    private static boolean isAnnotationPresent(final BeanManager beanManager, final Annotated annotated, 
            final Class<? extends Annotation> annotationType) {

        if (annotated.isAnnotationPresent(annotationType)) {
            return true;
        }
        
        final Stream<AnnotatedType<?>> annotatedTypes = annotated
            .getTypeClosure()
            .stream()
            .filter(Class.class::isInstance)
            .map(Class.class::cast)
            .map(cls -> (AnnotatedType<?>)beanManager.createAnnotatedType(cls));
        
        return annotatedTypes.anyMatch(at -> at.isAnnotationPresent(annotationType));
    }
}
