/*******************************************************************************
 * 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.program.tilestore.berkeleydb;

import java.awt.Point;
import java.awt.image.BufferedImage;
import java.awt.image.IndexColorModel;
import java.awt.image.WritableRaster;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

import com.sleepycat.je.DatabaseException;
import com.sleepycat.je.Environment;
import com.sleepycat.je.EnvironmentConfig;
import com.sleepycat.je.EnvironmentLockedException;
import com.sleepycat.persist.EntityCursor;
import com.sleepycat.persist.EntityStore;
import com.sleepycat.persist.PrimaryIndex;
import com.sleepycat.persist.StoreConfig;
import com.sleepycat.persist.evolve.Mutations;
import com.sleepycat.persist.evolve.Renamer;

import mobac.exceptions.TileStoreException;
import mobac.program.interfaces.MapSource;
import mobac.program.model.Settings;
import mobac.program.tilestore.TileStore;
import mobac.program.tilestore.TileStoreEntry;
import mobac.program.tilestore.TileStoreInfo;
import mobac.program.tilestore.berkeleydb.TileDbEntry.TileDbKey;
import mobac.utilities.GUIExceptionHandler;
import mobac.utilities.Utilities;
import mobac.utilities.file.DeleteFileFilter;
import mobac.utilities.file.DirInfoFileFilter;
import mobac.utilities.file.DirectoryFileFilter;

/**
 * The new database based tile store implementation.
 */
public class BerkeleyDbTileStore extends TileStore {

	/**
	 * Max count of tile stores opened
	 */
	private static final int MAX_CONCURRENT_ENVIRONMENTS = 5;

	private EnvironmentConfig envConfig;

	private Map<String, TileDatabase> tileDbMap;

	private FileLock tileStoreLock = null;

	private Mutations mutations;

	public BerkeleyDbTileStore() throws TileStoreException {
		super();
		acquireTileStoreLock();
		tileDbMap = new TreeMap<>();

		envConfig = new EnvironmentConfig();
		envConfig.setTransactional(false);
		envConfig.setLocking(true);
		envConfig.setExceptionListener(GUIExceptionHandler.getInstance());
		envConfig.setAllowCreate(true);
		envConfig.setSharedCache(true);
		envConfig.setCachePercent(50);

		mutations = new Mutations();

		String oldPackage1 = "tac.tilestore.berkeleydb";
		String oldPackage2 = "tac.program.tilestore.berkeleydb";
		String entry = ".TileDbEntry";
		String key = ".TileDbEntry$TileDbKey";
		mutations.addRenamer(new Renamer(oldPackage1 + entry, 0, TileDbEntry.class.getName()));
		mutations.addRenamer(new Renamer(oldPackage1 + key, 0, TileDbKey.class.getName()));
		mutations.addRenamer(new Renamer(oldPackage1 + entry, 1, TileDbEntry.class.getName()));
		mutations.addRenamer(new Renamer(oldPackage1 + key, 1, TileDbKey.class.getName()));
		mutations.addRenamer(new Renamer(oldPackage2 + entry, 2, TileDbEntry.class.getName()));
		mutations.addRenamer(new Renamer(oldPackage2 + key, 2, TileDbKey.class.getName()));

		// for (Renamer r : mutations.getRenamers())
		// log.debug(r.toString());
		Runtime.getRuntime().addShutdownHook(new ShutdownThread(true));
	}

	protected void acquireTileStoreLock() throws TileStoreException {
		try {
			// Get a file channel for the file
			File file = new File(tileStoreDir, "lock");
			if (!tileStoreDir.isDirectory())
				try {
					Utilities.mkDirs(tileStoreDir);
				} catch (IOException e) {
					throw new TileStoreException(
							"Unable to create tile store directory: \"" + tileStoreDir.getPath() + "\"");
				}
			FileChannel channel = new RandomAccessFile(file, "rw").getChannel();

			// Use the file channel to create a lock on the file.
			// This method blocks until it can retrieve the lock.

			// Try acquiring the lock without blocking. This method returns
			// null or throws an exception if the file is already locked.
			tileStoreLock = channel.tryLock();
			if (tileStoreLock == null) {
				throw new TileStoreException(
						"Unable to obtain tile store lock - another instance of Mobile Atlas Creator is running!");
			}

			// // Release the lock
			// lock.release();
			//
			// // Close the file
			// channel.close();
		} catch (Exception e) {
			log.error("", e);
			throw new TileStoreException(e.getMessage(), e.getCause());
		}
	}

