//
//  PBGitIndex.m
//  GitX
//
//  Created by Pieter de Bie on 9/12/09.
//  Copyright 2009 Pieter de Bie. All rights reserved.
//

#import "PBGitIndex.h"
#import "PBGitRepository.h"
#import "PBGitBinary.h"
#import "PBEasyPipe.h"
#import "NSString_RegEx.h"
#import "PBChangedFile.h"

NSString *PBGitIndexIndexRefreshStatus = @"PBGitIndexIndexRefreshStatus";
NSString *PBGitIndexIndexRefreshFailed = @"PBGitIndexIndexRefreshFailed";
NSString *PBGitIndexFinishedIndexRefresh = @"PBGitIndexFinishedIndexRefresh";

NSString *PBGitIndexIndexUpdated = @"GBGitIndexIndexUpdated";

NSString *PBGitIndexCommitStatus = @"PBGitIndexCommitStatus";
NSString *PBGitIndexCommitFailed = @"PBGitIndexCommitFailed";
NSString *PBGitIndexFinishedCommit = @"PBGitIndexFinishedCommit";

NSString *PBGitIndexAmendMessageAvailable = @"PBGitIndexAmendMessageAvailable";
NSString *PBGitIndexOperationFailed = @"PBGitIndexOperationFailed";

@interface PBGitIndex (IndexRefreshMethods)

- (NSArray *)linesFromNotification:(NSNotification *)notification;
- (NSMutableDictionary *)dictionaryForLines:(NSArray *)lines;
- (void)addFilesFromDictionary:(NSMutableDictionary *)dictionary staged:(BOOL)staged tracked:(BOOL)tracked;

- (void)indexStepComplete;

- (void)indexRefreshFinished:(NSNotification *)notification;
- (void)readOtherFiles:(NSNotification *)notification;
- (void)readUnstagedFiles:(NSNotification *)notification;
- (void)readStagedFiles:(NSNotification *)notification;

@end

@interface PBGitIndex ()

// Returns the tree to compare the index to, based
// on whether amend is set or not.
- (NSString *) parentTree;
- (void)postCommitUpdate:(NSString *)update;
- (void)postCommitFailure:(NSString *)reason;
- (void)postIndexChange;
- (void)postOperationFailed:(NSString *)description;
@end

@implementation PBGitIndex

@synthesize amend;

- (id)initWithRepository:(PBGitRepository *)theRepository workingDirectory:(NSURL *)theWorkingDirectory
{
	if (!(self = [super init]))
		return nil;

	NSAssert(theWorkingDirectory, @"PBGitIndex requires a working directory");
	NSAssert(theRepository, @"PBGitIndex requires a repository");

	repository = theRepository;
	workingDirectory = theWorkingDirectory;
	files = [NSMutableArray array];

	return self;
}

- (NSArray *)indexChanges
{
	return files;
}

- (void)setAmend:(BOOL)newAmend
{
	if (newAmend == amend)
		return;
	
	amend = newAmend;
	amendEnvironment = nil;

	[self refresh];

	if (!newAmend)
		return;

	// If we amend, we want to keep the author information for the previous commit
	// We do this by reading in the previous commit, and storing the information
	// in a dictionary. This dictionary will then later be read by [self commit:]
	NSString *message = [repository outputForCommand:@"cat-file commit HEAD"];
	NSArray *match = [message substringsMatchingRegularExpression:@"\nauthor ([^\n]*) <([^\n>]*)> ([0-9]+[^\n]*)\n" count:3 options:0 ranges:nil error:nil];
	if (match)
		amendEnvironment = [NSDictionary dictionaryWithObjectsAndKeys:[match objectAtIndex:1], @"GIT_AUTHOR_NAME",
							[match objectAtIndex:2], @"GIT_AUTHOR_EMAIL",
							[match objectAtIndex:3], @"GIT_AUTHOR_DATE",
							nil];

	// Find the commit message
	NSRange r = [message rangeOfString:@"\n\n"];
	if (r.location != NSNotFound) {
		NSString *commitMessage = [message substringFromIndex:r.location + 2];
		[[NSNotificationCenter defaultCenter] postNotificationName:PBGitIndexAmendMessageAvailable
															object: self
														  userInfo:[NSDictionary dictionaryWithObject:commitMessage forKey:@"message"]];
	}
	
}

