import groovy.json.JsonOutput
import groovy.transform.Field
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, Closure metadataProvider = null) {
        if (!key || !value) {
            return null
        }

        nodes.computeIfAbsent(key) { _ ->
            def node = ['name': value.name, 'version': value.version, 'parentIds': [] as Set]
            // Component metadata (opt-in via -PsnykIncludeComponentMetadata).
            // Computed lazily and only when the nodeId is genuinely new: a
            // shared component is re-walked once per incoming edge, but only the
            // first emission wins, so hashing on every edge would be wasted disk
            // I/O and CPU. Evaluating inside computeIfAbsent runs it exactly once.
            if (metadataProvider) {
                def metadata = metadataProvider()
                if (metadata) {
                    if (metadata.hashes) node.hashes = metadata.hashes
                    if (metadata.distributionUrl) node.distributionUrl = metadata.distributionUrl
                }
            }
            node
        }
    }

    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
    }
}

// Stream a file once, feeding every requested digest from a single read.
// `algorithms` maps the JDK algorithm name to its output label; returns
// {label: lowercase-hex}. Shared by every file-hashing helper below.
def digestFile(File file, Map<String, String> algorithms) {
    def javaNames = algorithms.keySet().toList()
    def mds = javaNames.collect { java.security.MessageDigest.getInstance(it) }
    // Reuse a single 64 KB buffer for the whole file. Groovy's
    // File.eachByte(int, Closure) allocates a fresh byte[] copy per chunk, so
    // hashing large artifacts churns enough short-lived garbage to inflate the
    // Gradle daemon's peak heap; a manual read loop keeps allocation ~constant.
    // Digests are identical either way — MessageDigest is chunking-invariant.
    // All state is local, so this stays safe under parallel per-project tasks.
    byte[] buffer = new byte[64 * 1024]
    file.withInputStream { input ->
        int read
        while ((read = input.read(buffer)) != -1) {
            for (md in mds) {
                md.update(buffer, 0, read)
            }
        }
    }
    def result = [:]
    javaNames.eachWithIndex { javaName, i ->
        result[algorithms[javaName]] = mds[i].digest().encodeHex().toString()
    }
    return result
}

// SHA-1 hex of a file's contents (normalized-mode node id).
def hash(File file) {
    return digestFile(file, ['SHA-1': 'sha-1'])['sha-1']
}

// SHA-1 hex of a string (fallback node id when a file hash is unavailable).
// Hashes in-memory bytes one-shot, so it does not use digestFile.
def hashString(String str) {
    return java.security.MessageDigest.getInstance('SHA-1').digest(str.bytes).encodeHex().toString()
}

// ── Component metadata (opt-in: -PsnykIncludeComponentMetadata) ──────────────
// Emits the same node-label vocabulary as snyk-mvn-plugin / nodejs-lockfile-parser:
//   hash:<alg>       lowercase-hex digests of the resolved artifact file
//   distribution:url the real remote URL Gradle read the artifact from
// distribution:url is TRUE provenance only — Gradle exposes no per-artifact
// source repository in its public API, so we capture the actual resource-read
// URLs via an internal build-operation listener (see registerResourceReadListener).
// On a warm cache nothing is re-fetched, so a node simply carries no
// distribution:url — graceful omission, never a reconstructed guess.

// @Field so the standalone `def` helpers below (buildArtifactMetadata etc.)
// can read this shared state — run()-scope locals aren't visible to methods,
// and bare assignments in an init script route to the Gradle object.
@Field boolean snykIncludeComponentMetadata = false
// filename -> Set<url>. A Set because a shared cache can see the same filename
// from more than one repo across a build; disambiguated at lookup by layout.
@Field def snykUrlsByFilename = new java.util.concurrent.ConcurrentHashMap()

// Compute md5/sha-1/sha-256/sha-512 in a single pass over the file bytes.
// Keys match the CycloneDX-friendly label suffixes used across ecosystems.
def computeHashes(File file) {
    return digestFile(file, ['MD5': 'md5', 'SHA-1': 'sha-1', 'SHA-256': 'sha-256', 'SHA-512': 'sha-512'])
}

