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

import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.util.Arrays;
import java.util.HashMap;
import java.util.zip.CRC32;

import freenet.l10n.NodeL10n;
import freenet.support.HexUtil;
import freenet.support.LogThresholdCallback;
import freenet.support.Logger;
import freenet.support.Logger.LogLevel;
import freenet.support.api.Bucket;
import freenet.support.io.Closer;
import freenet.support.io.FileBucket;

/**
 * Content filter for PNG's. Only allows valid chunks (valid CRC, known chunk type).
 * 
 * It can strip the timestamp and "text(.)*" chunks if asked to
 * 
 * FIXME: validate chunk contents where possible.
 */
public class PNGFilter implements ContentDataFilter {

	private final boolean deleteText;
	private final boolean deleteTimestamp;
	private final boolean checkCRCs;
	static final byte[] pngHeader = { (byte) 137, (byte) 80, (byte) 78, (byte) 71, (byte) 13, (byte) 10, (byte) 26,
	        (byte) 10 };
	static final String[] HARMLESS_CHUNK_TYPES = {
	// http://www.w3.org/TR/PNG/
	        "tRNS", "cHRM", "gAMA", "iCCP", // FIXME Embedded ICC profile: could this conceivably cause a web lookup?
	        "sBIT", // FIXME rather obscure ??
	        "sRGB", "bKGD", "hIST", "pHYs", "sPLT",
	        // APNG chunks (Firefox 3 will support APNG)
	        // http://wiki.mozilla.org/APNG_Specification
	        "acTL", "fcTL", "fdAT"
	// MNG isn't supported by Firefox and IE because of lack of market demand. Big surprise
	// given nobody supports it! It is supported by Konqueror though. Complex standard,
	// not worth it for the time being.

	// This might be a useful source of info too (e.g. on private chunks):
	// http://fresh.t-systems-sfr.com/unix/privat/pngcheck-2.3.0.tar.gz:a/pngcheck-2.3.0/pngcheck.c
	};

        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);
			}
		});
	}

	PNGFilter(boolean deleteText, boolean deleteTimestamp, boolean checkCRCs) {
		this.deleteText = deleteText;
		this.deleteTimestamp = deleteTimestamp;
		this.checkCRCs = checkCRCs;
	}

	@Override
	public void readFilter(InputStream input, OutputStream output, String charset, HashMap<String, String> otherParams,
			FilterCallback cb) throws DataFilterException, IOException {
		readFilter(input, output, charset, otherParams, cb, deleteText, deleteTimestamp, checkCRCs);
		output.flush();
	}

	public void readFilter(InputStream input, OutputStream output, String charset, HashMap<String, String> otherParams,
			FilterCallback cb, boolean deleteText, boolean deleteTimestamp, boolean checkCRCs)
			throws DataFilterException, IOException {
		DataInputStream dis = null;
		boolean hasSeenIHDR = false;
		boolean hasSeenIEND = false;
		boolean hasSeenIDAT = false;
		try {
                        long offset = 0;
			dis = new DataInputStream(input);
			// Check the header
			byte[] headerCheck = new byte[pngHeader.length];
			dis.readFully(headerCheck);
                        offset+=pngHeader.length;
			if (!Arrays.equals(headerCheck, pngHeader)) {
				// Throw an exception
				String message = l10n("invalidHeader");
				String title = l10n("invalidHeaderTitle");
				throw new DataFilterException(title, title, message);
			}

			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			DataOutputStream dos = new DataOutputStream(baos);
			output.write(pngHeader);
			if (logMINOR)
				Logger.minor(this, "Writing the PNG header to the output bucket");

			// Check the chunks :
			// @see http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html#C.Summary-of-standard-chunks
			String lastChunkType = "";

			while (!hasSeenIEND) {
				boolean skip = false;
				baos.reset();
				String chunkTypeString = null;
				// Length of the chunk
				byte[] lengthBytes = new byte[4];
				try {
					dis.readFully(lengthBytes);
					offset+=4;
				} catch (EOFException e) {
					// FIXME optimise out the throw, don't use readFully?
					// This will happen once per filtering.
					// We can't use available() for reasons explained in
					// the javadocs for ContentDataFilter.
					break;
				}

				int length = ((lengthBytes[0] & 0xff) << 24) + ((lengthBytes[1] & 0xff) << 16)
				        + ((lengthBytes[2] & 0xff) << 8) + (lengthBytes[3] & 0xff);
				if (logMINOR)
					Logger.minor(this, "length " + length+ "(offset=0x"+Long.toHexString(offset)+") ");
				if (dos != null)
					dos.write(lengthBytes);

				// Type of the chunk : Should match [a-zA-Z]{4}
				dis.readFully(lengthBytes);
                                offset+=4;
				StringBuilder sb = new StringBuilder();
				byte[] chunkTypeBytes = new byte[4];
				for (int i = 0; i < 4; i++) {
					char val = (char) lengthBytes[i];
					if ((val >= 65 && val <= 99) || (val >= 97 && val <= 122)) {
						chunkTypeBytes[i] = lengthBytes[i];
						sb.append(val);
					} else {
						String chunkName = HexUtil.bytesToHex(lengthBytes, 0, 4);
						throwError("Unknown Chunk", "The name of the chunk is invalid! (" + chunkName + ")");
					}
				}
				chunkTypeString = sb.toString();
				if (logMINOR)
					Logger.minor(this, "name " + chunkTypeString);
				if (dos != null)
					dos.write(chunkTypeBytes);

				// Content of the chunk
				byte[] chunkData = new byte[length];
				if(length > 0) {
					dis.readFully(chunkData, 0, length);
					offset+=length;
					if (logMINOR)
						if (logDEBUG)
							Logger.minor(this, "data (offset=0x"+Long.toHexString(offset)+") "+ (chunkData.length == 0 ? "null" : HexUtil.bytesToHex(chunkData)));
						else
							Logger.minor(this, "data " + chunkData.length);
					if (dos != null)
						dos.write(chunkData);
				}

				// CRC of the chunk
				byte[] crcLengthBytes = new byte[4];
				dis.readFully(crcLengthBytes);
				offset+=4;
				if(logMINOR) Logger.minor(this, "CRC offset=0x"+Long.toHexString(offset));
				if (dos != null)
					dos.write(crcLengthBytes);

				if (checkCRCs) {
					long readCRC = (((crcLengthBytes[0] & 0xff) << 24) + ((crcLengthBytes[1] & 0xff) << 16)
					        + ((crcLengthBytes[2] & 0xff) << 8) + (crcLengthBytes[3] & 0xff)) & 0x00000000ffffffffL;
					CRC32 crc = new CRC32();
					crc.update(chunkTypeBytes);
					if(length > 0)
                                            crc.update(chunkData);
					long computedCRC = crc.getValue();

					if (readCRC != computedCRC) {
						skip = true;
						if (logMINOR)
							Logger.minor(this, "CRC of the chunk " + chunkTypeString + " doesn't match ("
							        + Long.toHexString(readCRC) + " but should be " + Long.toHexString(computedCRC)
							        + ")!");
					}
				}

				boolean validChunkType = false;

				if (!skip && "IHDR".equals(chunkTypeString)) { // http://www.w3.org/TR/PNG/#11IHDR
					if (hasSeenIHDR)
						throwError("Duplicate IHDR", "Two IHDR chunks detected!!");
					if(length != 13)
						throwError("IHDR length!= 13", "The length of the IHDR file is not 13");
					long width = ((chunkData[0] & 0xff) << 24) + ((chunkData[1] & 0xff) << 16) + ((chunkData[2] & 0xff) << 8) + (chunkData[3] & 0xff);
					long height = ((chunkData[4] & 0xff) << 24) + ((chunkData[5] & 0xff) << 16) + ((chunkData[6] & 0xff) << 8) + (chunkData[7] & 0xff);
					if(width < 1 || height < 1)
						throwError("Width or Height is invalid", "Width or Height is invalid (<1)");
					int bitDepth = chunkData[8];
					int colourType = chunkData[9];
					switch (bitDepth) {
					case 1:
					case 2:
					case 4:
						if(colourType != 0 && colourType != 3)
							throwError("Invalid colourType/bitDepth combination!",
									"Invalid colourType/bitDepth combination! ("+colourType+'|'+bitDepth+')');
						break;
					case 16:
						if(colourType == 3)
							throwError("Invalid colourType/bitDepth combination!",
									"Invalid colourType/bitDepth combination! ("+colourType+'|'+bitDepth+')');
					case 8:
						if(colourType == 0 || colourType ==2 || colourType ==3 || colourType ==4|| colourType ==6)
							break;
					default: throwError("Invalid colourType/bitDepth combination!",
							"Invalid colourType/bitDepth combination! ("+colourType+'|'+bitDepth+')');
					}
					int compressionMethod = chunkData[10];
					if(compressionMethod != 0)
						throwError("Invalid CompressionMethod", "Invalid CompressionMethod! "+compressionMethod);
					int filterMethod = chunkData[11];
					if(filterMethod != 0)
						throwError("Invalid FilterMethod", "Invalid FilterMethod! "+filterMethod);
					int interlaceMethod = chunkData[12];
					if(interlaceMethod < 0 || interlaceMethod >1)
						throwError("Invalid InterlaceMethod", "Invalid InterlaceMethod! "+interlaceMethod);
					
					if(logMINOR)
						Logger.minor(this, "Info from IHDR: width="+width+"px height="+height+"px bitDepth="+bitDepth+
								" colourType="+colourType+" compressionMethod="+compressionMethod+" filterMethod="+
								filterMethod+" interlaceMethod="+interlaceMethod);
					hasSeenIHDR = true;
					validChunkType = true;
				}

				if (!hasSeenIHDR)
					throwError("No IHDR chunk!", "No IHDR chunk!");

				if (!skip && "IEND".equals(chunkTypeString)) {
					if (hasSeenIEND)
						throwError("Two IEND chunks detected!!", "Two IEND chunks detected!!");
					hasSeenIEND = true;
					validChunkType = true;
				}

				if (!skip && "PLTE".equalsIgnoreCase(chunkTypeString)) {
					if (hasSeenIDAT)
						throwError("PLTE must be before IDAT", "PLTE must be before IDAT");
					validChunkType = true;
				}

				if (!skip && "IDAT".equalsIgnoreCase(chunkTypeString)) {
					if (hasSeenIDAT && !"IDAT".equalsIgnoreCase(lastChunkType))
						throwError("Multiple IDAT chunks must be consecutive!",
						        "Multiple IDAT chunks must be consecutive!");
					hasSeenIDAT = true;
					validChunkType = true;
				}

				if (!validChunkType) {
					for (int i = 0; i < HARMLESS_CHUNK_TYPES.length; i++) {
						if (HARMLESS_CHUNK_TYPES[i].equals(chunkTypeString))
							validChunkType = true;
					}
				}

				if ("text".equalsIgnoreCase(chunkTypeString) || "itxt".equalsIgnoreCase(chunkTypeString)
				        || "ztxt".equalsIgnoreCase(chunkTypeString)) {
					if (deleteText)
						skip = true;
					else
						validChunkType = true;
				} else if (deleteTimestamp && "time".equalsIgnoreCase(chunkTypeString)) {
					if (deleteTimestamp)
						skip = true;
					else
						validChunkType = true;
				}

				if (!validChunkType) {
					if (logMINOR)
						Logger.minor(this, "Skipping unknown chunk type " + chunkTypeString);
					skip = true;
				}

				else if (!skip && output != null) {
					if (logMINOR)
						Logger
						        .minor(this, "Writing " + chunkTypeString + " (" + baos.size()
						                + ") to the output bucket");
					baos.writeTo(output);
					baos.flush();
				}
				lastChunkType = chunkTypeString;
			}

			if (!hasSeenIEND)
				throwError("Missing IEND", "Missing IEND");
			if (!hasSeenIHDR)
				throwError("Missing IHDR", "Missing IHDR");
			return; // Strip everything after IEND.
		} catch (ArrayIndexOutOfBoundsException e) {
			throwError("ArrayIndexOutOfBoundsException while filtering", "ArrayIndexOutOfBoundsException while filtering");
		} catch (NegativeArraySizeException e) {
			throwError("NegativeArraySizeException while filtering", "NegativeArraySizeException while filtering");
		} catch (EOFException e) {
			if(hasSeenIEND && hasSeenIHDR) return;
			throwError("EOF Exception while filtering", "EOF Exception while filtering");
		}
	}

	private String l10n(String key) {
		return NodeL10n.getBase().getString("PNGFilter." + key);
	}

	@Override
	public void writeFilter(InputStream input, OutputStream output, String charset, HashMap<String, String> otherParams,
	        FilterCallback cb) throws DataFilterException, IOException {
		// TODO Auto-generated method stub
		return;
	}

	public static void main(String arg[]) throws Throwable {
		final File fin = new File("/tmp/test.png");
		final File fout = new File("/tmp/test2.png");
		fout.delete();
		final Bucket inputBucket = new FileBucket(fin, true, false, false, false);
		final Bucket outputBucket = new FileBucket(fout, false, true, false, false);
		InputStream inputStream = null;
		OutputStream outputStream = null;
		try {
			inputStream = inputBucket.getInputStream();
			outputStream = outputBucket.getOutputStream();
			Logger.setupStdoutLogging(LogLevel.MINOR, "");

			ContentFilter.filter(inputStream, outputStream, "image/png",
					new URI("http://127.0.0.1:8888/"), null, null, null);
		} finally {
			Closer.close(inputStream);
			Closer.close(outputStream);
			inputBucket.free();
			outputBucket.free();
		}
	}

	private void throwError(String shortReason, String reason) throws DataFilterException {
		// Throw an exception
		String message = "Invalid PNG";
		if (reason != null)
			message += ' ' + reason;
		if (shortReason != null)
			message += " - " + shortReason;
		DataFilterException e = new DataFilterException(shortReason, shortReason, message);
		Logger.normal(this, "Throwing " + e.getMessage(), e);
		throw e;
	}
}
