/*
 * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
 *
 * Licensed 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
 *
 *     https://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.
 */
@file:Suppress("MemberVisibilityCanBePrivate")

import Target.Arch
import Target.OS
import java.io.File
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.attributes.Category
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.TaskProvider
import org.gradle.api.tasks.testing.Test
import org.gradle.internal.extensions.stdlib.capitalized
import org.gradle.internal.os.OperatingSystem
import org.gradle.jvm.toolchain.*
import org.gradle.kotlin.dsl.*
import org.gradle.kotlin.dsl.support.serviceOf
import org.gradle.process.CommandLineArgumentProvider

/**
 * JVM bytecode target; this is pinned at a reasonable version, because downstream JVM projects
 * which consume Pkl will need a minimum bytecode level at or above this one.
 *
 * Kotlin and Java need matching bytecode targets, so this is expressed as a build setting and
 * constant default. To override, pass `-DpklJdkToolchain=X` to the Gradle command line, where X is
 * a major Java version.
 */
const val PKL_JVM_TARGET_DEFAULT_MAXIMUM = 17

/**
 * The Pkl build requires JDK 25+; otherwise, NullAway will not work correctly.
 *
 * This is a build-time requirement, not a runtime requirement. To avoid the provisioning of
 * multiple JDKs and other build issues, keep this value in sync with the JVM toolchain versions in
 * `build-logic/build.gradle.kts` and `gradle-daemon-jvm.properties`.
 */
const val PKL_JDK_VERSION_MIN = 25

/**
 * The JDK minimum is set to match the bytecode minimum, to guarantee that fat JARs work against the
 * earliest supported bytecode target.
 */
const val PKL_TEST_JDK_MINIMUM = PKL_JVM_TARGET_DEFAULT_MAXIMUM

/**
 * Maximum JDK version which Pkl is tested with; this should be bumped when new JDK releases are
 * issued.
 */
const val PKL_TEST_JDK_MAXIMUM = 26

/**
 * If `true`, all JDK releases between [PKL_TEST_JDK_MINIMUM] and [PKL_TEST_JDK_MAXIMUM] are tested.
 * If `false`, only LTS releases within that range are tested. To override, pass
 * `-DpklTestAllJdks=true` on the Gradle command line.
 */
const val PKL_TEST_ALL_JDKS = false

// `buildInfo` in main build scripts
// `project.extensions.getByType<BuildInfo>()` in precompiled script plugins
open class BuildInfo(private val project: Project) {
  inner class GraalVm(
    val osName: String,
    val arch: String,
    val version: String,
    val graalJdkVersion: String,
  ) {
    val homeDir: String by lazy {
      System.getenv("GRAALVM_HOME") ?: "${System.getProperty("user.home")}/.graalvm"
    }
    val graalVmJdkVersion: String by lazy { libs.findVersion("graalVmJdkVersion").get().toString() }
    val graalVmInnovation: String by lazy { libs.findVersion("graalVmInnovation").get().toString() }

    val baseName: String by lazy {
      "graalvm-community-jdk-${graalVmJdkVersion}_${osName}-${arch}_bin"
    }

    val graalMajorVersion: String = graalJdkVersion.split(".").first()

    val downloadUrl: String by lazy {
      val ext = if (os.isWindows) "zip" else "tar.gz"
      val platformArch = if (arch == "amd64") "x64" else arch
      "https://github.com/graalvm/graalvm-ce-builds/releases/download/graal-$version/graalvm-community-jdk-${graalMajorVersion}i${graalVmInnovation}-${graalJdkVersion}_${osName}-${platformArch}_bin.$ext"
    }

    val downloadFile: File by lazy {
      val extension = if (os.isWindows) "zip" else "tar.gz"
      File(homeDir, "${baseName}.$extension")
    }

    val installDir: File by lazy { File(homeDir, baseName) }

    val baseDir: String by lazy {
      if (os.isMacOS) "$installDir/Contents/Home" else installDir.toString()
    }
  }

  /** The target machine to build, defaulting to the host system machine. */
  val targetMachine: Target by lazy { Target.from(os = os, arch = targetArch, musl = musl) }

  /** The target architecture to build, defaulting to the system architecture. */
  val targetArch: Arch by lazy { System.getProperty("pkl.targetArch")?.let(Arch::fromName) ?: arch }

  /** Tells if this is a cross-arch build (e.g. targeting amd64 when on an aarch64 machine). */
  val isCrossArch by lazy { arch != targetArch }

  /** Tells if cross-arch builds are supported on this machine. */
  val isCrossArchSupported by lazy { os.isMacOS }

