//
//  ZoomClient.m
//  ZoomCocoa
//
//  Created by Andrew Hunter on Wed Sep 10 2003.
//  Copyright (c) 2003 Andrew Hunter. All rights reserved.
//

#import "ZoomClient.h"
#import "ZoomProtocol.h"
#import "ZoomClientController.h"
#import "ZoomStoryOrganiser.h"

#import "ZoomAppDelegate.h"

#import "ifmetabase.h"

// This deserves a note by itself: the autosave system is a bit weird. Strange bits are
// handled by strange objects for strange reasons. This is because we have:
//
//   zMachine (Model, runs in a seperate process)
//   ZoomClient (Also a model)
//   ZoomClientController (Controller)
//   ZoomView (View)
//
// These are used like this:
//
//     ZoomClient -> ZoomClientController -> ZoomView
//                                              |
//                                              v
//											 zMachine (Look, ma, I broke the paradigm!)
//
// But autosave state is distributed across all of them. So things that save stuff are
// kind of distributed, too.
//
// The zMachine is simple: we just derive save state from there: same as an undo buffer
// (which in Zoom is the same as a save file). We need the display state from the ZoomView.
// Technically, y'see, ZoomClient doesn't represent a running ZMachine: that's associated
// with the ZoomView (ZoomView has to be self-contained). So, we have an encoder there.
// The actual saving is done by ZoomClientController: ZoomClient represents a game, but
// ZoomClientController represents a session with a game, and the autosave data is
// associated with a session. Anyway, we save at the same time we store the data in the
// game info window, as that's the opportune moment.
//
// Loading has even more hair: we load the autosave data into the ZoomClient, so the
// ZoomClientController can pick it up and ask ZoomView to do the actual work of loading.
// (Actually, this is just saving in reverse, but with now with the involvement of
// ZoomClient).
//
// Hmph, there may have been a better way to design this, but I really wanted (and need)
// ZoomView to be a self-contained z-machine thingie. Which is what breaks the MVC
// paradigm. Well, that and the need for two completely seperate models (game data and
// game state). It makes sense if you don't care about autosave.
//
// Oh, and, umm, official Zoom terminology is 'Story' rather than 'Game'. Except I always
// forget that.

@implementation ZoomClient

- (id) init {
    self = [super init];

    if (self) {
        gameData = nil;
		story = nil;
		storyId = nil;
		autosaveData = nil;
		skein = [[ZoomSkein alloc] init];
		resources = nil;
		wasRestored = NO;
    }

    return self;
}

- (void) dealloc {
    [gameData release];
	if (story) [story release];
	if (storyId) [storyId release];
	
	if (defaultView) [defaultView release];
	if (saveData) [saveData release];
	
	if (resources) [resources release];
	
	[skein release];
    
    [super dealloc];
}

// = Creating the document =

- (void) makeWindowControllers {
    ZoomClientController* controller = [[ZoomClientController allocWithZone: [self zone]] init];

    [self addWindowController: [controller autorelease]];
}

- (NSData *)dataRepresentationOfType:(NSString *)type {
    // Can't save, really

    return gameData;
}

