/*******************************************************************************
 * Copyright (c) 2000, 2018 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *******************************************************************************/
package org.eclipse.ui.internal.console;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.DocumentEvent;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IDocumentPartitionerExtension;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.ITypedRegion;
import org.eclipse.jface.text.Region;
import org.eclipse.swt.custom.StyleRange;
import org.eclipse.swt.widgets.Display;
import org.eclipse.ui.console.ConsolePlugin;
import org.eclipse.ui.console.IConsoleDocumentPartitioner;
import org.eclipse.ui.console.IOConsole;
import org.eclipse.ui.console.IOConsoleInputStream;
import org.eclipse.ui.console.IOConsoleOutputStream;
import org.eclipse.ui.progress.UIJob;
import org.eclipse.ui.progress.WorkbenchJob;

/**
 * Partitions an IOConsole's document
 * @since 3.1
 *
 */
public class IOConsolePartitioner implements IConsoleDocumentPartitioner, IDocumentPartitionerExtension {
	private PendingPartition consoleClosedPartition;
	private IDocument document;
	private ArrayList<IOConsolePartition> partitions;
	/**
	 * Blocks of data that have not yet been appended to the document.
	 */
	private ArrayList<PendingPartition> pendingPartitions;
	/**
	 * A list of PendingPartitions to be appended by the updateJob
	 */
	private ArrayList<PendingPartition> updatePartitions;
	/**
	 * The last partition appended to the document
	 */
	private IOConsolePartition lastPartition;
	/**
	 * Job that appends pending partitions to the document.
	 */
	private QueueProcessingJob queueJob;
	/**
	 * The input stream attached to this document.
	 */
	private IOConsoleInputStream inputStream;
	/**
	 * Flag to indicate that the updateJob is updating the document.
	 */
	private boolean updateInProgress;
	/**
	 * A list of partitions containing input from the console, that have
	 * not been appended to the input stream yet.
	 */
	private ArrayList<IOConsolePartition> inputPartitions;
	/**
	 * offset used by updateJob
	 */
	private int firstOffset;
	/**
	 * An array of legal line delimiters
	 */
	private String[] lld;
	private int highWaterMark = -1;
	private int lowWaterMark = -1;
	private boolean connected = false;

	private IOConsole console;

	private TrimJob trimJob = new TrimJob();
	/**
	 * Lock for appending to and removing from the document - used
	 * to synchronize addition of new text/partitions in the update
	 * job and handling buffer overflow/clearing of the console.
	 */
	private Object overflowLock = new Object();


	private int fBuffer;

	public IOConsolePartitioner(IOConsoleInputStream inputStream, IOConsole console) {
		this.inputStream = inputStream;
		this.console = console;
		trimJob.setRule(console.getSchedulingRule());
	}

	public IDocument getDocument() {
		return document;
	}

	@Override
	public void connect(IDocument doc) {
		document = doc;
		document.setDocumentPartitioner(this);
		lld = document.getLegalLineDelimiters();
		partitions = new ArrayList<>();
		pendingPartitions = new ArrayList<>();
		inputPartitions = new ArrayList<>();
		queueJob = new QueueProcessingJob();
		queueJob.setSystem(true);
		queueJob.setPriority(Job.INTERACTIVE);
		queueJob.setRule(console.getSchedulingRule());
		connected = true;
	}

	public int getHighWaterMark() {
		return highWaterMark;
	}

	public int getLowWaterMark() {
		return lowWaterMark;
	}

	public void setWaterMarks(int low, int high) {
		lowWaterMark = low;
		highWaterMark = high;
		ConsolePlugin.getStandardDisplay().asyncExec(() -> checkBufferSize());
	}

	/**
	 * Notification from the console that all of its streams have been closed.
	 */
	public void streamsClosed() {
		consoleClosedPartition = new PendingPartition(null, null);
		synchronized (pendingPartitions) {
			pendingPartitions.add(consoleClosedPartition);
		}
		queueJob.schedule(); //ensure that all pending partitions are processed.
	}

	@Override
	public void disconnect() {
		synchronized (overflowLock) {
			document = null;
			partitions.clear();
			connected = false;
			try {
				inputStream.close();
			} catch (IOException e) {
			}
		}
	}

	@Override
	public void documentAboutToBeChanged(DocumentEvent event) {
	}

	@Override
	public boolean documentChanged(DocumentEvent event) {
		return documentChanged2(event) != null;
	}

	@Override
	public String[] getLegalContentTypes() {
		return new String[] { IOConsolePartition.OUTPUT_PARTITION_TYPE, IOConsolePartition.INPUT_PARTITION_TYPE };
	}

