package aQute.bnd.osgi;

import static aQute.lib.io.IO.*;

import java.io.*;
import java.net.*;
import java.security.*;
import java.util.*;
import java.util.jar.*;
import java.util.regex.*;
import java.util.zip.*;

import aQute.bnd.version.*;
import aQute.lib.base64.*;
import aQute.lib.io.*;
import aQute.lib.zip.*;
import aQute.service.reporter.*;

public class Jar implements Closeable {
	static final int BUFFER_SIZE = IOConstants.PAGE_SIZE * 16;

	public enum Compression {
		DEFLATE, STORE
	}

	static final String	DEFAULT_MANIFEST_NAME	= "META-INF/MANIFEST.MF";

	public static final Object[]			EMPTY_ARRAY	= new Jar[0];
	final Map<String,Resource>				resources	= new TreeMap<String,Resource>();
	final Map<String,Map<String,Resource>>	directories	= new TreeMap<String,Map<String,Resource>>();
	Manifest								manifest;
	boolean									manifestFirst;
	String									manifestName = DEFAULT_MANIFEST_NAME;
	String									name;
	File									source;
	ZipFile									zipFile;
	long									lastModified;
	String									lastModifiedReason;
	Reporter								reporter;
	boolean									doNotTouchManifest;
	boolean									nomanifest;
	Compression								compression	= Compression.DEFLATE;
	boolean									closed;
	String[]								algorithms;

	public Jar(String name) {
		this.name = name;
	}

	public Jar(String name, File dirOrFile, Pattern doNotCopy) throws ZipException, IOException {
		this(name);
		source = dirOrFile;
		if (dirOrFile.isDirectory())
			FileResource.build(this, dirOrFile, doNotCopy);
		else if (dirOrFile.isFile()) {
			zipFile = ZipResource.build(this, dirOrFile);
		} else {
			throw new IllegalArgumentException("A Jar can only accept a valid file or directory: " + dirOrFile);
		}
	}

	public Jar(String name, InputStream in, long lastModified) throws IOException {
		this(name);
		EmbeddedResource.build(this, in, lastModified);
	}

	public Jar(String name, String path) throws IOException {
		this(name);
		File f = new File(path);
		InputStream in = new FileInputStream(f);
		EmbeddedResource.build(this, in, f.lastModified());
		in.close();
	}

	public Jar(File f) throws IOException {
		this(getName(f), f, null);
	}

	/**
	 * Make the JAR file name the project name if we get a src or bin directory.
	 * 
	 * @param f
	 * @return
	 */
	private static String getName(File f) {
		f = f.getAbsoluteFile();
		String name = f.getName();
		if (name.equals("bin") || name.equals("src"))
			return f.getParentFile().getName();
		if (name.endsWith(".jar"))
			name = name.substring(0, name.length() - 4);
		return name;
	}

	public Jar(String string, InputStream resourceAsStream) throws IOException {
		this(string, resourceAsStream, 0);
	}

	public Jar(String string, File file) throws ZipException, IOException {
		this(string, file, Pattern.compile(Constants.DEFAULT_DO_NOT_COPY));
	}

	public void setName(String name) {
		this.name = name;
	}

	@Override
	public String toString() {
		return "Jar:" + name;
	}

	public boolean putResource(String path, Resource resource) {
		check();
		return putResource(path, resource, true);
	}

	public boolean putResource(String path, Resource resource, boolean overwrite) {
		check();
		updateModified(resource.lastModified(), path);
		while (path.startsWith("/"))
			path = path.substring(1);

		if (path.equals(manifestName)) {
			manifest = null;
			if (resources.isEmpty())
				manifestFirst = true;
		}
		String dir = getDirectory(path);
		Map<String,Resource> s = directories.get(dir);
		if (s == null) {
			s = new TreeMap<String,Resource>();
			directories.put(dir, s);
			int n = dir.lastIndexOf('/');
			while (n > 0) {
				String dd = dir.substring(0, n);
				if (directories.containsKey(dd))
					break;
				directories.put(dd, null);
				n = dd.lastIndexOf('/');
			}
		}
		boolean duplicate = s.containsKey(path);
		if (!duplicate || overwrite) {
			resources.put(path, resource);
			s.put(path, resource);
		}
		return duplicate;
	}

