/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */


import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import org.apache.lucene.gradle.plugins.java.ModularPathsExtensionApi

// Configures miscellaneous aspects required to support the java module system layer.

allprojects {
  plugins.withType(JavaPlugin).configureEach {
    // We won't be using gradle's built-in automatic module finder.
    java {
      modularity.inferModulePath.set(false)
    }

    //
    // Configure modular extensions for each source set.
    //
    project.sourceSets.configureEach { SourceSet sourceSet ->
      // Create and register a source set extension for manipulating classpath/ module-path
      ModularPathsExtension modularPaths = new ModularPathsExtension(project, sourceSet)
      sourceSet.extensions.add("modularPaths", modularPaths)

      // LUCENE-10344: We have to provide a special-case extension for ECJ because it does not
      // support all of the module-specific javac options.
      ModularPathsExtension modularPathsForEcj = modularPaths
      if (sourceSet.name == SourceSet.TEST_SOURCE_SET_NAME && project.path in [
            ":lucene:spatial-extras",
            ":lucene:spatial3d",
          ]) {
        modularPathsForEcj = modularPaths.cloneWithMode(ModularPathsExtension.Mode.CLASSPATH_ONLY)
      }
      sourceSet.extensions.add("modularPathsForEcj", modularPathsForEcj)

      // TODO: the tests of these projects currently don't compile or work in
      // module-path mode. Make the modular paths extension use class path only.
      if (sourceSet.name == SourceSet.TEST_SOURCE_SET_NAME && project.path in [
            // Circular dependency between artifacts or source set outputs,
            // causing package split issues at runtime.
            ":lucene:core",
            ":lucene:codecs",
            ":lucene:test-framework",
          ]) {
        modularPaths.mode = ModularPathsExtension.Mode.CLASSPATH_ONLY
      }

      // Configure the JavaCompile task associated with this source set.
      tasks.named(sourceSet.getCompileJavaTaskName()).configure({ JavaCompile task ->
        task.dependsOn modularPaths.compileModulePathConfiguration

        // GH-12742: add the modular path as inputs so that if anything changes, the task
        // is not up to date and is re-run. I [dw] believe this should be a @Classpath parameter
        // on the task itself... but I don't know how to implement this on an existing class.
        // this is a workaround but should work just fine though.
        task.inputs.files(modularPaths.compileModulePathConfiguration)

        // LUCENE-10327: don't allow gradle to emit an empty sourcepath as it would break
        // compilation of modules.
        task.options.setSourcepath(sourceSet.java.sourceDirectories)

        // Add modular dependencies and their transitive dependencies to module path.
        task.options.compilerArgumentProviders.add(modularPaths.compilationArguments)

        // LUCENE-10304: if we modify the classpath here, IntelliJ no longer sees the dependencies as compile-time
        // dependencies, don't know why.
        if (!buildGlobals.intellijIdea.get().isIdeaSync) {
          task.classpath = modularPaths.compilationClasspath
        }

        doFirst {
          modularPaths.logCompilationPaths(logger)
        }
      })

      // For source sets that contain a module descriptor, configure a jar task that combines
      // classes and resources into a single module.
      if (sourceSet.name != SourceSet.MAIN_SOURCE_SET_NAME) {
        tasks.maybeCreate(sourceSet.getJarTaskName(), org.gradle.jvm.tasks.Jar).configure({
          archiveClassifier = sourceSet.name
          from(sourceSet.output)
        })
      }
    }

    // Connect modular configurations between their "test" and "main" source sets, this reflects
    // the conventions set by the Java plugin.
    project.configurations {
      moduleTestApi.extendsFrom moduleApi
      moduleTestImplementation.extendsFrom moduleImplementation
      moduleTestRuntimeOnly.extendsFrom moduleRuntimeOnly
      moduleTestCompileOnly.extendsFrom moduleCompileOnly
    }

    // Gradle's java plugin sets the compile and runtime classpath to be a combination
    // of configuration dependencies and source set's outputs. For source sets with modules,
    // this leads to split class and resource folders.
    //
    // We tweak the default source set path configurations here by assembling jar task outputs
    // of the respective source set, instead of their source set output folders. We also attach
    // the main source set's jar to the modular test implementation configuration.
    SourceSet mainSourceSet = project.sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME)
    boolean mainIsModular = mainSourceSet.modularPaths.hasModuleDescriptor()
    boolean mainIsEmpty = mainSourceSet.allJava.isEmpty()
    SourceSet testSourceSet = project.sourceSets.getByName(SourceSet.TEST_SOURCE_SET_NAME)
    boolean testIsModular = testSourceSet.modularPaths.hasModuleDescriptor()

    // LUCENE-10304: if we modify the classpath here, IntelliJ no longer sees the dependencies as compile-time
    // dependencies, don't know why.
    if (!buildGlobals.intellijIdea.get().isIdeaSync) {
      def jarTask = project.tasks.getByName(mainSourceSet.getJarTaskName())
      def testJarTask = project.tasks.getByName(testSourceSet.getJarTaskName())

      // Consider various combinations of module/classpath configuration between the main and test source set.
      if (testIsModular) {
        if (mainIsModular || mainIsEmpty) {
          // If the main source set is empty, skip the jar task.
          def jarTaskOutputs = mainIsEmpty ? [] :  jarTask.outputs

          // Fully modular tests - must have no split packages, proper access, etc.
          // Work around the split classes/resources problem by adjusting classpaths to
          // rely on JARs rather than source set output folders.
          testSourceSet.compileClasspath = project.objects.fileCollection().from(
              jarTaskOutputs,
              project.configurations.getByName(testSourceSet.getCompileClasspathConfigurationName()),
              )
          testSourceSet.runtimeClasspath = project.objects.fileCollection().from(
              jarTaskOutputs,
              testJarTask.outputs,
              project.configurations.getByName(testSourceSet.getRuntimeClasspathConfigurationName()),
              )

          project.dependencies {
            moduleTestImplementation files(jarTaskOutputs)
            moduleTestRuntimeOnly files(testJarTask.outputs)
          }
        } else {
          // This combination simply does not make any sense (in my opinion).
          throw new GradleException("Test source set is modular and main source set is class-based, this makes no sense: " + project.path)
        }
      } else {
        if (mainIsModular) {
          // This combination is a potential candidate for patching the main sourceset's module with test classes. I could
          // not resolve all the difficulties that arise when you try to do it though:
          // - either a separate module descriptor is needed that opens test packages, adds dependencies via requires clauses
          // or a series of jvm arguments (--add-reads, --add-opens, etc.) has to be generated and maintained. This is
          // very low-level (ECJ doesn't support a full set of these instructions, for example).
          //
          // Fall back to classpath mode.
        } else {
          // This is the 'plain old classpath' mode: neither the main source set nor the test set are modular.
        }
      }
    }

    //
    // Configures a Test task associated with the provided source set to use module paths.
    //
    // There is no explicit connection between source sets and test tasks so there is no way (?)
    // to do this automatically, convention-style.
    //
    // This closure can be used to configure a different task, with a different source set, should we
    // have the need for it.
    Closure<Void> configureTestTaskForSourceSet = { Test task, SourceSet sourceSet ->
      task.configure {
        def forkProperties = file("${task.temporaryDir}/jvm-forking.properties")

        ModularPathsExtension modularPaths = sourceSet.modularPaths

        dependsOn modularPaths

        // Add modular dependencies and their transitive dependencies to module path.
        jvmArgumentProviders.add(modularPaths.runtimeArguments)

        // Modify the default classpath.
        classpath = modularPaths.testRuntimeClasspath

        doFirst {
          modularPaths.logRuntimePaths(logger)
        }

        // Pass all the required properties for tests which fork the JVM. We don't use
        // regular system properties here because this could affect task up-to-date checks.
        jvmArgumentProviders.add(new CommandLineArgumentProvider() {
              @Override
              Iterable<String> asArguments() {
                return [
                  "-Dtests.jvmForkArgsFile=" + forkProperties.absolutePath
                ]
              }
            })
        doFirst {
          List<String> args = new ArrayList<>(modularPaths.runtimeArguments.asArguments().collect())
          def cp = modularPaths.runtimeClasspath.asPath
          if (!cp.isBlank()) {
            args.addAll(["-cp", cp])
          }

          // Sanity check.
          args.forEach({s ->
            if (s.contains("\n")) {
              throw new RuntimeException("LF in forked jvm property?: " + s)
            }
          })

          Files.createDirectories(forkProperties.toPath().getParent())
          Files.writeString(forkProperties.toPath(), String.join("\n", args), StandardCharsets.UTF_8)
        }
      }
    }

    // Configure (tasks.test, sourceSets.test)
    tasks.matching { it.name ==~ /test(_[0-9]+)?/ }.configureEach { Test task ->
      configureTestTaskForSourceSet(task, task.project.sourceSets.test)
    }

    // Configure module versions.
    tasks.withType(JavaCompile).configureEach { task ->
      // TODO: LUCENE-10267: workaround for gradle bug. Remove when the corresponding issue is fixed.
      String projectVersion = project.version.toString()
      task.options.compilerArgumentProviders.add((CommandLineArgumentProvider) {
        ->
        if (task.getClasspath().isEmpty()) {
          return [
            "--module-version",
            projectVersion
          ]
        } else {
          return []
        }
      })

      task.options.javaModuleVersion.set(projectVersion)
    }
  }
}