	@Override
	public String getContentType(int offset) {
		return getPartition(offset).getType();
	}

	@Override
	public ITypedRegion[] computePartitioning(int offset, int length) {
		int rangeEnd = offset + length;
		int left= 0;
		int right= partitions.size() - 1;
		int mid= 0;
		IOConsolePartition position= null;

		if (left == right) {
			return new IOConsolePartition[]{partitions.get(0)};
		}
		while (left < right) {

			mid= (left + right) / 2;

			position= partitions.get(mid);
			if (rangeEnd < position.getOffset()) {
				if (left == mid) {
					right= left;
				} else {
					right= mid -1;
				}
			} else if (offset > (position.getOffset() + position.getLength() - 1)) {
				if (right == mid) {
					left= right;
				} else {
					left= mid  +1;
				}
			} else {
				left= right= mid;
			}
		}


		List<IOConsolePartition> list = new ArrayList<>();
		int index = left - 1;
		if (index >= 0) {
			position= partitions.get(index);
			while (index >= 0 && (position.getOffset() + position.getLength()) > offset) {
				index--;
				if (index >= 0) {
					position= partitions.get(index);
				}
			}
		}
		index++;
		position= partitions.get(index);
		while (index < partitions.size() && (position.getOffset() < rangeEnd)) {
			list.add(position);
			index++;
			if (index < partitions.size()) {
				position= partitions.get(index);
			}
		}

		return list.toArray(new IOConsolePartition[list.size()]);
	}

	@Override
	public ITypedRegion getPartition(int offset) {
		for (int i = 0; i < partitions.size(); i++) {
			ITypedRegion partition = partitions.get(i);
			int start = partition.getOffset();
			int end = start + partition.getLength();
			if (offset >= start && offset < end) {
				return partition;
			}
		}

		if (lastPartition == null)  {
			synchronized(partitions) {
				lastPartition = new IOConsolePartition(inputStream, ""); //$NON-NLS-1$
				lastPartition.setOffset(offset);
				partitions.add(lastPartition);
				inputPartitions.add(lastPartition);
			}
		}
		return lastPartition;
	}

	/**
	 * Enforces the buffer size.
	 * When the number of lines in the document exceeds the high water mark, the
	 * beginning of the document is trimmed until the number of lines equals the
	 * low water mark.
	 */
	private void checkBufferSize() {
		if (document != null && highWaterMark > 0) {
			int length = document.getLength();
			if (length > highWaterMark) {
				if (trimJob.getState() == Job.NONE) { //if the job isn't already running
					trimJob.setOffset(length - lowWaterMark);
					trimJob.schedule();
				}
			}
		}
	}

	/**
	 * Clears the console
	 */
	public void clearBuffer() {
		synchronized (overflowLock) {
			trimJob.setOffset(-1);
			trimJob.schedule();
		}
	}

