#!/usr/bin/env python
import sys
import time
import array
import tty
import os
import word_wrapper
import io
import StringIO
from bisect import bisect_left
INSERT, DELETE, ALT, BACKSPACE = -1, -2, -3, -4
MAX_HEIGHT = 10000000
# TODO handle syntax highlighting.
def UTF8CharLen(text):
	if len(text) == 0:
		return(0)
	else:
		result = 1
		c = text[0]
		if ord(c) < 128:
			return(result)
		else: # UTF-8 start byte
			while True:
				c = text[result]
				if ord(c) < 0xC0:
					break
				result += 1
			return(result)
def load(name = None):
	with io.open(name or "dump.TXT", "r") as f:
		return(f.read())
def save(text):
	name = None
	with io.createTempFile("dump.TXT", "w") as f:
		name = f.name
		f.write(text)
	os.rename(name, "dump.TXT")
def UTF8BackLen(text):
	if len(text) == 0:
		return(0)
	else:
		c = text[-1]
		result = -1
		if ord(c) < 128:
			return(result)
		else:
			# TODO be more forgiving.
			while ord(c) >= 0xC0:
				result -= 1
				c = text[result]
			return(result)
class Command(object): # TODO timestamp
	def __init__(self, action, position, text, marks):
		self.action = action
		self.position = position
		self.text = text
		self.marks = marks
class Buffer(object): # there's always at least one mark.
	def __init__(self):
		self.fMarks = [] # of Mark # TODO keep sorted by position and whence.
		self.fData = array.array('c')
		self.fModified = []
		self.fFrontierMark = self.addMark(Mark(0, 2))
		self.fProtocol = []
	def logInsert(self, position, text):
		if len(self.fProtocol) > 0 and self.fProtocol[-1].action == INSERT and self.fProtocol[-1].position + len(self.fProtocol[-1].text) == position:
			self.fProtocol[-1].text += text
		else:
			self.fProtocol.append(Command(INSERT, position, text, []))
	def insert(self, position, text):
		self.logInsert(position, text)
		self.moveMarks(position, position, len(text))
		i = position
		for c in text:
			self.fData.insert(i, c)
			if c == "\n":
				self.addMark(ParagraphBreakMark(i, 0))
			i += 1
		self.emitModified()
	def emitModified(self):
		for item in self.fModified:
			item()
	def logDelete(self, position, count, text):
		if len(self.fProtocol) > 0 and self.fProtocol[-1].action == DELETE and self.fProtocol[-1].position - len(text) == position:
			self.fProtocol[-1].text = text + self.fProtocol[-1].text
			self.fProtocol[-1].position -= len(text)
		else:
			self.fProtocol.append(Command(DELETE, position, text, [])) # TODO marks
	#def findParagraphBreakMark(self, paragraphNumber):
	#	for mark in self.marks:
	#		if isinstance(mark, ParagraphBreakMark):
	def findMarksAt(self, position):
		return([mark for mark in self.marks if mark.position == position])
	def delete(self, position, count):
		text = self[position : position + count]
		self.logDelete(position, count, text)
		for i in range(len(text) - 1, -1, -1):
			if text[i] == "\n": # delete the paragraph mark
				marks = self.findMarksAt(position + i)
				for mark in [m for m in marks]:
					if isinstance(mark, ParagraphBreakMark):
						self.removeMark(mark)
		del self.fData[position : position + count]
		self.moveMarks(position, position + count, -count)
		self.emitModified()
	def moveMarks(self, min_position, end_position, offset):
		assert(end_position >= min_position)
		for mark in self.marks:
			if (mark.position >= end_position and mark.whence > 0) or (mark.position > end_position and mark.whence == 0):
				mark.position += offset
			elif mark.position >= min_position and mark.position < end_position:
				mark.position = min_position
	def __getitem__(self, selector):
		if hasattr(selector, "start"):
			position = selector.start
			frontier = selector.stop
			step = selector.step
			return self.fData[position : frontier : step].tostring()
		else:
			return self.fData[selector]
	def getSlice2(self, beginning, end):
		if end > self.fMarks[-1].position:
			end = self.fMarks[-1].position
		marks = sorted(self.fMarks) # FIXME keep sorted in the first place.
		prev_p = marks[0].position
		for mark in marks:
			assert(mark.position >= prev_p)
			prev_p = mark.position
		assert(len(marks) > 0)
		while len(marks) > 0 and marks[0].position < beginning:
			marks = marks[1:]
		assert(len(marks) > 0 and marks[0].position >= beginning)
		# there is bound to be one mark with a higher position than end.
		while len(marks) > 0:
			mark = marks[0]
			if beginning != mark.position:
				yield self.fData[beginning : mark.position].tostring()
			yield mark
			beginning = mark.position
			marks = marks[1:]
	def addMark(self, mark):
		assert(mark.buffer is None)
		self.marks.append(mark)
		mark.buffer = self
		return(mark)
	def removeMark(self, mark):
		self.marks.remove(mark)
		mark.buffer = None
	def __len__(self):
		return(len(self.fData))
	modified = property(lambda self: self.fModified)
	marks = property(lambda self: self.fMarks)
	protocol = property(lambda self: self.fProtocol)