	@Override
	public TileStoreEntry createNewEntry(int x, int y, int zoom, byte[] data, long timeLastModified, long timeExpires,
			String eTag) {
		return new TileDbEntry(x, y, zoom, data, timeLastModified, timeExpires, eTag);
	}

	@Override
	public TileStoreEntry createNewEmptyEntry(int x, int y, int zoom) {
		long time = System.currentTimeMillis();
		long timeExpires = time + Settings.getInstance().tileDefaultExpirationTime;
		// We set the tile data to an empty array because we can not store null
		return new TileDbEntry(x, y, zoom, new byte[] {}, time, timeExpires, "");
	}

	private TileDatabase getTileDatabase(MapSource mapSource) throws DatabaseException {
		TileDatabase db;
		if (tileDbMap == null)
			// Tile store has been closed already
			return null;
		String storeName = mapSource.getName();
		if (storeName == null)
			return null;
		synchronized (tileDbMap) {
			db = tileDbMap.get(storeName);
		}
		if (db != null)
			return db;
		try {
			synchronized (tileDbMap) {
				cleanupDatabases();
				db = tileDbMap.get(storeName);
				if (db == null) {
					db = new TileDatabase(storeName);
					db.lastAccess = System.currentTimeMillis();
					tileDbMap.put(mapSource.getName(), db);
				}
				return db;
			}
		} catch (Exception e) {
			log.error("Error creating tile store db \"" + mapSource.getName() + "\"", e);
			throw new TileStoreException(e);
		}
	}

	private TileDatabase getTileDatabase(String storeName) throws DatabaseException {
		TileDatabase db;
		if (tileDbMap == null)
			// Tile store has been closed already
			return null;
		if (storeName == null)
			return null;
		synchronized (tileDbMap) {
			db = tileDbMap.get(storeName);
		}
		if (db != null)
			return db;
		try {
			synchronized (tileDbMap) {
				cleanupDatabases();
				db = tileDbMap.get(storeName);
				if (db == null) {
					db = new TileDatabase(storeName);
					db.lastAccess = System.currentTimeMillis();
					tileDbMap.put(storeName, db);
				}
				return db;
			}
		} catch (Exception e) {
			log.error("Error creating tile store db \"" + storeName + "\"", e);
			throw new TileStoreException(e);
		}
	}

	@Override
	public TileStoreInfo getStoreInfo(String storeName) throws InterruptedException {
		int tileCount = getNrOfTiles(storeName);
		long storeSize = getStoreSize(storeName);
		return new TileStoreInfo(storeSize, tileCount);
	}

	@Override
	public void putTileData(byte[] tileData, int x, int y, int zoom, MapSource mapSource) throws IOException {
		this.putTileData(tileData, x, y, zoom, mapSource, -1, -1, null);
	}

	@Override
	public void putTileData(byte[] tileData, int x, int y, int zoom, MapSource mapSource, long timeLastModified,
			long timeExpires, String eTag) throws IOException {
		TileDbEntry tile = new TileDbEntry(x, y, zoom, tileData, timeLastModified, timeExpires, eTag);
		TileDatabase db = null;
		try {
			if (log.isTraceEnabled())
				log.trace("Saved " + mapSource.getName() + " " + tile);
			db = getTileDatabase(mapSource);
			if (db != null)
				db.put(tile);
		} catch (Exception e) {
			if (db != null)
				db.close();
			log.error("Faild to write tile to tile store \"" + mapSource.getName() + "\"", e);
		}
	}

