UpdatePlanner.kt

package com.depanalyzer.update

import com.depanalyzer.core.ProjectAnalyzer
import com.depanalyzer.parser.*
import com.depanalyzer.parser.npm.NpmPackageParser
import com.depanalyzer.parser.python.PyprojectPoetryParser
import com.depanalyzer.parser.python.RequirementsParser
import java.io.File
import java.nio.file.Path

data class UpdatePlan(
    val projectType: ProjectType,
    val buildFile: File,
    val suggestions: List<UpdateSuggestion>
)

data class UpdateAnalysisOptions(
    val dynamic: Boolean = false,
    val timeoutSeconds: Long = 1800L
)

interface UpdatePlanner {
    fun plan(projectDir: Path, options: UpdateAnalysisOptions = UpdateAnalysisOptions()): UpdatePlan
}

class AnalyzerUpdatePlanner(
    private val analyzer: ProjectAnalyzer = ProjectAnalyzer(),
    private val detector: ProjectDetector = ProjectDetector(),
    private val pomParser: PomDependencyParser = PomDependencyParser(),
    private val gradleGroovyParser: GradleGroovyDependencyParser = GradleGroovyDependencyParser(),
    private val gradleKotlinParser: GradleKotlinDependencyParser = GradleKotlinDependencyParser(),
    private val npmPackageParser: NpmPackageParser = NpmPackageParser(),
    private val pyprojectParser: PyprojectPoetryParser = PyprojectPoetryParser(),
    private val requirementsParser: RequirementsParser = RequirementsParser()
) : UpdatePlanner {
    override fun plan(projectDir: Path, options: UpdateAnalysisOptions): UpdatePlan {
        val projectType = detector.detect(projectDir)
        val report = analyzer.analyze(
            projectDir = projectDir,
            disableMaven = !options.dynamic,
            disableGradle = !options.dynamic,
            timeoutSeconds = options.timeoutSeconds
        )
        val buildFile = resolveBuildFile(projectDir, projectType)
        val declaredCoordinates = declaredCoordinates(buildFile, projectType)
        val vulnerableCoordinates = (report.directVulnerable + report.transitiveVulnerable)
            .map { "${it.groupId}:${it.artifactId}" }
            .toSet()
        val outdatedByGaAndVersion = report.outdated.associateBy(
            keySelector = { "${it.groupId}:${it.artifactId}:${it.currentVersion}" },
            valueTransform = { it.latestVersion }
        )

        val directSuggestions = report.outdated
            .filter { "${it.groupId}:${it.artifactId}" in declaredCoordinates }
            .map { outdated ->
                val coordinate = "${outdated.groupId}:${outdated.artifactId}"
                val hasCve = coordinate in vulnerableCoordinates
                val reason = when {
                    hasCve -> UpdateReason.CVE
                    else -> UpdateReason.OUTDATED
                }

                UpdateSuggestion(
                    groupId = outdated.groupId,
                    artifactId = outdated.artifactId,
                    currentVersion = outdated.currentVersion,
                    newVersion = outdated.latestVersion,
                    reason = reason,
                    targetType = UpdateTargetType.DIRECT,
                    ecosystem = outdated.ecosystem
                )
            }

        val transitiveOverrideSuggestions = if (options.dynamic) {
            buildTransitiveOverrideSuggestions(
                report = report,
                outdatedByGaAndVersion = outdatedByGaAndVersion,
                existingDirectCoordinates = directSuggestions.map { it.coordinate }.toSet()
            )
        } else {
            emptyList()
        }

        val suggestions = (directSuggestions + transitiveOverrideSuggestions)
            .distinctBy { "${it.coordinate}:${it.currentVersion}:${it.newVersion}:${it.targetType}" }
            .sortedBy { it.coordinate }

        return UpdatePlan(
            projectType = projectType,
            buildFile = buildFile,
            suggestions = suggestions
        )
    }

    private fun buildTransitiveOverrideSuggestions(
        report: com.depanalyzer.report.DependencyReport,
        outdatedByGaAndVersion: Map<String, String>,
        existingDirectCoordinates: Set<String>
    ): List<UpdateSuggestion> {
        val roots = report.dependencyTree.orEmpty()
        if (roots.isEmpty()) return emptyList()

        val rootByCoordinate = roots.associateBy { it.coordinate }
        val result = mutableListOf<UpdateSuggestion>()

        fun visit(node: com.depanalyzer.report.DependencyTreeNode) {
            if (!node.isDirectDependency) {
                val rootCoordinate = node.dependencyChain?.firstOrNull()
                val rootNode = rootCoordinate?.let { rootByCoordinate[it] }
                val rootIsFullyUpdated =
                    rootNode != null && rootNode.latestVersion == null && rootNode.vulnerabilities.isEmpty()

                val hasProblem = node.latestVersion != null || node.vulnerabilities.isNotEmpty()
                val latest = node.latestVersion
                    ?: outdatedByGaAndVersion["${node.groupId}:${node.artifactId}:${node.currentVersion}"]

                if (rootIsFullyUpdated && hasProblem && latest != null && latest != node.currentVersion) {
                    val coordinate = "${node.groupId}:${node.artifactId}"
                    if (coordinate !in existingDirectCoordinates) {
                        val reason = if (node.vulnerabilities.isNotEmpty()) UpdateReason.CVE else UpdateReason.OUTDATED
                        result.add(
                            UpdateSuggestion(
                                groupId = node.groupId,
                                artifactId = node.artifactId,
                                currentVersion = node.currentVersion,
                                newVersion = latest,
                                reason = reason,
                                targetType = UpdateTargetType.TRANSITIVE_OVERRIDE,
                                viaDirectCoordinate = rootNode.coordinate.substringBeforeLast(":"),
                                ecosystem = node.ecosystem
                            )
                        )
                    }
                }
            }

            node.children.forEach(::visit)
        }

        roots.forEach(::visit)
        return result
    }

    private fun resolveBuildFile(projectDir: Path, type: ProjectType): File {
        val dir = projectDir.toFile()
        return when (type) {
            ProjectType.MAVEN -> File(dir, "pom.xml")
            ProjectType.GRADLE_GROOVY -> File(dir, "build.gradle")
            ProjectType.GRADLE_KOTLIN -> File(dir, "build.gradle.kts")
            ProjectType.NPM -> File(dir, "package.json")
            ProjectType.PYTHON_POETRY -> File(dir, "pyproject.toml")
            ProjectType.PYTHON_REQUIREMENTS -> File(dir, "requirements.txt")
        }
    }

    private fun declaredCoordinates(buildFile: File, type: ProjectType): Set<String> {
        return when (type) {
            ProjectType.MAVEN -> {
                pomParser.parse(buildFile)
                    .filter { it.section == DependencySection.DEPENDENCIES || it.section == DependencySection.DEPENDENCY_MANAGEMENT }
                    .map { "${it.groupId}:${it.artifactId}" }
                    .toSet()
            }

            ProjectType.GRADLE_GROOVY -> {
                gradleGroovyParser.parse(buildFile)
                    .map { "${it.groupId}:${it.artifactId}" }
                    .toSet()
            }

            ProjectType.GRADLE_KOTLIN -> {
                gradleKotlinParser.parse(buildFile)
                    .map { "${it.groupId}:${it.artifactId}" }
                    .toSet()
            }

            ProjectType.NPM -> {
                npmPackageParser.parse(buildFile)
                    .map { "${it.groupId}:${it.artifactId}" }
                    .toSet()
            }

            ProjectType.PYTHON_POETRY -> {
                pyprojectParser.parse(buildFile)
                    .map { "${it.groupId}:${it.artifactId}" }
                    .toSet()
            }

            ProjectType.PYTHON_REQUIREMENTS -> {
                requirementsParser.parse(buildFile)
                    .map { "${it.groupId}:${it.artifactId}" }
                    .toSet()
            }
        }
    }
}