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

import static com.google.common.base.Preconditions.checkNotNull;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Dictionary;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicReference;
import java.util.jar.Attributes;

import javax.annotation.Nullable;

import org.apache.brooklyn.api.catalog.CatalogItem;
import org.apache.brooklyn.api.mgmt.ManagementContext;
import org.apache.brooklyn.core.mgmt.classloading.OsgiBrooklynClassLoadingContext;
import org.apache.brooklyn.core.mgmt.ha.OsgiManager;
import org.apache.brooklyn.core.mgmt.internal.ManagementContextInternal;
import org.apache.brooklyn.rt.felix.ManifestHelper;
import org.apache.brooklyn.util.core.ResourceUtils;
import org.apache.brooklyn.util.exceptions.Exceptions;
import org.apache.brooklyn.util.guava.Maybe;
import org.apache.brooklyn.util.osgi.OsgiUtil;
import org.apache.brooklyn.util.stream.Streams;
import org.apache.brooklyn.util.text.Strings;
import org.osgi.framework.Bundle;
import org.osgi.framework.Constants;
import org.osgi.framework.FrameworkUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Objects;
import com.google.common.base.Optional;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;

/**
 * Wraps the version of Brooklyn.
 * <p>
 * Also retrieves the SHA-1 from any OSGi bundle, and checks that the maven and osgi versions match.
 */
public class BrooklynVersion implements BrooklynVersionService {

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

    private static final String MVN_VERSION_RESOURCE_FILE = "META-INF/maven/org.apache.brooklyn/brooklyn-core/pom.properties";
    private static final String MANIFEST_PATH = "META-INF/MANIFEST.MF";
    private static final String BROOKLYN_CORE_SYMBOLIC_NAME = "org.apache.brooklyn.core";

    private static final String MVN_VERSION_PROPERTY_NAME = "version";
    private static final String OSGI_VERSION_PROPERTY_NAME = Attributes.Name.IMPLEMENTATION_VERSION.toString();
    private static final String OSGI_SHA1_PROPERTY_NAME = "Implementation-SHA-1";
    // may be useful:
//    private static final String OSGI_BRANCH_PROPERTY_NAME = "Implementation-Branch";

    private final static String VERSION_FROM_STATIC = "0.11.0"; // BROOKLYN_VERSION
    private static final AtomicReference<Boolean> IS_DEV_ENV = new AtomicReference<Boolean>();

    private static final String BROOKLYN_FEATURE_PREFIX = "Brooklyn-Feature-";

    public static final BrooklynVersion INSTANCE = new BrooklynVersion();

    private final Properties versionProperties = new Properties();

    private BrooklynVersion() {
        // we read the maven pom metadata and osgi metadata and make sure it's sensible
        // everything is put into a single map for now (good enough, but should be cleaned up)
        readPropertiesFromMavenResource(BrooklynVersion.class.getClassLoader());
        readPropertiesFromOsgiResource();
        // TODO there is also build-metadata.properties used in ServerResource /v1/server/version endpoint
        // see comments on that about folding it into this class instead

        checkVersions();
    }

    @Override
    public void checkVersions() {
        String mvnVersion = getVersionFromMavenProperties();
        if (mvnVersion != null && !VERSION_FROM_STATIC.equals(mvnVersion)) {
            throw new IllegalStateException("Version error: maven " + mvnVersion + " / code " + VERSION_FROM_STATIC);
        }

        String osgiVersion = versionProperties.getProperty(OSGI_VERSION_PROPERTY_NAME);
        // TODO does the OSGi version include other slightly differ gubbins/style ?
        if (osgiVersion != null && !VERSION_FROM_STATIC.equals(osgiVersion)) {
            throw new IllegalStateException("Version error: osgi " + osgiVersion + " / code " + VERSION_FROM_STATIC);
        }
    }

