public class ModLoader {

	private final Dao<Mod, Integer> modsDao;
	private final ConfigData config;

	@Inject
	ModLoader(Dao<Mod, Integer> modDao, ConfigData config){
		this.modsDao = modDao;
		this.config = config;
	}

	public Mod get(int id){
		try {
			return modsDao.queryForId(id);
		} catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	public Collection<Mod> getMods() {
		return config.getSelectedInstallation().getMods();
	}

	public Mod getByUrl(URL url) {
		for (Mod mod : getMods()){
			if (ObjectUtils.nullSafeEquals(mod.getUrl(), url)){
				return mod;
			}
		}
		return null;
	}

	///////////////
	// Factories //
	///////////////

	public Mod newLocalMod(Path path) throws SQLException{
		Mod mod = new Mod(null, config.getSelectedInstallation(), modsDao);
		mod.update(new ModUpdateData(path.getFileName().toString(), null, null, null, null));
		return mod;
	}

	public Mod newMod(URL url) throws SQLException {
		Mod mod = getByUrl(url);
		if (mod == null){
			mod = new Mod(url, config.getSelectedInstallation(), modsDao);
		}
		return mod;
	}
} @SuppressWarnings("serial")
public static class ModNotDownloadedException extends Exception {
	public ModNotDownloadedException(Mod mod, String message){
		super("Error for " + mod + ": " + message);
	}
} public class AnalyzeModZipTask extends WorkflowTask {

	private final Mod mod;
	private final Dao<ModFile, Integer> modFilesDao;

	public AnalyzeModZipTask(Mod mod, Dao<ModFile, Integer> modFilesDao) {
		super("Analyzing Mod");
		this.mod = mod;
		this.modFilesDao = modFilesDao;
	}

	@Override
	public boolean execute() throws Exception {
		Collection<ModFile> files = new LinkedList<>();
		String readmeText = null;

		Path zipPath = mod.getZipPath();
		if (zipPath == null){
			throw new FileNotFoundException();
		} else if(!zipPath.toFile().exists()){
			throw new FileNotFoundException(zipPath.toString());
		}

		try(ZipFile zipFile = new ZipFile(zipPath.toFile())){
			Path gameDataPath = null;

			//Make first pass of entries to get key information
			Enumeration<? extends ZipEntry> entries = zipFile.entries();
			for (ZipEntry entry; entries.hasMoreElements(); ){
				entry = entries.nextElement();

				// Find Gamedata path
				if (gameDataPath == null && entry.getName().toLowerCase().contains("gamedata")){

					// Once a candidate has been found, find the exact path to the folder
					Path tempPath = Paths.get(entry.getName());
					while(tempPath != null && gameDataPath == null){
						if (tempPath.getFileName().toString().toLowerCase().equals("gamedata")){
							gameDataPath = tempPath;
						} else {
							tempPath = tempPath.getParent();
						}
					}
				}

				// Find Readme text
				if (readmeText == null && !entry.isDirectory() && entry.getName().toLowerCase().contains("readme")){
					try(StringWriter writer = new StringWriter(); InputStream is = zipFile.getInputStream(entry)){
						IOUtils.copy(is, writer);
						readmeText = writer.toString();
					} catch (IOException e) {}
				}
			}

			// Make second pass of entries to get all Mod Files
			entries = zipFile.entries();
			if (gameDataPath == null){
				// If no gameDataPath, get all files with a path length of at least 2.
				// This is because, we only get files which are within folders in the root of the zip
				for (ZipEntry entry; entries.hasMoreElements(); ){
					entry = entries.nextElement();
					Path entryPath = Paths.get(entry.getName());
					if (!entry.isDirectory() && entryPath.getNameCount() >= 2){
						files.add(new ModFile(mod, entry.getName(), entryPath, modFilesDao));
					}
				}
			} else {
				// Get all files within the GameData directory
				for (ZipEntry entry; entries.hasMoreElements(); ){
					entry = entries.nextElement();
					Path entryPath = Paths.get(entry.getName());
					if (
							!entry.isDirectory() &&
							entryPath.startsWith(gameDataPath) && !entryPath.equals(gameDataPath) &&
							!(entry.getName().contains("ModuleManager") && entry.getName().endsWith(".dll"))
							){
						files.add(new ModFile(mod, entry.getName(), gameDataPath.relativize(entryPath), modFilesDao));
					}
				}
			}

			// If no files found, make third pass to get DLLs in root
			if (files.isEmpty()){
				entries = zipFile.entries();
				for (ZipEntry entry; entries.hasMoreElements(); ){
					entry = entries.nextElement();
					if (entry.getName().endsWith(".dll")){
						Path entryPath = Paths.get(entry.getName());
						Path path = gameDataPath == null ? entryPath : gameDataPath.relativize(entryPath);
						files.add(new ModFile(mod, entry.getName(), path, modFilesDao));
					}
				}
			}
		}

		mod.setModFiles(files);
		mod.setReadmeText(readmeText);
		return true;
	}

	@Override
	protected int findTargetProgress() throws IOException {
		return 0;
	}
} public class CheckForUpdateTask extends WorkflowTask {

	private final Crawler<?> crawler;
	private final Mod mod;
	private final boolean markIfAvailable;

	public CheckForUpdateTask(Crawler<?> crawler, Mod mod, boolean markIfAvailable) {
		super("Comparing Versions");
		this.crawler = crawler;
		this.mod = mod;
		this.markIfAvailable = markIfAvailable;
	}

	@Override
	public boolean execute() throws IOException, SQLException {
		boolean updateAvailable = crawler.isUpdateAvailable(mod);
		if (markIfAvailable && updateAvailable){
			mod.setUpdateAvailable(updateAvailable);
			mod.commit();
		}

		return updateAvailable;
	}

	@Override
	protected int findTargetProgress() throws IOException {
		return -1;
	}
} public class DownloadModImageTask extends FileTransferTask {

	private final Crawler<?> crawler;
	private final Mod mod;
	private final ImageManager imageManager;

	private URLConnection connection;

	public DownloadModImageTask(Crawler<?> crawler, Mod mod) {
		super(null, null);
		this.crawler = crawler;
		this.mod = mod;
		imageManager = new ImageManager();
	}

	@Override
	public boolean execute() throws Exception {
		if (crawler.getImageUrl() != null){
			try (
					InputStream is = getConnection().getInputStream();
					ByteArrayOutputStream os = new ByteArrayOutputStream();
					){
				transfer(is, os);
				BufferedImage img = ImageIO.read(new ByteArrayInputStream(os.toByteArray()));
				if (img != null){
					img = imageManager.resizeImage(img, imageManager.scaleToFit(img, Mod.MAX_IMAGE_SIZE));
					mod.setImage(img);
				}
			}
		}
		return true;
	}

	private synchronized URLConnection getConnection() throws IOException{
		if (connection == null){
			try {
				connection = crawler.getImageUrl().openConnection();
			} catch (NullPointerException e){
				throw new IOException(e);
			}
		}
		return connection;
	}

	@Override
	protected int findTargetProgress() throws IOException {
		return getConnection().getContentLength();
	}

} public class DownloadModInBrowserTask extends WorkflowTask {

	private final Crawler<?> crawler;
	private final Mod mod;

	public DownloadModInBrowserTask(Crawler<?> crawler, Mod mod) {
		super("Downloading Mod in Browser");
		this.crawler = crawler;
		this.mod = mod;
	}

	@Override
	public boolean execute() throws Exception {
		ModUpdateData data = crawler.getModUpdateData();

		if (JOptionPane.showConfirmDialog(
				null,
				String.format(
						"%s v%s is available.%n" +
								"Would you like to download it?%n" +
								"%n" +
								"You currently have v%s",
								data.name, data.modVersion, mod.getModVersion()
						),
						"Update Tinker Time",
						JOptionPane.YES_NO_OPTION,
						JOptionPane.QUESTION_MESSAGE
				) == JOptionPane.YES_OPTION){
			BrowserGoToTask.callNow(crawler.getDownloadLink());
			return true;
		}
		return false;
	}

	@Override
	protected int findTargetProgress() throws IOException {
		return 0;
	}

} public class DownloadModZipTask extends FileTransferTask {

	private final Crawler<?> crawler;
	private final Mod mod;

	public DownloadModZipTask(Crawler<?> crawler, Mod mod){
		super(null, null);
		this.crawler = crawler;
		this.mod = mod;
	}

	private URL getUrl() throws IOException{
		return crawler.getDownloadLink();
	}

	@Override
	public boolean execute() throws Exception {
		Path dest = mod.getZipPath();
		Path tempDest = Paths.get(dest.toString() + ".tempDownload");

		try {
			transfer(getUrl(), tempDest);  // Copy to temp file
			Files.move(tempDest, dest, StandardCopyOption.REPLACE_EXISTING);  // Rename to dest file
		} catch (NullSourceException e){
			// Do Nothing
		}

		return true;
	}

	@Override
	protected int findTargetProgress() throws IOException {
		URL url = getUrl();
		if (url != null){
			return url.openConnection().getContentLength();
		}
		return -1;
	}
} public class RemoveModTask extends WorkflowTask {

	private final Mod mod;

	public RemoveModTask(Mod mod) {
		super("Removing " + mod);
		this.mod = mod;
	}

	@Override
	public boolean execute() throws Exception {
		mod.delete();
		return true;
	}

	@Override
	protected int findTargetProgress() throws IOException {
		return 0;
	}

} public class RunCrawlerTask extends WorkflowTask {

	private final Crawler<?> crawler;

	public RunCrawlerTask(Crawler<?> crawler) {
		super("Crawling Page");
		this.crawler = crawler;
	}

	@Override
	public boolean execute() throws IOException {
		crawler.testConnection();
		return true;
	}

	@Override
	protected int findTargetProgress() throws IOException {
		return -1;
	}
} public class SaveModTask extends WorkflowTask {

	private final Crawler<?> crawler;
	private final Mod mod;

	public SaveModTask(Crawler<?> crawler, Mod mod) {
		super("Saving Mod");
		this.crawler = crawler;
		this.mod = mod;
	}

	@Override
	public boolean execute() throws IOException, SQLException {
		mod.update(crawler.getModUpdateData());
		mod.setUpdateAvailable(false);
		mod.commit();
		return true;
	}

	@Override
	protected int findTargetProgress() throws IOException {
		return 0;
	}
} public abstract class Crawler<T> {

	public static final String ZIP_EXTENSION = ".zip";

	private final URL pageUrl;
	private final PageLoader<T> pageLoader;

	private Asset cachedAsset;
	private AssetSelector assetSelector = new DialogAssetSelector();

	public Crawler(URL pageUrl, PageLoader<T> pageLoader) {
		this.pageUrl = checkNotNull(pageUrl);
		this.pageLoader = checkNotNull(pageLoader);
	}

	/////////
	// Api //
	/////////

	public void setAssetSelector(AssetSelector assetSelector){
		this.assetSelector = assetSelector;
	}

	public ModUpdateData getModUpdateData() throws IOException{
		return new ModUpdateData(
				getName(),
				getCreator(),
				getUpdatedOn(),
				getVersion(),
				getKspVersion()
				);
	}

	public final URL getDownloadLink() throws IOException{
		return getSelectedAsset().downloadLink;
	}

	public void testConnection() throws IOException{
		getPage(getApiUrl());
	}

	public boolean isUpdateAvailable(Mod compareTo){
		try{
			return getVersion().greaterThan(compareTo.getModVersion());
		} catch (NullPointerException e){
			try {
				return getUpdatedOn().before(compareTo.getUpdatedOn());
			} catch (NullPointerException | IOException e1) {
				return false;
			}
		}
	}

	//////////////////////
	// Abstract Methods //
	//////////////////////

	public abstract URL getImageUrl() throws IOException;
	public abstract Date getUpdatedOn() throws IOException;

	protected abstract String getName() throws IOException;
	protected abstract String getCreator() throws IOException;
	protected abstract String getKspVersion() throws IOException;
	protected abstract String getVersionString() throws IOException;
	protected abstract Collection<Asset> getNewestAssets() throws IOException;

	/////////////
	// Helpers //
	/////////////

	protected URL getPageUrl(){
		return pageUrl;
	}

	protected URL getApiUrl() throws MalformedURLException{
		return getPageUrl();
	}

	protected T getPage(URL url) throws IOException {
		return pageLoader.getPage(url);
	}

	private Version getVersion(){
		try {
			// First try to parse version from an available version tag field
			String versionString = VersionParser.parseVersionString(getVersionString());
			return Version.valueOf(versionString);
		} catch(IOException | IllegalArgumentException e){
			try {
				// Alternately, try to parse version from the fileName
				String versionString = VersionParser.parseVersionString(getSelectedAsset().fileName);
				return Version.valueOf(versionString);
			} catch (IOException | IllegalArgumentException e1) {
				return null;
			}
		}
	}

	private Asset getSelectedAsset() throws IOException {
		// If there is no cached selected asset, get it
		if (cachedAsset == null){
			// Get Valid Assets so one can be chosen
			List<Asset> assets = new LinkedList<>();
			for (Asset asset : getNewestAssets()){
				if (asset.fileName.endsWith(ZIP_EXTENSION)){
					assets.add(asset);
				}
			}

			switch(assets.size()){
			case 0:
				// No non-source downloads
				throw new IOException("No releases found for this mod");
			case 1:
				// One asset; use it by default
				cachedAsset = assets.get(0);
				break;
			default:
				// Ask user which asset to use
				cachedAsset = assetSelector.selectAsset(getName(), getNewestAssets());
				break;
			}

			if (cachedAsset == null){
				throw new IOException("You must select a download to use the mod!");
			}
		}

		return cachedAsset;
	}

	// -- Inner Asset Class ---------------------------------------------------

	public static class Asset {
		public final String fileName;
		public final URL downloadLink;

		protected Asset(String fileName, URL downloadLink){
			this.fileName = fileName;
			this.downloadLink = downloadLink;
		}

		@Override
		public String toString(){
			return fileName;
		}
	}

	public static interface AssetSelector {

		public Asset selectAsset(String modName, Collection<Asset> assets);
	}

	static class DialogAssetSelector implements AssetSelector {

		@Override
		public Asset selectAsset(String modName, Collection<Asset> assets) {
			return (Asset) JOptionPane.showInputDialog(null,
					"Which variant of the mod '" + modName + "' should be used?",
					"Multiple Downloads Available",
					JOptionPane.QUESTION_MESSAGE,
					null,
					assets.toArray(),
					assets.iterator().next()
					);
		}
	}
} @Singleton
public class CrawlerFactory {

