/*
 * Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors.
 * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
 */

@file:Suppress("DEPRECATION") // Suppress deprecation warning of HasConvention

package org.jetbrains.kotlin.pill

import org.gradle.api.Named
import org.gradle.api.NamedDomainObjectContainer
import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
import org.gradle.api.tasks.*
import org.gradle.api.plugins.JavaPlugin
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.plugins.ide.idea.IdeaPlugin
import org.gradle.api.file.SourceDirectorySet
import org.gradle.api.internal.file.copy.CopySpecInternal
import org.gradle.api.internal.file.copy.SingleParentCopySpec
import org.gradle.api.provider.Property
import org.gradle.jvm.tasks.Jar
import org.gradle.jvm.toolchain.JavaToolchainService
import org.gradle.kotlin.dsl.extra
import org.gradle.kotlin.dsl.findByType
import org.gradle.language.jvm.tasks.ProcessResources
import org.jetbrains.kotlin.pill.model.POrderRoot.*
import org.jetbrains.kotlin.pill.model.PSourceRoot.*
import org.jetbrains.kotlin.pill.model.*
import java.io.File
import java.util.*

typealias OutputDir = String
typealias GradleProjectPath = String

class ModelParser(private val modulePrefix: String, private val globalExcludedDirectories: List<File>) {
    fun parse(project: Project): PProject {
        if (project != project.rootProject) {
            error("$project is not a root project")
        }

        val (includedProjects, excludedProjects) = project.allprojects
            .partition { it == project || it.plugins.hasPlugin("jps-compatible") }

        val modules = includedProjects.flatMap { parseModules(it, excludedProjects) }
        val artifacts = parseArtifacts(project)

        return PProject("Kotlin", project.projectDir, modules, emptyList(), artifacts)
    }

    private fun parseArtifacts(rootProject: Project): Map<String, List<GradleProjectPath>> {
        val artifacts = HashMap<OutputDir, List<GradleProjectPath>>()
        val additionalOutputs = HashMap<OutputDir, List<OutputDir>>()

        for (project in rootProject.allprojects) {
            for (sourceSet in computeAllSourceSets(project)) {
                val path = makePath(project, sourceSet.name)

                if (sourceSet is SourceSetWrapper.JavaSourceSet) {
                    for (output in sourceSet.gradleSourceSet.output.toList()) {
                        artifacts[output.absolutePath] = listOf(path)
                    }
                }

                val jarTask = sourceSet.findJarTask(project)
                val embeddedTask = sourceSet.findEmbeddedTask(project)

                for (task in listOfNotNull(jarTask, embeddedTask)) {
                    val archiveFile = task.archiveFile.get().asFile
                    artifacts[archiveFile.absolutePath] = listOf(path)

                    val additionalOutputsForSourceSet = mutableListOf<File>()
                    fun process(spec: CopySpecInternal) {
                        spec.children.forEach { process(it) }
                        if (spec is SingleParentCopySpec) {
                            for (sourcePath in spec.sourcePaths) {
                                if (sourcePath is SourceSetOutput) {
                                    additionalOutputsForSourceSet += sourcePath.classesDirs
                                    sourcePath.resourcesDir?.let { additionalOutputsForSourceSet += it }
                                }
                            }
                        }
                    }
                    process(task.rootSpec)
                    additionalOutputs[archiveFile.absolutePath] = additionalOutputsForSourceSet.map { it.absolutePath }
                }
            }
        }

        for ((sourceSetOutputDir, additionalOutputsForSourceSet) in additionalOutputs) {
            val projectPaths = artifacts[sourceSetOutputDir] ?: error("Unknown artifact $sourceSetOutputDir")
            val newPaths = projectPaths + additionalOutputsForSourceSet.mapNotNull { artifacts[it] }.flatten()
            artifacts[sourceSetOutputDir] = newPaths.distinct()
        }

        return artifacts
    }