    /**
     * Returns version as inferred from classpath/osgi, if possible, or 0.0.0-SNAPSHOT.
     * See also {@link #getVersionFromMavenProperties()} and {@link #getVersionFromOsgiManifest()}.
     *
     * @deprecated since 0.7.0, in favour of the more specific methods (and does anyone need that default value?)
     */
    @Deprecated
    public String getVersionFromClasspath() {
        String v = getVersionFromMavenProperties();
        if (Strings.isNonBlank(v)) return v;
        v = getVersionFromOsgiManifest();
        if (Strings.isNonBlank(v)) return v;
        return "0.0.0-SNAPSHOT";
    }

    @Override
    @Nullable
    public String getVersionFromMavenProperties() {
        return versionProperties.getProperty(MVN_VERSION_PROPERTY_NAME);
    }

    @Override
    @Nullable
    public String getVersionFromOsgiManifest() {
        return versionProperties.getProperty(OSGI_VERSION_PROPERTY_NAME);
    }

    @Override
    @Nullable
    /** SHA1 of the last commit to brooklyn at the time this build was made.
     * For SNAPSHOT builds of course there may have been further non-committed changes. */
    public String getSha1FromOsgiManifest() {
        return versionProperties.getProperty(OSGI_SHA1_PROPERTY_NAME);
    }

    @Override
    public String getVersion() {
        return VERSION_FROM_STATIC;
    }

    @Override
    public boolean isSnapshot() {
        return (getVersion().indexOf("-SNAPSHOT") >= 0);
    }

    private void readPropertiesFromMavenResource(ClassLoader resourceLoader) {
        InputStream versionStream = null;
        try {
            versionStream = resourceLoader.getResourceAsStream(MVN_VERSION_RESOURCE_FILE);
            if (versionStream == null) {
                if (isDevelopmentEnvironment()) {
                    // allowed for dev env
                    log.trace("No maven resource file " + MVN_VERSION_RESOURCE_FILE + " available");
                } else {
                    log.warn("No maven resource file " + MVN_VERSION_RESOURCE_FILE + " available");
                }
                return;
            }
            versionProperties.load(checkNotNull(versionStream));
        } catch (IOException e) {
            log.warn("Error reading maven resource file " + MVN_VERSION_RESOURCE_FILE + ": " + e, e);
        } finally {
            Streams.closeQuietly(versionStream);
        }
    }

    /**
     * Reads main attributes properties from brooklyn-core's bundle manifest.
     */
    private void readPropertiesFromOsgiResource() {
        if (OsgiUtil.isBrooklynInsideFramework()) {
            Dictionary<String, String> headers = FrameworkUtil.getBundle(BrooklynVersion.class).getHeaders();
            for (Enumeration<String> keys = headers.keys(); keys.hasMoreElements();) {
                String key = keys.nextElement();
                versionProperties.put(key, headers.get(key));
            }
        } else {
            Enumeration<URL> paths;
            try {
                paths = BrooklynVersion.class.getClassLoader().getResources(MANIFEST_PATH);
            } catch (IOException e) {
                // shouldn't happen
                throw Exceptions.propagate(e);
            }
            while (paths.hasMoreElements()) {
                URL u = paths.nextElement();
                InputStream us = null;
                try {
                    us = u.openStream();
                    ManifestHelper mh = ManifestHelper.forManifest(us);
                    if (BROOKLYN_CORE_SYMBOLIC_NAME.equals(mh.getSymbolicName())) {
                        Attributes attrs = mh.getManifest().getMainAttributes();
                        for (Object key : attrs.keySet()) {
                            // key is an Attribute.Name; toString converts to string
                            versionProperties.put(key.toString(), attrs.getValue(key.toString()));
                        }
                        return;
                    }
                } catch (Exception e) {
                    Exceptions.propagateIfFatal(e);
                    log.warn("Error reading OSGi manifest from " + u + " when determining version properties: " + e, e);
                } finally {
                    Streams.closeQuietly(us);
                }
            }
            if (isDevelopmentEnvironment()) {
                // allowed for dev env
                log.trace("No OSGi manifest available to determine version properties");
            } else {
                log.warn("No OSGi manifest available to determine version properties");
            }
        }
    }

