// @ExecutionModes({on_single_node="main_menu_scripting/scripts[addons.installer.title]"})
// Copyright (C) 2011 Volker Boerchers
//
// 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.

import groovy.swing.SwingBuilder

import java.awt.Dimension
import java.awt.FlowLayout
import java.awt.Toolkit
import java.awt.event.ActionEvent;
import java.util.zip.ZipInputStream

import javax.swing.BoxLayout
import javax.swing.JOptionPane
import javax.swing.KeyStroke

import org.apache.commons.lang.StringUtils
import org.apache.commons.lang.WordUtils
import org.freeplane.core.resources.ResourceController
import org.freeplane.core.ui.AFreeplaneAction;
import org.freeplane.core.util.FreeplaneVersion
import org.freeplane.core.util.TextUtils
import org.freeplane.features.mode.Controller
import org.freeplane.main.addons.AddOnProperties
import org.freeplane.main.addons.AddOnsController
import org.freeplane.plugin.script.ExecuteScriptAction;
import org.freeplane.plugin.script.ScriptingPermissions
import org.freeplane.plugin.script.addons.AddOnDetailsPanel
import org.freeplane.plugin.script.addons.ScriptAddOnProperties
import org.freeplane.plugin.script.proxy.Proxy

//
// == script bindings (globals) ==
//
dialogTitle = textUtils.getText('addons.installer.title')
installationbase = c.userDirectory
addonsUrl = "https://www.freeplane.org/addons"

// parse result
configMap = [:]

//
// == methods ==
//
def debugPrintln(String message) {
    // println message
}

def terminate(String message) {
	throw new Exception(textUtils.getText('addons.installer.canceled') + ': ' + message)
}

def confirm(String question) {
	final int selection = JOptionPane.showConfirmDialog(ui.frame, question, dialogTitle, JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE);
	return selection == JOptionPane.OK_OPTION
}

boolean yesNoOrTerminate(String question) {
	final int selection = JOptionPane.showConfirmDialog(ui.frame, question, dialogTitle, JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE);
	if (selection != JOptionPane.YES_OPTION)
		throw new Exception(textUtils.getText('addons.installer.canceled'))
	return selection == JOptionPane.YES_OPTION
}

def mapStructureAssert(check, String issue) {
    if (! check)
        throw new Exception(WordUtils.wrap(textUtils.format('addons.installer.map.structure', issue), 80, null, true))
}

def installationAssert(boolean check, String issue) {
	if (! check)
		throw new Exception(textUtils.format('addons.installer.failed', issue))
}

def parseProperties(Map childNodeMap) {
	def property = 'properties'
	Proxy.Node propertyNode = node.map.root
	configMap[property] = propertyNode.attributes.map.inject([:]){ map, k, v ->
		if (v)
			map[k] = k.startsWith('freeplaneVersion') ? parseFreeplaneVersion(k, v) : v
		return map
	}
	configMap[property]['title'] = propertyNode.plainText
	def mandatoryPropertyNames = [
		'name',
		'version',
		'author',
		'freeplaneVersionFrom'
		// optional: 'freeplaneVersionTo'
	]
	def missingProperties = mandatoryPropertyNames.findAll {
		! configMap[property][it]
	}
	mapStructureAssert( ! missingProperties, textUtils.format('addons.installer.missing.properties', missingProperties))
	configMap[property]['homepage'] = propertyNode.link.text ?
		propertyNode.link.uri.toURL() : new URL(expandVariables(addonsUrl + '/${name}'))
    debugPrintln property + ': ' + configMap[property]
}

def checkFreeplaneVersion(Map configMap) {
	FreeplaneVersion currentVersion = c.freeplaneVersion
	def versionFrom = configMap['properties']['freeplaneVersionFrom']
	if (currentVersion.isOlderThan(versionFrom))
		terminate(textUtils.format('addons.installer.too.old', currentVersion, versionFrom))
	def versionTo = configMap['properties'].get('freeplaneVersionTo')
	if (versionTo && currentVersion.isNewerThan(versionTo))
		terminate(textUtils.format('addons.installer.too.new', currentVersion, versionTo))
}

