/* 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.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;

import freenet.l10n.NodeL10n;
import freenet.support.LogThresholdCallback;
import freenet.support.Logger;
import freenet.support.Logger.LogLevel;
import freenet.support.io.CountedInputStream;

/**
 * Content filter for JPEG's.
 * Just check the header.
 *
 * http://www.obrador.com/essentialjpeg/headerinfo.htm
 * Also the JFIF spec.
 * Also http://cs.haifa.ac.il/~nimrod/Compression/JPEG/J6sntx2005.pdf
 * http://svn.xiph.org/experimental/giles/jpegdump.c
 * http://it.jeita.or.jp/document/publica/standard/exif/english/jeida49e.htm
 *
 * L10n: Only the overall explanation message and the "too short" messages are localised.
 * It's probably not worth doing the others, they're way too detailed.
 */
public class JPEGFilter implements ContentDataFilter {

	private final boolean deleteComments;
	private final boolean deleteExif;

	private static final int MARKER_EOI = 0xD9; // End of image
	//private static final int MARKER_SOI = 0xD8; // Start of image
	private static final int MARKER_RST0 = 0xD0; // First reset marker
	private static final int MARKER_RST7 = 0xD7; // Last reset marker

        private static volatile boolean logMINOR;
	static {
		Logger.registerLogThresholdCallback(new LogThresholdCallback(){
			@Override
			public void shouldUpdate(){
				logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
			}
		});
	}

	JPEGFilter(boolean deleteComments, boolean deleteExif) {
		this.deleteComments = deleteComments;
		this.deleteExif = deleteExif;
	}

	static final byte[] soi = new byte[] {
		(byte)0xFF, (byte)0xD8 // Start of Image
	};
	static final byte[] identifier = new byte[] {
		(byte)'J', (byte)'F', (byte)'I', (byte)'F', 0
	};
	static final byte[] extensionIdentifier = new byte[] {
		(byte)'J', (byte)'F', (byte)'X', (byte)'X', 0
	};

	@Override
	public void readFilter(
      InputStream input, OutputStream output, String charset, Map<String, String> otherParams,
      String schemeHostAndPort, FilterCallback cb) throws DataFilterException, IOException {
		readFilter(input, output, charset, otherParams, cb, deleteComments, deleteExif);
		output.flush();
	}

