/**
 * Gradle does not seem to properly handle includes in external build scripts
 * like this one. It's basically impossible to use import statements in
 * external build scripts, so as a workaround, all references to grgit
 * use full package names instead of import statements. If there is another
 * way to handle referencing methods from a dependency, I haven'curl-services.sh yet found
 * out how
 *
 * NOTE: skeleton and solution tasks can overlap if not forced to be
 * sequential, which is hard to do with Gradle that wants to parallelize
 * as much as possible. So if problems exist with missing packages or
 * files in the skeleton generation (supposed to run after solution
 * generation) then it's likely because a solution task is running in
 * tandem with the skeleton task(s).
 */
buildscript {
    repositories {
        maven {
            url "https://plugins.gradle.org/m2/"
        }
    }
    dependencies {
        classpath "org.ajoberstar.grgit:grgit-gradle:4.1.0"
    }
}

def adminGroup = '[admin]'
def adminSupportGroup = '[admin support]'
def testGroup = '[test]'

static String solution(branch) { "$branch-solution" }

static String skeleton(branch) { "$branch-skeleton" }

ext.grgit = org.ajoberstar.grgit.Grgit.open(dir: "$projectDir")
ext.main = 'main'
ext.exclude = 'exclude'
ext.graderDir = "$projectDir/grader"
ext.adminDir = "$projectDir/admin"
ext.filesDir = "$adminDir/files"
ext.graderBuildDir = file("$projectDir/../grader/").absoluteFile
ext.buildFiles = ['AUTOGRADER_SUBMISSION', 'GRADE', 'log', 'admin']
ext.course = ""
ext.courses = [course]
ext.startTag = "// SOLUTION-START"
ext.endTag = "// SOLUTION-END"
ext.lineTag = "// SOLUTION-LINE"
ext.subproject = "microservices"
ext.MODULES = [subproject]

/* Grader only works with Android Studio apps */
ext.NO_GRADER = true

/* Assignment.java file not currently supported for server projects. */
ext.NO_ASSIGNMENT_FILE = true

/**
 * Returns a list of all assignment branches each mapped to
 * a file containing all the required solution files and test
 * files for that branch. The returned map will be different
 * for each support course.
 */
@SuppressWarnings('GrMethodMayBeStatic')
LinkedHashMap<String, String> getAssignments(String course) {
    LinkedHashMap assignments = new LinkedHashMap(10)

    def prefix = course ? "$course-" : ""

    assignments["${prefix}1a"] = 'sequential-stream'
    assignments["${prefix}1b"] = 'parallel-stream'
    assignments["${prefix}2"] = 'completable-future'
    assignments["${prefix}3a"] = 'executor-service'
    assignments["${prefix}3b"] = 'executor-completion-service'
    assignments["${prefix}4a"] = 'concurrent-flux'
    assignments["${prefix}4b"] = 'parallel-flux'

    return assignments
}

task validateAssignments(group: adminGroup) {
    doFirst {
        courses.each { course ->
            // Will throw an exception if any assignment solution
            // file does not exist in the main repo.
            println "Validating $course ..."
            def assignmentFileNames =
                    getAssignments(null).collect {
                        it.value
                    }
            validateSolutionFiles(course, assignmentFileNames, projectDir)
            println "Validation successful."
        }
    }
}

/**
 * To return a complete list of all possible assignments,
 * simply return all assignment file names found in the
 * admin/files directory (not including the 'exclude' file).
 *
 * @return The list of all possible assignment file names.
 */
def getAllAssignmentFiles() {
    return file(filesDir)
            .listFiles()
            .findAll { (it.name != (String) exclude) }
            .collect { it.name.toString() }
}

def validateSolutionFiles(course, assignments, projectDir, throwOnError = true) {
    def assignmentFiles = getAllAssignmentFiles()

    // Ensure that each assignment actually has a matching
    // table of contents file in the admin/files directory.
    assignments.each { assignment ->
        print "Validating $assignment files ..."
        if (!assignmentFiles.contains(assignment.toString())) {
            def error = "$course: $assignment was NOT FOUND in admin/files directory!"
            if (throwOnError) {
                throw new GradleException(error)
            } else {
                println error
            }
        } else {
            // Now ensure that all solution file paths in this assignment
            // map to a matching file in the main branch.
            readFileList(assignment).each { solutionFile ->
                def srcPath = "$projectDir/$solutionFile"
                def srcFile = file(srcPath)
                if (!srcFile.exists()) {
                    def error = "$course: $assignment solution file " +
                            "$solutionFile does not exist in main branch."
                    if (throwOnError) {
                        throw new GradleException(error)
                    } else {
                        println error
                    }
                }
            }

            assert assignmentFiles.removeElement(assignment)
        }

        println " ok"
    }

    if (assignmentFiles.size() > 0) {
        def plural = assignmentFiles.size() > 1
        println "The following assignment solution${plural ? "s were" : " was"} not " +
                "used in $course: ${assignmentFiles.toString()}."
    }
}

