package ar.com.hjg.pngj;

import java.util.zip.DataFormatException;
import java.util.zip.Inflater;

/**
 * A set of IDAT-like chunks which, concatenated, form a zlib stream.
 * <p>
 * The inflated stream is intented to be read as a sequence of "rows", of which
 * the caller knows the lengths (not
 * necessary equal) and number.
 * <p>
 * Eg: For IDAT non-interlaced images, a row has bytesPerRow + 1 filter byte<br>
 * For interlaced images, the lengths are variable.
 * <p>
 * This class can work in sync (polled) mode or async (callback) mode. But for
 * callback mode the method
 * processRowCallback() must be overriden
 * <p>
 * See {@link IdatSet}, which is mostly used and has a slightly simpler use.<br>
 * See <code>DeflatedChunkSetTest</code> for example of use.
 */
public class DeflatedChunksSet {

	protected byte[] row; // a "row" here means a raw (uncopressed filtered) part of the IDAT stream,
							// normally a image row (or subimage row for interlaced) plus a filter byte
	private int rowfilled; // effective/valid length of row
	private int rowlen; // what amount of bytes is to be interpreted as a complete "row". can change
						// (for interlaced)
	private int rown; // only coincide with image row if non-interlaced - incremented by
						// setNextRowSize()

	/*
	 * States WAITING_FOR_INPUT ROW_READY WORK_DONE TERMINATED
	 * 
	 * processBytes() is externally called, prohibited in READY (in DONE it's ignored)
	 * 
	 * WARNING: inflater.finished() != DONE (not enough, not neccesary) DONE means that we have already uncompressed all
	 * the data of interest.
	 * 
	 * In non-callback mode, prepareForNextRow() is also externally called, in
	 * 
	 * Flow: - processBytes() calls inflateData() - inflateData() : if buffer is filled goes to READY else if !
	 * inf.finished goes to WAITING else if any data goes to READY (incomplete data to be read) else goes to DONE - in
	 * Callback mode, after going to READY, n=processCallback() is called and then prepareForNextRow(n) is called. - in
	 * Polled mode, prepareForNextRow(n) must be called from outside (after checking state=READY) - prepareForNextRow(n)
	 * goes to DONE if n==0 calls inflateData() again - end() goes to DONE
	 */
	private enum State {
		WAITING_FOR_INPUT, // waiting for more input
		ROW_READY, // ready for consumption (might be less than fully filled), ephemeral for CALLBACK
		// mode
		WORK_DONE, // all data of interest has been read, but we might accept still more trailing chunks
		// (we'll ignore them)
		TERMINATED; // we are done, and also won't accept more IDAT chunks

		public boolean isDone() {
			return this == WORK_DONE || this == TERMINATED;
		} // the caller has already uncompressed all the data of interest or EOF

		public boolean isTerminated() {
			return this == TERMINATED;
		} // we dont accept more chunks
	}

	State state = State.WAITING_FOR_INPUT; // never null

	private Inflater inf;
	private final boolean infOwn; // true if we own the inflater (we created it)

	private DeflatedChunkReader curChunk;

	private boolean callbackMode = true;
	private long nBytesIn = 0; // count the total compressed bytes that have been fed
	private long nBytesOut = 0; // count the total uncompressed bytes
	int chunkNum = -1; // incremented at each new chunk start
	int firstChunqSeqNum = -1; // expected seq num for first chunk. used only for fDAT (APNG)

	/**
	 * All IDAT-like chunks that form a same DeflatedChunksSet should have the
	 * same id
	 */
	public final String chunkid;

	/**
	 * @param initialRowLen
	 *            Length in bytes of first "row" (see description)
	 * @param maxRowLen
	 *            Max length in bytes of "rows"
	 * @param inflater
	 *            Can be null. If not null, must be already reset (and it must
	 *            be closed/released by caller!)
	 */
	public DeflatedChunksSet(final String chunkid, final int initialRowLen, final int maxRowLen, final Inflater inflater, final byte[] buffer) {
		this.chunkid = chunkid;
		rowlen = initialRowLen;
		if (initialRowLen < 1 || maxRowLen < initialRowLen)
			throw new PngjException("bad inital row len " + initialRowLen);
		if (inflater != null) {
			inf = inflater;
			infOwn = false;
		} else {
			inf = new Inflater();
			infOwn = true; // inflater is own, we will release on close()
		}
		row = buffer != null && buffer.length >= initialRowLen ? buffer : new byte[maxRowLen];
		rown = -1;
		state = State.WAITING_FOR_INPUT;
		try {
			prepareForNextRow(initialRowLen);
		} catch (final RuntimeException e) {
			close();
			throw e;
		}
	}

