/*
 * Maven Launch4j Plugin
 * Copyright (c) 2006 Paul Jungwirth
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
 */
package com.akathist.maven.plugins.launch4j;

import net.sf.launch4j.Builder;
import net.sf.launch4j.BuilderException;
import net.sf.launch4j.config.Config;
import net.sf.launch4j.config.ConfigPersister;

import org.apache.maven.plugin.*;
import org.apache.maven.plugin.logging.*;
import org.apache.maven.plugin.descriptor.PluginDescriptor;
import org.apache.maven.project.MavenProject;
import org.apache.maven.artifact.Artifact;
import org.apache.maven.artifact.factory.ArtifactFactory;
import org.apache.maven.artifact.resolver.ArtifactResolver;
import org.apache.maven.artifact.resolver.ArtifactNotFoundException;
import org.apache.maven.artifact.resolver.ArtifactResolutionException;
import org.apache.maven.artifact.repository.ArtifactRepository;
import org.apache.maven.model.Plugin;

import org.codehaus.plexus.archiver.UnArchiver;
import org.codehaus.plexus.archiver.ArchiverException;
import org.codehaus.plexus.archiver.manager.ArchiverManager;
import org.codehaus.plexus.archiver.manager.NoSuchArchiverException;

import java.util.*;
import java.io.*;

/**
 * Wraps a jar in a Windows executable.
 *
 * @goal launch4j
 * @phase package
 * @requiresDependencyResolution compile
 */
public class Launch4jMojo extends AbstractMojo {

	/**
	 * The dependencies required by the project.
	 *
	 * @parameter default-value="${project.artifacts}"
	 * @required
	 * @readonly
	 */
	private Set dependencies;
	
	/**
	 * The user's current project.
	 *
	 * @parameter default-value="${project}"
	 * @required
	 * @readonly
	 */
	private MavenProject project;

	/**
	 * The user's plugins (including, I hope, this one).
	 *
	 * @parameter default-value="${project.build.plugins}"
	 * @required
	 * @readonly
	 */
	private List plugins;

	/**
	 * Used to unjar the platform-specific workdir.
	 *
	 * @parameter expression="${component.org.codehaus.plexus.archiver.manager.ArchiverManager}"
	 * @required
	 * @readonly
	 */
	private ArchiverManager archiverManager;

	/**
	 * Used to look up Artifacts in the remote repository.
	 *
	 * @parameter expression="${component.org.apache.maven.artifact.factory.ArtifactFactory}"
	 * @required
	 * @readonly
	 */
	private ArtifactFactory factory;

	/**
	 * The user's local repository
	 *
	 * @parameter default-value="${localRepository}"
	 * @required
	 * @readonly
	 */
	private ArtifactRepository localRepository;

	/**
	 * The artifact resolver used to grab the binary bits that launch4j needs.
	 *
	 * @component
	 */
	private ArtifactResolver resolver;

	/**
	 * The base of the current project.
	 *
	 * @parameter default-value="${basedir}"
	 * @required
	 * @readonly
	 */
	private File basedir;

	/**
	 * Whether you want a gui or console app.
	 * Valid values are "gui" and "console."
	 * If you say gui, then launch4j will run your app from javaw instead of java
	 * in order to avoid opening a DOS window.
	 * Choosing gui also enables other options like taskbar icon and a splash screen.
	 *
	 * @parameter
	 * @required
	 */
	private String headerType;

	/**
	 * The name of the executable you want launch4j to produce.
	 * The path, if relative, is relative to the pom.xml.
	 *
	 * @parameter default-value="${project.build.directory}/${project.artifactId}.exe"
	 */
	private File outfile;

	/**
	 * The jar to bundle inside the executable.
	 * The path, if relative, is relative to the pom.xml.
	 * <p>
	 * If you don't want to wrap the jar, then this value should be the runtime path
	 * to the jar relative to the executable. You should also set dontWrapJar to true.
	 * <p>
	 * You can only bundle a single jar. Therefore, you should either create a jar that contains
	 * your own code plus all your dependencies, or you should distribute your dependencies alongside
	 * the executable.
	 *
	 * @parameter default-value="${project.build.directory}/${project.build.finalName}.jar"
	 */
	private String jar;

	/**
	 * Whether the executable should wrap the jar or not.
	 *
	 * @parameter default-value=false
	 */
	private boolean dontWrapJar;