// Reduce an already-parsed resource URI to its canonical location —
// scheme://host[:port]/path — by dropping userInfo, query and fragment.
// Standard Maven/Gradle repos authenticate via headers and serve artifacts at
// plain path-based URLs, but a repo configured with credentials embedded in the
// URL (https://user:pass@host/repo) carries that userInfo on every artifact
// URL, so we strip it to keep credentials out of the dep-graph/SBOM. Query and
// fragment are dropped too: they are not part of the artifact's durable
// location (at most a request-scoped redirect artefact), so the bare path is
// the correct, reproducible provenance value. Operating on the parsed URI keeps
// the rule single and robust.
def canonicalUrl(URI u) {
    try {
        // Server-based authority: host and port are parsed out, so rebuild from
        // components with an explicit null userInfo.
        if (u.host != null) {
            return new URI(u.scheme, null, u.host, u.port, u.path, null, null).toString()
        }
        // Registry-based authority: java.net.URI implements RFC 2396, whose
        // hostname grammar allows only alphanumerics and "-", so an authority it
        // can't parse as host[:port] — one underscore is enough, common on
        // internal Maven mirrors — falls back to an opaque registry name with
        // host, port and userInfo all left null. The authority is then the only
        // component that survives, so strip the "user:pass@" prefix by hand.
        //
        // Split the *raw* authority: it keeps percent-escapes intact, so the only
        // unencoded "@" is the userInfo delimiter. On the decoded authority a
        // host containing %40 would arrive as a second "@" and get mistaken for
        // one. Split on the last "@" because an unencoded "@" in a password is
        // malformed but survivable, and the last one is still the delimiter.
        def raw = u.rawAuthority
        if (raw == null) return null
        int at = raw.lastIndexOf('@')
        if (at >= 0) raw = raw.substring(at + 1)
        return new URI(u.scheme + '://' + raw + (u.rawPath ?: '')).toString()
    } catch (Exception ignored) {
        // Fail closed: if the URI can't be rebuilt from its
        // components we cannot guarantee userinfo/query/fragment were stripped,
        // so drop it entirely rather than risk a credential surviving into a
        // label. The caller records nothing when this returns null.
        return null
    }
}

// Register an internal build-operation listener that records the URL of every
// external resource Gradle reads during resolution. This is the ONLY signal
// that carries true per-artifact provenance; the public resolution/artifact
// API deliberately omits it. Internal + unstable across Gradle versions, so
// the whole thing is best-effort: any failure degrades to "no distribution:url".
def registerResourceReadListener() {
    try {
        def manager = gradle.services.get(org.gradle.internal.operations.BuildOperationListenerManager)
        // Map-of-closures coerced to the interface: closures take untyped args,
        // so this satisfies the interface's typed abstract methods on every
        // Gradle version (the method set has changed — e.g. progress() was added
        // later). Methods absent from the map become harmless no-ops.
        def listener = [
            started : { buildOp, startEvent -> },
            progress: { operationId, progressEvent -> },
            finished: { buildOp, finishEvent -> recordResourceRead(buildOp) },
        ] as org.gradle.internal.operations.BuildOperationListener
        manager.addListener(listener)
        // BuildOperationListenerManager is a daemon-scoped service, so a listener
        // added here otherwise survives into later builds on a reused daemon —
        // accumulating one per build, each pinning its build's captured state.
        // Remove it when this build finishes. buildFinished is deprecated on newer
        // Gradle but remains the most portable cleanup hook across 4.x–9.x;
        // guarded so an unavailable hook never breaks resolution.
        try {
            gradle.buildFinished { manager.removeListener(listener) }
        } catch (Exception ignored) {
            // No usable build-finished hook: the listener is reclaimed when the
            // daemon exits, so the worst case is a bounded per-build leak.
        }
        debugLog('component-metadata: resource-read listener registered')
    } catch (Exception e) {
        debugLog("component-metadata: listener registration failed (${e.class.simpleName}: ${e.message}); distribution:url unavailable")
    }
}

