/**
 * Copyright (C) 2009-2018 Lightbend Inc. <https://www.lightbend.com>
 */

package akka

import com.lightbend.paradox.sbt.ParadoxPlugin
import com.lightbend.paradox.sbt.ParadoxPlugin.autoImport.paradox
import com.typesafe.tools.mima.plugin.MimaKeys.mimaReportBinaryIssues
import com.typesafe.tools.mima.plugin.MimaPlugin
import net.virtualvoid.sbt.graph.backend.SbtUpdateReport
import net.virtualvoid.sbt.graph.DependencyGraphKeys._
import net.virtualvoid.sbt.graph.ModuleGraph
import org.kohsuke.github._
import sbtunidoc.BaseUnidocPlugin.autoImport.unidoc
import sbt.Keys._
import sbt._

import scala.collection.immutable
import scala.sys.process._
import scala.util.matching.Regex

object ValidatePullRequest extends AutoPlugin {

  override def trigger = allRequirements
  override def requires = plugins.JvmPlugin

  sealed trait BuildMode {
    def task: Option[TaskKey[_]]
    def log(projectName: String, l: Logger): Unit
  }
  case object BuildSkip extends BuildMode {
    override def task = None
    def log(projectName: String, l: Logger) =
      l.info(s"Skipping validation of [$projectName], as PR does NOT affect this project...")
  }
  case object BuildQuick extends BuildMode {
    override def task = Some(test in ValidatePR)
    def log(projectName: String, l: Logger) =
      l.info(s"Building [$projectName] in quick mode, as it's dependencies were affected by PR.")
  }
  case object BuildProjectChangedQuick extends BuildMode {
    override def task = Some(test in ValidatePR)
    def log(projectName: String, l: Logger) =
      l.info(s"Building [$projectName] as the root `project/` directory was affected by this PR.")
  }
  final case class BuildCommentForcedAll(phrase: String, c: GHIssueComment) extends BuildMode {
    override def task = Some(test in Test)
    def log(projectName: String, l: Logger) =
      l.info(s"GitHub PR comment [ ${c.getUrl} ] contains [$phrase], forcing BUILD ALL mode!")
  }

  val ValidatePR = config("pr-validation") extend Test

  override lazy val projectConfigurations = Seq(ValidatePR)

  /*
    Assumptions:
      Env variables set "by Jenkins" are assumed to come from this plugin:
      https://wiki.jenkins-ci.org/display/JENKINS/GitHub+pull+request+builder+plugin
   */

  // settings
  val PullIdEnvVarName = "ghprbPullId" // Set by "GitHub pull request builder plugin"

  val TargetBranchEnvVarName = "PR_TARGET_BRANCH"
  val TargetBranchJenkinsEnvVarName = "ghprbTargetBranch"

  val SourceBranchEnvVarName = "PR_SOURCE_BRANCH"
  val SourcePullIdJenkinsEnvVarName = "ghprbPullId" // used to obtain branch name in form of "pullreq/17397"
  val sourceBranch = settingKey[String]("Branch containing the changes of this PR")

  val targetBranch = settingKey[String]("Target branch of this PR, defaults to `master`")

  // asking github comments if this PR should be PLS BUILD ALL
  val githubEnforcedBuildAll = taskKey[Option[BuildMode]]("Checks via GitHub API if comments included the PLS BUILD ALL keyword")
  val buildAllKeyword = taskKey[Regex]("Magic phrase to be used to trigger building of the entire project instead of analysing dependencies")

  // determining touched dirs and projects
  val changedDirectories = taskKey[immutable.Set[String]]("List of touched modules in this PR branch")
  val projectBuildMode = taskKey[BuildMode]("Determines what will run when this project is affected by the PR and should be rebuilt")

  // running validation
  val validatePullRequest = taskKey[Unit]("Validate pull request")
  val additionalTasks = taskKey[Seq[TaskKey[_]]]("Additional tasks for pull request validation")

  def changedDirectoryIsDependency(changedDirs: Set[String],
                                    name: String,
                                    graphsToTest: Seq[(Configuration, ModuleGraph)])(log: Logger): Boolean = {
    graphsToTest exists { case (ivyScope, deps) =>
      log.debug(s"Analysing [$ivyScope] scoped dependencies...")

      deps.nodes.foreach { m ⇒ log.debug(" -> " + m.id) }

      // if this project depends on a modified module, we must test it
      deps.nodes.exists { m =>
        // match just by name, we'd rather include too much than too little
        val dependsOnModule = changedDirs.find(m.id.name contains _)
        val depends = dependsOnModule.isDefined
        if (depends) log.info(s"Project [$name] must be verified, because depends on [${dependsOnModule.get}]")
        depends
      }
    }
  }

