/*
 * Scratch Project Editor and Player
 * Copyright (C) 2014 Massachusetts Institute of Technology
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */

// ProjectIO.as
// John Maloney, September 2010
//
// Support for project saving/loading, either to the local file system or a server.
// Three types of projects are supported: old Scratch projects (.sb), new Scratch
// projects stored as a JSON project file and a collection of media files packed
// in a single ZIP file, and new Scratch projects stored on a server as a collection
// of separate elements.

package util {
import flash.display.BitmapData;
import flash.display.Loader;
import flash.events.Event;
import flash.net.URLLoader;
import flash.net.URLLoaderDataFormat;
import flash.net.URLRequest;
import flash.utils.ByteArray;
import flash.utils.Dictionary;
import flash.utils.setTimeout;

import extensions.DeviceManager;

import scratch.ScratchCostume;
import scratch.ScratchObj;
import scratch.ScratchSound;
import scratch.ScratchSprite;
import scratch.ScratchStage;

import sound.WAVFile;
import sound.mp3.MP3Loader;

import svgutils.SVGElement;
import svgutils.SVGImporter;

import translation.Translator;

import uiwidgets.DialogBox;

public class ProjectIO {

	protected var app:IQCar;
	protected var images:Array = [];
	protected var sounds:Array = [];

	public function ProjectIO(app:IQCar):void {
		this.app = app;
	}

	public static function strings():Array {
		return [];
	}

	//----------------------------
	// Encode a project or sprite as a ByteArray (a 'one-file' project)
	//----------------------------

	public function encodeProjectAsZipFile(proj:ScratchStage):ByteArray {
		// Encode a project into a ByteArray. The format is a ZIP file containing
		// the JSON project data and all images and sounds as files.
		delete proj.info.penTrails; // remove the penTrails bitmap saved in some old projects' info
		proj.savePenLayer();
		proj.updateInfo();
		//recordImagesAndSounds(proj.allObjects(), false, proj);
		var zip:ZipIO = new ZipIO();
		zip.startWrite();
		addJSONData('project.json', proj, zip);
		addImagesAndSounds(zip);
		proj.clearPenLayer();
		return zip.endWrite();
	}

	public function encodeSpriteAsZipFile(spr:ScratchSprite):ByteArray {
		// Encode a sprite into a ByteArray. The format is a ZIP file containing
		// the JSON sprite data and all images and sounds as files.
		//recordImagesAndSounds([spr], false);
		var zip:ZipIO = new ZipIO();
		zip.startWrite();
		addJSONData('sprite.json', spr, zip);
		addImagesAndSounds(zip);
		return zip.endWrite();
	}

	private function addJSONData(fileName:String, obj:*, zip:ZipIO):void {
		var jsonData:ByteArray = new ByteArray();
		jsonData.writeUTFBytes(util.JSON.stringify(obj));
		zip.write(fileName, jsonData, true);
	}

	private function addImagesAndSounds(zip:ZipIO):void {
		var i:int, ext:String;
		for (i = 0; i < images.length; i++) {
			var imgData:ByteArray = images[i][1];
			ext = ScratchCostume.fileExtension(imgData);
			zip.write(i + ext, imgData);
		}
		for (i = 0; i < sounds.length; i++) {
			var sndData:ByteArray = sounds[i][1];
			ext = ScratchSound.isWAV(sndData) ? '.wav' : '.mp3';
			zip.write(i + ext, sndData);
		}
	}

	//----------------------------
	// Decode a project or sprite from a ByteArray containing ZIP data
	//----------------------------

	public function decodeProjectFromZipFile(zipData:ByteArray):ScratchStage {
		return decodeFromZipFile(zipData) as ScratchStage;
	}

	public function decodeSpriteFromZipFile(zipData:ByteArray, whenDone:Function):void {
		function imagesDecoded():void {
			spr.showCostume(spr.currentCostumeIndex);
			whenDone(spr);
		}
		var spr:ScratchSprite = decodeFromZipFile(zipData) as ScratchSprite;
		if (spr) decodeAllImages([spr], imagesDecoded);
	}

	private function decodeFromZipFile(zipData:ByteArray):ScratchObj {
		var jsonData:String;
		images = [];
		sounds = [];
		try {
			var files:Array = new ZipIO().read(zipData);
		} catch (e:*) {
			app.log('Bad zip file; attempting to recover');
			try {
				files = new ZipIO().recover(zipData);
			} catch (e:*) {
				return null; // couldn't recover
			}
		}
		for each (var f:Array in files) {
			var fName:String = f[0];
			if (fName.indexOf('__MACOSX') > -1) continue; // skip MacOS meta info in zip file
			var fIndex:int = int(integerName(fName));
			var contents:ByteArray = f[1];
			if (fName.slice(-4) == '.gif') images[fIndex] = contents;
			if (fName.slice(-4) == '.jpg') images[fIndex] = contents;
			if (fName.slice(-4) == '.png') images[fIndex] = contents;
			if (fName.slice(-4) == '.svg') images[fIndex] = contents;
			if (fName.slice(-4) == '.wav') sounds[fIndex] = contents;
			if (fName.slice(-4) == '.mp3') sounds[fIndex] = contents;
			if (fName.slice(-5) == '.json') jsonData = contents.readUTFBytes(contents.length);
		}
		if (jsonData == null) return null;
		jsonData = fixForNewExtension(jsonData);
		if(jsonData.indexOf("PicoBoard")>-1){
			DeviceManager.sharedManager().onSelectBoard("picoboard_unknown");
		}else if(jsonData.indexOf("Makeblock")>-1){
			if(!IQCar.app.extensionManager.checkExtensionSelected("Makeblock")){
				IQCar.app.extensionManager.onSelectExtension("Makeblock");
			}
		}else if(jsonData.indexOf("Arduino.")>-1){
			if(!IQCar.app.extensionManager.checkExtensionSelected("Arduino")){
				IQCar.app.extensionManager.onSelectExtension("Arduino");
			}
		}else if(jsonData.indexOf("ICbrick.")>-1){
			if(!IQCar.app.extensionManager.checkExtensionSelected("ICbrick")){
				IQCar.app.extensionManager.onSelectExtension("ICbrick");
			}
	}else if(jsonData.indexOf("IQCar")>-1){
		if(!IQCar.app.extensionManager.checkExtensionSelected("IQCar")){
			IQCar.app.extensionManager.onSelectExtension("IQCar");
		}
	}
	else
	{
		IQCar.app.extensionManager.onSelectExtension("IQCar");
	}
		var jsonObj:Object = util.JSON.parse(jsonData);
		if(jsonObj['info']){
			if(jsonObj['info']['boardVersion']){
				DeviceManager.sharedManager().onSelectBoard(jsonObj['info']['boardVersion']);
			}else{
				//DeviceManager.sharedManager().onSelectBoard("mbot_uno");
				DeviceManager.sharedManager().onSelectBoard("iqcar");
			}
		}
		if (jsonObj['children']) { // project JSON
			var proj:ScratchStage = new ScratchStage();
			proj.readJSON(jsonObj);
			if (proj.penLayerID >= 0) proj.penLayerPNG = images[proj.penLayerID]
			else if (proj.penLayerMD5) proj.penLayerPNG = images[0];
			installImagesAndSounds(proj.allObjects());
			return proj;
		}
		if (jsonObj['direction'] != null) { // sprite JSON
			var sprite:ScratchSprite = new ScratchSprite();
			sprite.readJSON(jsonObj);
			sprite.instantiateFromJSON(app.stagePane)
			installImagesAndSounds([sprite]);
			return sprite;
		}
		return null;
	}
	private var fixList:Array = [
		["arduino\\/main","runIQCar"],
		["Robots.","Makeblock."],
		["MBot.","mBot."],
		["get\\/timer","getTimer"],
		["run\\/timer","resetTimer"],
		["get\\/digital","getDigital"],
		["get\\/analog","getAnalog"],
		["run\\/servo_pin","runServo"],
		["run\\/tone","runTone"],
		["run\\/digital","runDigital"],
		["run\\/pwm","runPwm"],
		["run\\/motor","runMotor"],
		["run\\/servo","runServo"],
		["run\\/steppermotor","runStepperMotor"],
		["run\\/encodermotor","runEncoderMotor"],
		["run\\/sevseg","runSevseg"],
		["run\\/led","runLed"],
		["run\\/lightsensor","runLightSensor"],
		["run\\/shutter","runShutter"],
		["get\\/button_inner","getButtonOnBoard"],
		["get\\/ultrasonic","getUltrasonic"],
		["get\\/linefollower","getLinefollower"],
		["get\\/lightsensor","getLightSensor"],
		["get\\/joystick","getJoystick"],
		["get\\/potentiometer","getPotentiometer"],
		["get\\/soundsensor","getSoundSensor"],
		["get\\/infrared","getInfrared"],
		["get\\/limitswitch","getLimitswitch"],
		["get\\/pirmotion","getPirmotion"],
		["get\\/temperature","getTemperature"],
		["get\\/gyro","getGyro"],
		["run\\/buzzer","runBuzzer"],
		["stop\\/buzzer","stopBuzzer"],
		["get\\/irremote","getIrRemote"],
		["run\\/ir","runIR"],
		["get\\/ir","getIR"],
		['["mBot.getButtonOnBoard"]', '["mBot.getButtonOnBoard", "pressed"]'],
		["mBot.get\\/analog","mBot.getLightOnBoard"],
		["mBot.getAnalog","mBot.getLightOnBoard"],
		['["mBot.getLightOnBoard"]','["mBot.getLightSensor", "light sensor on board"]'],
		['Communication.serial\\/received','Communication.whenReceived'],
		['Communication.serial\\/read\\/available','Communication.isAvailable'],
		['Communication.serial\\/read\\/equal','Communication.isEqual'],
		['Communication.serial\\/read\\/line','Communication.readLine'],
		['Communication.serial\\/write\\/line','Communication.writeLine'],
		['Communication.serial\\/write\\/command','Communication.writeCommand'],
		['Communication.serial\\/read\\/command','Communication.readCommand'],
		['Communication.serial\\/clear','Communication.clearBuffer'],
		['["mBot.runLed", "all",','["mBot.runLed", "led on board","all",']
	];
	private function fixForNewExtension(json:String):String{
		trace(json);
		for(var i:uint=0;i<fixList.length;i++){
			json = json.split(fixList[i][0]).join(fixList[i][1]);
		}
		trace(json);
		return json.split("arduino\\/main").join("runIQCar");
	}
	private function integerName(s:String):String {
		// Return the substring of digits preceding the last '.' in the given string.
		// For example integerName('123.jpg') -> '123'.
		const digits:String = '1234567890';
		var end:int = s.lastIndexOf('.');
		if (end < 0) end = s.length;
		var start:int = end - 1;
		if (start < 0) return s;
		while ((start >= 0) && (digits.indexOf(s.charAt(start)) >= 0)) start--;
		return s.slice(start + 1, end);
	}

	private function installImagesAndSounds(objList:Array):void {
		// Install the images and sounds for the given list of ScratchObj objects.
		for each (var obj:ScratchObj in objList) {
			for each (var c:ScratchCostume in obj.costumes) {
				if (images[c.baseLayerID] != undefined) c.baseLayerData = images[c.baseLayerID];
				if (images[c.textLayerID] != undefined) c.textLayerData = images[c.textLayerID];
			}
			for each (var snd:ScratchSound in obj.sounds) {
				var sndData:* = sounds[snd.soundID];
				if (sndData) {
					snd.soundData = sndData;
					snd.convertMP3IfNeeded();
				}
			}
		}
	}

	public function decodeAllImages(objList:Array, whenDone:Function):void {
		// Load all images in all costumes from their image data, then call whenDone.
		function imageDecoded():void {
			for each (var o:* in imageDict) {
				if (o == 'loading...') return; // not yet finished loading
			}
			allImagesLoaded();
		}
		function allImagesLoaded():void {
			for each (c in allCostumes) {
				if ((c.baseLayerData != null) && (c.baseLayerBitmap == null)) {
					var img:* = imageDict[c.baseLayerData];
					if (img is BitmapData) c.baseLayerBitmap = img;
					if (img is SVGElement) c.setSVGRoot(img, false);
				}
				if ((c.textLayerData != null) && (c.textLayerBitmap == null)) c.textLayerBitmap = imageDict[c.textLayerData];
			}
			for each (c in allCostumes) c.generateOrFindComposite(allCostumes);
			whenDone();
		}

		var c:ScratchCostume;
		var allCostumes:Array = [];
		for each (var o:ScratchObj in objList) {
			for each (c in o.costumes) allCostumes.push(c);
		}
		var imageDict:Dictionary = new Dictionary(); // maps image data to BitmapData
		for each (c in allCostumes) {
			if ((c.baseLayerData != null) && (c.baseLayerBitmap == null)) {
				if (ScratchCostume.isSVGData(c.baseLayerData)) decodeSVG(c.baseLayerData, imageDict, imageDecoded);
				else decodeImage(c.baseLayerData, imageDict, imageDecoded);
			}
			if ((c.textLayerData != null) && (c.textLayerBitmap == null)) decodeImage(c.textLayerData, imageDict, imageDecoded);
		}
		imageDecoded(); // handles case when there were no images to load
	}

	private function decodeImage(imageData:ByteArray, imageDict:Dictionary, doneFunction:Function):void {
		function loadDone(e:Event):void {
			loader.contentLoaderInfo.removeEventListener(Event.COMPLETE, loadDone);
			imageDict[imageData] = e.target.content.bitmapData;
			doneFunction();
		}
		if (imageDict[imageData] != null) return; // already loading or loaded
		imageDict[imageData] = 'loading...';
		var loader:Loader = new Loader();
		loader.contentLoaderInfo.addEventListener(Event.COMPLETE, loadDone);
		loader.loadBytes(imageData);
	}

	private function decodeSVG(svgData:ByteArray, imageDict:Dictionary, doneFunction:Function):void {
		function loadDone(svgRoot:SVGElement):void {
			imageDict[svgData] = svgRoot;
			doneFunction();
		}
		if (imageDict[svgData] != null) return; // already loading or loaded
		var importer:SVGImporter = new SVGImporter(XML(svgData));
		if (importer.hasUnloadedImages()) {
			imageDict[svgData] = 'loading...';
			importer.loadAllImages(loadDone);
		} else {
			imageDict[svgData] = importer.root;
		}
	}

	public function downloadProjectAssets(projectData:ByteArray):void {
		function assetReceived(md5:String, data:ByteArray):void {
			assetDict[md5] = data;
			assetCount++;
			if (!data) {
				app.log('missing asset: ' + md5);
			}
			if (app.lp) {
				app.lp.setProgress(assetCount / assetsToFetch.length);
				app.lp.setInfo(
						assetCount + ' ' +
								Translator.map('of') + ' ' + assetsToFetch.length + ' ' +
								Translator.map('assets loaded'));
			}
			if (assetCount == assetsToFetch.length) {
				installAssets(proj.allObjects(), assetDict);
				app.runtime.decodeImagesAndInstall(proj);
			}
		}
		projectData.position = 0;
		var projObject:Object = util.JSON.parse(projectData.readUTFBytes(projectData.length));
		var proj:ScratchStage = new ScratchStage();
		proj.readJSON(projObject);
		var assetsToFetch:Array = collectAssetsToFetch(proj.allObjects());
		var assetDict:Object = new Object();
		var assetCount:int = 0;
		for each (var md5:String in assetsToFetch) fetchAsset(md5, assetReceived);
	}

	//----------------------------
	// Fetch a costume or sound from the server
	//----------------------------

	public function fetchImage(id:String, costumeName:String, whenDone:Function):URLLoader {
		// Fetch an image asset from the server and call whenDone with the resulting ScratchCostume.
		var c:ScratchCostume;
		function gotCostumeData(data:ByteArray):void {
			if (!data) {
				app.log('Image not found on server: ' + id);
				return;
			}
			if (ScratchCostume.isSVGData(data)) {
				c = new ScratchCostume(costumeName, data);
				c.baseLayerMD5 = id;
				whenDone(c);
			} else {
				var loader:Loader = new Loader();
				loader.contentLoaderInfo.addEventListener(Event.COMPLETE, imageLoaded);
				loader.loadBytes(data);
			}
		}
		function imageLoaded(e:Event):void {
			c = new ScratchCostume(costumeName, e.target.content.bitmapData);
			c.baseLayerMD5 = id;
			whenDone(c);
		}
		gotCostumeData(app.server.getAsset(id));
		return null;
	}

	public function fetchSound(id:String, sndName:String, whenDone:Function):void {
		// Fetch a sound asset from the server and call whenDone with the resulting ScratchSound.
		var sndData:ByteArray = app.server.getAsset(id);
		if (!sndData) {
			app.log('Sound not found on server: ' + id);
			return;
		}
		var snd:ScratchSound;
		try {
			snd = new ScratchSound(sndName, sndData); // try reading data as WAV file
		} catch (e:*) { }
		if (snd && (snd.sampleCount > 0)) { // WAV data
			snd.md5 = id;
			whenDone(snd);
		} else { // try to read data as an MP3 file
			MP3Loader.convertToScratchSound(sndName, sndData, whenDone);
		}
		
	}

	//----------------------------
	// Download a sprite from the server
	//----------------------------

	public function fetchSprite(md5AndExt:String, whenDone:Function):void {
		// Fetch a sprite with the md5 hash.
		function assetsReceived(assetDict:Object):void {
			installAssets([spr], assetDict);
			decodeAllImages([spr], done);
		}
		function done():void {
			spr.showCostume(spr.currentCostumeIndex);
			spr.setDirection(spr.direction);
			whenDone(spr);
		}
		var spr:ScratchSprite = new ScratchSprite();
		var data:ByteArray = app.server.getAsset(md5AndExt);
		if (!data) return;
		spr.readJSON(util.JSON.parse(data.readUTFBytes(data.length)));
		spr.instantiateFromJSON(app.stagePane);
		fetchSpriteAssets([spr], assetsReceived);
	}

	private function fetchSpriteAssets(objList:Array, whenDone:Function):void {
		// Download all media for the given list of ScratchObj objects.
		function assetReceived(md5:String, data:ByteArray):void {
			if (!data) {
				app.log('missing sprite asset: ' + md5);
			}
			assetDict[md5] = data;
			assetCount++;
			if (assetCount == assetsToFetch.length) whenDone(assetDict);
		}
		var assetDict:Object = new Object();
		var assetCount:int = 0;
		var assetsToFetch:Array = collectAssetsToFetch(objList);
		for each (var md5:String in assetsToFetch) fetchAsset(md5, assetReceived);
	}

	private function collectAssetsToFetch(objList:Array):Array {
		// Return list of MD5's for all project assets.
		var list:Array = new Array();
		for each (var obj:ScratchObj in objList) {
			for each (var c:ScratchCostume in obj.costumes) {
				if (list.indexOf(c.baseLayerMD5) < 0) list.push(c.baseLayerMD5);
				if (c.textLayerMD5) {
					if (list.indexOf(c.textLayerMD5) < 0) list.push(c.textLayerMD5);
				}
			}
			for each (var snd:ScratchSound in obj.sounds) {
				if (list.indexOf(snd.md5) < 0) list.push(snd.md5);
			}
		}
		return list;
	}

	private function installAssets(objList:Array, assetDict:Object):void {
		var data:ByteArray;
		for each (var obj:ScratchObj in objList) {
			for each (var c:ScratchCostume in obj.costumes) {
				data = assetDict[c.baseLayerMD5];
				if (data) c.baseLayerData = data;
				else c.baseLayerData = ScratchCostume.emptySVG(); // missing asset data; use empty costume
				if (c.textLayerMD5) c.textLayerData = assetDict[c.textLayerMD5];
			}
			for each (var snd:ScratchSound in obj.sounds) {
				data = assetDict[snd.md5];
				if (data) {
					snd.soundData = data;
					snd.convertMP3IfNeeded();
				} else {
					snd.soundData = WAVFile.empty();
				}
			}
		}
	}

	public function fetchAsset(md5:String, whenDone:Function):void {
		var data:ByteArray = app.server.getAsset(md5);
		whenDone(md5, data);
	}

	//----------------------------
	// Record unique images and sounds
	//----------------------------

	protected function recordImagesAndSounds(objList:Array, uploading:Boolean, proj:ScratchStage = null):void {
		var recordedAssets:Object = {};
		images = [];
		sounds = [];

		app.clearCachedBitmaps();
		if (!uploading && proj) proj.penLayerID = recordImage(proj.penLayerPNG, proj.penLayerMD5, recordedAssets, uploading);

		for each (var obj:ScratchObj in objList) {
			for each (var c:ScratchCostume in obj.costumes) {
				c.prepareToSave(); // encodes image and computes md5 if necessary
				c.baseLayerID = recordImage(c.baseLayerData, c.baseLayerMD5, recordedAssets, uploading);
				if (c.textLayerBitmap) {
					c.textLayerID = recordImage(c.textLayerData, c.textLayerMD5, recordedAssets, uploading);
				}
			}
			for each (var snd:ScratchSound in obj.sounds) {
				snd.prepareToSave(); // compute md5 if necessary
				snd.soundID = recordSound(snd, snd.md5, recordedAssets, uploading);
			}
		}
	}

	public function convertSqueakSounds(scratchObj:ScratchObj, done:Function):void {
		// Pre-convert any Squeak sounds (asynch, with a progress bar) before saving a project.
		// Note: If this is not called before recordImagesAndSounds(), sounds will
		// be converted synchronously, but there may be a long delay without any feedback.
		function convertASound():void {
			if (i < soundsToConvert.length) {
				var sndToConvert:ScratchSound = soundsToConvert[i++] as ScratchSound;
				sndToConvert.prepareToSave();
				app.lp.setProgress(i / soundsToConvert.length);
				app.lp.setInfo(sndToConvert.soundName);
				setTimeout(convertASound, 50);
			} else {
				app.removeLoadProgressBox();
				// Note: Must get user click in order to proceed with saving...
				DialogBox.notify('', 'Sounds converted', app.stage, false, soundsConverted);
			}
		}
		function soundsConverted(ignore:*):void { done(this) }
		var soundsToConvert:Array = [];
		for each (var obj:ScratchObj in scratchObj.allObjects()) {
			for each (var snd:ScratchSound in obj.sounds) {
				if ('squeak' == snd.format) soundsToConvert.push(snd);
			}
		}
		var i:int;
		if (soundsToConvert.length > 0) {
			app.addLoadProgressBox('Converting sounds...');
			setTimeout(convertASound, 50);
		} else done(this);
	}

	private function recordImage(img:*, md5:String, recordedAssets:Object, uploading:Boolean):int {
		var id:int = recordedAssetID(md5, recordedAssets, uploading);
		if (id > -2) return id; // image was already added
		images.push([md5, img]);
		id = images.length - 1;
		recordedAssets[md5] = id;
		return id;
	}

	protected function recordedAssetID(md5:String, recordedAssets:Object, uploading:Boolean):int {
		var id:* = recordedAssets[md5];
		return id != undefined ? id : -2;
	}

	private function recordSound(snd:ScratchSound, md5:String, recordedAssets:Object, uploading:Boolean):int {
		var id:int = recordedAssetID(md5, recordedAssets, uploading);
		if (id > -2) return id; // image was already added
		sounds.push([md5, snd.soundData]);
		id = sounds.length - 1;
		recordedAssets[md5] = id;
		return id;
	}
}}
