/*
 * 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.camel.test.blueprint;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Dictionary;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.jar.JarInputStream;

import org.apache.camel.impl.engine.DefaultClassResolver;
import org.apache.camel.spi.ClassResolver;
import org.apache.camel.support.ObjectHelper;
import org.apache.camel.support.ResourceHelper;
import org.apache.camel.util.FileUtil;
import org.apache.camel.util.IOHelper;
import org.apache.felix.connect.PojoServiceRegistryFactoryImpl;
import org.apache.felix.connect.felix.framework.util.Util;
import org.apache.felix.connect.launch.BundleDescriptor;
import org.apache.felix.connect.launch.ClasspathScanner;
import org.apache.felix.connect.launch.PojoServiceRegistry;
import org.apache.felix.connect.launch.PojoServiceRegistryFactory;
import org.ops4j.pax.swissbox.tinybundles.core.TinyBundle;
import org.ops4j.pax.swissbox.tinybundles.core.TinyBundles;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
import org.osgi.framework.BundleEvent;
import org.osgi.framework.BundleException;
import org.osgi.framework.Constants;
import org.osgi.framework.Filter;
import org.osgi.framework.FrameworkUtil;
import org.osgi.framework.InvalidSyntaxException;
import org.osgi.framework.ServiceReference;
import org.osgi.framework.ServiceRegistration;
import org.osgi.framework.SynchronousBundleListener;
import org.osgi.service.blueprint.container.BlueprintEvent;
import org.osgi.service.blueprint.container.BlueprintListener;
import org.osgi.service.cm.Configuration;
import org.osgi.service.cm.ConfigurationAdmin;
import org.osgi.util.tracker.ServiceTracker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.apache.camel.test.junit4.TestSupport.createDirectory;
import static org.apache.camel.test.junit4.TestSupport.deleteDirectory;

/**
 * Helper for using Blueprint with Camel.
 */
public final class CamelBlueprintHelper {

    public static final long DEFAULT_TIMEOUT = 30000;
    public static final String BUNDLE_FILTER = "(Bundle-SymbolicName=*)";
    public static final String BUNDLE_VERSION = "1.0.0";
    private static final Logger LOG = LoggerFactory.getLogger(CamelBlueprintHelper.class);
    private static final ClassResolver RESOLVER = new DefaultClassResolver();

    private CamelBlueprintHelper() {
    }

    public static BundleContext createBundleContext(String name, String descriptors, boolean includeTestBundle) throws Exception {
        return createBundleContext(name, descriptors, includeTestBundle, BUNDLE_FILTER, BUNDLE_VERSION);
    }

    public static BundleContext createBundleContext(String name, String descriptors, boolean includeTestBundle,
                                                    String bundleFilter, String testBundleVersion) throws Exception {
        return createBundleContext(name, descriptors, includeTestBundle, bundleFilter, testBundleVersion, null);
    }
    
    public static BundleContext createBundleContext(String name, String descriptors, boolean includeTestBundle,
                                                    String bundleFilter, String testBundleVersion, String testBundleDirectives,
                                                    String[]... configAdminPidFiles) throws Exception {
        return createBundleContext(name, descriptors, includeTestBundle,
                bundleFilter, testBundleVersion, testBundleDirectives,
                null,
                configAdminPidFiles);
    }

    public static BundleContext createBundleContext(String name, String descriptors, boolean includeTestBundle,
                                                    String bundleFilter, String testBundleVersion, String testBundleDirectives,
                                                    ClassLoader loader,
                                                    String[]... configAdminPidFiles) throws Exception {
        TinyBundle bundle = null;
        TinyBundle configAdminInitBundle = null;

        if (includeTestBundle) {
            // add ourselves as a bundle
            bundle = createTestBundle(testBundleDirectives == null ? name : name + ';' + testBundleDirectives,
                    testBundleVersion, descriptors);
        }
        if (configAdminPidFiles != null) {
            configAdminInitBundle = createConfigAdminInitBundle(configAdminPidFiles);
        }

        return createBundleContext(name, bundleFilter, bundle, configAdminInitBundle, loader);
    }

    public static BundleContext createBundleContext(String name, String bundleFilter, TinyBundle bundle) throws Exception {
        return createBundleContext(name, bundleFilter, bundle, null, null);
    }

