/*
 * 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.aries.jndi.services;

import java.lang.ref.WeakReference;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import javax.naming.NamingException;

import org.apache.aries.jndi.url.Activator;
import org.apache.aries.jndi.url.OsgiName;
import org.apache.aries.proxy.ProxyManager;
import org.apache.aries.proxy.UnableToProxyException;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.BundleEvent;
import org.osgi.framework.BundleListener;
import org.osgi.framework.Constants;
import org.osgi.framework.InvalidSyntaxException;
import org.osgi.framework.ServiceEvent;
import org.osgi.framework.ServiceException;
import org.osgi.framework.ServiceListener;
import org.osgi.framework.ServiceReference;
import org.osgi.service.jndi.JNDIConstants;

/**
 * This helper provides access to services registered in the OSGi service registry.
 * If a matching service cannot be located null may be returned. A caller should not
 * expect to get the same object if multiple requests are made to this API. A caller
 * should not expect to get a different object if multiple requests are made to this API.
 * A caller should avoid caching the returned service. OSGi is a dynamic environment and
 * the service may become unavailable while a reference to it is held. To minimize this
 * risk the caller should hold onto the service for the minimum length of time.
 * <p>
 * <p>This API should not be used from within an OSGi bundle. When in an OSGi environment
 * the BundleContext for the bundle should be used to obtain the service.
 * </p>
 */
public final class ServiceHelper {
    /**
     * A cache of proxies returned to the client
     */
    private static final ConcurrentMap<ServiceKey, WeakReference<Object>> proxyCache = new ConcurrentHashMap<>();
    private static final CacheClearoutListener cacheClearoutListener = new CacheClearoutListener(proxyCache);

    public static Object getService(BundleContext ctx, OsgiName lookupName, String id,
                                    boolean dynamicRebind, Map<String, Object> env, boolean requireProxy) throws NamingException {
        String interfaceName = lookupName.getInterface();
        String filter = lookupName.getFilter();
        String serviceName = lookupName.getServiceName();

        if (id != null) {
            if (filter == null) {
                filter = '(' + Constants.SERVICE_ID + '=' + id + ')';
            } else {
                filter = "(&(" + Constants.SERVICE_ID + '=' + id + ')' + filter + ')';
            }
        }

        ServicePair pair = null;

        if (!lookupName.isServiceNameBased()) {
            pair = findService(ctx, interfaceName, filter);
        }

        if (pair == null) {
            interfaceName = null;
            if (id == null) {
                filter = "(" + JNDIConstants.JNDI_SERVICENAME + "=" + serviceName + ')';
            } else {
                filter = "(&(" + Constants.SERVICE_ID + '=' + id + ")(" + JNDIConstants.JNDI_SERVICENAME
                        + "=" + serviceName + "))";
            }
            pair = findService(ctx, interfaceName, filter);
        }

        Object result = null;

        if (pair != null) {
            if (requireProxy) {
                Object obj = env.get(org.apache.aries.jndi.api.JNDIConstants.REBIND_TIMEOUT);
                int timeout = 0;
                if (obj instanceof String) {
                    timeout = Integer.parseInt((String) obj);
                } else if (obj instanceof Integer) {
                    timeout = (Integer) obj;
                }

                result = proxy(interfaceName, filter, dynamicRebind, ctx, pair, timeout);
            } else {
                result = pair.service;
            }
        }

        return result;
    }

    private static Object proxy(final String interface1, final String filter, final boolean rebind,
                                final BundleContext ctx, final ServicePair pair, final int timeout) {
        Object result = null;
        Bundle owningBundle = ctx.getBundle();
        ServiceKey k = new ServiceKey(owningBundle, pair.ref.getBundle(), (Long) pair.ref.getProperty(Constants.SERVICE_ID));

        WeakReference<Object> proxyRef = proxyCache.get(k);

        if (proxyRef != null) {
            result = proxyRef.get();
            if (result == null) {
                proxyCache.remove(k, proxyRef);
            }
        }

        if (result == null) {
            if (System.getSecurityManager() != null) {
                result = AccessController.doPrivileged((PrivilegedAction<Object>) () -> proxyPrivileged(interface1, filter, rebind, ctx, pair, timeout));
            } else {
                result = proxyPrivileged(interface1, filter, rebind, ctx, pair, timeout);
            }

            proxyRef = new WeakReference<>(result);
            // if we have two threads doing a put and then clashing we ignore it. The code to ensure only
            // one wins is quite complex to save a few bytes of memory and millis of execution time.
            proxyCache.putIfAbsent(k, proxyRef);
            cacheClearoutListener.add(ctx, k);
        }

        return result;
    }