// version could be a FormattedObject
def parseFreeplaneVersion(String propertyName, Object version) {
	try {
		if (version)
			return FreeplaneVersion.getVersion(version.toString().replaceFirst('^v', ''))
		return null
	}
	catch (Exception e) {
		e.printStackTrace()
		mapStructureAssert(false, textUtils.format('addons.installer.freeplaneversion.format.error', propertyName, version))
	}
}

def parseDescription(Map childNodeMap) {
	def property = 'description'
	Proxy.Node propertyNode = childNodeMap[property]
	configMap[property] = theOnlyChild(propertyNode).text
	debugPrintln property + ': ' + configMap[property]
}

def parseLicence(Map childNodeMap) {
	def property = 'license'
	Proxy.Node propertyNode = childNodeMap[property]
	configMap[property] = theOnlyChild(propertyNode).text
	debugPrintln property + ': ' + configMap[property]
}

def parseTranslations(Map childNodeMap) {
	def property = 'translations'
	Proxy.Node propertyNode = childNodeMap[property]
	// a Map<locale, Map<key, translation>>

	def translationsMap = propertyNode.children.inject([:]){ map, localeNode ->
		def locale = localeNode.plainText
		map[locale] = localeNode.attributes.map.inject([:]){ localeMap, k, v ->
			localeMap[expandVariables(k)] = v
			return localeMap
		}
		def key = ScriptAddOnProperties.getNameKey(configMap['properties']['name'])
		def expKey = expandVariables(key)
		mapStructureAssert(map[locale][expKey], textUtils.format('addons.installer.missing.translation', key, locale))
		return map
	}
	configMap[property] = translationsMap
	debugPrintln property + ': ' + configMap[property]
}

def parsePreferencesXml(Map childNodeMap) {
	def property = 'preferences.xml'
	Proxy.Node propertyNode = childNodeMap[property]
	configMap[property] = propertyNode.isLeaf() ? null : propertyNode.children[0].text
	debugPrintln property + ': ' + configMap[property]
}

def parseDefaultProperties(Map childNodeMap) {
	def property = 'default.properties'
	Proxy.Node propertyNode = childNodeMap[property]
	configMap[property] = propertyNode.attributes.map.inject([:]){ map, k, v ->
		map[expandVariables(k)] = expandVariables(v)
		return map
	}
	debugPrintln property + ': ' + configMap[property]
}

def parseZips(Map childNodeMap) {
	def property = 'zips'
	Proxy.Node propertyNode = childNodeMap[property]
	configMap[property] = propertyNode.children.collect{ ensureNoHtml(theOnlyChild(it)).binary }
	debugPrintln property + ': ' + configMap[property].dump()
}

def parseLib(Map childNodeMap) {
	def property = 'lib'
	Proxy.Node propertyNode = childNodeMap[property]
	if (!propertyNode)
		return
	configMap[property] = propertyNode.children.inject([:]){ map, child ->
		map[child.plainText] = ensureNoHtml(theOnlyChild(child)).binary
		return map
	}
	debugPrintln property + ': ' + configMap[property].dump()
}

def parseImages(Map childNodeMap) {
    def property = 'images'
    Proxy.Node propertyNode = childNodeMap[property]
    if (!propertyNode)
        return
    configMap[property] = propertyNode.children.inject([:]){ map, child ->
        map[child.plainText] = ensureNoHtml(theOnlyChild(child)).binary
        return map
    }
    debugPrintln property + ': ' + configMap[property].dump()
}

def installZips() {
	File destDir = installationbase
	configMap['zips'].each{ zipData ->
		try {
			unpack(destDir, zipData)
		} catch (Exception e) {
			e.printStackTrace()
			installationAssert(false, e.message);
		}
	}
}

def installImages() {
    File destDir = new File(installationbase, 'resources/images')
    destDir.mkdirs()
    configMap['images'].each{ filename, imageData ->
        try {
            new File(destDir, expandVariables(filename)).bytes = imageData
        } catch (Exception e) {
            e.printStackTrace()
            installationAssert(false, e.message);
        }
    }
}

def installLib() {
	File destDir = new File(privateAddonDir(), 'lib')
	destDir.mkdirs()
	configMap['lib'].each{ filename, libData ->
		try {
			new File(destDir, expandVariables(filename)).bytes = libData
		} catch (Exception e) {
			e.printStackTrace()
			installationAssert(false, e.message);
		}
	}
}

