/* This code is part of Freenet. It is distributed under the GNU General
 * Public License, version 2 (or at your option any later version). See
 * http://www.gnu.org/ for further details of the GPL. */
package freenet.clients.http.bookmark;

import static java.util.concurrent.TimeUnit.MINUTES;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;

import freenet.client.async.ClientContext;
import freenet.client.async.USKCallback;
import freenet.clients.http.FProxyToadlet;
import freenet.keys.FreenetURI;
import freenet.keys.USK;
import freenet.l10n.NodeL10n;
import freenet.node.FSParseException;
import freenet.node.NodeClientCore;
import freenet.node.RequestClient;
import freenet.node.RequestStarter;
import freenet.node.SemiOrderedShutdownHook;
import freenet.support.LogThresholdCallback;
import freenet.support.Logger;
import freenet.support.Logger.LogLevel;
import freenet.support.SimpleFieldSet;
import freenet.support.io.Closer;
import freenet.support.io.FileUtil;

public class BookmarkManager implements RequestClient {

	public static final SimpleFieldSet DEFAULT_BOOKMARKS;
	private final NodeClientCore node;
	private final USKUpdatedCallback uskCB = new USKUpdatedCallback();
	public static final BookmarkCategory MAIN_CATEGORY = new BookmarkCategory("/");
	public static final BookmarkCategory DEFAULT_CATEGORY = new BookmarkCategory("\\");
	private final HashMap<String, Bookmark> bookmarks = new HashMap<>();
	private final File bookmarksFile;
	private final File backupBookmarksFile;
	private boolean isSavingBookmarks = false;
	private boolean isSavingBookmarksLazy = false;
	static {
		String name = "freenet/clients/http/staticfiles/defaultbookmarks.dat";
		SimpleFieldSet defaultBookmarks = null;
		try {
			ClassLoader loader = BookmarkManager.class.getClassLoader();
			try (InputStream in = loader.getResourceAsStream(name)) {
				// loader returns null on lookup failures:
				if (in != null)
					defaultBookmarks = SimpleFieldSet.readFrom(in, false, false);
			}
		} catch(Exception e) {
			Logger.error(BookmarkManager.class, "Error while loading the default bookmark file from " + name + " :" + e.getMessage(), e);
		} finally {
			DEFAULT_BOOKMARKS = defaultBookmarks;
		}
	}

	private static volatile boolean logMINOR;
	static {
		Logger.registerLogThresholdCallback(new LogThresholdCallback(){
			@Override
			public void shouldUpdate(){
				logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
			}
		});
	}

	public BookmarkManager(NodeClientCore n, boolean publicGateway) {
		putPaths("/", MAIN_CATEGORY);
		this.node = n;
		this.bookmarksFile = n.getNode().userDir().file("bookmarks.dat");
		this.backupBookmarksFile = n.getNode().userDir().file("bookmarks.dat.bak");

		try {
			// Read the backup file if necessary
			if(!bookmarksFile.exists() || bookmarksFile.length() == 0)
				throw new IOException();
			Logger.normal(this, "Attempting to read the bookmark file from " + bookmarksFile.toString());
			SimpleFieldSet sfs = SimpleFieldSet.readFrom(bookmarksFile, false, true);
			readBookmarks(MAIN_CATEGORY, sfs);
		} catch(MalformedURLException mue) {
		} catch(IOException ioe) {
			Logger.error(this, "Error reading the bookmark file (" + bookmarksFile.toString() + "):" + ioe.getMessage(), ioe);

			try {
				if(backupBookmarksFile.exists() && backupBookmarksFile.canRead() && backupBookmarksFile.length() > 0) {
					Logger.normal(this, "Attempting to read the backup bookmark file from " + backupBookmarksFile.toString());
					SimpleFieldSet sfs = SimpleFieldSet.readFrom(backupBookmarksFile, false, true);
					readBookmarks(MAIN_CATEGORY, sfs);
				} else {
					Logger.normal(this, "We couldn't find the backup either! - " + FileUtil.getCanonicalFile(backupBookmarksFile));
					// restore the default bookmark set
					readBookmarks(MAIN_CATEGORY, DEFAULT_BOOKMARKS);
				}
			} catch(IOException e) {
				Logger.error(this, "Error reading the backup bookmark file !" + e.getMessage(), e);
			}
		}
		//populate defaults for hosts without full access permissions if we're in gateway mode.
		if (publicGateway) {
			putPaths("\\", DEFAULT_CATEGORY);
			readBookmarks(DEFAULT_CATEGORY, DEFAULT_BOOKMARKS);
		}

		SemiOrderedShutdownHook.get().addEarlyJob(new Thread() {
			BookmarkManager bm = BookmarkManager.this;

			@Override public void run() {
				bm.storeBookmarks();
				bm = null;
			}
		});
	}

