import groovy.json.JsonOutput
import java.util.regex.Pattern
import java.util.regex.Matcher
import org.gradle.util.GradleVersion
import org.gradle.api.internal.artifacts.DefaultResolvedDependency

// Snyk dependency resolution script for Gradle.
// Tested on Gradle versions from v2.14 to v6.8.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 {
//   depGraph: DepGraph;
//   gradleGraph: GradleGraph;
//   targetFile: string;
// }
// interface GradleGraph {
//   [id: string]: {
//     name: string;
//     version: string;
//     parentIds: string[];
//   };
// }

class GradleGraph {
    Map<String, Map> nodes = [:]
    String rootId

    GradleGraph(String rootId) {
        this.rootId = rootId
    }

    Map setNode(String key, Map value) {
        if (!key || !value) {
            return null
        }

        nodes.computeIfAbsent(key) { _ ->
            ['name': value.name, 'version': value.version, 'parentIds': [] as Set]
        }
    }

    void setEdge(String parentId, String childId) {
        if (isInvalidEdge(parentId, childId)) {
            return
        }

        def parentNode = nodes.computeIfAbsent(parentId) { _ -> createEmptyNode() }
        def childNode = nodes.computeIfAbsent(childId) { _ -> createEmptyNode() }

        if (!childNode.parentIds.contains(parentId)) {
            childNode.parentIds.add(parentId)
        }
    }

    private boolean isInvalidEdge(String parentId, String childId) {
        parentId == null || childId == null || parentId == childId || (parentId != rootId && !nodes.containsKey(parentId))
    }

    private Map createEmptyNode() {
        ['name': null, 'version': null, 'parentIds': [] as Set]
    }
}


class Sha1Map {
    def coordinates
    Sha1Map() {
        coordinates = [:]
    }

    def setCoordinate(key, value) {
        this.coordinates[key] = value
    }
}

def hash(File file) {
    def md = java.security.MessageDigest.getInstance('SHA-1')
    file.eachByte(1024 * 4) { buffer, len ->
        md.update(buffer, 0, len)
    }
    return md.digest().encodeHex().toString()
}

def hashString(String str) {
    def md = java.security.MessageDigest.getInstance('SHA-1')
    return md.digest(str.bytes).encodeHex().toString()
}

// Walks each resolvable configuration via Gradle's modern resolution APIs:
// resolutionResult for the post-conflict-resolution graph (which v5's
// ResolvedConfiguration didn't apply across project boundaries), and
// artifactView { lenient = true } for resolved artifacts with file metadata.
// Replaces v5's ResolvedConfiguration.getFirstLevelModuleDependencies() walk,
// which Gradle's own Javadoc calls "legacy API. Avoid this class for new code."
def getGradleGraph(Iterable resolvableConfigs) {
    def graph = new GradleGraph('root-node')
    walkResolvableConfigs(resolvableConfigs, graph, null)
    return graph.nodes
}

def getGradleGraphWithSha1Map(Iterable resolvableConfigs, Sha1Map sha1Map) {
    if (!sha1Map) sha1Map = new Sha1Map()
    def graph = new GradleGraph('root-node')
    walkResolvableConfigs(resolvableConfigs, graph, sha1Map)
    return [graph: graph.nodes, sha1Map: sha1Map]
}

// sha1Map != null switches the walker to normalized mode (nodeIds are sha1
// hashes of artifact contents); null = regular mode (nodeIds are coordinates).
def walkResolvableConfigs(Iterable resolvableConfigs, GradleGraph graph, Sha1Map sha1Map) {
    // coordToNodeId is shared across configs: lib/graph.ts dedupes by coord,
    // so the same coord must not surface under two nodeIds (e.g. hash(file)
    // here, hashString(coord) in emitUnresolvedNode) — that produces phantom
    // real+pruned pairs. resolvedNames below is per-config on purpose; see
    // its declaration site.
    def coordToNodeId = [:]
    resolvableConfigs.each { conf ->
        Set<org.gradle.api.artifacts.component.ComponentIdentifier> visited = new HashSet<>()
        try {
            def artifactsByComponent = collectArtifactsByComponent(conf)
            // Per-config so a module resolved in compileClasspath but failed
            // (e.g. repo content filtering) in testRuntimeClasspath still
            // surfaces as unresolved where it failed. See emitUnresolvedNode
            // for what this suppresses.
            def resolvedNames = new HashSet<String>()
            artifactsByComponent.each { cid, _arts ->
                // Use group/module directly: getModuleIdentifier() is Gradle 4.9+.
                // .toString() is load-bearing — GStrings hash != Strings.
                if (cid instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier) {
                    resolvedNames.add("${cid.group}:${cid.module}".toString())
                }
            }
            def root = conf.incoming.resolutionResult.root
            walkComponent(root, graph, 'root-node', visited, artifactsByComponent, sha1Map, true, coordToNodeId, resolvedNames)
            debugLog("conf ${conf.name}: walked ${visited.size()} components, ${artifactsByComponent.size()} with artifacts")
        } catch (Exception e) {
            debugLog("traversal failed for conf ${conf.name}: ${e.class.simpleName}: ${e.message}")
        }
    }
}