    private static Object proxyPrivileged(String interface1, String filter, boolean dynamicRebind, BundleContext ctx, ServicePair pair, int timeout) {
        String[] interfaces;
        if (interface1 != null) {
            interfaces = new String[]{interface1};
        } else {
            interfaces = (String[]) pair.ref.getProperty(Constants.OBJECTCLASS);
        }

        List<Class<?>> clazz = new ArrayList<>(interfaces.length);

        // We load the interface classes the service is registered under using the defining bundle.
        // This is ok because the service must be able to see the classes to be registered using them.
        // We then check to see if isAssignableTo on the reference  works for the owning bundle and
        // the interface name and only use the interface if true is returned there.

        // This might seem odd, but equinox and felix return true for isAssignableTo if the
        // Bundle provided does not import the package. This is under the assumption the
        // caller will then use reflection. The upshot of doing it this way is that a utility
        // bundle can be created which centralizes JNDI lookups, but the service will be used
        // by another bundle. It is true that class space consistency is less safe, but we
        // are enabling a slightly odd use case anyway.

        // August 13th 2013: We've found valid use cases in which a Bundle is exporting
        // services that the Bundle itself cannot load. We deal with this rare case by
        // noting the classes that we failed to load. If as a result we have no classes
        // to proxy, we try those classes again but instead pull the Class objects off
        // the service rather than from the bundle exporting that service.

        Bundle serviceProviderBundle = pair.ref.getBundle();
        Bundle owningBundle = ctx.getBundle();
        ProxyManager proxyManager = Activator.getProxyManager();

        Collection<String> classesNotFound = new ArrayList<>();
        for (String interfaceName : interfaces) {
            try {
                Class<?> potentialClass = serviceProviderBundle.loadClass(interfaceName);
                if (pair.ref.isAssignableTo(owningBundle, interfaceName)) {
                    clazz.add(potentialClass);
                }
            } catch (ClassNotFoundException e) {
                classesNotFound.add(interfaceName);
            }
        }

        if (clazz.isEmpty() && !classesNotFound.isEmpty()) {
            Class<?> ifacesOnService[] = ctx.getService(pair.ref).getClass().getInterfaces();
            for (String interfaceName : classesNotFound) {
                Class<?> thisClass = null;
                for (Class<?> c : getAllInterfaces(ifacesOnService)) {
                    if (c.getName().equals(interfaceName)) {
                        thisClass = c;
                        break;
                    }
                }
                if (thisClass != null) {
                    if (pair.ref.isAssignableTo(owningBundle, interfaceName)) {
                        clazz.add(thisClass);
                    }
                }
            }
        }

        if (clazz.isEmpty()) {
            throw new IllegalArgumentException(Arrays.asList(interfaces).toString());
        }

        Callable<Object> ih = new JNDIServiceDamper(ctx, interface1, filter, pair, dynamicRebind, timeout);

        // The ClassLoader needs to be able to load the service interface
        // classes so it needs to be
        // wrapping the service provider bundle. The class is actually defined
        // on this adapter.

        try {
            return proxyManager.createDelegatingProxy(serviceProviderBundle, clazz, ih, null);
        } catch (UnableToProxyException e) {
            throw new IllegalArgumentException(e);
        } catch (RuntimeException e) {
            throw new IllegalArgumentException("Unable to create a proxy for " + pair.ref + ".", e);
        }
    }

    private static ServicePair findService(BundleContext ctx, String interface1, String filter)
            throws NamingException {
        ServicePair p = null;

        try {
            ServiceReference<?>[] refs = ctx.getServiceReferences(interface1, filter);

            if (refs != null) {
                // natural order is the exact opposite of the order we desire.
                Arrays.sort(refs, Comparator.reverseOrder());

                for (ServiceReference<?> ref : refs) {
                    Object service = ctx.getService(ref);

                    if (service != null) {
                        p = new ServicePair();
                        p.ref = ref;
                        p.service = service;
                        break;
                    }
                }
            }

        } catch (InvalidSyntaxException e) {
            // If we get an invalid syntax exception we just ignore it. Null
            // will be returned which
            // is valid and that may result in a NameNotFoundException if that
            // is the right thing to do
        }

        return p;
    }

    public static ServiceReference<?>[] getServiceReferences(BundleContext ctx, String interface1,
                                                          String filter, String serviceName, Map<String, Object> env) throws NamingException {
        ServiceReference<?>[] refs;

        try {
            refs = ctx.getServiceReferences(interface1, filter);

            if (refs == null || refs.length == 0) {
                refs = ctx.getServiceReferences((String) null, "(" + JNDIConstants.JNDI_SERVICENAME + "="
                        + serviceName + ')');
            }
        } catch (InvalidSyntaxException e) {
            throw (NamingException) new NamingException(e.getFilter()).initCause(e);
        }

        if (refs != null) {
            // natural order is the exact opposite of the order we desire.
            Arrays.sort(refs, Comparator.reverseOrder());
        }

        return refs;
    }

