/* ###
 * IP: GHIDRA
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import org.apache.tools.ant.filters.ReplaceTokens

defaultTasks 'buildExtension'

apply plugin: 'java-library'

/*****************************************************************************************
 *
 * Reads the application.properties file and sets properties for the version, 
 *  		release name, and distro prefix (ghidira_<version>)
 *
 *****************************************************************************************/
def ghidraInstallDir = file(buildscript.sourceFile.getAbsolutePath() + "/../..").getCanonicalFile().getAbsolutePath()
def ghidraDir = file(ghidraInstallDir + "/Ghidra").getCanonicalFile().getAbsolutePath()
def ghidraProps = new Properties()
file(ghidraDir + "/application.properties").withReader { reader ->
	ghidraProps.load(reader)
	project.ext.ghidra_version = ghidraProps.getProperty('application.version')
	project.ext.RELEASE_NAME = ghidraProps.getProperty('application.release.name')
	project.ext.DISTRO_PREFIX = "ghidra_${ghidra_version}"
	project.ext.GRADLE_MIN = ghidraProps.getProperty('application.gradle.min')
	project.ext.GRADLE_MAX = ghidraProps.getProperty('application.gradle.max')
}

/***************************************************************************************
 * Make sure a supported version of Gradle is being used
 ***************************************************************************************/
checkGradleVersion()

task copyDependencies(type: Copy) {
	group = "Ghidra Private"
	from configurations.runtimeClasspath
	into "lib"
	exclude { fileTreeElement ->
		def fileAbsPath = fileTreeElement.getFile().getCanonicalFile().toPath()
		// Avoid including Ghidra Jars in lib folder...
		def isGhidraJar = fileAbsPath.startsWith(ghidraInstallDir)
		// ...and jars already in the destination location
		def destLibDir = project.file("lib").getCanonicalFile().toPath()
		def isFromDest = fileAbsPath.startsWith(destLibDir)
		return isGhidraJar || isFromDest
	}
}

compileJava {
    sourceCompatibility = ghidraProps.getProperty('application.java.compiler')
    targetCompatibility = ghidraProps.getProperty('application.java.compiler')
    dependsOn copyDependencies
}

dependencies {
	api fileTree(dir: 'lib', include: "*.jar")
	api fileTree(dir: ghidraDir + '/Framework', include: "**/*.jar")
	api fileTree(dir: ghidraDir + '/Features', include: "**/*.jar")
	api fileTree(dir: ghidraDir + '/Debug', include: "**/*.jar")
	api fileTree(dir: ghidraDir + '/Processors', include: "**/*.jar")
}
	
def ZIP_NAME_PREFIX = "${DISTRO_PREFIX}_${RELEASE_NAME}_${getCurrentDate()}"
def DISTRIBUTION_DIR = file("dist")
	
def pathInZip = "${project.name}"

task zipSource (type: Zip) {
	group = "Ghidra Private"
		
	// Define some metadata about the zip (name, location, version, etc....)
	it.archiveBaseName = project.name + "-src"
	it.archiveExtension = 'zip'
	it.destinationDirectory = file(project.projectDir.path + "/build/tmp/src")
	it.includeEmptyDirs = false
		
	// We MUST copy from a directory, and not just grab a list of source files.  
	// This is the only way to preserve the directory structure.
	it.from project.projectDir
	it.include 'src/main/java/'
}