def collectArtifactsByComponent(conf) {
    def grouped = [:].withDefault { [] }
    def artifacts
    try {
        // lenient=true: partially-failed resolution still returns what did resolve.
        artifacts = conf.incoming.artifactView { it.lenient = true }.artifacts
        // ArtifactCollection implements both Iterable<ResolvedArtifactResult>
        // AND FileCollection; Groovy's groupBy picks the wrong iteration, so
        // use explicit .each to force the ResolvedArtifactResult view.
        artifacts.each { grouped[it.id.componentIdentifier] << it }
    } catch (Exception e) {
        debugLog("artifactView failed for conf ${conf.name}: ${e.message}")
        return grouped
    }
    // Best-effort: .failures access varies across Gradle versions. If it works
    // we log unresolved artifacts for diagnostic visibility; if it doesn't,
    // we trace the failure so it's visible when it occurs on a version we
    // expected to support it.
    try {
        def failures = artifacts.failures
        if (failures && !failures.isEmpty()) {
            debugLog("conf ${conf.name}: ${failures.size()} unresolved artifact(s):")
            failures.each { f ->
                def msg = f.message ? f.message.replaceAll('\\s+', ' ').take(200) : '<no message>'
                debugLog("  unresolved: ${f.class.simpleName}: ${msg}")
            }
        }
    } catch (Exception failuresErr) {
        debugLog("conf ${conf.name}: artifactCollection.failures access failed (${failuresErr.class.simpleName}: ${failuresErr.message}); continuing without unresolved-dep enumeration")
    }
    return grouped
}

