import groovy.json.JsonOutput
import java.util.regex.Pattern
import java.util.regex.Matcher

// Snyk dependency resolution script for Gradle.
// Tested on Gradle versions from 2.14 to 5.4.1

// This script does the following: for all the projects in the build file,
// generate a merged configuration of all the available configurations,
// and then list the dependencies as a tree.

// It's the responsibility of the caller to pick the project(s) they are
// interested in from the results.

// CLI usages:
// gradle -q -I init.gradle snykResolvedDepsJson
// gradle -q -I init.gradle snykResolvedDepsJson -Pconfiguration=specificConf -PonlySubProject=sub-project
// gradle -q -I init.gradle snykResolvedDepsJson -Pconfiguration=confNameRegex -PconfAttr=buildtype:debug,usage:java-runtime

// (-q to have clean output, -P supplies args as per https://stackoverflow.com/a/48370451)

// confAttr parameter (supported only in Gradle 3+) is used to perform attribute-based dependency variant matching
// (important for Android: https://developer.android.com/studio/build/dependencies#variant_aware)
// Its value is a comma-separated list of key:value pairs. The "key" is a case-insensitive substring
// of the class name of the attribute (e.g. "buildtype" would match com.android.build.api.attributes.BuildTypeAttr),
// the value should be a case-insensitive stringified value of the attribute

// Output format:
//
// Since Gradle is chatty and often prints a "Welcome" banner even with -q option,
// the only output lines that matter are:
// - prefixed "SNYKECHO ": should be immediately printed as debug information by the caller
// - prefixed "JSONDEPS ": JSON representation of the dependencies trees for all projects in the following format

// interface JsonDepsScriptResult {
//   defaultProject: string;
//   projects: ProjectsDict;
//   allSubProjectNames: string[];
// }
// interface ProjectsDict {
//   [project: string]: GradleProjectInfo;
// }

// interface GradleProjectInfo {
//   depDict: DepDict;
//   targetFile: string;
// }
// export interface DepDict {
//   [name: string]: DepTree;
// }
// interface DepTree {
//   name: string;
//   version: string;
//   dependencies?: DepDict;
// }

class SnykGraph {
   def nodes
   def rootId
   SnykGraph(rootId) { 
       this.nodes = [:]
       this.rootId = rootId
   }
   
   def setNode(key, value) {
       if(!key) {
           return
       }
       if(this.nodes.get(key)) {
           return this.nodes.get(key)
       }
       if(!value) {
           return
       }
       def vertex = ['name': value.name, 'version': value.version, 'parentIds': [] as Set]
       this.nodes.put(key, vertex)
       return vertex
   }

   def setEdge(parentId, childId) {
       if(!parentId || !childId || parentId == childId) {
           return
       }
       // root-node will be the graphlib root that first-level deps will be attached to
       if(parentId != this.rootId) {
            def parentNode = this.setNode(parentId, null)
            if(!parentNode) {
                return
            }
            // avoids cyclic
            if(parentNode && parentNode.parentIds.contains(childId)) {
                return
            }
       }
       def childNode = this.setNode(childId, null)
       if(!childNode || childNode.parentIds.contains(parentId)) {
           return
       }
       childNode.parentIds.add(parentId)
   }
}

def loadGraph(Iterable deps, SnykGraph graph, parentId, currentChain) {
        deps.each { d -> 
            def childId = "${d.moduleGroup}:${d.moduleName}@${d.moduleVersion}"
            if(!graph.nodes.get(childId)) {
                def childDependency = ['name': "${d.moduleGroup}:${d.moduleName}", 'version': d.moduleVersion]
                graph.setNode(childId, childDependency)
            }
            //  In Gradle 2, there can be several instances of the same dependency present at each level,
            //  each for a different configuration. In this case, we need to merge the dependencies.
            if(!currentChain.contains(childId) && d.children) {
                currentChain.add(childId)
                loadGraph(d.children, graph, childId, currentChain)
            }
            graph.setEdge(parentId, childId)
        }
    }

def getSnykGraph(Iterable deps) {
    def rootId = 'root-node'
    def graph = new SnykGraph(rootId)
    def currentChain = new HashSet()
    loadGraph(deps, graph, rootId, currentChain) 
    return graph.nodes
}

