/*
 * Copyright (C) 2024 Dremio
 *
 * 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
 *
 * 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.
 */

package copiedcode

import java.nio.file.Files
import java.util.regex.Pattern
import javax.inject.Inject
import kotlin.collections.joinToString
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.component.SoftwareComponentFactory
import org.gradle.api.file.SourceDirectorySet
import org.gradle.api.tasks.SourceSetContainer
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.work.DisableCachingByDefault

/**
 * This plugin identifies files that have been originally copied from another project.
 *
 * Configuration is done using the [CopiedCodeCheckerExtension], available under the name
 * `copiedCodeChecks`.
 *
 * Such files need to contain a magic word, see [CopiedCodeCheckerExtension.magicWord].
 *
 * This plugin scans all source directories configured in the project's [SourceDirectorySet]. Files
 * in the project's build directory are always excluded.
 *
 * By default, this plugin scans all files. There is a convenience function to exclude known binary
 * types, see [CopiedCodeCheckerExtension.addDefaultContentTypes]. The
 * [CopiedCodeCheckerExtension.excludedContentTypePatterns] is checked first against a detected
 * content type. If a content-type's excluded, the
 * [CopiedCodeCheckerExtension.includedContentTypePatterns] is consulted. If a content-type could
 * not be detected, the property [CopiedCodeCheckerExtension.includeUnrecognizedContentType], which
 * defaults to `true`, is consulted.
 *
 * Each Gradle project has its own instance of the [CopiedCodeCheckerExtension], the extension of
 * the root project serves default values, except for [CopiedCodeCheckerExtension.scanDirectories]]
 *
 * The license file to check is configured via [CopiedCodeCheckerExtension.licenseFile]. Files must
 * be mentioned using the relative path from the root directory, with a trailing `* ` (star +
 * space).
 */
@Suppress("unused")
class CopiedCodeCheckerPlugin
@Inject
constructor(private val softwareComponentFactory: SoftwareComponentFactory) : Plugin<Project> {
  override fun apply(project: Project): Unit =
    project.run {
      val extension =
        extensions.create("copiedCodeChecks", CopiedCodeCheckerExtension::class.java, project)

      if (rootProject == this) {
        // Apply this plugin to all projects
        afterEvaluate { subprojects { plugins.apply(CopiedCodeCheckerPlugin::class.java) } }

        tasks.register(
          CHECK_COPIED_CODE_MENTIONS_EXIST_TASK_NAME,
          CheckCopiedCodeMentionsExistTask::class.java,
        )

        afterEvaluate {
          tasks.named("check").configure { dependsOn(CHECK_COPIED_CODE_MENTIONS_EXIST_TASK_NAME) }
        }
      } else {
        extension.excludedContentTypePatterns.convention(
          provider {
            rootProject.extensions
              .getByType(CopiedCodeCheckerExtension::class.java)
              .excludedContentTypePatterns
              .get()
          }
        )
        extension.includedContentTypePatterns.convention(
          provider {
            rootProject.extensions
              .getByType(CopiedCodeCheckerExtension::class.java)
              .includedContentTypePatterns
              .get()
          }
        )
        extension.includeUnrecognizedContentType.convention(
          provider {
            rootProject.extensions
              .getByType(CopiedCodeCheckerExtension::class.java)
              .includeUnrecognizedContentType
              .get()
          }
        )
        extension.licenseFile.convention(
          provider {
            rootProject.extensions
              .getByType(CopiedCodeCheckerExtension::class.java)
              .licenseFile
              .get()
          }
        )
      }

      tasks.register(CHECK_FOR_COPIED_CODE_TASK_NAME, CheckForCopiedCodeTask::class.java)

      afterEvaluate {
        tasks.named("check").configure { dependsOn(CHECK_FOR_COPIED_CODE_TASK_NAME) }
      }
    }

  companion object {
    private const val CHECK_FOR_COPIED_CODE_TASK_NAME = "checkForCopiedCode"
    private const val CHECK_COPIED_CODE_MENTIONS_EXIST_TASK_NAME = "checkCopiedCodeMentionsExist"
  }
}

@DisableCachingByDefault
abstract class CheckCopiedCodeMentionsExistTask : DefaultTask() {
  @TaskAction
  fun checkMentions() {
    val extension = project.extensions.getByType(CopiedCodeCheckerExtension::class.java)

    val licenseFile = extension.licenseFile.get().asFile
    val licenseFileRelative = licenseFile.relativeTo(project.rootDir).toString()

    logger.info("Checking whether files mentioned in the {} file exist", licenseFileRelative)

    val nonExistingMentions =
      extension.licenseFile
        .get()
        .asFile
        .readLines()
        .filter { line -> line.startsWith("* ") && line.length > 2 }
        .map { line -> line.substring(2) }
        .filter { relFilePath -> !project.rootProject.file(relFilePath).exists() }
        .sorted()

    if (nonExistingMentions.isNotEmpty()) {
      logger.error(
        """
        The following {} files mentioned in {} do not exist, fix the {} file.

        {}
        """
          .trimIndent(),
        nonExistingMentions.size,
        licenseFileRelative,
        licenseFileRelative,
        nonExistingMentions.joinToString("\n* ", "* "),
      )

      throw GradleException(
        "${nonExistingMentions.size} files mentioned in $licenseFileRelative do not exist, fix the $licenseFileRelative file."
      )
    }
  }
}

