package org.wikiwebserver.core;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
/**
 * 
 * A WikiMap is a used to store a collection of key-value pairs.
 * Keys are always Strings but values may be any Object.
 * 
 * A WikiMap may contain inner (or child) WikiMaps to create a multi-level
 * hierarchy of data.
 * 
 * A WikiMap is thread safe and may be accessed and modified by 
 * multiple threads concurrently. The performance characteristics are
 * those of a LinkedHashMap with the exception of storing inner WikiMaps
 * where there is a small maintenance overhead.
 * 
 * Typically a WikiMap is stored in a single file.  However, inner WikiMaps
 * may be persisted to their own file if sufficiently large.
 * 
 * A WikiMap maintains a last modified date, a child modified date and a 
 * last accessed date to aid maintenance and synchronisation by efficient 
 * traversal, automatic saving and unloading. Maintenance operations such as
 * persistMap() and unloadIfNotAccessedSince(...) should be called by a single 
 * thread to avoid possible corruption.
 * 
 * To aid synchronisation, WikiMaps store removal information.
 * Removed entries remain in the map with a null value. To synchronise
 * a WikiMap with another, the getPlainMapForSync() should be used to 
 * obtain the data and putAll() to update the older map. Child maps
 * must be synchronised separately.
 * 
 * 
 * @author Michael Gardiner
 *
 */
public final class WikiMap implements Map<String, Object>, Serializable {
    
    private static final long serialVersionUID = -481040154602950464L;    
    // The maximum size the map can grow to.
    // When this value is exceeded, each new entry will remove the least
    // accessed entry. (Behaviour defined in ForgetfulMap)
    private static final int MAX_SIZE_FOR_MAP =
        ConfigManager.getInt("max-size-for-map");    

    // If the size of an internal map (i.e. a map in a map) exceeds this value, 
    // it will be persisted in a separate file. 
    private static final int MAX_SIZE_FOR_INTERNAL_MAP = 
        ConfigManager.getInt("max-size-for-internal-map");
    
    
    // When set to true, outputs to System.out file accesses 
    private static final boolean DEBUG_FILE_ACCESS = 
        ConfigManager.getBoolean("map.debug-file-access");
    
    private static final String MAP_FILE_SUFFIX = ".map";
    private static final String TEMP_MAP_FILE_SUFFIX = ".tmp";
    
    private static final String[] CLASSES_EXCLUDED_FROM_PERMISSION_CHECK = {
        "org.wikiwebserver.core.WikiMap", 
        "org.wikiwebserver.core.WareHouse",
        "org.wikiwebserver.html.TemplatedPage"
    };
    
    private static final String[] CLASSES_ALWAYS_PERMITTED_ACCESS = {
        "org.wikiwebserver.core.WikiWebServer",
        "org.wikiwebserver.core.WikiMapSynchronizer",
        "org.wikiwebserver.handler.http.responder.WikiMapSyncResponder",
        "page.tools.stats.PersistentStorageBrowser"
    };
    
    
    // PERSISTED FIELDS ----------
    
    private String name;
    private String accessorClassName;
    
    private int maxSize = MAX_SIZE_FOR_MAP;
    
    // Small child maps are persisted internally in this field
    private Map<String, Object> internalMap = null;
    
    // Maintains the time this map was last accessed    
    private long lastAccessTime;
    // Maintains the time this map was last modified   
    private long lastModifiedTime;
    
    // END PERSISTED FIELDS ----------
    
    // The location of the root map on the filing system
    private transient File rootFile = null;
    
    // Maintains the time a child map was last modified
    private transient long childModifiedTime;
    // Maintains the time the map was last saved to long-term storage
    private transient long lastPersistTime;
    
    // References the parent map containing this map
    private transient WikiMap parent;    
    // The physical map for storing key-value pairs
    private transient Map<String, Object> map;
    
    
    
    // Used for creating the root node (only possible in core package)
    WikiMap(String name, File rootFile) {
        this(name, Integer.MAX_VALUE, null);
        this.rootFile = rootFile;
        loadPersistedMap();        
    }   
    
    public WikiMap() {
        this(null, Integer.MAX_VALUE, null);
        
    }    
    
    public WikiMap(int maxEntries) {
        this(null, maxEntries, null);
    }      
    
    public WikiMap(int maxEntries, String restrictAccessClassName) {
        this(null, maxEntries, restrictAccessClassName);
    }
    
    
    /**
     * @param name The name used to reference the map
     * 
     * @param maxEntries The maximum number of entries allowed in the map before
     *      old entries are removed.
     *                   
     * @param restrictAccessClassName The name of a class permitted to access this map. 
     *      Requests from other classes will be denied.
     */
    public WikiMap(String name, int maxEntries, String restrictAccessClassName) {
        this.name = name;
        this.maxSize = maxEntries;
        this.accessorClassName = restrictAccessClassName;
        this.map = new ForgetfulMap<String, Object>(this.maxSize);
    }
    