// We are attaching this task to every project, as this is the only reliable way to run it
// when we start with a subproject build.gradle. As a consequence, we need to make sure we
// only ever run it once, for the "starting" project.
def snykMergedDepsConfExecuted = false
allprojects { everyProj ->
    task snykResolvedDepsJson {
        def onlyProj = project.hasProperty('onlySubProject') ? onlySubProject : null

        def confNameFilter = (project.hasProperty('configuration')
            ? Pattern.compile(configuration, Pattern.CASE_INSENSITIVE)
            : /.*/
        )
        def confAttrSpec = (project.hasProperty('confAttr')
            ? confAttr.toLowerCase().split(',').collect { it.split(':') }
            : null
        )

        def matchesAttributeFilter
        matchesAttributeFilter = { conf ->
            if (!conf.hasProperty('attributes')) {
                // Gradle before version 3 does not support attributes
                return true
            }
            def matches = true
            def attrs = conf.attributes
            attrs.keySet().each({ attr ->
                def attrValueAsString = attrs.getAttribute(attr).toString().toLowerCase()
                confAttrSpec.each({ keyValueFilter ->
                    // attr.name is a class name, e.g. com.android.build.api.attributes.BuildTypeAttr
                    if (attr.name.toLowerCase().contains(keyValueFilter[0])
                            && attrValueAsString != keyValueFilter[1]) {
                        matches = false
                    }
                })
            })
            return matches
        }

        def isMatchingConfiguration
        isMatchingConfiguration = {
            it.name != 'snykMergedDepsConf' && it.name =~ confNameFilter && matchesAttributeFilter(conf)
        }

        doLast { task ->
            if (!snykMergedDepsConfExecuted) {
                println('SNYKECHO snykResolvedDepsJson task is executing via doLast')
                def projectsDict = [:]
                def defaultProjectName = task.project.name
                def result = [
                    'defaultProject': defaultProjectName,
                    'projects': projectsDict,
                    'allSubProjectNames': allprojects.collect { it.name }
                ]

                def shouldScanProject = {
                    onlyProj == null ||
                    (onlyProj == '.' && it.name == defaultProjectName) ||
                    it.name == onlyProj
                }

                // First pass: scan all configurations that match the attribute filter and collect all attributes
                // from them, to use unambiguous values of the attributes on the merged configuration.
                //
                // Why we need to scan all sub-projects: if a project A depends on B, and only B has some
                // configurations with attribute C, we still might need attribute C in our configuration
                // when resolving the project A, so that it selects a concrete variant of dependency B.
                def allConfigurationAttributes = [:] // Map<Attribute<?>, Set<?>>
                def attributesAsStrings = [:] // Map<String, Set<string>>
                rootProject.allprojects.each { proj ->
                    proj.configurations.findAll({ it.name != 'snykMergedDepsConf' && it.name =~ confNameFilter && matchesAttributeFilter(it) }).each { conf ->
                        if (!conf.hasProperty('attributes')) {
                           // Gradle before version 3 does not support attributes
                            return
                        }
                        def attrs = conf.attributes
                        attrs.keySet().each({ attr ->
                            def value = attrs.getAttribute(attr)
                            if (!allConfigurationAttributes.containsKey(attr)) {
                                allConfigurationAttributes[attr] = new HashSet()
                                attributesAsStrings[attr.name] = new HashSet()
                            }
                            allConfigurationAttributes[attr].add(value)
                            attributesAsStrings[attr.name].add(value.toString())
                        })
                    }
                }

                // These will be used to suggest attribute filtering to the user if the scan fails
                // due to ambiguous resolution of dependency variants
                println("JSONATTRS " + JsonOutput.toJson(attributesAsStrings))

                rootProject.allprojects.findAll(shouldScanProject).each { proj ->
                    println('SNYKECHO processing project: ' + proj.name)
                    def snykConf = null
                    def mergeableConfs = proj.configurations
                        .findAll({ it.name != 'snykMergedDepsConf' && it.name =~ confNameFilter })

                    // Drop all the configrations that don't match the attribute filter
                    if (confAttrSpec != null) {
                        mergeableConfs = mergeableConfs.findAll(matchesAttributeFilter)
                    }

                    if (mergeableConfs.size() == 0 && proj.configurations.size() > 0) {

                        throw new RuntimeException('Matching configurations not found: ' + confNameFilter +
                                ', available configurations for project ' + proj + ': '
                                + proj.configurations.collect { it.name })

                    } else if (mergeableConfs.size() == 1) {

                        // We use the only matching configuration, with its attributes.
                        snykConf = mergeableConfs.first()

                    } else if (mergeableConfs.size() > 1) {
                        println('SNYKECHO constructing merged configuration from ' + mergeableConfs.collect { conf -> conf.name })
                        // We create a new, "merged" configuration here.
                        snykConf = proj.configurations.create('snykMergedDepsConf')

                        mergeableConfs.each { snykConf.extendsFrom(it) }

                        // Copy all the unambiguous build attributes into the merged configuration
                        // Gradle before version 3 does not support attributes
                        if (snykConf.hasProperty('attributes')) {
                            allConfigurationAttributes.each({ attr, valueSet ->
                                if (valueSet.size() == 1) {
                                    snykConf.attributes.attribute(attr, valueSet.head())
                                }
                            })
                        }
                    }
                    if (snykConf != null) {
                        println('SNYKECHO resolving configuration ' + snykConf.name)
                        def gradleFirstLevelDeps = snykConf.resolvedConfiguration.firstLevelModuleDependencies
                        println('SNYKECHO converting the dependency graph to the DepTree format')
                        projectsDict[proj.name] = [
                            'targetFile': findProject(proj.path).buildFile.toString(),
                            'depDict': getSnykGraph(gradleFirstLevelDeps)
                        ]
                    } else {
                        projectsDict[proj.name] = [
                            'targetFile': findProject(proj.path).buildFile.toString()
                        ]
                    }
                }
                println("JSONDEPS " + JsonOutput.toJson(result))
                snykMergedDepsConfExecuted = true
            }
        }
    }
}