  def localTargetBranch: Option[String] = sys.env.get("PR_TARGET_BRANCH")
  def jenkinsTargetBranch: Option[String] = sys.env.get("ghprbTargetBranch")
  def runningOnJenkins: Boolean = jenkinsTargetBranch.isDefined
  def runningLocally: Boolean = !runningOnJenkins

  override lazy val buildSettings = Seq(
    sourceBranch in Global in ValidatePR := {
      sys.env.get(SourceBranchEnvVarName) orElse
        sys.env.get(SourcePullIdJenkinsEnvVarName).map("pullreq/" + _) getOrElse // Set by "GitHub pull request builder plugin"
        "HEAD"
    },

    targetBranch in Global in ValidatePR := {
      (localTargetBranch, jenkinsTargetBranch) match {
        case (Some(local), _)     => local // local override
        case (None, Some(branch)) => s"origin/$branch" // usually would be "master" or "release-2.3" etc
        case (None, None)         => "origin/master" // defaulting to diffing with "master"
      }
    },

    buildAllKeyword in Global in ValidatePR := """PLS BUILD ALL""".r,

    githubEnforcedBuildAll in Global in ValidatePR := {
      val log = streams.value.log
      val buildAllMagicPhrase = (buildAllKeyword in ValidatePR).value

      sys.env.get(PullIdEnvVarName).map(_.toInt) flatMap { prId =>
        log.info("Checking GitHub comments for PR validation options...")

        try {
          import scala.collection.JavaConverters._
          val gh = GitHubBuilder.fromEnvironment().withOAuthToken(GitHub.envTokenOrThrow).build()
          val comments = gh.getRepository("akka/akka").getIssue(prId).getComments.asScala

          def triggersBuildAll(c: GHIssueComment): Boolean = buildAllMagicPhrase.findFirstIn(c.getBody).isDefined
          comments collectFirst { case c if triggersBuildAll(c) =>
            BuildCommentForcedAll(buildAllMagicPhrase.toString(), c)
          }
        } catch {
          case ex: Exception =>
            log.warn("Unable to reach GitHub! Exception was: " + ex.getMessage)
            None
        }
      }
    },

    changedDirectories in Global in ValidatePR := {
      val log = streams.value.log

      val prId = (sourceBranch in ValidatePR).value

      val target = (targetBranch in ValidatePR).value

      // TODO could use jgit
      log.info(s"Diffing [$prId] to determine changed modules in PR...")
      val diffOutput = s"git diff $target --name-only".!!.split("\n")
      val diffedModuleNames =
        diffOutput
          .map(l => l.trim)
          .filter(l =>
            l.startsWith("akka-") ||
            (l.startsWith("project") && l != "project/MiMa.scala")
          )
          .map(l ⇒ l.takeWhile(_ != '/'))
          .toSet

      val dirtyModuleNames: Set[String] =
        if (runningOnJenkins) Set.empty
        else {
          val statusOutput = s"git status --short".!!.split("\n")
          val dirtyDirectories = statusOutput
            .map(l ⇒ l.trim.dropWhile(_ != ' ').drop(1))
            .map(_.takeWhile(_ != '/'))
            .filter(dir => dir.startsWith("akka-") || dir == "project")
            .toSet
          log.info("Detected uncommitted changes in directories (including in dependency analysis): " + dirtyDirectories.mkString("[", ",", "]"))
          dirtyDirectories
        }


      val allModuleNames = dirtyModuleNames ++ diffedModuleNames
      log.info("Detected changes in directories: " + allModuleNames.mkString("[", ", ", "]"))
      allModuleNames
    }
  )