    private fun makePath(project: Project, sourceSetName: String): GradleProjectPath {
        return project.path + "/" + sourceSetName
    }

    private fun parseModules(project: Project, excludedProjects: List<Project>): List<PModule> {
        val modules = mutableListOf<PModule>()

        fun getModuleFile(name: String): File {
            val relativePath = File(project.projectDir, "$modulePrefix$name.iml").toRelativeString(project.rootProject.projectDir)
            return File(project.rootProject.projectDir, ".idea/modules/$relativePath")
        }

        val embeddedDependencies = project.configurations.findByName(EMBEDDED_CONFIGURATION_NAME)
            ?.let { parseDependencies(it) } ?: emptyList()

        val javaLanguageVersion = getJavaLanguageVersion(project)

        val sourceSets = parseSourceSets(project).sortedBy { it.forTests }
        for (sourceSet in sourceSets) {
            val sourceRoots = mutableListOf<PSourceRoot>()

            for (dir in sourceSet.sourceDirectories) {
                sourceRoots += PSourceRoot(dir, if (sourceSet.forTests) Kind.TEST else Kind.PRODUCTION)
            }

            for (dir in sourceSet.resourceDirectories) {
                sourceRoots += PSourceRoot(dir, if (sourceSet.forTests) Kind.TEST_RESOURCES else Kind.RESOURCES)
            }

            if (sourceRoots.isEmpty()) {
                continue
            }

            val productionModule = if (sourceSet.forTests) modules.firstOrNull { !it.forTests } else null

            val contentRoots = sourceRoots.map { PContentRoot(it.directory, listOf(it), emptyList()) }

            var orderRoots = parseDependencies(project, sourceSet)
            if (productionModule != null) {
                val productionModuleDependency = PDependency.Module(productionModule)
                orderRoots = listOf(POrderRoot(productionModuleDependency, Scope.COMPILE, true)) + orderRoots
            }

            val name = project.pillModuleName + "." + sourceSet.name

            modules += PModule(
                name = modulePrefix + name,
                path = makePath(project, sourceSet.name),
                forTests = sourceSet.forTests,
                rootDirectory = sourceRoots.first().directory,
                moduleFile = getModuleFile(name),
                contentRoots = contentRoots,
                orderRoots = orderRoots,
                javaLanguageVersion = javaLanguageVersion,
                kotlinOptions = sourceSet.kotlinOptions,
                moduleForProductionSources = productionModule,
                embeddedDependencies = embeddedDependencies
            )
        }

        val mainModuleFileRelativePath = when (project) {
            project.rootProject -> File(project.rootProject.projectDir, modulePrefix + project.rootProject.name + ".iml")
            else -> getModuleFile(project.pillModuleName)
        }

        modules += PModule(
            name = modulePrefix + project.pillModuleName,
            path = project.path,
            forTests = false,
            rootDirectory = project.projectDir,
            moduleFile = mainModuleFileRelativePath,
            contentRoots = listOf(PContentRoot(project.projectDir, listOf(), computeAllExcludedDirectories(project, excludedProjects))),
            orderRoots = emptyList(),
            javaLanguageVersion = null,
            kotlinOptions = null,
            moduleForProductionSources = null,
            embeddedDependencies = emptyList()
        )

        return modules
    }

    private fun getJavaLanguageVersion(project: Project): Int? {
        val javaPluginExtension = project.extensions.findByType<JavaPluginExtension>() ?: return null
        val javaToolchainService = project.extensions.findByType<JavaToolchainService>() ?: return null
        return javaToolchainService.launcherFor(javaPluginExtension.toolchain).orNull?.metadata?.languageVersion?.asInt()
    }

    private fun computeAllExcludedDirectories(project: Project, excludedProjects: List<Project>): List<File> {
        val javaExcludedDirectories = project.plugins.findPlugin(IdeaPlugin::class.java)
            ?.model?.module?.excludeDirs?.toList().orEmpty()

        val excludedProjectDirectories = if (project == project.rootProject) excludedProjects.map { it.buildDir } else emptyList()

        return globalExcludedDirectories + javaExcludedDirectories + project.buildDir + excludedProjectDirectories
    }