	public void readFilter(InputStream input, OutputStream output, String charset, Map<String, String> otherParams,
			FilterCallback cb, boolean deleteComments, boolean deleteExif)
	throws DataFilterException, IOException {
		CountedInputStream cis = new CountedInputStream(input);
		DataInputStream dis = new DataInputStream(cis);
		assertHeader(dis, soi);
		output.write(soi);

		ByteArrayOutputStream baos = new ByteArrayOutputStream();
		DataOutputStream dos = new DataOutputStream(baos);

		// Check the chunks.

		boolean finished = false;
		int forceMarkerType = -1;
		while(!finished) {
			baos.reset();
			int markerType;
			if(forceMarkerType != -1) {
				markerType = forceMarkerType;
				forceMarkerType = -1;
			} else {
				int markerStart = dis.read();
				if(markerStart == -1) {
					// No more chunks to scan.
					break;
				} else if(finished) {
					if(logMINOR)
						Logger.minor(this, "More data after EOI, copying to truncate");
					return;
				}
				if(markerStart != 0xFF) {
					throwError("Invalid marker", "The file includes an invalid marker start "+Integer.toHexString(markerStart)+" and cannot be parsed further.");
				}
				if(baos != null) baos.write(0xFF);
				markerType = dis.readUnsignedByte();
				if(baos != null) baos.write(markerType);
			}
			if(logMINOR)
				Logger.minor(this, "Marker type: "+Integer.toHexString(markerType));
			long countAtStart = cis.count(); // After marker but before type
			int blockLength;
			if(markerType == MARKER_EOI || markerType >= MARKER_RST0 && markerType <= MARKER_RST7)
				blockLength = 0;
			else {
				blockLength = dis.readUnsignedShort();
				dos.writeShort(blockLength);
			}
			if(markerType == 0xDA) {
				// Start of scan marker

				// Copy marker
				if(blockLength < 2)
					throwError("Invalid frame length", "The file includes an invalid frame (length "+blockLength+").");
				byte[] buf = new byte[blockLength - 2];
				dis.readFully(buf);
				dos.write(buf);
				Logger.minor(this, "Copied start-of-frame marker length "+(blockLength-2));

				if(baos != null)
					baos.writeTo(output); // will continue; at end

				// Now copy the scan itself

				int prevChar = -1;
				while(true) {
					int x = dis.read();
					if(prevChar != -1 && output != null) {
						output.write(prevChar);
					}
					if(x == -1) {
						// Termination inside a scan; valid I suppose
						break;
					}
					if(prevChar == 0xFF && x != 0 &&
							!(x >= MARKER_RST0 && x <= MARKER_RST7)) { // reset markers can occur in the scan

						forceMarkerType = x;
						if(logMINOR)
							Logger.minor(this, "Moved scan at "+cis.count()+", found a marker type "+Integer.toHexString(x));
						if(output != null) output.write(x);
						break; // End of scan, new marker
					}
					prevChar = x;
				}

				continue; // Avoid writing the header twice

			} else if(markerType == 0xE0) { // APP0
				if(logMINOR) Logger.minor(this, "APP0");
				String type = readNullTerminatedAsciiString(dis);
				if(baos != null) writeNullTerminatedString(baos, type);
				if(logMINOR) Logger.minor(this, "Type: "+type+" length "+type.length());
				if(type.equals("JFIF")) {
					Logger.minor(this, "JFIF Header");
					// File header
					int majorVersion = dis.readUnsignedByte();
					if(majorVersion != 1)
						throwError("Invalid header", "Unrecognized major version "+majorVersion+".");
					dos.write(majorVersion);
					int minorVersion = dis.readUnsignedByte();
					if(minorVersion > 2)
						throwError("Invalid header", "Unrecognized version 1."+minorVersion+".");
					dos.write(minorVersion);
					int units = dis.readUnsignedByte();
					if(units > 2)
						throwError("Invalid header", "Unrecognized units type "+units+".");
					dos.write(units);
					dos.writeShort(dis.readShort()); // Copy Xdensity
					dos.writeShort(dis.readShort()); // Copy Ydensity
					int thumbX = dis.readUnsignedByte();
					dos.writeByte(thumbX);
					int thumbY = dis.readUnsignedByte();
					dos.writeByte(thumbY);
					int thumbLen = thumbX * thumbY * 3;
					byte[] buf = new byte[thumbLen];
					dis.readFully(buf);
					dos.write(buf);
				} else if(type.equals("JFXX")) {
					// JFIF extension marker
					int extensionCode = dis.readUnsignedByte();
					if(extensionCode == 0x10 || extensionCode == 0x11 || extensionCode == 0x13) {
						// Alternate thumbnail, perfectly valid
						dos.write(extensionCode);
						skipRest(blockLength, countAtStart, cis, dis, dos, "thumbnail frame");
						Logger.minor(this, "Thumbnail frame");
					} else
						throwError("Unknown JFXX extension "+extensionCode, "The file contains an unknown JFXX extension.");
				} else {
					if(logMINOR)
						Logger.minor(this, "Dropping application-specific APP0 chunk named "+type);
					// Application-specific extension
					skipRest(blockLength, countAtStart, cis, dis, dos, "application-specific frame");
					continue; // Don't write the frame.
				}
			} else if(markerType == 0xE1) { // EXIF
				if(deleteExif) {
					if(logMINOR)
						Logger.minor(this, "Dropping EXIF data");
					skipBytes(dis, blockLength - 2);
					continue; // Don't write the frame
				}
				skipRest(blockLength, countAtStart, cis, dis, dos, "EXIF frame");
			} else if(markerType == 0xFE) {
				// Comment
				if(deleteComments) {
					skipBytes(dis, blockLength - 2);
					if(logMINOR)
						Logger.minor(this, "Dropping comment length "+(blockLength - 2)+'.');
					continue; // Don't write the frame
				}
				skipRest(blockLength, countAtStart, cis, dis, dos, "comment");
			} else if(markerType == 0xD9) {
				// End of image
				finished = true;
				if(logMINOR)
					Logger.minor(this, "End of image");
			} else {
				boolean valid = false;
				// We used to support only DB C4 C0, because some website said they were
				// sufficient for decoding a JPEG. Unfortunately they are not, JPEG is a
				// very complex standard and the full spec is only available for a fee.
				// FIXME somebody who has access to the spec should have a look at this,
				// and ideally write some chunk sanitizers.
				switch(markerType) {
				// descriptions from http://svn.xiph.org/experimental/giles/jpegdump.c (GPL)
				case 0xc0: // start of frame
				case 0xc1: // extended sequential, huffman
				case 0xc2: // progressive, huffman
				case 0xc3: // lossless, huffman
				case 0xc5: // differential sequential, huffman
				case 0xc6: // differential progressive, huffman
				case 0xc7: // differential lossless, huffman
					// DELETE 0xc8 - "reserved for JPEG extension" - likely to be used for Bad Things
				case 0xc9: // extended sequential, arithmetic
				case 0xca: // progressive, arithmetic
				case 0xcb: // lossless, arithmetic
				case 0xcd: // differential sequential, arithmetic
				case 0xcf: // differential lossless, arithmetic
				case 0xc4: // define huffman tables
				case 0xcc: // define arithmetic-coding conditioning
					// Restart markers
				case 0xd0:
				case 0xd1:
				case 0xd2:
				case 0xd3:
				case 0xd4:
				case 0xd5:
				case 0xd6:
				case 0xd7:
					// Delimiters:
				case 0xd8: // start of image
				case 0xd9: // end of image
				case 0xda: // start of scan
				case 0xdb: // define quantization tables
				case 0xdc: // define number of lines
				case 0xdd: // define restart interval
				case 0xde: // define hierarchical progression
				case 0xdf: // expand reference components
					// DELETE APP0 - APP15 - application data sections, likely to be troublesome.
					// DELETE extension data sections JPG0-6,SOF48,LSE,JPG9-JPG13, JCOM (comment!!), TEM ("temporary private use for arithmetic coding")
					// DELETE 0x02 - 0xbf reserved sections.
					// Do not support JPEG2000 at the moment. Probably has different headers. FIXME.
					valid = true;
				}
				if(valid) {
					// Essential, non-terminal, but unparsed frames.
					if(blockLength < 2)
						throwError("Invalid frame length", "The file includes an invalid frame (length "+blockLength+").");
					byte[] buf = new byte[blockLength - 2];
					dis.readFully(buf);
					dos.write(buf);
					Logger.minor(this, "Essential frame type "+Integer.toHexString(markerType)+" length "+(blockLength-2)+" offset at end "+cis.count());
				} else {
					if(markerType >= 0xE0 && markerType <= 0xEF) {
						// APP marker. Can be safely deleted.
						if(logMINOR)
							Logger.minor(this, "Dropping application marker type "+Integer.toHexString(markerType)+" length "+blockLength);
					} else {
						if(logMINOR)
							Logger.minor(this, "Dropping unknown frame type "+Integer.toHexString(markerType)+" blockLength");
					}
					// Delete frame
					skipBytes(dis, blockLength - 2);
					continue;
				}
			}

			if(cis.count() != countAtStart + blockLength)
				throwError("Invalid frame", "The length of the frame is incorrect (read "+
						(cis.count()-countAtStart)+" bytes, frame length "+blockLength+" for type "+Integer.toHexString(markerType)+").");
			// Write frame
			baos.writeTo(output);
		}

		// In future, maybe we will check the other chunks too.
		// In particular, we may want to delete, or filter, the comment blocks.
		// FIXME
	}

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

