/*
* Copyright 2010 Srikanth Reddy Lingala  
* 
* Licensed under the Apache License, Version 2.0 (the "License"); 
* you may not use this file except in compliance with the License. 
* You may obtain a copy of the License at 
* 
* http://www.apache.org/licenses/LICENSE-2.0 
* 
* Unless required by applicable law or agreed to in writing, 
* software distributed under the License is distributed on an "AS IS" BASIS, 
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
* See the License for the specific language governing permissions and 
* limitations under the License. 
*/

package net.lingala.zip4j.unzip;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.util.Arrays;
import java.util.zip.CRC32;

import net.lingala.zip4j.core.HeaderReader;
import net.lingala.zip4j.crypto.AESDecrypter;
import net.lingala.zip4j.crypto.IDecrypter;
import net.lingala.zip4j.crypto.StandardDecrypter;
import net.lingala.zip4j.exception.ZipException;
import net.lingala.zip4j.io.InflaterInputStream;
import net.lingala.zip4j.io.PartInputStream;
import net.lingala.zip4j.io.ZipInputStream;
import net.lingala.zip4j.model.AESExtraDataRecord;
import net.lingala.zip4j.model.FileHeader;
import net.lingala.zip4j.model.LocalFileHeader;
import net.lingala.zip4j.model.UnzipParameters;
import net.lingala.zip4j.model.ZipModel;
import net.lingala.zip4j.progress.ProgressMonitor;
import net.lingala.zip4j.util.InternalZipConstants;
import net.lingala.zip4j.util.Raw;
import net.lingala.zip4j.util.Zip4jConstants;
import net.lingala.zip4j.util.Zip4jUtil;

public class UnzipEngine {
	
	private ZipModel zipModel;
	private FileHeader fileHeader;
	private int currSplitFileCounter = 0;
	private LocalFileHeader localFileHeader;
	private IDecrypter decrypter;
	private CRC32 crc;
	
	public UnzipEngine(ZipModel zipModel, FileHeader fileHeader) throws ZipException {
		if (zipModel == null || fileHeader == null) {
			throw new ZipException("Invalid parameters passed to StoreUnzip. One or more of the parameters were null");
		}
		
		this.zipModel = zipModel;
		this.fileHeader = fileHeader;
		this.crc = new CRC32();
	}
	
	public void unzipFile(ProgressMonitor progressMonitor, 
			String outPath, String newFileName, UnzipParameters unzipParameters) throws ZipException {
		if (zipModel == null || fileHeader == null || !Zip4jUtil.isStringNotNullAndNotEmpty(outPath)) {
			throw new ZipException("Invalid parameters passed during unzipping file. One or more of the parameters were null");
		}
		InputStream is = null;
		OutputStream os = null;
		try {
			byte[] buff = new byte[InternalZipConstants.BUFF_SIZE];
			int readLength = -1;
			
			is = getInputStream();
			os = getOutputStream(outPath, newFileName);
			
			while ((readLength = is.read(buff)) != -1) {
				os.write(buff, 0, readLength);
				progressMonitor.updateWorkCompleted(readLength);
				if (progressMonitor.isCancelAllTasks()) {
					progressMonitor.setResult(ProgressMonitor.RESULT_CANCELLED);
					progressMonitor.setState(ProgressMonitor.STATE_READY);
					return;
				}
			}
			
			closeStreams(is, os);
			
			UnzipUtil.applyFileAttributes(fileHeader, new File(getOutputFileNameWithPath(outPath, newFileName)), unzipParameters);
			
		} catch (IOException e) {
			throw new ZipException(e);
		} catch (Exception e) {
			throw new ZipException(e);
		} finally {
			closeStreams(is, os);
		}
	}
	