    public static BundleContext createBundleContext(String name, String bundleFilter,
                                                    TinyBundle bundle, TinyBundle configAdminInitBundle,
                                                    ClassLoader loader) throws Exception {
        // ensure felix-connect stores bundles in an unique target directory
        String uid = "" + System.currentTimeMillis();
        String tempDir = "target/bundles/" + uid;
        System.setProperty("org.osgi.framework.storage", tempDir);
        createDirectory(tempDir);

        // use another directory for the jar of the bundle as it cannot be in the same directory
        // as it has a file lock during running the tests which will cause the temp dir to not be
        // fully deleted between tests
        createDirectory("target/test-bundles");

        List<BundleDescriptor> bundles = new LinkedList<>();

        if (configAdminInitBundle != null) {
            String jarName = "configAdminInitBundle-" + uid + ".jar";
            bundles.add(getBundleDescriptor("target/test-bundles/" + jarName, configAdminInitBundle));
        }

        if (bundle != null) {
            String jarName = name.toLowerCase(Locale.ENGLISH) + "-" + uid + ".jar";
            bundles.add(getBundleDescriptor("target/test-bundles/" + jarName, bundle));
        }

        List<BundleDescriptor> bundleDescriptors = getBundleDescriptors(bundleFilter, loader);
        // let's put configadmin before blueprint.core
        int idx1 = -1;
        int idx2 = -1;
        for (int i = 0; i < bundleDescriptors.size(); i++) {
            BundleDescriptor bd = bundleDescriptors.get(i);
            if ("org.apache.felix.configadmin".equals(bd.getHeaders().get("Bundle-SymbolicName"))) {
                idx1 = i;
            }
            if ("org.apache.aries.blueprint.core".equals(bd.getHeaders().get("Bundle-SymbolicName"))) {
                idx2 = i;
            }
        }
        if (idx1 >= 0 && idx2 >= 0 && idx1 > idx2) {
            bundleDescriptors.add(idx2, bundleDescriptors.remove(idx1));
        }

        // get the bundles
        bundles.addAll(bundleDescriptors);

        if (LOG.isDebugEnabled()) {
            for (int i = 0; i < bundles.size(); i++) {
                BundleDescriptor desc = bundles.get(i);
                LOG.debug("Bundle #{} -> {}", i, desc);
            }
        }

        // setup felix-connect to use our bundles
        Map<String, Object> config = new HashMap<>();
        config.put(PojoServiceRegistryFactory.BUNDLE_DESCRIPTORS, bundles);

        // create pojorsr osgi service registry
        PojoServiceRegistry reg = new PojoServiceRegistryFactoryImpl().newPojoServiceRegistry(config);
        return reg.getBundleContext();
    }

    public static void disposeBundleContext(BundleContext bundleContext) throws BundleException {
        try {
            if (bundleContext != null) {
                List<Bundle> bundles = new ArrayList<>();
                bundles.addAll(Arrays.asList(bundleContext.getBundles()));
                Collections.reverse(bundles);
                for (Bundle bundle : bundles) {
                    LOG.debug("Stopping bundle {}", bundle);
                    bundle.stop();
                }
            }
        } catch (Exception e) {
            IllegalStateException ise = org.apache.camel.util.ObjectHelper.getException(IllegalStateException.class, e);
            if (ise != null) {
                // we dont care about illegal state exception as that may happen from OSGi
                LOG.debug("Error during disposing BundleContext. This exception will be ignored.", e);
            } else {
                LOG.warn("Error during disposing BundleContext. This exception will be ignored.", e);
            }
        } finally {
            String tempDir = System.clearProperty("org.osgi.framework.storage");
            if (tempDir != null) {
                LOG.info("Deleting work directory {}", tempDir);
                deleteDirectory(tempDir);
            }
        }
    }
    