    /**
     * Returns whether this is a Brooklyn dev environment,
     * specifically core/target/classes/ is on the classpath for the org.apache.brooklyn.core project.
     * <p/>
     * In a packaged or library build of Brooklyn (normal usage) this should return false,
     * and all OSGi components should be available.
     * <p/>
     * There is no longer any way to force this,
     * such as the old BrooklynDevelopmentMode class;
     * but that could easily be added if required (eg as a system property).
     */
    public static boolean isDevelopmentEnvironment() {
        Boolean isDevEnv = IS_DEV_ENV.get();
        if (isDevEnv != null) return isDevEnv;
        synchronized (IS_DEV_ENV) {
            isDevEnv = computeIsDevelopmentEnvironment();
            IS_DEV_ENV.set(isDevEnv);
            return isDevEnv;
        }
    }

    private static boolean computeIsDevelopmentEnvironment() {
        Enumeration<URL> paths;
        try {
            paths = BrooklynVersion.class.getClassLoader().getResources("org/apache/brooklyn/core/BrooklynVersion.class");
        } catch (IOException e) {
            // shouldn't happen
            throw Exceptions.propagate(e);
        }
        while (paths.hasMoreElements()) {
            URL u = paths.nextElement();
            // running fram a classes directory (including coverage-classes for cobertura) triggers dev env
            if (u.getPath().endsWith("org/apache/brooklyn/core/BrooklynVersion.class")) {
                try {
                    log.debug("Brooklyn dev/src environment detected: BrooklynVersion class is at: " + u);
                    return true;
                } catch (Exception e) {
                    Exceptions.propagateIfFatal(e);
                    log.warn("Error reading manifest to determine whether this is a development environment: " + e, e);
                }
            }
        }
        return false;
    }

    @Override
    public void logSummary() {
        log.debug("Brooklyn version " + getVersion() + " (git SHA1 " + getSha1FromOsgiManifest() + ")");
    }

    /**
     * @deprecated since 0.7.0, redundant with {@link #get()}
     */
    @Deprecated
    public static String getVersionFromStatic() {
        return VERSION_FROM_STATIC;
    }

    public static String get() {
        return INSTANCE.getVersion();
    }

    public static BrooklynVersion getInstance() {
        return INSTANCE;
    }

    /**
     * @param mgmt The context to search for features.
     * @return An iterable containing all features found in the management context's classpath and catalogue.
     */
    public static Iterable<BrooklynFeature> getFeatures(ManagementContext mgmt) {
        if (OsgiUtil.isBrooklynInsideFramework()) {
            List<Bundle> bundles = Lists.newArrayList(
                    FrameworkUtil.getBundle(BrooklynVersion.class)
                            .getBundleContext()
                            .getBundles()
            );

            Maybe<OsgiManager> osgi = ((ManagementContextInternal)mgmt).getOsgiManager();
            for (CatalogItem<?, ?> catalogItem : mgmt.getCatalog().getCatalogItems()) {
                if (osgi.isPresentAndNonNull()) {
                    for (CatalogItem.CatalogBundle catalogBundle : catalogItem.getLibraries()) {
                        Maybe<Bundle> osgiBundle = osgi.get().findBundle(catalogBundle);
                        if (osgiBundle.isPresentAndNonNull()) {
                            bundles.add(osgiBundle.get());
                        }
                    }
                }

            }

            // Set over list in case a bundle is reported more than once (e.g. from classpath and from OSGi).
            // Not sure of validity of this approach over just reporting duplicates.
            ImmutableSet.Builder<BrooklynFeature> features = ImmutableSet.builder();
            for(Bundle bundle : bundles) {
                Optional<BrooklynFeature> fs = BrooklynFeature.newFeature(bundle.getHeaders());
                if (fs.isPresent()) {
                    features.add(fs.get());
                }
            }
            return features.build();
        } else {
            Iterable<URL> manifests = ResourceUtils.create(mgmt).getResources(MANIFEST_PATH);

            for (CatalogItem<?, ?> catalogItem : mgmt.getCatalog().getCatalogItems()) {
                OsgiBrooklynClassLoadingContext osgiContext = new OsgiBrooklynClassLoadingContext(
                        mgmt, catalogItem.getCatalogItemId(), catalogItem.getLibraries());
                manifests = Iterables.concat(manifests, osgiContext.getResources(MANIFEST_PATH));
            }

            // Set over list in case a bundle is reported more than once (e.g. from classpath and from OSGi).
            // Not sure of validity of this approach over just reporting duplicates.
            ImmutableSet.Builder<BrooklynFeature> features = ImmutableSet.builder();
            for (URL manifest : manifests) {
                ManifestHelper mh = null;
                try {
                    mh = ManifestHelper.forManifest(manifest);
                } catch (Exception e) {
                    Exceptions.propagateIfFatal(e);
                    log.debug("Error reading OSGi manifest from " + manifest + " when determining version properties: " + e, e);
                }
                if (mh == null) continue;
                Attributes attrs = mh.getManifest().getMainAttributes();
                Optional<BrooklynFeature> fs = BrooklynFeature.newFeature(attrs);
                if (fs.isPresent()) {
                    features.add(fs.get());
                }
            }
            return features.build();
        }
    }