	public ZipInputStream getInputStream() throws ZipException {
		if (fileHeader == null) {
			throw new ZipException("file header is null, cannot get inputstream");
		}
		
		RandomAccessFile raf = null;
		try {
			raf = createFileHandler(InternalZipConstants.READ_MODE);
			String errMsg = "local header and file header do not match";
			//checkSplitFile();
			
			if (!checkLocalHeader())
				throw new ZipException(errMsg);
			
			init(raf);
			
			long comprSize = localFileHeader.getCompressedSize();
			long offsetStartOfData = localFileHeader.getOffsetStartOfData();
			
			if (localFileHeader.isEncrypted()) {
				if (localFileHeader.getEncryptionMethod() == Zip4jConstants.ENC_METHOD_AES) {
					if (decrypter instanceof AESDecrypter) {
						comprSize -= (((AESDecrypter)decrypter).getSaltLength() + 
								((AESDecrypter)decrypter).getPasswordVerifierLength() + 10);
						offsetStartOfData += (((AESDecrypter)decrypter).getSaltLength() + 
								((AESDecrypter)decrypter).getPasswordVerifierLength());
					} else {
						throw new ZipException("invalid decryptor when trying to calculate " +
								"compressed size for AES encrypted file: " + fileHeader.getFileName());
					}
				} else if (localFileHeader.getEncryptionMethod() == Zip4jConstants.ENC_METHOD_STANDARD) {
					comprSize -= InternalZipConstants.STD_DEC_HDR_SIZE;
					offsetStartOfData += InternalZipConstants.STD_DEC_HDR_SIZE;
				}
			}
			
			int compressionMethod = fileHeader.getCompressionMethod();
			if (fileHeader.getEncryptionMethod() == Zip4jConstants.ENC_METHOD_AES) {
				if (fileHeader.getAesExtraDataRecord() != null) {
					compressionMethod = fileHeader.getAesExtraDataRecord().getCompressionMethod();
				} else {
					throw new ZipException("AESExtraDataRecord does not exist for AES encrypted file: " + fileHeader.getFileName());
				}
			}
			raf.seek(offsetStartOfData);
			switch (compressionMethod) {
			case Zip4jConstants.COMP_STORE:
				return new ZipInputStream(new PartInputStream(raf, offsetStartOfData, comprSize, this));
			case Zip4jConstants.COMP_DEFLATE:
				return new ZipInputStream(new InflaterInputStream(raf, offsetStartOfData, comprSize, this));
			default:
				throw new ZipException("compression type not supported");
			}
		} catch (ZipException e) {
			if (raf != null) {
				try {
					raf.close();
				} catch (IOException e1) {
					//ignore
				}
			}
			throw e;
		} catch (Exception e) {
			if (raf != null) {
				try {
					raf.close();
				} catch (IOException e1) {
				}
			}
			throw new ZipException(e);
		}
		
	}
	
	private void init(RandomAccessFile raf) throws ZipException {
		
		if (localFileHeader == null) {
			throw new ZipException("local file header is null, cannot initialize input stream");
		}
		
		try {
			initDecrypter(raf);
		} catch (ZipException e) {
			throw e;
		} catch (Exception e) {
			throw new ZipException(e);
		}
	}
	
	private void initDecrypter(RandomAccessFile raf) throws ZipException {
		if (localFileHeader == null) {
			throw new ZipException("local file header is null, cannot init decrypter");
		}
		
		if (localFileHeader.isEncrypted()) {
			if (localFileHeader.getEncryptionMethod() == Zip4jConstants.ENC_METHOD_STANDARD) {
				decrypter = new StandardDecrypter(fileHeader, getStandardDecrypterHeaderBytes(raf));
			} else if (localFileHeader.getEncryptionMethod() == Zip4jConstants.ENC_METHOD_AES) {
				decrypter = new AESDecrypter(localFileHeader, getAESSalt(raf), getAESPasswordVerifier(raf));
			} else {
				throw new ZipException("unsupported encryption method");
			}
		}
	}
	
	private byte[] getStandardDecrypterHeaderBytes(RandomAccessFile raf) throws ZipException {
		try {
			byte[] headerBytes = new byte[InternalZipConstants.STD_DEC_HDR_SIZE];
			raf.seek(localFileHeader.getOffsetStartOfData());
			raf.read(headerBytes, 0, 12);
			return headerBytes;
		} catch (IOException e) {
			throw new ZipException(e);
		} catch (Exception e) {
			throw new ZipException(e);
		}
	}
	
	private byte[] getAESSalt(RandomAccessFile raf) throws ZipException {
		if (localFileHeader.getAesExtraDataRecord() == null)
			return null;
		
		try {
			AESExtraDataRecord aesExtraDataRecord = localFileHeader.getAesExtraDataRecord();
			byte[] saltBytes = new byte[calculateAESSaltLength(aesExtraDataRecord)]; 			
			raf.seek(localFileHeader.getOffsetStartOfData());
			raf.read(saltBytes);
			return saltBytes;
		} catch (IOException e) {
			throw new ZipException(e);
		}
	}
	
	private byte[] getAESPasswordVerifier(RandomAccessFile raf) throws ZipException {
		try {
			byte[] pvBytes = new byte[2];
			raf.read(pvBytes);
			return pvBytes;
		} catch (IOException e) {
			throw new ZipException(e);
		}
	}
	
	private int calculateAESSaltLength(AESExtraDataRecord aesExtraDataRecord) throws ZipException {
		if (aesExtraDataRecord == null) {
			throw new ZipException("unable to determine salt length: AESExtraDataRecord is null");
		}
		switch (aesExtraDataRecord.getAesStrength()) {
		case Zip4jConstants.AES_STRENGTH_128:
			return 8;
		case Zip4jConstants.AES_STRENGTH_192:
			return 12;
		case Zip4jConstants.AES_STRENGTH_256:
			return 16;
		default:
			throw new ZipException("unable to determine salt length: invalid aes key strength");
		}
	}
	
