/*
 * Copyright © 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("unused")

import org.apache.tools.ant.filters.ReplaceTokens
import org.gradle.kotlin.dsl.filter

plugins {
  id("pklAllProjects")
  id("pklGraalVm")
  id("pklJavaLibrary")
  id("pklNativeLifecycle")
}

val stagedMacAarch64NativeLibrary: Configuration =
  configurations.create("stagedMacAarch64NativeLibrary")
val stagedLinuxAmd64NativeLibrary: Configuration =
  configurations.create("stagedLinuxAmd64NativeLibrary")
val stagedLinuxAarch64NativeLibrary: Configuration =
  configurations.create("stagedLinuxAarch64NativeLibrary")
val stagedAlpineLinuxAmd64NativeLibrary: Configuration =
  configurations.create("stagedAlpineLinuxAmd64NativeLibrary")
val stagedWindowsAmd64NativeLibrary: Configuration =
  configurations.create("stagedWindowsAmd64NativeLibrary")

val nativeTestSourceSet: SourceSet = sourceSets.create("nativeTest")

val nativeTestImplementation: Configuration =
  configurations.getByName("nativeTestImplementation") {
    extendsFrom(configurations.testImplementation.get())
  }

val nativeTestRuntimeOnly: Configuration =
  configurations.getByName("nativeTestRuntimeOnly") {
    extendsFrom(configurations.testRuntimeOnly.get())
  }

dependencies {
  compileOnly(libs.graalSdk)

  implementation(projects.pklCore)
  implementation(projects.pklServer)

  implementation(libs.msgpack)
  implementation(libs.truffleApi)
  implementation(libs.truffleRuntime)

  nativeTestImplementation(projects.pklCommonsTest)
  nativeTestImplementation(libs.jna)
  nativeTestImplementation(libs.jnaPlatform)
  nativeTestRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.withType(CCompile::class) {
  if (buildInfo.targetMachine.os.isMacOS) {
    arch = buildInfo.targetMachine.arch
  }
}

private fun NativeImageBuild.configure(target: Target) {
  arch = target.arch

  if (target.arch == Target.Arch.AARCH64) {
    dependsOn(":installGraalVmAarch64")
  } else {
    dependsOn(":installGraalVmAmd64")
  }

  outputDir = target.tempOutputDir
  outputName = "libpkl_internal"

  classpath.from(sourceSets.main.map { it.output })
  classpath.from(
    project(":pkl-commons-cli").extensions.getByType(SourceSetContainer::class)["svm"].output
  )
  classpath.from(configurations.runtimeClasspath)

  sharedLibrary = true
  val scriptName = if (buildInfo.os.isWindows) "build_windows.bat" else "build_unix.sh"
  nativeCompilerPath = projectDir.resolve("scripts/${scriptName}")

  if (!buildInfo.os.isWindows) {
    // build_unix.sh extracts the object files of every archive it links against into a loose
    // `objects/` dir so that `buildStaticLibrary` can merge them into a single, self-contained
    // static library.
    //
    // Don't need to do this for Windows because it produces a merged archive.
    additionalOutputDirectoryNames.add("objects")
  }
}

val macNativeImageAarch64 =
  tasks.register<NativeImageBuild>("macNativeImageAarch64") {
    configure(Target.MacosAarch64)
    // macOS only supports 16K page size
    extraNativeImageArgs = buildList {
      add("-H:PageSize=16384")
      if (buildInfo.isCiBuild) {
        add("-J-Xmx12g")
        add("-H:+DeadlockWatchdogExitOnTimeout")
        add("-H:DeadlockWatchdogInterval=10")
      }
    }
  }

val linuxNativeImageAmd64 =
  tasks.register<NativeImageBuild>("linuxNativeImageAmd64") { configure(Target.LinuxAmd64) }

val linuxNativeImageAarch64 =
  tasks.register<NativeImageBuild>("linuxNativeImageAarch64") {
    configure(Target.LinuxAarch64)
    extraNativeImageArgs =
      listOf(
        // Ensure compatibility for kernels with page size set to 4k, 16k and 64k
        // (e.g. Raspberry Pi 5, Asahi Linux)
        "-H:PageSize=65536"
      )
  }

val alpineLinuxNativeImageAmd64 =
  tasks.register<NativeImageBuild>("alpineLinuxNativeImageAmd64") {
    configure(Target.AlpineLinuxAmd64)
    extraNativeImageArgs.addAll("--libc=musl")
  }

val windowsNativeImageAmd64 =
  tasks.register<NativeImageBuild>("windowsNativeImageAmd64") {
    configure(Target.WindowsAmd64)
    extraNativeImageArgs.addAll("-Dfile.encoding=UTF-8", "-H:-CheckToolchain")
  }

val buildNativeImageLibrary =
  tasks.register("buildNativeImageLibrary") {
    group = "build"
    val underlyingTask =
      when (buildInfo.targetMachine) {
        Target.MacosAarch64 -> macNativeImageAarch64
        Target.LinuxAarch64 -> linuxNativeImageAarch64
        Target.LinuxAmd64 -> linuxNativeImageAmd64
        Target.WindowsAmd64 -> windowsNativeImageAmd64
        Target.AlpineLinuxAmd64 -> alpineLinuxNativeImageAmd64
      }
    dependsOn(underlyingTask)
    outputs.files(underlyingTask)
  }

val buildPklObjectFile =
  tasks.register<CCompile>("buildPklObjectFile") {
    dependsOn(buildNativeImageLibrary)
    includeDirs.from(buildInfo.targetMachine.tempOutputDir)
    includeDirs.from(file("src/main/c/include/"))
    sourceFiles.from(file("src/main/c/pkl.c"))
    positionIndependentCode = true
    if (buildInfo.os.isWindows) {
      // Match the dynamic CRT (ucrt/msvcrt) that native-image's bundled JDK objects expect -
      // otherwise linking pkl.obj together with the merged libpkl_internal_s.lib archive hits a
      // LIBCMT/MSVCRT default-lib conflict and unresolved __imp_* CRT import symbols.
      runtimeLibrary = "MD"
    }

    outputDir =
      layout.buildDirectory.dir("tmp/compileC/${name}/${buildInfo.targetMachine.targetName}")

    val versionMacro =
      if (buildInfo.targetMachine.os.isWindows) "\\\"${buildInfo.pklVersion}\\\""
      else "\"${buildInfo.pklVersion}\""
    defines.put("PKL_VERSION", versionMacro)
  }

val buildStaticLibrary =
  tasks.register<CArchive>("buildStaticLibrary") {
    dependsOn(buildNativeImageLibrary, buildPklObjectFile)

    val targetMachine = buildInfo.targetMachine
    val objectExtension = targetMachine.os.objectFileExtension
    val pklObjectFile = buildPklObjectFile.flatMap {
      it.outputDir.map { dir -> dir.file("pkl.${objectExtension}") }
    }

    if (targetMachine.os.isWindows) {
      // build_windows.bat produces a single self-contained merged archive
      // (libpkl_internal_s.lib) directly, rather than extracting loose .obj members into an
      // "objects" directory the way build_unix.sh does - pull that in instead.
      objectFiles.from(
        buildNativeImageLibrary.map { task ->
          task.outputs.files.find {
            it.name == "libpkl_internal_s.${targetMachine.os.staticLibraryExtension}"
          }
        }
      )
    } else {
      objectFiles.from(
        fileTree(targetMachine.tempOutputDir.get().dir("objects")).matching {
          include("**/*.${objectExtension}")
        }
      )
    }
    objectFiles.from(pklObjectFile)

    outputFile =
      targetMachine.outputDir.map { dir ->
        dir.file("lib/libpkl.${targetMachine.os.staticLibraryExtension}")
      }
  }