//
// For a source set, create explicit configurations for declaring modular dependencies.
//
// These "modular" configurations correspond 1:1 to Gradle's conventions but have a 'module' prefix
// and a capitalized remaining part of the conventional name. For example, an 'api' configuration in
// the main source set would have a corresponding 'moduleApi' configuration for declaring modular
// dependencies.
//
// Gradle's java plugin "convention" configurations extend from their modular counterparts
// so all dependencies end up on classpath by default for backward compatibility with other
// tasks and gradle infrastructure.
//
// At the same time, we also know which dependencies (and their transitive graph of dependencies!)
// should be placed on module-path only.
//
// Note that an explicit configuration of modular dependencies also opens up the possibility of automatically
// validating whether the dependency configuration for a gradle project is consistent with the information in
// the module-info descriptor because there is a (nearly?) direct correspondence between the two:
//
// moduleApi            - 'requires transitive'
// moduleImplementation - 'requires'
// moduleCompileOnly    - 'requires static'
//
class ModularPathsExtension implements Cloneable, Iterable<Object>, ModularPathsExtensionApi {
  /**
   * Determines how paths are split between module path and classpath.
   */
  enum Mode {
    /**
     * Dependencies and source set outputs are placed on classpath, even if declared on modular
     * configurations. This would be the 'default' backward-compatible mode.
     */
    CLASSPATH_ONLY,