  /** Whether to build native executables using the musl toolchain or not. */
  val musl: Boolean by lazy { java.lang.Boolean.getBoolean("pkl.musl") }

  val arch: Arch by lazy {
    when (val arch = System.getProperty("os.arch")) {
      "x86_64",
      "amd64" -> Arch.AMD64
      "aarch64" -> Arch.AARCH64
      else -> throw GradleException("Cannot build Pkl on arch: $arch")
    }
  }

  val graalVmAarch64: GraalVm by lazy { createGraalVm("aarch64") }

  val graalVmAmd64: GraalVm by lazy { createGraalVm("x64") }

  private fun createGraalVm(arch: String): GraalVm {
    val osName =
      when {
        os.isMacOS -> "macos"
        os.isLinux -> "linux"
        os.isWindows -> "windows"
        else -> throw RuntimeException("Unsupported OS for GraalVM: ${os.name}")
      }
    val version = libs.findVersion("graalVm").get().toString()
    val graalJdkVersion = libs.findVersion("graalVmJdkVersion").get().toString()
    return GraalVm(osName, arch, version, graalJdkVersion)
  }

  val isCiBuild: Boolean by lazy { System.getenv("CI") != null }

  val isReleaseBuild: Boolean by lazy { java.lang.Boolean.getBoolean("releaseBuild") }

  val isNativeArch: Boolean by lazy { java.lang.Boolean.getBoolean("nativeArch") }

  val jvmTarget: Int by lazy {
    System.getProperty("pklJvmTarget")?.toInt() ?: PKL_JVM_TARGET_DEFAULT_MAXIMUM
  }

  // JPMS exports for Truffle; needed on some versions of Java, and transitively within some JARs.
  private val jpmsExports =
    arrayOf(
      "org.graalvm.truffle/com.oracle.truffle.api.exception=ALL-UNNAMED",
      "org.graalvm.truffle/com.oracle.truffle.api=ALL-UNNAMED",
      "org.graalvm.truffle/com.oracle.truffle.api.nodes=ALL-UNNAMED",
      "org.graalvm.truffle/com.oracle.truffle.api.source=ALL-UNNAMED",
    )

  // Extra JPMS modules forced onto the module path via `--add-modules` in some cases.
  private val jpmsAddModules = arrayOf("jdk.unsupported")

  // Formats `jpmsExports` for use in JAR manifest attributes.
  val jpmsExportsForJarManifest: String by lazy {
    jpmsExports.joinToString(" ") { it.substringBefore("=") }
  }

  // Formats `jpmsExports` for use on the command line with `--add-exports`.
  val jpmsExportsForAddExportsFlags: Collection<String> by lazy {
    jpmsExports.map { "--add-exports=$it" }
  }

  // Formats `jpmsAddModules` for use on the command line with `--add-modules`.
  val jpmsAddModulesFlags: Collection<String> by lazy { jpmsAddModules.map { "--add-modules=$it" } }

  // JVM properties to set during testing.
  val testProperties =
    mapOf<String, Any>(
      // @TODO: this should be removed once pkl supports JPMS as a true Java Module.
      "polyglotimpl.DisableClassPathIsolation" to true
    )

  val jdkVendor: JvmVendorSpec = JvmVendorSpec.ADOPTIUM

  val jdkToolchainVersion: JavaLanguageVersion by lazy {
    JavaLanguageVersion.of(System.getProperty("pklJdkToolchain")?.toInt() ?: PKL_JDK_VERSION_MIN)
  }

  val jdkTestFloor: JavaLanguageVersion by lazy { JavaLanguageVersion.of(PKL_TEST_JDK_MINIMUM) }

  val jdkTestCeiling: JavaLanguageVersion by lazy { JavaLanguageVersion.of(PKL_TEST_JDK_MAXIMUM) }

  val testAllJdks: Boolean by lazy {
    // By default, Pkl is tested against LTS JDK releases within the bounds of `PKL_TEST_JDK_TARGET`
    // and `PKL_TEST_JDK_MAXIMUM`. To test against the full suite of JDK versions, past and present,
    // set `-DpklTestAllJdks=true` on the Gradle command line. This results in non-LTS releases, old
    // releases, and "experimental releases" (newer than the toolchain version) being included in
    // the default `check` suite.
    System.getProperty("pklTestAllJdks")?.toBoolean() ?: PKL_TEST_ALL_JDKS
  }

  val testExperimentalJdks: Boolean by lazy {
    System.getProperty("pklTestFutureJdks")?.toBoolean() ?: false
  }