	public Resource getResource(String path) {
		check();
		if (resources == null)
			return null;
		return resources.get(path);
	}

	private String getDirectory(String path) {
		check();
		int n = path.lastIndexOf('/');
		if (n < 0)
			return "";

		return path.substring(0, n);
	}

	public Map<String,Map<String,Resource>> getDirectories() {
		check();
		return directories;
	}

	public Map<String,Resource> getResources() {
		check();
		return resources;
	}

	public boolean addDirectory(Map<String,Resource> directory, boolean overwrite) {
		check();
		boolean duplicates = false;
		if (directory == null)
			return false;

		for (Map.Entry<String,Resource> entry : directory.entrySet()) {
			String key = entry.getKey();
			if (!key.endsWith(".java")) {
				duplicates |= putResource(key, entry.getValue(), overwrite);
			}
		}
		return duplicates;
	}

	public Manifest getManifest() throws Exception {
		check();
		if (manifest == null) {
			Resource manifestResource = getResource(manifestName);
			if (manifestResource != null) {
				InputStream in = manifestResource.openInputStream();
				manifest = new Manifest(in);
				in.close();
			}
		}
		return manifest;
	}

	public boolean exists(String path) {
		check();
		return resources.containsKey(path);
	}

	public void setManifest(Manifest manifest) {
		check();
		manifestFirst = true;
		this.manifest = manifest;
	}

	public void setManifest(File file) throws IOException {
		check();
		FileInputStream fin = new FileInputStream(file);
		try {
			Manifest m = new Manifest(fin);
			setManifest(m);
		}
		finally {
			fin.close();
		}
	}
	
	public void setManifestName(String manifestName) {
		check();
		if (manifestName == null || manifestName.length() == 0)
			throw new IllegalArgumentException("Manifest name cannot be null or empty!");
		this.manifestName = manifestName;
	}

	public void write(File file) throws Exception {
		check();
		try {
			OutputStream out = new FileOutputStream(file);
			try {
				write(out);
			}
			finally {
				IO.close(out);
			}
			file.setLastModified(lastModified);
			return;

		}
		catch (Exception t) {
			file.delete();
			throw t;
		}
	}

	public void write(String file) throws Exception {
		check();
		write(new File(file));
	}

	public void write(OutputStream out) throws Exception {
		check();

		if (!doNotTouchManifest && !nomanifest && algorithms != null) {

			// ok, we have a request to create digests
			// of the resources. Since we have to output
			// the manifest first, we have a slight problem.
			// We can also not make multiple passes over the resource
			// because some resources are not idempotent and/or can
			// take significant time. So we just copy the jar
			// to a temporary file, read it in again, calculate
			// the checksums and save.

			String[] algs = algorithms;
			algorithms = null;
			try {
				File f = File.createTempFile(getName(), ".jar");
				write(f);
				Jar tmp = new Jar(f);
				try {
					tmp.calcChecksums(algorithms);
					tmp.write(out);
				}
				finally {
					f.delete();
					tmp.close();
				}
			}
			finally {
				algorithms = algs;
			}
			return;
		}

		ZipOutputStream jout = nomanifest || doNotTouchManifest ? new ZipOutputStream(out) : new JarOutputStream(out);

		switch (compression) {
			case STORE :
				jout.setMethod(ZipOutputStream.DEFLATED);
				break;

			default :
				// default is DEFLATED
		}

		Set<String> done = new HashSet<String>();

		Set<String> directories = new HashSet<String>();
		if (doNotTouchManifest) {
			Resource r = getResource(manifestName);
			if (r != null) {
				writeResource(jout, directories, manifestName, r);
				done.add(manifestName);
			}
		} else
			doManifest(done, jout);

		for (Map.Entry<String,Resource> entry : getResources().entrySet()) {
			// Skip metainf contents
			if (!done.contains(entry.getKey()))
				writeResource(jout, directories, entry.getKey(), entry.getValue());
		}
		jout.finish();
	}

	private void doManifest(Set<String> done, ZipOutputStream jout) throws Exception {
		check();
		if (nomanifest)
			return;

		JarEntry ze = new JarEntry(manifestName);
		ZipUtil.setModifiedTime(ze,lastModified);
		jout.putNextEntry(ze);
		writeManifest(jout);
		jout.closeEntry();
		done.add(ze.getName());
	}