	public DeflatedChunksSet(final String chunkid, final int initialRowLen, final int maxRowLen) {
		this(chunkid, initialRowLen, maxRowLen, null, null);
	}

	protected void appendNewChunk(final DeflatedChunkReader cr) {
		// all chunks must have same id
		if (!chunkid.equals(cr.getChunkRaw().id))
			throw new PngjInputException("Bad chunk inside IdatSet, id:" + cr.getChunkRaw().id + ", expected:" + chunkid);
		curChunk = cr;
		chunkNum++;
		if (firstChunqSeqNum >= 0)
			cr.setSeqNumExpected(chunkNum + firstChunqSeqNum);
	}

	/**
	 * Feeds the inflater with the compressed bytes
	 * 
	 * In poll mode, the caller should not call repeatedly this, without
	 * consuming first, checking
	 * isDataReadyForConsumer()
	 * 
	 * @param buf
	 * @param off
	 * @param len
	 */
	protected void processBytes(final byte[] buf, final int off, final int len) {
		nBytesIn += len;
		// PngHelperInternal.LOGGER.info("processing compressed bytes in chunkreader : " + len);
		if (len < 1 || state.isDone())
			return;
		if (state == State.ROW_READY)
			throw new PngjInputException("this should only be called if waitingForMoreInput");
		if (inf.needsDictionary() || !inf.needsInput())
			throw new RuntimeException("should not happen");
		inf.setInput(buf, off, len);
		// PngHelperInternal.debug("entering processs bytes, state=" + state +
		// " callback="+callbackMode);
		if (isCallbackMode())
			while (inflateData()) {
				final int nextRowLen = processRowCallback();
				prepareForNextRow(nextRowLen);
				if (isDone())
					processDoneCallback();
			}
		else
			inflateData();
	}

	/*
	 * This never inflates more than one row This returns true if this has resulted in a row being ready and preprocessed
	 * with preProcessRow (in callback mode, we should call immediately processRowCallback() and
	 * prepareForNextRow(nextRowLen)
	 */
	private boolean inflateData() {
		try {
			// PngHelperInternal.debug("entering inflateData bytes, state=" + state +
			// " callback="+callbackMode);
			if (state == State.ROW_READY)
				throw new PngjException("invalid state");// assert
			if (state.isDone())
				return false;
			int ninflated = 0;
			if (row == null || row.length < rowlen)
				row = new byte[rowlen]; // should not happen
			if (rowfilled < rowlen && !inf.finished()) {
				try {
					ninflated = inf.inflate(row, rowfilled, rowlen - rowfilled);
				} catch (final DataFormatException e) {
					throw new PngjInputException("error decompressing zlib stream ", e);
				}
				rowfilled += ninflated;
				nBytesOut += ninflated;
			}
			State nextstate = null;
			if (rowfilled == rowlen)
				nextstate = State.ROW_READY; // complete row, process it
			else if (!inf.finished())
				nextstate = State.WAITING_FOR_INPUT;
			else if (rowfilled > 0)
				nextstate = State.ROW_READY; // complete row, process it
			else
				nextstate = State.WORK_DONE; // eof, no more data
			state = nextstate;
			if (state == State.ROW_READY) {
				preProcessRow();
				return true;
			}
		} catch (final RuntimeException e) {
			close();
			throw e;
		}
		return false;
	}

	/**
	 * Called automatically in all modes when a full row has been inflated.
	 */
	protected void preProcessRow() {

	}

	/**
	 * Callback, must be implemented in callbackMode
	 * <p>
	 * This should use {@link #getRowFilled()} and {@link #getInflatedRow()} to
	 * access the row.
	 * <p>
	 * Must return byes of next row, for next callback.
	 */
	protected int processRowCallback() {
		throw new PngjInputException("not implemented");
	}

	/**
	 * Callback, to be implemented in callbackMode
	 * <p>
	 * This will be called once to notify state done
	 */
	protected void processDoneCallback() {}

	/**
	 * Inflated buffer.
	 * 
	 * The effective length is given by {@link #getRowFilled()}
	 */
	public byte[] getInflatedRow() {
		return row;
	}