void unpack(File destDir, byte[] zipData) {
    mapStructureAssert(zipData, textUtils.getText('addons.installer.no.zipdata'))
	ZipInputStream result = new ZipInputStream(new ByteArrayInputStream(zipData))
	result.withStream{
		def entry
		while(entry = result.nextEntry){
			if (!entry.isDirectory()){
				def destFile = new File(destDir, entry.name)
				destFile.parentFile?.mkdirs()
				def output = new FileOutputStream(destFile)
				output.withStream{
					int len = 0;
					byte[] buffer = new byte[4096]
					while ((len = result.read(buffer)) > 0){
						output.write(buffer, 0, len);
					}
				}
			}
		}
	}
}

/** ensures that 'parent' has exactly one child node. */
Proxy.Node theOnlyChild(Proxy.Node parent) {
    if (parent.children.size() != 1)
        logger.warn(StringUtils.abbreviate(parent.plainText, 32), new RuntimeException("dummy"))
	mapStructureAssert(parent.children.size() == 1,
		textUtils.format('addons.installer.one.child.expected',
            StringUtils.abbreviate(parent.plainText, 32), parent.children.size()))
	return parent.children.first()
}

/** ensures that 'first' has non-HTML text. */
Proxy.Node ensureNoHtml(Proxy.Node first) {
	mapStructureAssert( ! htmlUtils.isHtmlNode(first.text), textUtils.getText('addons.installer.html.script'))
	return first
}

def parseScripts(Map childNodeMap) {
	def property = 'scripts'
	Proxy.Node propertyNode = childNodeMap[property]
	configMap[property] = propertyNode.children.inject([]){ scripts, scriptNode ->
		def script = new ScriptAddOnProperties.Script()
		script.name = expandVariables(scriptNode.plainText)
		script.scriptBody = ensureNoHtml(theOnlyChild(scriptNode)).text
		mapStructureAssert( ! htmlUtils.isHtmlNode(script.scriptBody), textUtils.getText('addons.installer.html.script'))
		scriptNode.attributes.map.each { k,v ->
			if (k == 'executionMode')
				script[k] = ScriptAddOnProperties.parseExecutionMode(v)
			else if ( ! k.toString().toLowerCase().startsWith('execute_scripts_'))
				script[k] = expandVariables(v)
		}
		script.permissions = parsePermissions(scriptNode, script.name)
		mapStructureAssert(script.name.matches('.*\\.\\w+'), textUtils.format('addons.installer.script.name.suffix', script.name))
		mapStructureAssert(script.menuTitleKey, textUtils.format('addons.installer.script.no.menutitle', script))
		mapStructureAssert(script.menuLocation, textUtils.format('addons.installer.script.no.menulocation', script))
		mapStructureAssert(script.executionMode, textUtils.format('addons.installer.script.no.execution_mode', script))
		mapStructureAssert(script.permissions, textUtils.format('addons.installer.script.no.permissions', script))
		scripts << script
		return scripts
	}
//	mapStructureAssert(configMap[property], textUtils.getText('addons.installer.no.scripts'))
	debugPrintln property + ': ' + configMap[property].dump()
}

void createKeyboardShortcut(ScriptAddOnProperties.Script script) {
	def newShortcut = script.keyboardShortcut
    // check key syntax
	KeyStroke newKeyStroke = ui.getKeyStroke(newShortcut)
	mapStructureAssert(newKeyStroke, textUtils.format('addons.installer.invalid.keyboard.shortcut', newShortcut))
	String menuItemKey = ExecuteScriptAction.makeMenuItemKey(script.menuTitleKey, script.executionMode)
	def action = new AFreeplaneAction(menuItemKey, menuItemKey, null) {
		public void actionPerformed(ActionEvent e) {}
	};
	def acceleratorManager = ResourceController.resourceController.acceleratorManager
	acceleratorManager.newAccelerator(action, newKeyStroke)
}

ScriptingPermissions parsePermissions(Proxy.Node propertyNode, String scriptName) {
	def permissionNames = ScriptingPermissions.permissionNames.findAll { it.startsWith('execute_') }
	def missingPermissions = permissionNames.findAll{ !propertyNode[it] }
	mapStructureAssert( ! missingPermissions, textUtils.format('addons.installer.missing.permission.attribute', scriptName, missingPermissions))
	def permissions = propertyNode.attributes.map.findAll { k,v -> permissionNames.contains(k) }
	return new ScriptingPermissions(permissions as Properties)
}

