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

#import "XTOutputTextHandler.h"
#import "XTNotifications.h"
#import "XTPrefs.h"
#import "XTOutputFormatter.h"
#import "XTFormattedOutputElement.h"
#import "XTOutputTextParserPlain.h"
#import "XTOutputTextParserHtml.h"
#import "XTHtmlTag.h"
#import "XTHtmlTagBr.h"
#import "XTHtmlTagAboutBox.h"
#import "XTHtmlTagTitle.h"
#import "XTHtmlTagBanner.h"
#import "XTHtmlWhitespace.h"
#import "XTHtmlTagT2Hilite.h"
#import "XTLogger.h"
#import "XTStringUtils.h"
#import "XTBannerHandler.h"
#import "XTHtmlQuotedSpace.h"
#import "XTHtmlSpecialSpace.h"
#import "XTHtmlNonbreakingSpace.h"
#import "XTAllocDeallocCounter.h"


@interface XTOutputTextHandler ()

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

@property NSMutableArray *formattingQueue;

@property CGFloat maxTextViewHeightBeforePagination;
@property CGFloat visibleHeightBeforeLayoutOfViews;
@property CGFloat totalHeightBeforeLayoutOfViews;
@property NSAttributedString *attributedStringThatBrokePaginationLimit;

@end


@implementation XTOutputTextHandler

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

static XTLogger* logger;

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

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

OVERRIDE_ALLOC_FOR_COUNTER

OVERRIDE_DEALLOC_FOR_COUNTER

- (id)init
{
	XT_DEF_SELNAME;

	self = [super init];
    if (self) {
		_commandHistory = [XTCommandHistory new];
		_outputTextParserPlain = [XTOutputTextParserPlain new];
		_outputTextParserHtml = [XTOutputTextParserHtml new];
		_outputFormatter = [XTOutputFormatter new];
		_formattingQueue = [NSMutableArray arrayWithCapacity:200];
		_activeTagBannerHandle = nil;
		_nonstopMode = NO;
		_paginationActive = YES;
		// no text entry before first input prompt:
		_commandPromptPosition = initialCommandPromptPosition;
		_htmlMode = NO;
		_statusLineMode = NO;
		_gameTitle = [NSMutableString stringWithString:@""];
		_maxTextViewHeightBeforePagination = 0.0;
		_visibleHeightBeforeLayoutOfViews = 0.0;
		_totalHeightBeforeLayoutOfViews = 0.0;
		_attributedStringThatBrokePaginationLimit = nil;
		
		//[self resetFlags];
		
		[self setupReceptionOfAppLevelNotifications];
    }
    return self;
}

- (void)removeHandler
{
	XT_DEF_SELNAME;
	XT_TRACE_0(@"");

	[self.outputFormatter teardown];
	self.outputFormatter = nil;
	
	[self.outputTextView teardown];
	self.outputTextView = nil;
	
	[self teardownReceptionOfAppLevelNotifications];

	self.outputTextScrollView = nil;
	self.gameWindowController = nil;
	self.gameTitle = nil;
	self.commandHistory = nil;
	self.activeTagBannerHandle = nil;
	
	[self.outputTextParserPlain teardown];
	[self.outputTextParserHtml teardown];
	
	[self.formattingQueue removeAllObjects];
	self.formattingQueue = nil;
	
	self.attributedStringThatBrokePaginationLimit = nil;
}

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

- (void)setOutputTextView:(XTOutputTextView *)outputTextView
{
	_outputTextView = outputTextView;
	_outputFormatter.textView = outputTextView;
	_outputTextView.outputFormatter = _outputFormatter;
}

- (XTOutputTextView *)outputTextView
{
	return _outputTextView;
}

- (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)setIsForT3:(BOOL)isForT3
{
	self.outputFormatter.isForT3 = isForT3;
}

- (void)resetToDefaults
{
	// called when game file loads and starts
	
	self.htmlMode = NO;
	self.hiliteMode = NO;
	self.nonstopMode = NO;
	self.paginationActive = YES;
	self.statusLineMode = NO;
	
	[self clearText];
	[self.outputTextParserPlain resetForNextCommand];
	[self.outputTextParserHtml resetForNextCommand];
	[self.formattingQueue removeAllObjects];
	self.activeTagBannerHandle = nil;
	
	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)mainThread_noteStartOfLayoutOfViews
{
	XT_DEF_SELNAME;

	self.visibleHeightBeforeLayoutOfViews = [self.outputTextView findVisibleHeight];
	self.totalHeightBeforeLayoutOfViews = [self.outputTextView findTotalHeight];
	XT_TRACE_1(@"visibleHeightBeforeLayoutOfViews=%f", self.visibleHeightBeforeLayoutOfViews);
	XT_TRACE_1(@"totalHeightBeforeLayoutOfViews=%f", self.totalHeightBeforeLayoutOfViews);
}