    private fun parseSourceSets(project: Project): List<PSourceSet> {
        if (!project.plugins.hasPlugin(JavaPlugin::class.java)) {
            return emptyList()
        }

        val kotlinTasksBySourceSet = project.tasks.names
            .filter { it.startsWith("compile") && it.endsWith("Kotlin") }
            .map { project.tasks.getByName(it) }
            .associateBy { (it.invokeInternal("getSourceSetName") as Property<*>).get() as String }

        val gradleSourceSets = project.sourceSets?.toList() ?: emptyList()
        val sourceSets = mutableListOf<PSourceSet>()

        for (sourceSet in gradleSourceSets) {
            val kotlinCompileTask = kotlinTasksBySourceSet[sourceSet.name]
            val kotlinSourceDirectories = (sourceSet.extensions.findByName("kotlin") as? SourceDirectorySet)?.srcDirs ?: emptySet()

            val sourceDirectories = (sourceSet.java.sourceDirectories.files + kotlinSourceDirectories).toList()

            val resourceDirectoriesFromSourceSet = sourceSet.resources.sourceDirectories.files
            val resourceDirectoriesFromTask = parseResourceRootsProcessedByProcessResourcesTask(project, sourceSet)

            val resourceDirectories = (resourceDirectoriesFromSourceSet + resourceDirectoriesFromTask)
                .distinct().filter { it !in sourceDirectories }

            sourceSets += PSourceSet(
                name = sourceSet.name,
                forTests = SourceSetWrapper.JavaSourceSet(sourceSet).isTest,
                sourceDirectories = sourceDirectories,
                resourceDirectories = resourceDirectories,
                kotlinOptions = kotlinCompileTask?.let { getKotlinOptions(it, project) },
                compileClasspathConfigurationName = sourceSet.compileClasspathConfigurationName,
                runtimeClasspathConfigurationName = sourceSet.runtimeClasspathConfigurationName
            )
        }

        return sourceSets
    }

    private fun parseResourceRootsProcessedByProcessResourcesTask(project: Project, sourceSet: SourceSet): List<File> {
        val isMainSourceSet = sourceSet.name == SourceSet.MAIN_SOURCE_SET_NAME

        val taskNameBase = "processResources"
        val taskName = if (isMainSourceSet) taskNameBase else sourceSet.name + taskNameBase.replaceFirstChar {
            if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
        }
        val task = project.tasks.findByName(taskName) as? ProcessResources ?: return emptyList()

        val roots = mutableListOf<File>()
        fun collectRoots(spec: CopySpecInternal) {
            if (spec is SingleParentCopySpec && spec.children.none()) {
                roots += spec.sourcePaths.map { File(project.projectDir, it.toString()) }.filter { it.exists() }
                return
            }

            spec.children.forEach(::collectRoots)
        }
        collectRoots(task.rootSpec)
        return roots
    }