val buildSharedLibrary =
  tasks.register<CCompile>("buildSharedLibrary") {
    dependsOn(buildNativeImageLibrary, buildPklObjectFile)

    link = true
    val targetMachine = buildInfo.targetMachine

    val libraryFile = buildPklObjectFile.flatMap { task ->
      task.outputDir.map { dir -> dir.file("pkl.${targetMachine.os.objectFileExtension}") }
    }

    val extension = targetMachine.os.sharedLibraryExtension
    val staticLib = buildNativeImageLibrary.map { task ->
      // link against the self-contained static archive (produced by build_unix.sh /
      // build_windows.bat) rather than the shared library, so libpkl doesn't carry a runtime
      // dependency on a separate libpkl_internal shared library.
      val fileName =
        if (targetMachine.os.isWindows)
          "libpkl_internal_s.${targetMachine.os.staticLibraryExtension}"
        else "libpkl_internal.${targetMachine.os.staticLibraryExtension}"
      task.outputs.files.find { it.name == fileName }
    }

    sourceFiles.from(libraryFile)
    includeDirs.from(file("src/main/c/"))
    libraryFiles.from(staticLib)
    positionIndependentCode = true
    sharedLibrary = true

    outputFile = targetMachine.outputDir.map { it.file("lib/libpkl.${extension}") }

    if (buildInfo.os.isMacOS) {
      frameworks.addAll("Foundation", "CoreServices")
      linkerFlags.addAll("-current_version", project.version.toString())
      linkerFlags.addAll("-compatibility_version", "0.1.0")
      // prevent JNI symbols from being exported and clobbering the symbol table
      val exportedSymbolsFile = file("src/main/c/pkl.exported_symbols")
      inputs.file(exportedSymbolsFile)
      linkerFlags.addAll("-exported_symbols_list", exportedSymbolsFile.absolutePath)
      compilerArgs.addAll("-install_name", "@rpath/libpkl.dylib")
    }

    if (targetMachine.os.isWindows) {
      // Without an explicit /IMPLIB, MSVC would name the DLL's import library "libpkl.lib" —
      // the same path buildStaticLibrary writes its static archive to.
      linkerFlags.add(
        targetMachine.libraryDir.map { dir -> "/IMPLIB:${dir.file("libpkl_dll.lib").asFile}" }
      )
    } else {
      libraries.add("z")
      libraries.add("pthread")
    }

    if (targetMachine.os.isLinux) {
      libraries.add("dl")
      // prevent JNI symbols from being exported and clobbering the symbol table
      val versionScriptFile = file("src/main/c/pkl.map")
      inputs.file(versionScriptFile)
      linkerFlags.add("--version-script=${versionScriptFile.absolutePath}")
    }
  }