// Recurse over ResolvedComponentResult.dependencies. component.moduleVersion
// is the SELECTED (post-conflict-resolution) version — the correctness
// improvement over the v5 walker.
def walkComponent(component, GradleGraph graph, String parentId,
                  Set<org.gradle.api.artifacts.component.ComponentIdentifier> visited,
                  Map artifactsByComponent, Sha1Map sha1Map, boolean isRoot,
                  Map<String, String> coordToNodeId, Set<String> resolvedNames) {
    def cid = component.id
    def mv = component.moduleVersion
    // Root legitimately has null moduleVersion (isRoot branch below skips
    // emission). For non-root, no coordinate means a malformed pkgId
    // downstream — skip rather than fall back to displayName.
    if (mv == null && !isRoot) {
        debugLog("walkComponent: skipping ${cid} (non-root, null moduleVersion)")
        return
    }
    String name = mv != null ? "${mv.group}:${mv.name}" : null
    String version = mv != null ? (mv.version ?: 'unspecified') : null

    List<String> nodeIdsForThisComponent = []
    if (!isRoot) {
        def artifacts = artifactsByComponent[cid] ?: []
        if (artifacts.isEmpty()) {
            // BOMs, platforms, project deps with no jar: skip emission AND
            // skip walking children (see the early-return below).
        } else {
            artifacts.each { a ->
                def tc = extractTypeAndClassifier(a, name, version)
                String coordinate = "${name}:${tc.type}"
                if (tc.classifier) coordinate += ":${tc.classifier}"
                coordinate += "@${version}"
                // Reuse if already emitted in this walk. See walkResolvableConfigs.
                String existingNodeId = coordToNodeId[coordinate]
                String nodeId
                if (existingNodeId) {
                    nodeId = existingNodeId
                } else if (sha1Map) {
                    try {
                        nodeId = hash(a.file)
                    } catch (Exception e) {
                        debugLog("failed to hash ${a.id}: ${e.message}; falling back to coordinate hash")
                        nodeId = hashString(coordinate)
                    }
                    sha1Map.setCoordinate(nodeId, coordinate)
                    coordToNodeId[coordinate] = nodeId
                } else {
                    nodeId = coordinate
                    coordToNodeId[coordinate] = nodeId
                }
                graph.setNode(nodeId, ['name': name, 'version': version])
                graph.setEdge(parentId, nodeId)
                nodeIdsForThisComponent.add(nodeId)
            }
        }
    }

    if (visited.contains(cid)) return
    visited.add(cid)

    // Skip children of components we emitted nothing for: their dependencies
    // are constraint edges, not classpath contributions, and would leak in as
    // phantom children of our parent. Can't filter dep.isConstraint() — strict
    // lock mode marks real direct deps as constraints too (lock IS a constraint).
    boolean currentEmittedNothing = !isRoot && nodeIdsForThisComponent.isEmpty()
    if (currentEmittedNothing) return
    // ResolutionResult.dependencies is an unordered Set; sort for determinism.
    def sortedDeps = component.dependencies.toList().sort { a, b ->
        def aKey = (a instanceof org.gradle.api.artifacts.result.ResolvedDependencyResult) ? a.selected.id.displayName : a.requested.displayName
        def bKey = (b instanceof org.gradle.api.artifacts.result.ResolvedDependencyResult) ? b.selected.id.displayName : b.requested.displayName
        aKey <=> bKey
    }
    // Attach children under EVERY emitted node, not just the first: a component
    // with multiple artifacts (e.g. classifier variants) should show its
    // transitives under each artifact. The visited check inside walkComponent
    // blocks recursion explosion — subsequent calls only re-add parent edges.
    def parents = nodeIdsForThisComponent.isEmpty() ? [parentId] : nodeIdsForThisComponent
    parents.each { p ->
        sortedDeps.each { dep ->
            if (dep instanceof org.gradle.api.artifacts.result.UnresolvedDependencyResult) {
                emitUnresolvedNode(dep, graph, p, sha1Map, coordToNodeId, resolvedNames)
                return
            }
            if (!(dep instanceof org.gradle.api.artifacts.result.ResolvedDependencyResult)) return
            walkComponent(dep.selected, graph, p, visited, artifactsByComponent, sha1Map, false, coordToNodeId, resolvedNames)
        }
    }
}

// Emit a leaf node for an UnresolvedDependencyResult. Type is always 'jar'
// and classifier is dropped: ModuleComponentSelector's public API exposes
// neither (they live on the original Dependency's artifact filter, not the
// selector). 'foo:bar:1.0:linux-x86_64' that fails shows up as 'foo:bar:jar@1.0'.
def emitUnresolvedNode(dep, GradleGraph graph, String parentId, Sha1Map sha1Map, Map<String, String> coordToNodeId, Set<String> resolvedNames) {
    // Prefer attempted (the version Gradle actually tried) so dynamic-selector
    // failures show what Gradle saw; fall back to requested when null.
    def selector = dep.attempted ?: dep.requested
    // Project/library selectors don't have a coordinate worth emitting as a leaf.
    if (!(selector instanceof org.gradle.api.artifacts.component.ModuleComponentSelector)) return
    String name = "${selector.group}:${selector.module}"
    // Lock-rejection noise: strict locks emit a rejected UnresolvedDep next to
    // the resolved one for the locked version. If the name resolved at any
    // version in this config, the unresolved emission is noise — skip it.
    if (resolvedNames.contains(name)) return
    String version
    try {
        // VersionConstraint is Gradle 4.4+; older versions only have selector.version.
        def vc = selector.versionConstraint
        version = vc?.requiredVersion ?: vc?.preferredVersion ?: selector.version
    } catch (Exception ignored) {
        version = selector.version
    }
    version = version ?: 'unspecified'
    String coordinate = "${name}:jar@${version}"
    // Reuse if already in the graph (mirror of the lookup in walkComponent).
    String existingNodeId = coordToNodeId[coordinate]
    String nodeId
    if (existingNodeId) {
        nodeId = existingNodeId
    } else {
        nodeId = sha1Map ? hashString(coordinate) : coordinate
        if (sha1Map) sha1Map.setCoordinate(nodeId, coordinate)
        coordToNodeId[coordinate] = nodeId
    }
    graph.setNode(nodeId, ['name': name, 'version': version])
    graph.setEdge(parentId, nodeId)
}