	/**
	 * The title of the error popup if something goes wrong trying to run your program,
	 * like if java can't be found. If this is a console app and not a gui, then this value
	 * is used to prefix any error messages, as in ${errTitle}: ${errorMessage}.
	 *
	 * @parameter
	 */
	private String errTitle;

	/**
	 * Constant command line arguments to pass to your program's main method.
	 * Actual command line arguments entered by the user will appear after these.
	 *
	 * @parameter
	 */
	private String cmdLine;

	/**
	 * Changes to the given directory, relative to the executable, before running your jar.
	 * If set to <code>.</code> the current directory will be where the executable is.
	 * If omitted, the directory will not be changed.
	 *
	 * @parameter
	 */
	private String chdir;

	/**
	 * Sets the process name to the executable filename (instead of java) and uses XP-style manifests (if any).
	 * Using this parameter creates a launch4j-tmp directory inside the JRE,
	 * so don't use it if your app won't have permission to do that.
	 *
	 * @parameter default-value=false
	 */
	private boolean customProcName;

	/**
	 * If true, the executable waits for the java application to finish before returning its exit code.
	 * Defaults to false for gui applications. Has no effect for console applications, which always wait.
	 *
	 * @parameter default-value=false
	 */
	private boolean stayAlive;

	/**
	 * The icon to use in the taskbar. Must be in ico format.
	 *
	 * @parameter
	 */
	private File icon;

	/**
	 * Object files to include. Used for custom headers only.
	 *
	 * @parameter
	 */
	private List objs;

	/**
	 * Win32 libraries to include. Used for custom headers only.
	 *
	 * @parameter
	 */
	private List libs;

	/**
	 * Variables to set.
	 *
	 * @parameter
	 */
	private List vars;

	/**
	 * Details about the supported jres.
	 *
	 * @parameter
	 * @required
	 */
	private Jre jre;

	/**
	 * Details about the classpath your application should have.
	 * This is required if you are not wrapping a jar.
	 *
	 * @parameter
	 */
	private ClassPath classPath;

	/**
	 * Details about the splash screen.
	 *
	 * @parameter
	 */
	private Splash splash;
	
	/**
	 * Lots of information you can attach to the windows process.
	 *
	 * @parameter
	 */
	private VersionInfo versionInfo;

	private File getJar() {
		File f = new File(jar);
		if (dontWrapJar || f.isAbsolute()) return f;
		else return new File(getBaseDir(), jar);
	}

	public void execute() throws MojoExecutionException {
		if (getLog().isDebugEnabled()) printState();

		Config c = new Config();

		c.setHeaderType(headerType);
		c.setOutfile(outfile);
		c.setJar(getJar());
		c.setDontWrapJar(dontWrapJar);
		c.setErrTitle(errTitle);
		c.setCmdLine(cmdLine);
		c.setChdir(chdir);
		c.setCustomProcName(customProcName);
		c.setStayAlive(stayAlive);
		c.setIcon(icon);
		c.setHeaderObjects(objs);
		c.setLibs(libs);
		c.setVariables(vars);

		if (classPath != null) {
			c.setClassPath(classPath.toL4j(dependencies));
		}
		if (jre != null) {
			c.setJre(jre.toL4j());
		}
		if (splash != null) {
			c.setSplash(splash.toL4j());
		}
		if (versionInfo != null) {
			c.setVersionInfo(versionInfo.toL4j());
		}

		ConfigPersister.getInstance().setAntConfig(c, getBaseDir());
		Builder b = new Builder(new MavenLog(getLog()));

		File workdir = setupBuildEnvironment();
		b.setBasedir(workdir);

		try {
			b.build();
		} catch (BuilderException e) {
			getLog().error(e);
			throw new MojoExecutionException("Failed to build the executable; please verify your configuration.", e);
		}
	}

