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

import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

import org.apache.wicket.util.collections.UrlExternalFormComparator;
import org.apache.wicket.util.file.File;
import org.apache.wicket.util.listener.IChangeListener;
import java.time.Duration;
import org.apache.wicket.util.watch.IModifiable;
import org.apache.wicket.util.watch.IModificationWatcher;
import org.apache.wicket.util.watch.ModificationWatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


/**
 * Custom ClassLoader that reverses the classloader lookups, and that is able to notify a listener
 * when a class file is changed.
 * 
 * @author <a href="mailto:jbq@apache.org">Jean-Baptiste Quenot</a>
 */
public class ReloadingClassLoader extends URLClassLoader
{
	private static final Logger log = LoggerFactory.getLogger(ReloadingClassLoader.class);

	private static final Set<URL> urls = new TreeSet<>(new UrlExternalFormComparator());

	private static final List<String> patterns = new ArrayList<>();

	private IChangeListener<Class<?>> listener;

	private final IModificationWatcher watcher;

	static
	{
		addClassLoaderUrls(ReloadingClassLoader.class.getClassLoader());
		excludePattern("org.apache.wicket.*");
		includePattern("org.apache.wicket.examples.*");
	}

	/**
	 * 
	 * @param name
	 * @return true if class if found, false otherwise
	 */
	protected boolean tryClassHere(String name)
	{
		// don't include classes in the java or javax.servlet package
		if (name != null && (name.startsWith("java.") || name.startsWith("jakarta.servlet")))
		{
			return false;
		}
		// Scan includes, then excludes
		boolean tryHere;

		// If no explicit includes, try here
		if (patterns == null || patterns.size() == 0)
		{
			tryHere = true;
		}
		else
		{
			// See if it matches include patterns
			tryHere = false;
			for (String rawpattern : patterns)
			{
				if (rawpattern.length() <= 1)
				{
					continue;
				}
				// FIXME it seems that only "includes" are handled. "Excludes" are ignored
				boolean isInclude = rawpattern.substring(0, 1).equals("+");
				String pattern = rawpattern.substring(1);
				if (WildcardMatcherHelper.match(pattern, name) != null)
				{
					tryHere = isInclude;
				}
			}
		}

		return tryHere;
	}

	/**
	 * Include a pattern
	 * 
	 * @param pattern
	 *            the pattern to include
	 */
	public static void includePattern(String pattern)
	{
		patterns.add("+" + pattern);
	}

	/**
	 * Exclude a pattern
	 * 
	 * @param pattern
	 *            the pattern to exclude
	 */
	public static void excludePattern(String pattern)
	{
		patterns.add("-" + pattern);
	}

	/**
	 * Returns the list of all configured inclusion or exclusion patterns
	 * 
	 * @return list of patterns as String
	 */
	public static List<String> getPatterns()
	{
		return patterns;
	}

	/**
	 * Add the location of a directory containing class files
	 * 
	 * @param url
	 *            the URL for the directory
	 */
	public static void addLocation(URL url)
	{
		urls.add(url);
	}

	/**
	 * Returns the list of all configured locations of directories containing class files
	 * 
	 * @return list of locations as URL
	 */
	public static Set<URL> getLocations()
	{
		return urls;
	}

	/**
	 * Add all the url locations we can find for the provided class loader
	 * 
	 * @param loader
	 *            class loader
	 */
	private static void addClassLoaderUrls(ClassLoader loader)
	{
		if (loader != null)
		{
			final Enumeration<URL> resources;
			try
			{
				resources = loader.getResources("");
			}
			catch (IOException e)
			{
				throw new RuntimeException(e);
			}
			while (resources.hasMoreElements())
			{
				URL location = resources.nextElement();
				ReloadingClassLoader.addLocation(location);
			}
		}
	}

	/**
	 * Create a new reloading ClassLoader from a list of URLs, and initialize the
	 * ModificationWatcher to detect class file modifications
	 * 
	 * @param parent
	 *            the parent classloader in case the class file cannot be loaded from the above
	 *            locations
	 */
	public ReloadingClassLoader(ClassLoader parent)
	{
		super(new URL[] { }, parent);
		// probably doubles from this class, but just in case
		addClassLoaderUrls(parent);

		for (URL url : urls)
		{
			addURL(url);
		}
		Duration pollFrequency = Duration.ofSeconds(3);
		watcher = new ModificationWatcher(pollFrequency);
	}

