/*******************************************************************************
 * Copyright (c) 2010, 2015 Sonatype, Inc.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *    Stuart McCulloch (Sonatype, Inc.) - initial API and implementation
 *******************************************************************************/
package org.eclipse.sisu.space;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.jar.JarFile;
import java.util.jar.JarInputStream;
import java.util.jar.Manifest;

import org.eclipse.sisu.inject.DeferredClass;

/**
 * {@link ClassSpace} backed by a strongly-referenced {@link ClassLoader} and a {@link URL} class path.
 */
public class URLClassSpace
    implements ClassSpace
{
    // ----------------------------------------------------------------------
    // Static initialization
    // ----------------------------------------------------------------------

    static
    {
        ClassLoader systemLoader;
        String classPath;
        try
        {
            systemLoader = ClassLoader.getSystemClassLoader();
            classPath = System.getProperty( "java.class.path", "." );
        }
        catch ( final RuntimeException e )
        {
            systemLoader = null;
            classPath = null;
        }
        catch ( final LinkageError e )
        {
            systemLoader = null;
            classPath = null;
        }
        SYSTEM_LOADER = systemLoader;
        SYSTEM_CLASSPATH = classPath;
    }

    // ----------------------------------------------------------------------
    // Constants
    // ----------------------------------------------------------------------

    private static final String MANIFEST_ENTRY = "META-INF/MANIFEST.MF";

    private static final URL[] NO_URLS = {};

    private static final Enumeration<URL> NO_ENTRIES = Collections.enumeration( Collections.<URL> emptySet() );

    private static final String[] EMPTY_CLASSPATH = {};

    private static final ClassLoader SYSTEM_LOADER;

    private static final String SYSTEM_CLASSPATH;

    // ----------------------------------------------------------------------
    // Implementation fields
    // ----------------------------------------------------------------------

    private final ClassLoader loader;

    private final String pathDetails;

    private URL[] classPath;

    // ----------------------------------------------------------------------
    // Constructors
    // ----------------------------------------------------------------------

    /**
     * Creates a {@link ClassSpace} backed by a {@link ClassLoader} and its default class path.
     * <p>
     * For {@link URLClassLoader}s this is their expanded Class-Path; otherwise it is empty.
     * 
     * @param loader The class loader to use when getting/finding resources
     */
    public URLClassSpace( final ClassLoader loader )
    {
        this.loader = loader;
        pathDetails = null;
        // compute class path on demand
    }

    /**
     * Creates a {@link ClassSpace} backed by a {@link ClassLoader} with a restricted class path.
     * 
     * @param loader The class loader to use when getting resources
     * @param path The class path to use when finding resources
     * @see #getResources(String)
     * @see #findEntries(String, String, boolean)
     */
    public URLClassSpace( final ClassLoader loader, final URL[] path )
    {
        this.loader = loader;
        pathDetails = Arrays.toString( path );
        if ( null != path && path.length > 0 )
        {
            classPath = expandClassPath( path );
        }
        else
        {
            classPath = NO_URLS;
        }
    }

    // ----------------------------------------------------------------------
    // Public methods
    // ----------------------------------------------------------------------

    public final Class<?> loadClass( final String name )
    {
        try
        {
            return loader.loadClass( name );
        }
        catch ( final Exception e )
        {
            throw new TypeNotPresentException( name, e );
        }
        catch ( final LinkageError e )
        {
            throw new TypeNotPresentException( name, e );
        }
    }

    public final DeferredClass<?> deferLoadClass( final String name )
    {
        return new NamedClass<Object>( this, name );
    }

    public final URL getResource( final String name )
    {
        return loader.getResource( name );
    }

    public final Enumeration<URL> getResources( final String name )
    {
        try
        {
            final Enumeration<URL> resources = loader.getResources( name );
            return null != resources ? resources : NO_ENTRIES; // NOSONAR
        }
        catch ( final IOException e )
        {
            return NO_ENTRIES;
        }
    }

    public final Enumeration<URL> findEntries( final String path, final String glob, final boolean recurse )
    {
        // short-circuit finding resources with fixed names from default system class-path
        if ( null != SYSTEM_LOADER && loader == SYSTEM_LOADER && null == pathDetails // NOSONAR
            && !recurse && null != glob && glob.indexOf( '*' ) < 0 )
        {
            return getResources( ResourceEnumeration.normalizeSearchPath( path ) + glob );
        }
        return new ResourceEnumeration( path, glob, recurse, getClassPath() );
    }

    public final URL[] getURLs()
    {
        return getClassPath().clone();
    }

    @Override
    public final int hashCode()
    {
        return loader.hashCode(); // the loader is the primary key
    }

    @Override
    public final boolean equals( final Object rhs )
    {
        if ( this == rhs )
        {
            return true;
        }
        if ( rhs instanceof URLClassSpace )
        {
            return loader.equals( ( (URLClassSpace) rhs ).loader );
        }
        return false;
    }

    @Override
    public final String toString()
    {
        return null == pathDetails ? loader.toString() : loader + "(" + pathDetails + ")";
    }

    // ----------------------------------------------------------------------
    // Implementation methods
    // ----------------------------------------------------------------------

    /**
     * Returns the associated {@link URL} class path; this can either be explicit or implicit.
     */
    private synchronized URL[] getClassPath()
    {
        if ( null == classPath )
        {
            for ( ClassLoader l = loader; l != null; l = l.getParent() )
            {
                if ( l instanceof URLClassLoader )
                {
                    // pick first class loader with non-empty class path
                    final URL[] path = ( (URLClassLoader) l ).getURLs();
                    if ( null != path && path.length > 0 )
                    {
                        classPath = expandClassPath( path );
                        break;
                    }
                }
                else if ( null != SYSTEM_LOADER && l == SYSTEM_LOADER ) // NOSONAR
                {
                    classPath = expandClassPath( getSystemClassPath() );
                    break;
                }
            }
            if ( null == classPath )
            {
                classPath = NO_URLS; // no detectable Class-Path
            }
        }
        return classPath;
    }

    /**
     * Returns the system {@link URL} class path.
     */
    private static URL[] getSystemClassPath()
    {
        final String[] paths = SYSTEM_CLASSPATH.split( File.pathSeparator );

        final URL[] urls = new URL[paths.length];
        for ( int i = 0; i < paths.length; i++ )
        {
            try
            {
                urls[i] = new File( paths[i] ).toURI().toURL();
            }
            catch ( final MalformedURLException e )
            {
                urls[i] = null; // ignore malformed class-path entry
            }
        }
        return urls;
    }

    /**
     * Expands the given {@link URL} class path to include Class-Path entries from local manifests.
     * 
     * @param classPath The URL class path
     * @return Expanded URL class path
     */
    private static URL[] expandClassPath( final URL[] classPath )
    {
        final List<URL> searchPath = new ArrayList<URL>();
        Collections.addAll( searchPath, classPath );

        final List<URL> expandedPath = new ArrayList<URL>();
        final Set<String> visited = new HashSet<String>();

        // search path may grow, so use index not iterator
        for ( int i = 0; i < searchPath.size(); i++ )
        {
            final URL url = normalizeEntry( searchPath.get( i ) );
            if ( null == url || !visited.add( url.toString() ) )
            {
                continue; // already processed
            }
            expandedPath.add( url );
            final String[] classPathEntries;
            try
            {
                classPathEntries = getClassPathEntries( url );
            }
            catch ( final IOException e )
            {
                continue; // missing manifest
            }
            for ( final String entry : classPathEntries )
            {
                try
                {
                    searchPath.add( new URL( url, entry ) );
                }
                catch ( final MalformedURLException e ) // NOPMD
                {
                    // invalid Class-Path entry
                }
            }
        }

        return expandedPath.toArray( new URL[expandedPath.size()] );
    }

    /**
     * Normalizes the given class path entry by removing any extraneous "jar:"..."!/" padding.
     * 
     * @param path The URL to normalize
     * @return Normalized class path entry
     */
    private static URL normalizeEntry( final URL url )
    {
        if ( null != url && "jar".equals( url.getProtocol() ) )
        {
            final String path = url.getPath();
            if ( path.endsWith( "!/" ) )
            {
                try
                {
                    return new URL( path.substring( 0, path.length() - 2 ) );
                }
                catch ( final MalformedURLException e )
                {
                    // this shouldn't happen, hence illegal state
                    throw new IllegalStateException( e.toString() );
                }
            }
        }
        return url;
    }

    /**
     * Looks for Class-Path entries in the given jar or directory; returns empty array if none are found.
     * 
     * @param url The jar or directory to inspect
     * @return Array of Class-Path entries
     */
    private static String[] getClassPathEntries( final URL url )
        throws IOException
    {
        final Manifest manifest;
        if ( url.getPath().endsWith( "/" ) )
        {
            final InputStream in = Streams.open( new URL( url, MANIFEST_ENTRY ) );
            try
            {
                manifest = new Manifest( in );
            }
            finally
            {
                in.close();
            }
        }
        else if ( "file".equals( url.getProtocol() ) )
        {
            final JarFile jf = new JarFile( FileEntryIterator.toFile( url ) );
            try
            {
                manifest = jf.getManifest();
            }
            finally
            {
                jf.close();
            }
        }
        else
        {
            final JarInputStream jin = new JarInputStream( Streams.open( url ) );
            try
            {
                manifest = jin.getManifest();
            }
            finally
            {
                jin.close();
            }
        }
        if ( null != manifest )
        {
            final String classPath = manifest.getMainAttributes().getValue( "Class-Path" );
            if ( null != classPath )
            {
                return classPath.split( " " );
            }
        }
        return EMPTY_CLASSPATH;
    }
}
