/* 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.client.async;

import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;

import freenet.client.ClientMetadata;
import freenet.client.DefaultMIMETypes;
import freenet.client.InsertBlock;
import freenet.client.InsertContext;
import freenet.client.InsertException;
import freenet.client.InsertException.InsertExceptionMode;
import freenet.client.Metadata;
import freenet.client.MetadataUnresolvedException;
import freenet.client.ArchiveManager.ARCHIVE_TYPE;
import freenet.client.Metadata.DocumentType;
import freenet.client.Metadata.SimpleManifestComposer;
import freenet.client.async.BaseManifestPutter.PutHandler;
import freenet.keys.FreenetURI;
import freenet.support.Logger;
import freenet.support.api.Bucket;
import freenet.support.api.ManifestElement;
import freenet.support.api.RandomAccessBucket;
import freenet.support.io.BucketTools;
import freenet.support.io.Closer;
import freenet.support.io.ResumeFailedException;

/**
 * Insert a bunch of files as single Archive with .metadata
 * pack the container/archive, then hand it off to SimpleFileInserter
 *
 * TODO persistence
 * TODO add a MAX_SIZE for the final container(file)
 * 
 * @author saces
 * 
 */
public class ContainerInserter implements ClientPutState, Serializable {

    private static final long serialVersionUID = 1L;
    private static volatile boolean logMINOR;
	private static volatile boolean logDEBUG;

	static {
		Logger.registerClass(ContainerInserter.class);
	}

	private static class ContainerElement {
		private final Bucket data;
		private final String targetInArchive;
		
		private ContainerElement(Bucket data2, String targetInArchive2) {
			data = data2;
			targetInArchive = targetInArchive2;
		}
	}

	private ArrayList<ContainerElement> containerItems;

	private final BaseClientPutter parent;
	private final PutCompletionCallback cb;
	private boolean cancelled;
	private boolean finished;
	private final boolean persistent;
	/** See ContainerBuilder._rootDir. */
	private final HashMap<String, Object> origMetadata;
	private final ARCHIVE_TYPE archiveType;
	private final FreenetURI targetURI;
	private final Object token;
	private final InsertContext ctx;
	private final boolean reportMetadataOnly;
	private final boolean dontCompress;
	final byte[] forceCryptoKey;
	final byte cryptoAlgorithm;
	private final boolean realTimeFlag;

	/**
	 * Insert a bunch of files as single Archive with .metadata
	 * 
	 * @param parent2
	 * @param cb2
	 * @param metadata2
	 * @param targetURI2 The caller need to clone it for persistance
	 * @param ctx2
	 * @param dontCompress2
	 * @param reportMetadataOnly2
	 * @param token2
	 * @param archiveType2
	 * @param freeData
	 * @param forceCryptoKey
	 * @param cryptoAlgorithm
	 * @param realTimeFlag
	 * 
	 */
	public ContainerInserter(
			BaseClientPutter parent2, 
			PutCompletionCallback cb2, 
			HashMap<String, Object> metadata2,
			FreenetURI targetURI2,
			InsertContext ctx2,
			boolean dontCompress2,
			boolean reportMetadataOnly2,
			Object token2,
			ARCHIVE_TYPE archiveType2,
			boolean freeData,
			byte[] forceCryptoKey,
			byte cryptoAlgorithm,
			boolean realTimeFlag) {
		parent = parent2;
		cb = cb2;
		hashCode = super.hashCode();
		persistent = parent.persistent();
		origMetadata = metadata2;
		archiveType = archiveType2;
		targetURI = targetURI2;
		token = token2;
		ctx = ctx2;
		dontCompress = dontCompress2;
		reportMetadataOnly = reportMetadataOnly2;
		containerItems = new ArrayList<ContainerElement>();
		this.forceCryptoKey = forceCryptoKey;
		this.cryptoAlgorithm = cryptoAlgorithm;
		this.realTimeFlag = realTimeFlag;
	}

	@Override
	public void cancel(ClientContext context) {
		synchronized(this) {
			if(cancelled) return;
			cancelled = true;
		}
		// Must call onFailure so get removeFrom()'ed
		cb.onFailure(new InsertException(InsertExceptionMode.CANCELLED), this, context);
	}

	@Override
	public BaseClientPutter getParent() {
		return parent;
	}

	@Override
	public Object getToken() {
		return token;
	}

	@Override
	public void schedule(ClientContext context) throws InsertException {
		start(context);
	}