	public void checkCRC() throws ZipException {
		if (fileHeader != null) {
			if (fileHeader.getEncryptionMethod() == Zip4jConstants.ENC_METHOD_AES) {
				if (decrypter != null && decrypter instanceof AESDecrypter) {
					byte[] tmpMacBytes = ((AESDecrypter)decrypter).getCalculatedAuthenticationBytes();
					byte[] storedMac = ((AESDecrypter)decrypter).getStoredMac();
					byte[] calculatedMac = new byte[InternalZipConstants.AES_AUTH_LENGTH]; 
					
					if (calculatedMac == null || storedMac == null) {
						throw new ZipException("CRC (MAC) check failed for " + fileHeader.getFileName());
					}
					
					System.arraycopy(tmpMacBytes, 0, calculatedMac, 0, InternalZipConstants.AES_AUTH_LENGTH);
					
					if (!Arrays.equals(calculatedMac, storedMac)) {
						throw new ZipException("invalid CRC (MAC) for file: " + fileHeader.getFileName());
					}
				}
			} else {
				long calculatedCRC = crc.getValue() & 0xffffffffL;
				if (calculatedCRC != fileHeader.getCrc32()) {
					String errMsg = "invalid CRC for file: " + fileHeader.getFileName();
					if (localFileHeader.isEncrypted() && 
							localFileHeader.getEncryptionMethod() == Zip4jConstants.ENC_METHOD_STANDARD) {
						errMsg += " - Wrong Password?";
					}
					throw new ZipException(errMsg);
				}
			}
		}
	}
	
//	private void checkCRC() throws ZipException {
//		if (fileHeader != null) {
//			if (fileHeader.getEncryptionMethod() == Zip4jConstants.ENC_METHOD_AES) {
//				if (decrypter != null && decrypter instanceof AESDecrypter) {
//					byte[] tmpMacBytes = ((AESDecrypter)decrypter).getCalculatedAuthenticationBytes();
//					byte[] actualMacBytes = ((AESDecrypter)decrypter).getStoredMac();
//					if (tmpMacBytes == null || actualMacBytes == null) {
//						throw new ZipException("null mac value for AES encrypted file: " + fileHeader.getFileName());
//					}
//					byte[] calcMacBytes = new byte[10];
//					System.arraycopy(tmpMacBytes, 0, calcMacBytes, 0, 10);
//					if (!Arrays.equals(calcMacBytes, actualMacBytes)) {
//						throw new ZipException("invalid CRC(mac) for file: " + fileHeader.getFileName());
//					}
//				} else {
//					throw new ZipException("invalid decryptor...cannot calculate mac value for file: " 
//							+ fileHeader.getFileName());
//				}
//			} else if (unzipEngine != null) {
//				long calculatedCRC = unzipEngine.getCRC();
//				long actualCRC = fileHeader.getCrc32();
//				if (calculatedCRC != actualCRC) {
//					throw new ZipException("invalid CRC for file: " + fileHeader.getFileName());
//				}
//			}
//		}
//	}
	
	private boolean checkLocalHeader() throws ZipException {
		RandomAccessFile rafForLH = null;
		try {
			rafForLH = checkSplitFile();
			
			if (rafForLH == null) {
				rafForLH = new RandomAccessFile(new File(this.zipModel.getZipFile()), InternalZipConstants.READ_MODE);
			}
			
			HeaderReader headerReader = new HeaderReader(rafForLH);
			this.localFileHeader = headerReader.readLocalFileHeader(fileHeader);
			
			if (localFileHeader == null) {
				throw new ZipException("error reading local file header. Is this a valid zip file?");
			}
			
			//TODO Add more comparision later
			if (localFileHeader.getCompressionMethod() != fileHeader.getCompressionMethod()) {
				return false;
			}
			
			return true;
		} catch (FileNotFoundException e) {
			throw new ZipException(e);
		} finally {
			if (rafForLH != null) {
				try {
					rafForLH.close();
				} catch (IOException e) {
					// Ignore this
				} catch (Exception e) {
					//Ignore this
				}
			}
		}
	}
	