- (void)mainThread_noteEndOfLayoutOfViews
{
	XT_DEF_SELNAME;
	XT_TRACE_0(@"ENTER");
	
	if (self.visibleHeightBeforeLayoutOfViews > 0.0) {
		CGFloat visibleHeightAfterLayoutOfViews = [self.outputTextView findVisibleHeight];
		if (visibleHeightAfterLayoutOfViews > 0.0) {
			
			CGFloat totalHeightAfterLayoutOfViews = [self.outputTextView findTotalHeight];
			CGFloat diffTotalHeight = totalHeightAfterLayoutOfViews - self.totalHeightBeforeLayoutOfViews;
			if (diffTotalHeight != 0.0) {
				int brkpt = 1;
			}
			CGFloat oldMTVHBP = self.maxTextViewHeightBeforePagination;
			self.maxTextViewHeightBeforePagination += diffTotalHeight;
			XT_TRACE_2(@"maxTextViewHeightBeforePagination=%f (was %f)", self.maxTextViewHeightBeforePagination, oldMTVHBP);
			
			if (totalHeightAfterLayoutOfViews > visibleHeightAfterLayoutOfViews) {
				CGFloat diffVisibleHeight = visibleHeightAfterLayoutOfViews - self.visibleHeightBeforeLayoutOfViews;
				if (diffVisibleHeight < 0.0) {
					// visible height has shrunk as result of layout
					CGFloat oldMTVHBP = self.maxTextViewHeightBeforePagination;
					self.maxTextViewHeightBeforePagination += diffVisibleHeight;
					XT_TRACE_2(@"maxTextViewHeightBeforePagination=%f (was %f)", self.maxTextViewHeightBeforePagination, oldMTVHBP);
				}
			}
		}
		//XT_TRACE_1(@"totalHeight=%f", [self.outputTextView findTotalHeight]);
	}
	self.visibleHeightBeforeLayoutOfViews = 0.0;
	
	[self scrollToBottom];
}

- (void)noteStartOfPagination
{
	XT_DEF_SELNAME;
	XT_TRACE_0(@"ENTER");
	
	[self moveCursorToEndOfOutputPosition];
	
	CGFloat textViewVisibleHeight = [self.outputTextView findVisibleHeight];
	XT_TRACE_1(@"textViewVisibleHeight=%f", textViewVisibleHeight);
	
	CGFloat textViewHeight = [self.outputTextView findTotalHeight];  // 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;
	
	self.visibleHeightBeforeLayoutOfViews = 0.0;
	self.totalHeightBeforeLayoutOfViews = 0.0;

	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 temporary, 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.visibleHeightBeforeLayoutOfViews = 0.0;
	self.totalHeightBeforeLayoutOfViews = 0.0;
	self.attributedStringThatBrokePaginationLimit = nil;
	
	[self noteStartOfPagination];
	
	// Remove the invisible text we added earlier, so that than we haven't "used up"
	// the text alignment of the first paragraph:
	[self removeLastCharFromTextStorage];
	
	[self.formattingQueue removeAllObjects];
	
	XT_TRACE_0(@"done");
}

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

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

- (void)appendInput:(NSString *)string
{
	// Note: this is called for paste event
	
	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;
}

- (void)handleCommandLinkClicked:(NSString *)linkText atIndex:(NSUInteger)charIndex
{
	if (! [self canAppendNonTypedInput]) {
		return;
	}
	
	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:linkText];
	} else {
		NSAttributedString *attrLinkString = [self.outputFormatter formatInputText:linkText];
		[self appendAttributedStringToTextStorage:attrLinkString];
	}
	
	[self moveCursorToEndOfOutputPosition];
	
	if (! noenter) {
		[self.outputTextView.delegate textView:self.outputTextView doCommandBySelector:@selector(insertNewline:)];
	}
}

- (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);
	if (excessiveAmountBuffered) {
		int brkpt = 1;
	}
	
	return excessiveAmountBuffered;
}