	/**
	 * Cleanup the manifest for writing. Cleaning up consists of adding a space
	 * after any \n to prevent the manifest to see this newline as a delimiter.
	 * 
	 * @param out
	 *            Output
	 * @throws IOException
	 */

	public void writeManifest(OutputStream out) throws Exception {
		check();
		writeManifest(getManifest(), out);
	}

	public static void writeManifest(Manifest manifest, OutputStream out) throws IOException {
		if (manifest == null)
			return;

		manifest = clean(manifest);
		outputManifest(manifest, out);
	}

	/**
	 * Unfortunately we have to write our own manifest :-( because of a stupid
	 * bug in the manifest code. It tries to handle UTF-8 but the way it does it
	 * it makes the bytes platform dependent. So the following code outputs the
	 * manifest. A Manifest consists of
	 * 
	 * <pre>
	 *   'Manifest-Version: 1.0\r\n'
	 *   main-attributes *
	 *   \r\n
	 *   name-section
	 *   
	 *   main-attributes ::= attributes
	 *   attributes      ::= key ': ' value '\r\n'
	 *   name-section    ::= 'Name: ' name '\r\n' attributes
	 * </pre>
	 * 
	 * Lines in the manifest should not exceed 72 bytes (! this is where the
	 * manifest screwed up as well when 16 bit unicodes were used).
	 * <p>
	 * As a bonus, we can now sort the manifest!
	 */
	static byte[]	CONTINUE	= new byte[] {
			'\r', '\n', ' '
								};

	/**
	 * Main function to output a manifest properly in UTF-8.
	 * 
	 * @param manifest
	 *            The manifest to output
	 * @param out
	 *            The output stream
	 * @throws IOException
	 *             when something fails
	 */
	public static void outputManifest(Manifest manifest, OutputStream out) throws IOException {
		writeEntry(out, "Manifest-Version", "1.0");
		attributes(manifest.getMainAttributes(), out);

		TreeSet<String> keys = new TreeSet<String>();
		for (Object o : manifest.getEntries().keySet())
			keys.add(o.toString());

		for (String key : keys) {
			write(out, 0, "\r\n");
			writeEntry(out, "Name", key);
			attributes(manifest.getAttributes(key), out);
		}
		out.flush();
	}

	/**
	 * Write out an entry, handling proper unicode and line length constraints
	 */
	private static void writeEntry(OutputStream out, String name, String value) throws IOException {
		int n = write(out, 0, name + ": ");
		write(out, n, value);
		write(out, 0, "\r\n");
	}

	/**
	 * Convert a string to bytes with UTF8 and then output in max 72 bytes
	 * 
	 * @param out
	 *            the output string
	 * @param i
	 *            the current width
	 * @param s
	 *            the string to output
	 * @return the new width
	 * @throws IOException
	 *             when something fails
	 */
	private static int write(OutputStream out, int i, String s) throws IOException {
		byte[] bytes = s.getBytes("UTF8");
		return write(out, i, bytes);
	}

	/**
	 * Write the bytes but ensure that the line length does not exceed 72
	 * characters. If it is more than 70 characters, we just put a cr/lf +
	 * space.
	 * 
	 * @param out
	 *            The output stream
	 * @param width
	 *            The nr of characters output in a line before this method
	 *            started
	 * @param bytes
	 *            the bytes to output
	 * @return the nr of characters in the last line
	 * @throws IOException
	 *             if something fails
	 */
	private static int write(OutputStream out, int width, byte[] bytes) throws IOException {
		int w = width;
		for (int i = 0; i < bytes.length; i++) {
			if (w >= 72) { // we need to add the \n\r!
				out.write(CONTINUE);
				w = 1;
			}
			out.write(bytes[i]);
			w++;
		}
		return w;
	}