val Target.outputDir
  get() = layout.buildDirectory.dir("native-libs/$targetName")

val Target.libraryDir
  get() = layout.buildDirectory.dir("native-libs/$targetName/lib")

val Target.tempOutputDir
  get() = layout.buildDirectory.dir("tmp/native-libs/$targetName")

val distTar =
  tasks.register<Tar>("distTar") {
    val isUnix = buildInfo.os.isMacOS || buildInfo.os.isLinux
    onlyIf { isUnix }
    dependsOn(buildSharedLibrary, buildStaticLibrary, processFiles)
    compression = Compression.GZIP
    val baseName = "libpkl-${buildInfo.pklVersionNonUnique}-${buildInfo.targetMachine.targetName}"
    archiveFileName = "${baseName}.tar.gz"
    from(buildInfo.targetMachine.outputDir)
    into(baseName)
  }

val distZip =
  tasks.register<Zip>("distZip") {
    val isWindows = buildInfo.os.isWindows
    onlyIf { isWindows }
    dependsOn(buildSharedLibrary, buildStaticLibrary, processFiles)
    val baseName = "libpkl-${buildInfo.pklVersionNonUnique}-${buildInfo.targetMachine.targetName}"
    archiveFileName = "${baseName}.zip"
    from(buildInfo.targetMachine.outputDir)
    into(baseName)
  }