	private void writeNullTerminatedString(ByteArrayOutputStream baos, String type) throws IOException {
		byte[] data = type.getBytes(StandardCharsets.ISO_8859_1); // ascii, near enough
		baos.write(data);
		baos.write(0);
	}

	private String readNullTerminatedAsciiString(DataInputStream dis) throws IOException {
		StringBuilder sb = new StringBuilder();
		while(true) {
			int x = dis.read();
			if(x == -1)
				throwError("Invalid extension frame", "Could not read an extension frame name.");
			if(x == 0) break;
			char c = (char) x; // ASCII
			if(x > 128 || (c < 32 && c != 10 && c != 13))
				throwError("Invalid extension frame name", "Non-ASCII character in extension frame name");
			sb.append(c);
		}
		return sb.toString();
	}

	private void skipRest(int blockLength, long countAtStart, CountedInputStream cis, DataInputStream dis, DataOutputStream dos, String thing) throws IOException {
		// Skip the rest of the data
		int skip = (int) (blockLength - (cis.count() - countAtStart));
		if(skip < 0)
			throwError("Invalid "+thing, "The file includes an invalid "+thing+'.');
		if(skip == 0) return;
		byte[] buf = new byte[skip];
		dis.readFully(buf);
		dos.write(buf);
	}

	// FIXME factor this out somewhere ... an IOUtil class maybe
	private void skipBytes(DataInputStream dis, int skip) throws IOException {
		int skipped = 0;
		while(skipped < skip) {
			long x = dis.skip(skip - skipped);
			if(x <= 0) {
				byte[] buf = new byte[Math.min(4096, skip - skipped)];
				dis.readFully(buf);
				skipped += buf.length;
			} else
				skipped += x;
		}
	}

	private void assertHeader(DataInputStream dis, byte[] expected) throws IOException {
		byte[] read = new byte[expected.length];
		dis.readFully(read);
		if(!Arrays.equals(read, expected))
			throwError("Invalid header", "The file does not start with a valid JPEG (JFIF) header.");
	}

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

}