task testSolutionFiles(group: testGroup) {
    doFirst {
        courses.each { course ->
            println "Testing assignment solution sets for course [$course]"
            getAssignments(course).each {
                println it
            }
        }
    }
}

task changeToMainBranch {
    group adminSupportGroup
    doLast {
        checkoutBranch(main)
        ensureMainBranch()
    }
}

task cleanAll(type: Delete) {
    delete rootProject.buildDir
    subprojects {
        delete project.buildDir
    }
}

/**
 * Each assignment gets its own private build-solution
 * and private build-skeleton task which are both run
 * from the public [admin] group build- tasks.
 */
courses.each { course ->
    getAssignments(course).each { assignment ->
        String branch = assignment.key
        def solution = solution(branch)
        def skeleton = skeleton(branch)
        def buildTaskName = "build-$branch"
        def buildSolutionTaskName = "build-$solution"
        def testSolutionTaskName = "test-$solution"
        def compileSkeletonTaskName = "compile-$skeleton"
        def buildSkeletonTaskName = "build-$skeleton"
        def pushBranchName = "push-$branch"

        /**
         * Public facing task
         */
        task(buildTaskName) {
            MODULES.each {
                def classesTask = tasks.findByPath(":$it:classes")
                def testClassesTask = tasks.findByPath(":$it:testClasses")
                assert classesTask != null
                assert testClassesTask != null

                classesTask.outputs.upToDateWhen { false }
                testClassesTask.outputs.upToDateWhen { false }
            }
            group adminGroup
            dependsOn testSolutionTaskName
            finalizedBy compileSkeletonTaskName
        }

        task(buildSolutionTaskName) { task ->
            afterEvaluate {
                def cleanTask = tasks.findByName("clean")
                if (cleanTask) {
                    dependsOn cleanTask
                } else {
                    println "NO CLEAN TASK!!!"
                }
            }

            doFirst {
                buildSolutionBranch(course, branch, main)
            }
        }

        task(testSolutionTaskName) { task ->
            dependsOn buildSolutionTaskName

            afterEvaluate {
                MODULES.each {
                    def testTask = tasks.findByPath(":$it:test")
                    dependsOn testTask
                    testTask.mustRunAfter(tasks.findByName(buildSolutionTaskName))
                }
            }
        }

        task(buildSkeletonTaskName) { task ->
            dependsOn buildSolutionTaskName

            afterEvaluate {
                // Ensure that solution tests have completed
                // before creating the skeleton.
                task.mustRunAfter(tasks.findByName(testSolutionTaskName))
            }

            doFirst {
                buildSkeletonBranch(course, branch, solution)
            }
        }

        task(compileSkeletonTaskName) { task ->
            dependsOn buildSkeletonTaskName

            afterEvaluate {
                MODULES.each {
                    def classesTask = tasks.findByPath(":$it:classes")
                    def testClassesTask = tasks.findByPath(":$it:testClasses")
                    assert classesTask != null
                    assert testClassesTask != null

                    classesTask.outputs.upToDateWhen { false }
                    testClassesTask.outputs.upToDateWhen { false }

                    dependsOn classesTask, testClassesTask
                }
            }

            doFirst {
                println "$task: doFirst called"
            }
            doLast {
                println "$task: doLast called"
            }
        }

        task(pushBranchName) {
            group adminSupportGroup
            doFirst {
                pushBranch(skeleton)
                pushBranch(solution)
                println "pushed $branch skeleton and solution"
            }
        }

    }
}

task installGrader {
    group adminSupportGroup

    doFirst {
        def branch = currentBranch()
        println "${branch}: installing grader ..."
        removeFile(graderDir, false)
        execGradle(graderBuildDir, "-PtargetDir=$projectDir", 'runInstaller')
        grgit.add(patterns: ['grader'])

        commitBranch("$branch: Installed grader.", true)
        runGrader()
        grgit.add(patterns: ['GRADE'])
        commitBranch("$branch: Added grader results file.", true)
    }
}

/**
 * Since Gradle makes it impossible to run a single task more than once,
 * the only way to ensure that the build and test tasks are run for each
 * created solution branch is to execute each branch build task from the
 * command line.
 */