	@Override
	public void putTile(TileStoreEntry tile, MapSource mapSource) {
		TileDatabase db = null;
		try {
			if (log.isTraceEnabled())
				log.trace("Saved " + mapSource.getName() + " " + tile);
			db = getTileDatabase(mapSource);
			db.put((TileDbEntry) tile);
		} catch (Exception e) {
			if (db != null)
				db.close();
			log.error("Faild to write tile to tile store \"" + mapSource.getName() + "\"", e);
		}
	}

	@Override
	public TileStoreEntry getTile(int x, int y, int zoom, MapSource mapSource) {
		TileDatabase db = null;
		try {
			db = getTileDatabase(mapSource);
			if (db == null)
				return null;
			TileStoreEntry tile = db.get(new TileDbKey(x, y, zoom));
			if (log.isTraceEnabled()) {
				if (tile == null)
					log.trace("Tile store cache miss: (x,y,z)" + x + "/" + y + "/" + zoom + " " + mapSource.getName());
				else
					log.trace("Loaded " + mapSource.getName() + " " + tile);
			}
			return tile;
		} catch (Exception e) {
			if (db != null)
				db.close();
			log.error("failed to retrieve tile from tile store \"" + mapSource.getName() + "\"", e);
			return null;
		}
	}

	public boolean contains(int x, int y, int zoom, MapSource mapSource) {
		try {
			return getTileDatabase(mapSource).contains(new TileDbKey(x, y, zoom));
		} catch (DatabaseException e) {
			log.error("", e);
			return false;
		}
	}

	public void prepareTileStore(MapSource mapSource) {
		try {
			getTileDatabase(mapSource);
		} catch (DatabaseException e) {
		}
	}

	public void clearStore(String storeName) {
		File databaseDir = getStoreDir(storeName);

		TileDatabase db;
		synchronized (tileDbMap) {
			db = tileDbMap.get(storeName);
			if (db != null)
				db.close(false);
			if (databaseDir.exists()) {
				DeleteFileFilter dff = new DeleteFileFilter();
				databaseDir.listFiles(dff);
				databaseDir.delete();
				log.debug("Tilestore " + storeName + " cleared: " + dff);
			}
			tileDbMap.remove(storeName);
		}
	}

	/**
	 * This method returns the amount of tiles in the store of tiles which is specified by the {@link MapSource} object.
	 * 
	 * @param mapSourceName
	 *            the store to calculate number of tiles in
	 * @return the amount of tiles in the specified store.
	 * @throws InterruptedException
	 */
	public int getNrOfTiles(String mapSourceName) throws InterruptedException {
		try {
			File storeDir = getStoreDir(mapSourceName);
			if (!storeDir.isDirectory())
				return 0;
			TileDatabase db = getTileDatabase(mapSourceName);
			int tileCount = (int) db.entryCount();
			db.close();
			return tileCount;
		} catch (DatabaseException e) {
			log.error("", e);
			return -1;
		}
	}

	public long getStoreSize(String storeName) throws InterruptedException {
		File tileStore = getStoreDir(storeName);
		if (tileStore.exists()) {
			DirInfoFileFilter diff = new DirInfoFileFilter();
			try {
				tileStore.listFiles(diff);
			} catch (RuntimeException e) {
				throw new InterruptedException();
			}
			return diff.getDirSize();
		} else {
			return 0;
		}
	}

	public BufferedImage getCacheCoverage(MapSource mapSource, int zoom, Point tileNumMin, Point tileNumMax)
			throws InterruptedException {
		TileDatabase db;
		try {
			db = getTileDatabase(mapSource);
			return db.getCacheCoverage(zoom, tileNumMin, tileNumMax);
		} catch (DatabaseException e) {
			log.error("", e);
			return null;
		}
	}

