//
//  XTOutputTextHandler.m
//  TadsTerp
//
//  Created by Rune Berg on 28/03/14.
//  Copyright (c) 2014 Rune Berg. All rights reserved.
//

#import "XTOutputTextHandler.h"
#import "XTPrefs.h"
#import "XTCommandHistory.h"
#import "XTOutputFormatter.h"
#import "XTFormattedOutputElement.h"
#import "XTOutputTextParserPlain.h"
#import "XTOutputTextParserHtml.h"
#import "XTHtmlTag.h"
#import "XTHtmlTagBr.h"
#import "XTHtmlTagAboutBox.h"
#import "XTHtmlTagBanner.h"
#import "XTHtmlWhitespace.h"
#import "XTHtmlTagT2Hilite.h"
#import "XTLogger.h"
#import "XTStringUtils.h"


@interface XTOutputTextHandler ()

@property XTCommandHistory *commandHistory;

@property XTOutputTextParserPlain *outputTextParserPlain;
@property XTOutputTextParserHtml *outputTextParserHtml;
@property XTOutputFormatter *outputFormatter;

@property NSMutableArray *formattingQueue;

@property CGFloat maxTextViewHeightBeforePagination;
@property NSAttributedString *attributedStringThatBrokePaginationLimit;

@end


@implementation XTOutputTextHandler

const NSUInteger initialCommandPromptPosition = NSUIntegerMax;
const NSString *zeroWidthSpace = @"\u200B"; // non-printing

static XTLogger* logger;

@synthesize htmlMode = _htmlMode;
@synthesize nonstopMode = _nonstopMode;

+ (void)initialize
{
	logger = [XTLogger loggerForClass:[XTOutputTextHandler class]];
}

- (id)init
{
    self = [super init];
    if (self) {
		_commandHistory = [XTCommandHistory new];
		_outputTextParserPlain = [XTOutputTextParserPlain new];
		_outputTextParserHtml = [XTOutputTextParserHtml new];
		_outputFormatter = [XTOutputFormatter new];
		
		_formattingQueue = [NSMutableArray arrayWithCapacity:200];
		_nonstopMode = NO;
		_paginationActive = YES;
		// no text entry before first input prompt:
		_commandPromptPosition = initialCommandPromptPosition;
		_htmlMode = NO;
		_gameTitle = [NSMutableString stringWithString:@""];
		_maxTextViewHeightBeforePagination = 0.0;
		_attributedStringThatBrokePaginationLimit = nil;
		
		// Listen to KVO events from prefs - see observeValueForKeyPath:... below
		XTPrefs *prefs = [XTPrefs prefs];
		[prefs startObservingChangesToAll:self];

		_outputTextParserHtml.printBrokenHtmlMarkup = prefs.printBrokenHtmlMarkup.boolValue;
		
		//[self resetFlags];
    }
    return self;
}

- (void)dealloc
{
	//TODO why not called?
	// Stop listening to KVO events from prefs:
	XTPrefs *prefs = [XTPrefs prefs];
	[prefs stopObservingChangesToAll:self];
}

+ (instancetype)handler
{
	XTOutputTextHandler *handler = [[XTOutputTextHandler alloc] init];
	return handler;
}

- (BOOL)htmlMode
{
	return _htmlMode;
}

- (void)setHtmlMode:(BOOL)htmlMode
{
	XT_DEF_SELNAME;
	XT_TRACE_1(@"%d", htmlMode);

	_htmlMode = htmlMode;
	self.outputFormatter.htmlMode = htmlMode;
}

- (void)setHiliteMode:(BOOL)hiliteMode
{
	XT_DEF_SELNAME;
	XT_TRACE_1(@"%d", hiliteMode);

	// represent hilite mode on/off by a special tag object
	XTHtmlTagT2Hilite *t2HiliteTag = [XTHtmlTagT2Hilite new];
	t2HiliteTag.closing = (! hiliteMode);
	[self.formattingQueue addObject:t2HiliteTag];
}

- (BOOL)nonstopMode
{
	return _nonstopMode;
}

- (void)setNonstopMode:(BOOL)nonstopMode
{
	XT_DEF_SELNAME;
	XT_TRACE_1(@"%d", nonstopMode);
	
	_nonstopMode = nonstopMode;
	_paginationActive = ! _nonstopMode;
}

