/*******************************************************************************
 * Copyright (c) MOBAC developers
 * 
 * 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, see <http://www.gnu.org/licenses/>.
 ******************************************************************************/
package mobac.mapsources.loader;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLClassLoader;
import java.security.CodeSigner;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import java.util.ServiceLoader;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;

import javax.swing.JOptionPane;

import mobac.exceptions.MapSourceCreateException;
import mobac.exceptions.UnrecoverableDownloadException;
import mobac.exceptions.UpdateFailedException;
import mobac.mapsources.MapSourcesManager;
import mobac.program.Logging;
import mobac.program.ProgramInfo;
import mobac.program.interfaces.MapSource;
import mobac.program.model.MapSourceLoaderInfo;
import mobac.program.model.MapSourceLoaderInfo.LoaderType;
import mobac.program.model.Settings;
import mobac.utilities.GUIExceptionHandler;
import mobac.utilities.I18nUtils;
import mobac.utilities.Utilities;
import mobac.utilities.file.DirOrFileExtFilter;
import mobac.utilities.file.FileExtFilter;

import org.apache.commons.codec.binary.Hex;
import org.apache.log4j.Level;
import org.apache.log4j.Logger;

public class MapPackManager {

	private final Logger log = Logger.getLogger(MapPackManager.class);

	private final int requiredMapPackVersion;

	private final File mapPackDir;

	private final X509Certificate mapPackCert;

	public MapPackManager(File mapPackDir) throws CertificateException, IOException {
		this.mapPackDir = mapPackDir;
		requiredMapPackVersion = Integer.parseInt(System.getProperty("mobac.mappackversion"));
		CertificateFactory cf = CertificateFactory.getInstance("X.509");
		Collection<? extends Certificate> certs = cf.generateCertificates(Utilities
				.loadResourceAsStream("cert/MapPack.cer"));
		mapPackCert = (X509Certificate) certs.iterator().next();
	}

	/**
	 * Searches for updated map packs, verifies the signature
	 * 
	 * @throws IOException
	 */
	public void installUpdates() throws IOException {
		File[] newMapPacks = mapPackDir.listFiles(new FileExtFilter(".jar.new"));
		if (newMapPacks == null)
			throw new IOException("Failed to enumerate installable mappacks");
		for (File newMapPack : newMapPacks) {
			try {
				testMapPack(newMapPack);
				String name = newMapPack.getName();
				name = name.substring(0, name.length() - 4); // remove ".new"
				File oldMapPack = new File(mapPackDir, name);
				if (oldMapPack.isFile()) {
					// TODO: Check if new map pack file is still compatible
					// TODO: Check if the downloaded version is newer
					File oldMapPack2 = new File(mapPackDir, name + ".old");
					Utilities.renameFile(oldMapPack, oldMapPack2);
				}
				if (!newMapPack.renameTo(oldMapPack))
					throw new IOException("Failed to rename file: " + newMapPack);
			} catch (CertificateException e) {
				Utilities.deleteFile(newMapPack);
				log.error("Map pack certificate cerificateion failed (" + newMapPack.getName()
						+ ") installation aborted and file was deleted");
			}
		}
	}

	public List<File> getAllMapPackFiles() {
		return Utilities.traverseFolder(mapPackDir, new DirOrFileExtFilter(".jar"));
	}

	public void loadMapPacks(MapSourcesManager mapSourcesManager) throws IOException, CertificateException {
		List<File> mapPacks = getAllMapPackFiles();
		for (File mapPackFile : mapPacks) {
			File oldMapPackFile = new File(mapPackFile.getAbsolutePath() + ".old");
			try {
				loadMapPack(mapPackFile, mapSourcesManager);
				if (oldMapPackFile.isFile())
					Utilities.deleteFile(oldMapPackFile);
			} catch (MapSourceCreateException e) {
				if (oldMapPackFile.isFile()) {
					mapPackFile.deleteOnExit();
					File newMapPackFile = new File(mapPackFile.getAbsolutePath() + ".new");
					Utilities.renameFile(oldMapPackFile, newMapPackFile);
					try {
						JOptionPane.showMessageDialog(null,
								I18nUtils.localizedStringForKey("msg_update_map_pack_error"),
								I18nUtils.localizedStringForKey("msg_update_map_pack_error_title"),
								JOptionPane.INFORMATION_MESSAGE);
						System.exit(1);
					} catch (Exception e1) {
						log.error(e1.getMessage(), e1);
					}
				}
				GUIExceptionHandler.processException(e);
			} catch (CertificateException e) {
				throw e;
			} catch (Exception e) {
				throw new IOException("Failed to load map pack: " + mapPackFile, e);
			}
		}
	}

