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

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.JarURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

import org.apache.wicket.Component;
import org.apache.wicket.Page;
import org.apache.wicket.WicketRuntimeException;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.attributes.AjaxCallListener;
import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
import org.apache.wicket.ajax.markup.html.AjaxFallbackLink;
import org.apache.wicket.authorization.UnauthorizedInstantiationException;
import org.apache.wicket.core.util.lang.WicketObjects;
import org.apache.wicket.markup.head.CssHeaderItem;
import org.apache.wicket.markup.head.IHeaderResponse;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.list.ListItem;
import org.apache.wicket.markup.html.list.ListView;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.LoadableDetachableModel;
import org.apache.wicket.request.http.handler.ErrorCodeRequestHandler;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.apache.wicket.request.resource.CssResourceReference;
import org.apache.wicket.util.file.Files;
import org.apache.wicket.util.io.IOUtils;
import org.apache.wicket.util.lang.PackageName;
import org.apache.wicket.util.string.AppendingStringBuffer;
import org.apache.wicket.util.string.StringValue;
import org.apache.wicket.util.string.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.uwyn.jhighlight.renderer.Renderer;
import com.uwyn.jhighlight.renderer.XhtmlRendererFactory;

/**
 * Displays the resources in a packages directory in a browsable format.
 * 
 * @author Martijn Dashorst
 */
public class SourcesPage extends WebPage
{
	private static final Logger log = LoggerFactory.getLogger(SourcesPage.class);

	/**
	 * Model for retrieving the source code from the classpath of a packaged resource.
	 */
	private class SourceModel implements IModel<String>
	{
		/**
		 * Returns the contents of the file loaded from the classpath.
		 * 
		 * @return the contents of the file identified by name
		 */
		@Override
		public String getObject()
		{
			// name contains the name of the selected file
			StringValue sourceParam = getPageParameters().get(SOURCE);
			if (Strings.isEmpty(name) && sourceParam.isEmpty())
			{
				return "";
			}

			String source = null;
			InputStream resourceAsStream = null;
			try
			{
				source = (name != null) ? name : sourceParam.toString();
				resourceAsStream = getPageTargetClass().getResourceAsStream(source);
				if (resourceAsStream == null)
				{
					return "Unable to read the source for " + source;
				}

				int lastDot = source.lastIndexOf('.');
				if (lastDot != -1)
				{
					String type = source.substring(lastDot + 1);
					Renderer renderer = XhtmlRendererFactory.getRenderer(type);
					if (renderer != null)
					{
						ByteArrayOutputStream output = new ByteArrayOutputStream();
						renderer.highlight(source, resourceAsStream, output, "UTF-8", true);
						return output.toString("UTF-8");
					}
				}

				CharSequence escaped = Strings.escapeMarkup(IOUtils.toString(resourceAsStream),
					false, true);
				return Strings.replaceAll(escaped, "\n", "<br />").toString();
			}
			catch (IOException e)
			{
				log.error(
					"Unable to read resource stream for: " + source + "; Page=" + page.toString(),
					e);
				return "";
			}
			finally
			{
				IOUtils.closeQuietly(resourceAsStream);
			}
		}
	}

	/**
	 * Model for retrieving the contents of a package directory from the class path.
	 */
	private class PackagedResourcesModel extends LoadableDetachableModel<List<String>>
	{
		/**
		 * Returns the list of resources found in the package of the page.
		 * 
		 * @return the list of resources found in the package of the page.
		 */
		@Override
		protected List<String> load()
		{
			return get(getPageTargetClass());
		}

		private void addResources(final AppendingStringBuffer relativePath, final File dir,
			List<String> resources)
		{
			File[] files = Files.list(dir);
			for (File file : files)
			{
				if (file.isDirectory())
				{
					addResources(new AppendingStringBuffer(relativePath).append(file.getName())
						.append('/'), file, resources);
				}
				else
				{
					String name = file.getName();
					if (!name.endsWith("class"))
					{
						resources.add(relativePath + name);
					}
				}
			}
		}

