package freenet.support.io;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.Closeable;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.Vector;

import org.tanukisoftware.wrapper.WrapperManager;

import freenet.client.async.ClientContext;
import freenet.support.LogThresholdCallback;
import freenet.support.Logger;
import freenet.support.Logger.LogLevel;
import freenet.support.api.Bucket;
import freenet.support.api.LockableRandomAccessBuffer;
import freenet.support.api.RandomAccessBucket;

public abstract class BaseFileBucket implements RandomAccessBucket {
    private static volatile boolean logMINOR;
    private static volatile boolean logDEBUG;

    static {
        Logger.registerLogThresholdCallback(new LogThresholdCallback() {

            @Override
            public void shouldUpdate() {
                logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
                logDEBUG = Logger.shouldLog(LogLevel.DEBUG, this);
            }
        });
    }

	protected long fileRestartCounter;
	/** Has the bucket been freed? If so, no further operations may be done */
	private boolean freed;
	/** Vector of streams (FileBucketInputStream or FileBucketOutputStream) which 
	 * are open to this file. So we can be sure they are all closed when we free it. 
	 * Can be null. */
	private transient Vector<Closeable> streams;

	protected static String tempDir = null;

	/**
	 * Constructor.
	 * @param file
	 * @param deleteOnExit If true, call File.deleteOnExit() on the file. 
	 * WARNING: Delete on exit is a memory leak: The filenames are kept until the JVM exits, and 
	 * cannot be removed even when the file has been deleted! It should only be used where it is 
	 * ESSENTIAL! Note that if you want temp files to be deleted on exit, you also need to override
	 * deleteOnExit().
	 */
	public BaseFileBucket(File file, boolean deleteOnExit) {
		if(file == null) throw new NullPointerException();
		maybeSetDeleteOnExit(deleteOnExit, file);
        assert(!(createFileOnly() && tempFileAlreadyExists())); // Mutually incompatible!
	}
	
	protected BaseFileBucket() {
	    // For serialization.
	}

        private void maybeSetDeleteOnExit(boolean deleteOnExit, File file) {
        	if(deleteOnExit)
			setDeleteOnExit(file);
        }
	
	protected void setDeleteOnExit(File file) {
		try {
			file.deleteOnExit();
		} catch (NullPointerException e) {
			if(WrapperManager.hasShutdownHookBeenTriggered()) {
				Logger.normal(this, "NullPointerException setting deleteOnExit while shutting down - buggy JVM code: "+e, e);
			} else {
				Logger.error(this, "Caught "+e+" doing deleteOnExit() for "+file+" - JVM bug ????");
			}
		}
	}

	@Override
	public OutputStream getOutputStreamUnbuffered() throws IOException {
		synchronized (this) {
			File file = getFile();
			if(freed)
				throw new IOException("File already freed: "+this);
			if(isReadOnly())
				throw new IOException("Bucket is read-only: "+this);
			
			if(createFileOnly() && // Fail if file already exists 
			        fileRestartCounter == 0 && // Ignore if we're just clobbering our own file after a previous getOutputStream() 
			        !file.createNewFile()) {
			    throw new FileExistsException(file);
			}
			if(tempFileAlreadyExists() && !(file.exists() && file.canRead() && file.canWrite())) {
			    throw new FileDoesNotExistException(file);
			}
			
			if(streams != null && !streams.isEmpty())
				Logger.error(this, "Streams open on "+this+" while opening an output stream!: "+streams, new Exception("debug"));
			
			boolean rename = !tempFileAlreadyExists();
			File tempfile = rename ? getTempfile() : file;
			long streamNumber = ++fileRestartCounter;
			
			FileBucketOutputStream os = 
				new FileBucketOutputStream(tempfile, streamNumber);
			
			if(logDEBUG)
				Logger.debug(this, "Creating "+os, new Exception("debug"));
			
			addStream(os);
			return os;
		}
	}
	