task buildExtension (type: Zip) {
	group = "Ghidra Private"
				
	archiveBaseName = "${ZIP_NAME_PREFIX}_${project.name}"
	archiveExtension = 'zip'
	destinationDirectory = DISTRIBUTION_DIR
	archiveVersion = ''
	
	// Make sure that we don't try to copy the same file with the same path into the
	// zip (this can happen!)
	duplicatesStrategy = 'exclude'
	
	// This filtered property file copy must appear before the general 
	// copy to ensure that it is prefered over the unmodified file
	File propFile = new File(project.projectDir, "extension.properties")
	from (propFile) {
		String version = "${ghidra_version}"
		String name = "${project.name}"
		filter (ReplaceTokens, tokens: [extversion: version])
		filter (ReplaceTokens, tokens: [extname: name])
		into pathInZip
	}
	
	from (project.jar) {
		into pathInZip + "/lib"
	} 
		
	from (project.projectDir) {
		exclude 'build/**'
		exclude '*.gradle'
		exclude 'certification.manifest'
		exclude 'dist/**'
		exclude 'bin/**'
		exclude 'src/**'
		exclude '.gradle/**'
		exclude '.vscode/**'
		exclude '.classpath'
		exclude '.project'
		exclude '.pydevproject'
		exclude '.settings/**'
		exclude 'developer_scripts'
		exclude '.antProperties.xml'
		exclude 'gradlew'
		exclude 'gradlew.bat'
		exclude 'gradle/wrapper/gradle-wrapper.jar'
		exclude 'gradle/wrapper/gradle-wrapper.properties'
		
		into pathInZip
	}
			
	/////////////////
	// SOURCE
	/////////////////
	from (tasks["zipSource"]) {
		into pathInZip + "/lib"
	}.dependsOn(zipSource)
		
		
	/////////////////
	// GLOBALS
	/////////////////		
						
	// First get a list of all files that are under 'src/global'.	
	FileTree fileTree = project.fileTree('src/global') {
		include '**/*'
	}

			
	// Now loop over each one, copying it into the zip we're creating.  Each will be placed
	// at the root level, starting with the first folder AFTER 'src/global/'.  
	//
	// eg: 	If the file is '/Ghidra/Extensions/Sample/src/global/docs/hello.html', then
	// 		the file in the zip will be at /docs/hello.html
	//
	fileTree.each { File file ->
		String filePath = stripGlobalFilePath(file)
		from (file) {
			into filePath
		}
	}

	doLast {
		println "\nCreated ${archiveBaseName.get()}.${archiveExtension.get()} in ${destinationDirectory.get()}"
	}
}

/*********************************************************************************
 * Help Build Code
 * Note: This code is derived from helpProject.gradle.   Required changes should
 *       be made to that file and then reapplied here.
*********************************************************************************/

sourceSets {

	// register help resources to be considered inputs to this project; when these resources change,
	// this project will be considered out-of-date
	main {
		resources {
			srcDir 'src/main/help'   // help .html files to be copied to the jar
			srcDir 'build/help/main' // generated help items (from the indexer); copied to the jar
		}
	}
}

// Turns the given file into a 'normalized' path using the Java Path API
def normalize(File file) {
	def path = null;
	try {
		path = java.nio.file.Paths.get(file.getAbsolutePath());
	}
	catch (Exception e) { // InvalidPathException
		// we have seen odd strings being placed into the classpath--ignore them
		return cpPath;
	}	
	
	def normalizedPath = path.normalize();
	def absolutePath = normalizedPath.toAbsolutePath();
	return absolutePath.toString();
}

// Returns the Ghidra module directory for the given file if it is a Ghidra jar file
def getModulePathFromJar(File file) {

	String path = normalize(file)
	String forwardSlashedPath = path.replaceAll("\\\\", "/")
	def jarPattern = ~'.*/(.*)/(?:lib|build/libs)/(.+).jar'
	def matcher = jarPattern.matcher(forwardSlashedPath)
	if (!matcher.matches()) {
		return null
	}
	
	def moduleName = matcher.group(1);
	def index = forwardSlashedPath.indexOf(moduleName) + moduleName.length()
	return forwardSlashedPath.substring(0, index)
}

// This method contains logic for calculating help inputs based on the classpath of the project
// The work is cached, as the inputs may be requested multiple times during a build
ext.helpInputsCache = null
def getHelpInputs(Collection fullClasspath) {
	
	if (ext.helpInputsCache != null) {
		return ext.helpInputsCache
	}
	
	def results = new HashSet<File>()
	
	fullClasspath.each {
	
		String moduleDirPath = getModulePathFromJar(it)
		if (moduleDirPath == null) {
			return // continue
		}
		
		getHelpInputsFromModule(moduleDirPath, results)
	}
	
	// the classpath above does not include my module's contents, so add that manually
	def modulePath = file('.').getAbsolutePath()
	getHelpInputsFromModule(modulePath, results)
	
	ext.helpInputsCache = results.findAll(File::exists)	
	return ext.helpInputsCache
}