- (void)refresh
{
	// If we were already refreshing the index, we don't want
	// double notifications. As we can't stop the tasks anymore,
	// just cancel the notifications
	refreshStatus = 0;
	NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; 
	[nc removeObserver:self]; 

	// Ask Git to refresh the index
	NSFileHandle *updateHandle = [PBEasyPipe handleForCommand:[PBGitBinary path] 
													 withArgs:[NSArray arrayWithObjects:@"update-index", @"-q", @"--unmerged", @"--ignore-missing", @"--refresh", nil]
														inDir:[workingDirectory path]];

	[nc addObserver:self
		   selector:@selector(indexRefreshFinished:)
			   name:NSFileHandleReadToEndOfFileCompletionNotification
			 object:updateHandle];
	[updateHandle readToEndOfFileInBackgroundAndNotify];

}

- (NSString *) parentTree
{
	NSString *parent = amend ? @"HEAD^" : @"HEAD";
	
	if (![repository parseReference:parent])
		// We don't have a head ref. Return the empty tree.
		return @"4b825dc642cb6eb9a060e54bf8d69288fbee4904";

	return parent;
}

// TODO: make Asynchronous
- (void)commitWithMessage:(NSString *)commitMessage
{
	NSMutableString *commitSubject = [@"commit: " mutableCopy];
	NSRange newLine = [commitMessage rangeOfString:@"\n"];
	if (newLine.location == NSNotFound)
		[commitSubject appendString:commitMessage];
	else
		[commitSubject appendString:[commitMessage substringToIndex:newLine.location]];
	
	NSString *commitMessageFile;
	commitMessageFile = [[[repository fileURL] path] stringByAppendingPathComponent:@"COMMIT_EDITMSG"];
	
	[commitMessage writeToFile:commitMessageFile atomically:YES encoding:NSUTF8StringEncoding error:nil];

	
	[self postCommitUpdate:@"Creating tree"];
	NSString *tree = [repository outputForCommand:@"write-tree"];
	if ([tree length] != 40)
		return [self postCommitFailure:@"Creating tree failed"];
	
	
	NSMutableArray *arguments = [NSMutableArray arrayWithObjects:@"commit-tree", tree, nil];
	NSString *parent = amend ? @"HEAD^" : @"HEAD";
	if ([repository parseReference:parent]) {
		[arguments addObject:@"-p"];
		[arguments addObject:parent];
	}

	[self postCommitUpdate:@"Creating commit"];
	int ret = 1;
	NSString *commit = [repository outputForArguments:arguments
										  inputString:commitMessage
							   byExtendingEnvironment:amendEnvironment
											 retValue: &ret];
	
	if (ret || [commit length] != 40)
		return [self postCommitFailure:@"Could not create a commit object"];
	
	[self postCommitUpdate:@"Running hooks"];
	if (![repository executeHook:@"pre-commit" output:nil])
		return [self postCommitFailure:@"Pre-commit hook failed"];
	
	if (![repository executeHook:@"commit-msg" withArgs:[NSArray arrayWithObject:commitMessageFile] output:nil])
		return [self postCommitFailure:@"Commit-msg hook failed"];
	
	[self postCommitUpdate:@"Updating HEAD"];
	[repository outputForArguments:[NSArray arrayWithObjects:@"update-ref", @"-m", commitSubject, @"HEAD", commit, nil]
						  retValue: &ret];
	if (ret)
		return [self postCommitFailure:@"Could not update HEAD"];
	
	[self postCommitUpdate:@"Running post-commit hook"];
	
	BOOL success = [repository executeHook:@"post-commit" output:nil];
	NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithObject:[NSNumber numberWithBool:success] forKey:@"success"];
	NSString *description;  
	if (success)
		description = [NSString stringWithFormat:@"Successfully created commit %@", commit];
	else
		description = [NSString stringWithFormat:@"Post-commit hook failed, but successfully created commit %@", commit];
	
	[userInfo setObject:description forKey:@"description"];
	[userInfo setObject:commit forKey:@"sha"];

	[[NSNotificationCenter defaultCenter] postNotificationName:PBGitIndexFinishedCommit
														object:self
													  userInfo:userInfo];
	if (!success)
		return;

	repository.hasChanged = YES;

	amendEnvironment = nil;
	if (amend)
		self.amend = NO;
	else
		[self refresh];
	
}