- (void)resetToDefaults
{
	// called when game file loads and starts
	
	self.htmlMode = NO;
	self.hiliteMode = NO;
	self.nonstopMode = NO;
	self.paginationActive = YES;
	
	[self clearText];
	[self.outputTextParserPlain resetForNextCommand];
	[self.outputTextParserHtml resetForNextCommand];
	[self.formattingQueue removeAllObjects];
	
	self.gameTitle = [NSMutableString stringWithString:@""];

	self.attributedStringThatBrokePaginationLimit = nil;
}

- (void)resetForNextCommand
{
	[[self getOutputTextParser] flush];
	self.paginationActive = ! self.nonstopMode;
	[self.outputTextParserHtml resetForNextCommand];
	[self.outputTextParserPlain resetForNextCommand];
	[self.formattingQueue removeAllObjects];
	[self.outputFormatter resetForNextCommand];
	[self ensureInputFontIsInEffect];
	[self noteStartOfPagination];
	self.attributedStringThatBrokePaginationLimit = nil;
}

- (void)resetForGameHasEndedMsg
{
	[[self getOutputTextParser] flush];
	self.paginationActive = ! self.nonstopMode;
	[self.outputTextParserHtml resetForNextCommand];
	[self.outputTextParserPlain resetForNextCommand];
	[self.formattingQueue removeAllObjects];
	[self.outputFormatter resetFlags];
	[self noteStartOfPagination];
	self.attributedStringThatBrokePaginationLimit = nil;
}

- (void)noteStartOfPagination
{
	XT_DEF_SELNAME;
	
	[self moveCursorToEndOfOutputPosition];
	
	CGFloat textViewVisibleHeight = self.outputTextScrollView.documentVisibleRect.size.height;
	XT_TRACE_1(@"textViewVH=%f", textViewVisibleHeight);
	
	CGFloat textViewHeight = [self.outputTextView findHeight];  // height of text regardless of visible portion
	XT_TRACE_1(@"textViewHeight=%f", textViewHeight);

	NSFont *currentFontForOutput = [self.outputFormatter getCurrentFontForOutput];
	NSUInteger currentFontHeight = currentFontForOutput.pointSize;
	NSUInteger verticalInset = (NSUInteger)self.outputTextView.topBottomInset;
	
	CGFloat toAdd;
	if (textViewHeight > textViewVisibleHeight) {
		// If we've filled at least a screenfull
		NSUInteger uintToAdd = textViewVisibleHeight;
		uintToAdd -= verticalInset;
		uintToAdd -= currentFontHeight; // ensure a bit of overlap
		uintToAdd -= (uintToAdd % currentFontHeight); // compensate for partially visible lines
		toAdd = uintToAdd;
	} else {
		// Before we've filled the first screenfull
		CGFloat yCoordBottomOfText = [self.outputTextView findYCoordOfInsertionPoint]; // reverse y-axis: 0 is top
		NSUInteger uintYCoordBottomOfText = yCoordBottomOfText;
		NSUInteger uintToAdd;
		if (uintYCoordBottomOfText >= currentFontHeight) {
			uintToAdd = uintYCoordBottomOfText;
			XT_TRACE_1(@"uintToAdd = %lu (uintYCoordBottomOfText)", uintYCoordBottomOfText);
			uintToAdd -= currentFontHeight;
			XT_TRACE_1(@"uintToAdd -= %lu (currentFontHeight)", currentFontHeight);
			NSUInteger partiallyVisibleLineHeight = (uintToAdd % currentFontHeight);
			uintToAdd -= partiallyVisibleLineHeight;  // compensate for partially visible lines
			XT_TRACE_1(@"uintToAdd -= %lu (partiallyVisibleLineHeight)", partiallyVisibleLineHeight);
			if (uintToAdd > verticalInset) {
				XT_TRACE_1(@"uintToAdd -= %lu (verticalInset)", verticalInset);
				uintToAdd -= verticalInset;
			}
		} else {
			// just in case
			uintToAdd = uintYCoordBottomOfText;
			uintToAdd -= (uintToAdd % currentFontHeight);
		}
		toAdd = uintToAdd;
	}
	XT_TRACE_1(@"toAdd=%f", toAdd);
	if (toAdd < 0) {
		XT_ERROR_1(@"toAdd=%f", toAdd);
	}
	CGFloat oldMTVHBP = self.maxTextViewHeightBeforePagination;
	self.maxTextViewHeightBeforePagination = textViewHeight + toAdd;

	XT_TRACE_2(@"maxTextVHBP %f -> %f", oldMTVHBP, self.maxTextViewHeightBeforePagination);
}

