/*******************************************************************************
 * Copyright (c) 2006, 2017 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM - Initial API and implementation
 *******************************************************************************/
package org.eclipse.internal.provisional.equinox.p2.jarprocessor;

import java.io.*;
import java.util.*;
import java.util.jar.*;
import org.eclipse.equinox.internal.p2.jarprocessor.*;

public class JarProcessor {
	public static final String PACKED_SUFFIX = "pack.gz"; //$NON-NLS-1$

	private List<IProcessStep> steps = new ArrayList<>();
	private String workingDirectory = ""; //$NON-NLS-1$
	private int depth = -1;
	private boolean verbose = false;
	private boolean processAll = false;
	private LinkedList<Properties> containingInfs = new LinkedList<>();

	static public JarProcessor getUnpackProcessor(Properties properties) {
		if (!canPerformUnpack())
			throw new UnsupportedOperationException();
		JarProcessor processor = new JarProcessor();
		processor.addProcessStep(new UnpackStep(properties));
		return processor;
	}

	static public JarProcessor getPackProcessor(Properties properties) {
		if (!canPerformPack())
			throw new UnsupportedOperationException();
		JarProcessor processor = new JarProcessor();
		processor.addProcessStep(new PackStep(properties));
		return processor;
	}

	static public boolean canPerformPack() {
		return PackStep.canPack();
	}

	static public boolean canPerformUnpack() {
		return UnpackStep.canUnpack();
	}

	public String getWorkingDirectory() {
		return workingDirectory;
	}

	public void setWorkingDirectory(String dir) {
		if (dir != null)
			workingDirectory = dir;
	}

	public void setVerbose(boolean verbose) {
		this.verbose = verbose;
	}

	public void setProcessAll(boolean all) {
		this.processAll = all;
	}

	public void addProcessStep(IProcessStep step) {
		steps.add(step);
	}

	public void clearProcessSteps() {
		steps.clear();
	}

	public Iterator<IProcessStep> getStepIterator() {
		return steps.iterator();
	}

	/**
	 * Recreate a jar file.  The replacements map specifies entry names to be replaced, the replacements are
	 * expected to be found in directory.
	 * 
	 * @param jar - The input jar
	 * @param outputJar - the output
	 * @param replacements - map of entryName -> new entryName
	 * @param directory - location to find file for new entryName
	 * @throws IOException
	 */
	private void recreateJar(JarFile jar, JarOutputStream outputJar, Map<String, String> replacements, File directory, Properties inf) throws IOException {
		InputStream in = null;
		boolean marked = false;
		try {
			Enumeration<JarEntry> entries = jar.entries();
			for (JarEntry entry = entries.nextElement(); entry != null; entry = entries.hasMoreElements() ? (JarEntry) entries.nextElement() : null) {
				File replacement = null;
				JarEntry newEntry = null;
				if (replacements.containsKey(entry.getName())) {
					String name = replacements.get(entry.getName());
					replacement = ZipProcessor.createSubPathFile(directory, name);
					if (name != null) {
						if (replacement.exists()) {
							try {
								in = new BufferedInputStream(new FileInputStream(replacement));
								newEntry = new JarEntry(name);
							} catch (Exception e) {
								if (verbose) {
									e.printStackTrace();
									System.out.println("Warning: Problem reading " + replacement.getPath() + ", using " + jar.getName() + File.separator + entry.getName() + " instead."); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
								}
							}
						} else if (verbose) {
							System.out.println("Warning: " + replacement.getPath() + " not found, using " + jar.getName() + File.separator + entry.getName() + " instead."); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
						}
					}
				}
				if (newEntry == null) {
					try {
						in = new BufferedInputStream(jar.getInputStream(entry));
						newEntry = new JarEntry(entry.getName());
					} catch (Exception e) {
						if (verbose) {
							e.printStackTrace();
							System.out.println("ERROR: problem reading " + entry.getName() + " from " + jar.getName()); //$NON-NLS-1$ //$NON-NLS-2$
						}
						continue;
					}
				}
				newEntry.setTime(entry.getTime());
				outputJar.putNextEntry(newEntry);
				if (entry.getName().equals(Utils.MARK_FILE_NAME)) {
					//The eclipse.inf file was read in earlier, don't need to reread it, just write it out now
					Utils.storeProperties(inf, outputJar);
					marked = true;
				} else {
					Utils.transferStreams(in, outputJar, false);
				}
				outputJar.closeEntry();
				in.close();

				//delete the nested jar file
				if (replacement != null) {
					replacement.delete();
				}
			}
			if (!marked) {
				JarEntry entry = new JarEntry(Utils.MARK_FILE_NAME);
				outputJar.putNextEntry(entry);
				Utils.storeProperties(inf, outputJar);
				outputJar.closeEntry();
			}
		} finally {
			Utils.close(outputJar);
			Utils.close(jar);
			Utils.close(in);
		}
	}