- (BOOL)pumpOutput
{
	//XT_DEF_SELNAME;

	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.activeTagBannerHandle != nil) {
		XTBannerHandler *bannerHandler = [self.gameWindowController bannerHandlerForHandle:self.activeTagBannerHandle];
		[bannerHandler processFormattingQueueFromMainOutput:self.formattingQueue];
			// this will consume queue until </banner> or queue empty
	}
	
	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];
		
		if ([self shouldSkipElementInStatusLineMode:parsedElement]) {
			// Skip this parsedElement.
			// Not how HTML TADS behaves, but good enough.
			// TODO? should ideally impl "pending whitespace queue".
			continue;
		}
		
		NSArray *formattedOutputElements = [self.outputFormatter formatElement:parsedElement];
		
		NSAttributedString *lastAttrStringAppended = nil;
		
		for (XTFormattedOutputElement *outputElement in formattedOutputElements) {
			
			if ([outputElement isRegularOutputElement]) {
				lastAttrStringAppended = outputElement.attributedString;
				[self appendAttributedStringToTextStorage:lastAttrStringAppended];
				//TODO call ensure...
				
			} else if ([outputElement isTabElement]) {
				XTHtmlTagTab *tagTab = (XTHtmlTagTab *)outputElement.htmlTag;
				NSArray *tabFormattedOutputElements = [self.outputFormatter handleHtmlTagTabDelayed:tagTab];
				if (tabFormattedOutputElements.count >= 1) {
					XT_ERROR_1(@"tabFormattedOutputElements.count = %lu", tabFormattedOutputElements.count);
				}
			
			} 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 isBannerStartElement]) {
				[self handleBannerTagStart:outputElement];
				
			} else {
				XT_ERROR_1(@"unknown XTFormattedOutputElement %d", outputElement.elementType);
			}
		}
		
		if (lastAttrStringAppended == nil) {
			// no text was added
			continue;
		}

		[self.outputTextView ensureLayoutForTextContainer]; // or else frame rect isn't updated
		
		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 scrollToBottom];
	
	[self noteEndOfOutput];

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

- (void)flushFormattingQueue
{
	NSArray *wsArray = [self.outputFormatter flushPendingWhitespace];
	for (XTFormattedOutputElement *wsFmtElt in wsArray) {
		[self appendAttributedStringToTextStorage:wsFmtElt.attributedString];
	}
}

- (BOOL)shouldSkipElementInStatusLineMode:(id)parsedElement {

	BOOL res = NO;
	
	if (self.statusLineMode) {
		res = YES;

		if ([parsedElement isKindOfClass:[NSString class]]) {
			res = [self shouldSkipTextOrWhitespaceInStatusLineMode];
		 
		} else if ([parsedElement isKindOfClass:[XTHtmlWhitespace class]]) {
			res = [self shouldSkipTextOrWhitespaceInStatusLineMode];
		 
		} else if ([parsedElement isKindOfClass:[XTHtmlTagBanner class]]) {
			XTHtmlTagBanner *tagBanner = (XTHtmlTagBanner *)parsedElement;
			if (! tagBanner.closing) {
				res = NO;
			}
		} else if ([parsedElement isKindOfClass:[XTHtmlTagAboutBox class]]) {
			res = NO;
			
		} else if ([parsedElement isKindOfClass:[XTHtmlTagTitle class]]) {
			res = NO;

		} else if ([parsedElement isKindOfClass:[XTHtmlQuotedSpace class]]) {
			res = [self shouldSkipTextOrWhitespaceInStatusLineMode];
			
		} else if ([parsedElement isKindOfClass:[XTHtmlSpecialSpace class]]) {
			res = [self shouldSkipTextOrWhitespaceInStatusLineMode];
			
		} else if ([parsedElement isKindOfClass:[XTHtmlNonbreakingSpace class]]) {
			res = [self shouldSkipTextOrWhitespaceInStatusLineMode];
		}
	}
	
	return res;
}

- (BOOL)shouldSkipTextOrWhitespaceInStatusLineMode
{
	BOOL res = (! self.outputFormatter.receivingGameTitle);
	return res;
}

