PomDependencyParser.kt

package com.depanalyzer.parser

import com.depanalyzer.repository.ProjectRepository
import com.depanalyzer.security.InputSafety
import org.apache.maven.model.Dependency
import org.apache.maven.model.Model
import org.apache.maven.model.Repository
import org.apache.maven.model.io.xpp3.MavenXpp3Reader
import java.io.File
import java.io.FileReader

class PomDependencyParser(
    private val envProvider: (String) -> String? = { System.getenv(it) },
    private val trustedCredentialHosts: Set<String> =
        InputSafety.parseTrustedCredentialHosts(envProvider(InputSafety.CREDENTIAL_HOST_ALLOWLIST_ENV))
) {
    fun parse(pomFile: File): List<ParsedDependency> {
        require(pomFile.exists() && pomFile.isFile) { "Invalid pom file path: ${pomFile.absolutePath}" }
        require(pomFile.name == "pom.xml") { "Expected pom.xml, got ${pomFile.name}" }

        val hierarchy = buildHierarchy(pomFile)
        val allProperties = mergeProperties(hierarchy)
        val managedVersions = mergeManagedVersions(hierarchy, allProperties)

        val result = mutableListOf<ParsedDependency>()

        hierarchy.first().dependencies
            .mapNotNull { toParsedDependency(it, DependencySection.DEPENDENCIES, allProperties, managedVersions) }
            .also(result::addAll)

        hierarchy.first().dependencyManagement?.dependencies.orEmpty()
            .mapNotNull {
                toParsedDependency(
                    it,
                    DependencySection.DEPENDENCY_MANAGEMENT,
                    allProperties,
                    managedVersions
                )
            }
            .also(result::addAll)

        return result
    }

    fun repositories(pomFile: File): List<ProjectRepository> {
        require(pomFile.exists() && pomFile.isFile) { "Invalid pom file path: ${pomFile.absolutePath}" }
        val hierarchy = buildHierarchy(pomFile)
        val properties = mergeProperties(hierarchy)

        val repositories = mutableMapOf<String, ProjectRepository>()

        hierarchy.forEach { model ->
            model.repositories.orEmpty().forEach { repo ->
                val projectRepo = toProjectRepository(repo, properties)
                if (projectRepo != null) {
                    repositories.putIfAbsent(projectRepo.id, projectRepo)
                }
            }
            model.pluginRepositories.orEmpty().forEach { repo ->
                val projectRepo = toProjectRepository(repo, properties)
                if (projectRepo != null) {
                    repositories.putIfAbsent(projectRepo.id, projectRepo)
                }
            }
        }

        return if (repositories.isEmpty()) {
            listOf(ProjectRepository.MAVEN_CENTRAL)
        } else {
            repositories.values.toList()
        }
    }

    private fun toProjectRepository(repo: Repository, properties: Map<String, String>): ProjectRepository? {
        val id = repo.id?.trim() ?: return null
        val url = repo.url?.trim()?.let { resolvePlaceholders(it, properties) } ?: return null
        if (!InputSafety.isAllowedRepositoryUrl(url)) return null

        val releases = repo.releases?.isEnabled?.toString()?.equals("false", true)?.not() ?: true
        val snapshots = repo.snapshots?.isEnabled?.toString()?.equals("true", true) ?: false

        val allowCredentials = InputSafety.isTrustedCredentialDestination(url, trustedCredentialHosts)
        val username = if (allowCredentials) {
            envProvider("MAVEN_REPO_${id.uppercase().replace("-", "_")}_USERNAME")
        } else {
            null
        }
        val password = if (allowCredentials) {
            envProvider("MAVEN_REPO_${id.uppercase().replace("-", "_")}_PASSWORD")
        } else {
            null
        }

        return ProjectRepository(
            id = id,
            url = url,
            releases = releases,
            snapshots = snapshots,
            username = username,
            password = password
        )
    }

    private fun buildHierarchy(rootPom: File): List<Model> {
        val models = mutableListOf<Model>()
        var currentPom: File? = rootPom
        val rootCanonicalPom = rootPom.canonicalFile
        var depth = 0

        while (currentPom != null && depth < 10) {
            val model = readModel(currentPom)
            models.add(model)

            val parent = model.parent ?: break
            val relativePath = parent.relativePath?.trim().takeUnless { it.isNullOrBlank() } ?: "../pom.xml"
            val parentPom = File(currentPom.parentFile, relativePath).canonicalFile
            if (!InputSafety.isWithinParentBoundary(rootCanonicalPom, parentPom)) {
                break
            }

            if (!parentPom.exists() || parentPom == currentPom.canonicalFile) {
                break
            }

            currentPom = parentPom
            depth++
        }

        return models
    }

    private fun readModel(file: File): Model = FileReader(file).use { reader ->
        MavenXpp3Reader().read(reader)
    }

    private fun mergeProperties(hierarchy: List<Model>): Map<String, String> {
        val merged = mutableMapOf<String, String>()
        hierarchy.asReversed().forEach { model ->
            model.properties?.forEach { (key, value) ->
                merged[key.toString()] = value.toString()
            }
            model.groupId?.let { merged["project.groupId"] = it }
            model.version?.let { merged["project.version"] = it }
            model.artifactId?.let { merged["project.artifactId"] = it }
            model.parent?.groupId?.let { merged["project.parent.groupId"] = it }
            model.parent?.version?.let { merged["project.parent.version"] = it }
            model.parent?.artifactId?.let { merged["project.parent.artifactId"] = it }
        }
        return merged.mapValues { (_, value) -> resolvePlaceholders(value, merged) }
    }

    private fun mergeManagedVersions(
        hierarchy: List<Model>,
        properties: Map<String, String>
    ): Map<String, String> {
        val managed = mutableMapOf<String, String>()
        hierarchy.asReversed().forEach { model ->
            model.dependencyManagement?.dependencies.orEmpty().forEach { dependency ->
                val key = "${dependency.groupId}:${dependency.artifactId}"
                val rawVersion = dependency.version?.trim()
                if (!rawVersion.isNullOrBlank()) {
                    managed[key] = resolvePlaceholders(rawVersion, properties)
                }
            }
        }
        return managed
    }

    private fun toParsedDependency(
        dependency: Dependency,
        section: DependencySection,
        properties: Map<String, String>,
        managedVersions: Map<String, String>
    ): ParsedDependency? {
        val groupId = dependency.groupId?.trim()?.takeIf { it.isNotEmpty() } ?: return null
        val artifactId = dependency.artifactId?.trim()?.takeIf { it.isNotEmpty() } ?: return null
        val key = "$groupId:$artifactId"

        val rawVersion = dependency.version?.trim()
        val version = when {
            !rawVersion.isNullOrEmpty() -> resolvePlaceholders(rawVersion, properties)
            section == DependencySection.DEPENDENCIES -> managedVersions[key]
            else -> null
        }

        val scope = dependency.scope?.trim().takeUnless { it.isNullOrBlank() } ?: "compile"

        return ParsedDependency(
            groupId = resolvePlaceholders(groupId, properties),
            artifactId = resolvePlaceholders(artifactId, properties),
            version = version,
            scope = scope,
            section = section
        )
    }

    private fun resolvePlaceholders(rawValue: String, properties: Map<String, String>): String {
        var resolved = rawValue
        var guard = 0
        val regex = Regex("\\$\\{([^}]+)}")

        while (guard < 20) {
            val match = regex.find(resolved) ?: break
            val key = match.groupValues[1]
            val replacement = properties[key] ?: break
            resolved = resolved.replace("\${$key}", replacement)
            guard++
        }
        return resolved
    }
}