	public void loadMapPack(File mapPackFile, MapSourcesManager mapSourcesManager) throws CertificateException,
			IOException, MapSourceCreateException {
		// testMapPack(mapPackFile);
		URLClassLoader urlCl;
		URL url = mapPackFile.toURI().toURL();
		urlCl = new MapPackClassLoader(url, ClassLoader.getSystemClassLoader());
		InputStream manifestIn = urlCl.getResourceAsStream("META-INF/MANIFEST.MF");
		String rev = null;
		if (manifestIn != null) {
			Manifest mf = new Manifest(manifestIn);
			rev = mf.getMainAttributes().getValue("MapPackRevision");
			manifestIn.close();
			if (rev != null) {
				if ("exported".equals(rev)) {
					rev = ProgramInfo.getRevisionStr();
				} else {
					rev = Integer.toString(Utilities.parseSVNRevision(rev));
				}
			}
			mf = null;
		}
		MapSourceLoaderInfo loaderInfo = new MapSourceLoaderInfo(LoaderType.MAPPACK, mapPackFile, rev);
		final Iterator<MapSource> iterator = ServiceLoader.load(MapSource.class, urlCl).iterator();

		while (iterator.hasNext()) {
			try {
				MapSource ms = iterator.next();
				ms.setLoaderInfo(loaderInfo);
				mapSourcesManager.addMapSource(ms);
				log.trace("Loaded map source: " + ms.toString() + " (name: " + ms.getName() + ")");
			} catch (Error e) {
				urlCl = null;
				throw new MapSourceCreateException("Failed to load a map sources from map pack: "
						+ mapPackFile.getName() + " " + e.getMessage(), e);
			}
		}
	}