- (BOOL)loadDataRepresentation:(NSData *)data
						ofType:(NSString *)type {
	const unsigned char* bytes = [data bytes];
	BOOL isForm = NO;
	
	// No valid game can be less than 40 bytes long (in fact, it must be longer but 40 bytes is needed for the ID, etc)
	if ([data length] < 40) return NO;
	
	// See if this looks like a Blorb file (begins with 'FORM')
	if (bytes[0] == 'F' && bytes[1] == 'O' && bytes[2] == 'R' && bytes[3] == 'M') isForm = YES;
	
	if ([[type lowercaseString] isEqualToString: @"blorb resource file"] || isForm) {
		// Blorb files already have their resources pre-packaged: get the Z-Code chunk out of this file
		if (gameData) [gameData release];
		gameData = nil;
		
		ZoomBlorbFile* newRes = [[ZoomBlorbFile alloc] initWithData: data];
		if (newRes == nil) return NO;
		
		[self setResources: [newRes autorelease]];
		
		NSArray* zcodChunks = [newRes chunksWithType: @"ZCOD"];
		if (zcodChunks == nil || [zcodChunks count] <= 0) {
			NSLog(@"Not a Z-Code file");
			return NO;
		}
		
		gameData = [newRes dataForChunk: [zcodChunks objectAtIndex: 0]];
		if (gameData == nil) return NO;
		
		[gameData retain];
	} else {
		// Just a plain z-code file: load the lot
		if (gameData) [gameData release];
		gameData = [data retain];
	}
	
	// Discover the metadata for this game
	storyId = [[ZoomStoryID alloc] initWithZCodeStory: gameData];

	if (storyId == nil) {
		// Can't ID this story
		[gameData release];
		gameData = nil;
		return NO;
	}
	
	ZoomMetadata* userMetadata = [[NSApp delegate] userMetadata];
	
	story = [userMetadata containsStoryWithIdent: storyId]?[userMetadata findOrCreateStory: storyId]:nil;
	
	if (!story) {
		// If there is no metadata, then make some up
		story = [[NSApp delegate] findStory: storyId];
		
		if (story == nil) {
			story = [[ZoomStory defaultMetadataForFile: [self fileName]] retain];
		} else {
			[story retain];
		}
		
		[story addID: storyId];
		
		[[[NSApp delegate] userMetadata] copyStory: story];
		[story release];
		
		story = [[[NSApp delegate] userMetadata] findOrCreateStory: storyId];
		[story retain];
	} else {
		// If there is some metadata, then keep it around
		[story retain];
	}
	
	// Retrieve story resources (if available)
	NSString* resourceFilename = [story objectForKey: @"ResourceFilename"];
	if (resourceFilename != nil && [[NSFileManager defaultManager] fileExistsAtPath: resourceFilename]) {
		// Try to load the resources set for this game
		ZoomBlorbFile* newResources = [[ZoomBlorbFile alloc] initWithContentsOfFile: resourceFilename];
		
		if (newResources) {
			// Resources loaded OK: discard any resources we may have loaded earlier in findResourcesForFile
			[resources release];
			resources = newResources;
		} else {
			// Failed to load the resources that were set
			[self addLoadingError: @"Failed to load resources: the resource file set for the story was found but is not a valid Blorb resource file"];
			[story setObject: nil
					  forKey: @"ResourceFilename"];
		}
	} else if (resourceFilename != nil) {
		// Resource file not found
		[self addLoadingError: resources==nil?@"Failed to load resources: the resources set for this story could not be found":
			@"Failed to load resources that were set for this story, but found alternatives in the directory with story file"];
		[story setObject: nil
				  forKey: @"ResourceFilename"];
	}
	
	// Store/organise this story
	[[ZoomStoryOrganiser sharedStoryOrganiser] addStory: [self fileName]
											  withIdent: storyId
											   organise: [[ZoomPreferences globalPreferences] keepGamesOrganised]];
    
    return YES;
}

// = Document info =

- (NSData*) gameData {
    return gameData;
}

- (ZoomStory*) storyInfo {
	return story;
}

- (ZoomStoryID*) storyId {
	return storyId;
}

- (NSString*) displayName {
	if (story && [story title]) {
		if (wasRestored) {
			return [NSString stringWithFormat: @"%@ (restored from %@)", [story title], [super displayName]];
		} else {
			return [story title];
		}
	}
	
	return [super displayName];
}

// = Autosave =

- (void) setAutosaveData: (NSData*) data {
	if (autosaveData) [autosaveData release];
	autosaveData = [data retain];
}

- (NSData*) autosaveData {
	return autosaveData;
}

- (void) loadDefaultAutosave {
	if (autosaveData) [autosaveData release];
	
	NSString* autosaveDir = [[ZoomStoryOrganiser sharedStoryOrganiser] directoryForIdent: storyId
																				  create: NO];
	NSString* autosaveFile = [autosaveDir stringByAppendingPathComponent: @"autosave.zoomauto"];
	
	if (![[NSFileManager defaultManager] fileExistsAtPath: autosaveFile]) {
		autosaveData = nil;
		return;
	}
	
	autosaveData = [[NSData dataWithContentsOfFile: autosaveFile] retain];
}

- (BOOL) checkResourceFile: (NSString*) file {
	NSFileManager* fm = [NSFileManager defaultManager];
	BOOL exists, isDir;
	
	// Check that the file exists
	exists = [fm fileExistsAtPath: file
					  isDirectory: &isDir];
	if (!exists || isDir) return NO;
	
	// Try to load it as a blorb resource file
	ZoomBlorbFile* newRes = [[ZoomBlorbFile alloc] initWithContentsOfFile: file];
	if (newRes == nil) return NO;
	
	// Set resources appropriately
	[self setResources: [newRes autorelease]];
	
	// Success!
	return YES;
}