task('build-all') {
    group adminGroup
    dependsOn 'gitEnsureClean'

    doFirst {
        println "Building all solutions and skeletons ..."
        courses.each { course ->
            for (assignment in getAssignments(course)) {
                execGradle(null, "build-${skeleton(assignment.key)}")
                checkoutBranch(main)
            }
        }
    }

    doLast {
        ensureMainBranch()
    }
}

task listAllBranches {
    group adminSupportGroup
    doFirst {
        def branches = grgit.branch.list(
                mode: org.ajoberstar.grgit.operation.BranchListOp.Mode.ALL)
        branches.each {
            println "${it.fullName} -> ${it.trackingBranch}"
        }
    }
}

def buildSolutionBranch(String course, String branch, String from) {
    def solution = solution(branch)
    println "Building solution branch $solution ..."
    createNewBranch(solution, from, true)
    assert isCurrentBranch(solution)
    removeSolutionFiles(course, branch)
    updateAssignmentFile(course, branch)
    commitBranch("$solution: Created.")
}

def buildSkeletonBranch(String course, String branch, String from) {
    def skeleton = skeleton(branch)
    createNewBranch(skeleton, from, true)
    assert isCurrentBranch(skeleton)
    removeSolutionCode(course, branch)
    removeBuildFiles(branch)
    commitBranch()
    assert rootProject.buildDir.isDirectory()
    delete rootProject.buildDir
    assert !rootProject.buildDir.exists()
    subprojects {
        if (it.buildDir.isDirectory()) {
            println "deleting buildDir ${it.buildDir.path} ..."
            delete it.buildDir
            assert !it.buildDir.exists()
        }
    }
}

def installGrader() {
    println "${currentBranch()}: installing grader ..."

    def installer = new File(graderBuildDir, "installer/installer.gradle")
    if (installer.isFile()) {
        removeFile(graderDir, false)
        execGradle(graderBuildDir, "-PtargetDir=$projectDir", 'runInstaller')
        return true
    } else {
        project.logger.warn("${currentBranch()}: grader installer not found.")
        return false
    }
}

def addGraderResults(branch) {
    println "$branch: adding grader results to git change list ..."
    grgit.add(patterns: ['grader'])
}

def runGrader() {
    println "${currentBranch()}: running grader ..."
    if (!file(graderDir).isDirectory()) {
        project.logger.warn("${currentBranch()}: grader not installed.")
        return false
    }

    execGradle(null, 'runAutograder')

    checkGraderResult()

    return true
}

def checkGraderResult(branch) {
    def gradeFile = file("$projectDir/GRADE")
    if (!gradeFile.isFile()) {
        throw new GradleException("$branch: missing ${gradeFile.path}")
    }

    def pattern = ~/([0-9]+)\/([0-9]+)/
    def grade = gradeFile.text
    def matcher = pattern.matcher(grade)

    matcher.find()

    if (matcher.size() == 1 && matcher[0].size == 2) {
        throw new GradleException(
                "GRADE file must contain a single line matching the pattern: ${pattern.pattern()}")
    }

    int passed = matcher[0][1].toInteger()
    int total = matcher[0][2].toInteger()
    int failed = total - passed

    if (failed > 0) {
        throw new GradleException(
                "$branch: The grader detected ${failed} failed test${failed != 1 ? "s" : ""}")
    }
}

def removeBuildFiles(branch) {
    println "$branch: Removing temporary files ..."
    buildFiles.each { removeFile(it, false) }
}

task deleteSkeletons {
    group adminSupportGroup
    doFirst {
        def deleted = 0

        courses.each { course ->
            for (assignment in getAssignments(course)) {
                deleted += deleteBranch(skeleton(assignment.key)) ? 1 : 0
            }
        }

        println "$deleted skeleton branch${deleted != 1 ? 'es' : ''}"
    }
}

task deleteSolutions {
    group adminSupportGroup
    doFirst {
        def deleted = 0

        courses.each { course ->
            for (assignment in getAssignments(course)) {
                deleteBranch(solution(assignment.key))
            }
        }

        println "$deleted solution branch${deleted != 1 ? 'es' : ''}"
    }
}

task 'delete-all' {
    group adminGroup
    dependsOn deleteSkeletons, deleteSolutions
}