- (void)postCommitUpdate:(NSString *)update
{
	[[NSNotificationCenter defaultCenter] postNotificationName:PBGitIndexCommitStatus
													object:self
													  userInfo:[NSDictionary dictionaryWithObject:update forKey:@"description"]];
}

- (void)postCommitFailure:(NSString *)reason
{
	[[NSNotificationCenter defaultCenter] postNotificationName:PBGitIndexCommitFailed
														object:self
													  userInfo:[NSDictionary dictionaryWithObject:reason forKey:@"description"]];
}

- (void)postOperationFailed:(NSString *)description
{
	[[NSNotificationCenter defaultCenter] postNotificationName:PBGitIndexOperationFailed
														object:self
													  userInfo:[NSDictionary dictionaryWithObject:description forKey:@"description"]];	
}

- (BOOL)stageFiles:(NSArray *)stageFiles
{
	// Input string for update-index
	// This will be a list of filenames that
	// should be updated. It's similar to
	// "git add -- <files>
	NSMutableString *input = [NSMutableString string];

	for (PBChangedFile *file in stageFiles) {
		[input appendFormat:@"%@\0", file.path];
	}
	
	int ret = 1;
	[repository outputForArguments:[NSArray arrayWithObjects:@"update-index", @"--add", @"--remove", @"-z", @"--stdin", nil]
					   inputString:input
						  retValue:&ret];

	if (ret) {
		[self postOperationFailed:[NSString stringWithFormat:@"Error in staging files. Return value: %i", ret]];
		return NO;
	}

	for (PBChangedFile *file in stageFiles)
	{
		file.hasUnstagedChanges = NO;
		file.hasStagedChanges = YES;
	}

	[self postIndexChange];
	return YES;
}

// TODO: Refactor with above. What's a better name for this?
- (BOOL)unstageFiles:(NSArray *)unstageFiles
{
	NSMutableString *input = [NSMutableString string];

	for (PBChangedFile *file in unstageFiles) {
		[input appendString:[file indexInfo]];
	}

	int ret = 1;
	[repository outputForArguments:[NSArray arrayWithObjects:@"update-index", @"-z", @"--index-info", nil]
					   inputString:input 
						  retValue:&ret];

	if (ret)
	{
		[self postOperationFailed:[NSString stringWithFormat:@"Error in unstaging files. Return value: %i", ret]];
		return NO;
	}

	for (PBChangedFile *file in unstageFiles)
	{
		file.hasUnstagedChanges = YES;
		file.hasStagedChanges = NO;
	}

	[self postIndexChange];
	return YES;
}

- (void)discardChangesForFiles:(NSArray *)discardFiles
{
	NSArray *paths = [discardFiles valueForKey:@"path"];
	NSString *input = [paths componentsJoinedByString:@"\0"];

	NSArray *arguments = [NSArray arrayWithObjects:@"checkout-index", @"--index", @"--quiet", @"--force", @"-z", @"--stdin", nil];

	int ret = 1;
	[PBEasyPipe outputForCommand:[PBGitBinary path]	withArgs:arguments inDir:[workingDirectory path] inputString:input retValue:&ret];

	if (ret) {
		[self postOperationFailed:[NSString stringWithFormat:@"Discarding changes failed with return value %i", ret]];
		return;
	}

	for (PBChangedFile *file in discardFiles)
		file.hasUnstagedChanges = NO;

	[self postIndexChange];
}