// Get artifact type and classifier. Type comes from Gradle's own variant
// attributes when available (Gradle 5.1+, ResolvedArtifactResult.getVariant());
// this matches what v5's a.type returned, including non-jar types like
// java-classes-directory and java-resources-directory. Falls back to filename
// extension parsing for older Gradle. Classifier always comes from the
// filename — no public-API alternative.
def extractTypeAndClassifier(artifact, String name, String version) {
    def file = artifact.file
    def fileName = file?.name
    String classifier = extractClassifierFromFilename(fileName, name, version)

    // 1. Gradle 5.1+ — authoritative artifact-type attribute.
    try {
        def attr = artifact.variant?.attributes?.getAttribute(
            org.gradle.api.attributes.Attribute.of('artifactType', String))
        if (attr) return [type: attr, classifier: classifier]
    } catch (Exception ignored) { /* pre-5.1: fall through */ }

    // 2. Gradle 4.x fallback. Detect directories explicitly (plain Java —
    //    works on every Gradle version) so we don't mislabel them as 'jar'.
    //    Less specific than Gradle 5.1+'s artifactType attribute, but accurate.
    if (file?.isDirectory()) return [type: 'directory', classifier: null]
    return [type: fileExtensionOr(fileName, 'jar'), classifier: classifier]
}

// Returns the file extension (chars after the last dot), or `fallback` if
// there's no usable extension. A leading dot (e.g. `.gitignore`) or trailing
// dot (e.g. `file.`) both count as "no usable extension".
def fileExtensionOr(String fileName, String fallback) {
    if (!fileName) return fallback
    int dotAt = fileName.lastIndexOf('.')
    if (dotAt <= 0 || dotAt == fileName.length() - 1) return fallback
    return fileName.substring(dotAt + 1)
}

// After stripping "<moduleName>-<version>" the rest (post leading dash) is
// the classifier. Assumes moduleName has no colon — true for ModuleComponentIdentifier.
def extractClassifierFromFilename(String fileName, String name, String version) {
    if (!fileName) return null
    int dotAt = fileName.lastIndexOf('.')
    String stem = (dotAt > 0 && dotAt < fileName.length() - 1) ? fileName.substring(0, dotAt) : fileName
    String moduleName = name.contains(':') ? name.substring(name.lastIndexOf(':') + 1) : name
    String prefix = "${moduleName}-${version}"
    if (stem.startsWith(prefix)) {
        String suffix = stem.substring(prefix.length())
        if (suffix.startsWith('-') && suffix.length() > 1) {
            return suffix.substring(1)
        }
    }
    return null
}

def debugLog(msg) {
    def debug = System.getenv('DEBUG') ?: ''
    if (debug.length() > 0) {
        println("SNYKECHO $msg")
    }
}