task zipBranch(type: Zip, group: adminSupportGroup) {
    description = "Compresses your entire project into the zip archive zip/${project.name}.zip."
    String branchName = "${project.name}-${currentBranch()}"

    from projectDir
    into branchName

    archiveBaseName.set(branchName)

    include 'build.gradle'
    include 'gradle.properties'
    include 'gradlew'
    include 'gradlew.bat'
    include 'settings.gradle'
    include 'gradle/**'

    exclude 'build'
    exclude '.gradle'
    exclude '.idea'
    exclude '*.iml'
    exclude 'local.properties'

    include 'app/**'
    exclude 'app/*.iml'
    exclude 'app/build'

    destinationDir file('zip')

    doLast {
        println "Zip file location: ${projectDir}/zip/"
    }
}

task gitEnsureClean(group: adminSupportGroup) {
    doFirst {
        ensureCleanGitRepo()
    }
}

task gitEnsureMain(group: adminSupportGroup) {
    doFirst {
        ensureMainBranch()
    }
}

task gitRevert(group: adminSupportGroup) {
    description = 'Reverts all uncommited changes.'

    doFirst {
        grgit.reset(commit: 'HEAD', mode: 'HARD')
    }
}

task gradleSync(type: GradleBuild, group: adminSupportGroup) {
    doFirst {
        buildName "runTaskGradleSync"
        dir projectDir
        def param = new StartParameter()
        param.setRefreshDependencies(true)
        List<String> tasks = List.of("tasks")
        setTasks((Collection) tasks)
    }
}

def execGradle(File dir, Object... args) {
    exec {
        def cmd = []

        if (System.getProperty("os.name").toLowerCase().contains('windows')) {
            cmd.addAll((Collection) List.of("cmd", "/c", "gradlew.bat"))
        } else {
            // Make sure that gradlew is executable.
            def gradlew = new File(rootDir, "gradlew")
            if (!gradlew.canExecute()) {
                gradlew.setExecutable(true)
            }
            cmd.addAll((Collection) List.of("./gradlew"))
        }

        cmd.addAll(Arrays.asList(args))

        if (dir != null) {
            workingDir dir
        }

        print "Executing command: "
        cmd.each { print "$it " }
        println ""
        println "Working directory: ${dir == null ? projectDir : dir.path}"
        System.out.flush()

        commandLine cmd
    }
}

def ensureCleanGitRepo() {
    def status = grgit.status()
    if (!status.clean) {
        println "${project.name} GIT repository is ${status.clean ? 'CLEAN' : 'DIRTY'}:"
        println "     staged:    added    -> ${status.staged.added}"
        println "                modified -> ${status.staged.modified}"
        println "                removed  -> ${status.staged.removed}"
        println "     unstaged:  added    -> ${status.unstaged.added}"
        println "                modified -> ${status.unstaged.modified}"
        println "                removed  -> ${status.unstaged.removed}"
        println "     conflicts: ${status.conflicts}"
        throw new GradleException("You need to commit all changes before running this task " +
                "or else run the gitRevert task! (See git status above)")
    }
}

task hasTags(group: adminSupportGroup) {
    doFirst {
        if (findProperty("tagFile")) {
            println "Validate solution tags for file: $tagFile"
            _hasTags(file(tagFile), startTag, endTag, lineTag, true)
        } else {
            throw new GradleException("tagFile property not defined")
        }
    }
}

task validateTags(group: adminSupportGroup) {
    doFirst {
        if (findProperty("tagFile")) {
            println "Validate solution tags for file: $tagFile"
            _validateTags(file(tagFile), startTag, endTag, lineTag)
        } else {
            println "Validate solution tags for all files"
            courses.each {
                removeSolutionCode(it, main, true)
            }
        }
    }
}

/**
 * Validates tag integrity for passed file.
 */
static def _hasTags(File file, String startTag, String endTag, String lineTag, boolean printResult = false) {
    def matcher = file.text =~ /($startTag|$endTag|$lineTag)/
    def tags = matcher.find()
    if (tags && printResult) {
        println("validating: ${file.path} - ${tags ? "has tags" : "no tags"}")
    }
    return tags
}

/**
 * Validates tag integrity for passed file.
 */
static def _validateTags(file, startTag, endTag, lineTag) {
    def text = file.text
    def actualEndTag = endTag?.length() > 0 ? endTag : startTag

    // Check for tag integrity.
    def matcher = text =~ /($startTag|$actualEndTag|$lineTag)/
    def lastTag = ""
    while (matcher.find()) {
        def tag = matcher.group()
        switch (tag) {
            case startTag:
                if (lastTag == startTag) {
                    throw new GradleException("${file.name}: Found solution tag error: " +
                            "$startTag with no matching $actualEndTag.")
                }
                break
            case endTag:
                if (lastTag != startTag) {
                    throw new GradleException("${file.name}: Found solution tag error: " +
                            "$actualEndTag with no matching $startTag.")
                }
                break
            case lineTag:
                if (lastTag == startTag) {
                    throw new GradleException("${file.name}: Found solution tag error: " +
                            "$lineTag follows $startTag.")
                }
                break
        }
        lastTag = tag
    }
}