- (BOOL)applyPatch:(NSString *)hunk stage:(BOOL)stage reverse:(BOOL)reverse;
{
	NSMutableArray *array = [NSMutableArray arrayWithObjects:@"apply", nil];
	if (stage)
		[array addObject:@"--cached"];
	if (reverse)
		[array addObject:@"--reverse"];

	int ret = 1;
	NSString *error = [repository outputForArguments:array
										 inputString:hunk
											retValue:&ret];

	if (ret) {
		[self postOperationFailed:[NSString stringWithFormat:@"Applying patch failed with return value %i. Error: %@", ret, error]];
		return NO;
	}

	// TODO: Try to be smarter about what to refresh
	[self refresh];
	return YES;
}


- (NSString *)diffForFile:(PBChangedFile *)file staged:(BOOL)staged contextLines:(NSUInteger)context
{
	NSString *parameter = [NSString stringWithFormat:@"-U%u", context];
	if (staged) {
		NSString *indexPath = [@":0:" stringByAppendingString:file.path];

		if (file.status == NEW)
			return [repository outputForArguments:[NSArray arrayWithObjects:@"show", indexPath, nil]];

		return [repository outputInWorkdirForArguments:[NSArray arrayWithObjects:@"diff-index", parameter, @"--cached", [self parentTree], @"--", file.path, nil]];
	}

	// unstaged
	if (file.status == NEW) {
		NSStringEncoding encoding;
		NSError *error = nil;
		NSString *path = [[repository workingDirectory] stringByAppendingPathComponent:file.path];
		NSString *contents = [NSString stringWithContentsOfFile:path
												   usedEncoding:&encoding
														  error:&error];
		if (error)
			return nil;

		return contents;
	}

	return [repository outputInWorkdirForArguments:[NSArray arrayWithObjects:@"diff-files", parameter, @"--", file.path, nil]];
}

- (void)postIndexChange
{
	[[NSNotificationCenter defaultCenter] postNotificationName:PBGitIndexIndexUpdated
														object:self];
}

# pragma mark WebKit Accessibility

+ (BOOL)isSelectorExcludedFromWebScript:(SEL)aSelector
{
	return NO;
}

@end

@implementation PBGitIndex (IndexRefreshMethods)

- (void)indexRefreshFinished:(NSNotification *)notification
{
	if ([(NSNumber *)[(NSDictionary *)[notification userInfo] objectForKey:@"NSFileHandleError"] intValue])
	{
		[[NSNotificationCenter defaultCenter] postNotificationName:PBGitIndexIndexRefreshFailed
															object:self
														  userInfo:[NSDictionary dictionaryWithObject:@"update-index failed" forKey:@"description"]];
		return;
	}

	[[NSNotificationCenter defaultCenter] postNotificationName:PBGitIndexIndexRefreshStatus
														object:self
													  userInfo:[NSDictionary dictionaryWithObject:@"update-index success" forKey:@"description"]];

	// Now that the index is refreshed, we need to read the information from the index
	NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; 

	// Other files (not tracked, not ignored)
	refreshStatus++;
	NSFileHandle *handle = [PBEasyPipe handleForCommand:[PBGitBinary path] 
											   withArgs:[NSArray arrayWithObjects:@"ls-files", @"--others", @"--exclude-standard", @"-z", nil]
												  inDir:[workingDirectory path]];
	[nc addObserver:self selector:@selector(readOtherFiles:) name:NSFileHandleReadToEndOfFileCompletionNotification object:handle]; 
	[handle readToEndOfFileInBackgroundAndNotify];

	// Unstaged files
	refreshStatus++;
	handle = [PBEasyPipe handleForCommand:[PBGitBinary path] 
											   withArgs:[NSArray arrayWithObjects:@"diff-files", @"-z", nil]
												  inDir:[workingDirectory path]];
	[nc addObserver:self selector:@selector(readUnstagedFiles:) name:NSFileHandleReadToEndOfFileCompletionNotification object:handle]; 
	[handle readToEndOfFileInBackgroundAndNotify];

	// Staged files
	refreshStatus++;
	handle = [PBEasyPipe handleForCommand:[PBGitBinary path] 
								 withArgs:[NSArray arrayWithObjects:@"diff-index", @"--cached", @"-z", [self parentTree], nil]
									inDir:[workingDirectory path]];
	[nc addObserver:self selector:@selector(readStagedFiles:) name:NSFileHandleReadToEndOfFileCompletionNotification object:handle]; 
	[handle readToEndOfFileInBackgroundAndNotify];
}