class Mark(object):
	def __init__(self, position, whence):
		self.fBuffer = None
		self.fPosition = position
		self.fWhence = whence
		self.moved = []
	def setPosition(self, value):
		self.fPosition = value
		self.emitMoved()
	def setBuffer(self, value):
		self.fBuffer = value
		self.emitMoved()
	def setWhence(self, value):
		self.fWhence = value
		self.emitMoved()
	def __repr__(self):
		return "%s(%d,%d)" % (self.__class__.__name__, self.fPosition, self.fWhence)
	def emitMoved(self):
		for item in self.moved:
			item()
	def __cmp__(self, b):
		result = cmp(self.fPosition, b.fPosition)
		if result == 0:
			result = cmp(self.fWhence, b.fWhence)
			if result == 0:
				result = cmp(id(self), id(b))
		return(result)
	position = property(lambda self: self.fPosition, setPosition)
	whence = property(lambda self: self.fWhence, setWhence) # 0=left-, otherwise right-affine.
	buffer = property(lambda self: self.fBuffer, setBuffer)
class ParagraphBreakMark(Mark):
	# these are left as markers for the view (Controller) to find.
	pass
debug_file = io.open("debug.TXT", "w")
class Controller(object):
	def __init__(self, wordWrapper):
		self.fBuffer = None
		self.fBeginning = 0
		self.fFrontier = 0
		self.fInsertionMark = None
		self.fTerminal = wordWrapper
		self.fParagraphBreakPositions = [] # [(row, paragraph pos)], sorted, row is unique.
	def setBuffer(self, value):
		if self.fBuffer is not None:
			self.fBuffer.modified.remove(self.refresh)
			if self.fInsertionMark is not None:
				self.fInsertionMark.moved.remove(self.refresh) # FIXME don't overdo it
				self.fBuffer.marks.remove(self.fInsertionMark)
		self.fBuffer = value
		self.fInsertionMark = self.fBuffer.addMark(Mark(0, 2))
		self.fInsertionMark.moved.append(self.setTerminalCaret) # FIXME don't overdo it.
		#self.fMarks.append(self.fInsertionMark)
		self.refresh()
		self.fBuffer.modified.append(self.refresh)
	def setBeginning(self, value):
		self.fBeginning = value
		self.refresh()
	def setCaretPosition(self, value):
		self.fInsertionMark.position = value
	def findParagraphByRow(self, rowIndex):
		positions = self.fParagraphBreakPositions
		index = bisect_left(positions, (rowIndex, 0), 0, len(positions))
		if index < len(positions):
			previousBreakT = positions[index - 1] if index > 0 else (-1, -1)
			breakT = positions[index]
			debug_file.write("found paragraph by row %d to be (%r, %r)" % (rowIndex, previousBreakT, breakT))
			return(previousBreakT, breakT)
		else:
			return((-1, -1), (-1, -1))
	def findParagraphByPosition(self, position):
		prevMark = None
		positions = self.fParagraphBreakPositions
		nextPos = (-1, -1)
		prevPos = (-1, -1)
		# TODO just bisect on the snd of the item
		for rowIndex, xPosition in positions:
			nextPos = (rowIndex, xPosition)
			if xPosition >= position:
				break
			prevPos = (rowIndex, xPosition)
		if(prevPos[1] != -1):
			debug_file.write("found paragraph by position %d to be (%r, %r)" % (rowIndex, prevPos, nextPos))
			debug_file.flush()
			assert(prevPos[1] <= position)
			assert(nextPos[1] >= position)
		return(prevPos, nextPos)
	def setCaretToCell(self, x, y):
		previousBreakT, breakT = self.findParagraphByRow(y)
		position, xx, yy = self.findCaretXY(previousBreakT, breakT, x, y)
		# x and xx should be approx. the same.
		# y and yy should be approx. the same.
		if position != -1:
			self.setCaretPosition(position)
	def setTerminalCaret(self):
		self.refresh()
		# FIXME make the below work.
		return
		position = self.fInsertionMark.position
		previousBreakT, breakT = self.findParagraphByPosition(position)
		if previousBreakT[1] != -1 and breakT[1] != -1:
			assert(previousBreakT[1] <= position)
			assert(breakT[1] >= position)
			breakT = (breakT[0], position) # lie
			position, xx, yy = self.findCaretXY(previousBreakT, breakT, 999999, 999999)
			if xx != -1 and yy != -1:
				self.fTerminal.goTo(xx, yy)
	def findCaretXY(self, previousBreakT, breakT, x, y):
		if breakT == (-1, -1):
			return(-1,-1,-1)
		firstParagraphCellPosition = previousBreakT[1] + 1
		lastParagraphCellPosition = breakT[1] + 1 # actually the \n is invisible, so technically not.
		terminal = self.fTerminal
		p = None
		offset = 0
		try:
			try:
				p = terminal.startFaking(0, previousBreakT[0] + 1, x + 1, y + 1)
				for item in self.buffer.getSlice2(firstParagraphCellPosition, lastParagraphCellPosition):
					if isinstance(item, Mark):
						continue
					terminal.addChunk(item)
					if terminal.Y > y:
						break
					if terminal.Y == y and terminal.X > x:
						break
					offset += len(item)
			finally:
				terminal.stopFaking(p)
		except OverflowError as e:
			offset += terminal.offset
		return(firstParagraphCellPosition + offset, terminal.X, terminal.Y)
		#os.execvp("/bin/bash", ["/bin/bash"])
	def refresh(self): # TODO delay.
		self.fParagraphBreakPositions = [] # (row, paragraph_pos), sorted, row is unique
		debug_file.write("refreshing...\n")
		terminal = self.fTerminal
		terminal.start()
		try:
			# FIXME reuse frontier.
			for item in self.buffer.getSlice2(0, self.frontier):
				if isinstance(item, Mark):
					debug_file.write("a mark...\n")
					if item is self.fInsertionMark:
						terminal.saveCaretPosition()
						debug_file.write("caret...\n")
					elif isinstance(item, ParagraphBreakMark):
						debug_file.write("paragraph...\n")
						self.fParagraphBreakPositions.append((terminal.Y, item.position))
						debug_file.write("paragraph break at %d -> %d\n" % (terminal.Y, item.position))
				else:
					text = item
					terminal.addChunk(text)
					debug_file.write("text %r\n" % text)
			terminal.addChunk("[%d,%d]" % (len(self.buffer.protocol), len(self.buffer.marks)))
		except OverflowError as e: # impossible to fit.
			pass
		terminal.clearToEnd()
		terminal.restoreCaretPosition()
		terminal.finish()
		# TODO set frontier.
	def processEvent(self, event): # actually controller
		if hasattr(event, "charCode"):
			c = event.charCode
			if c == DELETE:
				self.deleteChar()
			elif c == BACKSPACE:
				self.backspaceChar()
			elif c == "\r":
				self.insert("\n")
			else:
				self.insert(c)
		elif hasattr(event, "y"):
			self.setCaretToCell(event.x, event.y)
		elif hasattr(event, "width"): # window resized or something
			self.resizeEditor(event.width, event.height)
	def resizeEditor(self, width, height):
		self.fTerminal.setSize(width, MAX_HEIGHT, height)
		self.refresh()
	def deleteChar(self):
		buffer = self.buffer
		if buffer is not None:
			caretPosition = self.caretPosition
			count = UTF8CharLen(buffer[caretPosition:caretPosition+5])
			self.buffer.delete(caretPosition, count)
	def backspaceChar(self):
		buffer = self.buffer
		if buffer is not None:
			caretPosition = self.caretPosition
			text = buffer[max(caretPosition - 5, 0):caretPosition]
			#print("HEEE", text, caretPosition)
			#sys.exit(1)
			c = UTF8BackLen(text)
			self.caretPosition += c
			if c < len(text): # i.e. anything is there
				count = UTF8CharLen(text[c])
				self.buffer.delete(self.caretPosition, count)
	def insert(self, c): # actually controller
		if self.buffer is not None:
			self.buffer.insert(self.caretPosition, c)
			#self.caretPosition += len(c) # done automatically by mark affinity
	buffer = property(lambda self: self.fBuffer, setBuffer)
	beginning = property(lambda self: self.fBeginning, setBeginning)
	frontier = property(lambda self: self.fFrontier)
	caretPosition = property(lambda self: self.fInsertionMark.position, setCaretPosition)
