/**
 * Copyright 2012 52°North Initiative for Geospatial Open Source Software GmbH
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.n52.geoar.newdata;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
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.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.PlainSocketFactory;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import org.n52.geoar.GeoARApplication;
import org.n52.geoar.ar.view.IntroController;
import org.n52.geoar.newdata.CheckList.OnCheckedChangedListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import android.content.Context;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.util.Base64;

import com.vividsolutions.jts.geom.Geometry;
import com.vividsolutions.jts.geom.GeometryFactory;
import com.vividsolutions.jts.geom.PrecisionModel;

public class PluginLoader {

	/**
	 * Class to hold information about a plugin, usually obtained from its
	 * plugin descriptor file. At least an identifier is required to load a
	 * plugin.
	 * 
	 */
	public static class PluginInfo {
		public PluginInfo(File pluginFile, String name, String description,
				Long version, String identifier, String publisher) {
			this.name = name;
			this.description = description;
			this.version = version;
			this.pluginFile = pluginFile;
			this.identifier = identifier;
			this.publisher = publisher;
		}

		File pluginFile; // Path to the plugin
		String name;
		String description;
		Long version;
		String identifier;
		String publisher;
	}

	private static final FilenameFilter PLUGIN_FILENAME_FILTER = new FilenameFilter() {
		@Override
		public boolean accept(File dir, String fileName) {
			return fileName.endsWith(".apk") || fileName.endsWith(".zip")
					|| fileName.endsWith(".jar");
		}
	};
	private static final String PLUGIN_STATE_PREF = "selected_plugins";
	private static final int PLUGIN_STATE_VERSION = 4;
	private static final File PLUGIN_DIRECTORY_PATH = GeoARApplication.applicationContext
			.getExternalFilesDir(null);
	// Pattern captures the plugin version string
	private static final Pattern pluginVersionPattern = Pattern
			.compile("-(\\d+(?:\\.\\d+)*)[.-]");
	// Pattern captures the plugin name, ignoring the optional version and
	// filename ending
	private static final Pattern pluginNamePattern = Pattern
			.compile("^((?:.(?!-\\d+\\.))+.).*\\.[^.]+$");
	private static CheckList<InstalledPluginHolder> mInstalledPlugins = new CheckList<InstalledPluginHolder>(
			InstalledPluginHolder.class);
	private static List<DataSourceHolder> mDataSources = new ArrayList<DataSourceHolder>();
	private static boolean mReloadingPlugins;
	// Listener to update the list of currently available data sources
	private static OnCheckedChangedListener<InstalledPluginHolder> pluginCheckedChangedListener = new OnCheckedChangedListener<InstalledPluginHolder>() {
		@Override
		public void onCheckedChanged(InstalledPluginHolder item,
				boolean newState) {
			for (DataSourceHolder dataSource : item.getDataSources()) {
				if (newState == true) {
					addDataSource(dataSource);
				} else {
					removeDataSource(dataSource);
				}
			}

			if (newState && !mReloadingPlugins) {
				item.postConstruct();
			}
		}
	};
	private static DefaultHttpClient mHttpClient;
	private static GeometryFactory mGeometryFactory;

	private static final Logger LOG = LoggerFactory
			.getLogger(PluginLoader.class);

	static {
		mInstalledPlugins
				.addOnCheckedChangeListener(pluginCheckedChangedListener);
		mReloadingPlugins = true;
		loadPlugins();
		restoreState();
		mReloadingPlugins = false;
	}

	/**
	 * Returns a thread safe {@link DefaultHttpClient} instance to be reused
	 * among different parts of the application
	 * 
	 * @return
	 */
	public static DefaultHttpClient getSharedHttpClient() {
		if (mHttpClient == null) {
			SchemeRegistry registry = new SchemeRegistry();
			registry.register(new Scheme("http", PlainSocketFactory
					.getSocketFactory(), 80));
			registry.register(new Scheme("https", SSLSocketFactory
					.getSocketFactory(), 443));

			HttpParams httpParameters = new BasicHttpParams();
			HttpConnectionParams.setSoTimeout(httpParameters, 60000);
			HttpConnectionParams.setConnectionTimeout(httpParameters, 20000);
			ClientConnectionManager cm = new ThreadSafeClientConnManager(
					httpParameters, registry);
			mHttpClient = new DefaultHttpClient(cm, httpParameters);
		}

		return mHttpClient;
	}
	
	public static GeometryFactory getGeometryFactory(){
	    if(mGeometryFactory == null){
	        mGeometryFactory = new GeometryFactory();
	    }
	    return mGeometryFactory;
	}

	/**
	 * Restores the state of plugins from {@link SharedPreferences}. If an error
	 * occurs, e.g. if a previously selected plugin got removed, this function
	 * will quit silently.
	 */
	private static void restoreState() {
		try {
			SharedPreferences preferences = GeoARApplication.applicationContext
					.getSharedPreferences(GeoARApplication.PREFERENCES_FILE,
							Context.MODE_PRIVATE);

			byte[] data = Base64.decode(
					preferences.getString(PLUGIN_STATE_PREF, ""),
					Base64.DEFAULT);
			PluginStateInputStream objectInputStream = new PluginStateInputStream(
					new ByteArrayInputStream(data));

			int stateVersion = objectInputStream.readInt();
			if (stateVersion != PLUGIN_STATE_VERSION) {
				// Do not read state if preferences contains old/invalid state
				// information
				return;
			}

			// Restore plugin state
			int count = objectInputStream.readInt();
			for (int i = 0; i < count; i++) {

				InstalledPluginHolder plugin = getPluginByIdentifier(objectInputStream
						.readUTF());
				if (plugin == null) {
					return;
				}
				try {
					plugin.restoreState(objectInputStream);
					plugin.postConstruct();
				} catch (IOException e) {
					LOG.warn("Exception while restoring state of plugin "
							+ plugin.getName(), e);
				}
			}
			objectInputStream.close();
		} catch (Exception e) {
			LOG.error("Exception while restoring state ", e);
			// TODO
		}
	}

	/**
	 * Saves the state of plugins to {@link SharedPreferences}.
	 */
	public static void saveState() {
		try {
			ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
			ObjectOutputStream objectOutputStream = new ObjectOutputStream(
					outputStream);

			// store plugin state version
			objectOutputStream.writeInt(PLUGIN_STATE_VERSION);

			List<InstalledPluginHolder> checkedPlugins = mInstalledPlugins
					.getCheckedItems();
			objectOutputStream.writeInt(checkedPlugins.size());

			for (InstalledPluginHolder plugin : checkedPlugins) {
				objectOutputStream.writeUTF(plugin.getIdentifier());
				plugin.saveState(objectOutputStream);
			}

			SharedPreferences preferences = GeoARApplication.applicationContext
					.getSharedPreferences(GeoARApplication.PREFERENCES_FILE,
							Context.MODE_PRIVATE);
			Editor editor = preferences.edit();
			objectOutputStream.flush();
			editor.putString(PLUGIN_STATE_PREF, Base64.encodeToString(
					outputStream.toByteArray(), Base64.DEFAULT));
			editor.commit();
			objectOutputStream.close();
		} catch (IOException e) {
			e.printStackTrace();
			// TODO
		}
	}

	/**
	 * Parses a version string to long. Assumes that each part of a version
	 * string is < 100.
	 * 
	 * @param version
	 *            Version string, e.g. "1.2.3"
	 * @return long built by multiplying each version component by 100 to the
	 *         power of its position from the back, i.e. "0.0.1" -> 1, "0.1.0"
	 *         -> 100
	 */
	private static long parseVersionNumber(String version) {
		String[] split = version.split("\\.");
		long versionNumber = 0;
		for (int i = 0; i < split.length; i++) {
			int num = Integer.parseInt(split[i]);
			if (num < 0 || num >= 100) {
				throw new NumberFormatException(
						"Unable to parse version number, each part may not exceed 100");
			}
			versionNumber += Math.pow(100, (split.length - 1) - i) * num;
		}
		return versionNumber;
	}

	/**
	 * Extracts and parses the geoar-plugin.xml plugin-descriptor to create and
	 * fill a {@link PluginInfo} instance.
	 * 
	 * @param pluginFile
	 * @return
	 */
	private static PluginInfo readPluginInfoFromPlugin(File pluginFile) {
		try {
			ZipFile zipFile = new ZipFile(pluginFile);
			ZipEntry pluginDescriptorEntry = zipFile
					.getEntry("geoar-plugin.xml");
			if (pluginDescriptorEntry == null) {
				return null;
			}

			Document document = DocumentBuilderFactory.newInstance()
					.newDocumentBuilder()
					.parse(zipFile.getInputStream(pluginDescriptorEntry));
			// Find name
			String name = null;
			NodeList nodeList = document.getElementsByTagName("name");
			if (nodeList != null && nodeList.getLength() >= 1) {
				name = nodeList.item(0).getTextContent();
			} else {
				LOG.warn("Plugin Descriptor for " + pluginFile.getName()
						+ " does not specify a name");
			}

			// Find publisher
			String publisher = null;
			nodeList = document.getElementsByTagName("publisher");
			if (nodeList != null && nodeList.getLength() >= 1) {
				publisher = nodeList.item(0).getTextContent();
			} else {
				LOG.warn("Plugin Descriptor for " + pluginFile.getName()
						+ " does not specify a publisher");
			}

			// Find description
			String description = null;
			nodeList = document.getElementsByTagName("description");
			if (nodeList != null && nodeList.getLength() >= 1) {
				description = nodeList.item(0).getTextContent();
			} else {
				LOG.warn("Plugin Descriptor for " + pluginFile.getName()
						+ " does not specify a description");
			}

			// Find identifier
			String identifier = null;
			nodeList = document.getElementsByTagName("identifier");
			if (nodeList != null && nodeList.getLength() >= 1) {
				identifier = nodeList.item(0).getTextContent();
			} else {
				LOG.warn("Plugin Descriptor for " + pluginFile.getName()
						+ " does not specify an identifier");
			}

			// Find version
			Long version = null;
			nodeList = document.getElementsByTagName("version");
			if (nodeList != null && nodeList.getLength() >= 1) {
				String versionString = "-" + nodeList.item(0).getTextContent();

				Matcher matcher = pluginVersionPattern.matcher(versionString);
				if (matcher.find() && matcher.group(1) != null) {
					try {
						version = parseVersionNumber(matcher.group(1));
					} catch (NumberFormatException e) {
						LOG.error("Plugin filename version invalid: "
								+ matcher.group(1));
					}
				}
			} else {
				LOG.warn("Plugin Descriptor for " + pluginFile.getName()
						+ " does not specify a version");
			}

			if (identifier == null) {
				identifier = name;
			}

			return new PluginInfo(pluginFile, name, description, version,
					identifier, publisher);
		} catch (SAXException e) {
			e.printStackTrace();
		} catch (ParserConfigurationException e) {
			e.printStackTrace();
		} catch (ZipException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
		return null;
	}

	/**
	 * Reads {@link PluginInfo} from the path of a plugin. This will only
	 * extract a name and version information
	 * 
	 * @param pluginFile
	 * @return
	 */
	private static PluginInfo readPluginInfoFromFilename(File pluginFile) {
		String pluginFileName = pluginFile.getName();

		Matcher matcher = pluginNamePattern.matcher(pluginFileName);
		if (!matcher.matches()) {
			LOG.error("Plugin filename invalid: " + pluginFileName);
			return null;
		}

		String name = matcher.group(1);

		Long version = null;
		matcher = pluginVersionPattern.matcher(pluginFileName);
		if (matcher.find() && matcher.group(1) != null) {
			try {
				version = parseVersionNumber(matcher.group(1));
			} catch (NumberFormatException e) {
				LOG.error("Plugin filename version invalid: "
						+ matcher.group(1));
			}
		}

		return new PluginInfo(pluginFile, name, null, version, name, null);
	}

	/**
	 * Loads all plugins from SD card, compares version strings for plugins with
	 * same names and loads the most recent plugin.
	 * 
	 * The plugin name is the filename without its ending and without optional
	 * version string. A version string is introduced by a hyphen, following
	 * dot-separated numbers, e.g. "-1.2.3"
	 */
	private static void loadPlugins() {
		String[] apksInDirectory = PLUGIN_DIRECTORY_PATH
				.list(PLUGIN_FILENAME_FILTER);

		if (apksInDirectory == null || apksInDirectory.length == 0){
			IntroController.startIntro(!mInstalledPlugins.isEmpty());
			return;
		}
			

		// Map to store all plugins with their versions for loading only the
		// newest ones
		HashMap<String, PluginInfo> pluginVersionMap = new HashMap<String, PluginInfo>();

		for (String pluginFileName : apksInDirectory) {

			PluginInfo pluginInfo = readPluginInfoFromPlugin(new File(
					PLUGIN_DIRECTORY_PATH, pluginFileName));
			if (pluginInfo == null) {
				LOG.info("Plugin " + pluginFileName
						+ " has no plugin descriptor");
				pluginInfo = readPluginInfoFromFilename(new File(
						PLUGIN_DIRECTORY_PATH, pluginFileName));
			}

			if (pluginInfo.identifier == null) {
				LOG.error("Plugin "
						+ pluginFileName
						+ " has an invalid plugin descriptor and an invalid filename. Plugin excluded from loading.");
				continue;
			}
			if (pluginInfo.version == null) {
				pluginInfo.version = -1L; // Set unknown version to version -1
			}

			PluginInfo pluginInfoMapping = pluginVersionMap
					.get(pluginInfo.identifier);
			if (pluginInfoMapping == null
					|| pluginInfoMapping.version < pluginInfo.version) {
				// Plugin not yet known or newer
				pluginVersionMap.put(pluginInfo.identifier, pluginInfo);
			}
		}

		for (PluginInfo pluginInfo : pluginVersionMap.values()) {
			InstalledPluginHolder pluginHolder = new InstalledPluginHolder(
					pluginInfo);
			mInstalledPlugins.add(pluginHolder);
		}
	}

	/**
	 * Reloads all plugins. The current state of the plugins are restored
	 * afterwards.
	 */
	public static void reloadPlugins() {
		mReloadingPlugins = true;
		saveState();
		mInstalledPlugins.clear();
		mDataSources.clear();
		loadPlugins();
		restoreState();
		mReloadingPlugins = false;
	}

	public static CheckList<InstalledPluginHolder> getInstalledPlugins() {
		return mInstalledPlugins;
	}

	private static void addDataSource(DataSourceHolder dataSource) {
		if (!mDataSources.contains(dataSource))
			mDataSources.add(dataSource);
	}

	private static void removeDataSource(DataSourceHolder dataSource) {
		mDataSources.remove(dataSource);
	}

	/**
	 * Returns all {@link DataSourceHolder} of currently activated plugins.
	 * 
	 * @return
	 */
	public static List<DataSourceHolder> getDataSources() {
		return mDataSources;
	}
	
	public static boolean hasDataSources(){
		return mInstalledPlugins == null ? false : mInstalledPlugins.size() > 0;
	}

	public static InstalledPluginHolder getPluginByIdentifier(String identifier) {
		for (InstalledPluginHolder plugin : mInstalledPlugins) {
			if (plugin.getIdentifier().equals(identifier)) {
				return plugin;
			}
		}
		return null;
	}

}
