/*
 * Syncany, www.syncany.org
 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com>
 *
 * 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 3 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 org.syncany.tests.unit.chunk;

import static org.junit.Assert.assertArrayEquals;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.crypto.NoSuchPaddingException;

import org.apache.commons.io.IOUtils;
import org.junit.Before;
import org.junit.Test;
import org.syncany.chunk.Chunk;
import org.syncany.chunk.Chunker;
import org.syncany.chunk.CipherTransformer;
import org.syncany.chunk.Deduper;
import org.syncany.chunk.DeduperListener;
import org.syncany.chunk.GzipTransformer;
import org.syncany.chunk.MultiChunk;
import org.syncany.chunk.MultiChunker;
import org.syncany.chunk.NoTransformer;
import org.syncany.chunk.Transformer;
import org.syncany.chunk.TttdChunker;
import org.syncany.chunk.ZipMultiChunker;
import org.syncany.config.Logging;
import org.syncany.crypto.CipherException;
import org.syncany.crypto.CipherSpec;
import org.syncany.crypto.CipherSpecs;
import org.syncany.crypto.CipherUtil;
import org.syncany.crypto.SaltedSecretKey;
import org.syncany.database.ChunkEntry.ChunkChecksum;
import org.syncany.database.MultiChunkEntry.MultiChunkId;
import org.syncany.tests.unit.util.TestFileUtil;
import org.syncany.util.FileUtil;
import org.syncany.util.StringUtil;

public class FrameworkCombinationTest {
	private static final Logger logger = Logger.getLogger(FrameworkCombinationTest.class.getSimpleName());

	private File tempDir;
	private List<FrameworkCombination> combinations;

	private SaltedSecretKey masterKey;

	static {
		Logging.init();
	}

	@Before
	public void initMasterKey() throws CipherException {
		masterKey = CipherUtil.createMasterKey("some password");
	}

	@Test
	public void testBlackBoxCombinationsWith50KBInputFile() throws Exception {
		// Setup
		setup();

		// Test
		List<File> inputFiles = TestFileUtil.createRandomFilesInDirectory(tempDir, 10 * 1024, 5);

		for (FrameworkCombination combination : combinations) {
			logger.info("");
			logger.info("Testing framework combination " + combination.name + " ...");
			logger.info("---------------------------------------------------------------");

			testBlackBoxCombination(inputFiles, combination);
		}

		// Tear down (if success)
		teardown();
	}

	public void setup() throws Exception {
		tempDir = TestFileUtil.createTempDirectoryInSystemTemp();
		combinations = new ArrayList<FrameworkCombination>();

		fillCombinations();
	}

	private void fillCombinations() throws InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException {
		// MultiChunks
		List<MultiChunker> multiChunkers = new LinkedList<MultiChunker>();

		int[] multiChunkSizes = new int[] { 250000, 500000 };

		for (int i = 0; i < multiChunkSizes.length; i++) {
			//multiChunkers.add(new CustomMultiChunker(multiChunkSizes[i]));
			multiChunkers.add(new ZipMultiChunker(multiChunkSizes[i]));
		}

		// Chunks
		List<Chunker> chunkers = new LinkedList<Chunker>();

		int[] chunkSizes = new int[] { 8000, 16000 };
		String[] digestAlgs = new String[] { /*"MD5" ,*/"SHA1" };
		String[] fingerprinters = new String[] { "Adler32" /*, "Plain", "Rabin"*/};

		for (int i = 0; i < chunkSizes.length; i++) {
			for (int j = 0; j < digestAlgs.length; j++) {
				//chunkers.add(new FixedOffsetChunker(chunkSizes[i], digestAlgs[j]));

				for (int k = 0; k < fingerprinters.length; k++) {
					chunkers.add(new TttdChunker(chunkSizes[i], TttdChunker.DEFAULT_WINDOW_SIZE, digestAlgs[j], fingerprinters[k]));
				}
			}
		}

		// Compression/Encryption
		List<CipherSpec> cipherSpecs = new ArrayList<CipherSpec>();
		cipherSpecs.add(CipherSpecs.getCipherSpec(1));
		cipherSpecs.add(CipherSpecs.getCipherSpec(2));

		List<Transformer> transformerChains = new LinkedList<Transformer>();

		transformerChains.add(new NoTransformer());
		transformerChains.add(new GzipTransformer());
		transformerChains.add(new CipherTransformer(cipherSpecs, masterKey));
		transformerChains.add(new GzipTransformer(new CipherTransformer(cipherSpecs, masterKey)));

		for (MultiChunker multiChunker : multiChunkers) {
			for (Transformer transformer : transformerChains) {
				for (Chunker chunker : chunkers) {
					String configName = multiChunker + "/" + chunker + "/" + transformer;
					combinations.add(new FrameworkCombination(configName, chunker, multiChunker, transformer));
				}
			}
		}
	}

	public void teardown() throws Exception {
		TestFileUtil.deleteDirectory(tempDir);
	}

	private void testBlackBoxCombination(List<File> inputFiles, FrameworkCombination combination) throws Exception {
		// Deduplicate
		ChunkIndex chunkIndex = deduplicateAndCreateChunkIndex(inputFiles, combination);

		// Assemble
		Map<ChunkChecksum, File> extractedChunkIDToChunkFile = extractChunksFromMultiChunks(chunkIndex.outputMultiChunkFiles, combination);
		Map<File, File> inputFilesToReassembledOutputFiles = reassembleFiles(chunkIndex.inputFileToChunkIDs, extractedChunkIDToChunkFile);

		// Compare checksums of files
		for (Map.Entry<File, File> inputFilesToReassembledOutputFilesEntry : inputFilesToReassembledOutputFiles.entrySet()) {
			File inputFile = inputFilesToReassembledOutputFilesEntry.getKey();
			File outputFile = inputFilesToReassembledOutputFilesEntry.getValue();

			byte[] inputFileChecksum = TestFileUtil.createChecksum(inputFile);
			byte[] outputFileChecksum = TestFileUtil.createChecksum(outputFile);

			assertArrayEquals("Input file and output file checksums do not match" +
					"for files " + inputFile + " and " + outputFile, inputFileChecksum, outputFileChecksum);
		}
	}

	private ChunkIndex deduplicateAndCreateChunkIndex(final List<File> inputFiles, FrameworkCombination combination) throws IOException {
		logger.log(Level.INFO, "- Deduplicate and create chunk index ...");

		final ChunkIndex chunkIndex = new ChunkIndex();

		Deduper deduper = new Deduper(combination.chunker, combination.multiChunker, combination.transformer, Long.MAX_VALUE, Long.MAX_VALUE);
		deduper.deduplicate(inputFiles, new DeduperListener() {
			@Override
			public void onMultiChunkWrite(MultiChunk multiChunk, Chunk chunk) {
				logger.log(Level.INFO, "    - Adding chunk " + StringUtil.toHex(chunk.getChecksum()) + " to multichunk " + multiChunk.getId()
						+ " ...");
				chunkIndex.chunkIDToMultiChunkID.put(new ChunkChecksum(chunk.getChecksum()), multiChunk.getId());
			}

			@Override
			public void onFileAddChunk(File file, Chunk chunk) {
				logger.log(Level.INFO, "    - Adding chunk " + StringUtil.toHex(chunk.getChecksum()) + " to inputFileToChunkIDs-map for file " + file
						+ " ...");
				List<ChunkChecksum> chunkIDsForFile = chunkIndex.inputFileToChunkIDs.get(file);

				if (chunkIDsForFile == null) {
					chunkIDsForFile = new ArrayList<ChunkChecksum>();
				}

				chunkIDsForFile.add(new ChunkChecksum(chunk.getChecksum()));
				chunkIndex.inputFileToChunkIDs.put(file, chunkIDsForFile);
			}

			@Override
			public boolean onChunk(Chunk chunk) {
				if (chunkIndex.chunkIDToMultiChunkID.containsKey(new ChunkChecksum(chunk.getChecksum()))) {
					logger.log(Level.INFO, "  + Known chunk " + StringUtil.toHex(chunk.getChecksum()));
					return false;
				}
				else {
					logger.log(Level.INFO, "  + New chunk " + StringUtil.toHex(chunk.getChecksum()));
					return true;
				}
			}

			@Override
			public File getMultiChunkFile(MultiChunkId multiChunkId) {
				File outputMultiChunk = new File(tempDir + "/multichunk-" + multiChunkId);
				chunkIndex.outputMultiChunkFiles.add(outputMultiChunk);

				return outputMultiChunk;
			}

			@Override
			public MultiChunkId createNewMultiChunkId(Chunk firstChunk) {
				// Note: In the real implementation, this should be random
				return new MultiChunkId(firstChunk.getChecksum());
			}

			@Override
			public boolean onFileFilter(File file) {
				return true;
			}

			@Override
			public boolean onFileStart(File file) {
				return file.isFile() && !FileUtil.isSymlink(file);
			}

			@Override
			public void onFileEnd(File file, byte[] checksum) {
				// Empty
			}

			@Override
			public void onMultiChunkOpen(MultiChunk multiChunk) {
				// Empty
			}

			@Override
			public void onMultiChunkClose(MultiChunk multiChunk) {
				// Empty
			}

			@Override
			public void onStart(int fileCount) {
				// Empty
			}

			@Override
			public void onFinish() {
				// Empty
			}
		});

		return chunkIndex;
	}

	private Map<ChunkChecksum, File> extractChunksFromMultiChunks(List<File> outputMultiChunkFiles, FrameworkCombination combination)
			throws IOException {
		Map<ChunkChecksum, File> extractedChunks = new HashMap<ChunkChecksum, File>();

		for (File outputMultiChunkFile : outputMultiChunkFiles) {
			logger.log(Level.INFO, "- Extracting multichunk " + outputMultiChunkFile + " ...");

			MultiChunk outputMultiChunk = combination.multiChunker.createMultiChunk(
					combination.transformer.createInputStream(new FileInputStream(outputMultiChunkFile)));

			Chunk outputChunkInMultiChunk = null;

			while (null != (outputChunkInMultiChunk = outputMultiChunk.read())) {
				File extractedChunkFile = new File(tempDir + "/chunk-" + StringUtil.toHex((outputChunkInMultiChunk.getChecksum()))
						+ "-from-multichunk-" + outputMultiChunk.getId());

				logger.log(Level.INFO, "  + Writing chunk " + StringUtil.toHex((outputChunkInMultiChunk.getChecksum())) + " to " + extractedChunkFile
						+ " ...");
				TestFileUtil.writeToFile(outputChunkInMultiChunk.getContent(), extractedChunkFile);

				extractedChunks.put(new ChunkChecksum(outputChunkInMultiChunk.getChecksum()), extractedChunkFile);
			}
		}

		return extractedChunks;
	}

	private Map<File, File> reassembleFiles(Map<File, List<ChunkChecksum>> inputFileToChunkIDs, Map<ChunkChecksum, File> extractedChunkIDToChunkFile)
			throws IOException {
		Map<File, File> inputFileToOutputFile = new HashMap<File, File>();

		for (Map.Entry<File, List<ChunkChecksum>> inputFileToChunkIDsEntry : inputFileToChunkIDs.entrySet()) {
			File inputFile = inputFileToChunkIDsEntry.getKey();
			List<ChunkChecksum> chunkIDs = inputFileToChunkIDsEntry.getValue();

			File outputFile = new File(tempDir + "/reassembledfile-" + inputFile.getName());
			FileOutputStream outputFileOutputStream = new FileOutputStream(outputFile);

			logger.log(Level.INFO, "- Reassemble file " + inputFile + " to " + outputFile + " ...");

			for (ChunkChecksum chunkID : chunkIDs) {
				File extractedChunkFile = extractedChunkIDToChunkFile.get(chunkID);

				logger.log(Level.INFO, "  + Appending " + chunkID + " (file: " + extractedChunkFile + ") to " + outputFile + " ...");
				IOUtils.copy(new FileInputStream(extractedChunkFile), outputFileOutputStream);
			}

			inputFileToOutputFile.put(inputFile, outputFile);
		}

		return inputFileToOutputFile;
	}

	private static class FrameworkCombination {
		private String name;
		private Chunker chunker;
		private MultiChunker multiChunker;
		private Transformer transformer;

		public FrameworkCombination(String name, Chunker chunker, MultiChunker multiChunker, Transformer transformerChain) {
			this.name = name;
			this.chunker = chunker;
			this.multiChunker = multiChunker;
			transformer = transformerChain;
		}
	}

	private static class ChunkIndex {
		private Map<File, List<ChunkChecksum>> inputFileToChunkIDs = new HashMap<File, List<ChunkChecksum>>();
		private Map<ChunkChecksum, MultiChunkId> chunkIDToMultiChunkID = new HashMap<ChunkChecksum, MultiChunkId>();
		private List<File> outputMultiChunkFiles = new ArrayList<File>();
	}

}
