/*
 * 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.jackrabbit.oak.run.osgi;

import static java.util.Objects.requireNonNull;

import java.lang.management.ManagementFactory;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.ServiceLoader;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;

import javax.jcr.Repository;
import javax.jcr.RepositoryException;
import javax.jcr.RepositoryFactory;
import javax.management.Attribute;
import javax.management.AttributeList;
import javax.management.MBeanServer;
import javax.management.ObjectName;

import org.apache.commons.io.FilenameUtils;
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.apache.jackrabbit.api.JackrabbitRepository;
import org.apache.jackrabbit.oak.commons.PropertiesUtil;
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.ServiceReference;
import org.osgi.framework.SynchronousBundleListener;
import org.osgi.util.tracker.ServiceTracker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * RepositoryFactory which constructs an instance of Oak repository. Thi factory supports following
 * parameters
 *
 *  <dl>
 *      <dt>org.osgi.framework.BundleActivator</dt>
 *      <dd>(Optional) BundleActivator instance which would be notified about the startup and shutdown</dd>
 *
 *      <dt>org.apache.jackrabbit.oak.repository.config</dt>
 *      <dd>(Optional) Config key which refers to the map of config where key in that map refers to OSGi config</dd>
 *
 *      <dt>org.apache.jackrabbit.oak.repository.configFile</dt>
 *      <dd>
 *          Comma separated list of file names which referred to config stored in form of JSON. The
 *          JSON content consist of pid as the key and config map as the value
 *      </dd>
 *
 *      <dt>org.apache.jackrabbit.repository.home</dt>
 *      <dd>Used to specify the absolute path of the repository home directory</dd>
 *
 *      <dt>org.apache.jackrabbit.oak.repository.bundleFilter</dt>
 *      <dd>Used to specify the bundle filter string which is passed to ClasspathScanner</dd>
 *
 *      <dt>org.apache.jackrabbit.oak.repository.timeoutInSecs</dt>
 *      <dd>Timeout in seconds for the repository startup/shutdown should wait. Defaults to 10 minutes</dd>
 *
 *      <dt>org.apache.jackrabbit.oak.repository.shutDownOnTimeout</dt>
 *      <dd>Boolean flag to determine if the OSGi container should be shutdown upon timeout. Defaults to false</dd>
 *  </dl>
 */
public class OakOSGiRepositoryFactory implements RepositoryFactory {

    private static Logger log = LoggerFactory.getLogger(OakOSGiRepositoryFactory.class);

    /**
     * Name of the repository home parameter.
     */
    public static final String REPOSITORY_HOME
            = "org.apache.jackrabbit.repository.home";

    /**
     * Timeout in seconds for the repository startup should wait
     */
    public static final String REPOSITORY_TIMEOUT_IN_SECS
            = "org.apache.jackrabbit.oak.repository.timeoutInSecs";

    /**
     * Config key which refers to the map of config where key in that map refers to OSGi
     * config
     */
    public static final String REPOSITORY_CONFIG = "org.apache.jackrabbit.oak.repository.config";

    /**
     * Comma separated list of file names which referred to config stored in form of JSON. The
     * JSON content consist of pid as the key and config map as the value
     */
    public static final String REPOSITORY_CONFIG_FILE = "org.apache.jackrabbit.oak.repository.configFile";

    public static final String REPOSITORY_BUNDLE_FILTER
            = "org.apache.jackrabbit.oak.repository.bundleFilter";

    public static final String REPOSITORY_SHUTDOWN_ON_TIMEOUT =
            "org.apache.jackrabbit.oak.repository.shutDownOnTimeout";

    public static final String REPOSITORY_ENV_SPRING_BOOT =
            "org.apache.jackrabbit.oak.repository.springBootMode";

    public static final String REPOSITORY_BUNDLE_FILTER_DEFAULT = "(|" +
            "(Bundle-SymbolicName=org.apache.jackrabbit*)" +
            "(Bundle-SymbolicName=org.apache.sling*)" +
            "(Bundle-SymbolicName=org.apache.felix*)" +
            "(Bundle-SymbolicName=org.apache.aries*)" +
            "(Bundle-SymbolicName=groovy-all)" +
            ")";