	/**
	 * Output an Attributes map. We will sort this map before outputing.
	 * 
	 * @param value
	 *            the attrbutes
	 * @param out
	 *            the output stream
	 * @throws IOException
	 *             when something fails
	 */
	private static void attributes(Attributes value, OutputStream out) throws IOException {
		TreeMap<String,String> map = new TreeMap<String,String>(String.CASE_INSENSITIVE_ORDER);
		for (Map.Entry<Object,Object> entry : value.entrySet()) {
			map.put(entry.getKey().toString(), entry.getValue().toString());
		}

		map.remove("Manifest-Version"); // get rid of
		// manifest
		// version
		for (Map.Entry<String,String> entry : map.entrySet()) {
			writeEntry(out, entry.getKey(), entry.getValue());
		}
	}

	private static Manifest clean(Manifest org) {

		Manifest result = new Manifest();
		for (Map.Entry< ? , ? > entry : org.getMainAttributes().entrySet()) {
			String nice = clean((String) entry.getValue());
			result.getMainAttributes().put(entry.getKey(), nice);
		}
		for (String name : org.getEntries().keySet()) {
			Attributes attrs = result.getAttributes(name);
			if (attrs == null) {
				attrs = new Attributes();
				result.getEntries().put(name, attrs);
			}

			for (Map.Entry< ? , ? > entry : org.getAttributes(name).entrySet()) {
				String nice = clean((String) entry.getValue());
				attrs.put(entry.getKey(), nice);
			}
		}
		return result;
	}

	private static String clean(String s) {
		StringBuilder sb = new StringBuilder(s);
		boolean changed = false;
		boolean replacedPrev = false;
		for ( int i=0; i<sb.length(); i++) {
			char c = s.charAt(i);
			switch(c) {
				case 0:
				case '\n':
				case '\r':
					changed = true;
					if ( !replacedPrev ) {
						sb.replace(i, i+1, " ");
						replacedPrev= true;
					} else
						sb.delete(i, i+1);
					break;
				default:
					replacedPrev = false;
					break;
			}
		}
		if ( changed)
			return sb.toString();
		else
			return s;
	}

	private void writeResource(ZipOutputStream jout, Set<String> directories, String path, Resource resource)
			throws Exception {
		if (resource == null)
			return;
		try {
			createDirectories(directories, jout, path);
			if (path.endsWith(Constants.EMPTY_HEADER))
				return;
			ZipEntry ze = new ZipEntry(path);
			ze.setMethod(ZipEntry.DEFLATED);
			long lastModified = resource.lastModified();
			if (lastModified == 0L) {
				lastModified = System.currentTimeMillis();
			}
			ZipUtil.setModifiedTime(ze,lastModified);
			if (resource.getExtra() != null)
				ze.setExtra(resource.getExtra().getBytes("UTF-8"));
			jout.putNextEntry(ze);
			resource.write(jout);
			jout.closeEntry();
		}
		catch (Exception e) {
			throw new Exception("Problem writing resource " + path, e);
		}
	}

	void createDirectories(Set<String> directories, ZipOutputStream zip, String name) throws IOException {
		int index = name.lastIndexOf('/');
		if (index > 0) {
			String path = name.substring(0, index);
			if (directories.contains(path))
				return;
			createDirectories(directories, zip, path);
			ZipEntry ze = new ZipEntry(path + '/');
			zip.putNextEntry(ze);
			zip.closeEntry();
			directories.add(path);
		}
	}

	public String getName() {
		return name;
	}

	/**
	 * Add all the resources in the given jar that match the given filter.
	 * 
	 * @param sub
	 *            the jar
	 * @param filter
	 *            a pattern that should match the resoures in sub to be added
	 */
	public boolean addAll(Jar sub, Instruction filter) {
		return addAll(sub, filter, "");
	}

	/**
	 * Add all the resources in the given jar that match the given filter.
	 * 
	 * @param sub
	 *            the jar
	 * @param filter
	 *            a pattern that should match the resoures in sub to be added
	 */
	public boolean addAll(Jar sub, Instruction filter, String destination) {
		check();
		boolean dupl = false;
		for (String name : sub.getResources().keySet()) {
			if (manifestName.equals(name))
				continue;

			if (filter == null || filter.matches(name) != filter.isNegated())
				dupl |= putResource(Processor.appendPath(destination, name), sub.getResource(name), true);
		}
		return dupl;
	}