	private void start(ClientContext context) {
		if(logDEBUG) Logger.debug(this, "Atempt to start a container inserter", new Exception("debug"));
		
		makeMetadata(context);
		
		synchronized(this) {
			if(finished) return;
		}
		
		InsertBlock block;
		OutputStream os = null;
		try {
		    RandomAccessBucket outputBucket = context.getBucketFactory(persistent).makeBucket(-1);
			os = new BufferedOutputStream(outputBucket.getOutputStream());
			String mimeType = (archiveType == ARCHIVE_TYPE.TAR ?
				createTarBucket(os) :
				createZipBucket(os));
			os = null; // create*Bucket closes os
			if(logMINOR)
				Logger.minor(this, "Archive size is "+outputBucket.size());
			
			if(logMINOR) Logger.minor(this, "We are using "+archiveType);
			
			// Now we have to insert the Archive we have generated.
			
			// Can we just insert it, and not bother with a redirect to it?
			// Thereby exploiting implicit manifest support, which will pick up on .metadata??
			// We ought to be able to !!
			block = new InsertBlock(outputBucket, new ClientMetadata(mimeType), targetURI);
		} catch (IOException e) {
			fail(new InsertException(InsertExceptionMode.BUCKET_ERROR, e, null), context);
			return;
		} finally {
			Closer.close(os);
		}
		
		boolean dc = dontCompress;
		if (!dontCompress) {
			dc = (archiveType == ARCHIVE_TYPE.ZIP);
		}
		
		// Treat it as a splitfile for purposes of determining reinsert count.
		SingleFileInserter sfi = new SingleFileInserter(parent, cb, block, false, ctx, realTimeFlag, dc, reportMetadataOnly, token, archiveType, true, null, true, persistent, 0, 0, null, cryptoAlgorithm, forceCryptoKey, -1);
		if(logMINOR)
			Logger.minor(this, "Inserting container: "+sfi+" for "+this);
		cb.onTransition(this, sfi, context);
		try {
			sfi.schedule(context);
		} catch (InsertException e) {
			fail(new InsertException(InsertExceptionMode.BUCKET_ERROR, e, null), context);
			return;
		}
	}

	private void makeMetadata(ClientContext context) {

		Bucket bucket = null;
		int x = 0;

		Metadata md = makeManifest(origMetadata, "");

		while(true) {
			try {
				bucket = md.toBucket(context.getBucketFactory(persistent));
				containerItems.add(new ContainerElement(bucket, ".metadata"));
				return;
			} catch (MetadataUnresolvedException e) {
				try {
					x = resolve(e, x, null, null, context);
				} catch (IOException e1) {
					fail(new InsertException(InsertExceptionMode.INTERNAL_ERROR, e, null), context);
					return;
				}
			} catch (IOException e) {
				fail(new InsertException(InsertExceptionMode.INTERNAL_ERROR, e, null), context);
				return;
			}
		}
		
	}

	private int resolve(MetadataUnresolvedException e, int x, FreenetURI key, String element2, ClientContext context) throws IOException {
		Metadata[] metas = e.mustResolve;
		for(Metadata m: metas) {
			try {
				Bucket bucket = m.toBucket(context.getBucketFactory(persistent));
				String nameInArchive = ".metadata-"+(x++);
				containerItems.add(new ContainerElement(bucket, nameInArchive));
				m.resolve(nameInArchive);
			} catch (MetadataUnresolvedException e1) {
				x = resolve(e, x, key, element2, context);
			}
		}
		return x;
	}

	private void fail(InsertException e, ClientContext context) {
		// Cancel all, then call the callback
		synchronized(this) {
			if(finished) return;
			finished = true;
		}
		cb.onFailure(e, this, context);
	}

	// A persistent hashCode is helpful in debugging, and also means we can put
	// these objects into sets etc when we need to.
	
	private final int hashCode;
	
	@Override
	public int hashCode() {
		return hashCode;
	}
	
	/**
	** OutputStream os will be close()d if this method returns successfully.
	*/
	private String createTarBucket(OutputStream os) throws IOException {
		if(logMINOR) Logger.minor(this, "Create a TAR Bucket");
		
		TarArchiveOutputStream tarOS = new TarArchiveOutputStream(os);
		try {
			tarOS.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU);
			TarArchiveEntry ze;

			for (ContainerElement ph : containerItems) {
				if (logMINOR)
					Logger.minor(this, "Putting into tar: " + ph + " data length " + ph.data.size() + " name " + ph.targetInArchive);
				ze = new TarArchiveEntry(ph.targetInArchive);
				ze.setModTime(0);
				long size = ph.data.size();
				ze.setSize(size);
				tarOS.putArchiveEntry(ze);
				BucketTools.copyTo(ph.data, tarOS, size);
				tarOS.closeArchiveEntry();
			}
		} finally {
			tarOS.close();
		}
		