	@Override
	public OutputStream getOutputStream() throws IOException {
	    return new BufferedOutputStream(getOutputStreamUnbuffered());
	}

	private synchronized void addStream(Closeable stream) {
		// BaseFileBucket is a very common object, and often very long lived,
		// so we need to minimize memory usage even at the cost of frequent allocations.
		if(streams == null)
			streams = new Vector<Closeable>(1, 1);
		streams.add(stream);
	}

	private synchronized void removeStream(Closeable stream) {
		// Race condition is possible
		if(streams == null) return;
		streams.remove(stream);
		if(streams.isEmpty()) streams = null;
	}
	
	/** If true, then the file is temporary and must already exist, so we will just open it. 
	 * Otherwise we will create a temporary file and then rename it over the target.
	 * Incompatible with createFileOnly()! */
	protected abstract boolean tempFileAlreadyExists();

	/** If true, we will fail if the file already exist. Incompatible with tempFileAlreadyExists()! */
	protected abstract boolean createFileOnly();
	
	protected abstract boolean deleteOnExit();
	
	protected abstract boolean deleteOnFree();

	/**
	 * Create a temporary file in the same directory as this file.
	 */
	protected File getTempfile() throws IOException {
		File file = getFile();
		File f = FileUtil.createTempFile(file.getName(), ".freenet-tmp", file.getParentFile());
		if(deleteOnExit()) f.deleteOnExit();
		return f;
	}
	
	/**
	 * Internal OutputStream impl.
	 * If createFileOnly is set, we won't overwrite an existing file, and we write to a temp file
	 * then rename over the target. Note that we can't use createNewFile then new FOS() because while
	 * createNewFile is atomic, the combination is not, so if we do it we are vulnerable to symlink
	 * attacks.
	 * @author toad
	 */
	class FileBucketOutputStream extends FileOutputStream {

		private long restartCount;
		private File tempfile;
		private boolean closed;
		
		protected FileBucketOutputStream(
			File tempfile, long restartCount)
			throws FileNotFoundException {
			super(tempfile, false);
			if(logMINOR)
				Logger.minor(FileBucketOutputStream.class, "Writing to "+tempfile+" for "+getFile()+" : "+this);
			this.tempfile = tempfile;
			this.restartCount = restartCount;
			closed = false;
		}
		
		protected void confirmWriteSynchronized() throws IOException {
			synchronized(BaseFileBucket.this) {
				if (fileRestartCounter > restartCount)
					throw new IllegalStateException("writing to file after restart");
				if(freed)
					throw new IOException("writing to file after it has been freed");
			}
			if(isReadOnly())
				throw new IOException("File is read-only");
				
		}
		
		@Override
		public void write(byte[] b) throws IOException {
			synchronized (BaseFileBucket.this) {
				confirmWriteSynchronized();
				super.write(b);
			}
		}

		@Override
		public void write(byte[] b, int off, int len) throws IOException {
			synchronized (BaseFileBucket.this) {
				confirmWriteSynchronized();
				super.write(b, off, len);
			}
		}

		@Override
		public void write(int b) throws IOException {
			synchronized (BaseFileBucket.this) {
				confirmWriteSynchronized();
				super.write(b);
			}
		}
		
		@Override
		public void close() throws IOException {
			File file;
			synchronized(this) {
				if(closed) return;
				closed = true;
				file = getFile();
			}
			boolean renaming = !tempFileAlreadyExists();
			removeStream(this);
			if(logMINOR)
				Logger.minor(this, "Closing "+BaseFileBucket.this);
			try {
				super.close();
			} catch (IOException e) {
				if(logMINOR)
					Logger.minor(this, "Failed closing "+BaseFileBucket.this+" : "+e, e);
				if(renaming) tempfile.delete();
				throw e;
			}
			if(renaming) {
			    // getOutputStream() creates the file as a marker, so DON'T check for its existence, 
			    // even if createFileOnly() is true.
			    if(!FileUtil.renameTo(tempfile, file)) {
			        tempfile.delete();
			        if(logMINOR)
			            Logger.minor(this, "Deleted, cannot rename file for "+this);
			        throw new IOException("Cannot rename file");
			    }
			}
		}
		