    public static class BrooklynFeature {
        private final String name;
        private final String symbolicName;
        private final String version;
        private final String lastModified;
        private final Map<String, String> additionalData;

        BrooklynFeature(String name, String symbolicName, String version, String lastModified, Map<String, String> additionalData) {
            this.symbolicName = checkNotNull(symbolicName, "symbolicName");
            this.name = name;
            this.version = version;
            this.lastModified = lastModified;
            this.additionalData = ImmutableMap.copyOf(additionalData);
        }

        private static Optional<BrooklynFeature> newFeature(Attributes attrs) {
            // unfortunately Attributes is a Map<Object,Object>
            Dictionary<String,String> headers = new Hashtable<>();
            for (Map.Entry<Object, Object> entry : attrs.entrySet()) {
                headers.put(entry.getKey().toString(), entry.getValue().toString());
            }
            return newFeature(headers);
        }

        /** @return Present if any attribute name begins with {@link #BROOKLYN_FEATURE_PREFIX}, absent otherwise. */
        private static Optional<BrooklynFeature> newFeature(Dictionary<String,String> headers) {
            Map<String, String> additionalData = Maps.newHashMap();
            for (Enumeration<String> keys = headers.keys(); keys.hasMoreElements();) {
                String key = keys.nextElement();
                if (key.startsWith(BROOKLYN_FEATURE_PREFIX)) {
                    String value = headers.get(key);
                    if (!Strings.isBlank(value)) {
                        additionalData.put(key, value);
                    }
                }
            }
            if (additionalData.isEmpty()) {
                return Optional.absent();
            }

            // Name is special cased as it a useful way to indicate a feature without
            String nameKey = BROOKLYN_FEATURE_PREFIX + "Name";
            String name = Optional.fromNullable(additionalData.remove(nameKey))
                    .or(Optional.fromNullable(Constants.BUNDLE_NAME))
                    .or(headers.get(Constants.BUNDLE_SYMBOLICNAME));

            return Optional.of(new BrooklynFeature(
                    name,
                    headers.get(Constants.BUNDLE_SYMBOLICNAME),
                    headers.get(Constants.BUNDLE_VERSION),
                    headers.get("Bnd-LastModified"),
                    additionalData));
        }

        public String getLastModified() {
            return lastModified;
        }

        public String getName() {
            return name;
        }

        public String getSymbolicName() {
            return symbolicName;
        }

        public String getVersion() {
            return version;
        }

        /** @return an unmodifiable map */
        public Map<String, String> getAdditionalData() {
            return additionalData;
        }

        @Override
        public String toString() {
            return getClass().getSimpleName() + "{" + symbolicName + (version != null ? ":" + version : "") + "}";
        }

        @Override
        public int hashCode() {
            return Objects.hashCode(symbolicName, version);
        }

        @Override
        public boolean equals(Object other) {
            if (this == other) return true;
            if (other == null || getClass() != other.getClass()) return false;
            BrooklynFeature that = (BrooklynFeature) other;
            if (!symbolicName.equals(that.symbolicName)) {
                return false;
            } else if (version != null ? !version.equals(that.version) : that.version != null) {
                return false;
            }
            return true;
        }
    }
}