tasks.assembleNative { dependsOn(distZip, distTar, processFiles) }

val processFiles =
  tasks.register<Copy>("processFiles") {
    inputs.property("version", buildInfo.pklVersion)
    inputs.dir("src/main/c")
    inputs.dir("src/main/files")
    from(fileTree("src/main/files"))
    from(fileTree("src/main/c") { exclude("*.{c,map,exported_symbols}") })
    includeEmptyDirs = true
    into(buildInfo.targetMachine.outputDir)

    val tokens = buildMap {
      this["version"] = buildInfo.pklVersion
      this["static_lib_filename"] = "libpkl.${buildInfo.os.staticLibraryExtension}"
      this["extra_static_libs"] =
        when {
          buildInfo.os.isMacOS -> " -lz -framework Foundation -framework CoreServices"
          buildInfo.os.isWindows -> ""
          else -> " -lz"
        }
      this["extra_shared_libs"] =
        when {
          buildInfo.os.isMacOS -> " -lpkl"
          buildInfo.os.isWindows -> " -l:libpkl_dll.lib"
          else -> " -lpkl"
        }
    }
    filesMatching("**/libpkl*.pc") { filter<ReplaceTokens>("tokens" to tokens) }
  }

val testNativeJava =
  tasks.register<Test>("testNativeJava") {
    dependsOn(tasks.assembleNative)

    description = "Test native libraries from Java"
    group = "verification"

    testClassesDirs = nativeTestSourceSet.output.classesDirs
    classpath = nativeTestSourceSet.runtimeClasspath

    // It's not good enough to just provide `jna.library.path` on Linux; need to also provide
    // `LD_LIBRARY_PATH` or `java.library.path` so that transitive libraries can be loaded.
    jvmArgumentProviders.add(
      CommandLineArgumentProvider {
        listOf(
          "-Djna.library.path=" + buildInfo.targetMachine.libraryDir.get().asFile.absolutePath,
          "-Djava.library.path=" + buildInfo.targetMachine.libraryDir.get().asFile.absolutePath,
          "--enable-native-access=ALL-UNNAMED",
        )
      }
    )

    environment("LD_LIBRARY_PATH", buildInfo.targetMachine.libraryDir.get().asFile.absolutePath)

    useJUnitPlatform()
  }

// link to static library
val compileNativeTestStatic =
  tasks.register<CCompile>("compileNativeTestStatic") {
    description = "Build C test with static library"
    group = "verification"

    dependsOn(tasks.assembleNative)

    val libDir = buildInfo.targetMachine.libraryDir.get().asFile
    val testSrc = file("src/nativeTest/c/test_pkl.c")

    link = true
    sourceFiles.from(testSrc)
    includeDirs.from(buildInfo.targetMachine.outputDir.map { it.file("include") })
    // Match the dynamic CRT (ucrt/msvcrt) that libpkl.lib's bundled JDK objects expect -
    // otherwise this defaults to the static CRT (LIBCMT), causing a defaultlib conflict and
    // unresolved __imp_* CRT import symbols, same as buildPklObjectFile.
    if (buildInfo.os.isWindows) {
      runtimeLibrary = "MD"
    }
    outputFile =
      layout.buildDirectory.file(
        "native-test/${buildInfo.targetMachine.targetName}/test_pkl_static"
      )

    libraryFiles.from("${libDir.absolutePath}/libpkl.${buildInfo.os.staticLibraryExtension}")

    if (!buildInfo.os.isWindows) {
      libraries.add("pthread")
      libraries.add("z")
    }

    if (buildInfo.os.isLinux) {
      libraries.add("dl")
    }

    if (buildInfo.os.isMacOS) {
      frameworks.addAll("Foundation", "CoreServices")
    }
  }