		@Override
		public String toString() {
			return super.toString()+":"+BaseFileBucket.this.toString();
		}
	}

	class FileBucketInputStream extends FileInputStream {
		boolean closed;

		public FileBucketInputStream(File f) throws IOException {
			super(f);
		}
		
		@Override
		public void close() throws IOException {
			synchronized(this) {
				if(closed) return;
				closed = true;
			}
			removeStream(this);
			super.close();
		}
		
		@Override
		public String toString() {
			return super.toString()+":"+BaseFileBucket.this.toString();
		}
	}

	@Override
	public synchronized InputStream getInputStreamUnbuffered() throws IOException {
		if(freed)
			throw new IOException("File already freed: "+this);
		File file = getFile();
		if(!file.exists()) {
			Logger.normal(this, "File does not exist: "+file+" for "+this);
			return new NullInputStream();
		} else {
			FileBucketInputStream is =
				new FileBucketInputStream(file);
			addStream(is);
			if(logDEBUG)
				Logger.debug(this, "Creating "+is, new Exception("debug"));
			return is;
		}
	}
	
	public InputStream getInputStream() throws IOException {
	    return new BufferedInputStream(getInputStreamUnbuffered());
	}

	/**
	 * @return the name of the file.
	 */
	@Override
	public synchronized String getName() {
		return getFile().getName();
	}

	@Override
	public synchronized long size() {
	    return getFile().length();
	}

	/**
	 * Actually delete the underlying file. Called by finalizer, will not be
	 * called twice. But length must still be valid when calling it.
	 */
	protected synchronized void deleteFile() {
		if(logMINOR)
			Logger.minor(this, "Deleting "+getFile()+" for "+this, new Exception("debug"));
		getFile().delete();
	}

	/**
	 * Return directory used for temp files.
	 */
	public synchronized static String getTempDir() {
		return tempDir;  // **FIXME**/TODO: locking on tempDir needs to be checked by a Java guru for consistency
	}

	/**
	 * Set temp file directory.
	 * <p>
	 * The directory must exist.
	 */
	public synchronized static void setTempDir(String dirName) {
		File dir = new File(dirName);
		if (!(dir.exists() && dir.isDirectory() && dir.canWrite())) {
			throw new IllegalArgumentException(
				"Bad Temp Directory: " + dir.getAbsolutePath());
		}
		tempDir = dirName;  // **FIXME**/TODO: locking on tempDir needs to be checked by a Java guru for consistency
	}

	// determine the temp directory in one of several ways

	static {
		// Try the Java property (1.2 and above)
		tempDir = System.getProperty("java.io.tmpdir");

		// Deprecated calls removed.

		// Try TEMP and TMP
		//	if (tempDir == null) {
		//	    tempDir = System.getenv("TEMP");
		//	}

		//	if (tempDir == null) {
		//	    tempDir = System.getenv("TMP");
		//	}

		// make some semi-educated guesses based on OS.

		if (tempDir == null) {
			String os = System.getProperty("os.name");
			if (os != null) {

				String[] candidates = null;

				// XXX: Add more possible OSes here.
				if (os.equalsIgnoreCase("Linux")
					|| os.equalsIgnoreCase("FreeBSD")) {
					String[] linuxCandidates = { "/tmp", "/var/tmp" };
					candidates = linuxCandidates;
				} else if (os.equalsIgnoreCase("Windows")) {
					String[] windowsCandidates =
						{ "C:\\TEMP", "C:\\WINDOWS\\TEMP" };
					candidates = windowsCandidates;
				}

				if (candidates != null) {
					for (String candidate: candidates) {
						File path = new File(candidate);
						if (path.exists()
							&& path.isDirectory()
							&& path.canWrite()) {
							tempDir = candidate;
							break;
						}
					}
				}
			}
		}

		// last resort -- use current working directory

		if (tempDir == null) {
			// This can be null -- but that's OK, null => cwd for File
			// constructor, anyways.
			tempDir = System.getProperty("user.dir");
		}
	}