- (void) findResourcesForFile: (NSString*) resFilename {
	// If we're loading a .zX game, then look for resources
	// If we're foo.zX, look in:
	//   foo.blb
	//   foo.zlb
	//   resources.blb
	// for the resources

	if (resFilename != nil) {
		NSString* resPath = [resFilename stringByDeletingLastPathComponent];
		NSString* resPrefix = [[resFilename lastPathComponent] stringByDeletingPathExtension];
		
		NSString* fileToCheck;
		
		// foo.blb
		fileToCheck = [[resPath stringByAppendingPathComponent: resPrefix] stringByAppendingPathExtension: @"blb"];
		if (![self checkResourceFile: fileToCheck]) {
			// foo.zlb
			fileToCheck = [[resPath stringByAppendingPathComponent: resPrefix] stringByAppendingPathExtension: @"zlb"];

			if (![self checkResourceFile: fileToCheck]) {
				// foo.zblorb
				fileToCheck = [[resPath stringByAppendingPathComponent: resPrefix] stringByAppendingPathExtension: @"zblorb"];
				if (![self checkResourceFile: fileToCheck]) {
					// resources.blb
					fileToCheck = [resPath stringByAppendingPathComponent: @"resources.blb"];
					[self checkResourceFile: fileToCheck];
				}
			}
		}
	}
}