	public void close() {
		this.closed = true;
		if (zipFile != null)
			try {
				zipFile.close();
			}
			catch (IOException e) {
				// Ignore
			}
		resources.clear();
		directories.clear();
		manifest = null;
		source = null;
	}

	public long lastModified() {
		return lastModified;
	}

	public void updateModified(long time, String reason) {
		if (time > lastModified) {
			lastModified = time;
			lastModifiedReason = reason;
		}
	}

	public void setReporter(Reporter reporter) {
		this.reporter = reporter;
	}

	public boolean hasDirectory(String path) {
		check();
		return directories.get(path) != null;
	}

	public List<String> getPackages() {
		check();
		List<String> list = new ArrayList<String>(directories.size());

		for (Map.Entry<String,Map<String,Resource>> i : directories.entrySet()) {
			if (i.getValue() != null) {
				String path = i.getKey();
				String pack = path.replace('/', '.');
				list.add(pack);
			}
		}
		return list;
	}

	public File getSource() {
		check();
		return source;
	}

	public boolean addAll(Jar src) {
		check();
		return addAll(src, null);
	}

	public boolean rename(String oldPath, String newPath) {
		check();
		Resource resource = remove(oldPath);
		if (resource == null)
			return false;

		return putResource(newPath, resource);
	}

	public Resource remove(String path) {
		check();
		Resource resource = resources.remove(path);
		String dir = getDirectory(path);
		Map<String,Resource> mdir = directories.get(dir);
		// must be != null
		mdir.remove(path);
		return resource;
	}

	/**
	 * Make sure nobody touches the manifest! If the bundle is signed, we do not
	 * want anybody to touch the manifest after the digests have been
	 * calculated.
	 */
	public void setDoNotTouchManifest() {
		doNotTouchManifest = true;
	}

	/**
	 * Calculate the checksums and set them in the manifest.
	 */

	public void calcChecksums(String algorithms[]) throws Exception {
		check();
		if (algorithms == null)
			algorithms = new String[] {
					"SHA", "MD5"
			};

		Manifest m = getManifest();
		if (m == null) {
			m = new Manifest();
			setManifest(m);
		}

		MessageDigest digests[] = new MessageDigest[algorithms.length];
		int n = 0;
		for (String algorithm : algorithms)
			digests[n++] = MessageDigest.getInstance(algorithm);

		byte buffer[] = new byte[BUFFER_SIZE];

		for (Map.Entry<String,Resource> entry : resources.entrySet()) {

			// Skip the manifest
			if (entry.getKey().equals(manifestName))
				continue;

			Resource r = entry.getValue();
			Attributes attributes = m.getAttributes(entry.getKey());
			if (attributes == null) {
				attributes = new Attributes();
				getManifest().getEntries().put(entry.getKey(), attributes);
			}
			InputStream in = r.openInputStream();
			try {
				for (MessageDigest d : digests)
					d.reset();
				int size = in.read(buffer);
				while (size > 0) {
					for (MessageDigest d : digests)
						d.update(buffer, 0, size);
					size = in.read(buffer);
				}
			}
			finally {
				in.close();
			}
			for (MessageDigest d : digests)
				attributes.putValue(d.getAlgorithm() + "-Digest", Base64.encodeBase64(d.digest()));
		}
	}

	static Pattern	BSN	= Pattern.compile("\\s*([-\\w\\d\\._]+)\\s*;?.*");

	/**
	 * Get the jar bsn from the {@link Constants#BUNDLE_SYMBOLICNAME} manifest
	 * header.
	 * 
	 * @return null when the jar has no manifest, when the manifest has no
	 *         {@link Constants#BUNDLE_SYMBOLICNAME} header, or when the value
	 *         of the header is not a valid bsn according to {@link #BSN}.
	 * @throws Exception
	 *             when the jar is closed or when the manifest could not be
	 *             retrieved.
	 */
	public String getBsn() throws Exception {
		check();
		Manifest m = getManifest();
		if (m == null)
			return null;

		String s = m.getMainAttributes().getValue(Constants.BUNDLE_SYMBOLICNAME);
		if (s == null)
			return null;

		Matcher matcher = BSN.matcher(s);
		if (matcher.matches()) {
			return matcher.group(1);
		}
		return null;
	}