- (NSString *)getCommand
{
	NSTextStorage *ts = [self.outputTextView textStorage];
	NSInteger tsLength = ts.length;
	NSInteger commandLength = tsLength - self.commandPromptPosition - 1;
	
	NSRange range = NSMakeRange(self.commandPromptPosition + 1, commandLength);
	NSAttributedString *ats = [ts attributedSubstringFromRange:range];
	NSString *command = ats.string;
	
	[self.commandHistory appendCommand:command];
	
	return command;
}

- (void)clearText
{
	// also gets called when game clears screen
	
	XT_DEF_SELNAME;
	
	[[self getOutputTextParser] flush];
	[self.outputTextParserPlain resetForNextCommand];
	[self.outputTextParserHtml resetForNextCommand];

	[self.outputFormatter resetFlags];
	
	[[[self.outputTextView textStorage] mutableString] setString:@""];

	// Insert some initial, invisible text to get font height set and paging calculations correct from the start:
	NSArray *formattedOutputElements = [self.outputFormatter formatElement:zeroWidthSpace];
	XTFormattedOutputElement *elt = [formattedOutputElements objectAtIndex:0];
	NSAttributedString *attrStr = elt.attributedString;
	[self appendAttributedStringToTextStorage:attrStr];
	[self.outputFormatter resetFlags]; // get rid of state due to the zws 
	
	[self.outputTextView scrollPageUp:self]; // needed to ensure new text isn't initially "scrolled past"
	[self moveCursorToEndOfOutputPosition];

	self.commandPromptPosition = initialCommandPromptPosition;

	self.maxTextViewHeightBeforePagination = 0.0;
	self.attributedStringThatBrokePaginationLimit = nil;
	
	[self noteStartOfPagination];
	
	[self.formattingQueue removeAllObjects];
	
	XT_TRACE_0(@"done");
}

- (void)flushOutput
{
	XT_TRACE_ENTRY;
	
	NSArray *parseResultArray = [[self getOutputTextParser] flush];
	[self.formattingQueue addObjectsFromArray:parseResultArray];
	[self processFormattingQueue];
}

- (void)appendInput:(NSString *)string
{
	// Note: this is called for paste event
	
	//TODO retest for paste etc.
	if (! [self canAppendNonTypedInput]) {
		return;
	}
	
	NSAttributedString *attrString = [self.outputFormatter formatInputText:string];
	[self appendAttributedStringToTextStorage:attrString];
}

//TODO mv down
// Allow appending pasted text, text from clicked command link, etc. ?
- (BOOL)canAppendNonTypedInput
{
	BOOL res = YES;
	if (! self.gameWindowController.gameIsRunning) {
		res = NO;
	}
	if ([self.gameWindowController isWaitingForKeyPressed]) {
		res = NO;
	}
	return res;
}

- (BOOL)handleCommandLinkClicked:(NSString *)linkString atIndex:(NSUInteger)charIndex
{
	BOOL commandEntered = NO;

	if (! [self canAppendNonTypedInput]) {
		return commandEntered;
	}
	
	NSRange proposedRange = NSMakeRange(charIndex, 1);
	NSRange actualRange;
	NSAttributedString *as = [self.outputTextView attributedSubstringForProposedRange:proposedRange
																		  actualRange:&actualRange];
	id appendAttr = [as attribute:XT_OUTPUT_FORMATTER_ATTR_CMDLINK_APPEND atIndex:0 effectiveRange:nil];
	BOOL append = (appendAttr != nil);
	id noenterAttr = [as attribute:XT_OUTPUT_FORMATTER_ATTR_CMDLINK_NOENTER atIndex:0 effectiveRange:nil];
	BOOL noenter = (noenterAttr != nil);
	
	if (! append) {
		[self replaceCommandText:linkString];
	} else {
		NSAttributedString *attrLinkString = [self.outputFormatter formatInputText:linkString];
		[self appendAttributedStringToTextStorage:attrLinkString];
	}
	
	[self moveCursorToEndOfOutputPosition];
	
	return (! noenter);
}