def stripSolution(File file, String startTag, String endTag, String lineTag, boolean dryRun) {
    def text = file.text
    def dirty = false
    String actualEndTag = endTag?.length() > 0 ? endTag : startTag

    // Throws and exception if any errors are discovered.
    _validateTags(file, startTag, endTag, lineTag)

    if (startTag?.length() > 0) {
        if (text.contains(startTag as String)) {
            def matcher = text =~ /(?s)(.*?)\n\s*$startTag.*?(\n[ \t]*)$actualEndTag[ ]*/
            matcher.groupCount()
            if (!matcher.find()) {
                throw new GradleException("${file.name}: Unable to remove source " +
                        "code solution block (missing $actualEndTag).")
            }
            text = matcher.replaceAll('$1$2')
            dirty = true
        }
    }

    if (lineTag?.length() > 0) {
        if (text.contains(lineTag as String)) {
            def matcher = text =~ /(?s)(.*?)$lineTag[ \t]*([^\n]*\n)([^\n]*\n)/
            if (!matcher.find()) {
                throw new GradleException("${file.name}: Unable to remove source code solution line.")
            }
            text = matcher.replaceAll('$1$2')
            dirty = true
        }
    }

    if (dirty) {
        // Finally, check if any tags remain (improperly defined blocks)
        if (startTag?.length() > 0 && text.contains(startTag)) {
            throw new GradleException("${file.name}: failed to remove all $startTag tags.")
        }
        if (endTag?.length() > 0 && text.contains(endTag)) {
            throw new GradleException("${file.name}: failed to remove all $endTag tags.")
        }
        if (lineTag?.length() > 0 && text.contains(lineTag)) {
            throw new GradleException("${file.name}: failed to remove all $lineTag tags.")
        }

        if (!dryRun) {
            // Save changes
            file.text = text
        }
    }

    return dirty
}

def printFileText(name, text) {
    println ""
    println "--------------------------------------------------------------------------------"
    println "SKELETON: $name"
    println "--------------------------------------------------------------------------------"
    text.readLines().each {
        println it
    }
}

/**
 *
 * Regular expression that recursively removes solution blocks and lines
 * from all files in the passed directory.
 *
 * Note: that this regular expression will not handle nested tag pairs.
 *
 * EXAMPLE:
 *
 *  public Object add(int a, int b)  {*      int x = 1;
 *      // TODO - you fill in here.
 *      // SOLUTION-LINE
 *      int removeThisLine = 2;
 *      int keepThis = 3;
 *
 *      // TODO - you fill in here.
 *      // SOLUTION-LINE int replaceThis = -1; // TODO: Change -1 to the correct value.
 *      int replaceThis = 4;
 *
 *      // TODO - you fill in here.
 *      // SOLUTION-START
 *      int solution = a + b;
 *      return solution;
 *      // SOLUTION-END return -1; // TODO you replace -1 with the appropriate value
 *}*  RESULT:
 *
 *  public Object add(int a, int b) {*      int x = 1;
 *      // TODO - you fill in here.
 *
 *      int keepThis = 3;
 *
 *      // TODO - you fill in here.
 *      int replaceThis = -1; // TODO: Change -1 to the correct value.
 *
 *      // TODO - you fill in here.
 *
 *     return -1; // TODO you replace -1 with the appropriate value
 *}*/