	/**
	 * Prepares a little directory for launch4j to do its thing. Launch4j needs a bunch of object files
	 * (in the w32api and head directories) and the ld and windres binaries (in the bin directory).
	 * The tricky part is that launch4j picks this directory based on where its own jar is sitting.
	 * In our case, the jar is going to be sitting in the user's ~/.m2 repository. That's okay: we know
	 * maven is allowed to write there. So we'll just add our things to that directory.
	 * <p>
	 * This approach is not without flaws.
	 * It risks two processes writing to the directory at the same time.
	 * But fortunately, once the binary bits are in place, we don't do any more writing there,
	 * and launch4j doesn't write there either.
	 * Usually ~/.m2 will only be one system or another.
	 * But if it's an NFS mount shared by several system types, this approach will break.
	 * <p>
	 * Okay, so here is a better proposal: package the plugin without these varying binary files,
	 * and put each set of binaries in its own tarball. Download the tarball you need to ~/.m2 and
	 * unpack it. Then different systems won't contend for the same space. But then I'll need to hack
	 * the l4j code so it permits passing in a work directory and doesn't always base it on
	 * the location of its own jarfile.
	 * 
	 * @return the work directory.
	 */
	private File setupBuildEnvironment() throws MojoExecutionException {
		Artifact binaryBits = chooseBinaryBits();
		retrieveBinaryBits(binaryBits);
		return unpackWorkDir(binaryBits);
	}

	/**
	 * Unzips the given artifact in-place and returns the newly-unzipped top-level directory.
	 * Writes a marker file to prevent unzipping more than once.
	 */
	private File unpackWorkDir(Artifact a) throws MojoExecutionException {
		String version = a.getVersion();
		File platJar = a.getFile();
		File dest = platJar.getParentFile();
		File marker = new File(dest, platJar.getName() + ".unpacked");

		// If the artifact is a SNAPSHOT, then a.getVersion() will report the long timestamp,
		// but getFile() will be 1.1-SNAPSHOT.
		// Since getFile() doesn't use the timestamp, all timestamps wind up in the same place.
		// Therefore we need to expand the jar every time, if the marker file is stale.
		if (marker.exists() && marker.lastModified() > platJar.lastModified()) {
		// if (marker.exists() && marker.platJar.getName().indexOf("SNAPSHOT") == -1) {
			getLog().info("Platform-specific work directory already exists: " + dest.getAbsolutePath());
		} else {
			try {
				UnArchiver un = archiverManager.getUnArchiver(platJar);
				un.setSourceFile(platJar);
				un.setDestDirectory(dest);
				un.extract();
			} catch (NoSuchArchiverException e) {
				getLog().error(e);
				throw new MojoExecutionException("Don't know how to unarchive files like " + platJar, e);
			} catch (IOException e) {
				throw new MojoExecutionException("IO Error unarchiving " + platJar, e);
			} catch (ArchiverException e) {
				throw new MojoExecutionException("Error unarchiving " + platJar, e);
			}

			try {
				marker.createNewFile();
				marker.setLastModified(new Date().getTime());
			} catch (IOException e) {
				getLog().warn("Trouble creating marker file " + marker, e);
			}
		}

		String n = platJar.getName();
		File workdir = new File(dest, n.substring(0, n.length() - 4));
		setPermissions(workdir);
		return workdir;
	}

	/**
	 * Chmods the helper executables ld and windres on systems where that is necessary.
	 */
	private void setPermissions(File workdir) throws MojoExecutionException {
		if ( ! System.getProperty("os.name").startsWith("Windows")) {
			Runtime r = Runtime.getRuntime();
			try {
				r.exec("chmod 755 " + workdir + "/bin/ld").waitFor();
				r.exec("chmod 755 " + workdir + "/bin/windres").waitFor();
			} catch (InterruptedException e) {
				getLog().warn("Interrupted while chmodding platform-specific binaries", e);
			} catch (IOException e) {
				getLog().warn("Unable to set platform-specific binaries to 775", e);
			}
		}
	}

	/**
	 * Downloads the platform-specific parts, if necessary.
	 */
	private void retrieveBinaryBits(Artifact a) throws MojoExecutionException {
		try {
			resolver.resolve(a, project.getRemoteArtifactRepositories(), localRepository);

		} catch (ArtifactNotFoundException e) {
			throw new MojoExecutionException("Can't find platform-specific components", e);
		} catch (ArtifactResolutionException e) {
			throw new MojoExecutionException("Can't retrieve platform-specific components", e);
		}
	}

	/**
	 * Decides which platform-specific bundle we need, based on the current operating system.
	 */
	private Artifact chooseBinaryBits() throws MojoExecutionException {
		String plat;
		String os = System.getProperty("os.name");
		getLog().debug("OS = " + os);

		// See here for possible values of os.name:
		// http://lopica.sourceforge.net/os.html
		if (os.startsWith("Windows")) {
			plat = "win32";
		} else if ("Linux".equals(os)) {
			plat = "linux";
		} else if ("Solaris".equals(os) || "SunOS".equals(os)) {
			plat = "solaris";
		} else if ("Mac OS X".equals(os)) {
			plat = "mac";
		} else {
			throw new MojoExecutionException("Sorry, Launch4j doesn't support the '" + os + "' OS.");
		}

		return factory.createArtifactWithClassifier("org.bluestemsoftware.open.maven.plugin", "launch4j-plugin",
				getMyVersion(), "jar", "workdir-" + plat);
	}