// Record the URL of one finished build operation if it is an external-resource
// read. Duck-typed on getLocation() to avoid importing the internal details
// type (whose package has moved across Gradle versions).
def recordResourceRead(buildOp) {
    try {
        def details = buildOp?.details
        if (details == null) return
        if (!details.metaClass.respondsTo(details, 'getLocation')) return
        def loc = details.getLocation()
        if (loc == null) return
        def url = loc.toString()
        if (!(url.startsWith('http://') || url.startsWith('https://'))) return
        def uri
        try { uri = new URI(url) } catch (Exception ignored) { return }
        // Derive the filename from the parsed path (already free of query and
        // fragment), then store the canonicalised, credential-free URL.
        def path = uri.path
        if (!path) return
        int slash = path.lastIndexOf('/')
        if (slash < 0 || slash == path.length() - 1) return
        def filename = path.substring(slash + 1)
        def clean = canonicalUrl(uri)
        if (clean == null) return
        snykUrlsByFilename
            .computeIfAbsent(filename) { _ -> java.util.Collections.synchronizedSet(new LinkedHashSet()) }
            .add(clean)
    } catch (Exception ignored) { /* one bad op must not break resolution */ }
}

// Read the requested -P property at init time; project properties surface on
// the start parameter before any project is evaluated. Gate on the property's
// value (not mere presence) so an explicit =false disables the feature,
// matching the snyk-deps-init.gradle resolver.
snykIncludeComponentMetadata = gradle.startParameter.projectProperties
    .get('snykIncludeComponentMetadata')?.toBoolean() ?: false
if (snykIncludeComponentMetadata) {
    registerResourceReadListener()
}

// Build the per-node metadata map for a resolved artifact: file hashes plus,
// when we observed the real read, its distribution:url. Returns null when the
// flag is off or nothing could be derived (e.g. no artifact file).
def buildArtifactMetadata(artifact, String name, String version) {
    if (!snykIncludeComponentMetadata) return null
    try {
        def file = artifact?.file
        if (file == null || !file.isFile()) return null
        def meta = ['hashes': computeHashes(file)]
        def url = lookupDistributionUrl(name, version, file.name)
        if (url) meta.distributionUrl = url
        return meta
    } catch (Exception e) {
        debugLog("component-metadata: failed for ${name}@${version}: ${e.message}")
        return null
    }
}

// Resolve a filename to the URL it was actually read from. Always confirm the
// captured URL belongs to THIS artifact's Maven-layout path before labelling:
// a shared build can read the same filename from more than one repo, and even
// a lone captured URL may belong to a different GAV that happens to share a
// filename. If no candidate — or more than one — matches the layout, emit
// nothing rather than guess.
def lookupDistributionUrl(String name, String version, String fileName) {
    def candidates = snykUrlsByFilename.get(fileName)
    if (!candidates || candidates.isEmpty()) return null
    try {
        def group = name.contains(':') ? name.substring(0, name.indexOf(':')) : name
        def module = name.contains(':') ? name.substring(name.indexOf(':') + 1) : name
        def suffix = "/${group.replace('.', '/')}/${module}/${version}/${fileName}".toString()
        def matches = candidates.findAll { candidate ->
            try {
                return new URI(candidate).path?.endsWith(suffix)
            } catch (Exception ignored) {
                return false
            }
        }
        if (matches.size() == 1) return matches[0]
    } catch (Exception ignored) { /* fall through to no-label */ }
    return null
}

// 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]) { buildArtifactMetadata(a, name, 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
    // Snapshot the key set with toList() before iterating: getAttribute() below can realize a
    // lazy (provider-based) attribute, which on Gradle 7.4-8.2 mutates the live keySet() union
    // view mid-iteration and throws ConcurrentModificationException (OSM-3867).
    attrs.keySet().toList().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 ->
            // Snapshot the key set with toList() before iterating: getAttribute() below can
            // realize a lazy (provider-based) attribute, which on Gradle 7.4-8.2 mutates the
            // live keySet() union view mid-iteration and throws ConcurrentModificationException.
            // This restores the pre-7.0.0 behaviour and fixes OSM-3867.
            conf.attributes.keySet().toList().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))
        }
    }
}