// a list of [action, file] pairs
def parseDeinstallationRules(Map childNodeMap) {
	def property = 'deinstall'
	Proxy.Node propertyNode = childNodeMap[property]
	def attribs = propertyNode.attributes
	// we can't use a simple map since most entries have the same key -> iterate over index
	configMap[property] = (0..attribs.size()-1).collect {
		// the right type for AddOnProperties
		[attribs.getKey(it), expandVariables(attribs.get(it)).trim()] as String[]
	}
	def knownDeinstallationRules = [
		'delete'
	]
	def unknownDeinstallationRules = attribs.names.findAll{ k -> ! knownDeinstallationRules.contains(k) }
	mapStructureAssert( ! unknownDeinstallationRules, textUtils.format('addons.installer.unknown.deinstallation.rules', unknownDeinstallationRules))
	debugPrintln property + ': ' + configMap[property]
}

def handlePermissions() {
	def permissionMap = configMap['permissions']
    def nonStandardPermissions = permissionMap.keySet().findAll{
		config.getProperty(it, 'false') == 'false' && permissionMap[it] == true
	}.collect {
		textUtils.getText(it)
	}
	if (yesNoOrTerminate(textUtils.format('addons.installer.nonstandard.permissions', nonStandardPermissions))) {
		nonStandardPermissions.each {
			ResourceController.resourceController.setProperty(it, 'true')
		}
	}
}

def addOnDir() {
	File dir = new File(installationbase, 'addons')
	installationAssert(dir.exists(), null)
	return dir
}

def privateAddonDir() {
	new File(addOnDir(), configMap['properties']['name'])
}

def installScripts() {
	File destDir = new File(privateAddonDir(), 'scripts')
	destDir.mkdirs()
	configMap['scripts'].each { script ->
		File file = new File(destDir, script.name)
		try {
			file.text = script.scriptBody
		}
		catch (Exception e) {
			terminate(e.message)
		}
		if (script.keyboardShortcut)
			createKeyboardShortcut(script)
	}
}

def expandVariables(Object o) {
	Map variableMap = configMap['properties']
	// expands strings like "${name}.groovy"
	String.valueOf(o).replaceAll(/\$\{([^}]+)\}/, { match, key -> variableMap[key] ? variableMap[key] : match })
}

AddOnProperties parse() {
	def propertyNames = [
		'description',
		'license',
		'translations',
		'preferences.xml',
		'default.properties',
		'scripts',
		'zips',
		'deinstall',
		'images',
		'lib',
	]
	Map<String, Proxy.Node> childNodeMap = node.map.root.children.inject([:]) { map, child ->
		map[child.plainText] = child
		return map
	}
	def Map<String, Proxy.Node> missingChildNodes = childNodeMap.findAll{ k,v->
		v == null
	}
    // note: images came after the first beta
    missingChildNodes.remove('images')
	missingChildNodes.remove('lib')
	mapStructureAssert( ! missingChildNodes, textUtils.format('addons.installer.missing.child.nodes', missingChildNodes.keySet()))

	parseProperties(childNodeMap)
	checkFreeplaneVersion(configMap)
	parseDescription(childNodeMap)
	parseLicence(childNodeMap)
	parseTranslations(childNodeMap)
	parsePreferencesXml(childNodeMap)
	parseDefaultProperties(childNodeMap)
	parseScripts(childNodeMap)
	parseZips(childNodeMap)
	parseImages(childNodeMap)
	parseLib(childNodeMap)
	parseDeinstallationRules(childNodeMap)

	def addOn = new ScriptAddOnProperties(configMap['properties']['name'])
	configMap['properties'].each { k,v ->
		if (addOn.hasProperty(k))
			addOn[k] = v
		else if (k != "title")
			logger.warn("add-on has no property $k (hopefully that's not bad)")
	}
	addOn.description = configMap['description']
	addOn.license = configMap['license']
	addOn.translations = configMap['translations']
	addOn.preferencesXml = configMap['preferences.xml']
	addOn.defaultProperties = configMap['default.properties']
	addOn.deinstallationRules = configMap['deinstall']
    addOn.images = configMap['images'] ? configMap['images'].keySet() : []
	addOn.lib = configMap['lib'] ? configMap['lib'].keySet() : []
	addOn.scripts = configMap['scripts']

	return addOn
}