def matchesAttributeFilter(conf, confAttrSpec) {
    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 findMatchingConfigs(confs, confNameFilter, confAttrSpec) {
    def matching = confs.findAll({ it.name =~ confNameFilter })
    if (confAttrSpec == null) {
        // We don't have an attribute spec to match
        return matching
    }
    return matching.findAll({ matchesAttributeFilter(it, confAttrSpec) })
}

def findProjectConfigs(proj, confNameFilter, confAttrSpec) {
    def matching = findMatchingConfigs(proj.configurations, confNameFilter, confAttrSpec)
    proj.configurations.each({ debugLog("conf.name=$it.name; conf.canBeResolved=$it.canBeResolved; conf.canBeConsumed=$it.canBeConsumed") })
    // Skip Gradle's auto-created "publication" configs (default, archives) —
    // they're both resolvable AND consumable and exist to expose this project
    // to downstream project-dep consumers, not to describe its own classpaths.
    // V6's transitive ResolutionResult walk would pull in unlocked
    // extends-from transitives (lockfiles typically cover compile/runtime
    // classpaths, not default), which doesn't reflect any real classpath the
    // project actually builds against. The v5 walker walked them via
    // getFirstLevelModuleDependencies() — empty since nothing is directly
    // declared on them — so it never saw their transitives and the issue
    // was masked.
    //
    // We can't filter purely on `canBeConsumed`: user-defined custom configs
    // declared without explicit flags (e.g. `configurations { extraLibs }`)
    // also default to both flags = true, and those ARE meant to be walked.
    // The signal that distinguishes them: auto-created publication configs
    // have no directly-declared dependencies (they aggregate via
    // extendsFrom), while user-intent custom configs have their own.
    def resolvable = matching.findAll {
        it.canBeResolved && (!it.canBeConsumed || !it.dependencies.isEmpty())
    }
    debugLog("resolvableConfigs=$resolvable")
    return resolvable
}

String formatPath(path) {
    return path.replace(':', '/').replaceAll(~/(^\/+?)|(\/+$)/, '')
}

Boolean isRootPath(path){
    return path == rootProject.path
}

// Shared body for both snykResolvedDepsJson and snykNormalizedResolvedDepsJson;
// they differ only in whether gradleGraph nodeIds are sha1 hashes ("normalized"
// mode) and whether the result includes a sha1Map.
def buildSnykDepsResult(currProj, onlyProj, confNameFilter, confAttrSpec, boolean withSha1Map) {
    // Attribute values across matching configs — used as a user-facing hint
    // when variant resolution fails ambiguously.
    def attributesAsStrings = [:].withDefault { new HashSet() }
    rootProject.allprojects.each { proj ->
        findMatchingConfigs(proj.configurations, confNameFilter, confAttrSpec).each { conf ->
            conf.attributes.keySet().each { attr ->
                attributesAsStrings[attr.name].add(conf.attributes.getAttribute(attr).toString())
            }
        }
    }
    println("JSONATTRS " + JsonOutput.toJson(attributesAsStrings))

    String defaultProjectName = currProj.name
    String defaultProjectKey = isRootPath(currProj.path) ? defaultProjectName : formatPath(currProj.path)
    // currProj.allprojects (not rootProject.allprojects) — when the task fires
    // on a subproject, sibling projects shouldn't appear here.
    List allSubProjectNames = currProj.allprojects
        .findAll { it.path != currProj.path }
        .collect { formatPath(it.path) }
    def shouldScanProject = {
        onlyProj == null ||
        (onlyProj == '.' && it.path == currProj.path) ||
        it.name == onlyProj ||
        formatPath(it.path) == onlyProj
    }
    debugLog("defaultProjectName=$defaultProjectName; defaultProjectKey=$defaultProjectKey; allSubProjectNames=$allSubProjectNames")

    def sha1Map = withSha1Map ? new Sha1Map() : null
    def projectsDict = [:]

    rootProject.allprojects.findAll(shouldScanProject).each { proj ->
        debugLog("processing project: name=$proj.name; path=$proj.path")
        def resolvableConfigs = findProjectConfigs(proj, confNameFilter, confAttrSpec)
        debugLog("converting gradle graph to snyk-graph format")
        def projGraph = withSha1Map
            ? getGradleGraphWithSha1Map(resolvableConfigs, sha1Map).graph
            : getGradleGraph(resolvableConfigs)
        String projKey = formatPath(proj.path)
        if (projKey == "") {
            debugLog("project path is empty (proj.path=$proj.path)! will use defaultProjectName=$defaultProjectName")
            projKey = defaultProjectKey
        }
        projectsDict[projKey] = [
            'targetFile': rootProject.findProject(proj.path).buildFile.toString(),
            'gradleGraph': projGraph,
            'projectVersion': proj.version.toString()
        ]
    }

    def result = [
        'defaultProject': defaultProjectName,
        'defaultProjectKey': defaultProjectKey,
        'projects': projectsDict,
        'allSubProjectNames': allSubProjectNames
    ]
    if (withSha1Map) result['sha1Map'] = sha1Map.coordinates
    return result
}

// Attach to every project so the task is runnable when a subproject build.gradle
// is the entry point; the flag ensures only the first body actually runs.
def snykDepsConfExecuted = false
allprojects { Project currProj ->
    debugLog("Current project: $currProj.name")
    String 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
    )

    task snykResolvedDepsJson {
        doLast { task ->
            if (snykDepsConfExecuted) return
            snykDepsConfExecuted = true
            debugLog('snykResolvedDepsJson task is executing via doLast')
            def result = buildSnykDepsResult(currProj, onlyProj, confNameFilter, confAttrSpec, false)
            println("JSONDEPS " + JsonOutput.toJson(result))
        }
    }

    task snykNormalizedResolvedDepsJson {
        doLast { task ->
            if (snykDepsConfExecuted) return
            snykDepsConfExecuted = true
            debugLog('snykNormalizedResolvedDepsJson task is executing via doLast')
            def result = buildSnykDepsResult(currProj, onlyProj, confNameFilter, confAttrSpec, true)
            println("JSONDEPS " + JsonOutput.toJson(result))
        }
    }
}