    public String getName() {
        return this.name;
    }
    
    public String[] getHeirarchy() {
        List<String> list = new LinkedList<String>();
        populateAncestory(list);
        return list.toArray(new String[list.size()]);
    }    
    
    public String getPath() {
        return (this.parent == null) ? getName() : this.parent.getPath() + "/" + getName();
    }
    
    private File getRootFile() {
    	if (this.parent == null) return rootFile;
		return this.parent.getRootFile();    	
    }
    
    private void populateAncestory(List<String> list) {
        if (this.parent != null) {
            list.add(0, getName());         
            this.parent.populateAncestory(list);
        }
    }
    
    public String getPersistPath() {
        if (size() > MAX_SIZE_FOR_INTERNAL_MAP) {
            return getPath();
        }
        else if (this.parent == null) {
            return getName();
        }
        else return parent.getPersistPath();
    }
    
    public int getMaximumSize() {
        return this.maxSize;
    }
    
    public String getAccessorClassName() {
        return this.accessorClassName;
    }    

    public void clear() {
        synchronized (this.map) {
            this.map.clear();
        }
        if (this.parent != null) {
            this.parent.remove(getName());
        }
        markModified();        
    }

    public boolean containsKey(Object key) {
        synchronized (this.map) {
            return this.map.containsKey(key);
        }
    }

    public boolean containsValue(Object value) {
        synchronized (this.map) {
            return this.map.containsValue(value);
        }
    }

    public Set<Map.Entry<String, Object>> entrySet() {
        HashSet<Map.Entry<String, Object>> entries = null;
        synchronized (this.map) {
            entries = new HashSet<Map.Entry<String, Object>>(this.map.entrySet());
        }
        Iterator<Map.Entry<String, Object>> i = entries.iterator();
        while (i.hasNext()) {
            Map.Entry<String, Object> entry = i.next();
            // Remove entries with null value
            if (entry.getValue() == null) {
                i.remove();
            }
            // Prepare inner maps
            else if (entry.getValue() instanceof WikiMap) {
                WikiMap innerMap = (WikiMap) entry.getValue();
                innerMap.parent = this;
                innerMap.checkPermission();
                if (innerMap.map == null) {
	                innerMap.loadPersistedMap();            
	                innerMap.markAccessed();
                }
            }
        }
        return entries;
    }
    
    /**
     * Returns a map of the plain data within this map.
     * 
     * Child maps will not be included in the returned map.
     * Entries that were removed from the map and marked with a null
     * value are included to aid synchronisation of deletes.
     * 
     * @return plain data stored within this map.
     */
    Map<String, Object> getPlainMapForSync() {
        
        Map<String, Object> plain;
        synchronized (this.map) {
            plain = new HashMap<String, Object>(this.map);
        }   
        
        Iterator<Map.Entry<String, Object>> i = plain.entrySet().iterator();
        while (i.hasNext()) {
            Map.Entry<String, Object> entry = i.next();
            // Remove child maps
            if (entry.getValue() instanceof WikiMap) {
                i.remove();             
            }
        }        
        return plain;
    }

    public Object get(Object key) {
        Object value = null;
        synchronized (this.map) {
            value = this.map.get(key);
        }
        if (value instanceof WikiMap) {
            WikiMap innerMap = ((WikiMap)value);
            innerMap.parent = this;
            innerMap.checkPermission();
            if (innerMap.map == null) {
                innerMap.loadPersistedMap();            
                innerMap.markAccessed();
            }
        }
        markAccessed();
        return value;
    }
    
    public Object deepGet(String... keys) {
        Object value = this;
        for (int i=0; i<keys.length; i++) {
            if (value instanceof WikiMap) {
                value = ((WikiMap)value).get(keys[i]);
            }
            else break;
        }
        return value;
    }

    public boolean isEmpty() {    
        synchronized (this.map) {
            return this.map.isEmpty();
        }
    }

    public Set<String> keySet() {
        HashSet<String> keys;
        synchronized (this.map) {
            keys = new HashSet<String>(this.map.keySet());
        }
        Iterator<String> i = keys.iterator();
        while (i.hasNext()) {
            Object value = get(i.next());
            // Remove keys with null values
            if (value == null) {
                i.remove();
            }   
        }
        return keys;
    }