// link to dynamic library
val compileNativeTestDynamic =
  tasks.register<CCompile>("compileNativeTestDynamic") {
    description = "Build C test with dynamic library"
    group = "verification"

    dependsOn(tasks.assembleNative)

    val testSrc = file("src/nativeTest/c/test_pkl.c")

    link = true
    sourceFiles.from(testSrc)
    includeDirs.from(buildInfo.targetMachine.outputDir.map { it.file("include") })
    outputFile =
      layout.buildDirectory.file(
        "native-test/${buildInfo.targetMachine.targetName}/test_pkl_dynamic"
      )

    libraryPaths.from(buildInfo.targetMachine.libraryDir)

    if (buildInfo.os.isWindows) {
      // "pkl" would resolve to libpkl.lib, which is buildStaticLibrary's static archive, not
      // the DLL's import library — link against the import library directly instead.
      libraryFiles.from(buildInfo.targetMachine.libraryDir.map { it.file("libpkl_dll.lib") })
    } else {
      libraries.add("pkl")
      // Bake the library's location into the test executable so the dynamic loader can find
      // libpkl.so/libpkl.dylib without needing LD_LIBRARY_PATH/DYLD_LIBRARY_PATH set at run time.
      linkerFlags.add("-rpath")
      linkerFlags.add(buildInfo.targetMachine.libraryDir.get().asFile.absolutePath)
    }

    if (buildInfo.os.isLinux) {
      libraries.add("dl")
    }
  }

val buildNativeTestC =
  tasks.register("buildNativeTestC") {
    description = "Build both static and dynamic C tests"
    group = "verification"

    dependsOn(compileNativeTestStatic, compileNativeTestDynamic)
  }

fun Task.configureDummyOutput() {
  group = "verification"

  // dummy output to satisfy up-to-date check
  val outputFile = layout.buildDirectory.file(name)
  outputs.file(outputFile)

  doFirst { outputFile.get().asFile.delete() }
  doLast { outputFile.get().asFile.writeText("OK") }
}

val testNativeCStatic =
  tasks.register<Exec>("testNativeCStatic") {
    configureDummyOutput()
    group = "verification"
    dependsOn(compileNativeTestStatic)

    val testDir = layout.buildDirectory.dir("native-test").get().asFile

    workingDir = projectDir
    commandLine(compileNativeTestStatic.get().outputFile.get().asFile.absolutePath)
  }

val testNativeCDynamic =
  tasks.register<Exec>("testNativeCDynamic") {
    configureDummyOutput()
    group = "verification"

    dependsOn(compileNativeTestDynamic)

    val testDir = layout.buildDirectory.dir("native-test").get().asFile

    workingDir = projectDir
    commandLine(compileNativeTestDynamic.get().outputFile.get().asFile.absolutePath)

    if (buildInfo.os.isWindows) {
      // Windows has no rpath equivalent — the DLL loader only searches the exe's own
      // directory and PATH, so libpkl.dll must be made findable via PATH.
      doFirst {
        environment(
          "PATH",
          "${buildInfo.targetMachine.libraryDir.get().asFile.absolutePath};${System.getenv("PATH")}",
        )
      }
    }
  }

tasks.testNative {
  dependsOn(testNativeCStatic, testNativeCDynamic)

  // can't run libraries from other arch in Java
  if (!buildInfo.isCrossArch) {
    dependsOn(testNativeJava)
  }
}

spotless {
  cpp {
    licenseHeaderFile(
      rootProject.file("build-logic/src/main/resources/license-header.star-block.txt"),
      "// ",
    )
    target("src/*/c/**/*.c", "src/*/c/**/*.h")
    eclipseCdt(libs.versions.eclipseCdtFormat.get())
  }
  shell {
    licenseHeaderFile(
        rootProject.file("build-logic/src/main/resources/license-header.hash-comment.txt"),
        "## build_",
      )
      // skip shebang
      .skipLinesMatching("^#!.+?$")

    target("scripts/*.sh")
  }
  format("bat") {
    target("scripts/*.bat")
    licenseHeaderFile(
      rootProject.file("build-logic/src/main/resources/license-header.batch-script.txt"),
      "@echo",
    )
  }
}