  val testJdkVendors: Sequence<JvmVendorSpec> by lazy {
    // By default, only Adoptium is tested during multi-JDK testing. Flip `-DpklTestAllVendors=true`
    // to additionally test against GraalVM and Oracle.
    when (System.getProperty("pklTestAllVendors")?.toBoolean()) {
      true -> sequenceOf(JvmVendorSpec.ADOPTIUM, JvmVendorSpec.GRAAL_VM, JvmVendorSpec.ORACLE)
      else -> sequenceOf(JvmVendorSpec.ADOPTIUM)
    }
  }

  private val isArmWindows: Boolean
    get() {
      if (!os.isWindows) {
        return false
      }
      // System.getProperty("os.arch") returns the architecture of the JVM, not the host OS.
      val procArch = System.getenv("PROCESSOR_ARCHITECTURE")
      return "ARM64".equals(procArch, ignoreCase = true)
    }

  // Assembles a collection of JDK versions which tests can be run against, considering ancillary
  // parameters like `testAllJdks` and `testExperimentalJdks`.
  val jdkTestRange: Collection<JavaLanguageVersion> by lazy {
    if (isArmWindows) {
      // Java toolchains does not work on ARM windows: https://github.com/gradle/gradle/issues/29807
      // prevent creating tasks to test different JDKs if developing on a Windows ARM machine.
      return@lazy listOf()
    }
    JavaVersionRange.inclusive(jdkTestFloor, jdkTestCeiling).toList()
  }

  val JavaLanguageVersion.isEnabled: Boolean
    get() = isVersionEnabled(this)

  fun isVersionEnabled(version: JavaLanguageVersion): Boolean {
    return when {
      testAllJdks -> true
      multiJdkTesting -> JavaVersionRange.isLTS(version)
      testExperimentalJdks -> version > jdkToolchainVersion
      else -> false
    }
  }

  private fun JavaToolchainSpec.pklJdkToolchain() {
    languageVersion.set(jdkToolchainVersion)
    vendor.set(jdkVendor)
  }

  private fun labelForVendor(vendor: JvmVendorSpec): String =
    when (vendor) {
      JvmVendorSpec.AZUL -> "Zulu"
      JvmVendorSpec.GRAAL_VM -> "GraalVm"
      JvmVendorSpec.ORACLE -> "Oracle"
      JvmVendorSpec.ADOPTIUM -> "Adoptium"
      else -> error("Unrecognized JDK vendor: $vendor")
    }

  private fun testNamer(baseName: () -> String): (JavaLanguageVersion, JvmVendorSpec?) -> String =
    { jdkTarget, vendor ->
      val targetToken =
        when (vendor) {
          null -> "Jdk${jdkTarget.asInt()}"
          else -> "Jdk${jdkTarget.asInt()}${labelForVendor(vendor).capitalized()}"
        }
      if (jdkTarget > jdkToolchainVersion) {
        // test targets above the toolchain target are considered "experimental".
        "${baseName()}${targetToken}Experimental"
      } else {
        "${baseName()}${targetToken}"
      }
    }

  @Suppress("UnstableApiUsage")
  fun multiJdkTestingWith(
    templateTask: TaskProvider<out Test>,
    configurator: MultiJdkTestConfigurator = {},
  ): Iterable<Provider<out Any>> =
    with(project) {
      val isMultiVendor = testJdkVendors.count() > 1
      val baseNameProvider = { templateTask.get().name }
      val namer = testNamer(baseNameProvider)
      val applyConfig: MultiJdkTestConfigurator = { (version, jdk) ->
        // 1) copy configurations from the template task
        templateTask.get().let { template ->
          // copy explicit dependencies not inferred from task inputs
          dependsOn(template.dependsOn)
          classpath = template.classpath
          testClassesDirs = template.testClassesDirs
          jvmArgs.addAll(template.jvmArgs)
          jvmArgumentProviders.addAll(template.jvmArgumentProviders)
          forkEvery = template.forkEvery
          maxParallelForks = template.maxParallelForks
          minHeapSize = template.minHeapSize
          maxHeapSize = template.maxHeapSize
          exclude(template.excludes)
          template.systemProperties.forEach { prop -> systemProperty(prop.key, prop.value) }
        }

        // 2) assign launcher
        javaLauncher = jdk

        // 3) dispatch the user's configurator
        configurator(version to jdk)
      }

      serviceOf<JavaToolchainService>().let { toolchains ->
        jdkTestRange
          .flatMap { targetVersion ->
            // multiply out by jdk vendor
            testJdkVendors.map { vendor -> (targetVersion to vendor) }
          }
          .mapNotNull { (jdkTarget, vendor) ->
            if (jdkToolchainVersion == jdkTarget) {
              tasks.register(namer(jdkTarget, vendor)) {
                // alias to `test`
                dependsOn(templateTask)
                group = Category.VERIFICATION
                description =
                  "Alias for regular '${baseNameProvider()}' task, on JDK ${jdkTarget.asInt()}"
              }
            } else {
              // Always register and enable the task so it can be run explicitly,
              // but only return it if it should be included in "check".
              val task =
                tasks.register(namer(jdkTarget, vendor.takeIf { isMultiVendor }), Test::class) {
                  group = Category.VERIFICATION
                  description = "Run tests against JDK ${jdkTarget.asInt()}"
                  applyConfig(jdkTarget to toolchains.launcherFor { languageVersion = jdkTarget })
                  // fix: on jdk17, we must force the polyglot module on to the modulepath
                  if (jdkTarget.asInt() == 17)
                    jvmArgumentProviders.add(
                      CommandLineArgumentProvider {
                        buildList { listOf("--add-modules=org.graalvm.polyglot") }
                      }
                    )
                }
              task.takeIf { jdkTarget.isEnabled }
            }
          }
          .toList()
      }
    }