- (BOOL)loadFileWrapperRepresentation:(NSFileWrapper *)wrapper 
							   ofType:(NSString *)docType {
	if (![[docType lowercaseString] isEqualToString: @"quetzal saved game"] && ![wrapper isDirectory]) {		
		// Note that resources might come from elsewhere later on in the load process, too
		[self findResourcesForFile: [self fileName]];
		
		// Pass files onto the data loader
		return [self loadDataRepresentation: [wrapper regularFileContents] 
									 ofType: docType];
	}

	if (![[docType lowercaseString] isEqualToString: @"zoom savegame"] &&
		![[docType lowercaseString] isEqualToString: @"quetzal saved game"]) {
		// Process only zoomSave files
		return NO;
	}
	
	BOOL isSingleFile = [[docType lowercaseString] isEqualToString: @"quetzal saved game"];
	
	// NOTE: a future version of Zoom will add a story type identifier to the zoomSave file format, to
	// support various types of Glk games.
	
	// Read the IFhd section of the save data to find the story identifier
	NSData* quetzal = isSingleFile?[wrapper regularFileContents]:[[[wrapper fileWrappers] objectForKey: @"save.qut"] regularFileContents];
	ZoomStoryID* storyID = nil;
	
	if (quetzal == nil) {
		// Not a valid zoomSave file
		 NSBeginAlertSheet(@"Not a valid Zoom savegame package", 
						   @"Cancel", nil, nil, nil, nil, nil, nil, nil,
						   @"%@ does not contain a valid 'save.qut' file", [[wrapper filename] lastPathComponent]);
		
		return NO;
	}
	
	const unsigned char* bytes = [quetzal bytes];
	unsigned int len = [quetzal length];
	unsigned int pos = 16;
	
	while (pos < len) {
		unsigned int blockLength = (bytes[pos]<<24)  | (bytes[pos+1]<<16) | (bytes[pos+2]<<8) | bytes[pos+3];
		const unsigned char* header = (bytes + pos - 4);
		
		if (memcmp(header, "IFhd", 4) == 0) {
			if (blockLength == 13 && pos + blockLength <= len) {
				unsigned int release = (bytes[pos+4]<<8)|bytes[pos+5];
				const unsigned char* serial = bytes + pos + 6;
				unsigned int checksum = (bytes[pos+12]<<8)|bytes[pos+13];
				
				// Set up the ZoomStoryID object for this savegame
				storyID = [[ZoomStoryID alloc] initWithZcodeRelease: release
															 serial: serial
														   checksum: checksum];
				[storyID autorelease];
			}
		}
		
		if ((blockLength&1) == 1) blockLength++;
		pos += blockLength+8;
	}
	
	if ((pos-4) > len) {
		// Not a valid zoomSave file
		if (!isSingleFile) {
			NSBeginAlertSheet(@"Not a valid Zoom savegame package", 
							  @"Cancel", nil, nil, nil, nil, nil, nil, nil,
							  @"%@ does not contain a valid 'save.qut' file", [[wrapper filename] lastPathComponent]);
		} else {
			NSBeginAlertSheet(@"Not a valid Quetzal file", 
							  @"Cancel", nil, nil, nil, nil, nil, nil, nil,
							  @"%@ is not a valid Quetzal file", [[wrapper filename] lastPathComponent]);
		}
		
		return NO;
	}
	
	// If this isn't a single file, then try retrieving the ID from the Info.plist file (if it exists)
	if (!isSingleFile) {
		NSData* plist = [[[wrapper fileWrappers] objectForKey: @"Info.plist"] regularFileContents];
		
		if (plist != nil) {
			NSDictionary* plistDict = [NSPropertyListSerialization propertyListFromData: plist
																	   mutabilityOption: NSPropertyListImmutable
																				 format: nil
																	   errorDescription: nil];
			NSString* idString  = [plistDict objectForKey: @"ZoomStoryId"];
			if (idString != nil) {
				storyID = [[[ZoomStoryID alloc] initWithIdString: idString] autorelease];
			}
		}
	}
	
	// Get the game file for this save from the story organiser
	NSString* gameFile = [[ZoomStoryOrganiser sharedStoryOrganiser] filenameForIdent: storyID];
	[self findResourcesForFile: gameFile];
	
	if (gameFile == nil) {
		// Couldn't find a story for this savegame
		NSBeginAlertSheet(@"Unable to find story file", 
						  @"Cancel", nil, nil, nil, nil, nil, nil, nil,
						  @"Zoom does not know where a valid story file for '%@' is and so is unable to load it", [[wrapper filename] lastPathComponent]);
		
		return NO;		
	}
	
	NSData* data = [NSData dataWithContentsOfFile: gameFile];
	if (data == nil) {
		// Couldn't find the story data for this savegame
		NSBeginAlertSheet(@"Unable to find story file", 
						  @"Cancel", nil, nil, nil, nil, nil, nil, nil,
						  @"Zoom is unable to load a valid story file for '%@' (tried '%@')", [[wrapper filename] lastPathComponent],
						  gameFile);
		
		return NO;		
	}
	
	if (isSingleFile) {
		// No more to do
		if (saveData) [saveData release];
		saveData = [[NSData dataWithBytes: ((unsigned char*)[quetzal bytes])+12 length: [quetzal length]-12] retain];

		wasRestored = YES;
		
		[self setFileName: gameFile];
		return [self loadDataRepresentation: data
									 ofType: @"ZCode story"];
	}
	
	// Get the saved view state for this game
	NSData* savedViewArchive = [[[wrapper fileWrappers] objectForKey: @"ZoomStatus.dat"] regularFileContents];
	ZoomView* savedView = nil;
		
	if (savedViewArchive) 
		savedView = [NSUnarchiver unarchiveObjectWithData: savedViewArchive];
	
	if (savedView == nil || ![savedView isKindOfClass: [ZoomView class]]) {
		NSBeginAlertSheet(@"Unable to load saved screen state", 
						  @"Cancel", nil, nil, nil, nil, nil, nil, nil,
						  @"Zoom was unable to find the saved screen state for '%@', and so is unable to start it", [[wrapper filename] lastPathComponent]);
		return NO;
	}
	
	// Get the skein for this game
	NSData* skeinArchive = [[[wrapper fileWrappers] objectForKey: @"Skein.skein"] regularFileContents];
	if (skeinArchive) {
		[skein parseXmlData: skeinArchive];
	}
	
	// OK, we're ready to roll!
	if (defaultView) [defaultView release];
	if (saveData) [saveData release];
	
	defaultView = [savedView retain];
	saveData = [[NSData dataWithBytes: ((unsigned char*)[quetzal bytes])+12 length: [quetzal length]-12] retain];
	
	// NOTE: saveData is the data minus the 'FORM' chunk - that is, valid input for state_decompile()
	// (which doesn't use this chunk)
	wasRestored = YES;
	
	[self setFileName: gameFile];
	return [self loadDataRepresentation: data
								 ofType: @"ZCode story"];
}

- (ZoomView*) defaultView {
	return defaultView;
}

- (NSData*) saveData {
	return saveData;
}

- (void) setSaveData: (NSData*) newSaveData {
	[saveData release];
	saveData = [newSaveData copy];
}

- (ZoomSkein*) skein {
	return skein;
}

- (void) setResources: (ZoomBlorbFile*) res {
	if (resources) [resources release];
	resources = [res retain];
}

- (ZoomBlorbFile*) resources {
	return resources;
}

// = Errors that might have happened but we recovered from =

- (void) addLoadingError: (NSString*) loadingError {
	// Using this mechanism allows us to report that we couldn't find any resources (for instance) to the
	// controller, so it can display a proper error messages
	if (!loadingErrors) loadingErrors = [[NSMutableArray alloc] init];
	
	[loadingErrors addObject: [[loadingError copy] autorelease]];
}

- (NSArray*) loadingErrors {
	return loadingErrors;
}

@end
