/*
 * Copyright (c) 2012, 2022 Oracle and/or its affiliates. All rights reserved.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0, which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the
 * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
 * version 2 with the GNU Classpath Exception, which is available at
 * https://www.gnu.org/software/classpath/license.html.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 */

package org.glassfish.jersey.internal;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.security.PrivilegedActionException;
import java.security.PrivilegedExceptionAction;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.logging.Level;
import java.util.logging.Logger;

import jakarta.ws.rs.ProcessingException;

import org.glassfish.jersey.internal.util.ReflectionHelper;

import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.BundleEvent;
import org.osgi.framework.BundleReference;
import org.osgi.framework.FrameworkUtil;
import org.osgi.framework.SynchronousBundleListener;

/**
 * Utility class to deal with OSGi runtime specific behavior.
 * This is mainly to handle META-INF/services lookup
 * and generic/application class lookup issue in OSGi.
 *
 * When OSGi runtime is detected by the {@link ServiceFinder} class,
 * an instance of OsgiRegistry is created and associated with given
 * OSGi BundleContext. META-INF/services entries are then being accessed
 * via the OSGi Bundle API as direct ClassLoader#getResource() method invocation
 * does not work in this case within OSGi.
 *
 * @author Jakub Podlesak
 * @author Adam Lindenthal
 */
public final class OsgiRegistry implements SynchronousBundleListener {

    private static final String WEB_INF_CLASSES = "WEB-INF/classes/";
    private static final String CoreBundleSymbolicNAME = "org.glassfish.jersey.core.jersey-common";
    private static final Logger LOGGER = Logger.getLogger(OsgiRegistry.class.getName());

    private final BundleContext bundleContext;
    private final Map<Long, Map<String, Callable<List<Class<?>>>>> factories =
            new HashMap<Long, Map<String, Callable<List<Class<?>>>>>();
    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    private static OsgiRegistry instance;

    private final Map<String, Bundle> classToBundleMapping = new HashMap<String, Bundle>();

    /**
     * Returns an {@code OsgiRegistry} instance. Call this method only if sure that the application is running in OSGi
     * environment, otherwise a call to this method can lead to an {@link ClassNotFoundException}.
     *
     * @return an {@code OsgiRegistry} instance.
     */
    public static synchronized OsgiRegistry getInstance() {
        if (instance == null) {
            final ClassLoader classLoader = AccessController
                    .doPrivileged(ReflectionHelper.getClassLoaderPA(ReflectionHelper.class));
            if (classLoader instanceof BundleReference) {
                final BundleContext context = FrameworkUtil.getBundle(OsgiRegistry.class).getBundleContext();
                if (context != null) { // context could be still null if the current bundle has not been started
                    instance = new OsgiRegistry(context);
                }
            }
        }
        return instance;
    }

    private final class OsgiServiceFinder extends ServiceFinder.ServiceIteratorProvider {

        final ServiceFinder.ServiceIteratorProvider defaultIterator = new ServiceFinder.DefaultServiceIteratorProvider();

        @Override
        public <T> Iterator<T> createIterator(
                final Class<T> serviceClass,
                final String serviceName,
                final ClassLoader loader,
                final boolean ignoreOnClassNotFound) {

            final List<Class<?>> providerClasses = locateAllProviders(serviceClass);
            if (!providerClasses.isEmpty()) {
                return new Iterator<T>() {

                    Iterator<Class<?>> it = providerClasses.iterator();

                    @Override
                    public boolean hasNext() {
                        return it.hasNext();
                    }

                    @SuppressWarnings("unchecked")
                    @Override
                    public T next() {
                        final Class<T> nextClass = (Class<T>) it.next();
                        try {
                            return nextClass.newInstance();
                        } catch (final Exception ex) {
                            final ServiceConfigurationError sce = new ServiceConfigurationError(serviceName + ": "
                                    + LocalizationMessages.PROVIDER_COULD_NOT_BE_CREATED(
                                    nextClass.getName(), serviceClass, ex.getLocalizedMessage()));
                            sce.initCause(ex);
                            throw sce;
                        }
                    }

                    @Override
                    public void remove() {
                        throw new UnsupportedOperationException();
                    }
                };
            }
            return defaultIterator.createIterator(serviceClass, serviceName, loader, ignoreOnClassNotFound);
        }