- (void)handleBannerTagStart:(XTFormattedOutputElement *)outputElement
{
	XT_DEF_SELNAME;

	BOOL removeAllBanners = [outputElement.htmlTag hasAttribute:@"removeall"];
	if (removeAllBanners) {
		[self.gameWindowController bannerDeleteAll];
		return;
	}
	
	NSString *tagId = [outputElement.htmlTag attributeAsString:@"id"];
	if (tagId == nil || tagId.length == 0) {
		tagId = @"xtads-id-less-banner";
		XT_TRACE_0(@"<banner> has no id attribute - using a default id");
	}
	
	void *bannerHandle = [self.gameWindowController bannerHandleForTagId:tagId];
	
	BOOL removeOneBanner = [outputElement.htmlTag hasAttribute:@"remove"];
	if (removeOneBanner) {
		if (bannerHandle != NULL) {
			[self.gameWindowController bannerDelete:bannerHandle];
		} else {
			XT_WARN_1(@"Cannot remove non-existent banner with tagId %@", tagId);
		}
		return;
	}

	NSString *alignStr = [outputElement.htmlTag attributeAsString:@"align"];
	NSInteger align = [self bannerAlignmentFrom:alignStr];

	NSInteger sizeUnits = OS_BANNER_SIZE_ABS;
	NSInteger size = 0;
	BOOL sizeToContents = YES;
	BOOL sizeAsPrevious = NO;
	NSString *sizeAttrName = @"height";
	if ((align == OS_BANNER_ALIGN_LEFT) || (align == OS_BANNER_ALIGN_RIGHT)) {
		sizeAttrName = @"width";
	}
	NSString *sizeStr = [outputElement.htmlTag attributeAsString:sizeAttrName];
	[self extractTagBannerSizeFrom:sizeStr
						  attrName:sizeAttrName
					sizeToContents:&sizeToContents
					sizeAsPrevious:&sizeAsPrevious
							  size:&size
						 sizeUnits:&sizeUnits];
	
	NSInteger style = 0;
	if ([outputElement.htmlTag hasAttribute:@"border"]) {
		style |= OS_BANNER_STYLE_BORDER;
	}
	
	XTBannerHandler *bannerHandler;
	
	if (bannerHandle == NULL) {
	
		void *parent = 0; // parent is always"root banner", i.e. main output area
		NSInteger where = OS_BANNER_LAST;
		void *other = 0;
		NSInteger wintype = OS_BANNER_TYPE_TEXT;

		bannerHandle = [self.gameWindowController bannerCreate:parent
														 tagId:tagId
														 where:where
														 other:other
													   wintype:wintype
														 align:align
														  size:size
													 sizeUnits:sizeUnits
														 style:style];
		
		bannerHandler = [self.gameWindowController bannerHandlerForHandle:bannerHandle];
		
		bannerHandler.wasInitiallySizedToPrevious = sizeAsPrevious;
		
		bannerHandler.tagBannerNeedsSizeToContent = (sizeToContents || sizeAsPrevious);
			// keep this value, so that tag banner gets resized to current contents if necessary
			//TODO very clumsy to do it this way...
		
		bannerHandler.outputTextHandler = self;
		
		[self.gameWindowController bannerSetHtmlMode:bannerHandle on:YES];
		
	} else {
		
		bannerHandler = [self.gameWindowController bannerHandlerForHandle:bannerHandle];
		
		[bannerHandler synchClear];
			
		//TODO? don't resize if size not changed
		[self.gameWindowController tagBannerReconfigure:bannerHandle
												  align:align
										 sizeToContents:sizeToContents
										 sizeAsPrevious:sizeAsPrevious
												   size:size
											  sizeUnits:sizeUnits
												  style:style];
	}

	bannerHandler.hadUnspecifiedSizeLastTime = sizeToContents;
	bannerHandler.hadPreviousSizeLastTime = sizeAsPrevious;
		// Needed for a weird-ass special case :-(
	
	self.activeTagBannerHandle = bannerHandle;

	[bannerHandler noteStartedFromHtmlTag];
	[bannerHandler processFormattingQueueFromMainOutput:self.formattingQueue];
		// this will consume queue until </banner> or queue empty
}

- (void)extractTagBannerSizeFrom:(NSString *)string
						attrName:(NSString *)attrName
				  sizeToContents:(BOOL *)sizeToContents
				  sizeAsPrevious:(BOOL *)sizeAsPrevious
							size:(NSInteger *)size
					   sizeUnits:(NSInteger *)sizeUnits
{
	XT_DEF_SELNAME;

	*sizeToContents = YES;
	*sizeAsPrevious = NO;
	
	if ([string length] >= 1) {
		string = [string lowercaseString];
		
		if ([string isEqualToString:@"previous"]) {
			*sizeToContents = NO;
			*sizeAsPrevious = YES;
			
		} else if ([string hasSuffix:@"%"]) {
			NSUInteger idxPctSign = string.length - 1;
			NSString *numPrefix = [string substringToIndex:idxPctSign];
			NSScanner *scanner = [NSScanner scannerWithString:numPrefix];
			NSInteger tempSize;
			BOOL found = [scanner scanInteger:&tempSize];
			if (found && [scanner isAtEnd] && tempSize >= 0 && tempSize <= 100) {
				*size = tempSize;
				*sizeUnits = OS_BANNER_SIZE_PCT;
				*sizeToContents = NO;
				*sizeAsPrevious = NO;
			} else {
				XT_WARN_2(@"illegal %* attribute \"%@\" - defaulting to content size", attrName, string);
				// keep default "size to content"
			}
		} else {
			NSScanner *scanner = [NSScanner scannerWithString:string];
			NSInteger tempSize;
			BOOL found = [scanner scanInteger:&tempSize];
			if (found && [scanner isAtEnd] && tempSize >= 0) {
				*size = tempSize;
				*sizeUnits = OS_BANNER_SIZE_PIXELS;
				*sizeToContents = NO;
				*sizeAsPrevious = NO;
			} else {
				XT_WARN_2(@"illegal %* attribute \"%@\" - defaulting to content size", attrName, string);
				// keep default "size to content"
			}
		}
	}
}