	/**
	 * All this work just to get the version of the current plugin!
	 * We want to download the platform-specific bundle whose version matches the plugin's version,
	 * so we have to figure out what version we are.
	 */
	private String getMyVersion() throws MojoExecutionException {
		/*
		getLog().info("version = " + plugin.getVersion());
		return plugin.getVersion();	// plugin was set by ${plugin}, but it doesn't work: getVersion returns null!
		*/
		Log log = getLog();
		log.debug("searching for launch4j plugin");
		Iterator i = plugins.iterator();
		while (i.hasNext()) {
			Plugin p = (Plugin)i.next();
			if (log.isDebugEnabled()) log.debug(p.getGroupId() + " ## " + p.getArtifactId() + " ## " + p.getVersion());
			if ("launch4j-plugin".equals(p.getArtifactId()) &&
					"org.bluestemsoftware.open.maven.plugin".equals(p.getGroupId())) {
				String v = p.getVersion();
				log.debug("Found launch4j version " + v);
				return v;
			}
		}
		throw new MojoExecutionException("Launch4j isn't among this project's plugins. How can that be?");
	}

	private File getBaseDir() {
		return basedir;
	}

	/**
	 * Just prints out how we were configured.
	 */
	private void printState() {
		Log log = getLog();

		log.debug("headerType = " + headerType);
		log.debug("outfile = " + outfile);
		log.debug("jar = " + jar);
		log.debug("dontWrapJar = " + dontWrapJar);
		log.debug("errTitle = " + errTitle);
		log.debug("cmdLine = " + cmdLine);
		log.debug("chdir = " + chdir);
		log.debug("customProcName = " + customProcName);
		log.debug("stayAlive = " + stayAlive);
		log.debug("icon = " + icon);
		log.debug("objs = " + objs);
		log.debug("libs = " + libs);
		log.debug("vars = " + vars);
		if (jre != null) {
			log.debug("jre.path = " + jre.path);
			log.debug("jre.minVersion = " + jre.minVersion);
			log.debug("jre.maxVersion = " + jre.maxVersion);
			log.debug("jre.dontUsePrivateJres = " + jre.dontUsePrivateJres);
			log.debug("jre.initialHeapSize = " + jre.initialHeapSize);
			log.debug("jre.maxHeapSize = " + jre.maxHeapSize);
			log.debug("jre.opts = "+ jre.opts);
		} else {
			log.debug("jre = null");
		}
		if (classPath != null) {
			log.debug("classPath.mainClass = " + classPath.mainClass);
			log.debug("classPath.addDependencies = " + classPath.addDependencies);
			log.debug("classPath.jarLocation = " + classPath.jarLocation);
			log.debug("classPath.preCp = " + classPath.preCp);
			log.debug("classPath.postCp = " + classPath.postCp);
		} else {
			log.info("classpath = null");
		}
		if (splash != null) {
			log.debug("splash.file = " + splash.file);
			log.debug("splash.waitForWindow = " + splash.waitForWindow);
			log.debug("splash.timeout = " + splash.timeout);
			log.debug("splash.timoutErr = " + splash.timeoutErr);
		} else {
			log.debug("splash = null");
		}
		if (versionInfo != null) {
			log.debug("versionInfo.fileVersion = " + versionInfo.fileVersion);
			log.debug("versionInfo.txtFileVersion = " + versionInfo.txtFileVersion);
			log.debug("versionInfo.fileDescription = " + versionInfo.fileDescription);
			log.debug("versionInfo.copyright = " + versionInfo.copyright);
			log.debug("versionInfo.productVersion = " + versionInfo.productVersion);
			log.debug("versionInfo.txtProductVersion = " + versionInfo.txtProductVersion);
			log.debug("versionInfo.productName = " + versionInfo.productName);
			log.debug("versionInfo.companyName = " + versionInfo.companyName);
			log.debug("versionInfo.internalName = " + versionInfo.internalName);
			log.debug("versionInfo.originalFilename = " + versionInfo.originalFilename);
		} else {
			log.debug("versionInfo = null");
		}
	}

}