        @Override
        public <T> Iterator<Class<T>> createClassIterator(
                final Class<T> service, final String serviceName, final ClassLoader loader, final boolean ignoreOnClassNotFound) {
            final List<Class<?>> providerClasses = locateAllProviders(service);
            if (!providerClasses.isEmpty()) {
                return new Iterator<Class<T>>() {

                    Iterator<Class<?>> it = providerClasses.iterator();

                    @Override
                    public boolean hasNext() {
                        return it.hasNext();
                    }

                    @SuppressWarnings("unchecked")
                    @Override
                    public Class<T> next() {
                        return (Class<T>) it.next();
                    }

                    @Override
                    public void remove() {
                        throw new UnsupportedOperationException();
                    }
                };
            }
            return defaultIterator.createClassIterator(service, serviceName, loader, ignoreOnClassNotFound);
        }
    }

    private static class BundleSpiProvidersLoader implements Callable<List<Class<?>>> {

        private final String spi;
        private final URL spiRegistryUrl;
        private final String spiRegistryUrlString;
        private final Bundle bundle;

        BundleSpiProvidersLoader(final String spi, final URL spiRegistryUrl, final Bundle bundle) {
            this.spi = spi;
            this.spiRegistryUrl = spiRegistryUrl;
            this.spiRegistryUrlString = spiRegistryUrl.toExternalForm();
            this.bundle = bundle;
        }

        @Override
        public List<Class<?>> call() throws Exception {
            BufferedReader reader = null;

            try {
                if (LOGGER.isLoggable(Level.FINEST)) {
                    LOGGER.log(Level.FINEST, "Loading providers for SPI: {0}", spi);
                }
                reader = new BufferedReader(new InputStreamReader(spiRegistryUrl.openStream(), "UTF-8"));
                String providerClassName;

                final List<Class<?>> providerClasses = new ArrayList<Class<?>>();
                while ((providerClassName = reader.readLine()) != null) {
                    if (providerClassName.trim().length() == 0) {
                        continue;
                    }
                    if (providerClassName.startsWith("#")) {
                        continue;
                    }
                    if (LOGGER.isLoggable(Level.FINEST)) {
                        LOGGER.log(Level.FINEST, "SPI provider: {0}", providerClassName);
                    }
                    providerClasses.add(loadClass(bundle, providerClassName));
                }

                return providerClasses;
            } catch (final Exception e) {
                LOGGER.log(Level.WARNING, LocalizationMessages.EXCEPTION_CAUGHT_WHILE_LOADING_SPI_PROVIDERS(), e);
                throw e;
            } catch (final Error e) {
                LOGGER.log(Level.WARNING, LocalizationMessages.ERROR_CAUGHT_WHILE_LOADING_SPI_PROVIDERS(), e);
                throw e;
            } finally {
                if (reader != null) {
                    try {
                        reader.close();
                    } catch (final IOException ioe) {
                        LOGGER.log(Level.FINE, "Error closing SPI registry stream:" + spiRegistryUrl, ioe);
                    }
                }
            }
        }

        @Override
        public String toString() {
            return spiRegistryUrlString;
        }

        @Override
        public int hashCode() {
            return spiRegistryUrlString.hashCode();
        }

        @Override
        public boolean equals(final Object obj) {
            if (obj instanceof BundleSpiProvidersLoader) {
                return spiRegistryUrlString.equals(((BundleSpiProvidersLoader) obj).spiRegistryUrlString);
            } else {
                return false;
            }
        }
    }

    @Override
    public void bundleChanged(final BundleEvent event) {

        if (event.getType() == BundleEvent.RESOLVED) {
            register(event.getBundle());
        } else if (event.getType() == BundleEvent.UNRESOLVED || event.getType() == BundleEvent.UNINSTALLED) {

            final Bundle unregisteredBundle = event.getBundle();

            lock.writeLock().lock();
            try {
                factories.remove(unregisteredBundle.getBundleId());

                if (unregisteredBundle.getSymbolicName().equals(CoreBundleSymbolicNAME)) {
                    bundleContext.removeBundleListener(this);
                    factories.clear();
                }
            } finally {
                lock.writeLock().unlock();
            }
        }
    }