- (NSInteger)bannerAlignmentFrom:(NSString *)alignStr
{
	XT_DEF_SELNAME;

	NSInteger res = OS_BANNER_ALIGN_TOP;
	
	if (alignStr != nil) {
		NSString *alignStrLc = [alignStr lowercaseString];
		if ([alignStrLc isEqualToString:@"top"]) {
			res = OS_BANNER_ALIGN_TOP;
		} else if ([alignStrLc isEqualToString:@"bottom"]) {
			res = OS_BANNER_ALIGN_BOTTOM;
		} else if ([alignStrLc isEqualToString:@"left"]) {
			res = OS_BANNER_ALIGN_LEFT;
		} else if ([alignStrLc isEqualToString:@"right"]) {
			res = OS_BANNER_ALIGN_RIGHT;
		} else {
			XT_WARN_1(@"unknown alignment %@ - using default TOP", alignStr);
			res = OS_BANNER_ALIGN_TOP;
		}
	}
	
	return res;
}

- (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 findTotalHeight];
			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 findTotalHeight];
			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)scrollToBottom
{
	[self.outputTextView scrollRangeToVisible:NSMakeRange(self.outputTextView.string.length, 0)];
}

- (BOOL)recalcPagination
{
	XT_DEF_SELNAME;
	
	[self moveCursorToEndOfOutputPosition];
	
	CGFloat newTextViewHeight = [self.outputTextView findTotalHeight];
	CGFloat maxTextViewHeightBeforePagination = self.maxTextViewHeightBeforePagination;
	CGFloat exceededBy = newTextViewHeight - maxTextViewHeightBeforePagination;

	XT_TRACE_3(@"exceededBy=%f (newTextViewHeight=%f self.maxTextViewHeightBeforePagination=%f)", 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)removeLastCharFromTextStorage
{
	NSTextStorage *ts = [self.outputTextView textStorage];
	NSUInteger tsLen = [ts length];
	if (tsLen >= 1) {
		NSRange rangeToRemove = NSMakeRange(tsLen - 1, 1);
		[self removeFromTextStorage:rangeToRemove];
	}
}

- (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];
}

//------- App. level notifications -------

- (void)setupReceptionOfAppLevelNotifications
{
	XT_TRACE_ENTRY;
	
	[[NSNotificationCenter defaultCenter] addObserver:self
											 selector:@selector(handleNotifyTextLinkClicked:)
												 name:XTadsNotifyTextLinkClicked
											   object:nil]; // nil means "for any sender"

	[[NSNotificationCenter defaultCenter] addObserver:self
											 selector:@selector(handleSetFocusToMainOutput:)
												 name:XTadsNotifySetFocusToMainOutput
											   object:nil]; // nil means "for any sender"
}

- (void)teardownReceptionOfAppLevelNotifications
{
	XT_TRACE_ENTRY;
	
	[[NSNotificationCenter defaultCenter] removeObserver:self
													name:XTadsNotifyTextLinkClicked
												  object:nil];
	
	[[NSNotificationCenter defaultCenter] removeObserver:self
													name:XTadsNotifySetFocusToMainOutput
												  object:nil];
}

- (void)handleNotifyTextLinkClicked:(NSNotification *)notification
{
	NSString *linkText = notification.userInfo[XTadsNotificationUserInfoKeyLink];
	NSNumber *tempCharIndex = notification.userInfo[XTadsNotificationUserInfoKeyLinkCharIndex];
	NSUInteger charIndex = tempCharIndex.unsignedIntegerValue;
	
	[self handleCommandLinkClicked:linkText atIndex:charIndex];
}

- (void)handleSetFocusToMainOutput:(NSNotification *)notification
{
	XT_TRACE_ENTRY;
	
	// Transfer focus back to main output view
	[[self.outputTextView window] makeFirstResponder:self.outputTextView];
	[self moveCursorToEndOfOutputPosition];
}

@end
