package processing.mode.java.pdex;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.ListIterator;
import java.util.stream.Collectors;

import processing.core.PApplet;


public class TextTransform {

	private static final Comparator<Edit> INPUT_OFFSET_COMP =
			(o1, o2) -> Integer.compare(o1.fromOffset, o2.fromOffset);

	private static final Comparator<Edit> OUTPUT_OFFSET_COMP =
			(o1, o2) -> Integer.compare(o1.toOffset, o2.toOffset);


	private CharSequence input;

	private List<Edit> edits = new ArrayList<>();

	private List<Edit> inMap = new ArrayList<>();
	private List<Edit> outMap = new ArrayList<>();

	private boolean built;
	private int builtForLength;


	TextTransform(CharSequence input) {
		this.input = input;
	}


	public void add(Edit edit) {
		edits.add(edit);
		built = false;
	}


	public void addAll(Collection<Edit> edits) {
		this.edits.addAll(edits);
		built = false;
	}


	public String apply() {
		final int inLength = input.length();
		final StringBuilder output = new StringBuilder(inLength);

		buildIfNeeded(inLength);

		outMap.stream()
				// Filter out Delete edits
				.filter(outEdit -> outEdit.toLength > 0)
				.forEach(outEdit -> {
					if (outEdit.outputText != null) {
						// Insert or Replace edit
						output.append(outEdit.outputText);
					} else {
						// Move edit
						output.append(input, outEdit.fromOffset, outEdit.fromOffset + outEdit.fromLength);
					}
				});

		return output.toString();
	}


	public OffsetMapper getMapper() {
		int inLength = input.length();
		buildIfNeeded(inLength);
		return new SimpleOffsetMapper(inMap, outMap);
	}


	private void buildIfNeeded(int inLength) {
		if (built && inLength == builtForLength) return;

		// Make copies of Edits to preserve original edits
		List<Edit> inEdits = edits.stream().map(Edit::new).collect(Collectors.toList());
		List<Edit> outEdits = new ArrayList<>(inEdits);

		// Edits sorted by input offsets
		Collections.sort(inEdits, INPUT_OFFSET_COMP);

		// Edits sorted by output offsets
		Collections.sort(outEdits, OUTPUT_OFFSET_COMP);

		// TODO: add some validation

		// Input
		ListIterator<Edit> inIt = inEdits.listIterator();
		Edit inEdit = inIt.hasNext() ? inIt.next() : null;
		int inEditOff = inEdit == null ? inLength : inEdit.fromOffset;

		// Output
		ListIterator<Edit> outIt = outEdits.listIterator();
		Edit outEdit = outIt.hasNext() ? outIt.next() : null;
		int outEditOff = outEdit == null ? inLength : outEdit.toOffset;

		int inOffset = 0;
		int outOffset = 0;

		inMap.clear();
		outMap.clear();

		// Walk through the input, apply changes, create mapping
		while (inOffset < inLength || inEdit != null || outEdit != null) {

			{ // Create mapping for unmodified portion of the input
				int nextEditOffset = Math.min(inEditOff, outEditOff);

				{ // Insert move block to have mapping for unmodified portions too
					int length = nextEditOffset - inOffset;
					if (length > 0) {
						Edit ch = Edit.move(inOffset, length, outOffset);
						inMap.add(ch);
						outMap.add(ch);
					}
				}

				// Move offsets accordingly
				outOffset += nextEditOffset - inOffset;
				inOffset = nextEditOffset;
			}

			// Process encountered input edits
			while (inEdit != null && inOffset >= inEditOff) {
				inOffset += inEdit.fromLength;
				if (inEdit.fromLength > 0) inMap.add(inEdit);
				inEdit = inIt.hasNext() ? inIt.next() : null;
				inEditOff = inEdit != null ? inEdit.fromOffset : inLength;
			}

			// Process encountered output edits
			while (outEdit != null && inOffset >= outEditOff) {
				outEdit.toOffset = outOffset;
				if (outEdit.toLength > 0) outMap.add(outEdit);
				outOffset += outEdit.toLength;
				outEdit = outIt.hasNext() ? outIt.next() : null;
				outEditOff = outEdit != null ? outEdit.toOffset : inLength;
			}
		}

		built = true;
		builtForLength = inLength;
	}


	@Override
	public String toString() {
		return "SourceMapping{" +
				"edits=" + edits +
				'}';
	}


	protected static class Edit {

		static Edit insert(int offset, String text) {
			return new Edit(offset, 0, offset, text.length(), text);
		}

		static Edit replace(int offset, int length, String text) {
			return new Edit(offset, length, offset, text.length(), text);
		}

		static Edit move(int fromOffset, int length, int toOffset) {
			Edit result = new Edit(fromOffset, length, toOffset, length, null);
			result.toOffset = toOffset;
			return result;
		}