    /**
     * Translates bundle entry path as returned from {@link org.osgi.framework.Bundle#findEntries(String, String, boolean)} to
     * fully qualified class name that resides in given package path (directly or indirectly in its subpackages).
     *
     * @param packagePath     The package path where the class is located (even recursively)
     * @param bundleEntryPath The bundle path to translate.
     * @return Fully qualified class name.
     */
    public static String bundleEntryPathToClassName(String packagePath, String bundleEntryPath) {
        // normalize packagePath
        packagePath = normalizedPackagePath(packagePath);

        // remove WEB-INF/classes from bundle entry path
        if (bundleEntryPath.contains(WEB_INF_CLASSES)) {
            bundleEntryPath = bundleEntryPath.substring(bundleEntryPath.indexOf(WEB_INF_CLASSES) + WEB_INF_CLASSES.length());
        }

        final int packageIndex = bundleEntryPath.indexOf(packagePath);

        String normalizedClassNamePath = packageIndex > -1
                // the package path was found in the bundle path
                ? bundleEntryPath.substring(packageIndex)
                // the package path is not included in the bundle entry path
                // fall back to the original implementation of the translation which does not consider recursion
                : packagePath + bundleEntryPath.substring(bundleEntryPath.lastIndexOf('/') + 1);

        return (normalizedClassNamePath.startsWith("/") ? normalizedClassNamePath.substring(1) : normalizedClassNamePath)
                .replace(".class", "").replace('/', '.');
    }

    /**
     * Returns whether the given entry path is located directly in the provided package path. That is,
     * if the entry is located in a sub-package, then {@code false} is returned.
     *
     * @param packagePath Package path which the entry is compared to
     * @param entryPath Entry path
     * @return Whether the given entry path is located directly in the provided package path.
     */
    public static boolean isPackageLevelEntry(String packagePath, final String entryPath) {
        // normalize packagePath
        packagePath = normalizedPackagePath(packagePath);

        // if the package path is contained in the jar entry name, subtract it
        String entryWithoutPackagePath = entryPath.contains(packagePath)
                ? entryPath.substring(entryPath.indexOf(packagePath) + packagePath.length())
                : entryPath;

        return !(entryWithoutPackagePath.startsWith("/") ? entryWithoutPackagePath.substring(1)
                         : entryWithoutPackagePath)
                .contains("/");
    }

    /**
     * Normalized package returns path that does not start with '/' character and ends with '/' character.
     * If the argument is '/' then returned value is empty string "".
     *
     * @param packagePath package path to normalize.
     * @return Normalized package path.
     */
    public static String normalizedPackagePath(String packagePath) {
        packagePath = packagePath.startsWith("/") ? packagePath.substring(1) : packagePath;
        packagePath = packagePath.endsWith("/") ? packagePath : packagePath + "/";
        packagePath = "/".equals(packagePath) ? "" : packagePath;
        return packagePath;
    }