	/**
	 * Should be called after the previous row was processed
	 * <p>
	 * Pass 0 or negative to signal that we are done (not expecting more bytes)
	 * <p>
	 * This resets {@link #rowfilled}
	 * <p>
	 * The
	 */
	public void prepareForNextRow(final int len) {
		rowfilled = 0;
		rown++;
		if (len < 1) {
			rowlen = 0;
			done();
		} else if (inf.finished()) {
			rowlen = 0;
			done();
		} else {
			state = State.WAITING_FOR_INPUT;
			rowlen = len;
			if (!callbackMode)
				inflateData();
		}
	}

	/**
	 * In this state, the object is waiting for more input to deflate.
	 * <p>
	 * Only in this state it's legal to feed this
	 */
	public boolean isWaitingForMoreInput() {
		return state == State.WAITING_FOR_INPUT;
	}

	/**
	 * In this state, the object is waiting the caller to retrieve inflated data
	 * <p>
	 * Effective length: see {@link #getRowFilled()}
	 */
	public boolean isRowReady() {
		return state == State.ROW_READY;
	}

	/**
	 * In this state, all relevant data has been uncompressed and retrieved
	 * (exceptionally, the reading has ended
	 * prematurely).
	 * <p>
	 * We can still feed this object, but the bytes will be swallowed/ignored.
	 */
	public boolean isDone() {
		return state.isDone();
	}

	public boolean isTerminated() {
		return state.isTerminated();
	}

	/**
	 * This will be called by the owner to report us the next chunk to come. We
	 * can make our own internal changes and
	 * checks. This returns true if we acknowledge the next chunk as part of
	 * this set
	 */
	public boolean ackNextChunkId(final String id) {
		if (state.isTerminated())
			return false;
		else if (id.equals(chunkid))
			return true;
		else if (!allowOtherChunksInBetween(id)) {
			if (state.isDone()) {
				if (!isTerminated())
					terminate();
				return false;
			} else
				throw new PngjInputException("Unexpected chunk " + id + " while " + chunkid + " set is not done");
		} else
			return true;
	}

	protected void terminate() {
		close();
	}

	/**
	 * This should be called when discarding this object, or for aborting.
	 * Secure, idempotent Don't use this just to
	 * notify this object that it has no more work to do, see {@link #done()}
	 */
	public void close() {
		try {
			if (!state.isTerminated())
				state = State.TERMINATED;
			if (infOwn && inf != null) {
				inf.end();// we end the Inflater only if we created it
				inf = null;
			}
		} catch (final Exception e) {}
	}

	/**
	 * Forces the DONE state, this object won't uncompress more data. It's still
	 * not terminated, it will accept more IDAT
	 * chunks, but will ignore them.
	 */
	public void done() {
		if (!isDone())
			state = State.WORK_DONE;
	}

	/**
	 * Target size of the current row, including filter byte. <br>
	 * should coincide (or be less than) with row.length
	 */
	public int getRowLen() {
		return rowlen;
	}

	/** This the amount of valid bytes in the buffer */
	public int getRowFilled() {
		return rowfilled;
	}

	/**
	 * Get current (last) row number.
	 * <p>
	 * This corresponds to the raw numeration of rows as seen by the deflater.
	 * Not the same as the real image row, if
	 * interlaced.
	 * 
	 */
	public int getRown() {
		return rown;
	}

	/**
	 * Some IDAT-like set can allow other chunks in between (APGN?).
	 * <p>
	 * Normally false.
	 * 
	 * @param id
	 *            Id of the other chunk that appeared in middel of this set.
	 * @return true if allowed
	 */
	public boolean allowOtherChunksInBetween(final String id) {
		return false;
	}

	/**
	 * Callback mode = async processing
	 */
	public boolean isCallbackMode() {
		return callbackMode;
	}

	public void setCallbackMode(final boolean callbackMode) {
		this.callbackMode = callbackMode;
	}

	/** total number of bytes that have been fed to this object */
	public long getBytesIn() {
		return nBytesIn;
	}

	/** total number of bytes that have been uncompressed */
	public long getBytesOut() {
		return nBytesOut;
	}

	@Override
	public String toString() {
		final StringBuilder sb = new StringBuilder("idatSet : " + curChunk.getChunkRaw().id + " state=" + state + " rows=" + rown + " bytes=" + nBytesIn + "/" + nBytesOut);
		return sb.toString();
	}

}
