/*
 *  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
 *
 *    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 org.grails.orm.hibernate.cfg;

import java.io.IOException;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

import javax.sql.DataSource;

import jakarta.annotation.Nullable;
import jakarta.persistence.Embeddable;
import jakarta.persistence.Entity;
import jakarta.persistence.MappedSuperclass;

import org.hibernate.HibernateException;
import org.hibernate.MappingException;
import org.hibernate.SessionFactory;
import org.hibernate.boot.registry.BootstrapServiceRegistry;
import org.hibernate.boot.registry.BootstrapServiceRegistryBuilder;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.boot.registry.classloading.internal.ClassLoaderServiceImpl;
import org.hibernate.boot.registry.classloading.spi.ClassLoaderService;
import org.hibernate.boot.spi.AdditionalMappingContributor;
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.cfg.BytecodeSettings;
import org.hibernate.cfg.Configuration;
import org.hibernate.cfg.Environment;
import org.hibernate.cfg.JdbcSettings;
import org.hibernate.context.spi.CurrentSessionContext;
import org.hibernate.internal.util.config.ConfigurationHelper;
import org.hibernate.service.ServiceRegistry;

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternUtils;
import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.core.type.classreading.MetadataReaderFactory;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.core.type.filter.TypeFilter;
import org.springframework.util.ClassUtils;

import org.grails.datastore.gorm.GormEntity;
import org.grails.datastore.gorm.jdbc.connections.DataSourceSettings;
import org.grails.datastore.mapping.core.connections.ConnectionSource;
import org.grails.datastore.mapping.model.PersistentEntity;
import org.grails.orm.hibernate.EventListenerIntegrator;
import org.grails.orm.hibernate.GrailsSessionContext;
import org.grails.orm.hibernate.HibernateEventListeners;
import org.grails.orm.hibernate.MetadataIntegrator;
import org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsDomainBinder;
import org.grails.orm.hibernate.cfg.domainbinding.util.NamingStrategyProvider;
import org.grails.orm.hibernate.proxy.GrailsBytecodeProvider;

/**
 * A Configuration that uses a MappingContext to configure Hibernate
 *
 * @since 5.0
 */