    public Object put(String key, Object value) {      

        Object oldValue = null;
        synchronized (this.map) {
            oldValue = this.map.put(key, value);
            if (oldValue instanceof WikiMap) {
            	WikiMap oldMap = (WikiMap)oldValue;
                try {
                    // Check permission on WikiMap
                	oldMap.checkPermission();
                } catch (SecurityException ex) {
                    // Whoops! put old value back in
                    oldValue = this.map.put(key, oldValue);   
                    throw ex;
                }
                if (oldMap.map == null) {
                	oldMap.loadPersistedMap();            
                	oldMap.markAccessed();
                }
            }
        }
        
        // Link inner map
        if (value instanceof WikiMap) {
            WikiMap innerMap = ((WikiMap)value);
            innerMap.parent = this;
            innerMap.name = key;
        }    
        
        // Cleanup persisted inner maps
        if (value == null && oldValue instanceof WikiMap) {
            ((WikiMap)oldValue).deletePersistedMap();        
        }
        
        if (hasChanged(oldValue, value)) markModified();        

        return oldValue;

    }
    
    private boolean hasChanged(Object from, Object to) {
        if (from == null && to == null) return false;
        if (from != null && to == null) return true;
        if (from == null && to != null) return true;
        return !from.equals(to);
    }

    public void putAll(Map<? extends String, ? extends Object> map) {
        for (Map.Entry<? extends String, ? extends Object> entry : map.entrySet()) {
            put(entry.getKey(), entry.getValue());
        }
    }

    public Object remove(Object key) {
        
        // Entries are not removed but marked with a null value.
        // This is to aid synchronisation with remote WikiMaps where 
        // knowledge of the deletes must be transferred.
        // Deletion occurs when the map is persisted and reloaded.
        return put((String)key, null);
    }

    public int size() {
        synchronized (this.map) {
            return this.map.size();
        }
    }

    public Collection<Object> values() {
        List<Object> values = null;
        synchronized (this.map) { 
            values = new LinkedList<Object>(this.map.values());
        }
        Iterator<Object> i = values.iterator();
        while (i.hasNext()) {
            Object value = i.next();
            // Remove null values
            if (value == null) {
                i.remove();
            }       
            // Prepare inner maps
            else if (value instanceof WikiMap) {
                WikiMap innerMap = (WikiMap) value;
                innerMap.parent = this;
                innerMap.checkPermission();
                if (innerMap.map == null) {
	                innerMap.loadPersistedMap();            
	                innerMap.markAccessed();
                }              
            }
        }
        return values;
    }

    public long incrementLongValue(String key, long amount) {
        Object obj = get(key);
        if (obj == null) {
            obj = new AtomicLong(0);
            put(key, obj);
        }
        if (!(obj instanceof AtomicLong) && obj instanceof Number) {
            obj = new AtomicLong(((Number)obj).longValue());
            put(key, obj);            
        }
        if (obj instanceof AtomicLong) {
            return ((AtomicLong)obj).addAndGet(amount);
        }        
        throw new IllegalArgumentException("The value for key " + key +
            " can not be incremented because it is not a number.");
    } 
    
    public double recalculateAverage(String key, long amount) {
        long total, count;
        synchronized (this.map) {
            total = incrementLongValue(key + "$total", amount);
            count = incrementLongValue(key + "$count", 1);
        }

        double avg = total / count;
        put(key, new Double(avg));
        return avg;
    }    
    
    public long getLastAccessTime() {
        return this.lastAccessTime;
    }

    public long getLastModifiedTime() {
        return this.lastModifiedTime;
    }
    
    public long getChildModifiedTime() {
        return this.childModifiedTime;
    }    
    
    void setLastAccessTime(long lastAccessTime) {
        this.lastAccessTime = lastAccessTime;
    }

    void setLastModifiedTime(long lastModifiedTime) {
        this.lastModifiedTime = lastModifiedTime;
    }

    public String toString() {
        return entrySet().toString();
    }    