- (void)ensureInputFontIsInEffect
{
	XTPrefs *prefs = [XTPrefs prefs];
	if (prefs.inputFontUsedEvenIfNotRequestedByGame.boolValue) {
		[self appendInput:zeroWidthSpace];
		[self noteEndOfOutput];
	}
}

- (void)moveCursorToEndOfOutputPosition
{
	NSUInteger index = [self endOfOutputPosition];
	[self.outputTextView setSelectedRange:NSMakeRange(index, 0)];
}

- (BOOL)appendOutput:(NSString *)string
{
	XT_DEF_SELNAME;
	XT_TRACE_0(@"-------------------------------------------------------------");
	XT_TRACE_1(@"\"%@\"", string);

	NSArray *parseResultArray = [[self getOutputTextParser] parse:string];
	[self.formattingQueue addObjectsFromArray:parseResultArray];

	BOOL excessiveAmountBuffered = (self.formattingQueue.count >= 1000);
	
	return excessiveAmountBuffered;
}

- (BOOL)pumpOutput
{
	XT_DEF_SELNAME;
	//XT_TRACE_0(@"");

	BOOL needMorePrompt = [self processFormattingQueue];
	return needMorePrompt;
}

// the index where new output text should be appended
- (NSUInteger)endOfOutputPosition
{
	return [self.outputTextView textStorage].length;
}

// the index where new input text is appended
- (NSInteger)insertionPoint
{
	NSRange r = [self.outputTextView selectedRange];
	return r.location;
}

- (NSInteger)minInsertionPoint
{
	NSInteger res = self.commandPromptPosition + 1;
	return res;
}

- (BOOL)allowTextInsertion:(NSRange)affectedCharRange
{
	NSInteger minInsPt = [self minInsertionPoint];
	BOOL res = (affectedCharRange.location >= minInsPt);
	return res;
}

- (void)goToPreviousCommand
{
	NSString *previousCommand = [self.commandHistory getPreviousCommand];
	if (previousCommand != nil) {
		[self replaceCommandText:previousCommand];
	}
}

- (void)goToNextCommand
{
	NSString *newCommandText = [self.commandHistory getNextCommand];
	if (newCommandText == nil) {
		if ([self.commandHistory hasBeenAccessed]) {
			// we're back out of the historic commands
			newCommandText = @"";
			//TODO better: replace with command that was *being typed*
			//		- requires capturing that conmand on every keystroke
			[self.commandHistory resetHasBeenAccessed];
		}
	}
	if (newCommandText != nil) {
		[self replaceCommandText:newCommandText];
	}
}

- (void)replaceCommandText:(NSString *)newCommandText
{
	NSRange commandTextRange = [self getCommandTextRange];
	[self removeFromTextStorage:commandTextRange];
	NSAttributedString *attrString = [self.outputFormatter formatInputText:newCommandText];
	[self appendAttributedStringToTextStorage:attrString];
}

//=========  Internal functions  =======================================

- (id<XTOutputTextParserProtocol>)getOutputTextParser
{
	id<XTOutputTextParserProtocol> res = (self.htmlMode ? self.outputTextParserHtml : self.outputTextParserPlain);
	return res;
}

- (NSRange)getCommandTextRange
{
	NSUInteger minInsertionPoint = self.minInsertionPoint;
	NSUInteger endOfOutputPosition = self.endOfOutputPosition;
	NSRange commandTextRange = NSMakeRange(minInsertionPoint, endOfOutputPosition - minInsertionPoint);
	return commandTextRange;
}

- (void)noteEndOfOutput
{
	// find new starting pos of cmd prompt
	NSTextStorage *ts = [self.outputTextView textStorage];
	NSInteger tsLength = ts.length;
	self.commandPromptPosition = (tsLength > 0 ? tsLength - 1 : 0);
}