	protected void cleanupDatabases() {
		if (tileDbMap.size() < MAX_CONCURRENT_ENVIRONMENTS)
			return;
		synchronized (tileDbMap) {
			List<TileDatabase> list = new ArrayList<TileDatabase>(tileDbMap.values());
			Collections.sort(list, new Comparator<TileDatabase>() {

				public int compare(TileDatabase o1, TileDatabase o2) {
					if (o1.lastAccess == o2.lastAccess)
						return 0;
					return (o1.lastAccess < o2.lastAccess) ? -1 : 1;
				}
			});
			for (int i = 0; i < list.size() - 2; i++)
				list.get(i).close();
		}
	}

	public void closeAll() {
		Thread t = new ShutdownThread(false);
		t.start();
		try {
			t.join();
		} catch (InterruptedException e) {
			log.error("", e);
		}
	}

	/**
	 * Returns <code>true</code> if the tile store directory of the specified {@link MapSource} exists.
	 * 
	 * @param mapSource
	 * @return
	 */
	public boolean storeExists(MapSource mapSource) {
		File tileStore = getStoreDir(mapSource);
		return (tileStore.isDirectory()) && (tileStore.exists());
	}

	/**
	 * Returns the directory used for storing the tile database of the {@link MapSource} specified by
	 * <code>mapSource</code>
	 * 
	 * @param mapSource
	 * @return
	 */
	protected File getStoreDir(MapSource mapSource) {
		return getStoreDir(mapSource.getName());
	}

	/**
	 * @param mapSourceName
	 * @return directory used for storing the tile database belonging to <code>mapSource</code>
	 */
	protected File getStoreDir(String mapSourceName) {
		return new File(tileStoreDir, "db-" + mapSourceName);
	}

	public String[] getAllStoreNames() {
		File[] dirs = tileStoreDir.listFiles(new DirectoryFileFilter());
		ArrayList<String> storeNames = new ArrayList<String>(dirs.length);
		for (File d : dirs) {
			String name = d.getName();
			if (name.startsWith("db-")) {
				name = name.substring(3);
				storeNames.add(name);
			}
		}
		String[] result = new String[storeNames.size()];
		storeNames.toArray(result);
		return result;
	}

	private class ShutdownThread extends DelayedInterruptThread {

		private final boolean shutdown;

		public ShutdownThread(boolean shutdown) {
			super("DBShutdown");
			this.shutdown = shutdown;
		}

		@Override
		public void run() {
			log.debug("Closing all tile databases...");
			synchronized (tileDbMap) {
				for (TileDatabase db : tileDbMap.values()) {
					db.close(false);
				}
				tileDbMap.clear();
				if (shutdown) {
					tileDbMap = null;
					try {
						tileStoreLock.release();
					} catch (IOException e) {
						log.error("", e);
					}
				}
			}
			log.debug("All tile databases has been closed");
		}
	}

	protected class TileDatabase implements AutoCloseable {

		final String mapSourceName;
		final Environment env;
		final EntityStore store;
		final PrimaryIndex<TileDbKey, TileDbEntry> tileIndex;
		boolean dbClosed = false;

		long lastAccess;

		public TileDatabase(String mapSourceName) throws IOException, EnvironmentLockedException, DatabaseException {
			this(mapSourceName, getStoreDir(mapSourceName));
		}

		public TileDatabase(String mapSourceName, File databaseDirectory)
				throws IOException, EnvironmentLockedException, DatabaseException {
			log.debug("Opening tile store db: \"" + databaseDirectory + "\"");
			File storeDir = databaseDirectory;
			DelayedInterruptThread t = (DelayedInterruptThread) Thread.currentThread();
			try {
				t.pauseInterrupt();
				this.mapSourceName = mapSourceName;
				lastAccess = System.currentTimeMillis();

				Utilities.mkDirs(storeDir);

				env = new Environment(storeDir, envConfig);

				StoreConfig storeConfig = new StoreConfig();
				storeConfig.setAllowCreate(true);
				storeConfig.setTransactional(false);
				storeConfig.setMutations(mutations);
				store = new EntityStore(env, "TilesEntityStore", storeConfig);

				tileIndex = store.getPrimaryIndex(TileDbKey.class, TileDbEntry.class);
			} finally {
				if (t.interruptedWhilePaused())
					close();
				t.resumeInterrupt();
			}
		}

