/*
 * InstallPanel.java - For installing plugins
 * :tabSize=4:indentSize=4:noTabs=false:
 * :folding=explicit:collapseFolds=1:
 *
 * Copyright (C) 2002-2013 Kris Kopicki, Slava Pestov, Dale Anson,
 *      Matthieu Casanova, Alan Ezust, Björn "Vampire" Kautler
 *
 * 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 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, write to the Free Software
 * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
 */

package org.gjt.sp.jedit.pluginmgr;

//{{{ Imports
import org.gjt.sp.jedit.*;
import org.gjt.sp.jedit.browser.VFSBrowser;
import org.gjt.sp.jedit.gui.RolloverButton;
import org.gjt.sp.jedit.io.VFS;
import org.gjt.sp.jedit.io.VFSManager;
import org.gjt.sp.util.*;
import org.gjt.sp.util.swing.event.UniqueActionDocumentListener;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import javax.swing.*;
import javax.swing.border.EmptyBorder;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.swing.table.AbstractTableModel;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.table.JTableHeader;
import javax.swing.table.TableColumn;
import java.awt.*;
import java.awt.event.*;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.text.MessageFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.List;
//}}}

/**
 * @version $Id: InstallPanel.java 25285 2020-04-22 19:08:46Z kpouer $
 */
class InstallPanel extends JPanel implements EBComponent
{
	//{{{ Variables
	private final JTable table;
	private final JScrollPane scrollpane;
	private final PluginTableModel pluginModel;
	private final PluginManager window;
	private final PluginInfoBox infoBox;
	private final ChoosePluginSet chooseButton;
	private final boolean updates;
	private final CardLayout layout;
	private final JTextField searchField;
	private boolean hideInstalled;
	private boolean isLoading;

	private final Collection<String> pluginSet = new HashSet<>();
	//}}}