- (BOOL)processFormattingQueue
{
	XT_DEF_SELNAME;
	//XT_TRACE_1(@"\"%lu\" elements", parseResultArray.count);
	
	if (self.attributedStringThatBrokePaginationLimit != nil) {
		// print the text that brought us over the limit last time
		[self appendAttributedStringToTextStorage:self.attributedStringThatBrokePaginationLimit];
		self.attributedStringThatBrokePaginationLimit = nil;
	}
	
	BOOL reachedPaginationLimit = NO;
	
	XT_TRACE_1(@"entry formattingQueue.count=%lu", self.formattingQueue.count);
	
	if (self.formattingQueue.count == 0) {
		return reachedPaginationLimit;
	}
	
	while ([self.formattingQueue count] >= 1 && ! reachedPaginationLimit) {
		
		id parsedElement = [self.formattingQueue firstObject];
		[self.formattingQueue removeObjectAtIndex:0];
		
		NSArray *formattedOutputElements = [self.outputFormatter formatElement:parsedElement];
		
		NSAttributedString *lastAttrStringAppended = nil;
		
		for (XTFormattedOutputElement *outputElement in formattedOutputElements) {
			
			if ([outputElement isRegularOutputElement]) {
				lastAttrStringAppended = outputElement.attributedString;
				[self appendAttributedStringToTextStorage:lastAttrStringAppended];
				
			} else if ([outputElement isGameTitleElement]) {
				if ([outputElement.attributedString.string isEqualToString:@"{{clear}}"]) {
					//TODO make element type for this case
					self.gameTitle = [NSMutableString stringWithString:@""];
				} else {
					[self.gameTitle appendString:outputElement.attributedString.string];
				}
				
			} else if ([outputElement isRemoveTabsToStartOfLineElement]) {
				[self removeWhitespaceToStartOfLine];
				
			} else {
				XT_ERROR_1(@"unknown XTFormattedOutputElement %d", outputElement.elementType);
			}
		}
		
		if (lastAttrStringAppended == nil) {
			// no text was added
			continue;
		}
		
		if (self.paginationActive) {
			reachedPaginationLimit = [self recalcPagination];
			if (reachedPaginationLimit) {
				// remove the text that brought us over the limit, but rememember it so we can print it next time
				XT_TRACE_1(@"reachedPaginationLimit for \"%@\"", lastAttrStringAppended.string);
				[self removeAttributedStringFromEndOfTextStorage:lastAttrStringAppended];
				self.attributedStringThatBrokePaginationLimit = lastAttrStringAppended;
			}
		}
	}
	
	[self trimScrollbackBuffer];

	[self scrollToEnd];
	
	[self noteEndOfOutput];

	XT_TRACE_1(@"exit formattingQueue.count=%lu", self.formattingQueue.count);
	
	return reachedPaginationLimit;
}

- (void)trimScrollbackBuffer
{
	XT_DEF_SELNAME;
	
	XTPrefs *prefs = [XTPrefs prefs];
	
	if (! prefs.limitScrollbackBufferSize.boolValue) {
		return;
	}
	
	NSUInteger scrollbackBufferSize = 1000 * prefs.scrollbackBufferSizeInKBs.unsignedIntegerValue;
	
	NSTextStorage *ts = [self.outputTextView textStorage];
	NSUInteger tsSize = ts.length;
	
	XT_TRACE_1(@"tsSize=%lu", tsSize);
	
	if (tsSize > scrollbackBufferSize) {
		NSUInteger excess = tsSize - scrollbackBufferSize;
		NSUInteger deleteBlockSize = 20000; // so we only delete if in excess by a goodish amount
		if (excess > deleteBlockSize) {
			CGFloat oldTextViewHeight = [self.outputTextView findHeight];
			NSUInteger toDelete = excess - (excess % deleteBlockSize);
			NSRange rangeToDelete = NSMakeRange(0, toDelete);
			[ts deleteCharactersInRange:rangeToDelete];
			NSUInteger tsSizeAfterDelete = ts.length;
			XT_TRACE_2(@"excess=%lu, tsSize -> %lu", excess, tsSizeAfterDelete);
			//https://developer.apple.com/library/ios/documentation/Cocoa/Conceptual/AttributedStrings/Tasks/ChangingAttrStrings.html
			NSRange rangeToFix = NSMakeRange(0, ts.length);
			[ts fixAttributesInRange:rangeToFix];
			
			// deleting from the text store affects state used for pagination, so:
			CGFloat newTextViewHeight = [self.outputTextView findHeight];
			CGFloat trimmedTextViewHeight = (oldTextViewHeight - newTextViewHeight);
				// this isn't 100% correct in all cases, but let's stay on the sane side ;-)
			if (trimmedTextViewHeight <= 0.0) {
				XT_ERROR_1(@"trimmedTextViewHeight was %f, setting it to 0.0", trimmedTextViewHeight);
				trimmedTextViewHeight = 0.0;
			}
			XT_TRACE_1(@"trimmedTextViewHeight", trimmedTextViewHeight);
			self.maxTextViewHeightBeforePagination -= trimmedTextViewHeight;
			XT_TRACE_1(@"maxTextViewHeightBeforePagination", self.maxTextViewHeightBeforePagination);
		}
	}
}