	private String recursionEffect(String entryName) {
		String result = null;
		for (IProcessStep step : steps) {
			result = step.recursionEffect(entryName);
			if (result != null)
				entryName = result;
		}
		return result;
	}

	private void extractEntries(JarFile jar, File tempDir, Map<String, String> data, Properties inf) throws IOException {
		if (inf != null) {
			//skip if excluding children
			if (inf.containsKey(Utils.MARK_EXCLUDE_CHILDREN)) {
				String excludeChildren = inf.getProperty(Utils.MARK_EXCLUDE_CHILDREN);
				if (Boolean.parseBoolean(excludeChildren))
					if (verbose) {
						for (int i = 0; i <= depth; i++)
							System.out.print("  "); //$NON-NLS-1$
						System.out.println("Children of " + jar.getName() + "are excluded from processing."); //$NON-NLS-1$ //$NON-NLS-2$
					}
				return;
			}
		}

		Enumeration<JarEntry> entries = jar.entries();
		if (entries.hasMoreElements()) {
			for (JarEntry entry = entries.nextElement(); entry != null; entry = entries.hasMoreElements() ? (JarEntry) entries.nextElement() : null) {
				String name = entry.getName();
				String newName = recursionEffect(name);
				if (newName != null) {
					if (verbose) {
						for (int i = 0; i <= depth; i++)
							System.out.print("  "); //$NON-NLS-1$
						System.out.println("Processing nested file: " + name); //$NON-NLS-1$
					}
					//extract entry to temp directory
					File extracted = ZipProcessor.createSubPathFile(tempDir, name);
					File parentDir = extracted.getParentFile();
					if (!parentDir.exists())
						parentDir.mkdirs();

					InputStream in = null;
					OutputStream out = null;
					try {
						in = jar.getInputStream(entry);
						out = new BufferedOutputStream(new FileOutputStream(extracted));
						Utils.transferStreams(in, out, true); //this will close both streams
					} finally {
						Utils.close(in);
						Utils.close(out);
					}
					extracted.setLastModified(entry.getTime());

					//recurse
					String dir = getWorkingDirectory();
					try {
						containingInfs.addFirst(inf);
						setWorkingDirectory(parentDir.getCanonicalPath());
						File result = processJar(extracted);
						newName = name.substring(0, name.length() - extracted.getName().length()) + result.getName();
						data.put(name, newName);
					} finally {
						setWorkingDirectory(dir);
						containingInfs.removeFirst();
					}

					//delete the extracted item leaving the recursion result
					if (!name.equals(newName))
						extracted.delete();
				}
			}
		}
	}

	private File preProcess(File input, File tempDir) {
		File result = null;
		for (IProcessStep step : steps) {
			result = step.preProcess(input, tempDir, containingInfs);
			if (result != null)
				input = result;
		}
		return input;
	}

	private File postProcess(File input, File tempDir) {
		File result = null;
		for (IProcessStep step : steps) {
			result = step.postProcess(input, tempDir, containingInfs);
			if (result != null)
				input = result;
		}
		return input;
	}

	private boolean adjustInf(File input, Properties inf) {
		boolean adjusted = false;
		for (IProcessStep step : steps) {
			adjusted |= step.adjustInf(input, inf, containingInfs);
		}
		return adjusted;
	}