class KeyEvent(object):
	def __init__(self, charCode, modifiers = None):
		self.charCode = charCode
		self.modifiers = modifiers or set()
class TerminalResizeEvent(object):
	def __init__(self, width, height):
		self.width = width
		self.height = height
class MouseEvent(object):
	def __init__(self, button, x, y):
		self.button = button
		self.x = x
		self.y = y
class EventQueue(object):
	def __init__(self):
		self.fEntries = []
	def enqueue(self, event):
		self.fEntries.append(event)
	def shift(self):
		if len(self.fEntries) == 0:
			return(None)
		else:
			e = self.fEntries[0]
			self.fEntries = self.fEntries[1:]
			return(e)
class MonitoredList(object):
	def __init__(self):
		self.fList = []
		self.inserted = []
		self.removed = []
	def append(self, value):
		self.fList.append(value)
		for item in self.inserted:
			item(value)
def parseEscapeSequence(f):
	c = f.read(1)
	if c == "[":
		c = f.read(1)
		argumentBuffer = StringIO.StringIO()
		while c != "" and c in "0123456789;":
			argumentBuffer.write(c)
			c = f.read(1)
		argument = argumentBuffer.getvalue()
		# c is now a non-number
		if c == "~": # DEL etc
			if argument == "3":
				return KeyEvent(DELETE)
		elif c == "t": # report terminal size etc
			parts = argument.split(";")
			if len(parts) >= 3 and parts[0] == "8":
				height = int(parts[1])
				width = int(parts[2])
				return TerminalResizeEvent(width, height)
		elif c == "M": # mouse tracking
			raw_coordinates = f.read(3)
			raw_button, raw_x, raw_y = raw_coordinates
			button = ord(raw_button) - 32
			x = ord(raw_x) - ord("!")
			y = ord(raw_y) - ord("!")
			return MouseEvent(button, x, y)
		#elif c == "D": # cursor left
		#	return KeyEvent(CURSOR_LEFT)
	elif c >= "a" and c <= "z": # Alt-x
		return KeyEvent(c, set([ALT]))
	while c != "~" and (c < 'a' or c > 'z') and (c < 'A' or c > 'Z'):
		print(hex(ord(c))), 
		c = f.read(1)