def getHelpInputsFromModule(String moduleDirPath, Set<File> results) {
	
	// add all desired directories now and filter later those that do not exist	
	File moduleDir = new File(moduleDirPath) 
	results.add(new File(moduleDir, 'src/main/resources')) // images		
	results.add(new File(moduleDir, 'src/main/help'))      // html files
			
	File dataDir = new File(moduleDir, 'data')               // theme properties files
	if (dataDir.exists()) {
		FileCollection themeFiles = fileTree(dataDir) {
			include '**/*.theme.properties'
		}			
		results.addAll(themeFiles.getFiles())
	}	
}

// Returns true if the given file is a jar file that contains a '/help/topics' diretory
def hasJarHelp(File file) {
	
	if (!file.exists()) {
		return false
	}
	
	if (!file.getAbsolutePath().endsWith(".jar")) {
		return false
	}
	
	def fileSystem = null;	
	try {		
		def jarURI = new URI("jar:file://" + file.toURI().getRawPath());
		fileSystem = java.nio.file.FileSystems.getFileSystem(jarURI);		
	}
	catch (Exception e) { // FileSystemNotFoundException
		// handled below	
	}
	
	if (fileSystem == null) {
		// not yet created; try to create the file system
		def jarURI = new URI("jar:file://" + file.toURI().getRawPath());
		def env = Map.of("create", "false")
		fileSystem = java.nio.file.FileSystems.newFileSystem(jarURI, env);
	}
	
	def topicsPath = fileSystem.getPath("/help/topics");
	return java.nio.file.Files.exists(topicsPath)
}

tasks.register('cleanHelp') {
	group = "Ghidra Private"
	
	File helpOutput = file('build/help/main/help')
	doFirst {
		delete helpOutput
	}
}

// Task for calling the java help indexer, which creates a searchable index of the help contents
tasks.register('indexHelp', JavaExec) {
	group = "Ghidra Private"

	File helpRootDir = file('src/main/help/help')		
	File outputFile = file("build/help/main/help/${project.name}_JavaHelpSearch")	

	inputs.dir helpRootDir skipWhenEmpty()
	outputs.dir outputFile
	
	classpath = sourceSets.main.runtimeClasspath
	
	mainClass = 'com.sun.java.help.search.Indexer'

	doFirst {
	
		// gather up all the help files into a file collection
		FileTree helpFiles = fileTree('src/main/help') {
			include '**/*.htm'
			include '**/*.html'
		}

		// The index has a config file parameter.  The only thing we use in the config file
		// is a root directory path that should be stripped off all the help references to
		// make them relative instead of absolute	
		File configFile = file('build/helpconfig')	
		
		// create the config file when the task runs and not during configuration.
		configFile.parentFile.mkdirs();
		configFile.write "IndexRemove ${helpRootDir.absolutePath}" + File.separator + "\n"
	
		// pass the config file we created as an argument to the indexer
		args '-c',"$configFile"	

		// tell the indexer where send its output	
		args '-db', outputFile.absolutePath
			
		
		// for each help file that was found, add it as an argument to the indexer
		helpFiles.each { File file ->
			args "${file.absolutePath}"
		}	
	}
}


// Task for building Ghidra help files 
// 		- depends on the output from the help indexer
//      - validates help
//      - the files generated will be placed in a diretory usable during development mode and will
//        eventually be placed in the <Module>.jar file 
tasks.register('buildHelp', JavaExec) {
	group = "Ghidra Private"

	dependsOn 'indexHelp'

	File helpRootDir = file('src/main/help/help')	
	File outputDir = file('build/help/main/help')

	onlyIf {
		helpRootDir.exists()
	}

	if (helpRootDir.exists()) {
		inputs.dir helpRootDir
	
		inputs.files({
			// Note: this must be done lazily in a closure since the classpath is not ready at 
			// configuration time.
			return getHelpInputs(sourceSets.main.runtimeClasspath.files)
		})	
	}



	outputs.dir outputDir

	mainClass = 'help.GHelpBuilder'
	
	args '-n', "${project.name}"	 		// use the module's name for the help file name
	
	args '-o', "${outputDir.absolutePath}"	// set the output directory arg

	// register the Ghidra installation as an application root so the help system can find modules
	systemProperties = [
		"ADDITIONAL_APPLICATION_ROOT_DIRS": "${ghidraInstallDir}/Ghidra"
	]
	
	// args '-debug' // print debug info

	doFirst {		
	
		//
		// The classpath needs to include:
		// 1) the jar of each dependent Module that has already been built
		// 2) 'src/main/resources'
		// 
	
		// Each java project and its dependencies are needed to locate each Ghidra module.  Each
		// module is scanned to find the theme properties files in the 'data' directories. 
    	classpath += sourceSets.main.runtimeClasspath

		classpath += files('src/main/resources')

		// To build help, the validator needs any other help content that this module may reference.
		// Add each of these dependencies as an argument to the validator.
		def helpJars = classpath.findAll(file -> hasJarHelp(file))
		helpJars.each {				
			args "-hp"
			args "${it.absolutePath}"
		}
		
		// The help dir to process.  This needs to be the last argument to the process, 
		// thus, this is why it is inside of this block
		args "${helpRootDir.absolutePath}"	
				
		// Sigal that any System.out messages from this Java process should be logged at INFO level.
		// To see this output, run gradle with the '-i' option to show INFO messages.
		logging.captureStandardOutput LogLevel.INFO			
	}
}