	public void reAddDefaultBookmarks() {
		BookmarkCategory bc = new BookmarkCategory(l10n("defaultBookmarks") + " - " + new Date());
		addBookmark("/", bc);
		_innerReadBookmarks("/", bc, DEFAULT_BOOKMARKS);
	}

	private class USKUpdatedCallback implements USKCallback {

		@Override
		public void onFoundEdition(long edition, USK key, ClientContext context, boolean wasMetadata, short codec, byte[] data, boolean newKnownGood, boolean newSlotToo) {
			if(!newKnownGood) {
				FreenetURI uri = key.copy(edition).getURI();
				node.makeClient(PRIORITY_PROGRESS, false, false).prefetch(uri, MINUTES.toMillis(60), FProxyToadlet.MAX_LENGTH_WITH_PROGRESS, null, PRIORITY_PROGRESS);
				return;
			}
			List<BookmarkItem> items = MAIN_CATEGORY.getAllItems();
			boolean matched = false;
			boolean updated = false;
			for(int i = 0; i < items.size(); i++) {
				if(!"USK".equals(items.get(i).getKeyType()))
					continue;

				try {
					FreenetURI furi = new FreenetURI(items.get(i).getKey());
					USK usk = USK.create(furi);

					if(usk.equals(key, false)) {
						if(logMINOR) Logger.minor(this, "Updating bookmark for "+furi+" to edition "+edition);
						matched = true;
						BookmarkItem item = items.get(i);
						updated |= item.setEdition(edition, node);
						// We may have bookmarked the same site twice, so continue the search.
					}
				} catch(MalformedURLException mue) {
				}
			}
			if(updated) {
				storeBookmarksLazy();
			} else if(!matched) {
				Logger.error(this, "No match for bookmark "+key+" edition "+edition);
			}
		}

		@Override
		public short getPollingPriorityNormal() {
			return PRIORITY;
		}

		@Override
		public short getPollingPriorityProgress() {
			return PRIORITY_PROGRESS;
		}
	}

	public String l10n(String key) {
		return NodeL10n.getBase().getString("BookmarkManager." + key);
	}

	public String parentPath(String path) {
		if(path.equals("/"))
			return "/";

		return path.substring(0, path.substring(0, path.length() - 1).lastIndexOf('/')) + "/";
	}

	public Bookmark getBookmarkByPath(String path) {
		synchronized(bookmarks) {
			return bookmarks.get(path);
		}
	}

	public BookmarkCategory getCategoryByPath(String path) {
		Bookmark cat = getBookmarkByPath(path);
		if(cat instanceof BookmarkCategory)
			return (BookmarkCategory) cat;

		return null;
	}

	public BookmarkItem getItemByPath(String path) {
		if(getBookmarkByPath(path) instanceof BookmarkItem)
			return (BookmarkItem) getBookmarkByPath(path);

		return null;
	}

	public void addBookmark(String parentPath, Bookmark bookmark) {
		if(logMINOR)
			Logger.minor(this, "Adding bookmark " + bookmark + " to " + parentPath);
		BookmarkCategory parent = getCategoryByPath(parentPath);
		parent.addBookmark(bookmark);
		putPaths(parentPath + bookmark.getName() + ((bookmark instanceof BookmarkCategory) ? "/" : ""),
			bookmark);

		if(bookmark instanceof BookmarkItem)
			subscribeToUSK((BookmarkItem)bookmark);
	}

	public void renameBookmark(String path, String newName) {
		Bookmark bookmark = getBookmarkByPath(path);
		String oldName = bookmark.getName();
		String oldPath = '/' + oldName;
		String newPath = path.substring(0, path.indexOf(oldPath)) + '/' + newName + (bookmark instanceof BookmarkCategory ? "/" : "");

		bookmark.setName(newName);
		synchronized(bookmarks) {
			Iterator<String> it = bookmarks.keySet().iterator();
			while(it.hasNext()) {
				String s = it.next();
				if(s.startsWith(path)) {
					it.remove();
				}
			}
			putPaths(newPath, bookmark);
		}
		storeBookmarks();
	}