    private void writeObject(ObjectOutputStream out) throws IOException {

        // If this WikiMap is unloaded or large, don't persist internally  
        if (this.map == null || size() > MAX_SIZE_FOR_INTERNAL_MAP) {
            this.internalMap = null;
        }
        else {
            debugFileAccess("Persisting map");
            this.internalMap = new ForgetfulMap<String, Object>(this.map, this.maxSize);        
        }
        
        out.defaultWriteObject(); 
    }
    
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        this.map = this.internalMap;
    }
    
    void persistMap() {
    	
    	if (this.map == null || size() == 0) return; // Nothing to persist!
        
        // Create a snapshot of the current data to avoid blocking access
        // TODO Check sync in ForgetfulMap
        ForgetfulMap<String, Object> snapshot = new ForgetfulMap<String, Object>(this.map, this.maxSize);    
        
        // Persist map to own file if not stored inside another map
        if (this.internalMap == null) {
            boolean thisModified = this.lastModifiedTime > this.lastPersistTime;
            boolean childModified = this.childModifiedTime > this.lastPersistTime;
            if (thisModified || childModified) {               
                ObjectOutputStream out = null;
                String path = getPath();
                File finalFile = new File(getRootFile(), path + MAP_FILE_SUFFIX);
                File tempFile = new File(getRootFile(), path + TEMP_MAP_FILE_SUFFIX);
                File parent = finalFile.getParentFile();
                if (parent != null && !parent.exists()) parent.mkdirs();
                boolean completedWrite = false;
                try {
                    out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(tempFile)));
                    out.writeObject(snapshot);   
                    completedWrite = true;
                } catch (Exception ex) {
                    System.err.println("Failed to persist: " + getPath());
                    ex.printStackTrace();
                }  finally {
                    try { 
                        out.close();
                        if (completedWrite) {
                            finalFile.delete();
                            tempFile.renameTo(finalFile);      
                            this.lastPersistTime = System.currentTimeMillis(); 
                            debugFileAccess("Persisted map");                            
                        }
                    } catch (Exception ex) {}
                }
            }       
        }
        
        // Attempt to persist inner maps to files (they may be stored internally)
        for (Map.Entry<String, Object> entry : snapshot.entrySet()) {
            if (entry.getValue() instanceof WikiMap) {
                ((WikiMap)entry.getValue()).persistMap();
            }
        }
    }
    
    @SuppressWarnings({ "unchecked", "rawtypes" })
    private void loadPersistedMap() { 
        debugFileAccess("Loading map");    
        // WikiMap requires loading
        ObjectInputStream in = null;
        try {
            File file = new File(getRootFile(), getPath() + MAP_FILE_SUFFIX);             
            in = new ObjectInputStream(new FileInputStream(file));
            this.map = (Map<String, Object>) in.readObject();
            
        } catch (FileNotFoundException ex) {
        	this.map = new ForgetfulMap(this.maxSize);
        } catch (IOException ex) { 
        	this.map = new ForgetfulMap(this.maxSize);
            ex.printStackTrace();
        } catch (SecurityException ex) { 
            this.map = new ForgetfulMap(this.maxSize);
            ex.printStackTrace();            
        } catch (ClassNotFoundException ex) {
            ex.printStackTrace();
        }  finally {
            try { 
                in.close();
                debugFileAccess("Loaded map: " + getPath());                    
            } catch (Exception ex) {}
        }
    }
    
    private void unload() {
        // Only maps not stored internally can be reloaded easily.
        if (this.internalMap == null) {
            this.map = null;
            debugFileAccess("Unloaded map");
        }
    }
    
    void unloadIfNotAccessedSince(long time) {
        if (this.parent != null && this.lastAccessTime < time) {
            // Unload this WikiMap and all children
            unload();
        }
        else if (this.map != null){
            // Try unloading the children
            Iterator<Object> i = values().iterator();
            while(i.hasNext()) {
                Object value = i.next();
                if (value instanceof WikiMap) {
                    ((WikiMap)value).unloadIfNotAccessedSince(time);
                }
            }     
        }
    }
    
    private void markAccessed() {
        this.lastAccessTime = System.currentTimeMillis();
    }
    
    private void markModified() {
        this.lastModifiedTime = System.currentTimeMillis();
        // Notify the parent that inner maps have been modified
        if (this.parent != null) {
            this.parent.markChildModified();
        } 
    }  
    
    private void markChildModified() {
        this.childModifiedTime = System.currentTimeMillis();        
        if (this.parent != null) {
            this.parent.markChildModified();
        }
    }     
    
    private void deletePersistedMap() {
        fileCleanUp(new File(getPath() + ".map"));    
    }
    
    private void fileCleanUp(File file) {
        if (file.exists()) {
            file.delete();
            File parent = file.getParentFile();
            while (parent != null && parent.listFiles().length == 0) {
                parent.delete();
                parent = parent.getParentFile();
            }
            debugFileAccess("Cleaned map");                
        }        
    }
    
    private void checkPermission() {
        if (this.accessorClassName == null) return;
        
        // Find the class that called this WikiMap
        // 'Helper' classes should be ignored
        String[] excludes = CLASSES_EXCLUDED_FROM_PERMISSION_CHECK;
        
        String callingClassName = WareHouse.getCallingClassName(excludes);
        if (this.accessorClassName.equals(callingClassName)) {
            return;
        }
        
        String[] permitted = CLASSES_ALWAYS_PERMITTED_ACCESS;
        
        for (String allow : permitted) {
            if (callingClassName.equals(allow)) return;
        }  
        
        throw new SecurityException(callingClassName +
                " is not permitted to access " + getPath() +
                " because access is restricted to " + 
                this.accessorClassName);
    }   
    
    private void debugFileAccess(String msg) {
        if (DEBUG_FILE_ACCESS) System.out.println(msg + ": " + getPath());
    }
}