	@Override
	public IRegion documentChanged2(DocumentEvent event) {
		if (document == null) {
			return null; //another thread disconnected the partitioner
		}
		if (document.getLength() == 0) { //document cleared
			if (lastPartition != null && lastPartition.getType().equals(IOConsolePartition.INPUT_PARTITION_TYPE)) {
				synchronized (partitions) {
					partitions.remove(lastPartition);
					inputPartitions.remove(lastPartition);
				}
			}
			lastPartition = null;
			return new Region(0, 0);
		}


		if (updateInProgress) {
			synchronized(partitions) {
				if (updatePartitions != null) {
					for (PendingPartition pp : updatePartitions) {
						if (pp == consoleClosedPartition) {
							continue;
						}

						int ppLen = pp.text.length();
						if (lastPartition != null && lastPartition.getStream() == pp.stream) {
							int len = lastPartition.getLength();
							lastPartition.setLength(len + ppLen);
						} else {
							IOConsolePartition partition = new IOConsolePartition(pp.stream, ppLen);
							partition.setOffset(firstOffset);
							lastPartition = partition;
							partitions.add(partition);
						}
						firstOffset += ppLen;
					}
				}
			}
		} else {// user input.
			int amountDeleted = event.getLength() ;

			if (amountDeleted > 0) {
				int offset = event.fOffset;
				IOConsolePartition partition = (IOConsolePartition) getPartition(offset);
				if (partition == lastPartition && IOConsolePartition.INPUT_PARTITION_TYPE.equals(partition.getType())) {
					partition.delete(event.fOffset-partition.getOffset(), amountDeleted);
				}
			}

			synchronized(partitions) {
				if (lastPartition == null || lastPartition.isReadOnly()) {
					lastPartition = new IOConsolePartition(inputStream, event.fText);
					lastPartition.setOffset(event.fOffset);
					partitions.add(lastPartition);
					inputPartitions.add(lastPartition);
				} else {
					lastPartition.insert(event.fText, (event.fOffset-lastPartition.getOffset()));
				}

				int lastLineDelimiter = -1;
				String partitionText = lastPartition.getString();
				for (int i = 0; i < lld.length; i++) {
					String ld = lld[i];
					int index = partitionText.lastIndexOf(ld);
					if (index != -1) {
						index += ld.length();
					}
					if (index > lastLineDelimiter) {
						lastLineDelimiter = index;
					}
				}
				if (lastLineDelimiter != -1) {
					StringBuffer input = new StringBuffer();
					Iterator<IOConsolePartition> it = inputPartitions.iterator();
					while (it.hasNext()) {
						IOConsolePartition partition = it.next();
						if (partition.getOffset() + partition.getLength() <= event.fOffset + lastLineDelimiter) {
							if (partition == lastPartition) {
								lastPartition = null;
							}
							input.append(partition.getString());
							partition.clearBuffer();
							partition.setReadOnly();
							it.remove();
						} else {
							//create a new partition containing everything up to the line delimiter
							//and append that to the string buffer.
							String contentBefore = partitionText.substring(0, lastLineDelimiter);
							IOConsolePartition newPartition = new IOConsolePartition(inputStream, contentBefore);
							newPartition.setOffset(partition.getOffset());
							newPartition.setReadOnly();
							newPartition.clearBuffer();
							int index = partitions.indexOf(partition);
							partitions.add(index, newPartition);
							input.append(contentBefore);
							//delete everything that has been appended to the buffer.
							partition.delete(0, lastLineDelimiter);
							partition.setOffset(lastLineDelimiter + partition.getOffset());
							lastLineDelimiter = 0;
						}
					}
					if (input.length() > 0) {
						inputStream.appendData(input.toString());
					}

				}
			}
		}

		return new Region(event.fOffset, event.fText.length());
	}

	private void setUpdateInProgress(boolean b) {
		updateInProgress = b;
	}

	/**
	 * A stream has been appended, add to pendingPartions list and schedule updateJob.
	 * updateJob is scheduled with a slight delay, this allows the console to run the job
	 * less frequently and update the document with a greater amount of data each time
	 * the job is run
	 * @param stream The stream that was written to.
	 * @param s The string that should be appended to the document.
	 */
	public void streamAppended(IOConsoleOutputStream stream, String s) throws IOException {
		if (document == null) {
			throw new IOException("Document is closed"); //$NON-NLS-1$
		}
		synchronized(pendingPartitions) {
			PendingPartition last = pendingPartitions.size() > 0 ? pendingPartitions.get(pendingPartitions.size()-1) : null;
			if (last != null && last.stream == stream) {
				last.append(s);
			} else {
				pendingPartitions.add(new PendingPartition(stream, s));
				if (fBuffer > 1000) {
					queueJob.schedule();
				} else {
					queueJob.schedule(50);
				}
			}

			if (fBuffer > 160000) {
				if(Display.getCurrent() == null){
					try {
						pendingPartitions.wait();
					} catch (InterruptedException e) {
					}
				} else {
					/*
					 * if we are in UI thread we cannot lock it, so process
					 * queued output.
					 */
					processQueue();
				}
			}
		}
	}

	/**
	 * Holds data until updateJob can be run and the document can be updated.
	 */
	private class PendingPartition {
		StringBuffer text = new StringBuffer(8192);
		IOConsoleOutputStream stream;

		PendingPartition(IOConsoleOutputStream stream, String text) {
			this.stream = stream;
			if (text != null) {
				append(text);
			}
		}

		void append(String moreText) {
			text.append(moreText);
			fBuffer += moreText.length();
		}
	}

	/**
	 * Updates the document. Will append everything that is available before
	 * finishing.
	 */
	private class QueueProcessingJob extends UIJob {

		QueueProcessingJob() {
			super("IOConsole Updater"); //$NON-NLS-1$
		}

		@Override
		public IStatus runInUIThread(IProgressMonitor monitor) {
			processQueue();
			return Status.OK_STATUS;
		}