	/**
	 * Get the jar version from the {@link Constants#BUNDLE_VERSION} manifest
	 * header.
	 * 
	 * @return null when the jar has no manifest or when the manifest has no
	 *         {@link Constants#BUNDLE_VERSION} header
	 * @throws Exception
	 *             when the jar is closed or when the manifest could not be
	 *             retrieved.
	 */
	public String getVersion() throws Exception {
		check();
		Manifest m = getManifest();
		if (m == null)
			return null;

		String s = m.getMainAttributes().getValue(Constants.BUNDLE_VERSION);
		if (s == null)
			return null;

		return s.trim();
	}

	/**
	 * Expand the JAR file to a directory.
	 * 
	 * @param dir
	 *            the dst directory, is not required to exist
	 * @throws Exception
	 *             if anything does not work as expected.
	 */
	public void expand(File dir) throws Exception {
		check();
		dir = dir.getAbsoluteFile();
		if (!dir.exists() && !dir.mkdirs()) {
			throw new IOException("Could not create directory " + dir);
		}
		if (!dir.isDirectory()) {
			throw new IllegalArgumentException("Not a dir: " + dir.getAbsolutePath());
		}

		for (Map.Entry<String,Resource> entry : getResources().entrySet()) {
			File f = getFile(dir, entry.getKey());
			File fp = f.getParentFile();
			if (!fp.exists() && !fp.mkdirs()) {
				throw new IOException("Could not create directory " + fp);
			}
			IO.copy(entry.getValue().openInputStream(), f);
		}
	}

	/**
	 * Make sure we have a manifest
	 * 
	 * @throws Exception
	 */
	public void ensureManifest() throws Exception {
		if (getManifest() != null)
			return;
		manifest = new Manifest();
	}

	/**
	 * Answer if the manifest was the first entry
	 */

	public boolean isManifestFirst() {
		return manifestFirst;
	}

	public void copy(Jar srce, String path, boolean overwrite) {
		check();
		addDirectory(srce.getDirectories().get(path), overwrite);
	}

	public void setCompression(Compression compression) {
		this.compression = compression;
	}

	public Compression hasCompression() {
		return this.compression;
	}

	void check() {
		if (closed)
			throw new RuntimeException("Already closed " + name);
	}

	/**
	 * Return a data uri from the JAR. The data must be less than 32k
	 * 
	 * @param jar
	 *            The jar to load the data from
	 * @param path
	 *            the path in the jar
	 * @param mime
	 *            the mime type
	 * @return a URI or null if conversion could not take place
	 */

	public URI getDataURI(String path, String mime, int max) throws Exception {
		Resource r = getResource(path);

		if (r.size() >= max || r.size() <= 0)
			return null;

		byte[] data = new byte[(int) r.size()];
		DataInputStream din = new DataInputStream(r.openInputStream());
		try {
			din.readFully(data);
			String encoded = Base64.encodeBase64(data);
			return new URI("data:" + mime + ";base64," + encoded);
		}
		finally {
			din.close();
		}
	}

	public void setDigestAlgorithms(String[] algorithms) {
		this.algorithms = algorithms;
	}
	

	public byte[] getTimelessDigest() throws Exception {
		check();
		
		MessageDigest md = MessageDigest.getInstance("SHA1");
		OutputStream dout = new DigestOutputStream(IO.nullStream, md);
		//dout = System.out;
		
		Manifest m = getManifest();

		
		
		if ( m != null) {
			Manifest m2 = new Manifest(m);
			Attributes main = m2.getMainAttributes();
			String lastmodified = (String) main.remove(new Attributes.Name(Constants.BND_LASTMODIFIED));
			String version = main.getValue(new Attributes.Name(Constants.BUNDLE_VERSION));
			if ( version != null && Verifier.isVersion(version)) {
				Version v = new Version(version);
				main.putValue( Constants.BUNDLE_VERSION, v.getWithoutQualifier().toString());
			}
			writeManifest(m2, dout);
			
			for ( Map.Entry<String,Resource> entry : getResources().entrySet()) {
				String path = entry.getKey();
				if ( path.equals(manifestName))
					continue;
				Resource resource = entry.getValue();
				dout.write(path.getBytes("UTF-8"));
				resource.write(dout);
			}
		}
		return md.digest();
	}
}