    private fun getKotlinOptions(kotlinCompileTask: Any, project: Project): PSourceRootKotlinOptions {
        val compileArguments = run {
            val method = kotlinCompileTask::class.java.getMethod("getSerializedCompilerArguments")
            method.isAccessible = true
            @Suppress("UNCHECKED_CAST")
            method.invoke(kotlinCompileTask) as List<String>
        }

        fun parseBoolean(name: String) = compileArguments.contains("-$name")
        fun parseString(name: String) = compileArguments.dropWhile { it != "-$name" }.drop(1).firstOrNull()

        fun parseVersion(name: String, limit: KotlinLanguageVersion): String? {
            val rawVersion = parseString(name) ?: return null
            return if (KotlinLanguageVersion.parse(rawVersion) >= limit) rawVersion else null
        }

        val apiVersion = parseVersion("api-version", MIN_API_VERSION)
        val languageVersion = parseVersion("language-version", MIN_LANGUAGE_VERSION)
        val isVersionUnspecified = apiVersion == null || languageVersion == null

        val extraArguments = getExtraArguments(compileArguments) { arg ->
            // Skip 'warnings-as-errors' in cases when API/language versions are absent or too old to be compiled with JPS
            !isVersionUnspecified || arg != "-Werror"
        }

        val pluginClasspath = mutableListOf<String>()
        if (project.plugins.hasPlugin("org.jetbrains.kotlin.plugin.serialization")) {
            pluginClasspath += "\$KOTLIN_BUNDLED\$/lib/kotlinx-serialization-compiler-plugin.jar"
        }

        return PSourceRootKotlinOptions(
            parseBoolean("no-stdlib"),
            parseBoolean("no-reflect"),
            parseString("module-name"),
            apiVersion ?: MIN_API_VERSION.toString(),
            languageVersion ?: MIN_LANGUAGE_VERSION.toString(),
            parseString("jvm-target").takeUnless { it == "1.6" },
            extraArguments,
            pluginClasspath
        )
    }

    private fun parseDependencies(project: Project, sourceSet: PSourceSet): List<POrderRoot> {
        val roots = mutableListOf<POrderRoot>()

        fun process(name: String, scope: Scope) {
            val configuration = project.configurations.findByName(name) ?: return
            roots += parseDependencies(configuration).map { POrderRoot(it, scope) }
        }

        process(sourceSet.compileClasspathConfigurationName, Scope.PROVIDED)
        process(sourceSet.runtimeClasspathConfigurationName, Scope.RUNTIME)

        if (sourceSet.forTests) {
            process("jpsTest", Scope.TEST)
        }

        return roots
    }

    private fun parseDependencies(configuration: Configuration): List<PDependency> {
        return configuration.resolve().map { file ->
            val library = PLibrary(file.name, listOf(file))
            return@map PDependency.ModuleLibrary(library)
        }
    }

    private companion object {
        private val MIN_API_VERSION = KotlinLanguageVersion(1, 6)
        private val MIN_LANGUAGE_VERSION = KotlinLanguageVersion(1, 6)

        private val SKIPPED_STANDALONE_ARGUMENTS =
            listOf("-no-stdlib", "-no-reflect", "-Xfriend-paths=", "-Xbuild-file=", "-Xplugin=")

        private val SKIPPED_ARGUMENTS_WITH_VALUE =
            listOf("-classpath", "-d", "-module-name", "-api-version", "-language-version", "-jvm-target", "-P")

        private fun getExtraArguments(args: List<String>, filter: (String) -> Boolean): List<String> {
            val result = mutableListOf<String>()
            var index = 0
            while (index < args.size) {
                val arg = args[index]

                if (SKIPPED_STANDALONE_ARGUMENTS.any { arg.startsWith(it) }) {
                    index += 1
                    continue
                } else if (SKIPPED_ARGUMENTS_WITH_VALUE.any { arg.startsWith(it) }) {
                    index += 2 // Skip also the value
                    continue
                }

                if (filter(arg)) {
                    result += arg
                }

                index += 1
            }
            return result
        }
    }
}

/**
 * Represents a Kotlin language or API version.
 */
private data class KotlinLanguageVersion(val major: Int, val minor: Int) : Comparable<KotlinLanguageVersion> {
    companion object {
        fun parse(rawVersion: String): KotlinLanguageVersion {
            val chunks = rawVersion.split('.')
            require(chunks.size == 2)
            return KotlinLanguageVersion(chunks[0].toInt(), chunks[1].toInt())
        }
    }

    override fun compareTo(other: KotlinLanguageVersion): Int {
        return compareValuesBy(this, other, { it.major }, { it.minor })
    }

    override fun toString(): String = "$major.$minor"
}