    public static Object getService(BundleContext ctx, ServiceReference<?> ref) {
        Object service = ctx.getService(ref);
        if (service == null) {
            return null;
        }

        ServicePair pair = new ServicePair();
        pair.ref = ref;
        pair.service = service;
        return proxy(null, null, false, ctx, pair, 0);
    }

    static Collection<Class<?>> getAllInterfaces(Class<?>[] baseInterfaces) {
        Set<Class<?>> result = new HashSet<>();
        for (Class<?> c : baseInterfaces) {
            if (!c.equals(Object.class)) {
                result.add(c);
                Class<?> ifaces[] = c.getInterfaces();
                if (ifaces.length != 0) {
                    result.addAll(getAllInterfaces(ifaces));
                }
            }
        }
        return result;
    }

    public static final class CacheClearoutListener implements BundleListener, ServiceListener {
        /**
         * The cache to purge
         */
        private final ConcurrentMap<ServiceKey, WeakReference<Object>> cache;


        public CacheClearoutListener(ConcurrentMap<ServiceKey, WeakReference<Object>> pc) {
            cache = pc;
        }

        public void bundleChanged(BundleEvent event) {
            if (event.getType() == BundleEvent.STOPPED) {
                Bundle b = event.getBundle();
                cache.keySet().removeIf(key -> key.requesting == b);
            }
        }

        public void serviceChanged(ServiceEvent event) {
            if (event.getType() == ServiceEvent.UNREGISTERING) {
                ServiceReference ref = event.getServiceReference();
                Long serviceId = (Long) ref.getProperty(Constants.SERVICE_ID);
                Bundle registeringBundle = ref.getBundle();
                Iterator<ServiceKey> keys = cache.keySet().iterator();
                while (keys.hasNext()) {
                    ServiceKey key = keys.next();
                    if (key.registering == registeringBundle && serviceId.equals(key.serviceId)) {
                        keys.remove();
                        break;
                    }
                }
            }
        }

        public void add(final BundleContext ctx, ServiceKey k) {
            // try to use the system bundle for our listener, if that fails we fall back to the calling context
            BundleContext systemBundle;
            Bundle system = ctx.getBundle(0);
            if (system != null) {
                if (System.getSecurityManager() != null) {
                    systemBundle = AccessController.doPrivileged((PrivilegedAction<BundleContext>) system::getBundleContext);
                } else {
                    systemBundle = system.getBundleContext();
                }
            } else {
                systemBundle = ctx;
            }
            systemBundle.addBundleListener(cacheClearoutListener);
            systemBundle.addServiceListener(cacheClearoutListener);
        }
    }

    private static final class ServiceKey {
        private final Bundle requesting;
        private final Bundle registering;
        private final Long serviceId;
        private final int hash;

        public ServiceKey(Bundle owningBundle, Bundle registeringBundle, Long property) {
            requesting = owningBundle;
            registering = registeringBundle;
            serviceId = property;
            hash = serviceId.intValue() * 100003 + System.identityHashCode(requesting);
        }

        public int hashCode() {
            return hash;
        }

        public boolean equals(Object other) {
            if (other == this) return true;
            if (other == null) return false;

            if (other instanceof ServiceKey) {
                ServiceKey otherKey = (ServiceKey) other;
                return (otherKey.requesting == requesting && otherKey.serviceId.equals(serviceId));
            }

            return false;
        }
    }

    private static class JNDIServiceDamper implements Callable<Object> {
        private BundleContext ctx;
        private ServicePair pair;
        private String interfaceName;
        private String filter;
        private boolean dynamic;
        private int rebindTimeout;

        public JNDIServiceDamper(BundleContext context, String i, String f, ServicePair service,
                                 boolean d, int timeout) {
            ctx = context;
            pair = service;
            interfaceName = i;
            filter = f;
            dynamic = d;
            rebindTimeout = timeout;
        }

        public Object call() throws NamingException {
            if (pair == null || pair.ref.getBundle() == null) {
                if (dynamic) {
                    pair = findService(ctx, interfaceName, filter);
                    if (pair == null && rebindTimeout > 0) {
                        long startTime = System.currentTimeMillis();
                        try {
                            while (pair == null && System.currentTimeMillis() - startTime < rebindTimeout) {
                                Thread.sleep(100);
                                pair = findService(ctx, interfaceName, filter);
                            }
                        } catch (InterruptedException e) {
                        }
                    }
                } else {
                    pair = null;
                }
            }

            if (pair == null) {
                throw new ServiceException(interfaceName, ServiceException.UNREGISTERED);
            }
            return pair.service;
        }
    }

    private static class ServicePair {
        private ServiceReference<?> ref;
        private Object service;
    }
}