		private List<String> get(Class<?> scope)
		{
			List<String> resources = new ArrayList<>();

			String packageRef = Strings.replaceAll(PackageName.forClass(scope).getName(), ".", "/")
				.toString();
			ClassLoader loader = scope.getClassLoader();
			try
			{
				// loop through the resources of the package
				Enumeration<URL> packageResources = loader.getResources(packageRef);
				while (packageResources.hasMoreElements())
				{
					URL resource = packageResources.nextElement();
					URLConnection connection = resource.openConnection();
					if (connection instanceof JarURLConnection)
					{
						JarFile jf = ((JarURLConnection)connection).getJarFile();
						scanJarFile(packageRef, jf, resources);
					}
					else
					{
						String absolutePath = scope.getResource("").toExternalForm();
						File basedir;
						URI uri;
						try
						{
							uri = new URI(absolutePath);
						}
						catch (URISyntaxException e)
						{
							throw new RuntimeException(e);
						}
						try
						{
							basedir = new File(uri);
						}
						catch (IllegalArgumentException e)
						{
							log.debug("Can't construct the uri as a file: " + absolutePath);
							// if this is throwen then the path is not really a
							// file. but could be a zip.
							String jarZipPart = uri.getSchemeSpecificPart();
							// lowercased for testing if jar/zip, but leave the
							// real filespec unchanged
							String lowerJarZipPart = jarZipPart.toLowerCase(Locale.ROOT);
							int index = lowerJarZipPart.indexOf(".zip");
							if (index == -1)
								index = lowerJarZipPart.indexOf(".jar");
							if (index == -1)
								throw e;

							String filename = jarZipPart.substring(0, index + 4); // 4 =
							// len
							// of
							// ".jar"
							// or
							// ".zip"
							log.debug("trying the filename: " + filename + " to load as a zip/jar.");
							JarFile jarFile = new JarFile(filename, false);
							scanJarFile(packageRef, jarFile, resources);
							return resources;
						}
						if (!basedir.isDirectory())
						{
							throw new IllegalStateException(
								"unable to read resources from directory " + basedir);
						}
						addResources(new AppendingStringBuffer(), basedir, resources);
					}
				}
			}
			catch (IOException e)
			{
				throw new WicketRuntimeException(e);
			}
			Collections.sort(resources);

			return resources;
		}

		private void scanJarFile(String packageRef, JarFile jf, List<String> resources)
		{
			Enumeration<JarEntry> enumeration = jf.entries();
			while (enumeration.hasMoreElements())
			{
				JarEntry je = enumeration.nextElement();
				String name = je.getName();
				if (name.startsWith(packageRef))
				{
					name = name.substring(packageRef.length() + 1);
					if (!name.endsWith("class"))
					{
						resources.add(name);
					}
				}
			}
		}
	}

	/**
	 * Displays the resources embedded in a package in a list.
	 */
	private class FilesBrowser extends WebMarkupContainer
	{
		/**
		 * Constructor.
		 * 
		 * @param id
		 *            the component identifier
		 */
		private FilesBrowser(String id)
		{
			super(id);
			ListView<String> lv = new ListView<String>("file", new PackagedResourcesModel())
			{
				@Override
				protected void populateItem(final ListItem<String> item)
				{
					AjaxFallbackLink<String> link = new AjaxFallbackLink<String>("link",
						item.getModel())
					{
						@Override
						public void onClick(Optional<AjaxRequestTarget> targetOptional)
						{
							setName(getDefaultModelObjectAsString());

							targetOptional.ifPresent(target -> {
								target.add(codePanel);
								target.add(filename);
							});
						}

						@Override
						protected CharSequence getURL()
						{
							return urlFor(SourcesPage.class, SourcesPage.generatePageParameters(
								getPageTargetClass(), item.getModel().getObject()));
						}

						@Override
						protected void updateAjaxAttributes(AjaxRequestAttributes attributes)
						{
							super.updateAjaxAttributes(attributes);
							AjaxCallListener ajaxCallListener = new AjaxCallListener()
							{
								@Override
								public CharSequence getFailureHandler(Component component)
								{
									return "window.location=this.href;";
								}
							};
							attributes.getAjaxCallListeners().add(ajaxCallListener);
							attributes.getExtraParameters().put(PAGE_CLASS, "1");
						}
					};
					link.add(new Label("name", item.getDefaultModelObjectAsString()));
					item.add(link);
				}
			};
			add(lv);
		}
	}