	public void moveBookmark(String bookmarkPath, String newParentPath) {
		Bookmark b = getBookmarkByPath(bookmarkPath);
		addBookmark(newParentPath, b);

		getCategoryByPath(parentPath(bookmarkPath)).removeBookmark(b);
		removePaths(bookmarkPath);
	}

	public void removeBookmark(String path) {
		Bookmark bookmark = getBookmarkByPath(path);
		if(bookmark == null)
			return;

		if(bookmark instanceof BookmarkCategory) {
			BookmarkCategory cat = (BookmarkCategory) bookmark;
			for(int i = 0; i < cat.size(); i++)
				removeBookmark(path + cat.get(i).getName() + ((cat.get(i) instanceof BookmarkCategory) ? "/"
					: ""));
		} else {
			if(((BookmarkItem) bookmark).getKeyType().equals("USK")) {
				try {
					USK u = ((BookmarkItem) bookmark).getUSK();
					if(!wantUSK(u, (BookmarkItem)bookmark)) {
						this.node.getUskManager().unsubscribe(u, this.uskCB);
					}
				} catch(MalformedURLException mue) {
				}
			}
		}

		getCategoryByPath(parentPath(path)).removeBookmark(bookmark);
		synchronized(bookmarks) {
			bookmarks.remove(path);
		}
	}

	private boolean wantUSK(USK u, BookmarkItem ignore) {
		List<BookmarkItem> items = MAIN_CATEGORY.getAllItems();
		for(BookmarkItem item : items) {
			if(item == ignore)
				continue;
			if(!"USK".equals(item.getKeyType()))
				continue;

			try {
				FreenetURI furi = new FreenetURI(item.getKey());
				USK usk = USK.create(furi);

				if(usk.equals(u, false)) return true;
			} catch(MalformedURLException mue) {
			}
		}
		return false;
	}

	public void moveBookmarkUp(String path, boolean store) {
		BookmarkCategory parent = getCategoryByPath(parentPath(path));
		parent.moveBookmarkUp(getBookmarkByPath(path));

		if(store)
			storeBookmarks();
	}

	public void moveBookmarkDown(String path, boolean store) {
		BookmarkCategory parent = getCategoryByPath(parentPath(path));
		parent.moveBookmarkDown(getBookmarkByPath(path));

		if(store)
			storeBookmarks();
	}

	private void putPaths(String path, Bookmark b) {
		synchronized(bookmarks) {
			bookmarks.put(path, b);
		}
		if(b instanceof BookmarkCategory)
			for(int i = 0; i < ((BookmarkCategory) b).size(); i++) {
				Bookmark child = ((BookmarkCategory) b).get(i);
				putPaths(path + child.getName() + (child instanceof BookmarkItem ? "" : "/"), child);
			}

	}

	private void removePaths(String path) {
		if(getBookmarkByPath(path) instanceof BookmarkCategory) {
			BookmarkCategory cat = getCategoryByPath(path);
			for(int i = 0; i < cat.size(); i++)
				removePaths(path + cat.get(i).getName() + (cat.get(i) instanceof BookmarkCategory ? "/" : ""));
		}
		bookmarks.remove(path);
	}

	public FreenetURI[] getBookmarkURIs() {
		List<BookmarkItem> items = MAIN_CATEGORY.getAllItems();
		FreenetURI[] uris = new FreenetURI[items.size()];
		for(int i = 0; i < items.size(); i++)
			uris[i] = items.get(i).getURI();

		return uris;
	}

	public void storeBookmarksLazy() {
		synchronized(bookmarks) {
			if(isSavingBookmarksLazy) return;
			isSavingBookmarksLazy = true;
			node.getNode().getTicker().queueTimedJob(new Runnable() {

				@Override
				public void run() {
					try {
						storeBookmarks();
					} finally {
						isSavingBookmarksLazy = false;
					}
				}

			}, MINUTES.toMillis(5));
		}
	}

