/*
 * 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.karaf;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Dictionary;
import java.util.EnumSet;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.function.Consumer;
import javax.inject.Inject;

import org.apache.camel.CamelContext;
import org.apache.camel.Component;
import org.apache.camel.spi.DataFormat;
import org.apache.camel.spi.Language;
import org.apache.karaf.features.FeaturesService;
import org.junit.After;
import org.junit.Before;
import org.ops4j.pax.exam.CoreOptions;
import org.ops4j.pax.exam.Option;
import org.ops4j.pax.exam.ProbeBuilder;
import org.ops4j.pax.exam.TestProbeBuilder;
import org.ops4j.pax.exam.karaf.container.internal.JavaVersionUtil;
import org.ops4j.pax.exam.karaf.options.KarafDistributionOption;
import org.ops4j.pax.exam.karaf.options.LogLevelOption;
import org.ops4j.pax.exam.options.UrlReference;
import org.ops4j.pax.exam.options.extra.VMOption;
import org.ops4j.pax.tinybundles.core.TinyBundle;
import org.ops4j.pax.tinybundles.core.TinyBundles;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
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.service.blueprint.container.BlueprintContainer;
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.junit.Assert.assertNotNull;
import static org.ops4j.pax.exam.CoreOptions.maven;
import static org.ops4j.pax.exam.CoreOptions.mavenBundle;
import static org.ops4j.pax.exam.CoreOptions.systemProperty;
import static org.ops4j.pax.exam.CoreOptions.vmOption;
import static org.ops4j.pax.exam.CoreOptions.when;
import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.configureConsole;
import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.editConfigurationFilePut;
import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.features;
import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.keepRuntimeFolder;
import static org.ops4j.pax.exam.karaf.options.KarafDistributionOption.logLevel;

public abstract class AbstractFeatureTest {

    public static final Long SERVICE_TIMEOUT = 30000L;
    protected static final Logger LOG = LoggerFactory.getLogger(AbstractFeatureTest.class);

    @Inject
    protected BundleContext bundleContext;

    @Inject
    protected BlueprintContainer blueprintContainer;

    @Inject
    protected FeaturesService featuresService;

    @ProbeBuilder
    public TestProbeBuilder probeConfiguration(TestProbeBuilder probe) {
        // makes sure the generated Test-Bundle contains this import!
        probe.setHeader(Constants.DYNAMICIMPORT_PACKAGE, "*");
        return probe;
    }

    @Before
    public void setUp() throws Exception {
        LOG.info("setUp() using BundleContext: {}", bundleContext);
    }

    @After
    public void tearDown() throws Exception {
        LOG.info("tearDown()");
    }

    protected Bundle installBlueprintAsBundle(String name, URL url, boolean start) throws BundleException {
        return installBlueprintAsBundle(name, url, start, bundle -> { });
    }

    protected Bundle installBlueprintAsBundle(String name, URL url, boolean start, Consumer<Object> consumer) throws BundleException {
        // TODO Type Consumer<TinyBundle> cannot be used for this method signature to avoid bundle dependency to pax tinybundles
        TinyBundle bundle = TinyBundles.bundle();
        bundle.add("OSGI-INF/blueprint/blueprint-" + name.toLowerCase(Locale.ENGLISH) + ".xml", url);
        bundle.set("Manifest-Version", "2")
                .set("Bundle-ManifestVersion", "2")
                .set("Bundle-SymbolicName", name)
                .set("Bundle-Version", "1.0.0")
                .set(Constants.DYNAMICIMPORT_PACKAGE, "*");
        consumer.accept(bundle);
        Bundle answer = bundleContext.installBundle(name, bundle.build());

        if (start) {
            answer.start();
        }
        return answer;
    }

    protected Bundle installSpringAsBundle(String name, URL url, boolean start) throws BundleException {
        return installSpringAsBundle(name, url, start, bundle -> { });
    }

    protected Bundle installSpringAsBundle(String name, URL url, boolean start, Consumer<Object> consumer) throws BundleException {
        // TODO Type Consumer<TinyBundle> cannot be used for this method signature to avoid bundle dependency to pax tinybundles
        TinyBundle bundle = TinyBundles.bundle();
        bundle.add("META-INF/spring/spring-" + name.toLowerCase(Locale.ENGLISH) + ".xml", url);
        bundle.set("Manifest-Version", "2")
                .set("Bundle-ManifestVersion", "2")
                .set("Bundle-SymbolicName", name)
                .set("Bundle-Version", "1.0.0");
        consumer.accept(bundle);
        Bundle answer = bundleContext.installBundle(name, bundle.build());

        if (start) {
            answer.start();
        }
        return answer;
    }

    protected void installCamelFeature(String mainFeature) throws Exception {
        if (!mainFeature.startsWith("camel-")) {
            mainFeature = "camel-" + mainFeature;
        }
        LOG.info("Install main feature: {}", mainFeature);
        // do not refresh bundles causing out bundle context to be invalid
        // TODO: see if we can find a way maybe to install camel.xml as bundle/feature instead of part of unit test (see src/test/resources/OSGI-INF/blueprint)
        featuresService.installFeature(mainFeature, EnumSet.of(FeaturesService.Option.NoAutoRefreshBundles));
    }

    protected void overridePropertiesWithConfigAdmin(String pid, Properties props) throws IOException {
        ConfigurationAdmin configAdmin = getOsgiService(bundleContext, ConfigurationAdmin.class);
        // passing null as second argument ties the configuration to correct bundle.
        Configuration config = configAdmin.getConfiguration(pid, null);
        if (config == null) {
            throw new IllegalArgumentException("Cannot find configuration with pid " + pid + " in OSGi ConfigurationAdmin service.");
        }

        // let's merge configurations
        Dictionary<String, Object> currentProperties = config.getProperties();
        Dictionary newProps = new Properties();
        if (currentProperties == null) {
            currentProperties = newProps;
        }
        for (Enumeration<String> ek = currentProperties.keys(); ek.hasMoreElements();) {
            String k = ek.nextElement();
            newProps.put(k, currentProperties.get(k));
        }
        for (String p : props.stringPropertyNames()) {
            newProps.put(p, props.getProperty(p));
        }

        LOG.info("Updating ConfigAdmin {} by overriding properties {}", config, newProps);
        config.update(newProps);
    }

    protected void testComponent(String component) throws Exception {
        testComponent("camel-" + component, component);
    }

    protected void testComponent(String mainFeature, String component) throws Exception {
        LOG.info("Looking up CamelContext(myCamel) in OSGi Service Registry");

        installCamelFeature(mainFeature);

        CamelContext camelContext = getOsgiService(bundleContext, CamelContext.class, "(camel.context.name=myCamel)", SERVICE_TIMEOUT);
        assertNotNull("Cannot find CamelContext with name myCamel", camelContext);

        LOG.info("Getting Camel component: {}", component);
        // do not auto start the component as it may not have been configured properly and fail in its start method
        Component comp = camelContext.getComponent(component, true, false);
        assertNotNull("Cannot get component with name: " + component, comp);

        LOG.info("Found Camel component: {} instance: {} with className: {}", component, comp, comp.getClass());
    }

    protected void testDataFormat(String dataFormat) throws Exception {
        testDataFormat("camel-" + dataFormat, dataFormat);
    }

    protected void testDataFormat(String mainFeature, String dataFormat) throws Exception {
        LOG.info("Looking up CamelContext(myCamel) in OSGi Service Registry");

        installCamelFeature(mainFeature);

        CamelContext camelContext = getOsgiService(bundleContext, CamelContext.class, "(camel.context.name=myCamel)", SERVICE_TIMEOUT);
        assertNotNull("Cannot find CamelContext with name myCamel", camelContext);

        LOG.info("Getting Camel dataformat: {}", dataFormat);
        DataFormat df = camelContext.resolveDataFormat(dataFormat);
        assertNotNull("Cannot get dataformat with name: " + dataFormat, df);

        LOG.info("Found Camel dataformat: {} instance: {} with className: {}", dataFormat, df, df.getClass());
    }

    protected void testLanguage(String language) throws Exception {
        testLanguage("camel-" + language, language);
    }

    protected void testLanguage(String mainFeature, String language) throws Exception {
        LOG.info("Looking up CamelContext(myCamel) in OSGi Service Registry");

        installCamelFeature(mainFeature);

        CamelContext camelContext = getOsgiService(bundleContext, CamelContext.class, "(camel.context.name=myCamel)", 20000);
        assertNotNull("Cannot find CamelContext with name myCamel", camelContext);

        LOG.info("Getting Camel language: {}", language);
        Language lan = camelContext.resolveLanguage(language);
        assertNotNull("Cannot get language with name: " + language, lan);

        LOG.info("Found Camel language: {} instance: {} with className: {}", language, lan, lan.getClass());
    }

    public static String extractName(Class<?> clazz) {
        String name = clazz.getName();
        int id0 = name.indexOf("Camel") + "Camel".length();
        int id1 = name.indexOf("Test");
        StringBuilder sb = new StringBuilder();
        for (int i = id0; i < id1; i++) {
            char c = name.charAt(i);
            if (Character.isUpperCase(c) && sb.length() > 0) {
                sb.append("-");
            }
            sb.append(Character.toLowerCase(c));
        }
        return sb.toString();
    }

    public static UrlReference getCamelKarafFeatureUrl() {
        return mavenBundle().
                groupId("org.apache.camel.karaf").
                artifactId("apache-camel").
                version(getCamelKarafFeatureVersion()).
                type("xml/features");
    }

    private static String getCamelKarafFeatureVersion() {
        String camelKarafFeatureVersion = System.getProperty("camelKarafFeatureVersion");
        if (camelKarafFeatureVersion == null) {
            throw new RuntimeException("Please specify the maven artifact version to use for org.apache.camel.karaf/apache-camel through the camelKarafFeatureVersion System property");
        }
        return camelKarafFeatureVersion;
    }

    private static void switchPlatformEncodingToUTF8() {
        try {
            System.setProperty("file.encoding", "UTF-8");
            Field charset = Charset.class.getDeclaredField("defaultCharset");
            charset.setAccessible(true);
            charset.set(null, null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private static String getKarafVersion() {
        InputStream ins = AbstractFeatureTest.class.getResourceAsStream("/META-INF/maven/dependencies.properties");
        Properties p = new Properties();
        try {
            p.load(ins);
        } catch (Throwable t) {
            // ignore
        }
        String karafVersion = p.getProperty("org.apache.karaf/apache-karaf/version");
        if (karafVersion == null) {
            karafVersion = System.getProperty("karafVersion");
        }
        if (karafVersion == null) {
            // setup the default version of it
            karafVersion = "4.1.0";
        }
        return karafVersion;
    }

    public static Option[] configure(String... extra) {

        List<String> camel = new ArrayList<>();
        camel.add("camel");
        if (extra != null && extra.length > 0) {
            for (String e : extra) {
                camel.add(e);
            }
        }
        final String[] camelFeatures = camel.toArray(new String[camel.size()]);

        switchPlatformEncodingToUTF8();
        String karafVersion = getKarafVersion();
        LOG.info("*** Apache Karaf version is " + karafVersion + " ***");

        Option[] options = new Option[]{
            // for remote debugging
//            new VMOption("-Xdebug"),
//            new VMOption("-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5008"),

            KarafDistributionOption.karafDistributionConfiguration()
                    .frameworkUrl(maven().groupId("org.apache.karaf").artifactId("apache-karaf").type("tar.gz").versionAsInProject())
                    .karafVersion(karafVersion)
                    .name("Apache Karaf")
                    .useDeployFolder(false).unpackDirectory(new File("target/paxexam/unpack/")),
            logLevel(LogLevelOption.LogLevel.INFO),

            // keep the folder so we can look inside when something fails
            keepRuntimeFolder(),

            // Disable the SSH port
            configureConsole().ignoreRemoteShell(),

            // need to modify the jre.properties to export some com.sun packages that some features rely on
//            KarafDistributionOption.replaceConfigurationFile("etc/jre.properties", new File("src/test/resources/jre.properties")),

            vmOption("-Dfile.encoding=UTF-8"),

            // Disable the Karaf shutdown port
            editConfigurationFilePut("etc/custom.properties", "karaf.shutdown.port", "-1"),

            // log config
            editConfigurationFilePut("etc/custom.properties", "karaf.log", "${karaf.data}/log"),

            // Assign unique ports for Karaf
//            editConfigurationFilePut("etc/org.ops4j.pax.web.cfg", "org.osgi.service.http.port", Integer.toString(AvailablePortFinder.getNextAvailable())),
//            editConfigurationFilePut("etc/org.apache.karaf.management.cfg", "rmiRegistryPort", Integer.toString(AvailablePortFinder.getNextAvailable())),
//            editConfigurationFilePut("etc/org.apache.karaf.management.cfg", "rmiServerPort", Integer.toString(AvailablePortFinder.getNextAvailable())),

            // install junit
            CoreOptions.junitBundles(),

            // install camel
            features(getCamelKarafFeatureUrl(), camelFeatures),

            // install camel-test-karaf as bundle (not feature as the feature causes a bundle refresh that invalidates the @Inject bundleContext)
            mavenBundle().groupId("org.apache.camel").artifactId("camel-test-karaf").versionAsInProject(),
            when(JavaVersionUtil.getMajorVersion() >= 9)
                    .useOptions(
                    systemProperty("pax.exam.osgi.`unresolved.fail").value("true"),
                    systemProperty("java.awt.headless").value("true"),
                    new VMOption("--add-reads=java.xml=java.logging"),
                    new VMOption("--add-exports=java.base/org.apache.karaf.specs.locator=java.xml,ALL-UNNAMED"),
                    new VMOption("--patch-module"),
                    new VMOption("java.base=lib/endorsed/org.apache.karaf.specs.locator-"
                            + System.getProperty("karafVersion", "4.2.4") + ".jar"),
                    new VMOption("--patch-module"),
                    new VMOption("java.xml=lib/endorsed/org.apache.karaf.specs.java.xml-"
                            + System.getProperty("karafVersion", "4.2.4") + ".jar"),
                    new VMOption("--add-opens"),
                    new VMOption("java.base/java.security=ALL-UNNAMED"),
                    new VMOption("--add-opens"),
                    new VMOption("java.base/java.net=ALL-UNNAMED"),
                    new VMOption("--add-opens"),
                    new VMOption("java.base/java.lang=ALL-UNNAMED"),
                    new VMOption("--add-opens"),
                    new VMOption("java.base/java.util=ALL-UNNAMED"),
                    new VMOption("--add-opens"),
                    new VMOption("java.naming/javax.naming.spi=ALL-UNNAMED"),
                    new VMOption("--add-opens"),
                    new VMOption("java.rmi/sun.rmi.transport.tcp=ALL-UNNAMED"),
                    new VMOption("--add-exports=java.base/sun.net.www.protocol.http=ALL-UNNAMED"),
                    new VMOption("--add-exports=java.base/sun.net.www.protocol.https=ALL-UNNAMED"),
                    new VMOption("--add-exports=java.base/sun.net.www.protocol.jar=ALL-UNNAMED"),
                    new VMOption("--add-exports=jdk.naming.rmi/com.sun.jndi.url.rmi=ALL-UNNAMED"),
                    new VMOption("-classpath"),
                    new VMOption("lib/jdk9plus/*" + File.pathSeparator + "lib/boot/*")
            )
        };

        return options;
    }

    protected <T> T getOsgiService(BundleContext bundleContext, Class<T> type) {
        return getOsgiService(bundleContext, type, null, SERVICE_TIMEOUT);
    }

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

    @SuppressWarnings("unchecked")
    public static <T> T getOsgiService(BundleContext bundleContext, Class<T> type, String filter, long timeout) {
        ServiceTracker tracker;
        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);
        }
    }

    /**
     * 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);
    }

}