- (void)readOtherFiles:(NSNotification *)notification
{
	NSArray *lines = [self linesFromNotification:notification];
	NSMutableDictionary *dictionary = [[NSMutableDictionary alloc] initWithCapacity:[lines count]];
	// Other files are untracked, so we don't have any real index information. Instead, we can just fake it.
	// The line below is not used at all, as for these files the commitBlob isn't set
	NSArray *fileStatus = [NSArray arrayWithObjects:@":000000", @"100644", @"0000000000000000000000000000000000000000", @"0000000000000000000000000000000000000000", @"A", nil];
	for (NSString *path in lines) {
		if ([path length] == 0)
			continue;
		[dictionary setObject:fileStatus forKey:path];
	}

	[self addFilesFromDictionary:dictionary staged:NO tracked:NO];
	[self indexStepComplete];	
}

- (void) readStagedFiles:(NSNotification *)notification
{
	NSArray *lines = [self linesFromNotification:notification];
	NSMutableDictionary *dic = [self dictionaryForLines:lines];
	[self addFilesFromDictionary:dic staged:YES tracked:YES];
	[self indexStepComplete];
}

- (void) readUnstagedFiles:(NSNotification *)notification
{
	NSArray *lines = [self linesFromNotification:notification];
	NSMutableDictionary *dic = [self dictionaryForLines:lines];
	[self addFilesFromDictionary:dic staged:NO tracked:YES];
	[self indexStepComplete];
}