	public static final String
	HOST_CURSE = "curse.com",
	HOST_GITHUB = "github.com",
	HOST_MODULE_MANAGER = "ksp.sarbian.com",
	HOST_KERBAL_STUFF = "kerbalstuff.com";

	public static final String[] ACCEPTED_MOD_HOSTS	 = new String[]{
		HOST_KERBAL_STUFF, HOST_CURSE, HOST_GITHUB
	};

	private final PageLoader<Document> docLoader;
	private final PageLoader<JsonElement> jsonLoader;

	@Inject
	CrawlerFactory(PageLoader<Document> docLoader, PageLoader<JsonElement> jsonLoader){
		this.docLoader = docLoader;
		this.jsonLoader = jsonLoader;
	}

	public Crawler<?> getCrawler(Mod mod) throws UnsupportedHostException{
		String host = mod.getUrl().getHost();
		if (host.contains(HOST_CURSE)){
			return new CurseCrawler(mod.getUrl(), docLoader);
		} else if (host.contains(HOST_GITHUB)){
			return new GithubCrawler(mod.getUrl(), jsonLoader);
		} else if (host.contains(HOST_KERBAL_STUFF)){
			return new KerbalStuffCrawler(mod.getUrl(), jsonLoader);
		} else if (host.equals(HOST_MODULE_MANAGER)){
			return new JenkinsCrawler(mod.getUrl(), jsonLoader);
		}
		throw new UnsupportedHostException(host);
	}