/**
 * Returns both Java and Kotlin source sets, including multiplatform ones.
 */
internal fun computeAllSourceSets(project: Project): List<SourceSetWrapper> {
    return buildList {
        val existingNames = HashSet<String>()

        for (javaSourceSet in project.sourceSets.orEmpty()) {
            if (existingNames.add(javaSourceSet.name)) {
                add(SourceSetWrapper.JavaSourceSet(javaSourceSet))
            }
        }

        val kotlinExtension = project.extensions.findByName("kotlin")
        if (kotlinExtension != null) {
            // Calls 'KotlinSourceSetContainer.getSourceSets()'
            val kotlinSourceSets = kotlinExtension.javaClass.getMethod("getSourceSets").invoke(kotlinExtension)
            if (kotlinSourceSets is NamedDomainObjectContainer<*>) {
                for (kotlinSourceSet in kotlinSourceSets) {
                    val name = (kotlinSourceSet as Named).name
                    if (existingNames.add(name)) {
                        add(SourceSetWrapper.KotlinSourceSet(kotlinSourceSet))
                    }
                }
            }
        }
    }
}

sealed class SourceSetWrapper(val name: String) {
    class KotlinSourceSet(private val sourceSet: Named) : SourceSetWrapper(sourceSet.name) {
        override val jarTaskCandidates: List<String>
            get() {
                // Production source sets (such as 'jvmMain') have a task without the 'Main' suffix, e.g. 'jvmJar'
                return listOf("${baseName}Jar")
            }

        override fun findEmbeddedTask(project: Project): Jar? {
            return null
        }
    }

    class JavaSourceSet(val gradleSourceSet: SourceSet) : SourceSetWrapper(gradleSourceSet.name) {
        override val jarTaskCandidates: List<String>
            get() = buildList {
                add(gradleSourceSet.jarTaskName)
                when (gradleSourceSet.name) {
                    SourceSet.MAIN_SOURCE_SET_NAME -> add("jvmJar")
                    SourceSet.TEST_SOURCE_SET_NAME -> add("jvmJarForTests")
                }
            }

        override val isTest: Boolean
            get() {
                if (gradleSourceSet.extra.has("jpsKind") && gradleSourceSet.extra.get("jpsKind") == SourceSet.TEST_SOURCE_SET_NAME) {
                    return true
                }

                return super.isTest
            }

        override fun findEmbeddedTask(project: Project): Jar? {
            val jarName = gradleSourceSet.jarTaskName
            val embeddable = "embeddable"
            val embeddedName = if (jarName == "jar") embeddable else jarName.dropLast("jar".length) +
                    embeddable.replaceFirstChar { it.uppercase() }
            return project.tasks.findByName(embeddedName) as? Jar
        }
    }

    val baseName: String
        get() = name.removeSuffix("Main")

    open val isTest: Boolean
        get() {
            return name == SourceSet.TEST_SOURCE_SET_NAME
                    || name == "testFixtures"
                    || name.endsWith("Test")
                    || name.endsWith("Tests")
        }

    protected abstract val jarTaskCandidates: List<String>

    fun findJarTask(project: Project): Jar? {
        return jarTaskCandidates.firstNotNullOfOrNull { project.tasks.findByName(it) } as? Jar
    }

    abstract fun findEmbeddedTask(project: Project): Jar?

    override fun toString(): String {
        return name
    }
}

private fun Any.invokeInternal(name: String, instance: Any = this): Any? {
    val method = javaClass.methods.single { it.name.startsWith(name) && it.parameterTypes.isEmpty() }
    method.isAccessible = true
    return method.invoke(instance)
}

val Project.pillModuleName: String
    get() = path.removePrefix(":").replace(':', '.')

private val Project.sourceSets: SourceSetContainer?
    get() {
        val javaExtension = project.extensions.findByType<JavaPluginExtension>() ?: return null
        return javaExtension.sourceSets
    }