    /**
     * Default timeout for repository creation
     */
    private static final int DEFAULT_TIMEOUT = (int) TimeUnit.MINUTES.toSeconds(10);

    private static final BundleActivator NOOP = new BundleActivator() {
        @Override
        public void start(BundleContext bundleContext) throws Exception {

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

        }
    };

    @SuppressWarnings("unchecked")
    public Repository getRepository(Map parameters) throws RepositoryException {
        if(parameters == null || !parameters.containsKey(REPOSITORY_HOME)){
            //Required param missing so repository cannot be created
            return null;
        }

        Map config = new HashMap();
        config.putAll(parameters);

        PojoServiceRegistry registry = initializeServiceRegistry(config);
        BundleActivator activator = getApplicationActivator(config);

        try {
            activator.start(registry.getBundleContext());
        } catch (Exception e) {
            log.warn("Error occurred while starting activator {}", activator.getClass(), e);
        }

        //Future which would be used to notify when repository is ready
        // to be used
        CompletableFuture<Repository> repoFuture = new CompletableFuture<>();

        new RunnableJobTracker(registry.getBundleContext());

        int timeoutInSecs = PropertiesUtil.toInteger(config.get(REPOSITORY_TIMEOUT_IN_SECS), DEFAULT_TIMEOUT);

        //Start the tracker for repository creation
        new RepositoryTracker(registry, activator, repoFuture, timeoutInSecs);


        //Now wait for repository to be created with given timeout
        //if repository creation takes more time. This is required to handle case
        // where OSGi runtime fails to start due to bugs (like cycles)
        try {
            return repoFuture.get(timeoutInSecs, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RepositoryException("Repository initialization was interrupted");
        } catch (ExecutionException e) {
            throw new RepositoryException(e);
        } catch (TimeoutException e) {
            try {
                if (PropertiesUtil.toBoolean(config.get(REPOSITORY_SHUTDOWN_ON_TIMEOUT), true)) {
                    shutdown(registry, timeoutInSecs);
                    log.info("OSGi container shutdown after waiting for repository initialization for {} sec",timeoutInSecs);
                }else {
                    log.warn("[{}] found to be false. Container is not stopped", REPOSITORY_SHUTDOWN_ON_TIMEOUT);
                }
            } catch (BundleException be) {
                log.warn("Error occurred while shutting down the service registry (due to " +
                        "startup timeout) backing the Repository ", be);
            }
            throw new RepositoryException("Repository could not be started in " +
                    timeoutInSecs + " seconds", e);
        }
    }

    @SuppressWarnings("unchecked")
    PojoServiceRegistry initializeServiceRegistry(Map config) {
        processConfig(config);

        PojoServiceRegistry registry = createServiceRegistry(config);
        registerMBeanServer(registry);
        startConfigTracker(registry, config);
        preProcessRegistry(registry);
        startBundles(registry, (String)config.get(REPOSITORY_BUNDLE_FILTER), config);
        postProcessRegistry(registry);

        return registry;
    }

    /**
     * Enables pre processing of service registry by sub classes. This can be
     * used to register services before any bundle gets started
     *
     * @param registry service registry
     */
    protected void preProcessRegistry(PojoServiceRegistry registry) {

    }

    /**
     * Enables post processing of service registry e.g. registering new services etc
     * by sub classes
     *
     * @param registry service registry
     */
    protected void postProcessRegistry(PojoServiceRegistry registry) {

    }

    protected List<BundleDescriptor> processDescriptors(List<BundleDescriptor> descriptors) {
        Collections.sort(descriptors, new BundleDescriptorComparator());
        return descriptors;
    }

    static void shutdown(PojoServiceRegistry registry, int timeoutInSecs) throws BundleException {
        if (registry == null){
            return;
        }
        final Bundle systemBundle = registry.getBundleContext().getBundle();
        final CountDownLatch shutdownLatch = new CountDownLatch(1);

        //Logic here is similar to org.apache.felix.connect.PojoServiceRegistryFactoryImpl.FrameworkImpl.waitForStop()
        systemBundle.getBundleContext().addBundleListener(new SynchronousBundleListener() {
            public void bundleChanged(BundleEvent event) {
                if (event.getBundle() == systemBundle && event.getType() == BundleEvent.STOPPED) {
                    shutdownLatch.countDown();
                }
            }
        });

        //Initiate shutdown
        systemBundle.stop();

        //Wait for framework shutdown to complete
        try {
            boolean shutdownWithinTimeout = shutdownLatch.await(timeoutInSecs,
                    TimeUnit.SECONDS);
            if (!shutdownWithinTimeout){
                throw new BundleException("Timed out while waiting for repository " +
                        "shutdown for "+ timeoutInSecs + " secs");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BundleException("Timed out while waiting for repository " +
                    "shutdown for "+ timeoutInSecs + " secs", e);
        }
    }

    private static void startConfigTracker(PojoServiceRegistry registry, Map config) {
        new ConfigTracker(config, registry.getBundleContext());
    }

    /**
     * Return the BundleActivator provided by the embedding application
     * @param config config passed to factory for initialization
     * @return BundleActivator instance
     */
    private static BundleActivator getApplicationActivator(Map config) {
        BundleActivator activator = (BundleActivator) config.get(BundleActivator.class.getName());
        if (activator == null){
            activator = NOOP;
        }
        return activator;
    }

    @SuppressWarnings("unchecked")
    private static void processConfig(Map config) {
        String home = (String) config.get(REPOSITORY_HOME);
        requireNonNull(home, String.format("Repository home not defined via [%s]", REPOSITORY_HOME));

        home = FilenameUtils.normalizeNoEndSeparator(home);

        String bundleDir = FilenameUtils.concat(home, "bundles");
        config.put(Constants.FRAMEWORK_STORAGE, bundleDir);

        //FIXME Pojo SR currently reads this from system property instead of Framework Property
        config.put(Constants.FRAMEWORK_STORAGE, bundleDir);

        //Directory used by Felix File Install to watch for configs
        config.put("felix.fileinstall.dir", FilenameUtils.concat(home, "config"));

        //Set log level for config to INFO LogService.LOG_INFO
        config.put("felix.fileinstall.log.level", "3");

        //This ensures that configuration is registered in main thread
        //and not in a different thread
        config.put("felix.fileinstall.noInitialDelay", "true");

        config.put("repository.home", FilenameUtils.concat(home, "repository"));

    }

    private PojoServiceRegistry createServiceRegistry(Map<String, Object> config) {
        try {
            ServiceLoader<PojoServiceRegistryFactory> loader = ServiceLoader.load(PojoServiceRegistryFactory.class);
            return loader.iterator().next().newPojoServiceRegistry(config);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }


    private void startBundles(PojoServiceRegistry registry, String bundleFilter, Map config) {
        try {
            if (bundleFilter == null){
                bundleFilter = REPOSITORY_BUNDLE_FILTER_DEFAULT;
            }
            List<BundleDescriptor> descriptors = new ArrayList<>(new ClasspathScanner().scanForBundles(bundleFilter));
            if (PropertiesUtil.toBoolean(config.get(REPOSITORY_ENV_SPRING_BOOT), false)){
                descriptors = SpringBootSupport.processDescriptors(descriptors);
            }
            descriptors = processDescriptors(descriptors);
            registry.startBundles(descriptors);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Registers the Platform MBeanServer as OSGi service. This would enable
     * Aries JMX Whitboard support to then register the JMX MBean which are registered as OSGi service
     * to be registered against the MBean server
     */
    private static void registerMBeanServer(PojoServiceRegistry registry) {
        MBeanServer platformMBeanServer = ManagementFactory.getPlatformMBeanServer();
        Hashtable<String, Object> mbeanProps = new Hashtable<String, Object>();
        try {
            ObjectName beanName = ObjectName.getInstance("JMImplementation:type=MBeanServerDelegate");
            AttributeList attrs = platformMBeanServer.getAttributes(beanName,
                    new String[]{"MBeanServerId", "SpecificationName",
                            "SpecificationVersion", "SpecificationVendor",
                            "ImplementationName", "ImplementationVersion",
                            "ImplementationVendor"});
            for (Object object : attrs) {
                Attribute attr = (Attribute) object;
                if (attr.getValue() != null) {
                    mbeanProps.put(attr.getName(), attr.getValue().toString());
                }
            }
        } catch (Exception je) {
            log.info("Cannot set service properties of Platform MBeanServer service, registering without",
                    je);
        }
        registry.registerService(MBeanServer.class.getName(),
                platformMBeanServer, mbeanProps);
    }

    private static class RepositoryTracker extends ServiceTracker<Repository, Repository> {
        private final CompletableFuture<Repository> repoFuture;
        private final PojoServiceRegistry registry;
        private final BundleActivator activator;
        private RepositoryProxy proxy;
        private final int timeoutInSecs;

        public RepositoryTracker(PojoServiceRegistry registry, BundleActivator activator,
                                 CompletableFuture<Repository> repoFuture, int timeoutInSecs) {
            super(registry.getBundleContext(), Repository.class.getName(), null);
            this.repoFuture = repoFuture;
            this.registry = registry;
            this.activator = activator;
            this.timeoutInSecs = timeoutInSecs;
            this.open();
        }

        @Override
        public Repository addingService(ServiceReference<Repository> reference) {
            Repository service = context.getService(reference);
            if (proxy == null) {
                //As its possible that future is accessed before the service
                //get registered with tracker. We also capture the initial reference
                //and use that for the first access case
                repoFuture.complete(createProxy(service));
            }
            return service;
        }

        @Override
        public void removedService(ServiceReference reference, Repository service) {
            if (proxy != null) {
                proxy.clearInitialReference();
            }
        }

        public PojoServiceRegistry getRegistry() {
            return registry;
        }

        private Repository createProxy(Repository service) {
            proxy = new RepositoryProxy(this, service);
            return (Repository) Proxy.newProxyInstance(getClass().getClassLoader(),
                    new Class[]{Repository.class, JackrabbitRepository.class, ServiceRegistryProvider.class}, proxy);
        }

        public void shutdownRepository() throws BundleException {
            try {
                activator.stop(getRegistry().getBundleContext());
            } catch (Exception e) {
                log.warn("Error occurred while shutting down activator {}", activator.getClass(), e);
            }
            shutdown(getRegistry(), timeoutInSecs);
        }
    }

    /**
     * Due to the way SecurityConfiguration is managed in OSGi env its possible
     * that repository gets created/shutdown few times. So need to have a proxy
     * to access the latest service
     */
    private static class RepositoryProxy implements InvocationHandler {
        private final RepositoryTracker tracker;
        private Repository initialService;
        private final AtomicBoolean shutdownInitiated = new AtomicBoolean();

        private RepositoryProxy(RepositoryTracker tracker, Repository initialService) {
            this.tracker = tracker;
            this.initialService = initialService;
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            Object obj = tracker.getService();
            if (obj == null) {
                obj = initialService;
            }

            final String name = method.getName();
            //If shutdown then close the framework and return
            //Repository would be shutdown by the owning OSGi
            //component like RepositoryManager
            if ("shutdown".equals(name)) {
                if (!shutdownInitiated.getAndSet(true)) {
                    tracker.shutdownRepository();
                }
                return null;
            }

            if ("getServiceRegistry".equals(name)){
                return tracker.getRegistry();
            }

            requireNonNull(obj, "Repository service is not available");
            return method.invoke(obj, args);
        }

        public void clearInitialReference() {
            this.initialService = null;
        }
    }
}