	/**
	 * Container for displaying the source of the selected page, resource or other element from the
	 * package.
	 */
	private class CodePanel extends WebMarkupContainer
	{
		/**
		 * Constructor.
		 * 
		 * @param id
		 *            the component id
		 */
		private CodePanel(String id)
		{
			super(id);
			Label code = new Label("code", new SourceModel());
			code.setEscapeModelStrings(false);
			code.setOutputMarkupId(true);
			add(code);
		}
	}

	/**
	 * Parameter key for identifying the page class. UUID generated.
	 */
	public static final String PAGE_CLASS = SourcesPage.class.getSimpleName() + "_class";

	/**
	 * Parameter key for identify the name of the source file in the package.
	 */
	public static final String SOURCE = "source";

	/**
	 * The selected name of the packaged resource to display.
	 */
	private String name;

	private transient Class<? extends Page> page;

	/**
	 * The panel for setting the ajax calls.
	 */
	private final Component codePanel;

	private final Label filename;

	/**
	 * Sets the name.
	 * 
	 * @param name
	 *            the name to set.
	 */
	public void setName(String name)
	{
		this.name = name;
	}

	/**
	 * Gets the name.
	 * 
	 * @return the name.
	 */
	public String getName()
	{
		return name;
	}


	/**
	 * 
	 * Construct.
	 * 
	 * @param params
	 */
	public SourcesPage(final PageParameters params)
	{
		super(params);

		filename = new Label("filename", () -> name != null ? name : params.get(SOURCE).toOptionalString());
		filename.setOutputMarkupId(true);
		add(filename);
		codePanel = new CodePanel("codepanel").setOutputMarkupId(true);
		add(codePanel);
		add(new FilesBrowser("filespanel"));
	}

	/**
	 * 
	 * @param page
	 * @return PageParameters for reconstructing the bookmarkable page.
	 */
	public static PageParameters generatePageParameters(Page page)
	{
		return generatePageParameters(page.getClass(), null);
	}

	/**
	 * 
	 * @param clazz
	 * @param fileName
	 * @return PageParameters for reconstructing the bookmarkable page.
	 */
	public static PageParameters generatePageParameters(Class<? extends Page> clazz, String fileName)
	{
		PageParameters p = new PageParameters();
		p.set(PAGE_CLASS, clazz.getName());
		if (fileName != null)
		{
			p.set(SOURCE, fileName);
		}
		return p;
	}

	private Class<? extends Page> getPageTargetClass()
	{
		if (page == null)
		{
			String pageParam = getPageParameters().get(PAGE_CLASS).toOptionalString();
			if (pageParam == null)
			{
				log.error("key: {} is null.", PAGE_CLASS);
				getRequestCycle().replaceAllRequestHandlers(
					new ErrorCodeRequestHandler(404,
						"Could not find sources for the page you requested"));
			}
			else if (!pageParam.startsWith("org.apache.wicket.examples"))
			{
				log.error("user is trying to access class: {} which is not in the scope of org.apache.wicket.examples",
						pageParam);
				throw new UnauthorizedInstantiationException(getClass());
			}
			page = WicketObjects.resolveClass(pageParam);

			if (page == null)
			{
				getRequestCycle().replaceAllRequestHandlers(
					new ErrorCodeRequestHandler(404,
						"Could not find sources for the page you requested"));
			}
		}
		return page;
	}

	@Override
	public void renderHead(IHeaderResponse response)
	{
		super.renderHead(response);
		response.render(
			CssHeaderItem.forReference(new CssResourceReference(SourcesPage.class, "style.css")));
	}
}