def removeSolutionCode(course, branch, dryRun = false) {
    def changedFiles = []
    def errorFiles = []

    for (solutionFile in getSolutionFiles(course, branch, dryRun)) {
        def entry = file("$projectDir/$solutionFile")
        if (entry.exists()) {
            if (entry.isDirectory()) {
                entry.eachFileRecurse(groovy.io.FileType.FILES) {
                    if (_hasTags(it, startTag, endTag, lineTag, true)) {
                        try {
                            if (stripSolution(it, startTag, endTag, lineTag, dryRun)) {
                                changedFiles << it
                            }
                        } catch (Exception ignored) {
                            errorFiles << it.getPath()
                        }
                    }
                }
            } else {
                try {
                    if (stripSolution(file(solutionFile), startTag, endTag, lineTag, dryRun)) {
                        changedFiles << solutionFile
                    }
                } catch (Exception ignored) {
                    errorFiles << solutionFile
                }
            }
        }
    }

    def errorCount = errorFiles.size()
    if (errorFiles.size() > 0) {
        def msg =
                "$branch: Solution ERRORS found in the " +
                        "following $errorCount file${errorCount == 1 ? "" : "s"}:\n"
        errorFiles.each {
            msg += "$it - TAG ERROR\n"
        }

        throw new GradleException(msg)
    }

    def changedCount = changedFiles.size()

    if (changedCount == 0) {
        throw new IllegalStateException("${project.name}: No solution code was " +
                "${dryRun ? "found in" : "removed from "} this project!")
    }

    project.logger.warn("$branch: Solution code was " +
            "${dryRun ? "found in" : "removed from "} the following " +
            "$changedCount file${changedCount == 1 ? "" : "s"}.")

    changedFiles.each {
        println "$it - OKAY"
    }
}

def removeTestFiles(branch) {
    subprojects {
        if (getExtensions().findByName('java') != null) {
            println "$branch: Removing test files ..."
            sourceSets.findByName('test')?.allSource?.dirs?.each {
                removeFile(it, false)
            }
        }
    }
}

/**
 * @return All files in main source sets of all sub-projects.
 */
def getSourceFiles() {
    def files = []
    subprojects {
        if (getExtensions().findByName('android') != null) {
            def main = getExtensions().findByName('android').sourceSets?.findByName('main')
            main.java.srcDirs.each {
                if (it.isDirectory()) {
                    it.eachFileRecurse(groovy.io.FileType.FILES) {
                        files << it
                    }
                }
            }
        } else if (getExtensions().findByName('java') != null) {
            sourceSets?.findByName('main')?.allSource?.getFiles()?.each { files << it }
        }
    }

    return files
}

task testRemoveSolutionFiles(group: testGroup) {
    doFirst {
        courses.each { course ->
            for (assignment in getAssignments(course)) {
                testRemoveSolutionFiles(assignment.key, false)
            }
        }
    }
}

def testRemoveSolutionFiles(branch, remove) {

    println "--------------- testRemoveSolutionFiles for branch $branch -----------------"
    def allSolutionFiles = getAllSolutionFiles()
    def branchSolutionFiles = getSolutionFiles(course, branch)

    // Ensures that even where duplicates may exist,
    // no branch solution files will ever get removed.
    def excludeFiles = getExcludeFiles()
    def filesToRemove = ((allSolutionFiles - branchSolutionFiles) + excludeFiles).sort()

    println "Removing solution files ..."
    println "All solution files: ${allSolutionFiles.size()}"
    println "Branch files: ${branchSolutionFiles.size()}"
    println "Files to remove: ${filesToRemove.size()}"
    println "FILES TO REMOVE:"

    filesToRemove.each {
        println it
        assert file(it).exists()
        if (remove) {
            file(it).delete()
            assert file(it).exists()
        }
    }
}

def removeSolutionFiles(course, branch) {
    def allSolutionFiles = getAllSolutionFiles()
    def branchSolutionFiles = getSolutionFiles(course, branch)

    // Ensures that even where duplicates may exist,
    // no branch solution files will ever get removed.
    def filesToRemove = allSolutionFiles - branchSolutionFiles

    println "Removing solution files ..."
    filesToRemove.each {
        removeFile(it)
    }

    println "Removing excluded files ..."
    def excludeFiles = getExcludeFiles()
    excludeFiles.each {
        removeFile(it, false)
    }
}

def removeFile(path, shouldExist = true) {
    //ensureNotMainBranch(currentBranch())

    //grgit.remove(patterns: [path])
    def file = file(path)
    if (file.isDirectory()) {
        println "Deleting directory ${file.name}"
        file.deleteDir()
        assert !shouldExist || !file.isDirectory()
    } else if (file.isFile()) {
        println "Deleting file ${file.name}"
        file.delete()
        assert !shouldExist || !file.isFile()
    } else if (shouldExist) {
        throw new GradleException("Unable to remove $file: file not found.")
    }

    if (file.exists()) {
        throw new GradleException("Unable to delete file $file")
    }
}

def branchExists(branch) {
    grgit.branch.list().find { it.getName() == branch } != null
}