    /**
     * Get URLs of resources from a given package.
     *
     * @param packagePath package.
     * @param classLoader resource class loader.
     * @param recursive   whether the given package path should be scanned recursively by OSGi
     * @return URLs of the located resources.
     */
    @SuppressWarnings("unchecked")
    public Enumeration<URL> getPackageResources(final String packagePath,
                                                final ClassLoader classLoader,
                                                final boolean recursive) {
        final List<URL> result = new LinkedList<URL>();

        for (final Bundle bundle : bundleContext.getBundles()) {
            // Look for resources at the given <packagePath> and at WEB-INF/classes/<packagePath> in case a WAR is being examined.
            for (final String bundlePackagePath : new String[] {packagePath, WEB_INF_CLASSES + packagePath}) {
                final Enumeration<URL> enumeration = findEntries(bundle, bundlePackagePath, "*.class", recursive);

                if (enumeration != null) {
                    while (enumeration.hasMoreElements()) {
                        final URL url = enumeration.nextElement();
                        final String path = url.getPath();

                        classToBundleMapping.put(bundleEntryPathToClassName(packagePath, path), bundle);
                        result.add(url);
                    }
                }
            }

            // Now interested only in .jar provided by current bundle.
            final Enumeration<URL> jars = findEntries(bundle, "/", "*.jar", true);
            if (jars != null) {
                while (jars.hasMoreElements()) {
                    final URL jar = jars.nextElement();
                    final InputStream inputStream = classLoader.getResourceAsStream(jar.getPath());
                    if (inputStream == null) {
                        LOGGER.config(LocalizationMessages.OSGI_REGISTRY_ERROR_OPENING_RESOURCE_STREAM(jar));
                        continue;
                    }
                    final JarInputStream jarInputStream;
                    try {
                        jarInputStream = new JarInputStream(inputStream);
                    } catch (final IOException ex) {
                        LOGGER.log(Level.CONFIG, LocalizationMessages.OSGI_REGISTRY_ERROR_PROCESSING_RESOURCE_STREAM(jar), ex);
                        try {
                            inputStream.close();
                        } catch (final IOException e) {
                            // ignored
                        }
                        continue;
                    }

                    try {
                        JarEntry jarEntry;
                        while ((jarEntry = jarInputStream.getNextJarEntry()) != null) {
                            final String jarEntryName = jarEntry.getName();
                            final String jarEntryNameLeadingSlash = jarEntryName.startsWith("/")
                                    ? jarEntryName : "/" + jarEntryName;

                            if (jarEntryName.endsWith(".class")
                                    // Added leading and trailing slashes '/' to package path (e.g. '/com/') helps us to not
                                    // accidentally match sub-strings of the package path (e.g., if package path 'com' was used
                                    // for scanning, package 'whatever.foo.telecom' would be matched because of word 'tele[com]').
                                    // Note that we cannot avoid all corner cases with accidental matches since jar
                                    // entry name might be almost anything (e.g., if package path 'telecom' was used, package
                                    // 'whatever.foo.telecom' will be matched and there is no way to avoid it unless user
                                    // explicitly instructs us to do so somehow (not implemented)
                                    && jarEntryNameLeadingSlash.contains("/" + normalizedPackagePath(packagePath))) {
                                if (!recursive && !isPackageLevelEntry(packagePath, jarEntryName)) {
                                    continue;
                                }
                                classToBundleMapping.put(jarEntryName.replace(".class", "").replace('/', '.'), bundle);
                                result.add(bundle.getResource(jarEntryName));
                            }
                        }
                    } catch (final Exception ex) {
                        LOGGER.log(Level.CONFIG, LocalizationMessages.OSGI_REGISTRY_ERROR_PROCESSING_RESOURCE_STREAM(jar), ex);
                    } finally {
                        try {
                            jarInputStream.close();
                        } catch (final IOException e) {
                            // ignored
                        }
                    }
                }
            }
        }

        return Collections.enumeration(result);
    }

    /**
     * Get the Class from the class name.
     * <p>
     * The context class loader will be utilized if accessible and non-null.
     * Otherwise the defining class loader of this class will
     * be utilized.
     *
     * @param className the class name.
     * @return the Class, otherwise null if the class cannot be found.
     * @throws ClassNotFoundException if the class cannot be found.
     */
    public Class<?> classForNameWithException(final String className) throws ClassNotFoundException {
        final Bundle bundle = classToBundleMapping.get(className);

        if (bundle == null) {
            throw new ClassNotFoundException(className);
        }
        return loadClass(bundle, className);
    }

    /**
     * Tries to load resource bundle via OSGi means. No caching involved here,
     * as localization properties are being cached in Localizer class already.
     *
     * @param bundleName name of the resource bundle to load
     * @return resource bundle instance if found, null otherwise
     */
    public ResourceBundle getResourceBundle(final String bundleName) {
        final int lastDotIndex = bundleName.lastIndexOf('.');
        final String path = bundleName.substring(0, lastDotIndex).replace('.', '/');
        final String propertiesName = bundleName.substring(lastDotIndex + 1, bundleName.length()) + ".properties";
        for (final Bundle bundle : bundleContext.getBundles()) {
            final Enumeration<URL> entries = findEntries(bundle, path, propertiesName, false);
            if (entries != null && entries.hasMoreElements()) {
                final URL entryUrl = entries.nextElement();
                try {
                    return new PropertyResourceBundle(entryUrl.openStream());
                } catch (final IOException ex) {
                    if (LOGGER.isLoggable(Level.FINE)) {
                        // does not make sense to localize this
                        LOGGER.fine("Exception caught when tried to load resource bundle in OSGi");
                    }
                    return null;
                }
            }
        }
        return null;
    }