	@SuppressWarnings("serial")
	public static class UnsupportedHostException extends Exception {

		private UnsupportedHostException(String host){
			super(String.format("Unsupported host: %s", host));
		}
	}
} @SuppressWarnings("serial")
public static class UnsupportedHostException extends Exception {

	private UnsupportedHostException(String host){
		super(String.format("Unsupported host: %s", host));
	}
} @DatabaseTable(tableName = "modFiles")
public class ModFile extends BaseDaoEnabled<ModFile, Integer>{

	@DatabaseField(generatedId=true)
	private int id;

	@DatabaseField(foreign = true, canBeNull=false)
	private Mod mod;

	@DatabaseField(canBeNull=false)
	private String entryName, relDestPath;

	// Required by ormlite
	ModFile() {	}

	public ModFile(Mod mod, String entryName, Path relPath, Dao<ModFile, Integer> dao) throws SQLException{
		this.mod = mod;
		this.entryName = entryName.toString();
		this.relDestPath = relPath.toString();

		if (id == 0 && dao != null){
			setDao(dao);
			create();
		}
	}

	public Path getRelDestPath(){
		return Paths.get(relDestPath);
	}

	public Path getDestPath(){
		return mod.getInstallation().getGameDataPath().resolve(relDestPath);
	}

	public ZipEntry getEntry(ZipFile zipFile){
		return zipFile.getEntry(entryName);
	}
} @DatabaseTable(tableName = "mods")
public class Mod extends BaseDaoEnabled<Mod, Integer> implements Comparable<Mod> {