def createNewBranch(branch, startPoint = main, checkout = true) {
    ensureNotMainBranch(branch)
    ensureCleanGitRepo()

    if (branchExists(branch)) {
        println "Deleting $branch ..."
        deleteBranch(branch)
    }

    println "Creating $branch from $startPoint ..."
    grgit.branch.add(name: branch, startPoint: startPoint)

    if (checkout) {
        checkoutBranch(branch)
    }
}

def deleteBranch(branch, push = false) {
    ensureNotMainBranch(branch)

    if (branchExists(branch)) {
        if (isCurrentBranch(branch)) {
            checkoutBranch(main)
        }

        println "Deleting branch $branch ..."

        def branchObject = grgit.resolve.toBranch(branch)
        def deletedList = grgit.branch.remove(names: [branch], force: true)
        if (push && deletedList != null && deletedList[0] != null) {
            grgit.push(refsOrSpecs: [":${branchObject.fullName}"])
        }
        return true
    }

    return false
}

def pushBranch(branch) {
    if (branchExists(branch)) {
        def originalBranch = currentBranch()
        if (!isCurrentBranch(branch)) {
            checkoutBranch(branch)
        }

        println "Deleting remote branch $branch ..."

        def branchObject = grgit.resolve.toBranch(branch)
        grgit.push(refsOrSpecs: [":${branchObject.fullName}"], ["--delete"])

        println "Pushing branch $branch ..."
        grgit.push(refsOrSpecs: [":${branchObject.fullName}"])

        if (!isCurrentBranch(originalBranch)) {
            checkout(originalBranch)
        }

        return true
    }

    return false
}

task testGetExcludeFiles(group: testGroup) {
    doFirst {
        getExcludeFiles().each {
            println it
        }
    }
}

/**
 * Returns all branch solution files along with all exclude files
 * that currently exist (forgiving if an old non-existent file is
 * named in exclude file).
 */
def getExcludeFiles() {
    readFileList('exclude')
            .findAll { !file(it).exists() }
            .each { project.logger.warn("$it: does not exist.") }
    // hard-coded in source code!!!
    def result = readFileList('exclude').findAll { file(it).exists() }
    return result
}

task testGetSolutionFiles(group: testGroup) {
    doFirst {
        getSolutionFiles(local, "4").each {
            println it
        }
    }
}

/**
 * Returns all assignment solution files up to and including the
 * passed assignment [branch].
 */
def getSolutionFiles(course, branch, dryRun = false) {
    if (!dryRun && branch != null) {
        ensureNotMainBranch(branch)
    }

    def results = []

    for (def assignment : getAssignments(course)) {
        readFileList(assignment.value).each {
            results << it
        }
        if (assignment.key == branch) {
            break
        }
    }

    // Removes duplicates.
    return results.toSet()
}

/**
 * Returns the list of all possible solution files contained
 * in the main branch. This list is created by simply finding
 * all assignment files in the admin/files directory and
 * including all file paths in each of these files.
 */
def getAllSolutionFiles() {
    def results = []

    getAllAssignmentFiles().each { assignmentFile ->
        readFileList(assignmentFile).each { solutionFile ->
            results << solutionFile
        }
    }

    // Removes duplicates.
    return results.toSet()
}

task listDuplicateSolutionFiles(group: adminSupportGroup) {
    doFirst {
        courses.each { course ->
            println "------------------- Duplicate files for course '$course' -------------------"
            def result = []

            for (def assignment : getAssignments(course)) {
                readFileList(assignment.value).each { file ->
                    result.add(branch: assignment.key, file: file)
                }
            }

            result.each { println it }
            result.groupBy {
                it.file
            }.collect {
                [it.key, it.value.branch]
            }.findAll {
                it[1].size() > 1
            }.each {
                println it
            }
        }
    }
}

def readFileList(fileName) {
    def result = []
    def inputFile = file("${filesDir}/$fileName")
    assert inputFile.isFile()
    inputFile.readLines().each {
        if (!it.isAllWhitespace()) {
            result.add("$subproject/$it")
        }
    }

    return result
}

def ensureNotMainBranch(branch) {
    assertValidBranchName(branch)

    if (branch?.contains(main)) {
        throw new GradleException("Achtung Verboten!! - $main found in branch name $branch!")
    }
}

def ensureMainBranch() {
    if (!isCurrentBranch(main)) {
        Thread.currentThread().getStackTrace().each { println it }
        throw new GradleException("Not on $main branch!")
    }
}

def assertValidBranchName(branch) {
    assert branch != null && !branch.isAllWhitespace()
}

def isCurrentBranch(branch) {
    return grgit.branch.current.name == branch
}

def currentBranch() {
    return grgit.branch.current.name
}