  override lazy val projectSettings = inConfig(ValidatePR)(Defaults.testTasks) ++ Seq(
    testOptions in ValidatePR += Tests.Argument(TestFrameworks.ScalaTest, "-l", "performance"),
    testOptions in ValidatePR += Tests.Argument(TestFrameworks.ScalaTest, "-l", "long-running"),
    testOptions in ValidatePR += Tests.Argument(TestFrameworks.ScalaTest, "-l", "timing"),

    // make it fork just like regular test running
    fork in ValidatePR := (fork in Test).value,
    testGrouping in ValidatePR := (testGrouping in Test).value,
    javaOptions in ValidatePR := (javaOptions in Test).value,

    projectBuildMode in ValidatePR := {
      val log = streams.value.log
      log.debug(s"Analysing project (for inclusion in PR validation): [${name.value}]")
      val changedDirs = (changedDirectories in ValidatePR).value
      val githubCommandEnforcedBuildAll = (githubEnforcedBuildAll in ValidatePR).value

      val thisProjectId = CrossVersion(scalaVersion.value, scalaBinaryVersion.value)(projectID.value)

      def graphFor(updateReport: UpdateReport, config: Configuration): (Configuration, ModuleGraph) =
        config -> SbtUpdateReport.fromConfigurationReport(updateReport.configuration(config).get, thisProjectId)

      def isDependency: Boolean =
        changedDirectoryIsDependency(
          changedDirs,
          name.value,
          Seq(
            graphFor((update in Compile).value, Compile),
            graphFor((update in Test).value, Test),
            graphFor((update in Runtime).value, Runtime),
            graphFor((update in Provided).value, Provided),
            graphFor((update in Optional).value, Optional)))(log)

      if (githubCommandEnforcedBuildAll.isDefined)
        githubCommandEnforcedBuildAll.get
      else if (changedDirs contains "project")
        BuildProjectChangedQuick
      else if (isDependency)
        BuildQuick
      else
        BuildSkip
    },

    additionalTasks in ValidatePR := Seq.empty,

    validatePullRequest := Def.taskDyn {
      val log = streams.value.log
      val buildMode = (projectBuildMode in ValidatePR).value

      buildMode.log(name.value, log)

      val validationTasks = buildMode.task.toSeq ++ (buildMode match {
        case BuildSkip => Seq.empty // do not run the additional task if project is skipped during pr validation
        case _ => (additionalTasks in ValidatePR).value
      })

      // Create a task for every validation task key and
      // then zip all of the tasks together discarding outputs.
      // Task failures are propagated as normal.
      val zero: Def.Initialize[Seq[Task[Any]]] = Def.setting { Seq(task(()))}
      validationTasks.map(taskKey => Def.task { taskKey.value } ).foldLeft(zero) { (acc, current) =>
        acc.zipWith(current) { case (taskSeq, task) =>
          taskSeq :+ task.asInstanceOf[Task[Any]]
        }
      } apply { tasks: Seq[Task[Any]] =>
        tasks.join map { seq => () /* Ignore the sequence of unit returned */ }
      }
    }.value
  )
}

/**
* This autoplugin adds Multi Jvm tests to validatePullRequest task.
* It is needed, because ValidatePullRequest autoplugin does not depend on MultiNode and
* therefore test:executeTests is not yet modified to include multi-jvm tests when ValidatePullRequest
* build strategy is being determined.
*
* Making ValidatePullRequest depend on MultiNode is impossible, as then ValidatePullRequest
* autoplugin would trigger only on projects which have both of these plugins enabled.
*/
object MultiNodeWithPrValidation extends AutoPlugin {
  import ValidatePullRequest._
  import com.typesafe.sbt.MultiJvmPlugin.MultiJvmKeys.MultiJvm
  override def trigger = allRequirements
  override def requires = ValidatePullRequest && MultiNode
  override lazy val projectSettings =
    if (MultiNode.multiNodeTestInTest) Seq(additionalTasks in ValidatePR += MultiNode.multiTest)
    else Seq.empty
}

/**
* This autoplugin adds MiMa binary issue reporting to validatePullRequest task,
* when a project has MimaPlugin autoplugin enabled.
*/
object MimaWithPrValidation extends AutoPlugin {
  import ValidatePullRequest._

  override def trigger = allRequirements
  override def requires = ValidatePullRequest && MimaPlugin
  override lazy val projectSettings = Seq(
    additionalTasks in ValidatePR += mimaReportBinaryIssues
  )
}

/**
  * This autoplugin adds Paradox doc generation to validatePullRequest task,
  * when a project has ParadoxPlugin autoplugin enabled.
  */
object ParadoxWithPrValidation extends AutoPlugin {
  import ValidatePullRequest._

  override def trigger = allRequirements
  override def requires = ValidatePullRequest && ParadoxPlugin
  override lazy val projectSettings = Seq(
    additionalTasks in ValidatePR += paradox in Compile
  )
}

object UnidocWithPrValidation extends AutoPlugin {
  import ValidatePullRequest._

  override def trigger = noTrigger
  override lazy val projectSettings = Seq(
    additionalTasks in ValidatePR += unidoc in Compile
  )
}