	public static final Dimension MAX_IMAGE_SIZE = new Dimension(250, 250);

	@DatabaseField(generatedId=true)
	private int id;

	@DatabaseField
	private Date updatedOn;

	@DatabaseField
	private String name, creator, modVersion, kspVersion;

	@DatabaseField(canBeNull = false)
	private String url;

	@DatabaseField
	private boolean updateAvailable = false, builtIn = false;

	@DatabaseField(foreign = true, canBeNull = false)
	private Installation installation;

	@ForeignCollectionField
	private Collection<ModFile> modFiles = new LinkedList<>();
	private Collection<ModFile> cachedModFiles;

	@DatabaseField(dataType = DataType.BYTE_ARRAY)
	private byte[] imageBytes;

	@DatabaseField(dataType = DataType.LONG_STRING)
	private String readmeText = "";

	// Used by ormlite
	Mod() { }

	public Mod(URL url, Installation installation, Dao<Mod, Integer> dao) throws SQLException{
		this(url, installation, false, dao);
	}

	public Mod(URL url, Installation installation, boolean builtIn, Dao<Mod, Integer> dao) throws SQLException{
		this.url = url != null ? url.toString() : null;
		this.installation = installation;
		if (installation != null) installation.addMod(this);
		this.builtIn = builtIn;

		setDao(dao);
	}