class EventDispatcher(object):
	def __init__(self, inputFile, queue):
		self.fInputFile = inputFile
		self.B_quit = False
		self.fQueue = queue
		self.windows = MonitoredList() # TODO update currentWindow if removed here.
		def unsetter(item):
			if item == self.fCurrentWindow:
				self.fCurrentWindow = self.windows[0] if len(self.windows) > 0 else None
		def setter(item):
			if self.fCurrentWindow is None:
				self.fCurrentWindow = item
		self.windows.removed.append(unsetter)
		self.windows.inserted.append(setter)
		self.fCurrentWindow = None
	def run(self):
		while not self.B_quit:
			c = self.fInputFile.read(1)
			if c == "\x1B": # ESC
				self.fQueue.enqueue(parseEscapeSequence(self.fInputFile))
			elif len(c) > 0:
				self.fQueue.enqueue(KeyEvent(c if c != chr(127) else BACKSPACE))
			else: # weird...
				pass
			self.process()
	def process(self):
		while not self.B_quit:
			event = self.fQueue.shift()
			if event is None:
				break
			self.dispatch(event)		
	def dispatch(self, event):
		if hasattr(event, "charCode"):
			c = event.charCode
			if c == 'x' and ALT in event.modifiers:
				save(self.fCurrentWindow.buffer[:])
				self.B_quit = True
			else:
				self.fCurrentWindow.processEvent(event)
		else:
			self.fCurrentWindow.processEvent(event)
	def setCurrentWindow(self, value):
		self.fCurrentWindow = value
	currentWindow = property(lambda self: self.fCurrentWindow, setCurrentWindow)
if __name__ == "__main__":
	tty.setraw(0)
	#tty.setcbreak(1)
	terminalReader, terminalWriter = io.createTerminalIO(0, 1)
	terminalWriter.write("\033[2J") # clear screen
	terminalWriter.write("\033[?9h") # enable mouse
	terminalWriter.write("\033[7l") # disable line wrapping
	terminalWriter.flush()
	eventQueue = EventQueue()
	eventDispatcher = EventDispatcher(terminalReader, eventQueue)
	buffer = Buffer()
	width, height = io.getTerminalSize(0)
	controller = Controller(word_wrapper.ConsoleWordWrapper(width, MAX_HEIGHT, terminalWriter, height))
	controller.buffer = buffer
	eventDispatcher.windows.append(controller)
	#buffer.insert(0, "hello")
	try:
		controller.insert(load(sys.argv[1] if len(sys.argv) > 1 else None))
	except IOError:
		pass
	eventDispatcher.run()
	#print(buffer[1:10])
	terminalWriter.write("\033[?9l") # disable mouse
	terminalWriter.write("\033[7h") # enable line wrapping
	os.system("reset") # FIXME