	//{{{ InstallPanel constructor
	InstallPanel(PluginManager window, boolean updates)
	{
		super(null);
		setLayout(layout = new CardLayout());

		this.window = window;
		this.updates = updates;

		setBorder(new EmptyBorder(12,12,12,12));

		final JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
		split.setResizeWeight(0.75);
		/* Setup the table */
		table = new JTable(pluginModel = new PluginTableModel());
		table.setShowGrid(false);
		table.setIntercellSpacing(new Dimension(0,0));
		table.setRowHeight(GenericGUIUtilities.defaultRowHeight() + 2);
		table.setPreferredScrollableViewportSize(new Dimension(500,200));
		table.setDefaultRenderer(Object.class, new TextRenderer(
			(DefaultTableCellRenderer)table.getDefaultRenderer(Object.class)));
		table.addFocusListener(new TableFocusHandler());
		InputMap tableInputMap = table.getInputMap(JComponent.WHEN_FOCUSED);
		ActionMap tableActionMap = table.getActionMap();
		tableInputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB,0),"tabOutForward");
		tableActionMap.put("tabOutForward",new KeyboardAction(KeyboardCommand.TAB_OUT_FORWARD));
		tableInputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB,InputEvent.SHIFT_DOWN_MASK),"tabOutBack");
		tableActionMap.put("tabOutBack",new KeyboardAction(KeyboardCommand.TAB_OUT_BACK));
		tableInputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE,0),"editPlugin");
		tableActionMap.put("editPlugin",new KeyboardAction(KeyboardCommand.EDIT_PLUGIN));
		tableInputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER,0),"closePluginManager");
		tableActionMap.put("closePluginManager",new KeyboardAction(KeyboardCommand.CLOSE_PLUGIN_MANAGER));

		TableColumn col1 = table.getColumnModel().getColumn(0);
		TableColumn col2 = table.getColumnModel().getColumn(1);
		TableColumn col3 = table.getColumnModel().getColumn(2);
		TableColumn col4 = table.getColumnModel().getColumn(3);
		TableColumn col5 = table.getColumnModel().getColumn(4);

		col1.setPreferredWidth(30);
		col1.setMinWidth(30);
		col1.setMaxWidth(30);
		col1.setResizable(false);

		col2.setPreferredWidth(180);
		col3.setPreferredWidth(130);
		col4.setPreferredWidth(70);
		col5.setPreferredWidth(70);

		JTableHeader header = table.getTableHeader();
		header.setReorderingAllowed(false);
		header.addMouseListener(new HeaderMouseHandler());
		header.setDefaultRenderer(new HeaderRenderer(
			(DefaultTableCellRenderer)header.getDefaultRenderer()));

		scrollpane = new JScrollPane(table);
		scrollpane.getViewport().setBackground(table.getBackground());
		split.setTopComponent(scrollpane);

		/* Create description */
		JScrollPane infoPane = new JScrollPane(infoBox = new PluginInfoBox());
		infoPane.setPreferredSize(new Dimension(500,100));
		split.setBottomComponent(infoPane);

		EventQueue.invokeLater(() -> split.setDividerLocation(0.75));

		searchField = new JTextField();
		searchField.addKeyListener(new KeyAdapter()
		{
			@Override
			public void keyPressed(KeyEvent e)
			{
				if (e.getKeyCode() == KeyEvent.VK_DOWN || e.getKeyCode() == KeyEvent.VK_UP)
				{
					table.dispatchEvent(e);
					table.requestFocus();
				}
			}
		});
		searchField.getDocument().addDocumentListener(new UniqueActionDocumentListener(e -> pluginModel.setFilterString(searchField.getText())));
		table.addKeyListener(new KeyAdapter()
		{
			@Override
			public void keyPressed(KeyEvent e)
			{
				int i = table.getSelectedRow(), n = table.getModel().getRowCount();
				if (e.getKeyChar() == '/' ||
					e.getKeyCode() == KeyEvent.VK_DOWN && i == (n - 1) ||
					e.getKeyCode() == KeyEvent.VK_UP && i == 0)
				{
					searchField.requestFocus();
					searchField.selectAll();
				}
			}
			@Override
			public void keyTyped(KeyEvent e)
			{
				char c = e.getKeyChar();
				if(c != KeyEvent.CHAR_UNDEFINED && Character.isAlphabetic(c))
				{
					searchField.dispatchEvent(e);
					searchField.requestFocus();
				}
			}
		});
		hideInstalled = !updates;
		final JCheckBox hideInstalledCB = new JCheckBox("Hide installed plugins", hideInstalled);
		hideInstalledCB.addActionListener(e ->
		{
			hideInstalled = hideInstalledCB.isSelected();
			updateModel();
		});
		hideInstalledCB.setAlignmentX(0);

		Box filterBox = Box.createHorizontalBox();
		filterBox.add(new JLabel("Filter : "));
		filterBox.add(searchField);
		filterBox.setAlignmentX(0);
		Box topBox = new Box(BoxLayout.PAGE_AXIS);
		topBox.add(filterBox);
		if(!updates)
		{
			topBox.add(hideInstalledCB);
		}

		/* Create buttons */
		Box buttons = new Box(BoxLayout.X_AXIS);

		buttons.add(new InstallButton());
		buttons.add(Box.createHorizontalStrut(12));
		buttons.add(new SelectallButton());
		buttons.add(chooseButton = new ChoosePluginSet());
		buttons.add(new ClearPluginSet());
		buttons.add(Box.createGlue());
		buttons.add(new SizeLabel());

		JPanel _installPanel = new JPanel(new BorderLayout(12, 12));
		_installPanel.add(BorderLayout.NORTH,topBox);
		_installPanel.add(BorderLayout.CENTER,split);
		_installPanel.add(BorderLayout.SOUTH, buttons);

		add(_installPanel, "INSTALL");

		JPanel loadingLabelPanel = new JPanel(new GridBagLayout());
		loadingLabelPanel.add(new JLabel("<html><b><strong>" + jEdit.getProperty("common.loading", "Loading...") +
								  "</strong></b></html>"));
		add(loadingLabelPanel, "LOADING");

		JPanel noAvailablePluginsPanel = new JPanel(new GridBagLayout());
		noAvailablePluginsPanel.add(new JLabel("<html><b><strong>" +
			jEdit.getProperty("options.plugin-manager.no-plugin-available", "No available plugins...") +
			"</strong></b></html>"));
		add(noAvailablePluginsPanel, "NO_PLUGIN_AVAILABLE");

		JPanel pluginsAreUpToDatePanel = new JPanel(new GridBagLayout());
		pluginsAreUpToDatePanel.add(new JLabel("<html><b><strong>" +
			jEdit.getProperty("options.plugin-manager.no-plugin-uptodate", "Plugins are up to date...") +
			"</strong></b></html>"));
		add(pluginsAreUpToDatePanel, "PLUGIN_ARE_UP_TO_DATE");

		layout.show(this, "INSTALL");
		String path = jEdit.getProperty(PluginManager.PROPERTY_PLUGINSET, "");
		if (!path.isEmpty())
		{
			try
			{
				loadPluginSet(path);
			}
			catch (IOException e)
			{
				Log.log(Log.WARNING, this, "Loading Pluginset failed:" + e.getMessage());
			}
		}
	} //}}}

	//{{{ loadPluginSet() methods
	/**
	 * loads a pluginSet xml file and updates the model to reflect
	 * certain checked selections
	 * @since jEdit 4.3pre10
	 * @author Alan Ezust
	 */
	void loadPluginSet(String path) throws IOException
	{
		loadPluginSet(path, null);
	}

	/**
	 * loads a pluginSet xml file and updates the model to reflect
	 * certain checked selections
	 * @param path the path of the pluginset to load
	 * @param edtTask a task that will be executed in the Event dispatcher thread after the plugin set was loaded
	 * @since jEdit 5.6pre1
	 * @author Alan Ezust
	 */
	void loadPluginSet(String path, Runnable edtTask) throws IOException
	{
		pluginSet.clear();
		pluginModel.clearSelection();

		VFS vfs = VFSManager.getVFSForPath(path);
		Object session = vfs.createVFSSession(path, InstallPanel.this);
		InputStream is = vfs._createInputStream(session, path, false, InstallPanel.this);
		XMLUtilities.parseXML(is, new StringMapHandler());
		pluginModel.update(edtTask);
	} //}}}

	//{{{ updateModel() method
	/**
	 * Must be called from the EDT.
	 **/
	public void updateModel()
	{
		infoBox.setText(null);
		pluginModel.update(() ->
		{
			if (pluginModel.getRowCount() == 0)
			{
				if (updates)
				layout.show(InstallPanel.this, "PLUGIN_ARE_UP_TO_DATE");
			else
				layout.show(InstallPanel.this, "NO_PLUGIN_AVAILABLE");
			}
			else
			{
				layout.show(InstallPanel.this, "INSTALL");
				EventQueue.invokeLater(searchField::requestFocusInWindow);
			}
			isLoading = false;
		});
	} //}}}

	//{{{ handleMessage() method
	@Override
	public void handleMessage(EBMessage message)
	{
		 if (message.getSource() == PluginManager.getInstance())
		 {
			 chooseButton.path = jEdit.getProperty(PluginManager.PROPERTY_PLUGINSET, "");
			 if (!chooseButton.path.isEmpty())
			 {
				 try
				 {
					 loadPluginSet(chooseButton.path, () ->
					 {
						 pluginModel.clearSelection();
						 chooseButton.updateUI();
					 });
				 }
				 catch (IOException e)
				 {
					 Log.log(Log.WARNING, this, "Loading Pluginset failed:" + e.getMessage());
				 }
			 }
		}
	} //}}}

	//{{{ loading() method
	void loading()
	{
		layout.show(this, "LOADING");
		isLoading = true;
	} //}}}

	//{{{ Private members

	//{{{ isDownloadingList() method
	private boolean isDownloadingList()
	{
		return isLoading;
	} //}}}


	//{{{ formatSize() method
	private static String formatSize(int size)
	{
		NumberFormat df = NumberFormat.getInstance();
		df.setMaximumFractionDigits(1);
		df.setMinimumFractionDigits(0);
		String sizeText;
		if (size < 1048576)
			sizeText = (size >> 10) + "KB";
		else
			sizeText = df.format(size/ 1048576.0d) + "MB";
		return sizeText;
	} //}}}

	//}}}

	//{{{ Inner classes

	//{{{ PluginTableModel class
	private class PluginTableModel extends AbstractTableModel
	{
		private List<Entry> entries = new ArrayList<>();
		private final List<Entry> filteredEntries = new ArrayList<>();
		private int sortType = EntryCompare.COLUMN_NAME;
		private String filterString;
		int sortDirection = 1;

		//{{{ setFilterString() method
		public void setFilterString(String filterString)
		{
			this.filterString = filterString;
			updateFilteredEntries();
		} //}}}

		//{{{ updateFilteredEntries() method
		void updateFilteredEntries()
		{
			filteredEntries.clear();
			if (filterString == null)
			{
				if(!hideInstalled)
				{
					filteredEntries.addAll(entries);
				}
				else
				{
					for(Entry e: entries)
					{
						if(e.install || e.installedVersion == null || updates)
						{
							filteredEntries.add(e);
						}
					}
				}
			}
			else
			{
				String[] words = filterString.toLowerCase().split("\\s+");
				for (Entry e : entries)
				{
					if (e.install)
					{
						filteredEntries.add(e);
						continue;
					}

					if(hideInstalled && e.installedVersion != null)
					{
						continue;
					}

					String s = (e.name + ' ' + e.set + ' ' + e.description).toLowerCase();
					boolean hasAll = true;
					boolean negative = false;
					for (String word : words)
					{
						if (word.startsWith("-"))
						{
							word = word.substring(1);
							negative = true;
						}
						if (word.isEmpty())
							continue;

						if (negative == s.contains(word))
						{
							hasAll = false;
							break;
						}
						negative = false;
					}
					if (hasAll)
						filteredEntries.add(e);
				}
			}
			fireTableChanged(new TableModelEvent(PluginTableModel.this));
		} //}}}

		//{{{ getColumnClass() method
		@Override
		public Class getColumnClass(int columnIndex)
		{
			switch (columnIndex)
			{
				case 0: return Boolean.class;
				case 1:
				case 2:
				case 3:
				case 4:
				case 5: return Object.class;
				default: throw new Error("Column out of range");
			}
		} //}}}

		//{{{ getColumnCount() method
		@Override
		public int getColumnCount()
		{
			return 6;
		} //}}}

		//{{{ getColumnName() method
		@Override
		public String getColumnName(int column)
		{
			switch (column)
			{
				case 0: return " ";
				case 1: return ' '+jEdit.getProperty("install-plugins.info.name");
				case 2: return ' '+jEdit.getProperty("install-plugins.info.category");
				case 3: return ' '+jEdit.getProperty("install-plugins.info.version");
				case 4: return ' '+jEdit.getProperty("install-plugins.info.size");
				case 5: return ' '+jEdit.getProperty("install-plugins.info.releaseDate");
				default: throw new Error("Column out of range");
			}
		} //}}}

		//{{{ getRowCount() method
		@Override
		public int getRowCount()
		{
			return filteredEntries.size();
		} //}}}

		//{{{ getValueAt() method
		@Override
		public Object getValueAt(int rowIndex,int columnIndex)
		{
			Entry entry = filteredEntries.get(rowIndex);
			switch (columnIndex)
			{
				case 0:
					return entry.install;
				case 1:
					return entry.name;
				case 2:
					return entry.set;
				case 3:
					if ((entry.installedVersion != null) && !entry.installedVersion.equals(entry.version))
						return entry.installedVersion + "->" + entry.version;
					return entry.version;
				case 4:
					return formatSize(entry.size);
				case 5:
					return entry.date;
				default:
					throw new Error("Column out of range");
			}
		} //}}}

		//{{{ isCellEditable() method
		@Override
		public boolean isCellEditable(int rowIndex, int columnIndex)
		{
			return columnIndex == 0;
		} //}}}

		//{{{ setSelectAll() method
		public void setSelectAll(boolean b)
		{
			if(isDownloadingList())
				return;

			int length = getRowCount();
			for (int i = 0; i < length; i++)
			{
				if (b)
					setValueAt(Boolean.TRUE,i,0);
				else
				{
					Entry entry = filteredEntries.get(i);
					entry.dependents = new LinkedList<>();
					entry.install = false;
				}
			}
			fireTableChanged(new TableModelEvent(this));
		} //}}}

		//{{{ setSortType() method
		public void setSortType(int type)
		{
			sortType = type;
			sort(type);
		} //}}}

		//{{{ deselectDependents() method
		/**
		 * deselect all plugins depending upon entry after a warning.
		 * If user cancels, reinstate install field of entry
		 * @param entry	the entry that would be no more installed
		 * @return	confirmed that deselection may proceed
		 */
		private boolean deselectDependents(Entry entry)
		{
			Entry[] dependents = entry.getTransitiveDependents();

			if (dependents.length == 0)
				return true;

			String[] args = { entry.name };
			int result = GUIUtilities.listConfirm(
				window,"plugin-manager.dependency",
				args,dependents);
			if (result != JOptionPane.OK_OPTION)
			{
				entry.install = true;
				return false;
			}

			for(Entry dependent: dependents)
			{
				 dependent.install = false;
				 dependent.checked = false;
				 // must setInstall() to remove the
				 // plugin from its dependencies 'dependents' list
				 // so that they can be unchecked if no longer required
				 updateDeps(dependent);
			}
			return true;
		} //}}}

		//{{{ setValueAt() method
		@Override
		public void setValueAt(Object aValue, int row, int column)
		{
			if (column != 0) return;

			Entry entry = filteredEntries.get(row);

			boolean before = entry.install;
			entry.install = Boolean.TRUE.equals(aValue);
			if (before == entry.install) return;

			// deselect after a warning if entry is required
			// by some other selected plugin.
			// If the user cancelled, don't proceed
			if (!entry.install && !deselectDependents(entry))
			{
				return;
			}

			// checked is set after deselectDependents to prevent
			// override when the user cancelled deselectDependents
			entry.checked = entry.install;

			updateDeps(entry);

			/* prune entries to install to keep only the entries
			 * really checked by the user.
			 * Removes dependencies no longer required and such */

			Collection<Entry> selected = new ArrayList<>(entries.size());
			for(Entry temp: entries)
			{
				if(temp.install)selected.add(temp);
			}

			Collection<Entry> toRemove = new ArrayList<>(selected.size());
			boolean changed;
			do{
				changed = false;
				for(Entry temp: selected)
				{
					temp.dependents.removeAll(toRemove);
					if(!temp.checked && temp.dependents.isEmpty())
					{
						toRemove.add(temp);
						temp.install = false;
						changed = true;
					}
				}
				selected.removeAll(toRemove);
			}while(changed);

			updateFilteredEntries();
			// make the row selected after updated filtering
			for(int i=0; i< filteredEntries.size(); i++)
			{
				if(entry == filteredEntries.get(i))
				{
					table.setRowSelectionInterval(i, i);
					break;
				}
			}
		} //}}}

		//{{{ updateDeps() method
		/***
		 * recursively add dependencies to install,
		 * or remove entry from its dependencies' dependents list
		 * @param entry	entry whose install field has been set
		 */
		private void updateDeps(Entry entry)
		{
			List<PluginList.Dependency> deps = entry.plugin.getCompatibleBranch().deps;

			for (PluginList.Dependency dep: deps)
			{
				if ("plugin".equals(dep.what))
				{
					for (Entry temp: entries)
					{
						if (temp.plugin == dep.plugin)
						{
							if (entry.install)
							{
								temp.dependents.add(entry);
								if(!temp.install)
								{
									if (temp.installedVersion == null || !temp.plugin.loaded)
									{
										temp.install = true;
									}
									updateDeps(temp);
								}
							}
							else
							{
								temp.dependents.remove(entry);
							}
							break;
						}
					}
				}
			}
		} //}}}

		//{{{ sort() method
		public void sort(int type)
		{
			Set<String> savedChecked = new HashSet<>();
			Set<String> savedSelection = new HashSet<>();
			saveSelection(savedChecked,savedSelection);

			if (sortType != type)
			{
				sortDirection = 1;
			}
			sortType = type;

			entries.sort(new EntryCompare(type, sortDirection));
			updateFilteredEntries();
			restoreSelection(savedChecked,savedSelection);
			table.getTableHeader().repaint();
		}
		//}}}

		//{{{ update() method
		public void update(Runnable edtTask)
		{
			ThreadUtilities.runInBackground(new UpdateModelTask(edtTask));
		} //}}}

		public List<Entry> buildEntryList()
		{
			PluginList pluginList = window.getPluginList();
			if (pluginList == null)
				return null;

			List<Entry> entries = Collections.synchronizedList(new ArrayList<>());
			pluginList.pluginSets
				.stream()
				.parallel()
				.forEach(set ->
				{
					set.plugins
						.stream()
						.parallel()
						.map(pluginList.pluginHash::get).forEach(plugin ->
					{
						PluginList.Branch branch = plugin.getCompatibleBranch();
						String installedVersion = plugin.getInstalledVersion();
						if (updates)
						{
							if (branch != null
								&& branch.canSatisfyDependencies()
								&& installedVersion != null
								&& StandardUtilities.compareStrings(branch.version,
								installedVersion, false) > 0)
							{
								entries.add(new Entry(plugin, set.name));
							}
						}
						else
						{
							if (plugin.canBeInstalled())
								entries.add(new Entry(plugin, set.name));
						}
					});
				});
			return entries;
		}

		//{{{ saveSelection() method
		public void saveSelection(Set<String> savedChecked, Set<String> savedSelection)
		{
			if (entries.isEmpty())
				return;
			for (int i=0, c=getRowCount() ; i<c ; i++)
			{
				if ((Boolean)getValueAt(i,0))
				{
					savedChecked.add(filteredEntries.get(i).toString());
				}
			}
			int[] rows = table.getSelectedRows();
			for (int row: rows)
			{
				savedSelection.add(filteredEntries.get(row).toString());
			}
		} //}}}

		//{{{ clearSelection() method
		public void clearSelection()
		{
			restoreSelection(Collections.emptySet(), Collections.emptySet());
		} //}}}

		//{{{ restoreSelection() method
		public void restoreSelection(Set<String> savedChecked, Set<String> savedSelection)
		{
			for (int i=0, c=getRowCount() ; i<c ; i++)
			{
				Entry entry = filteredEntries.get(i);
				String name = entry.plugin.jar;
				if (pluginSet.contains(name))
					setValueAt(true, i, 0);
				else setValueAt(savedChecked.contains(name), i, 0);
			}
			if (table == null) return;

			table.setColumnSelectionInterval(0,0);
			if (!savedSelection.isEmpty())
			{
				int i = 0;
				int rowCount = getRowCount();
				for ( ; i<rowCount ; i++)
				{
					String name = filteredEntries.get(i).toString();
					if (savedSelection.contains(name))
					{
						table.setRowSelectionInterval(i,i);
						break;
					}
				}
				ListSelectionModel lsm = table.getSelectionModel();
				for ( ; i<rowCount ; i++)
				{
					String name = filteredEntries.get(i).toString();
					if (savedSelection.contains(name))
					{
						lsm.addSelectionInterval(i,i);
					}
				}
			}
			else
			{
				if (table.getRowCount() != 0)
					table.setRowSelectionInterval(0,0);
				JScrollBar scrollbar = scrollpane.getVerticalScrollBar();
				scrollbar.setValue(scrollbar.getMinimum());
			}
		} //}}}

		private class UpdateModelTask extends Task
		{
			private final Runnable edtTask;

			UpdateModelTask(Runnable edtTask)
			{
				this.edtTask = edtTask;
			}

			@Override
			public void _run()
			{
				List<Entry> newEntries = buildEntryList();
				if (newEntries == null)
					return;

				ThreadUtilities.runInDispatchThread(() ->
				{
					Set<String> savedChecked = new HashSet<>();
					Set<String> savedSelection = new HashSet<>();
					saveSelection(savedChecked,savedSelection);

					entries = newEntries;
					sort(sortType);
					restoreSelection(savedChecked, savedSelection);
					if (edtTask != null)
						edtTask.run();
				});
			}
		}
	} //}}}

	//{{{ Entry class
	private static class Entry
	{
		String name, installedVersion, version, author, date, description, set, dependencies;

		long timestamp;
		int size;
		boolean checked;
		boolean install;
		PluginList.Plugin plugin;
		List<Entry> dependents = new LinkedList<>();

		Entry(PluginList.Plugin plugin, String set)
		{
			PluginList.Branch branch = plugin.getCompatibleBranch();
			boolean downloadSource = jEdit.getBooleanProperty("plugin-manager.downloadSource");
			int size = downloadSource ? branch.downloadSourceSize : branch.downloadSize;

			name = plugin.name;
			author = plugin.author;
			installedVersion = plugin.getInstalledVersion();
			version = branch.version;
			this.size = size;
			date = branch.date;
			description = plugin.description;
			dependencies = branch.depsToString();
			this.set = set;
			install = false;
			checked = false;
			this.plugin = plugin;
			SimpleDateFormat format = new SimpleDateFormat("d MMMM yyyy", Locale.ENGLISH);
			try
			{
				timestamp = format.parse(date).getTime();
			}
			catch (ParseException e)
			{
				Log.log(Log.ERROR, this, e);
			}
		}

		private void getTransitiveDependents(List<Entry> list)
		{
			for (Entry entry : dependents)
			{
				if (entry.install && !list.contains(entry))
				{
					list.add(entry);
					entry.getTransitiveDependents(list);
				}
			}
		}

		Entry[] getTransitiveDependents()
		{
			List<Entry> list = new ArrayList<>();
			getTransitiveDependents(list);
			Entry[] array = list.toArray(new Entry[0]);
			Arrays.sort(array,new StandardUtilities.StringCompare<>(true));
			return array;
		}

		@Override
		public String toString()
		{
			return name;
		}
	} //}}}

	//{{{ PluginInfoBox class
	/**
	 * TODO: refactor to use the PluginDetailPanel?
	 */
	private class PluginInfoBox extends JEditorPane implements ListSelectionListener
	{
		PluginInfoBox()
		{
			setBackground(jEdit.getColorProperty("view.bgColor"));
			setForeground(jEdit.getColorProperty("view.fgColor"));
			putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true);
			setContentType("text/html");
			setEditable(false);
			table.getSelectionModel().addListSelectionListener(this);
		}


		@Override
		public void valueChanged(ListSelectionEvent e)
		{
			String text = getText();
			if (table.getSelectedRowCount() == 1)
			{
				Entry entry = pluginModel.filteredEntries.get(table.getSelectedRow());
				String pattern = "<b>{0}</b>: {1}<br><b>{2}</b>: {3}<br><br>{4}";
				Collection<String> params = new ArrayList<>();
				params.add(jEdit.getProperty("install-plugins.info.author", "Author"));
				params.add(entry.author);
				params.add(jEdit.getProperty("install-plugins.info.released", "Released"));
				params.add(entry.date);
				params.add(entry.description);
				if (entry.dependencies != null && !entry.dependencies.isEmpty())
				{
					pattern += "<br><br><b>{5}</b>:<br>{6}";
					params.add(jEdit.getProperty("install-plugins.info.depends", "Depends on"));
					StringList sl = StringList.split(entry.dependencies, "\n");
					params.add(sl.join(", "));
				}
				text = MessageFormat.format(pattern, params.toArray());
			}
			setText(text);
			setCaretPosition(0);
			putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true);
		}
	} //}}}

	//{{{ SizeLabel class
	private class SizeLabel extends JLabel implements TableModelListener
	{
		private int size;
		private int nbPlugins;

		SizeLabel()
		{
			update();
			pluginModel.addTableModelListener(this);
		}

		@Override
		public void tableChanged(TableModelEvent e)
		{
			if (e.getType() == TableModelEvent.UPDATE)
			{
				if(isDownloadingList())
					return;

				size = 0;
				nbPlugins = 0;
				int length = pluginModel.entries.size();
				for (int i = 0; i < length; i++)
				{
					Entry entry = pluginModel.entries.get(i);
					if (entry.install)
					{
						nbPlugins++;
						size += entry.size;
					}
				}
				update();
			}
		}

		private void update()
		{
			Object[] args = {nbPlugins, formatSize(size)};
			setText(jEdit.getProperty("install-plugins.totalSize", args));
		}
	} //}}}

	//{{{ SelectallButton class
	private class SelectallButton extends JCheckBox implements ActionListener, TableModelListener
	{
		SelectallButton()
		{
			super(jEdit.getProperty("install-plugins.select-all"));
			addActionListener(this);
			pluginModel.addTableModelListener(this);
			// setEnabled(false);
		}

		@Override
		public void actionPerformed(ActionEvent evt)
		{
			pluginModel.setSelectAll(isSelected());
		}

		@Override
		public void tableChanged(TableModelEvent e)
		{
			if(isDownloadingList())
				return;

			setEnabled(pluginModel.getRowCount() != 0);
			if (e.getType() == TableModelEvent.UPDATE)
			{
				int length = pluginModel.getRowCount();
				for (int i = 0; i < length; i++)
					if (!(Boolean) pluginModel.getValueAt(i, 0))
					{
						setSelected(false);
						return;
					}
				if (length > 0)
					setSelected(true);
			}
		}
	} //}}}

	//{{{ StringMapHandler class
	/** For parsing the pluginset xml files into pluginSet */
	private class StringMapHandler extends DefaultHandler
	{
		StringMapHandler()
		{
			pluginSet.clear();
		}

		@Override
		public void startElement(String uri, String localName, String qName, Attributes attrs) throws SAXException
		{
			if ("plugin".equals(localName))
			{
				pluginSet.add(attrs.getValue("jar"));
			}
		}
	} //}}}

	//{{{ ChoosePluginSet class
	private class ChoosePluginSet extends RolloverButton implements ActionListener
	{
		private String path;

		//{{{ ChoosePluginSet constructor
		ChoosePluginSet()
		{
			setIcon(GUIUtilities.loadIcon(jEdit.getProperty("install-plugins.choose-plugin-set.icon")));
			addActionListener(this);
			updateUI();
		} //}}}

		//{{{ updateUI method
		@Override
		public void updateUI()
		{
			path = jEdit.getProperty(PluginManager.PROPERTY_PLUGINSET, "");
			if (path.length()<1) setToolTipText ("Click here to choose a predefined plugin set");
			else setToolTipText ("Choose pluginset (" + path + ')');
			super.updateUI();
		}//}}}

		//{{{ actionPerformed() method
		@Override
		public void actionPerformed(ActionEvent ae)
		{
			path = jEdit.getProperty(PluginManager.PROPERTY_PLUGINSET,
				jEdit.getSettingsDirectory() + File.separator);
			String[] selectedFiles = GUIUtilities.showVFSFileDialog(InstallPanel.this.window,
				jEdit.getActiveView(), path, VFSBrowser.OPEN_DIALOG, false);
			if (selectedFiles.length != 1)
				return;

			path = selectedFiles[0];
			try
			{
				loadPluginSet(path, () ->
				{
					jEdit.setProperty(PluginManager.PROPERTY_PLUGINSET, path);
					updateUI();
				});
				jEdit.setProperty(PluginManager.PROPERTY_PLUGINSET, path);
			}
			catch (IOException e)
			{
				Log.log(Log.WARNING, this, "Loading Pluginset failed:" + e.getMessage());
			}
		} //}}}
	}//}}}

	//{{{ ClearPluginSet class
	private class ClearPluginSet extends RolloverButton implements ActionListener
	{
		//{{{ ClearPluginSet constructor
		ClearPluginSet()
		{
			setIcon(GUIUtilities.loadIcon(jEdit.getProperty("install-plugins.clear-plugin-set.icon")));
			setToolTipText("clear plugin set");
			addActionListener(this);
		} //}}}

		//{{{ actionPerformed() method
		@Override
		public void actionPerformed(ActionEvent e)
		{
			pluginSet.clear();
			pluginModel.clearSelection();
			jEdit.unsetProperty(PluginManager.PROPERTY_PLUGINSET);
			chooseButton.updateUI();
		} //}}}
	} //}}}

	//{{{ InstallButton class
	private class InstallButton extends JButton implements ActionListener, TableModelListener
	{
		InstallButton()
		{
			super(jEdit.getProperty("install-plugins.install"));
			pluginModel.addTableModelListener(this);
			addActionListener(this);
			setEnabled(false);
		}

		@Override
		public void actionPerformed(ActionEvent evt)
		{
			if(isDownloadingList())
				return;

			boolean downloadSource = jEdit.getBooleanProperty(
				"plugin-manager.downloadSource");
			boolean installUser = jEdit.getBooleanProperty(
				"plugin-manager.installUser");
			Roster roster = new Roster();
			String installDirectory;
			if(installUser)
			{
				installDirectory = MiscUtilities.constructPath(
					jEdit.getSettingsDirectory(),"jars");
			}
			else
			{
				installDirectory = MiscUtilities.constructPath(
					jEdit.getJEditHome(),"jars");
			}

			int length = pluginModel.entries.size();
			int instcount = 0;
			for (int i = 0; i < length; i++)
			{
				Entry entry = pluginModel.entries.get(i);
				if (entry.install)
				{
					entry.plugin.install(roster,installDirectory,downloadSource, !entry.checked);
					if (updates)
						entry.plugin.getCompatibleBranch().satisfyDependencies(
						roster,installDirectory,downloadSource);
					instcount++;
				}
			}

			if(roster.isEmpty())
				return;

			boolean cancel = false;
			if (updates && roster.getOperationCount() > instcount)
				if (GUIUtilities.confirm(window,
					"install-plugins.depend",
					null,
					JOptionPane.OK_CANCEL_OPTION,
					JOptionPane.WARNING_MESSAGE) == JOptionPane.CANCEL_OPTION)
					cancel = true;

			if (!cancel)
			{
				new PluginManagerProgress(window,roster);

				roster.performOperationsInAWTThread(window);
				pluginModel.update(null);
			}
		}

		@Override
		public void tableChanged(TableModelEvent e)
		{
			if(isDownloadingList())
				return;

			if (e.getType() == TableModelEvent.UPDATE)
			{
				int length = pluginModel.getRowCount();
				for (int i = 0; i < length; i++)
					if ((Boolean) pluginModel.getValueAt(i, 0))
					{
						setEnabled(true);
						return;
					}
				setEnabled(false);
			}
		}
	} //}}}

	//{{{ EntryCompare class
	private static class EntryCompare implements Comparator<Entry>
	{
		private static final int COLUMN_INSTALL = 0;
		private static final int COLUMN_NAME = 1;
		private static final int COLUMN_CATEGORY = 2;
		private static final int COLUMN_VERSION = 3;
		private static final int COLUMN_SIZE = 4;
		private static final int COLUMN_RELEASE = 5;

		private final int type;

		/** 1=up, -1=down */
		private final int sortDirection;

		EntryCompare(int type, int sortDirection)
		{
			this.type = type;
			this.sortDirection = sortDirection;
		}

		@Override
		public int compare(Entry e1, Entry e2)
		{
			int result;

			switch (type)
			{
				case COLUMN_INSTALL:
					result = (e1.install == e2.install) ? 0 : (e1.install ? 1 : -1);
					break;
				case COLUMN_NAME:
					result = e1.name.compareToIgnoreCase(e2.name);
					break;
				case COLUMN_CATEGORY:
					result = e1.set.compareToIgnoreCase(e2.set);
					if (result == 0)
					{
						result = e1.name.compareToIgnoreCase(e2.name);
					}
					break;
				case COLUMN_VERSION:
					result = StandardUtilities.compareStrings(e1.version, e2.version, true);
					break;
				case COLUMN_SIZE:
					result = Integer.compare(e1.size, e2.size);
					break;
				case COLUMN_RELEASE:
					result = Long.compare(e1.timestamp, e2.timestamp);
					break;
				default:
					result = 0;
			}
			return result * sortDirection;
		}
	} //}}}

	//{{{ HeaderMouseHandler class
	private class HeaderMouseHandler extends MouseAdapter
	{
		@Override
		public void mouseClicked(MouseEvent evt)
		{
			int column = table.getTableHeader().columnAtPoint(evt.getPoint());
			pluginModel.sortDirection *= -1;
			pluginModel.sort(column);
		}
	} //}}}

	//{{{ TextRenderer
	private static class TextRenderer extends DefaultTableCellRenderer
	{
		private final DefaultTableCellRenderer tcr;

		TextRenderer(DefaultTableCellRenderer tcr)
		{
			this.tcr = tcr;
		}

		@Override
		public Component getTableCellRendererComponent(JTable table, Object value,
			boolean isSelected, boolean hasFocus, int row, int column)
		{
			if (column == 5)
				tcr.setHorizontalAlignment(TRAILING);
			else
				tcr.setHorizontalAlignment(LEADING);
			return tcr.getTableCellRendererComponent(table,value,isSelected,false,row,column);
		}
	} //}}}

	//{{{ KeyboardAction class
	private class KeyboardAction extends AbstractAction
	{
		private final KeyboardCommand command;

		KeyboardAction(KeyboardCommand command)
		{
			this.command = command;
		}

		@Override
		public void actionPerformed(ActionEvent evt)
		{
			switch (command)
			{
			case TAB_OUT_FORWARD:
				KeyboardFocusManager.getCurrentKeyboardFocusManager().focusNextComponent();
				break;
			case TAB_OUT_BACK:
				KeyboardFocusManager.getCurrentKeyboardFocusManager().focusPreviousComponent();
				break;
			case EDIT_PLUGIN:
				int[] rows = table.getSelectedRows();
				Object[] state = new Object[rows.length];
				for (int i=0 ; i<rows.length ; i++)
				{
					state[i] = pluginModel.getValueAt(rows[i],0);
				}
				for (int i=0 ; i<rows.length ; i++)
				{
					pluginModel.setValueAt(state[i].equals(Boolean.FALSE),rows[i],0);
				}
				break;
			case CLOSE_PLUGIN_MANAGER:
				window.ok();
				break;
			default:
				throw new InternalError();
			}
		}
	} //}}}

	//{{{ TableFocusHandler class
	private class TableFocusHandler extends FocusAdapter
	{
		@Override
		public void focusGained(FocusEvent fe)
		{
			if (-1 == table.getSelectedRow() && table.getRowCount() > 0)
			{
				table.setRowSelectionInterval(0,0);
				JScrollBar scrollbar = scrollpane.getVerticalScrollBar();
				scrollbar.setValue(scrollbar.getMinimum());
			}
			if (-1 == table.getSelectedColumn())
			{
				table.setColumnSelectionInterval(0,0);
			}
		}
	} //}}}

	//{{{ HeaderRenderer
	private static class HeaderRenderer extends DefaultTableCellRenderer
	{
		private final DefaultTableCellRenderer tcr;

		HeaderRenderer(DefaultTableCellRenderer tcr)
		{
			this.tcr = tcr;
		}

		@Override
		public Component getTableCellRendererComponent(JTable table, Object value,
							       boolean isSelected, boolean hasFocus,
							       int row, int column)
		{
			JLabel l = (JLabel)tcr.getTableCellRendererComponent(table,value,isSelected,hasFocus,row,column);
			PluginTableModel model = (PluginTableModel) table.getModel();
			Icon icon = (column == model.sortType)
				? (model.sortDirection == 1) ? ASC_ICON : DESC_ICON
				: null;
			l.setIcon(icon);
			return l;
		}
	} //}}}

	//}}}

	static final Icon ASC_ICON  = GUIUtilities.loadIcon("arrow-asc.png");
	static final Icon DESC_ICON = GUIUtilities.loadIcon("arrow-desc.png");
}