def checkoutBranch(branch) {
    ensureCleanGitRepo()
    assertValidBranchName(branch)

    if (!branchExists(branch)) {
        throw new GradleException("Unable to checkout branch $branch: branch does not exist.")
    }

    if (isCurrentBranch(branch)) {
        println "WARNING: checkoutBranch - $branch is already checked out."
    } else {
        println "Checking out $branch ..."
        grgit.checkout(branch: branch)
    }
}

def commitBranch(message = 'Created.', allowMain = false, push = false) {
    allowMain || ensureNotMainBranch(currentBranch())

    println "Committing branch ${currentBranch()} ..."
    grgit.commit(message: message, all: true)
    if (push) {
        grgit.push()
    }
    ensureCleanGitRepo()
}

/**
 * No Assignment.java file for server projects (currently).
 */
def updateAssignmentFile(String course, String branch) {
    if (NO_ASSIGNMENT_FILE) {
        return
    }

    def assignments = getAssignments(course)
    assert assignments.containsKey(branch)

    // Remove course prefix to yield actual branch id.
    // For example, prefix1-2b or prefix2-2b will both become just
    // 2b.
    def branchId = branch.replaceFirst(".*-", "")

    def enums = ""
    for (it in assignments) {
        if (enums != "") {
            enums += ",\n        "
        }
        def id = it.key.toString().replaceFirst(".*-", "")
        enums += "Assignment$id"
    }

    String clazz = "Assignment"
    String dir = "$projectDir/src/main/java"
    String pkg = "edu.vanderbilt.imagecrawler.utils"
    File assignmentFile = file("$dir/${pkg.replace('.', '/')}/${clazz}.kt")

    // Only update modules that already have an Assignment.kt file.
    if (!assignmentFile.exists()) {
        return
    }

    assignmentFile.text = "package ${pkg}\n\n" +
            "object $clazz {\n" +
            "    enum class Name { \n" +
            "        $enums,\n" +
            "        all\n" +
            "    }\n\n" +
            "    @JvmStatic\n" +
            "    var version = Name.Assignment$branchId\n\n" +
            "    @JvmStatic\n" +
            "    fun includes(name: Name): Boolean = name.ordinal <= version.ordinal\n\n" +
            "    @JvmStatic\n" +
            "    fun `is`(name: Name) = includes(name)\n\n" +
            "    @JvmStatic\n" +
            "    fun isAssignment(name: Name) = `is`(name)\n\n" +
            "    @JvmStatic\n" +
            "    fun isUndergraduate(name: Name) = isUndergraduate() && isAssignment(name)\n\n" +
            "    @JvmStatic\n" +
            "    fun isGraduate(name: Name) = isGraduate() && isAssignment(name)\n\n" +
            "    @JvmStatic\n" +
            "    fun isUndergraduate() = Student.isUndergraduate()\n\n" +
            "    @JvmStatic\n" +
            "    fun isGraduate() = Student.isGraduate()\n" +
            "}"
}

task testAdminFileNames(group: testGroup) {
    doFirst {
        getAllAssignmentFiles().each {
            println it
        }
    }
}

task testGetAssigments(group: testGroup) {
    doFirst {
        getAssignments(null).collect { it.value }.each { println it }
    }
}

String getGraderDirPath(Project project) {
    if (NO_GRADER) {
        return null
    }
    // First try environment variable.
    // Try parent directory.
    String path = System.getenv("GRADER_DIR")
    if (path != null && !path.isBlank()) {
        if (new File(path, "installer/installer.gradle").isFile()) {
            return path
        } else {
            project.logger.warn("${currentBranch()}: GRADER_DIR is not a valid grader path: $path")
        }
    }

    path = "${project.projectDir}/../grader/"
    if (new File(path, "installer/installer.gradle").isFile()) {
        return path
    } else {
        project.logger.warn("${currentBranch()}: grader not installed.")
    }

    return null
}

def getTestClassesTasks() {
    def tasks = []
    MODULES.each { name ->
        def proj = project(name)
        if (proj.tasks.findByName('testClasses')) {
            tasks.add(proj.tasks.findByName('testClasses'))
        } else {
            throw new GradleException("Missing 'testClasses' task for $proj.")
        }
    }

    return tasks
}

task testFindSubprojectsWithTestClasses(group: testGroup) {
    doFirst {
        MODULES.each { name ->
            def proj = project(name)
            if (proj.tasks.findByName('testClasses')) {
                println "$proj: testClasses found."
            } else {
                throw new GradleException("Missing 'testClasses' task for $proj.")
            }
        }
    }
}