boolean confirmInstall(ScriptAddOnProperties addOn, ScriptAddOnProperties installedAddOn) {
	def screenSize = Toolkit.getDefaultToolkit().getScreenSize();
	def dialogPrefSize = new Dimension((int) (screenSize.getWidth() * 3 / 5), (int) (screenSize.getHeight() * 1 / 2));
	def warning = textUtils.removeTranslateComment(textUtils.getText('addons.installer.warning'))
	def addOnDetailsPanel = new AddOnDetailsPanel(addOn, warning)
	addOnDetailsPanel.maxWidth = 500
	def installButtonText = installedAddOn ? textUtils.format('addons.installer.update', installedAddOn.version)
		: textUtils.getText('addons.installer.install')

	def s = new SwingBuilder()
	s.setVariable('myDialog-properties',[:])
	def vars = s.variables
	def dial = s.dialog(title:dialogTitle, id:'myDialog', modal:true,
						locationRelativeTo:ui.frame, owner:ui.frame, pack:true, preferredSize:dialogPrefSize) {
		scrollPane() {
			panel() {
				panel(alignmentX:0.05) {
					flowLayout(alignment:FlowLayout.LEFT)
					button(action: action(name: textUtils.getText('cancel'), mnemonic: 'C', closure: {dispose()}))
					defaultButton = button(id:'defBtn', action: action(name: installButtonText,
						mnemonic: 'I', defaultButton:true, selected:true, closure: {vars.ok = true; dispose()}))
				}
				boxLayout(axis:BoxLayout.Y_AXIS)
				widget(addOnDetailsPanel)
				panel(alignmentX:0.05) {
					flowLayout(alignment:FlowLayout.RIGHT)
					button(action: action(name: textUtils.getText('cancel'), mnemonic: 'C', closure: {dispose()}))
					defaultButton = button(id:'defBtn', action: action(name: installButtonText,
						mnemonic: 'I', defaultButton:true, selected:true, closure: {vars.ok = true; dispose()}))
				}
			}
		}
	}
	defaultButton.requestFocusInWindow()
    ui.addEscapeActionToDialog(dial)
    ui.setDialogLocationRelativeTo(dial, ui.frame)
    dial.visible = true
	if (!vars.ok)
		return false
	// 2. license
	boolean licenseUnchanged = addOn.license && installedAddOn?.license && addOn.license.equals(installedAddOn.license)
	def license = addOn.license.replaceAll('</?(html|body|head)>', '').trim()
	def question = textUtils.removeTranslateComment(textUtils.format('addons.installer.confirm.licence', license)).replace("\n", "<p>")
	if (licenseUnchanged)
		c.statusInfo = textUtils.getText('addons.installer.licence.unchanged')
	if (addOn.license && !licenseUnchanged && !confirm(question))
		return false
    // really bother the user with such details?
	// 3. permissions
	//	handlePermissions()
	return true
}

def install(AddOnProperties addOn) {
	installScripts()
	installZips()
	installImages()
	installLib()
	new File(addOnDir(), expandVariables('${name}.script.xml')).text = addOn.toXmlString()
}

// == main ==
try {
	def addOn = parse()
	AddOnsController.registerAddOnResources(addOn, ResourceController.resourceController)
	def installedAddOn = AddOnsController.getController().getInstalledAddOn(addOn.name)
	def isUpdate = installedAddOn != null
	if (confirmInstall(addOn, installedAddOn)) {
		def message
		if (isUpdate) {
			AddOnsController.getController().deinstall(installedAddOn)
			message = textUtils.format('addons.installer.success.update', installedAddOn.version, addOn.version)
		}
		else {
			message = textUtils.getText('addons.installer.success')
		}
		install(addOn)
		JOptionPane.showMessageDialog(ui.frame, message, dialogTitle, JOptionPane.INFORMATION_MESSAGE)
		return addOn
	}
	return null
} catch (Exception e) {
	JOptionPane.showMessageDialog(ui.frame, e.message, dialogTitle, JOptionPane.ERROR_MESSAGE)
	logger.warn("installation failure", e)
	return null
}