  val javaCompiler: Provider<JavaCompiler> by lazy {
    project.serviceOf<JavaToolchainService>().let { toolchainService ->
      toolchainService.compilerFor { pklJdkToolchain() }
    }
  }

  val javaTestLauncher: Provider<JavaLauncher> by lazy {
    project.serviceOf<JavaToolchainService>().let { toolchainService ->
      toolchainService.launcherFor { pklJdkToolchain() }
    }
  }

  val multiJdkTesting: Boolean by lazy {
    // Test Pkl against a full range of JDK versions, past and present, within the
    // supported bounds of `PKL_TEST_JDK_TARGET` and `PKL_TEST_JDK_MAXIMUM`.
    //
    // In CI, this defaults to `true` to catch potential cross-JDK compat regressions or other bugs.
    // In local dev, this defaults to `false` to speed up the build and reduce contributor load.
    System.getProperty("pklMultiJdkTesting")?.toBoolean() ?: isCiBuild
  }

  val hasMuslToolchain: Boolean by lazy {
    // see .github/scripts/install_musl.sh
    File(System.getProperty("user.home"), "staticdeps/bin/x86_64-linux-musl-gcc").exists()
  }

  val os: OS by lazy {
    val currentOs = OperatingSystem.current()
    when {
      currentOs.isMacOsX -> OS.MacOS
      currentOs.isLinux -> OS.Linux
      currentOs.isWindows -> OS.Windows
      else -> throw GradleException("Cannot build on ${currentOs.name}")
    }
  }

  // could be `commitId: Provider<String> = project.provider { ... }`
  val commitId: String by lazy {
    // allow -DcommitId=abc123 for build environments that don't have git.
    System.getProperty("commitId").let { if (it != null) return@lazy it }
    // only run command once per build invocation
    if (project.path == project.rootProject.path) {
      val process =
        ProcessBuilder()
          .command("git", "rev-parse", "--short", "HEAD")
          .directory(project.rootDir)
          .start()
      process.waitFor().also { exitCode ->
        if (exitCode == -1) throw RuntimeException(process.errorStream.reader().readText())
      }
      process.inputStream.reader().readText().trim()
    } else {
      project.rootProject.extensions.getByType(BuildInfo::class.java).commitId
    }
  }

  val commitish: String by lazy { if (isReleaseBuild) project.version.toString() else commitId }

  val pklVersion: String by lazy {
    if (isReleaseBuild) {
      project.version.toString()
    } else {
      project.version.toString().replace("-SNAPSHOT", "-dev+$commitId")
    }
  }

  val pklVersionNonUnique: String by lazy {
    if (isReleaseBuild) {
      project.version.toString()
    } else {
      project.version.toString().replace("-SNAPSHOT", "-dev")
    }
  }

  // https://melix.github.io/blog/2021/03/version-catalogs-faq.html#_but_how_can_i_use_the_catalog_in_em_plugins_em_defined_in_code_buildsrc_code
  val libs: VersionCatalog by lazy {
    project.extensions.getByType<VersionCatalogsExtension>().named("libs")
  }

  init {
    if (!isReleaseBuild) {
      project.version = "${project.version}-SNAPSHOT"
    }
  }
}

// Shape of a function which is applied to configure multi-JDK testing.
private typealias MultiJdkTestConfigurator =
  Test.(Pair<JavaLanguageVersion, Provider<JavaLauncher>>) -> Unit

private val OperatingSystem.canonicalName
  get() =
    when {
      isMacOsX -> "macos"
      isWindows -> "windows"
      isLinux -> "linux"
      else -> throw RuntimeException("Unsupported OS: $name")
    }