	public synchronized Bucket[] split(int splitSize) {
	    long length = size();
		if(length > ((long)Integer.MAX_VALUE) * splitSize)
			throw new IllegalArgumentException("Way too big!: "+length+" for "+splitSize);
		int bucketCount = (int) (length / splitSize);
		if(length % splitSize > 0) bucketCount++;
		Bucket[] buckets = new Bucket[bucketCount];
		File file = getFile();
		for(int i=0;i<buckets.length;i++) {
			long startAt = 1L * i * splitSize;
			long endAt = Math.min(startAt + splitSize * 1L, length);
			long len = endAt - startAt;
			buckets[i] = new ReadOnlyFileSliceBucket(file, startAt, len);
		}
		return buckets;
	}

	@Override
	public void free() {
		free(false);
	}
	
	public void free(boolean forceFree) {
		Closeable[] toClose;
		if(logMINOR)
			Logger.minor(this, "Freeing "+this, new Exception("debug"));
		synchronized(this) {
			if(freed) return;
			freed = true;
			toClose = streams == null ? null : streams.toArray(new Closeable[streams.size()]);
			streams = null;
		}
		
		if(toClose != null) {
			Logger.error(this, "Streams open free()ing "+this+" : "+Arrays.toString(toClose), new Exception("debug"));
			for(Closeable strm: toClose) {
				try {
					strm.close();
				} catch (IOException e) {
					Logger.error(this, "Caught closing stream in free(): "+e, e);
				} catch (Throwable t) {
					Logger.error(this, "Caught closing stream in free(): "+t, t);
				}
			}
		}
		
		File file = getFile();
		if ((deleteOnFree() || forceFree) && file.exists()) {
			Logger.debug(this,
				"Deleting bucket " + file, new Exception("debug"));
			deleteFile();
			if (file.exists())
				Logger.error(this,
					"Delete failed on bucket " + file, new Exception("debug"));
		}
	}
	
	@Override
	public synchronized String toString() {
		StringBuffer sb = new StringBuffer();
		sb.append(super.toString());
		sb.append(':');
		File f = getFile();
		if(f != null)
			sb.append(f.getPath());
		else
			sb.append("???");
		sb.append(":streams=");
		sb.append(streams == null ? 0 : streams.size());
		return sb.toString();
	}

	/**
	 * Returns the file object this buckets data is kept in.
	 */
	public abstract File getFile();

	@Override
	public void onResume(ClientContext context) throws ResumeFailedException {
	    // Do nothing.
	}
	
	public static final int MAGIC = 0xc4b7533d;
	static final int VERSION = 1;
	
    @Override
    public void storeTo(DataOutputStream dos) throws IOException {
        dos.writeInt(MAGIC);
        dos.writeInt(VERSION);
        dos.writeBoolean(freed);
    }
	
    protected BaseFileBucket(DataInputStream dis) throws IOException, StorageFormatException {
        // Not constructed directly, so we DO need to read the magic value.
        int magic = dis.readInt();
        if(magic != MAGIC) throw new StorageFormatException("Bad magic");
        int version = dis.readInt();
        if(version != VERSION) throw new StorageFormatException("Bad version");
        freed = dis.readBoolean();
    }
    
    @Override
    public LockableRandomAccessBuffer toRandomAccessBuffer() throws IOException {
        if(freed) throw new IOException("Already freed");
        setReadOnly();
        long size = size();
        if(size == 0) throw new IOException("Must not be empty");
        return new PooledFileRandomAccessBuffer(getFile(), true, size, null, 
                getPersistentTempID(), deleteOnFree());
    }
    
    protected long getPersistentTempID() {
        return -1;
    }

}