- (void)scrollToEnd
{
	[self.outputTextView scrollRangeToVisible:NSMakeRange(self.outputTextView.string.length, 0)];
}

- (BOOL)recalcPagination
{
	XT_DEF_SELNAME;
	
	[self moveCursorToEndOfOutputPosition];
	
	CGFloat newTextViewHeight = [self.outputTextView findHeight];
	//XT_TRACE_2(@"newTextViewHeight=%f self.maxTextViewHeightBeforePagination=%f", newTextViewHeight, self.maxTextViewHeightBeforePagination);
	
	CGFloat exceededBy = newTextViewHeight - self.maxTextViewHeightBeforePagination;
	
	BOOL res = (exceededBy > 0.0);
	if (res) {
		XT_TRACE_1(@"--> YES, exceeded by %f", exceededBy);
	}
	
	return res;
}

//------  text storage manipulation  ---------

- (void)appendAttributedStringToTextStorage:(NSAttributedString *)attrString
{
	XT_DEF_SELNAME;

	if (attrString == nil || attrString.length == 0) {
		return;
	}

	XT_TRACE_1(@"\"%@\"", attrString.string);
	
    NSTextStorage *ts = [self.outputTextView textStorage];
	
	NSUInteger insertionIndexBefore = ts.length;
    [ts appendAttributedString:attrString];

	// Apply temporary attributes:
	NSDictionary *attrDict = [attrString attributesAtIndex:0 effectiveRange:nil];
	if (attrDict != nil) {
		NSDictionary *tempAttrDict = attrDict[XT_OUTPUT_FORMATTER_ATTR_TEMPATTRSDICT];
		if (tempAttrDict != nil && tempAttrDict.count >= 1) {
			NSUInteger insertionIndexAfter = ts.length;
			NSRange range = NSMakeRange(insertionIndexBefore, insertionIndexAfter - insertionIndexBefore);
			[self.outputTextView.layoutManager addTemporaryAttributes:tempAttrDict forCharacterRange:range];
		}
	}
}

- (void)removeAttributedStringFromEndOfTextStorage:(NSAttributedString *)attrString
{
	NSTextStorage *ts = [self.outputTextView textStorage];
	NSUInteger tsLen = [ts length];
	NSUInteger numToRemove = [attrString length];
	NSRange rangeToRemove = NSMakeRange(tsLen - numToRemove, numToRemove);
	[self removeFromTextStorage:rangeToRemove];
}

- (void)removeFromTextStorage:(NSRange)range
{
	XT_DEF_SELNAME;
	XT_TRACE_2(@"%lu %lu", range.location, range.length);
	
    NSTextStorage *ts = [self.outputTextView textStorage];

	[ts deleteCharactersInRange:range];
}

- (void)removeWhitespaceToStartOfLine
{
	NSMutableString *s = [[self.outputTextView textStorage] mutableString];
	NSRange rangeWhitespaceAfterLastNewline = [XTStringUtils findRangeOfWhitespaceAfterLastNewline:s];
	if (rangeWhitespaceAfterLastNewline.location != NSNotFound) {
		[s deleteCharactersInRange:rangeWhitespaceAfterLastNewline];
	}
}

//------- KVO on Prefs object -------

- (void)observeValueForKeyPath:(NSString *)keyPath
					  ofObject:(id)object
						change:(NSDictionary *)change
					   context:(void *)context
{
	XT_DEF_SELNAME;
	XT_TRACE_1(@"keyPath=\"%@\"", keyPath);

	XTPrefs *prefs = [XTPrefs prefs];
	self.outputTextParserHtml.printBrokenHtmlMarkup = prefs.printBrokenHtmlMarkup.boolValue;
}

@end