// include the help into the module's jar
jar {
    duplicatesStrategy = 'exclude'
	from "build/help/main"		// include the generated help index files
	from "src/main/help"		// include the help source files
	archiveVersion = ""
}

// build the help whenever this module's jar file is built
jar.dependsOn 'buildHelp'			


/*********************************************************************************
 * End Help Build Code
*********************************************************************************/


/*********************************************************************************
 *  Takes the given file and returns a string representing the file path with everything
 *	up-to and including 'src/global' removed, as well as the filename. 
 *	
 *	eg: 	If the file path is '/Ghidra/Configurations/Common/src/global/docs/hello.html', 
 *			the returned string will be at /docs
 *
 *	Note: 	We have to use 'File.separator' instead of a slash ('/') because of how 
 * 			windows/unix handle slashes ('/' vs. '\'). We only need to do this in cases where we're
 *			using java string manipulation libraries (eg String.replace); Gradle already
 *			understands how to use the proper slash.
*********************************************************************************/
String stripGlobalFilePath(File file) {

	// First strip off everything before 'src/global/ in the file path.
	def slashIndex = file.path.indexOf('src' + File.separator + 'global')
	String filePath = file.path.substring(slashIndex);
					
	// Now remove 'src/global/' from the string.
	filePath = filePath.replace('src' + File.separator + 'global' + File.separator, "");
					
	// Now we need to strip off the filename itself, which we do by finding the last
	// instance of a slash ('/') in the string.  Unfortunately, groovy doesn't give
	// us a "lastIndexOf('/')" or something nice like that, so we reverse the string
	// and look for the slash that way, remove the filename, then reverse it back.
	//
	// Note that it's possible there is no slash (all we have is a filename), meaning 
	// this file will be placed at the root level.
	//
	String reverseFilePath = filePath.reverse()
	slashIndex = reverseFilePath.indexOf(File.separator)
	if (slashIndex != -1) {
		reverseFilePath = reverseFilePath.substring(slashIndex)
		filePath = reverseFilePath.reverse()
	}	
	else {
		filePath = ""
	}
	
	return filePath 
}
/*********************************************************************************
 * Returns the current date formatted as yyyyMMdd.
 *********************************************************************************/
def getCurrentDate() {

	def date = new Date()
	def formattedDate = date.format('yyyyMMdd')
	return formattedDate
}

/*********************************************************************************
 * Throws a GradleException if the current Gradle version is outside of the supported
 * Gradle version range defined in application.properties
 *********************************************************************************/
import org.gradle.util.GradleVersion;
def checkGradleVersion() {
	GradleVersion min = null;
	GradleVersion max = null;
	try {
		min = GradleVersion.version("${GRADLE_MIN}")
	}
	catch (IllegalArgumentException e) {
		String defaultMin = "1.0"
		println "Invalid minimum Gradle version specified in application.properties...using ${defaultMin}"
		min = GradleVersion.version(defaultMin)
	}
	try {
		if (GRADLE_MAX) {
			max = GradleVersion.version("${GRADLE_MAX}")
		}
	}
	catch (IllegalArgumentException e) {
		println "Invalid maximum Gradle version specified in application.properties...ignoring"
	}
	String gradleRange = "at least ${min}"
	if (max) {
		gradleRange += " and less than ${max}"		
	}
	if (GradleVersion.current() < min || (max && GradleVersion.current() >= max)) {
		throw new GradleException("Requires ${gradleRange}, but was run with $gradle.gradleVersion")
	}
}