	/**
	 * Gets a resource from this <code>ClassLoader</class>.  If the
	 * resource does not exist in this one, we check the parent.
	 * Please note that this is the exact opposite of the
	 * <code>ClassLoader</code> spec. We use it to work around inconsistent class loaders from third
	 * party vendors.
	 * 
	 * @param name
	 *            of resource
	 */
	@Override
	public final URL getResource(final String name)
	{
		URL resource = findResource(name);
		ClassLoader parent = getParent();
		if (resource == null && parent != null)
		{
			resource = parent.getResource(name);
		}

		return resource;
	}

	/**
	 * Loads the class from this <code>ClassLoader</class>.  If the
	 * class does not exist in this one, we check the parent.  Please
	 * note that this is the exact opposite of the
	 * <code>ClassLoader</code> spec. We use it to load the class from the same classloader as
	 * WicketFilter or WicketServlet. When found, the class file is watched for modifications.
	 * 
	 * @param name
	 *            the name of the class
	 * @param resolve
	 *            if <code>true</code> then resolve the class
	 * @return the resulting <code>Class</code> object
	 * @exception ClassNotFoundException
	 *                if the class could not be found
	 */
	@Override
	public final Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
	{
		// First check if it's already loaded
		Class<?> clazz = findLoadedClass(name);

		if (clazz == null)
		{
			final ClassLoader parent = getParent();

			if (tryClassHere(name))
			{
				try
				{
					clazz = findClass(name);
					watchForModifications(clazz);
				}
				catch (ClassNotFoundException cnfe)
				{
					if (parent == null)
					{
						// Propagate exception
						throw cnfe;
					}
				}
			}

			if (clazz == null)
			{
				if (parent == null)
				{
					throw new ClassNotFoundException(name);
				}
				else
				{
					// Will throw a CFNE if not found in parent
					// see http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6500212
					// clazz = parent.loadClass(name);
					clazz = Class.forName(name, false, parent);
				}
			}
		}

		if (resolve)
		{
			resolveClass(clazz);
		}

		return clazz;
	}

	/**
	 * Sets the listener that will be notified when a class changes
	 * 
	 * @param listener
	 *            the listener to notify upon class change
	 */
	public void setListener(IChangeListener<Class<?>> listener)
	{
		this.listener = listener;
	}

	/**
	 * Watch changes of a class file by locating it in the list of location URLs and adding the
	 * corresponding file to the ModificationWatcher
	 * 
	 * @param clz
	 *            the class to watch
	 */
	private void watchForModifications(final Class<?> clz)
	{
		// Watch class in the future
		Iterator<URL> locationsIterator = urls.iterator();
		File clzFile = null;
		while (locationsIterator.hasNext())
		{
			// FIXME only works for directories, but JARs etc could be checked
			// as well
			URL location = locationsIterator.next();
			String clzLocation = location.getFile() + clz.getName().replaceAll("\\.", "/") +
				".class";
			log.debug("clzLocation=" + clzLocation);
			clzFile = new File(clzLocation);
			final File finalClzFile = clzFile;
			if (clzFile.exists())
			{
				log.info("Watching changes of class " + clzFile);
				watcher.add(clzFile, new IChangeListener<IModifiable>()
				{
					@Override
					public void onChange(IModifiable modifiable)
					{
						log.info("Class file " + finalClzFile + " has changed, reloading");
						try
						{
							listener.onChange(clz);
						}
						catch (Exception e)
						{
							log.error("Could not notify listener", e);
							// If an error occurs when the listener is notified,
							// remove the watched object to avoid rethrowing the
							// exception at next check
							// FIXME check if class file has been deleted
							watcher.remove(finalClzFile);
						}
					}
				});
				break;
			}
			else
			{
				log.debug("Class file does not exist: " + clzFile);
			}
		}
		if (clzFile != null && !clzFile.exists())
		{
			log.debug("Could not locate class " + clz.getName());
		}
	}

	/**
	 * Remove the ModificationWatcher from the current reloading class loader
	 */
	public void destroy()
	{
		watcher.destroy();
	}
}