	public String downloadMD5SumList() throws IOException, UpdateFailedException {
		String md5eTag = Settings.getInstance().mapSourcesUpdate.etag;
		log.debug("Last md5 eTag: " + md5eTag);
		String updateUrl = System.getProperty("mobac.updateurl");
		if (updateUrl == null)
			throw new RuntimeException("Update url not present");

		byte[] data = null;

		// Proxy p = new Proxy(Type.HTTP, InetSocketAddress.createUnresolved("localhost", 8888));
		HttpURLConnection conn = (HttpURLConnection) new URL(updateUrl).openConnection();
		conn.setInstanceFollowRedirects(false);
		if (md5eTag != null)
			conn.addRequestProperty("If-None-Match", md5eTag);
		int responseCode = conn.getResponseCode();
		if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
			log.debug("No newer md5 file available");
			return null;
		}
		if (responseCode != HttpURLConnection.HTTP_OK)
			throw new UpdateFailedException("Invalid HTTP response: " + responseCode + " for update url "
					+ conn.getURL());
		// Case HTTP_OK
		InputStream in = conn.getInputStream();
		data = Utilities.getInputBytes(in);
		in.close();
		Settings.getInstance().mapSourcesUpdate.etag = conn.getHeaderField("ETag");
		log.debug("New md5 file retrieved");
		String md5sumList = new String(data);
		return md5sumList;
	}

	/**
	 * Clean up old files (<code>.jar.new</code> and <code>jar.unverified</code>)in mapsources directory
	 * 
	 * @throws IOException
	 */
	public void cleanMapPackDir() throws IOException {
		File[] newMapPacks = mapPackDir.listFiles(new FileExtFilter(".jar.new"));
		for (File newMapPack : newMapPacks)
			Utilities.deleteFile(newMapPack);
		File[] unverifiedMapPacks = mapPackDir.listFiles(new FileExtFilter(".jar.unverified"));
		for (File unverifiedMapPack : unverifiedMapPacks)
			Utilities.deleteFile(unverifiedMapPack);
	}

	/**
	 * Performs on map sources online update
	 * 
	 * @return <ul>
	 *         <li>0: no change in online md5 sum file (based on ETag)</li>
	 *         <li>-1: Online md5 file is empty indicationg that this MOBAc versiosn is no longer supported</li>
	 *         <li>x>0: Number of updated map packs</li>
	 *         </ul>
	 * @throws IOException
	 */
	public int updateMapPacks() throws UpdateFailedException, UnrecoverableDownloadException, IOException {
		String updateBaseUrl = System.getProperty("mobac.updatebaseurl");
		if (updateBaseUrl == null)
			throw new RuntimeException("Update base url not present");

		cleanMapPackDir();
		String md5sumList = downloadMD5SumList();
		if (md5sumList == null)
			return 0; // no new md5 file available
		if (md5sumList.length() == 0)
			return -1; // empty file means - outdated version
		int updateCount = 0;
		String[] outdatedMapPacks = searchForOutdatedMapPacks(md5sumList);
		for (String mapPack : outdatedMapPacks) {
			log.debug("Updaing map pack: " + mapPack);
			try {
				File newMapPackFile = downloadMapPack(updateBaseUrl, mapPack);
				try {
					testMapPack(newMapPackFile);
				} catch (CertificateException e) {
					// Certificate validation failed
					log.error(e.getMessage(), e);
					Utilities.deleteFile(newMapPackFile);
					continue;
				}
				log.debug("Verification of map pack \"" + mapPack + "\" passed successfully");

				// Check if the downloaded version is newer
				int newRev = getMapPackRevision(newMapPackFile);
				File oldMapPack = new File(mapPackDir, mapPack);
				int oldRev = -1;
				if (oldMapPack.isFile())
					oldRev = getMapPackRevision(oldMapPack);
				if (newRev < oldRev) {
					log.warn("Downloaded map pack was older than existing map pack - ignoring update");
					Utilities.deleteFile(newMapPackFile);
				} else {
					String name = newMapPackFile.getName();
					name = name.replace(".unverified", ".new");
					File f = new File(newMapPackFile.getParentFile(), name);
					// Change file extension
					Utilities.renameFile(newMapPackFile, f);
					updateCount++;
				}
			} catch (IOException e) {
				log.error(e.getMessage(), e);
			}
		}
		return updateCount;
	}

	public int getMapPackRevision(File mapPackFile) throws ZipException, IOException {
		ZipFile zip = new ZipFile(mapPackFile);
		try {
			ZipEntry entry = zip.getEntry("META-INF/MANIFEST.MF");
			if (entry == null)
				throw new ZipException("Unable to find MANIFEST.MF");
			Manifest mf = new Manifest(zip.getInputStream(entry));
			Attributes a = mf.getMainAttributes();
			String mpv = a.getValue("MapPackRevision").trim();
			return Utilities.parseSVNRevision(mpv);
		} catch (NumberFormatException e) {
			return -1;
		} finally {
			zip.close();
		}
	}

	public File downloadMapPack(String baseURL, String mapPackFilename) throws IOException {
		if (!mapPackFilename.endsWith(".jar"))
			throw new IOException("Invalid map pack filename");
		byte[] mapPackData = Utilities.downloadHttpFile(baseURL + mapPackFilename);
		File newMapPackFile = new File(mapPackDir, mapPackFilename + ".unverified");
		FileOutputStream out = new FileOutputStream(newMapPackFile);
		try {
			out.write(mapPackData);
			out.flush();
		} finally {
			Utilities.closeStream(out);
		}
		log.debug("New map pack \"" + mapPackFilename + "\" successfully downloaded");
		return newMapPackFile;
	}

	/**
	 * 
	 * @param md5sumList
	 * @return Array of filenames of map packs which are outdated
	 */
	public String[] searchForOutdatedMapPacks(String md5sumList) throws UpdateFailedException {
		ArrayList<String> outdatedMappacks = new ArrayList<String>();
		String[] md5s = md5sumList.split("[\\n\\r]+");
		Pattern linePattern = Pattern.compile("([0-9a-f]{32}) (mp-[\\w]+\\.jar)");

		for (String line : md5s) {
			line = line.trim();
			if (line.length() == 0)
				continue;
			Matcher m = linePattern.matcher(line);
			if (!m.matches()) {
				throw new UpdateFailedException("Invalid content found in md5 list: \"" + line + "\"");
			}
			String md5 = m.group(1);
			String filename = m.group(2);
			// Check if there is already an update map pack
			File mapPackFile = new File(mapPackDir, filename + ".new");
			if (!mapPackFile.isFile())
				mapPackFile = new File(mapPackDir, filename);
			if (!mapPackFile.isFile()) {
				outdatedMappacks.add(filename);
				log.debug("local map pack file missing: " + filename);
				continue;
			}
			try {
				String localmd5 = generateMappackMD5(mapPackFile);
				if (localmd5.equals(md5))
					continue; // No change in map pack
				log.debug("Found outdated map pack: \"" + filename + "\" local md5: " + localmd5 + " remote md5: "
						+ md5);
				outdatedMappacks.add(filename);
			} catch (Exception e) {
				log.error("Failed to generate md5sum of " + mapPackFile, e);
			}
		}
		String[] result = new String[outdatedMappacks.size()];
		outdatedMappacks.toArray(result);
		return result;
	}

	/**
	 * Calculate the md5sum on all files in the map pack file (except those in META-INF) and their filenames inclusive
	 * path in the map pack file).
	 * 
	 * @param mapPackFile
	 * @return
	 * @throws IOException
	 * @throws NoSuchAlgorithmException
	 */
	public String generateMappackMD5(File mapPackFile) throws IOException, NoSuchAlgorithmException {
		ZipFile zip = new ZipFile(mapPackFile);
		try {
			Enumeration<? extends ZipEntry> entries = zip.entries();
			MessageDigest md5Total = MessageDigest.getInstance("MD5");
			MessageDigest md5 = MessageDigest.getInstance("MD5");
			while (entries.hasMoreElements()) {
				ZipEntry entry = entries.nextElement();

				if (entry.isDirectory())
					continue;
				// Do not hash files from META-INF
				String name = entry.getName();
				if (name.toUpperCase().startsWith("META-INF"))
					continue;
				md5.reset();
				InputStream in = zip.getInputStream(entry);
				byte[] data = Utilities.getInputBytes(in);
				in.close();
				// name = name.replaceAll("\\\\", "/");
				byte[] digest = md5.digest(data);
				log.trace("Hashsum " + Hex.encodeHexString(digest) + " includes \"" + name + "\"");
				md5Total.update(digest);
				md5Total.update(name.getBytes());
			}
			String md5sum = Hex.encodeHexString(md5Total.digest());
			log.trace("md5sum of " + mapPackFile.getName() + ": " + md5sum);
			return md5sum;
		} finally {
			zip.close();
		}
	}

	/**
	 * Verifies the class file signatures of the specified map pack
	 * 
	 * @param mapPackFile
	 * @throws IOException
	 * @throws CertificateException
	 */
	public void testMapPack(File mapPackFile) throws IOException, CertificateException {
		String fileName = mapPackFile.getName();
		JarFile jf = new JarFile(mapPackFile, true);
		try {
			Enumeration<JarEntry> it = jf.entries();
			while (it.hasMoreElements()) {
				JarEntry entry = it.nextElement();
				// We verify only class files
				if (!entry.getName().endsWith(".class"))
					continue; // directory or other entry
				// Get the input stream (triggers) the signature verification for the specific class
				Utilities.readFully(jf.getInputStream(entry));
				if (entry.getCodeSigners() == null)
					throw new CertificateException("Unsigned class file found: " + entry.getName());
				CodeSigner signer = entry.getCodeSigners()[0];
				List<? extends Certificate> cp = signer.getSignerCertPath().getCertificates();
				if (cp.size() > 1)
					throw new CertificateException("Signature certificate not accepted: "
							+ "certificate path contains more than one certificate");
				// Compare the used certificate with the mapPack certificate
				if (!mapPackCert.equals(cp.get(0)))
					throw new CertificateException("Signature certificate not accepted: "
							+ "not the MapPack signer certificate");
			}
			Manifest mf = jf.getManifest();
			Attributes a = mf.getMainAttributes();
			String mpv = a.getValue("MapPackVersion");
			if (mpv == null)
				throw new IOException("MapPackVersion info missing!");
			int mapPackVersion = Integer.parseInt(mpv);
			if (requiredMapPackVersion != mapPackVersion)
				throw new IOException("This pack \"" + fileName + "\" is not compatible with this MOBAC version.");
			ZipEntry entry = jf.getEntry("META-INF/services/mobac.program.interfaces.MapSource");
			if (entry == null)
				throw new IOException("MapSources services list is missing in file " + fileName);
		} finally {
			jf.close();
		}

	}

	public static void main(String[] args) {
		try {
			Logging.configureConsoleLogging(Level.DEBUG);
			ProgramInfo.initialize();
			MapPackManager mpm = new MapPackManager(new File("mapsources"));
			// System.out.println(mpm.generateMappackMD5(new File("mapsources/mp-bing.jar")));
			mpm.updateMapPacks();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

}