    // pick up persistent file configuration
    @SuppressWarnings({"unchecked", "rawtypes"})
    public static void setPersistentFileForConfigAdmin(BundleContext bundleContext, String pid,
                                                       String fileName, final Dictionary props,
                                                       String symbolicName, Set<Long> bpEvents,
                                                       boolean expectReload) throws IOException, InterruptedException {
        if (pid != null) {
            if (fileName == null) {
                throw new IllegalArgumentException("The persistent file should not be null");
            } else {
                File load = new File(fileName);
                LOG.debug("Loading properties from OSGi config admin file: {}", load);
                org.apache.felix.utils.properties.Properties cfg = new org.apache.felix.utils.properties.Properties(load);
                for (Object key : cfg.keySet()) {
                    props.put(key, cfg.get(key));
                }

                ConfigurationAdmin configAdmin = CamelBlueprintHelper
                    .getOsgiService(bundleContext, ConfigurationAdmin.class);
                if (configAdmin != null) {
                    // ensure we update
                    // we *have to* use "null" as 2nd arg to have correct bundle location for Configuration object
                    final Configuration config = configAdmin.getConfiguration(pid, null);
                    LOG.info("Updating ConfigAdmin {} by overriding properties {}", config, props);
                    // we may have update and in consequence, BP container reload, let's wait for it to
                    // be CREATED again
                    if (expectReload) {
                        CamelBlueprintHelper.waitForBlueprintContainer(bpEvents, bundleContext, symbolicName, BlueprintEvent.CREATED, new Runnable() {
                            @Override
                            public void run() {
                                try {
                                    config.update(props);
                                } catch (IOException e) {
                                    throw new RuntimeException(e.getMessage(), e);
                                }
                            }
                        });
                    } else {
                        config.update(props);
                    }
                }

            }
        }
    }

    public static <T> T getOsgiService(BundleContext bundleContext, Class<T> type, long timeout) {
        return getOsgiService(bundleContext, type, null, timeout);
    }

    public static <T> T getOsgiService(BundleContext bundleContext, Class<T> type) {
        return getOsgiService(bundleContext, type, null, DEFAULT_TIMEOUT);
    }

    public static <T> T getOsgiService(BundleContext bundleContext, Class<T> type, String filter) {
        return getOsgiService(bundleContext, type, filter, DEFAULT_TIMEOUT);
    }