	private RandomAccessFile checkSplitFile() throws ZipException {
		if (zipModel.isSplitArchive()) {
			int diskNumberStartOfFile = fileHeader.getDiskNumberStart();
			currSplitFileCounter = diskNumberStartOfFile + 1;
			String curZipFile = zipModel.getZipFile();
			String partFile = null;
			if (diskNumberStartOfFile == zipModel.getEndCentralDirRecord().getNoOfThisDisk()) {
				partFile = zipModel.getZipFile();
			} else {
				if (diskNumberStartOfFile >= 9) {
					partFile = curZipFile.substring(0, curZipFile.lastIndexOf(".")) + ".z" + (diskNumberStartOfFile+ 1);
				} else{
					partFile = curZipFile.substring(0, curZipFile.lastIndexOf(".")) + ".z0" + (diskNumberStartOfFile+ 1);
				}
			}
			
			try {
				RandomAccessFile raf = new RandomAccessFile(partFile, InternalZipConstants.READ_MODE);
				
				if (currSplitFileCounter == 1) {
					byte[] splitSig = new byte[4];
					raf.read(splitSig);
					if (Raw.readIntLittleEndian(splitSig, 0) != InternalZipConstants.SPLITSIG) {
						throw new ZipException("invalid first part split file signature");
					}
				}
				return raf;
			} catch (FileNotFoundException e) {
				throw new ZipException(e);
			} catch (IOException e) {
				throw new ZipException(e);
			}
		}
		return null;
	}
	
	private RandomAccessFile createFileHandler(String mode) throws ZipException {
		if (this.zipModel == null || !Zip4jUtil.isStringNotNullAndNotEmpty(this.zipModel.getZipFile())) {
			throw new ZipException("input parameter is null in getFilePointer");
		}
		
		try {
			RandomAccessFile raf = null;
			if (zipModel.isSplitArchive()) {
				raf = checkSplitFile();
			} else {
				raf = new RandomAccessFile(new File(this.zipModel.getZipFile()), mode);
			}
			return raf;
		} catch (FileNotFoundException e) {
			throw new ZipException(e);
		} catch (Exception e) {
			throw new ZipException(e);
		}
	}
	
	private FileOutputStream getOutputStream(String outPath, String newFileName) throws ZipException {
		if (!Zip4jUtil.isStringNotNullAndNotEmpty(outPath)) {
			throw new ZipException("invalid output path");
		}
		
		try {
			File file = new File(getOutputFileNameWithPath(outPath, newFileName));
			
			if (!file.getParentFile().exists()) {
				file.getParentFile().mkdirs();
			}
			
			if (file.exists()) {
				file.delete();
			}
			
			FileOutputStream fileOutputStream = new FileOutputStream(file);
			return fileOutputStream;
		} catch (FileNotFoundException e) {
			throw new ZipException(e);
		}
	}
	
	private String getOutputFileNameWithPath(String outPath, String newFileName) throws ZipException {
		String fileName = null;
		if (Zip4jUtil.isStringNotNullAndNotEmpty(newFileName)) {
			fileName = newFileName;
		} else {
			fileName = fileHeader.getFileName();
		}
		return outPath + System.getProperty("file.separator") + fileName;
	}
	
	public RandomAccessFile startNextSplitFile() throws IOException, FileNotFoundException {
		String currZipFile = zipModel.getZipFile();
		String partFile = null;
		if (currSplitFileCounter == zipModel.getEndCentralDirRecord().getNoOfThisDisk()) {
			partFile = zipModel.getZipFile();
		} else {
			if (currSplitFileCounter >= 9) {
				partFile = currZipFile.substring(0, currZipFile.lastIndexOf(".")) + ".z" + (currSplitFileCounter + 1);
			} else {
				partFile = currZipFile.substring(0, currZipFile.lastIndexOf(".")) + ".z0" + (currSplitFileCounter + 1);
			}
		}
		currSplitFileCounter++;
		try {
			if(!Zip4jUtil.checkFileExists(partFile)) {
				throw new IOException("zip split file does not exist: " + partFile);
			}
		} catch (ZipException e) {
			throw new IOException(e.getMessage());
		}
		return new RandomAccessFile(partFile, InternalZipConstants.READ_MODE);
	}
	
	private void closeStreams(InputStream is, OutputStream os) throws ZipException {
		try {
			if (is != null) {
				is.close();
				is = null;
			}
		} catch (IOException e) {
			if (e != null && Zip4jUtil.isStringNotNullAndNotEmpty(e.getMessage())) {
				if (e.getMessage().indexOf(" - Wrong Password?") >= 0) {
					throw new ZipException(e.getMessage());
				}
			}
		} finally {
			try {
				if (os != null) {
					os.close();
					os = null;
				}
			} catch (IOException e) {
				//do nothing
			}
		}
	}
	
	public void updateCRC(int b) {
		crc.update(b);
	}
	
	public void updateCRC(byte[] buff, int offset, int len) {
		if (buff != null) {
			crc.update(buff, offset, len);
		}
	}

	public FileHeader getFileHeader() {
		return fileHeader;
	}

	public IDecrypter getDecrypter() {
		return decrypter;
	}

	public ZipModel getZipModel() {
		return zipModel;
	}

	public LocalFileHeader getLocalFileHeader() {
		return localFileHeader;
	}
}