    /**
     * Dependencies from modular configurations are placed on module path. Source set outputs
     * are placed on classpath.
     */
    DEPENDENCIES_ON_MODULE_PATH
  }

  Project project
  SourceSet sourceSet
  Configuration compileModulePathConfiguration
  Configuration runtimeModulePathConfiguration
  Configuration modulePatchOnlyConfiguration

  // The mode of splitting paths for this source set.
  Mode mode = Mode.DEPENDENCIES_ON_MODULE_PATH

  // More verbose debugging for paths.
  private boolean debugPaths

  /**
   * A list of module name - path provider entries that will be converted
   * into {@code --patch-module} options.
   */
  private List<Map.Entry<String, Provider<Path>>> modulePatches = new ArrayList<>()

  ModularPathsExtension(Project project, SourceSet sourceSet) {
    this.project = project
    this.sourceSet = sourceSet

    // enable to debug paths.
    debugPaths = false

    ConfigurationContainer configurations = project.configurations

    // Create modular configurations for gradle's java plugin convention configurations.
    Configuration moduleApi = createModuleConfigurationForConvention(sourceSet.apiConfigurationName)
    Configuration moduleImplementation = createModuleConfigurationForConvention(sourceSet.implementationConfigurationName)
    Configuration moduleRuntimeOnly = createModuleConfigurationForConvention(sourceSet.runtimeOnlyConfigurationName)
    Configuration moduleCompileOnly = createModuleConfigurationForConvention(sourceSet.compileOnlyConfigurationName)


    // Apply hierarchy relationships to modular configurations.
    moduleImplementation.extendsFrom(moduleApi)

    // Patched modules have to end up in the implementation configuration for IDEs, which
    // otherwise get terribly confused.
    Configuration modulePatchOnly = createModuleConfigurationForConvention(
        SourceSet.isMain(sourceSet) ? "patchOnly" : sourceSet.name + "PatchOnly")
    modulePatchOnly.canBeResolved = (true)
    moduleImplementation.extendsFrom(modulePatchOnly)
    this.modulePatchOnlyConfiguration = modulePatchOnly

    // This part of convention configurations seems like a very esoteric use case, leave out for now.
    // sourceSet.compileOnlyApiConfigurationName

    // We have to ensure configurations are using assembled resources and classes (jar variant) as a single
    // module can't be expanded into multiple folders.
    Closure<Void> ensureJarVariant = { Configuration c ->
      c.attributes.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, project.objects.named(LibraryElements, LibraryElements.JAR))
    }