		public boolean isClosed() {
			return dbClosed;
		}

		public long entryCount() throws DatabaseException {
			return tileIndex.count();
		}

		public void put(TileDbEntry tile) throws DatabaseException {
			DelayedInterruptThread t = (DelayedInterruptThread) Thread.currentThread();
			try {
				t.pauseInterrupt();
				tileIndex.put(tile);
			} finally {
				if (t.interruptedWhilePaused())
					close();
				t.resumeInterrupt();
			}
		}

		public boolean contains(TileDbKey key) throws DatabaseException {
			return tileIndex.contains(key);
		}

		public TileDbEntry get(TileDbKey key) throws DatabaseException {
			return tileIndex.get(key);
		}

		public PrimaryIndex<TileDbKey, TileDbEntry> getTileIndex() {
			return tileIndex;
		}

		public BufferedImage getCacheCoverage(int zoom, Point tileNumMin, Point tileNumMax)
				throws DatabaseException, InterruptedException {
			log.debug("Loading cache coverage for region " + tileNumMin + " " + tileNumMax + " of zoom level " + zoom);
			DelayedInterruptThread t = (DelayedInterruptThread) Thread.currentThread();
			int width = tileNumMax.x - tileNumMin.x + 1;
			int height = tileNumMax.y - tileNumMin.y + 1;
			byte ff = (byte) 0xFF;
			byte[] colors = new byte[] { 120, 120, 120, 120, // alpha-gray
					10, ff, 0, 120 // alpha-green
			};
			IndexColorModel colorModel = new IndexColorModel(2, 2, colors, 0, true);
			BufferedImage image = null;
			try {
				image = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_INDEXED, colorModel);
			} catch (Throwable e) {
				log.error("Failed to create coverage image: " + e.toString());
				image = null;
				System.gc();
				return null;
			}
			WritableRaster raster = image.getRaster();

			// We are loading the coverage of the selected area column by column which is much faster than loading the
			// whole region at once
			for (int x = tileNumMin.x; x <= tileNumMax.x; x++) {
				TileDbKey fromKey = new TileDbKey(x, tileNumMin.y, zoom);
				TileDbKey toKey = new TileDbKey(x, tileNumMax.y, zoom);
				EntityCursor<TileDbKey> cursor = tileIndex.keys(fromKey, true, toKey, true);
				try {
					TileDbKey key = cursor.next();
					while (key != null) {
						int pixelx = key.x - tileNumMin.x;
						int pixely = key.y - tileNumMin.y;
						raster.setSample(pixelx, pixely, 0, 1);
						key = cursor.next();
						if (t.isInterrupted()) {
							log.debug("Cache coverage loading aborted");
							throw new InterruptedException();
						}
					}
				} finally {
					cursor.close();
				}
			}
			return image;
		}

		protected void purge() {
			try {
				store.sync();
				env.cleanLog();
			} catch (DatabaseException e) {
				log.error("database compression failed: ", e);
			}
		}

		public void close() {
			close(true);
		}

		public void close(boolean removeFromMap) {
			if (dbClosed)
				return;
			if (removeFromMap) {
				synchronized (tileDbMap) {
					TileDatabase db2 = tileDbMap.get(mapSourceName);
					if (db2 == this)
						tileDbMap.remove(mapSourceName);
				}
			}
			DelayedInterruptThread t = (DelayedInterruptThread) Thread.currentThread();
			try {
				t.pauseInterrupt();
				try {
					log.debug("Closing tile store db \"" + mapSourceName + "\"");
					if (store != null)
						store.close();
				} catch (Exception e) {
					log.error("", e);
				}
				try {
					env.close();
				} catch (Exception e) {
					log.error("", e);
				} finally {
					dbClosed = true;
				}
			} finally {
				if (t.interruptedWhilePaused())
					close();
				t.resumeInterrupt();
			}
		}

		@Override
		protected void finalize() throws Throwable {
			close();
			super.finalize();
		}

	}
}