	public void storeBookmarks() {
		Logger.normal(this, "Attempting to save bookmarks to " + bookmarksFile.toString());
		SimpleFieldSet sfs = null;
		synchronized(bookmarks) {
			if(isSavingBookmarks)
				return;
			isSavingBookmarks = true;

			sfs = toSimpleFieldSet();
		}
		FileOutputStream fos = null;
		try {
			fos = new FileOutputStream(backupBookmarksFile);
			sfs.writeToBigBuffer(fos);
			fos.close();
			fos = null;
			if(!FileUtil.renameTo(backupBookmarksFile, bookmarksFile))
				Logger.error(this, "Unable to rename " + backupBookmarksFile.toString() + " to " + bookmarksFile.toString());
		} catch(IOException ioe) {
			Logger.error(this, "An error has occured saving the bookmark file :" + ioe.getMessage(), ioe);
		} finally {
			Closer.close(fos);
			synchronized(bookmarks) {
				isSavingBookmarks = false;
			}
		}
	}

	private void readBookmarks(BookmarkCategory category, SimpleFieldSet sfs) {
		_innerReadBookmarks("", category, sfs);
	}

	static final short PRIORITY = RequestStarter.BULK_SPLITFILE_PRIORITY_CLASS;
	static final short PRIORITY_PROGRESS = RequestStarter.UPDATE_PRIORITY_CLASS;

	private void subscribeToUSK(BookmarkItem item) {
		if("USK".equals(item.getKeyType()))
			try {
				USK u = item.getUSK();
				this.node.getUskManager().subscribe(u, this.uskCB, true, this);
			} catch(MalformedURLException mue) {}
	}

	private synchronized void _innerReadBookmarks(String prefix, BookmarkCategory category, SimpleFieldSet sfs) {
		boolean hasBeenParsedWithoutAnyProblem = true;
		boolean isRoot = ("".equals(prefix) && MAIN_CATEGORY.equals(category));
		synchronized(bookmarks) {
			if(!isRoot)
				putPaths(prefix + category.name + '/', category);

			if (sfs == null) {
				hasBeenParsedWithoutAnyProblem = false;
			} else {
				try {
					int nbBookmarks = sfs.getInt(BookmarkItem.NAME);
					int nbCategories = sfs.getInt(BookmarkCategory.NAME);

					for (int i = 0; i < nbBookmarks; i++) {
						SimpleFieldSet subset = sfs.getSubset(BookmarkItem.NAME + i);
						try {
							BookmarkItem item = new BookmarkItem(subset, this, node.getAlerts());
							String name = (isRoot ? "" : prefix + category.name) + '/' + item.name;
							putPaths(name, item);
							category.addBookmark(item);
							item.registerUserAlert();
							subscribeToUSK(item);
						} catch (MalformedURLException e) {
							throw new FSParseException(e);
						}
					}

					for (int i = 0; i < nbCategories; i++) {
						SimpleFieldSet subset = sfs.getSubset(BookmarkCategory.NAME + i);
						BookmarkCategory currentCategory = new BookmarkCategory(subset);
						category.addBookmark(currentCategory);
						String name = (isRoot ? "/" : (prefix + category.name + '/'));
						_innerReadBookmarks(name, currentCategory, subset.getSubset("Content"));
					}

				} catch (FSParseException e) {
					Logger.error(this, "Error parsing the bookmarks file!", e);
					hasBeenParsedWithoutAnyProblem = false;
				}
			}

		}
		if(hasBeenParsedWithoutAnyProblem)
			storeBookmarks();
	}


	public SimpleFieldSet toSimpleFieldSet() {
		SimpleFieldSet sfs = new SimpleFieldSet(true);

		sfs.put("Version", 1);
		synchronized(bookmarks) {
			sfs.putAllOverwrite(BookmarkManager.toSimpleFieldSet(MAIN_CATEGORY));
		}

		return sfs;
	}

	public static SimpleFieldSet toSimpleFieldSet(BookmarkCategory cat) {
		SimpleFieldSet sfs = new SimpleFieldSet(true);
		List<BookmarkCategory> bc = cat.getSubCategories();

		for(int i = 0; i < bc.size(); i++) {
			BookmarkCategory currentCat = bc.get(i);
			sfs.put(BookmarkCategory.NAME + i, currentCat.getSimpleFieldSet());
		}
		sfs.put(BookmarkCategory.NAME, bc.size());


		List<BookmarkItem> bi = cat.getItems();
		for(int i = 0; i < bi.size(); i++)
			sfs.put(BookmarkItem.NAME + i, bi.get(i).getSimpleFieldSet());
		sfs.put(BookmarkItem.NAME, bi.size());

		return sfs;
	}

	@Override
	public boolean persistent() {
		return false;
	}

	@Override
	public boolean realTimeFlag() {
		return false;
	}
}