    // Set up compilation module path configuration combining corresponding convention configurations.
    Closure<Configuration> createResolvableModuleConfiguration = { String configurationName ->
      Configuration conventionConfiguration = configurations.maybeCreate(configurationName)
      Configuration moduleConfiguration = configurations.maybeCreate(moduleConfigurationNameFor(conventionConfiguration.name))
      moduleConfiguration.canBeConsumed = false
      moduleConfiguration.canBeResolved = true
      ensureJarVariant(moduleConfiguration)

      project.logger.info("Created resolvable module configuration for '${conventionConfiguration.name}': ${moduleConfiguration.name}")
      return moduleConfiguration
    }

    ensureJarVariant(configurations.maybeCreate(sourceSet.compileClasspathConfigurationName))
    ensureJarVariant(configurations.maybeCreate(sourceSet.runtimeClasspathConfigurationName))

    this.compileModulePathConfiguration = createResolvableModuleConfiguration(sourceSet.compileClasspathConfigurationName)
    compileModulePathConfiguration.extendsFrom(moduleCompileOnly, moduleImplementation)

    this.runtimeModulePathConfiguration = createResolvableModuleConfiguration(sourceSet.runtimeClasspathConfigurationName)
    runtimeModulePathConfiguration.extendsFrom(moduleRuntimeOnly, moduleImplementation)
  }

  /**
   * Adds {@code --patch-module} option for the provided module name and the provider of a
   * folder or JAR file.
   *
   * @param moduleName
   * @param pathProvider
   */
  void patchModule(String moduleName, Provider<Path> pathProvider) {
    modulePatches.add(Map.entry(moduleName, pathProvider));
  }

  private FileCollection getCompilationModulePath() {
    if (mode == Mode.CLASSPATH_ONLY) {
      return project.files()
    }
    return compileModulePathConfiguration - modulePatchOnlyConfiguration
  }

  private FileCollection getRuntimeModulePath() {
    if (mode == Mode.CLASSPATH_ONLY) {
      if (hasModuleDescriptor()) {
        // The source set is itself a module.
        throw new GradleException("Source set contains a module but classpath-only" +
        " dependencies requested: ${project.path}, source set '${sourceSet.name}'")
      }

      return project.files()
    }

    return runtimeModulePathConfiguration - modulePatchOnlyConfiguration
  }

  FileCollection removeNonExisting(FileCollection fc) {
    return fc.filter { f -> f.exists() }
  }

  FileCollection getCompilationClasspath() {
    if (mode == Mode.CLASSPATH_ONLY) {
      return removeNonExisting(sourceSet.compileClasspath)
    }

    // Modify the default classpath by removing anything already placed on module path.
    // Use a lazy provider to delay computation.
    return removeNonExisting(project.files({
      ->
      return sourceSet.compileClasspath - compileModulePathConfiguration - modulePatchOnlyConfiguration
    }))
  }

  CommandLineArgumentProvider getCompilationArguments() {
    return new CommandLineArgumentProvider() {
          @Override
          Iterable<String> asArguments() {
            FileCollection modulePath = ModularPathsExtension.this.compilationModulePath

            if (modulePath.isEmpty()) {
              return []
            }

            ArrayList<String> extraArgs = []
            extraArgs += [
              "--module-path",
              modulePath.join(File.pathSeparator)
            ]

            if (!hasModuleDescriptor()) {
              // We're compiling what appears to be a non-module source set so we'll
              // bring everything on module path in the resolution graph,
              // otherwise modular dependencies wouldn't be part of the resolved module graph and this
              // would result in class-not-found compilation problems.
              extraArgs += [
                "--add-modules",
                "ALL-MODULE-PATH"
              ]
            }

            // Add module-patching.
            extraArgs += getPatchModuleArguments(modulePatches)

            return extraArgs
          }
        }
  }

  FileCollection getRuntimeClasspath() {
    if (mode == Mode.CLASSPATH_ONLY) {
      return sourceSet.runtimeClasspath
    }

    // Modify the default classpath by removing anything already placed on module path.
    // Use a lazy provider to delay computation.
    project.files({
      ->
      return sourceSet.runtimeClasspath - runtimeModulePath - modulePatchOnlyConfiguration
    })
  }

  /**
   * Returns the runtime classpath for test tasks.
   *
   * The plain runtimeClassspath would also filter out junit dependencies, which must
   * be on the Test.classpath as the gradle test worker depends on it.
   * Not having junit explicitly on the classpath would result in a deprecation warning
   * about junit not being on the classpath and indirectly added by gradle internal logic.
   */
  FileCollection getTestRuntimeClasspath() {
    if (mode == Mode.CLASSPATH_ONLY) {
      return sourceSet.runtimeClasspath
    }

    // Modify the default classpath by removing anything already placed on module path.
    // Use a lazy provider to delay computation.
    project.files({
      ->
      return sourceSet.runtimeClasspath - runtimeModulePath - modulePatchOnlyConfiguration +
          sourceSet.runtimeClasspath.filter {file -> file.name.contains("junit")}
    })
  }

  CommandLineArgumentProvider getRuntimeArguments() {
    return new CommandLineArgumentProvider() {
          @Override
          Iterable<String> asArguments() {
            FileCollection modulePath = ModularPathsExtension.this.runtimeModulePath

            if (modulePath.isEmpty()) {
              return []
            }

            def extraArgs = []

            // Add source set outputs to module path.
            extraArgs += [
              "--module-path",
              modulePath.files.join(File.pathSeparator)
            ]

            // Ideally, we should only add the sourceset's module here, everything else would be resolved via the
            // module descriptor. But this would require parsing the module descriptor and may cause JVM version conflicts
            // so keeping it simple.
            extraArgs += [
              "--add-modules",
              "ALL-MODULE-PATH"
            ]

            // Add module-patching.
            extraArgs += getPatchModuleArguments(modulePatches)

            return extraArgs
          }
        }
  }

  boolean hasModuleDescriptor() {
    return sourceSet.allJava.srcDirs.stream()
        .map {dir -> new File(dir, "module-info.java") }
        .anyMatch {file -> file.exists() }
  }

  private List<String> getPatchModuleArguments(List<Map.Entry<String, Provider<Path>>> patches) {
    def args = []
    patches.each {
      args.add("--patch-module");
      args.add(it.key + "=" + it.value.get())
    }
    return args
  }

  private static String toList(FileCollection files) {
    return files.isEmpty() ? " [empty]" : ("\n    " + files.sort().join("\n    "))
  }

  private static String toList(List<Map.Entry<String, Provider<Path>>> patches) {
    return patches.isEmpty() ? " [empty]" : ("\n    " + patches.collect {"${it.key}=${it.value.get()}"}.join("\n    "))
  }

  public void logCompilationPaths(Logger logger) {
    def value = "Modular extension, compilation paths, source set=${sourceSet.name}${hasModuleDescriptor() ? " (module)" : ""}, mode=${mode}:\n" +
        "  Module path:${toList(compilationModulePath)}\n" +
        "  Class path: ${toList(compilationClasspath)}\n" +
        "  Patches:    ${toList(modulePatches)}"

    if (debugPaths) {
      logger.lifecycle(value)
    } else {
      logger.info(value)
    }
  }

  public void logRuntimePaths(Logger logger) {
    def value = "Modular extension, runtime paths, source set=${sourceSet.name}${hasModuleDescriptor() ? " (module)" : ""}, mode=${mode}:\n" +
        "  Module path:${toList(runtimeModulePath)}\n" +
        "  Class path: ${toList(runtimeClasspath)}\n" +
        "  Patches   : ${toList(modulePatches)}"

    if (debugPaths) {
      logger.lifecycle(value)
    } else {
      logger.info(value)
    }
  }

  public ModularPathsExtension clone() {
    return (ModularPathsExtension) super.clone()
  }

  ModularPathsExtension cloneWithMode(Mode newMode) {
    def cloned = this.clone()
    cloned.mode = newMode
    return cloned
  }

  // Map convention configuration names to "modular" corresponding configurations.
  static String moduleConfigurationNameFor(String configurationName) {
    return "module" + configurationName.capitalize().replace("Classpath", "Path")
  }

  // Create module configuration for the corresponding convention configuration.
  private Configuration createModuleConfigurationForConvention(String configurationName) {
    ConfigurationContainer configurations = project.configurations
    Configuration conventionConfiguration = configurations.maybeCreate(configurationName)
    Configuration moduleConfiguration = configurations.maybeCreate(moduleConfigurationNameFor(configurationName))
    moduleConfiguration.canBeConsumed = false
    moduleConfiguration.canBeResolved = false
    conventionConfiguration.extendsFrom(moduleConfiguration)

    project.logger.info("Created module configuration for '${conventionConfiguration.name}': ${moduleConfiguration.name}")
    return moduleConfiguration
  }

  /**
   * Provide internal dependencies for tasks willing to depend on this modular paths object.
   */
  @Override
  Iterator<Object> iterator() {
    return [
      compileModulePathConfiguration,
      runtimeModulePathConfiguration
    ].iterator()
  }
}