		static Edit delete(int position, int length) {
			return new Edit(position, length, position, 0, null);
		}

		Edit(Edit edit) {
			this.fromOffset = edit.fromOffset;
			this.fromLength = edit.fromLength;
			this.toOffset = edit.toOffset;
			this.toLength = edit.toLength;
			this.outputText = edit.outputText;
		}

		Edit(int fromOffset, int fromLength, int toOffset, int toLength, String text) {
			this.fromOffset = fromOffset;
			this.fromLength = fromLength;
			this.toOffset = toOffset;
			this.toLength = toLength;
			this.outputText = text;
		}

		private final int fromOffset;
		private final int fromLength;
		private int toOffset;
		private final int toLength;
		private final String outputText;

		@Override
		public String toString() {
			return "Edit{" +
					"from=" + fromOffset + ":" + fromLength +
					", to=" + toOffset + ":" + toLength +
					((outputText != null) ? (", text='" + outputText + '\'') : "") +
					'}';
		}
	}


	protected interface OffsetMapper {
		int getInputOffset(int outputOffset);
		int getOutputOffset(int inputOffset);
		OffsetMapper thenMapping(OffsetMapper mapper);
		OffsetMapper EMPTY_MAPPER = CompositeOffsetMapper.of();
	}


	private static class SimpleOffsetMapper implements OffsetMapper {
		private List<Edit> inMap = new ArrayList<>();
		private List<Edit> outMap = new ArrayList<>();

		private int outputOffsetOfInputStart;
		private int inputOffsetOfOutputStart;

		private SimpleOffsetMapper(List<Edit> inMap, List<Edit> outMap) {
			this.inMap.addAll(inMap);
			this.outMap.addAll(outMap);

			Edit inStart = null;
			for (Edit in : this.inMap) {
				inStart = in;
				if (in.fromLength > 0) break;
			}
			outputOffsetOfInputStart = inStart == null ? 0 : inStart.toOffset;

			Edit outStart = null;
			for (Edit out : this.inMap) {
				outStart = out;
				if (out.toLength > 0) break;
			}
			inputOffsetOfOutputStart = outStart == null ? 0 : outStart.fromOffset;
		}

		@Override
		public int getInputOffset(int outputOffset) {
			if (outputOffset < outputOffsetOfInputStart) return -1;
			Edit searchKey = new Edit(0, 0, outputOffset, Integer.MAX_VALUE, null);
			int i = Collections.binarySearch(outMap, searchKey, OUTPUT_OFFSET_COMP);
			if (i < 0) {
				i = -(i + 1);
				i -= 1;
			}
			i = PApplet.constrain(i, 0, outMap.size()-1);
			Edit edit = outMap.get(i);
			int diff = outputOffset - edit.toOffset;
			return edit.fromOffset + Math.min(diff, Math.max(0, edit.fromLength - 1));
		}

		@Override
		public int getOutputOffset(int inputOffset) {
			if (inputOffset < inputOffsetOfOutputStart) return -1;
			Edit searchKey = new Edit(inputOffset, Integer.MAX_VALUE, 0, 0, null);
			int i = Collections.binarySearch(inMap, searchKey, INPUT_OFFSET_COMP);
			if (i < 0) {
				i = -(i + 1);
				i -= 1;
			}
			i = PApplet.constrain(i, 0, inMap.size()-1);
			Edit edit = inMap.get(i);
			int diff = inputOffset - edit.fromOffset;
			return edit.toOffset + Math.min(diff, Math.max(0, edit.toLength - 1));
		}

		@Override
		public OffsetMapper thenMapping(OffsetMapper mapper) {
			return CompositeOffsetMapper.of(this, mapper);
		}
	}


	private static class CompositeOffsetMapper implements OffsetMapper {
		private List<OffsetMapper> mappers = new ArrayList<>();

		public static CompositeOffsetMapper of(OffsetMapper... inMappers) {
			CompositeOffsetMapper composite = new CompositeOffsetMapper();

			// Add mappers one by one, unwrap if Composite
			for (OffsetMapper mapper : inMappers) {
				if (mapper instanceof CompositeOffsetMapper) {
					composite.mappers.addAll(((CompositeOffsetMapper) mapper).mappers);
				} else {
					composite.mappers.add(mapper);
				}
			}

			return composite;
		}

		@Override
		public int getInputOffset(int outputOffset) {
			for (int i = mappers.size() - 1; i >= 0; i--) {
				outputOffset = mappers.get(i).getInputOffset(outputOffset);
			}
			return outputOffset;
		}

		@Override
		public int getOutputOffset(int inputOffset) {
			for (OffsetMapper mapper : mappers) {
				inputOffset = mapper.getOutputOffset(inputOffset);
			}
			return inputOffset;
		}

		@Override
		public OffsetMapper thenMapping(OffsetMapper mapper) {
			return CompositeOffsetMapper.of(this, mapper);
		}
	}

}
