/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 *
 * Copyright (c) 2011-2015 Oracle and/or its affiliates. All rights reserved.
 *
 * The contents of this file are subject to the terms of either the GNU
 * General Public License Version 2 only ("GPL") or the Common Development
 * and Distribution License("CDDL") (collectively, the "License").  You
 * may not use this file except in compliance with the License.  You can
 * obtain a copy of the License at
 * https://glassfish.dev.java.net/public/CDDL+GPL_1_1.html
 * or packager/legal/LICENSE.txt.  See the License for the specific
 * language governing permissions and limitations under the License.
 *
 * When distributing the software, include this License Header Notice in each
 * file and include the License file at packager/legal/LICENSE.txt.
 *
 * GPL Classpath Exception:
 * Oracle designates this particular file as subject to the "Classpath"
 * exception as provided by Oracle in the GPL Version 2 section of the License
 * file that accompanied this code.
 *
 * Modifications:
 * If applicable, add the following below the License Header, with the fields
 * enclosed by brackets [] replaced by your own identifying information:
 * "Portions Copyright [year] [name of copyright owner]"
 *
 * Contributor(s):
 * If you wish your version of this file to be governed by only the CDDL or
 * only the GPL Version 2, indicate your decision by adding "[Contributor]
 * elects to include this software in this distribution under the [CDDL or GPL
 * Version 2] license."  If you don't indicate a single choice of license, a
 * recipient has the option to distribute your version of this file under
 * either the CDDL, the GPL Version 2 or to extend the choice of license to
 * its licensees as provided above.  However, if you add GPL Version 2 code
 * and therefore, elected the GPL Version 2 license, then the option applies
 * only if the new code is made subject to such option by the copyright
 * holder.
 */

package org.jvnet.hk2.osgiadapter;

import static org.jvnet.hk2.osgiadapter.Logger.logger;

import java.io.File;
import java.io.FileFilter;
import java.io.FileWriter;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.Executors;
import java.util.logging.Level;

import org.apache.felix.bundlerepository.DataModelHelper;
import org.apache.felix.bundlerepository.Reason;
import org.apache.felix.bundlerepository.Repository;
import org.apache.felix.bundlerepository.RepositoryAdmin;
import org.apache.felix.bundlerepository.Resolver;
import org.apache.felix.bundlerepository.Resource;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.Filter;
import org.osgi.framework.InvalidSyntaxException;
import org.osgi.framework.ServiceReference;
import org.osgi.framework.Version;
import org.osgi.util.tracker.ServiceTracker;

/**
 * @author Sanjeeb.Sahoo@Sun.COM
 */
class ObrHandler extends ServiceTracker {

    /*
    Implementation Note: This class has no dependency on HK2.
     */

    private boolean deployFragments = false;
    private boolean deployOptionalRequirements = false;
    // We maintain our own repository list which we use during resolution process.
    // That way, we are not affected by any repository added by user to a shared instance of repository admin.
    private List<Repository> repositories = new ArrayList<Repository>();

    public ObrHandler(BundleContext bctx) {
        super(bctx, RepositoryAdmin.class.getName(), null);
        deployFragments = Boolean.valueOf(bctx.getProperty(Constants.OBR_DEPLOYS_FRGAMENTS));
        deployOptionalRequirements = Boolean.valueOf(bctx.getProperty(Constants.OBR_DEPLOYS_OPTIONAL_REQUIREMENTS));
        open();
    }

    @Override
    public Object addingService(ServiceReference reference) {
        if (this.getTrackingCount() == 1) {
            logger.logp(Level.INFO, "ObrHandler", "addingService",
                    "We already have a repository admin service, so ignoring {0}", new Object[]{reference});
            return null; // we are not tracking this
        }
        RepositoryAdmin repositoryAdmin = (RepositoryAdmin) context.getService(reference);
        repositories.add(repositoryAdmin.getSystemRepository());
        repositories.add(repositoryAdmin.getLocalRepository());
        return super.addingService(reference);
    }

    @Override
    public void remove(ServiceReference reference) {
        super.remove(reference);
    }