    public static <T> T getOsgiService(BundleContext bundleContext, Class<T> type, String filter, long timeout) {
        ServiceTracker<T, T> tracker = null;
        try {
            String flt;
            if (filter != null) {
                if (filter.startsWith("(")) {
                    flt = "(&(" + Constants.OBJECTCLASS + "=" + type.getName() + ")" + filter + ")";
                } else {
                    flt = "(&(" + Constants.OBJECTCLASS + "=" + type.getName() + ")(" + filter + "))";
                }
            } else {
                flt = "(" + Constants.OBJECTCLASS + "=" + type.getName() + ")";
            }
            Filter osgiFilter = FrameworkUtil.createFilter(flt);
            tracker = new ServiceTracker<>(bundleContext, osgiFilter, null);
            tracker.open(true);
            // Note that the tracker is not closed to keep the reference
            // This is buggy, as the service reference may change i think
            Object svc = tracker.waitForService(timeout);

            if (svc == null) {
                Dictionary<?, ?> dic = bundleContext.getBundle().getHeaders();
                LOG.warn("Test bundle headers: " + explode(dic));

                for (ServiceReference ref : asCollection(bundleContext.getAllServiceReferences(null, null))) {
                    LOG.warn("ServiceReference: " + ref + ", bundle: " + ref.getBundle() + ", symbolicName: " + ref.getBundle().getSymbolicName());
                }

                for (ServiceReference ref : asCollection(bundleContext.getAllServiceReferences(null, flt))) {
                    LOG.warn("Filtered ServiceReference: " + ref + ", bundle: " + ref.getBundle() + ", symbolicName: " + ref.getBundle().getSymbolicName());
                }

                throw new RuntimeException("Gave up waiting for service " + flt);
            }
            return type.cast(svc);
        } catch (InvalidSyntaxException e) {
            throw new IllegalArgumentException("Invalid filter", e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Synchronization method to wait for particular state of BlueprintContainer under test.
     */
    public static void waitForBlueprintContainer(final Set<Long> eventHistory, BundleContext context,
                                                 final String symbolicName, final int bpEvent, final Runnable runAndWait)
        throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(1);
        final Throwable[] pThrowable = new Throwable[] {null};
        ServiceRegistration<BlueprintListener> registration = context.registerService(BlueprintListener.class, new BlueprintListener() {
            @Override
            public void blueprintEvent(BlueprintEvent event) {
                if (event.getBundle().getSymbolicName().equals(symbolicName)) {
                    if (event.getType() == bpEvent) {
                        // we skip events that we've already seen
                        // it works with BP container reloads if next CREATE state is at least 1ms after previous one
                        if (eventHistory == null || eventHistory.add(event.getTimestamp())) {
                            latch.countDown();
                        }
                    } else if (event.getType() == BlueprintEvent.FAILURE) {
                        // we didn't wait for FAILURE, but we got it - fail fast then
                        pThrowable[0] = event.getCause();
                        latch.countDown();
                    }
                }
            }
        }, null);
        if (runAndWait != null) {
            runAndWait.run();
        }
        boolean found = latch.await(CamelBlueprintHelper.DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS);
        registration.unregister();

        if (!found) {
            throw new RuntimeException("Gave up waiting for BlueprintContainer from bundle \"" + symbolicName + "\"");
        }

        if (pThrowable[0] != null) {
            throw new RuntimeException(pThrowable[0].getMessage(), pThrowable[0]);
        }
    }

    protected static TinyBundle createConfigAdminInitBundle(String[]... configAdminPidFiles) throws IOException {
        TinyBundle bundle = TinyBundles.newBundle();
        StringWriter configAdminInit = null;
        for (String[] configAdminPidFile : configAdminPidFiles) {
            if (configAdminPidFile == null) {
                continue;
            }
            if (configAdminInit == null) {
                configAdminInit = new StringWriter();
            } else {
                configAdminInit.append(',');
            }
            configAdminInit.append(configAdminPidFile[1]).append("=");
            configAdminInit.append(new File(configAdminPidFile[0]).toURI().toString());
        }
        bundle.add(TestBundleActivator.class);
        bundle.add(Util.class);
        bundle.set("Manifest-Version", "2")
                .set("Bundle-ManifestVersion", "2")
                .set("Bundle-SymbolicName", "ConfigAdminInit")
                .set("Bundle-Version", BUNDLE_VERSION)
                .set("Bundle-Activator", TestBundleActivator.class.getName());

        if (configAdminInit != null) {
            bundle.set("X-Camel-Blueprint-ConfigAdmin-Init", configAdminInit.toString());
        }

        return bundle;
    }

    protected static TinyBundle createTestBundle(String name, String version, String descriptors) throws IOException {
        TinyBundle bundle = TinyBundles.newBundle();
        for (URL url : getBlueprintDescriptors(descriptors)) {
            LOG.info("Using Blueprint XML file: " + url.getFile());
            bundle.add("OSGI-INF/blueprint/blueprint-" + url.getFile().replace("/", "-"), url);
        }
        bundle.set("Manifest-Version", "2")
                .set("Bundle-ManifestVersion", "2")
                .set("Bundle-SymbolicName", name)
                .set("Bundle-Version", version);

        return bundle;
    }

    /**
     * Explode the dictionary into a <code>,</code> delimited list of <code>key=value</code> pairs.
     */
    private static String explode(Dictionary<?, ?> dictionary) {
        Enumeration<?> keys = dictionary.keys();
        StringBuilder result = new StringBuilder();
        while (keys.hasMoreElements()) {
            Object key = keys.nextElement();
            result.append(String.format("%s=%s", key, dictionary.get(key)));
            if (keys.hasMoreElements()) {
                result.append(", ");
            }
        }
        return result.toString();
    }

    /**
     * Provides an iterable collection of references, even if the original array is <code>null</code>.
     */
    private static Collection<ServiceReference> asCollection(ServiceReference[] references) {
        return references  == null ? new ArrayList<>(0) : Arrays.asList(references);
    }

    /**
     * Gets list of bundle descriptors.
     * @param bundleFilter Filter expression for OSGI bundles.
     *
     * @return List pointers to OSGi bundles.
     * @throws Exception If looking up the bundles fails.
     */
    private static List<BundleDescriptor> getBundleDescriptors(final String bundleFilter, ClassLoader loader) throws Exception {
        return new ClasspathScanner().scanForBundles(bundleFilter, loader);
    }

    /**
     * Gets the bundle descriptors as {@link URL} resources.
     *
     * @param descriptors the bundle descriptors, can be separated by comma
     * @return the bundle descriptors.
     * @throws FileNotFoundException is thrown if a bundle descriptor cannot be found
     */
    protected static Collection<URL> getBlueprintDescriptors(String descriptors) throws FileNotFoundException, MalformedURLException {
        List<URL> answer = new ArrayList<>();
        if (descriptors != null) {
            // there may be more resources separated by comma
            Iterator<?> it = ObjectHelper.createIterator(descriptors);
            while (it.hasNext()) {
                String s = (String) it.next();
                LOG.trace("Resource descriptor: {}", s);

                // remove leading / to be able to load resource from the classpath
                s = FileUtil.stripLeadingSeparator(s);

                // if there is wildcards for *.xml then we need to find the urls from the package
                if (s.endsWith("*.xml")) {
                    String packageName = s.substring(0, s.length() - 5);
                    // remove trailing / to be able to load resource from the classpath
                    Enumeration<URL> urls = org.apache.camel.util.ObjectHelper.loadResourcesAsURL(packageName);
                    while (urls.hasMoreElements()) {
                        URL url = urls.nextElement();
                        File dir = new File(url.getFile());
                        if (dir.isDirectory()) {
                            File[] files = dir.listFiles();
                            if (files != null) {
                                for (File file : files) {
                                    if (file.isFile() && file.exists() && file.getName().endsWith(".xml")) {
                                        String name = packageName + file.getName();
                                        LOG.debug("Resolving resource: {}", name);
                                        URL xmlUrl = org.apache.camel.util.ObjectHelper.loadResourceAsURL(name);
                                        if (xmlUrl != null) {
                                            answer.add(xmlUrl);
                                        }
                                    }
                                }
                            }
                        }
                    }
                } else {
                    LOG.debug("Resolving resource: {}", s);
                    URL url = ResourceHelper.resolveMandatoryResourceAsUrl(RESOLVER, s);
                    if (url == null) {
                        throw new FileNotFoundException("Resource " + s + " not found");
                    }
                    answer.add(url);
                }
            }
        } else {
            throw new IllegalArgumentException("No bundle descriptor configured. Override getBlueprintDescriptor() or getBlueprintDescriptors() method");
        }

        if (answer.isEmpty()) {
            throw new IllegalArgumentException("Cannot find any resources in classpath from descriptor " + descriptors);
        }
        return answer;
    }

    private static BundleDescriptor getBundleDescriptor(String path, TinyBundle bundle) throws Exception {
        File file = new File(path);
        // tell the JVM its okay to delete this file on exit as its a temporary file
        // the JVM may not successfully delete the file though
        file.deleteOnExit();

        FileOutputStream fos = new FileOutputStream(file, false);
        InputStream is = bundle.build();
        try {
            IOHelper.copyAndCloseInput(is, fos);
        } finally {
            IOHelper.close(is, fos);
        }

        BundleDescriptor answer = null;
        FileInputStream fis = null;
        JarInputStream jis = null;
        try {
            fis = new FileInputStream(file);
            jis = new JarInputStream(fis);
            Map<String, String> headers = new HashMap<>();
            for (Map.Entry<Object, Object> entry : jis.getManifest().getMainAttributes().entrySet()) {
                headers.put(entry.getKey().toString(), entry.getValue().toString());
            }

            answer = new BundleDescriptor(
                    bundle.getClass().getClassLoader(),
                    "jar:" + file.toURI().toString() + "!/",
                    headers);
        } finally {
            IOHelper.close(jis, fis);
        }

        return answer;
    }

    /**
     * Bundle activator that will be invoked in right time to set initial configadmin configuration
     * for blueprint container.
     */
    public static class TestBundleActivator implements BundleActivator {
        @Override
        public void start(BundleContext bundleContext) throws Exception {
            final String configAdminInit = bundleContext.getBundle().getHeaders().get("X-Camel-Blueprint-ConfigAdmin-Init");
            if (configAdminInit != null) {
                final BundleContext sysContext = bundleContext.getBundle(0).getBundleContext();
                // we are started before blueprint.core and felix.configadmin
                // we are sure that felix.configadmin is started before blueprint.core
                sysContext.addBundleListener(new SynchronousBundleListener() {
                    @Override
                    public void bundleChanged(BundleEvent event) {
                        if (event.getType() == BundleEvent.STARTED
                                && "org.apache.felix.configadmin".equals(event.getBundle().getSymbolicName())) {
                            // configadmin should have already been started
                            ServiceReference<?> sr = sysContext.getServiceReference("org.osgi.service.cm.ConfigurationAdmin");
                            if (sr != null && sysContext.getService(sr) != null) {
                                initializeConfigAdmin(sysContext, configAdminInit);
                            }
                        }
                    }
                });
            }
        }

        private void initializeConfigAdmin(BundleContext context, String configAdminInit) {
            String[] pidFiles = configAdminInit.split(",");
            for (String pidFile : pidFiles) {
                String[] pf = pidFile.split("=");
                try {
                    CamelBlueprintHelper.setPersistentFileForConfigAdmin(context, pf[0], new URI(pf[1]).getPath(),
                            new Properties(), null, null, false);
                } catch (IOException | URISyntaxException e) {
                    throw new RuntimeException(e.getMessage(), e);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException(e.getMessage(), e);
                }
            }
        }

        @Override
        public void stop(BundleContext bundleContext) throws Exception {
        }
    }
}