    /**
     * Creates a new OsgiRegistry instance bound to a particular OSGi runtime.
     * The only parameter must be an instance of a {@link BundleContext}.
     *
     * @param bundleContext must be a non-null instance of a BundleContext
     */
    private OsgiRegistry(final BundleContext bundleContext) {
        this.bundleContext = bundleContext;
    }

    /**
     * Will hook up this instance with the OSGi runtime.
     * This is to actually update SPI provider lookup and class loading mechanisms in Jersey
     * to utilize OSGi features.
     */
    void hookUp() {
        setOSGiServiceFinderIteratorProvider();
        bundleContext.addBundleListener(this);
        registerExistingBundles();
    }

    private void registerExistingBundles() {
        for (final Bundle bundle : bundleContext.getBundles()) {
            if (bundle.getState() == Bundle.RESOLVED || bundle.getState() == Bundle.STARTING
                    || bundle.getState() == Bundle.ACTIVE || bundle.getState() == Bundle.STOPPING) {
                register(bundle);
            }
        }
    }

    private void setOSGiServiceFinderIteratorProvider() {
        ServiceFinder.setIteratorProvider(new OsgiServiceFinder());
    }

    private void register(final Bundle bundle) {
        if (LOGGER.isLoggable(Level.FINEST)) {
            LOGGER.log(Level.FINEST, "checking bundle {0}", bundle.getBundleId());
        }

        Map<String, Callable<List<Class<?>>>> map;
        lock.writeLock().lock();
        try {
            map = factories.get(bundle.getBundleId());
            if (map == null) {
                map = new ConcurrentHashMap<String, Callable<List<Class<?>>>>();
                factories.put(bundle.getBundleId(), map);
            }
        } finally {
            lock.writeLock().unlock();
        }

        final Enumeration<URL> e = findEntries(bundle, "META-INF/services/", "*", false);
        if (e != null) {
            while (e.hasMoreElements()) {
                final URL u = e.nextElement();
                final String url = u.toString();
                if (url.endsWith("/")) {
                    continue;
                }
                final String factoryId = url.substring(url.lastIndexOf("/") + 1);
                map.put(factoryId, new BundleSpiProvidersLoader(factoryId, u, bundle));
            }
        }
    }

    private List<Class<?>> locateAllProviders(final Class<?> serviceClass) {
        lock.readLock().lock();
        try {
            final List<Class<?>> result = new LinkedList<Class<?>>();
            for (final Map<String, Callable<List<Class<?>>>> value : factories.values()) {
                if (value.containsKey(serviceClass.getName())) {
                    try {
                        for (final Class<?> clazz : value.get(serviceClass.getName()).call()) {
                            if (serviceClass.isAssignableFrom(clazz)) {
                                result.add(clazz);
                            } else if (LOGGER.isLoggable(Level.FINER)) {
                                LOGGER.log(Level.FINER,
                                    "Ignoring provider class " + clazz.getName() + " because it is not assignable to "
                                    + " service class " + serviceClass.getName());
                            }
                        }
                    } catch (final Exception ex) {
                        // ignore
                    }
                }
            }
            return result;
        } finally {
            lock.readLock().unlock();
        }
    }

    private static Class<?> loadClass(final Bundle bundle, final String className) throws ClassNotFoundException {
        try {
            return AccessController.doPrivileged(new PrivilegedExceptionAction<Class<?>>() {
                @Override
                public Class<?> run() throws ClassNotFoundException {
                    return bundle.loadClass(className);
                }
            });
        } catch (final PrivilegedActionException ex) {
            final Exception originalException = ex.getException();
            if (originalException instanceof ClassNotFoundException) {
                throw (ClassNotFoundException) originalException;
            } else if (originalException instanceof RuntimeException) {
                throw (RuntimeException) originalException;
            } else {
                throw new ProcessingException(originalException);
            }
        }
    }

    private static Enumeration<URL> findEntries(final Bundle bundle,
                                                final String path,
                                                final String fileNamePattern,
                                                final boolean recursive) {
        return AccessController.doPrivileged(new PrivilegedAction<Enumeration<URL>>() {
            @SuppressWarnings("unchecked")
            @Override
            public Enumeration<URL> run() {
                return bundle.findEntries(path, fileNamePattern, recursive);
            }
        });
    }
}