		return ARCHIVE_TYPE.TAR.mimeTypes[0];
	}
	
	private String createZipBucket(OutputStream os) throws IOException {
		if(logMINOR) Logger.minor(this, "Create a ZIP Bucket");
		
		ZipOutputStream zos = new ZipOutputStream(os);
		try {
			ZipEntry ze;

			for (ContainerElement ph : containerItems) {
				ze = new ZipEntry(ph.targetInArchive);
				ze.setTime(0);
				zos.putNextEntry(ze);
				BucketTools.copyTo(ph.data, zos, ph.data.size());
				zos.closeEntry();
			}
		} finally {
			zos.close();
		}

		return ARCHIVE_TYPE.ZIP.mimeTypes[0];
	}

	private Metadata makeManifest(HashMap<String, Object> manifestElements, String archivePrefix) {
		SimpleManifestComposer smc = new Metadata.SimpleManifestComposer();
		for (Map.Entry<String, Object> me : manifestElements.entrySet()) {
			String name = me.getKey();
			Object o = me.getValue();
			if(o instanceof HashMap) {
				@SuppressWarnings("unchecked")
				HashMap<String,Object> hm = (HashMap<String, Object>) o;
				HashMap<String,Object> subMap = new HashMap<String,Object>();
				//System.out.println("Decompose: "+name+" (SubDir)");
				smc.addItem(name, makeManifest(hm, archivePrefix+name+ '/'));
				if(logDEBUG)
					Logger.debug(this, "Sub map for "+name+" : "+subMap.size()+" elements from "+hm.size());
			} else if (o instanceof Metadata) {
				//already Metadata, take it as is
				//System.out.println("Decompose: "+name+" (Metadata)");
				smc.addItem(name, (Metadata)o);
			} else {
				ManifestElement element = (ManifestElement) o;
				String mimeType = element.getMimeType();
				ClientMetadata cm;
				if(mimeType == null || mimeType.equals(DefaultMIMETypes.DEFAULT_MIME_TYPE))
					cm = null;
				else
					cm = new ClientMetadata(mimeType);
				Metadata m;
				if(element.targetURI != null) {
					//System.out.println("Decompose: "+name+" (ManifestElement, Redirect)");
					m = new Metadata(DocumentType.SIMPLE_REDIRECT, null, null, element.targetURI, cm);
				} else {
					//System.out.println("Decompose: "+name+" (ManifestElement, Data)");
					containerItems.add(new ContainerElement(element.getData(), archivePrefix+name));
					m = new Metadata(DocumentType.ARCHIVE_INTERNAL_REDIRECT, null, null, archivePrefix+element.fullName, cm);
				}
				smc.addItem(name, m);
			}
		}
		return smc.getMetadata();
	}
	
	private transient boolean resumed = false;

    @Override
    public void onResume(ClientContext context) throws InsertException, ResumeFailedException {
        synchronized(this) {
            if(resumed) return;
            resumed = true;
        }
        if(cb != null && cb != parent)
            cb.onResume(context);
        if(containerItems != null) {
            for(ContainerElement e : containerItems) {
                if(e.data != null)
                    e.data.onResume(context);
            }
        }
        resumeMetadata(origMetadata, context);
        // Do not call start(). start() immediately transitions to another state.
    }
    
    @SuppressWarnings("unchecked")
    public static void resumeMetadata(Map<String, Object> map, ClientContext context) throws ResumeFailedException {
        Map<String, Object> manifestElements = (Map<String, Object>)map;
        for (Object o : manifestElements.values()) {
            if(o instanceof HashMap) {
                resumeMetadata((Map<String, Object>)o, context);
            } else if(o instanceof ManifestElement) {
                ManifestElement e = (ManifestElement) o;
                e.onResume(context);
            } else if(o instanceof Metadata) {
                // Ignore
            } else if(o instanceof PutHandler) {
                PutHandler handler = (PutHandler) o;
                handler.onResume(context);
            } else if(o instanceof ManifestElement) {
                ((ManifestElement)o).onResume(context);
            } else throw new IllegalArgumentException("Unknown manifest element: "+o);
        }
    }

    @Override
    public void onShutdown(ClientContext context) {
        // Ignore.
    }
}