	// Used only for updating the TinkerTime App
	public static Mod newModManagerMod(){
		Mod mod = new Mod();
		mod.url = TinkerTimeLauncher.DOWNLOAD_URL;
		mod.modVersion = TinkerTimeLauncher.VERSION.getNormalVersion();
		return mod;
	}

	/////////////
	// Setters //
	/////////////

	public void update(ModUpdateData updateData) throws SQLException{
		this.name = updateData.name;
		this.creator = updateData.creator;
		this.updatedOn = updateData.updatedOn;
		this.kspVersion = updateData.kspVersion;
		this.modVersion = updateData.modVersion != null ? updateData.modVersion.getNormalVersion() : null;
	}

	public void setUpdateAvailable(boolean updateAvailable) throws SQLException{
		this.updateAvailable = updateAvailable;
	}

	public void setModFiles(Collection<ModFile> modFiles) throws SQLException{
		this.modFiles = modFiles;
		this.cachedModFiles = modFiles;
	}

	public void setImage(BufferedImage image) throws IOException, SQLException{
		if (image != null){
			ByteArrayOutputStream baos=new ByteArrayOutputStream();
			ImageIO.write(image, "png", baos);
			this.imageBytes = baos.toByteArray();
		} else {
			this.imageBytes = null;
		}
	}

	public void setReadmeText(String text) throws SQLException {
		this.readmeText = text;
	}

	/////////////
	// Getters //
	/////////////

	public int getId(){
		return id;
	}

	public String getName(){
		if (name == null){
			return (url != null ? getUrl().getHost(): "Local") + " Mod";
		}
		return name;
	}

	public String getCreator(){
		return creator;
	}