@DisableCachingByDefault
abstract class CheckForCopiedCodeTask : DefaultTask() {
  private fun namedDirectorySets(): List<Pair<String, SourceDirectorySet>> {
    val namedDirectorySets = mutableListOf<Pair<String, SourceDirectorySet>>()

    val extension = project.extensions.getByType(CopiedCodeCheckerExtension::class.java)
    extension.scanDirectories.forEach { scanDirectory ->
      namedDirectorySets.add(Pair("scan directory ${scanDirectory.name}", scanDirectory))
    }

    val sourceSets: SourceSetContainer? by project
    sourceSets?.forEach { sourceSet ->
      namedDirectorySets.add(Pair("source set ${sourceSet.name}", sourceSet.allSource))
    }

    return namedDirectorySets
  }

  @TaskAction
  fun checkForCopiedCode() {
    val extension = project.extensions.getByType(CopiedCodeCheckerExtension::class.java)

    val licenseFile = extension.licenseFile.get().asFile
    val licenseFileRelative = licenseFile.relativeTo(project.rootDir).toString()

    logger.info("Running copied code check against root project's {} file", licenseFileRelative)

    val namedDirectorySets = namedDirectorySets()

    val includedPatterns = extension.includedContentTypePatterns.get().map { Pattern.compile(it) }
    val excludedPatterns = extension.includedContentTypePatterns.get().map { Pattern.compile(it) }
    val includeUnknown = extension.includeUnrecognizedContentType.get()

    val magicWord = extension.magicWord.get()
    val magicWordPattern = Pattern.compile(".*\\b${magicWord}\\b.*")

    val mentionedFilesInLicense =
      extension.licenseFile
        .get()
        .asFile
        .readLines()
        .filter { line -> line.startsWith("* ") && line.length > 2 }
        .map { line -> line.substring(2) }
        .toSet()

    val buildDir = project.layout.buildDirectory.asFile.get()

    val unmentionedFiles =
      namedDirectorySets
        .flatMap { pair ->
          val name = pair.first
          val sourceDirectorySet = pair.second

          logger.info(
            "Checking {} for files containing {} not mentioned in {}",
            name,
            magicWord,
            licenseFileRelative,
          )

          sourceDirectorySet.asFileTree
            .filter { file -> !file.startsWith(buildDir) }
            .map { file ->
              val projectRelativeFile = file.relativeTo(project.projectDir)
              val fileType = Files.probeContentType(file.toPath())
              logger.info(
                "Checking file '{}' (probed content type: {})",
                projectRelativeFile,
                fileType,
              )

              var r: String? = null

              var check = true
              if (fileType == null) {
                if (!includeUnknown) {
                  logger.info("   ... unknown content type, skipping")
                  check = false
                }
              } else {
                val excluded =
                  excludedPatterns.any { pattern -> pattern.matcher(fileType).matches() }
                if (excluded) {
                  val included =
                    includedPatterns.any { pattern -> pattern.matcher(fileType).matches() }
                  if (!included) {
                    logger.info("   ... excluded and not included content type, skipping")
                    check = false
                  }
                }
              }

              if (check) {
                if (!file.readLines().any { s -> magicWordPattern.matcher(s).matches() }) {
                  logger.info(
                    "   ... no magic word, not expecting an entry in {}",
                    licenseFileRelative,
                  )
                } else {
                  val relativeFilePath = file.relativeTo(project.rootProject.projectDir).toString()
                  if (mentionedFilesInLicense.contains(relativeFilePath)) {
                    logger.info("   ... has magic word & mentioned in {}", licenseFileRelative)
                  } else {
                    // error (summary) logged below
                    logger.info(
                      "The file '{}' has the {} marker, but is not mentioned in {}",
                      relativeFilePath,
                      magicWord,
                      licenseFileRelative,
                    )
                    r = relativeFilePath
                  }
                }
              }

              r
            }
            .filter { r -> r != null }
            .map { r -> r!! }
        }
        .sorted()
        .toList()

    if (!unmentionedFiles.isEmpty()) {
      logger.error(
        """
        The following {} files have the {} marker but are not mentioned in {}, add those in an appropriate section.

        {}
        """
          .trimIndent(),
        unmentionedFiles.size,
        magicWord,
        licenseFileRelative,
        unmentionedFiles.joinToString("\n* ", "* "),
      )

      throw GradleException(
        "${unmentionedFiles.size} files with the $magicWord marker need to be mentioned in $licenseFileRelative. See the messages above."
      )
    }
  }
}