- (void) addFilesFromDictionary:(NSMutableDictionary *)dictionary staged:(BOOL)staged tracked:(BOOL)tracked
{
	// Iterate over all existing files
	for (PBChangedFile *file in files) {
		NSArray *fileStatus = [dictionary objectForKey:file.path];
		// Object found, this is still a cached / uncached thing
		if (fileStatus) {
			if (tracked) {
				NSString *mode = [[fileStatus objectAtIndex:0] substringFromIndex:1];
				NSString *sha = [fileStatus objectAtIndex:2];
				file.commitBlobSHA = sha;
				file.commitBlobMode = mode;
				
				if (staged)
					file.hasStagedChanges = YES;
				else
					file.hasUnstagedChanges = YES;
				if ([[fileStatus objectAtIndex:4] isEqualToString:@"D"])
					file.status = DELETED;
			} else {
				// Untracked file, set status to NEW, only unstaged changes
				file.hasStagedChanges = NO;
				file.hasUnstagedChanges = YES;
				file.status = NEW;
			}

			// We handled this file, remove it from the dictionary
			[dictionary removeObjectForKey:file.path];
		} else {
			// Object not found in the dictionary, so let's reset its appropriate
			// change (stage or untracked) if necessary.

			// Staged dictionary, so file does not have staged changes
			if (staged)
				file.hasStagedChanges = NO;
			// Tracked file does not have unstaged changes, file is not new,
			// so we can set it to No. (If it would be new, it would not
			// be in this dictionary, but in the "other dictionary").
			else if (tracked && file.status != NEW)
				file.hasUnstagedChanges = NO;
			// Unstaged, untracked dictionary ("Other" files), and file
			// is indicated as new (which would be untracked), so let's
			// remove it
			else if (!tracked && file.status == NEW)
				file.hasUnstagedChanges = NO;
		}
	}

	// Do new files only if necessary
	if (![[dictionary allKeys] count])
		return;

	// All entries left in the dictionary haven't been accounted for
	// above, so we need to add them to the "files" array
	[self willChangeValueForKey:@"indexChanges"];
	for (NSString *path in [dictionary allKeys]) {
		NSArray *fileStatus = [dictionary objectForKey:path];

		PBChangedFile *file = [[PBChangedFile alloc] initWithPath:path];
		if ([[fileStatus objectAtIndex:4] isEqualToString:@"D"])
			file.status = DELETED;
		else if([[fileStatus objectAtIndex:0] isEqualToString:@":000000"])
			file.status = NEW;
		else
			file.status = MODIFIED;

		if (tracked) {
			file.commitBlobMode = [[fileStatus objectAtIndex:0] substringFromIndex:1];
			file.commitBlobSHA = [fileStatus objectAtIndex:2];
		}

		file.hasStagedChanges = staged;
		file.hasUnstagedChanges = !staged;

		[files addObject:file];
	}
	[self didChangeValueForKey:@"indexChanges"];
}

# pragma mark Utility methods
- (NSArray *)linesFromNotification:(NSNotification *)notification
{
	NSData *data = [[notification userInfo] valueForKey:NSFileHandleNotificationDataItem];
	if (!data)
		return [NSArray array];

	NSString *string = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
	// FIXME: throw an error?
	if (!string)
		return [NSArray array];

	// Strip trailing null
	if ([string hasSuffix:@"\0"])
		string = [string substringToIndex:[string length]-1];

	if ([string length] == 0)
		return [NSArray array];

	return [string componentsSeparatedByString:@"\0"];
}

- (NSMutableDictionary *)dictionaryForLines:(NSArray *)lines
{
	NSMutableDictionary *dictionary = [NSMutableDictionary dictionaryWithCapacity:[lines count]/2];
	
	// Fill the dictionary with the new information. These lines are in the form of:
	// :00000 :0644 OTHER INDEX INFORMATION
	// Filename

	NSAssert1([lines count] % 2 == 0, @"Lines must have an even number of lines: %@", lines);

	NSEnumerator *enumerator = [lines objectEnumerator];
	NSString *fileStatus;
	while (fileStatus = [enumerator nextObject]) {
		NSString *fileName = [enumerator nextObject];
		[dictionary setObject:[fileStatus componentsSeparatedByString:@" "] forKey:fileName];
	}

	return dictionary;
}

// This method is called for each of the three processes from above.
// If all three are finished (self.busy == 0), then we can delete
// all files previously marked as deletable
- (void)indexStepComplete
{
	// if we're still busy, do nothing :)
	if (--refreshStatus) {
		[self postIndexChange];
		return;
	}

	// At this point, all index operations have finished.
	// We need to find all files that don't have either
	// staged or unstaged files, and delete them

	NSMutableArray *deleteFiles = [NSMutableArray array];
	for (PBChangedFile *file in files) {
		if (!file.hasStagedChanges && !file.hasUnstagedChanges)
			[deleteFiles addObject:file];
	}
	
	if ([deleteFiles count]) {
		[self willChangeValueForKey:@"indexChanges"];
		for (PBChangedFile *file in deleteFiles)
			[files removeObject:file];
		[self didChangeValueForKey:@"indexChanges"];
	}

	[[NSNotificationCenter defaultCenter] postNotificationName:PBGitIndexFinishedIndexRefresh
														object:self];
	[self postIndexChange];

}

@end