	public URL getUrl(){
		try {
			return new URL(url);
		} catch (MalformedURLException e) {
			return null;
		}
	}

	public Date getUpdatedOn(){
		return updatedOn;
	}

	public Version getModVersion(){
		return modVersion != null ? Version.valueOf(modVersion) : null;
	}

	/**
	 * Returns the version of KSP that this mod version supports.
	 * @return supported KSP version
	 */
	public String getSupportedVersion(){
		return kspVersion;
	}

	public boolean isUpdateable(){
		return url != null;
	}

	public boolean isUpdateAvailable(){
		return updateAvailable;
	}

	public Installation getInstallation(){
		return installation;
	}

	public Path getZipPath() {
		return installation.getModZipsPath().resolve(getId() + ".zip");
	}

	public boolean isDownloaded(){
		return getZipPath().toFile().exists();
	}

	public boolean isEnabled(){
		if (getModFiles().isEmpty()) return false;

		for (ModFile modFile : getModFiles()){
			if (!modFile.getDestPath().toFile().exists()){
				return false;
			}
		}
		return true;
	}

	public Collection<ModFile> getModFiles(){
		if (cachedModFiles == null){
			cachedModFiles = new LinkedList<>(modFiles);
		}
		return cachedModFiles;
	}

	public BufferedImage getImage(){
		try {
			return ImageIO.read(new ByteArrayInputStream(imageBytes));
		} catch (IOException | NullPointerException e) {
			return null;
		}
	}

	public String getReadmeText(){
		return readmeText;
	}

	public boolean isBuiltIn(){
		return builtIn;
	}

	/////////
	// Dao //
	/////////

	public int commit() throws SQLException{
		if (dao != null){
			return getId() == 0 ? create() : update();
		}
		System.err.println("No Dao configured to update " + getName());
		return 0;
	}

	@Override
	public int delete() throws SQLException {
		if (installation != null){
			installation.removeMod(this);
		}
		return super.delete();
	}

	////////////////
	// Comparable //
	////////////////

	@Override
	public int compareTo(Mod other) {
		return Integer.compare(getId(), other.getId());
	}

	////////////
	// Object //
	////////////

	@Override
	public String toString(){
		return getName();
	}

	@Override
	public int hashCode(){
		return new Integer(id).hashCode();
	}

	@Override
	public boolean equals(Object o){
		if (o instanceof Mod){
			Mod mod = (Mod) o;
			return getId() == mod.getId() || getName().equals(mod.getName());
		}
		return false;
	}
}
import java.io.IOException;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import com.google.inject.Inject;
import com.j256.ormlite.dao.Dao;
import io.andrewohara.common.workflows.tasks.WorkflowBuilder;
import io.andrewohara.tinkertime.controllers.ModLoader;
import io.andrewohara.tinkertime.controllers.ModManager.ModNotDownloadedException;
import io.andrewohara.tinkertime.controllers.workflows.tasks.AnalyzeModZipTask;
import io.andrewohara.tinkertime.controllers.workflows.tasks.CheckForUpdateTask;
import io.andrewohara.tinkertime.controllers.workflows.tasks.DownloadModImageTask;
import io.andrewohara.tinkertime.controllers.workflows.tasks.DownloadModInBrowserTask;
import io.andrewohara.tinkertime.controllers.workflows.tasks.DownloadModZipTask;
import io.andrewohara.tinkertime.controllers.workflows.tasks.RemoveModTask;
import io.andrewohara.tinkertime.controllers.workflows.tasks.RunCrawlerTask;
import io.andrewohara.tinkertime.controllers.workflows.tasks.SaveModTask;
import io.andrewohara.tinkertime.io.crawlers.Crawler;
import io.andrewohara.tinkertime.io.crawlers.CrawlerFactory;
import io.andrewohara.tinkertime.io.crawlers.CrawlerFactory.UnsupportedHostException;
import io.andrewohara.tinkertime.models.ModFile;
import io.andrewohara.tinkertime.models.mod.Mod;
package io.andrewohara.tinkertime.controllers.workflows;




public class ModWorkflowBuilder extends WorkflowBuilder {

	private final CrawlerFactory crawlerService;
	private final ModLoader modLoader;
	private final Dao<ModFile, Integer> modFilesDao;