	public File processJar(File input) throws IOException {
		File tempDir = null;
		++depth;
		try {
			long lastModified = input.lastModified();
			File workingDir = new File(getWorkingDirectory());
			if (!workingDir.exists())
				workingDir.mkdirs();

			boolean skip = Utils.shouldSkipJar(input, processAll, verbose);
			if (depth == 0 && verbose) {
				if (skip)
					System.out.println("Skipping " + input.getPath()); //$NON-NLS-1$
				else {
					System.out.print("Running "); //$NON-NLS-1$ 
					for (IProcessStep step : steps) {
						System.out.print(step.getStepName() + " "); //$NON-NLS-1$
					}
					System.out.println("on " + input.getPath()); //$NON-NLS-1$
				}
			}

			if (skip) {
				//This jar was not marked as conditioned, and we are only processing conditioned jars, so do nothing
				return input;
			}

			//pre
			File workingFile = preProcess(input, workingDir);

			//Extract entries from jar and recurse on them
			if (depth == 0) {
				tempDir = new File(workingDir, "temp." + workingFile.getName()); //$NON-NLS-1$
			} else {
				File parent = workingDir.getParentFile();
				tempDir = new File(parent, "temp_" + depth + '_' + workingFile.getName()); //$NON-NLS-1$
			}

			JarFile jar = null;
			try {
				jar = new JarFile(workingFile, false);
				Map<String, String> replacements = new HashMap<>();
				Properties inf = Utils.getEclipseInf(workingFile, verbose);
				extractEntries(jar, tempDir, replacements, inf);

				boolean infAdjusted = false;
				if (inf != null)
					infAdjusted = adjustInf(workingFile, inf);

				//Recreate the jar with replacements. 
				//This is not strictly necessary if we didn't change the inf file and didn't change any content
				if (!replacements.isEmpty() || infAdjusted) {
					File tempJar = null;
					tempJar = new File(tempDir, workingFile.getName());
					File parent = tempJar.getParentFile();
					if (!parent.exists())
						parent.mkdirs();
					JarOutputStream jarOut = null;
					try {
						jarOut = new JarOutputStream(new BufferedOutputStream(new FileOutputStream(tempJar)));
						recreateJar(jar, jarOut, replacements, tempDir, inf);
					} finally {
						Utils.close(jarOut);
					}
					if (tempJar != null) {
						if (!workingFile.equals(input)) {
							workingFile.delete();
						}
						workingFile = tempJar;
					}
				}
			} finally {
				Utils.close(jar);
			}

			//post
			File result = postProcess(workingFile, workingDir);

			//have to normalize after the post steps
			normalize(result, workingDir);

			// If the original input is where we ended up, just return it
			if (input.equals(result))
				return result;

			if (!result.equals(workingFile) && !workingFile.equals(input))
				workingFile.delete();
			if (!result.getParentFile().equals(workingDir)) {
				File finalFile = new File(workingDir, result.getName());
				if (finalFile.exists())
					finalFile.delete();
				result.renameTo(finalFile);
				result = finalFile;
			}

			result.setLastModified(lastModified);
			return result;
		} finally {
			--depth;
			if (tempDir != null && tempDir.exists())
				Utils.clear(tempDir);
		}
	}

	private void normalize(File input, File directory) {
		if (input.getName().endsWith(JarProcessor.PACKED_SUFFIX)) {
			//not a jar
			return;
		}
		try {
			File tempJar = new File(directory, "temp_" + input.getName()); //$NON-NLS-1$
			JarFile jar = null;
			JarOutputStream jarOut = null;
			InputStream jarIn = null;
			try {
				jar = new JarFile(input, false);
				jarOut = new JarOutputStream(new BufferedOutputStream(new FileOutputStream(tempJar)));
				Enumeration<JarEntry> entries = jar.entries();
				for (JarEntry entry = entries.nextElement(); entry != null; entry = entries.hasMoreElements() ? (JarEntry) entries.nextElement() : null) {
					JarEntry newEntry = new JarEntry(entry.getName());
					newEntry.setTime(entry.getTime());
					jarIn = new BufferedInputStream(jar.getInputStream(entry));
					jarOut.putNextEntry(newEntry);
					Utils.transferStreams(jarIn, jarOut, false);
					jarOut.closeEntry();
					jarIn.close();
				}
			} catch (JarException e) {
				//not a jar
				return;
			} finally {
				Utils.close(jarOut);
				Utils.close(jarIn);
				Utils.close(jar);
			}
			tempJar.setLastModified(input.lastModified());
			input.delete();
			tempJar.renameTo(input);
		} catch (IOException e) {
			if (verbose) {
				System.out.println("Error normalizing jar " + input.getName()); //$NON-NLS-1$
				e.printStackTrace();
			}
		}
	}
}