		/*
		 * Job will process as much as it can each time it's run, but it gets
		 * scheduled everytime a PendingPartition is added to the list, meaning
		 * that this job could get scheduled unnecessarily in cases of heavy output.
		 * Note however, that schedule() will only reschedule a running/scheduled Job
		 * once even if it's called many times.
		 */
		@Override
		public boolean shouldRun() {
			boolean shouldRun = connected && pendingPartitions != null && pendingPartitions.size() > 0;
			return shouldRun;
		}
	}

	void processQueue() {
		synchronized (overflowLock) {
			ArrayList<PendingPartition> pendingCopy = new ArrayList<>();
			StringBuffer buffer = null;
			boolean consoleClosed = false;
			synchronized(pendingPartitions) {
				pendingCopy.addAll(pendingPartitions);
				pendingPartitions.clear();
				fBuffer = 0;
				pendingPartitions.notifyAll();
			}
			// determine buffer size
			int size = 0;
			for (PendingPartition pp : pendingCopy) {
				if (pp != consoleClosedPartition) {
					size+= pp.text.length();
				}
			}
			buffer = new StringBuffer(size);
			for (PendingPartition pp : pendingCopy) {
				if (pp != consoleClosedPartition) {
					buffer.append(pp.text);
				} else {
					consoleClosed = true;
				}
			}
			if (connected) {
				setUpdateInProgress(true);
				updatePartitions = pendingCopy;
				firstOffset = document.getLength();
				try {
					if (buffer != null) {
						document.replace(firstOffset, 0, buffer.toString());
					}
				} catch (BadLocationException e) {
				}
				updatePartitions = null;
				setUpdateInProgress(false);
			}
			if (consoleClosed) {
				console.partitionerFinished();
			}
			checkBufferSize();
		}

	}

	/**
	 * Job to trim the console document, runs in the  UI thread.
	 */
	private class TrimJob extends WorkbenchJob {

		/**
		 * trims output up to the line containing the given offset,
		 * or all output if -1.
		 */
		private int truncateOffset;

		/**
		 * Creates a new job to trim the buffer.
		 */
		TrimJob() {
			super("Trim Job"); //$NON-NLS-1$
			setSystem(true);
		}

		/**
		 * Sets the trim offset.
		 *
		 * @param offset trims output up to the line containing the given offset
		 */
		public void setOffset(int offset) {
			truncateOffset = offset;
		}

		/* (non-Javadoc)
		 * @see org.eclipse.ui.progress.UIJob#runInUIThread(org.eclipse.core.runtime.IProgressMonitor)
		 */
		@Override
		public IStatus runInUIThread(IProgressMonitor monitor) {
			if (document == null) {
				return Status.OK_STATUS;
			}

			int length = document.getLength();
			if (truncateOffset < length) {
				synchronized (overflowLock) {
					try {
						if (truncateOffset < 0) {
							// clear
							setUpdateInProgress(true);
							document.set(""); //$NON-NLS-1$
							setUpdateInProgress(false);
							partitions.clear();
						} else {
							// overflow
							int cutoffLine = document.getLineOfOffset(truncateOffset);
							int cutOffset = document.getLineOffset(cutoffLine);


							// set the new length of the first partition
							IOConsolePartition partition = (IOConsolePartition) getPartition(cutOffset);
							partition.setLength(partition.getOffset() + partition.getLength() - cutOffset);

							setUpdateInProgress(true);
							document.replace(0, cutOffset, ""); //$NON-NLS-1$
							setUpdateInProgress(false);

							//remove partitions and reset Partition offsets
							int index = partitions.indexOf(partition);
							for (int i = 0; i < index; i++) {
								partitions.remove(0);
							}

							int offset = 0;
							for (IOConsolePartition p : partitions) {
								p.setOffset(offset);
								offset += p.getLength();
							}
						}
					} catch (BadLocationException e) {
					}
				}
			}
			return Status.OK_STATUS;
		}
	}

	@Override
	public boolean isReadOnly(int offset) {
		return ((IOConsolePartition)getPartition(offset)).isReadOnly();
	}

	@Override
	public StyleRange[] getStyleRanges(int offset, int length) {
		if (!connected) {
			return new StyleRange[0];
		}
		IOConsolePartition[] computedPartitions = (IOConsolePartition[])computePartitioning(offset, length);
		StyleRange[] styles = new StyleRange[computedPartitions.length];
		for (int i = 0; i < computedPartitions.length; i++) {
			int rangeStart = Math.max(computedPartitions[i].getOffset(), offset);
			int rangeLength = computedPartitions[i].getLength();
			styles[i] = computedPartitions[i].getStyleRange(rangeStart, rangeLength);
		}
		return styles;
	}
}