    public RepositoryAdmin getRepositoryAdmin() {
        assert (getTrackingCount() < 2);
        try {
            return (RepositoryAdmin) waitForService(Constants.OBR_TIMEOUT);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }

    public synchronized void addRepository(URI obrUri) throws Exception {
        if (isDirectory(obrUri)) {
            setupRepository(new File(obrUri), isSynchronous());
        } else {
            repositories.add(getRepositoryAdmin().getHelper().repository(obrUri.toURL()));
        }
    }

    private boolean isDirectory(URI obrUri) {
        try {
            return new File(obrUri).isDirectory();
        } catch (Exception e) {
        }
        return false;
    }

    private void setupRepository(final File repoDir, boolean synchronous) throws Exception {
        if (synchronous) {
            _setupRepository(repoDir);
        } else {
            Executors.newSingleThreadExecutor().submit(new Runnable() {
                @Override
                public void run() {
                    try {
                        _setupRepository(repoDir);
                    } catch (Exception e) {
                        throw new RuntimeException(e); // TODO(Sahoo): Proper Exception Handling
                    }
                }
            });
        }
    }

    private boolean isSynchronous() {
        String property = context.getProperty(Constants.INITIALIZE_OBR_SYNCHRONOUSLY);
        // default is synchronous as we are not sure if we have covered every race condition in asynchronous path
        return property == null || Boolean.TRUE.toString().equalsIgnoreCase(property);
    }

    private synchronized void _setupRepository(File repoDir) throws Exception {
        Repository repository;
        File repoFile = getRepositoryFile(repoDir);
        final long tid = Thread.currentThread().getId();
        if (repoFile != null && repoFile.exists()) {
            long t = System.currentTimeMillis();
            repository = updateRepository(repoFile, repoDir);
            long t2 = System.currentTimeMillis();
            logger.logp(Level.INFO, "ObrHandler", "_setupRepository", "Thread #{0}: updateRepository took {1} ms",
                    new Object[]{tid, t2 - t});
        } else {
            long t = System.currentTimeMillis();
            repository = createRepository(repoFile, repoDir);
            long t2 = System.currentTimeMillis();
            logger.logp(Level.INFO, "ObrHandler", "_setupRepository", "Thread #{0}: createRepository took {1} ms",
                    new Object[]{tid, t2 - t});
        }
        repositories.add(repository);
        // We don't add repository to RepositoryAdmin, as we pass the list of repositories to use in resolver().
//        final String repoUrl = repository.getURI();
//        logger.logp(Level.INFO, "ObrHandler", "_setupRepository", "Thread #{0}: Adding repository = {1}",
//                new Object[]{tid, repoUrl});
//        long t = System.currentTimeMillis();
//        getRepositoryAdmin().addRepository(repository);
//        long t2 = System.currentTimeMillis();
//        logger.logp(Level.INFO, "ObrHandler", "_setupRepository", "Thread #{0}: Adding repo took {1} ms",
//                new Object[]{tid, t2 - t});
    }

    private File getRepositoryFile(File repoDir) {
        String extn = ".xml";
        String cacheDir = context.getProperty(Constants.HK2_CACHE_DIR);
        if (cacheDir == null) {
            return null; // caching is disabled, so don't do it.
        }
        return new File(cacheDir, Constants.OBR_FILE_NAME_PREFIX + repoDir.getName() + extn);
    }

    /**
     * Create a new Repository from a directory by recurssively traversing all the jar files found there.
     *
     * @param repoFile
     * @param repoDir
     * @return
     * @throws IOException
     */
    private Repository createRepository(File repoFile, File repoDir) throws IOException {
        DataModelHelper dmh = getRepositoryAdmin().getHelper();
        List<Resource> resources = new ArrayList<Resource>();
        for (File jar : findAllJars(repoDir)) {
            Resource r = dmh.createResource(jar.toURI().toURL());
            
            if (r == null) {
            	logger.logp(Level.WARNING, "ObrHandler", "createRepository", "{0} not an OSGi bundle", jar.toURI().toURL());
            } else {
                resources.add(r);
            }
        }
        Repository repository = dmh.repository(resources.toArray(new Resource[resources.size()]));
        logger.logp(Level.INFO, "ObrHandler", "createRepository", "Created {0} containing {1} resources.",
                new Object[]{repoFile, resources.size()});
        if (repoFile != null) {
            saveRepository(repoFile, repository);
        }
        return repository;
    }

    private void saveRepository(File repoFile, Repository repository) throws IOException {
        assert (repoFile != null);
        final FileWriter writer = new FileWriter(repoFile);
        
        getRepositoryAdmin().getHelper().writeRepository(repository, writer);
        writer.flush();
    }

    private Repository loadRepository(File repoFile) throws Exception {
        assert (repoFile != null);
        return getRepositoryAdmin().getHelper().repository(repoFile.toURI().toURL());
    }

    private Repository updateRepository(File repoFile, File repoDir) throws Exception {
        Repository repository = loadRepository(repoFile);
        if (isObsoleteRepo(repository, repoFile, repoDir)) {
            if (!repoFile.delete()) {
                throw new IOException("Failed to delete " + repoFile.getAbsolutePath());
            }
            logger.logp(Level.INFO, "ObrHandler", "updateRepository", "Recreating {0}", new Object[]{repoFile});
            repository = createRepository(repoFile, repoDir);
        }
        return repository;
    }

    private boolean isObsoleteRepo(Repository repository, File repoFile, File repoDir) {
        // TODO(Sahoo): Revisit this...
        // This method assumes that the cached repoFile has been created before a newer jar is created.
        // So, this method does not always detect stale repoFile. Imagine the following situation:
        // time t1: v1 version of jar is released.
        // time t2: v2 version of jar is released.
        // time t3: repo.xml is populated using v1 version of jar, so repo.xml records a timestamp of t3 > t2.
        // time t4: v2 version of jar is unzipped on modules/ and unzip maintains the timestamp of jar as t2.
        // Next time when we compare timestamp, we will see that repo.xml is newer than this jar, when it is not.
        // So, we include a size check. We go for the total size check...

        long lastModifiedTime = repoFile.lastModified();
        // optimistic: see if the repoDir has been touched. dir timestamp changes when files are added or removed.
        if (repoDir.lastModified() > lastModifiedTime) {
            return true;
        }
        long totalSize = 0;
        // now compare timestamp of each jar and take a sum of size of all jars.
        for (File jar : findAllJars(repoDir)) {
            if (jar.lastModified() > lastModifiedTime) {
                logger.logp(Level.INFO, "ObrHandler", "isObsoleteRepo", "{0} is newer than {1}", new Object[]{jar, repoFile});
                return true;
            }
            totalSize += jar.length();
        }
        // time stamps didn't identify any difference, so check sizes. The probabibility of sizes of all jars being same
        // when some jars have changed is very very low.
        for (Resource r : repository.getResources()) {
            totalSize -= r.getSize();
        }
        if (totalSize != 0) {
            logger.logp(Level.INFO, "ObrHandler", "isObsoleteRepo", "Change in size detected by {0} bytes", new Object[]{totalSize});
            return true;
        }
        return false;
    }

    private List<File> findAllJars(File repo) {
        final List<File> files = new ArrayList<File>();
        repo.listFiles(new FileFilter() {
            @Override
            public boolean accept(File pathname) {
                if (pathname.isDirectory()) {
                    pathname.listFiles(this);
                } else if (pathname.getName().endsWith("jar")) {
                    files.add(pathname);
                }
                return true;
            }
        });
        return files;
    }

    /* package */
    synchronized Bundle deploy(Resource resource) {
        final Resolver resolver = getRepositoryAdmin().resolver(getRepositories());
        boolean resolved = resolve(resolver, resource);
        if (resolved) {
            final int flags = !deployOptionalRequirements ? Resolver.NO_OPTIONAL_RESOURCES : 0;
            resolver.deploy(flags);
            return getBundle(resource);
        } else {
            Reason[] reqs = resolver.getUnsatisfiedRequirements();
            logger.logp(Level.WARNING, "ObrHandler", "deploy",
                    "Unable to satisfy the requirements: {0}", new Object[]{Arrays.toString(reqs)});
            return null;
        }
    }

    /* package */ boolean resolve(final Resolver resolver, Resource resource) {
        resolver.add(resource);
        boolean resolved = resolver.resolve();
        logger.logp(Level.INFO, "ObrHandler", "resolve", "At the end of first pass, resolver outcome is \n: {0}",
                new Object[]{getResolverOutput(resolver)});
        // The following code is not enough to deploy fragments, so it is commented out.
//        if (resolved && deployFragments) {
//            // Take a copy of required and optional resources before adding them to resolver, otherwise
//            // resolver.getRequiredResources() or resolver.getOptionalResources() throws IllegalStateException
//            // as its m_resolved flag changes to false when we add a resource to it.
//            final Resource[] requiredResources = resolver.getRequiredResources();
//            final Resource[] optionalResources = resolver.getOptionalResources();
//
//            for (Resource r: requiredResources) {
//                resolver.add(r);
//            }
//            for (Resource r: optionalResources) {
//                resolver.add(r);
//            }
//            resolved = resolver.resolve();
//            logger.logp(Level.INFO, "ObrHandler", "resolve", "At the end of second pass, resolver outcome is \n: {0}",
//                    new Object[]{getResolverOutput(resolver)});
//        }
        return resolved;
    }

    /* package */
    synchronized Bundle deploy(String name, String version) {
        Resource resource = findResource(name, version);
        if (resource == null) {
            logger.logp(Level.INFO, "ObrHandler", "deploy",
                    "No resource matching name = {0} and version = {1} ", new Object[]{name, version});
            return null;
        }
        if (resource.isLocal()) {
            return getBundle(resource);
        }
        return deploy(resource);
    }

    private Bundle getBundle(Resource resource) {
        for (Bundle b : context.getBundles()) {
            final String bsn = b.getSymbolicName();
            final Version bv = b.getVersion();
            final String rsn = resource.getSymbolicName();
            final Version rv = resource.getVersion();
            boolean versionMatching = (rv == bv) || (rv != null && rv.equals(bv));
            boolean nameMatching = (bsn == rsn) || (bsn != null && bsn.equals(rsn));
            if (nameMatching && versionMatching) return b;
        }
        return null;
    }

    private Resource findResource(String name, String version) {
        final RepositoryAdmin repositoryAdmin = getRepositoryAdmin();
        if (repositoryAdmin == null) {
            logger.logp(Level.WARNING, "ObrHandler", "findResource",
                    "OBR is not yet available, so can't find resource with name = {0} and version = {1} from repository",
                    new Object[]{name, version});
            return null;
        }
        String s1 = "(symbolicname=" + name + ")";
        String s2 = "(version=" + version + ")";
        String query = (version != null) ? "(&" + s1 + s2 + ")" : s1;
        try {
            Resource[] resources = discoverResources(query);
            logger.logp(Level.INFO, "ObrHandler", "findResource",
                    "Using the first one from the list of {0} discovered bundles shown below: {1}",
                    new Object[]{resources.length, Arrays.toString(resources)});
            return resources.length > 0 ? resources[0] : null;
        } catch (InvalidSyntaxException e) {
            throw new RuntimeException(e); // TODO(Sahoo): Proper Exception Handling
        }
    }

    private Resource[] discoverResources(String filterExpr) throws InvalidSyntaxException {
        // TODO(Sahoo): File a bug against Obr to add a suitable method to Repository interface.
        // We can't use the following method, because we can't rely on the RepositoryAdmin to have the correct
        // list of repositories. So, we do the discovery ourselves.
        // return getRepositoryAdmin().discoverResources(query);
        Filter filter = filterExpr != null ? getRepositoryAdmin().getHelper().filter(filterExpr) : null;
        Resource[] resources;
        Repository[] repos = getRepositories();
        List<Resource> matchList = new ArrayList<Resource>();
        for (int repoIdx = 0; (repos != null) && (repoIdx < repos.length); repoIdx++) {
            resources = repos[repoIdx].getResources();
            for (int resIdx = 0; (resources != null) && (resIdx < resources.length); resIdx++) {
                Properties dict = new Properties();
                dict.putAll(resources[resIdx].getProperties());
                if (filter == null || filter.match(dict)) {
                    matchList.add(resources[resIdx]);
                }
            }
        }

        return matchList.toArray(new Resource[matchList.size()]);
    }

    private void printResolverOutput(Resolver resolver) {
        StringBuffer sb = getResolverOutput(resolver);
        logger.logp(Level.INFO, "ObrHandler", "printResolverOutput", "OBR resolver state: {0}", new Object[]{sb});
    }

    private StringBuffer getResolverOutput(Resolver resolver) {
        Resource[] addedResources = resolver.getAddedResources();
        Resource[] requiredResources = resolver.getRequiredResources();
        Resource[] optionalResources = resolver.getOptionalResources();
        Reason[] unsatisfiedRequirements = resolver.getUnsatisfiedRequirements();
        StringBuffer sb = new StringBuffer("Added resources: [");
        for (Resource r : addedResources) {
            sb.append("\n").append(r.getSymbolicName()).append(", ").append(r.getVersion()).append(", ").append(r.getURI());
        }
        sb.append("]\nRequired Resources: [");
        for (Resource r : requiredResources) {
            sb.append("\n").append(r.getURI());
        }
        String optionalRequirementsDeployed = deployOptionalRequirements ? "deployed" : "not deployed";
        sb.append("]\nOptional resources (" + optionalRequirementsDeployed + "): [");
        for (Resource r : optionalResources) {
            sb.append("\n").append(r.getURI());
        }
        sb.append("]\nUnsatisfied requirements: [");
        for (Reason r : unsatisfiedRequirements) {
            sb.append("\n").append(r.getRequirement());
        }
        sb.append("]");
        return sb;
    }

    private Repository[] getRepositories() {
        return repositories.toArray(new Repository[repositories.size()]);
    }
}