@SuppressWarnings({"rawtypes", "PMD.UseProperClassLoader", "PMD.DataflowAnomalyAnalysis", "PMD.CloseResource"})
public class HibernateMappingContextConfiguration extends Configuration
        implements ApplicationContextAware, Serializable {

    @Serial
    private static final long serialVersionUID = -7115087342689305517L;

    private static final String RESOURCE_PATTERN = "/**/*.class";

    private static final TypeFilter[] ENTITY_TYPE_FILTERS = new TypeFilter[] {
        new AnnotationTypeFilter(Entity.class, false),
        new AnnotationTypeFilter(Embeddable.class, false),
        new AnnotationTypeFilter(MappedSuperclass.class, false)
    };
    private static final String FALSE_LITERAL = "false";
    private final Class<? extends CurrentSessionContext> currentSessionContext = GrailsSessionContext.class;
    //    private MetadataContributor metadataContributor;
    private final Set<Class> additionalClasses = new HashSet<>();
    protected String sessionFactoryBeanName = "sessionFactory";
    protected String dataSourceName = ConnectionSource.DEFAULT;
    protected transient HibernateMappingContext hibernateMappingContext;
    private transient HibernateEventListeners hibernateEventListeners;
    private Map<String, Object> eventListeners;
    private transient ServiceRegistry serviceRegistry;
    private transient ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver();
    private transient NamingStrategyProvider namingStrategyProvider = new NamingStrategyProvider();
    protected GrailsBytecodeProvider bytecodeProvider;

    public void setBytecodeProvider(GrailsBytecodeProvider bytecodeProvider) {
        this.bytecodeProvider = bytecodeProvider;
    }

    public NamingStrategyProvider getNamingStrategyProvider() {
        return namingStrategyProvider;
    }

    public void setNamingStrategyProvider(NamingStrategyProvider namingStrategyProvider) {
        this.namingStrategyProvider = namingStrategyProvider;
    }

    public MappingCacheHolder getMappingCacheHolder() {
        return hibernateMappingContext != null ? hibernateMappingContext.getMappingCacheHolder() : null;
    }

    public void setHibernateMappingContext(HibernateMappingContext hibernateMappingContext) {
        this.hibernateMappingContext = hibernateMappingContext;
    }

    @Override
    public void setApplicationContext(@Nullable ApplicationContext applicationContext) throws BeansException {
        resourcePatternResolver = ResourcePatternUtils.getResourcePatternResolver(applicationContext);
        String dsName = ConnectionSource.DEFAULT.equals(dataSourceName) ? "dataSource" : "dataSource_" + dataSourceName;
        Properties properties = getProperties();

        if (applicationContext != null) {
            if (!properties.containsKey(JdbcSettings.JAKARTA_NON_JTA_DATASOURCE) && applicationContext.containsBean(dsName)) {
                properties.put(JdbcSettings.JAKARTA_NON_JTA_DATASOURCE, applicationContext.getBean(dsName));
            }
            properties.put(Environment.CURRENT_SESSION_CONTEXT_CLASS, currentSessionContext.getName());
            properties.put(
                    "hibernate.enhancer.bytecodeprovider.instance",
                getGrailsBytecodeProvider());
            properties.put("hibernate.bytecode.allow_enhancement_as_proxy", FALSE_LITERAL);
            properties.put("hibernate.bytecode.enhancement_metadata_cache", FALSE_LITERAL);
            properties.put("hibernate.enhancer.enableLazyInitialization", FALSE_LITERAL);
            properties.put("hibernate.enhancer.enableDirtyTracking", FALSE_LITERAL);
            properties.put("hibernate.enhancer.enableAssociationManagement", FALSE_LITERAL);
            ClassLoader classLoader = applicationContext.getClassLoader();
            if (classLoader != null) {
                properties.put(AvailableSettings.CLASSLOADERS, classLoader);
            }
        }
    }

    protected GrailsBytecodeProvider getGrailsBytecodeProvider() {
        return bytecodeProvider != null ? bytecodeProvider : new GrailsBytecodeProvider();
    }

    /**
     * Set the target SQL {@link DataSource}
     *
     * @param connectionSource The data source to use
     */
    public void setDataSourceConnectionSource(ConnectionSource<DataSource, DataSourceSettings> connectionSource) {
        this.dataSourceName = connectionSource.getName();
        DataSource source = connectionSource.getSource();
        getProperties().put(JdbcSettings.JAKARTA_NON_JTA_DATASOURCE, source);
        getProperties().put(Environment.CURRENT_SESSION_CONTEXT_CLASS, GrailsSessionContext.class.getName());
        setBytecodeProvider(getGrailsBytecodeProvider());
        final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        if (contextClassLoader != null &&
                contextClassLoader.getClass().getSimpleName().equalsIgnoreCase("RestartClassLoader")) {
            getProperties().put(AvailableSettings.CLASSLOADERS, contextClassLoader);
        } else {
            getProperties()
                    .put(
                            AvailableSettings.CLASSLOADERS,
                            connectionSource.getClass().getClassLoader());
        }
    }

    /**
     * Add the given annotated classes in a batch.
     *
     * @return Configuration
     * @see #addAnnotatedClass
     * @see #scanPackages
     */
    @Override
    public Configuration addAnnotatedClasses(Class... annotatedClasses) {
        for (Class<?> annotatedClass : annotatedClasses) {
            addAnnotatedClass(annotatedClass);
        }
        return this;
    }

    @Override
    public Configuration addAnnotatedClass(Class annotatedClass) {
        additionalClasses.add(annotatedClass);
        return super.addAnnotatedClass(annotatedClass);
    }

    @Override
    public HibernateMappingContextConfiguration addPackages(String... annotatedPackages) {
        for (String annotatedPackage : annotatedPackages) {
            addPackage(annotatedPackage);
        }
        return this;
    }

    /**
     * Perform Spring-based scanning for entity classes, registering them as annotated classes with
     * this {@code Configuration}.
     *
     * @param packagesToScan one or more Java package names
     * @throws HibernateException if scanning fails for any reason
     */
    public void scanPackages(String... packagesToScan) throws HibernateException {
        try {
            MetadataReaderFactory readerFactory = new CachingMetadataReaderFactory(resourcePatternResolver);
            for (String pkg : packagesToScan) {
                String pattern = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
                        ClassUtils.convertClassNameToResourcePath(pkg) +
                        RESOURCE_PATTERN;
                Resource[] resources = resourcePatternResolver.getResources(pattern);
                for (Resource resource : resources) {
                    if (resource.isReadable()) {
                        MetadataReader reader = readerFactory.getMetadataReader(resource);
                        String className = reader.getClassMetadata().getClassName();
                        if (matchesFilter(reader, readerFactory)) {
                            ClassLoader classLoader = resourcePatternResolver.getClassLoader();
                            Class<?> loadedClass = classLoader != null ?
                                    classLoader.loadClass(className) :
                                    ClassUtils.forName(className, null);
                            addAnnotatedClasses(loadedClass);
                        }
                    }
                }
            }
        } catch (IOException ex) {
            throw new MappingException("Failed to scan classpath for unlisted classes", ex);
        } catch (ClassNotFoundException ex) {
            throw new MappingException("Failed to load annotated classes from classpath", ex);
        }
    }

    /**
     * Check whether any of the configured entity type filters matches the current class descriptor
     * contained in the metadata reader.
     */
    protected boolean matchesFilter(MetadataReader reader, MetadataReaderFactory readerFactory) throws IOException {
        for (TypeFilter filter : ENTITY_TYPE_FILTERS) {
            if (filter.match(reader, readerFactory)) {
                return true;
            }
        }
        return false;
    }

    public void setSessionFactoryBeanName(String name) {
        sessionFactoryBeanName = name;
    }

    public void setDataSourceName(String name) {
        dataSourceName = name;
    }

    /* (non-Javadoc)
     * @see org.hibernate.cfg.Configuration#buildSessionFactory()
     */
    @Override
    public SessionFactory buildSessionFactory() throws HibernateException {
        // 1. FORCE the custom bytecode provider instance right before bootstrap
        // This bypasses the ServiceLoader and ensures your GrailsBytecodeProvider is used.
        GrailsBytecodeProvider bytecodeProvider = getGrailsBytecodeProvider();
        getProperties()
                .put(
                        BytecodeSettings.BYTECODE_PROVIDER_INSTANCE,
                        bytecodeProvider);

        // set the class loader to load Groovy classes

        // work around for HHH-2624
        SessionFactory sessionFactory;

        Object classLoaderObject = getProperties().get(AvailableSettings.CLASSLOADERS);
        ClassLoader appClassLoader;

        if (classLoaderObject instanceof ClassLoader) {
            appClassLoader = (ClassLoader) classLoaderObject;
        } else {
            appClassLoader = getClass().getClassLoader();
        }

        ConfigurationHelper.resolvePlaceHolders(getProperties());

        final GrailsDomainBinder domainBinder = new GrailsDomainBinder(
                dataSourceName,
                sessionFactoryBeanName,
                hibernateMappingContext,
                namingStrategyProvider,
                hibernateMappingContext.getMappingCacheHolder());

        List<Class> annotatedClasses = new ArrayList<>();
        for (PersistentEntity persistentEntity : hibernateMappingContext.getPersistentEntities()) {
            Class<?> javaClass = persistentEntity.getJavaClass();
            if (javaClass.isAnnotationPresent(Entity.class)) {
                annotatedClasses.add(javaClass);
            }
        }

        if (!additionalClasses.isEmpty()) {
            for (Class additionalClass : additionalClasses) {
                if (GormEntity.class.isAssignableFrom(additionalClass)) {
                    hibernateMappingContext.addPersistentEntity(additionalClass);
                }
            }
        }

        addAnnotatedClasses(annotatedClasses.toArray(new Class[0]));

        ClassLoaderService classLoaderService = new ClassLoaderServiceImpl(appClassLoader) {
            @Override
            public <S> Collection<S> loadJavaServices(Class<S> serviceContract) {
                // Ensure Grails contributes mappings for GORM entities even if they lack JPA @Entity
                if (AdditionalMappingContributor.class.isAssignableFrom(serviceContract)) {
                    // Include the GrailsDomainBinder first, then any other contributors
                    // discovered by the parent classloader (e.g., Envers AdditionalMappingContributorImpl).
                    // Without this, Envers' AdditionalMappingContributor would be excluded,
                    // preventing EnversService from being initialized before EnversIntegrator runs.
                    Collection<S> parentContributors = super.loadJavaServices(serviceContract);
                    @SuppressWarnings("unchecked")
                    S grailsBinder = (S) domainBinder;
                    List<S> allContributors = new ArrayList<>(parentContributors.size() + 1);
                    allContributors.add(grailsBinder);
                    allContributors.addAll(parentContributors);
                    return allContributors;
                }
                return super.loadJavaServices(serviceContract);
            }
        };
        EventListenerIntegrator eventListenerIntegrator =
                new EventListenerIntegrator(hibernateEventListeners, eventListeners);
        BootstrapServiceRegistry bootstrapServiceRegistry = createBootstrapServiceRegistryBuilder()
                .applyIntegrator(eventListenerIntegrator)
                .applyIntegrator(new MetadataIntegrator())
                .applyClassLoaderService(classLoaderService)
                .build();

        StandardServiceRegistryBuilder standardServiceRegistryBuilder =
                createStandardServiceRegistryBuilder(bootstrapServiceRegistry).applySettings((Map) getProperties());

        Object dataSource = getProperties().get(JdbcSettings.JAKARTA_NON_JTA_DATASOURCE);
        if (dataSource instanceof DataSource) {
            standardServiceRegistryBuilder.applySetting(JdbcSettings.JAKARTA_NON_JTA_DATASOURCE, dataSource);
        }

        standardServiceRegistryBuilder.addService(org.hibernate.bytecode.spi.BytecodeProvider.class, bytecodeProvider);

        StandardServiceRegistry ssr = standardServiceRegistryBuilder.build();
        try {
            sessionFactory = super.buildSessionFactory(ssr);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        this.serviceRegistry = ssr;

        return sessionFactory;
    }

    /**
     * Creates the {@link BootstrapServiceRegistryBuilder} to use
     *
     * @return The {@link BootstrapServiceRegistryBuilder}
     */
    protected BootstrapServiceRegistryBuilder createBootstrapServiceRegistryBuilder() {
        return new BootstrapServiceRegistryBuilder();
    }

    /**
     * Creates the standard service registry builder. Subclasses can override to customize the
     * creation of the StandardServiceRegistry
     *
     * @param bootstrapServiceRegistry The {@link BootstrapServiceRegistry}
     * @return The {@link StandardServiceRegistryBuilder}
     */
    protected StandardServiceRegistryBuilder createStandardServiceRegistryBuilder(
            BootstrapServiceRegistry bootstrapServiceRegistry) {
        return new StandardServiceRegistryBuilder(bootstrapServiceRegistry);
    }

    /**
     * Default listeners.
     *
     * @param listeners the listeners
     */
    public void setEventListeners(Map<String, Object> listeners) {
        eventListeners = listeners;
    }

    /**
     * User-specifiable extra listeners.
     *
     * @param listeners the listeners
     */
    public void setHibernateEventListeners(HibernateEventListeners listeners) {
        hibernateEventListeners = listeners;
    }

    public ServiceRegistry getServiceRegistry() {
        return serviceRegistry;
    }
}
