/*
 * sbt
 * Copyright 2023, Scala center
 * Copyright 2011 - 2022, Lightbend, Inc.
 * Copyright 2008 - 2010, Mark Harrah
 * Licensed under Apache License 2.0 (see LICENSE)
 */

package sbt
package internal

import com.github.benmanes.caffeine.cache.{ Cache as CCache, Caffeine, Weigher }
import java.io.File
import java.nio.file.{ Files, NoSuchFileException, Path as NioPath }
import java.nio.file.attribute.BasicFileAttributes
import java.util.Optional
import Keys.{ organization, thisProject, autoGeneratedProject, publish, publishLocal, skip }
import Def.Setting
// import sbt.ProjectExtra.apply
import sbt.io.Hash
import sbt.internal.util.{ Attributed, StringAttributeMap }
import sbt.internal.inc.{ FileAnalysisStore, MixedAnalyzingCompiler, ReflectUtilities }
import sbt.util.CacheImplicits.given
import scala.jdk.OptionConverters.*
import xsbti.{ FileConverter, VirtualFileRef }
import xsbti.compile.{ AnalysisContents, AnalysisStore, CompileAnalysis }

trait BuildDef {
  def projectDefinitions(@deprecated("unused", "") baseDirectory: File): Seq[Project] = projects
  def projects: Seq[Project] =
    CompositeProject.expand(ReflectUtilities.allValsC(this, classOf[CompositeProject]).values.toSeq)
  // TODO: Should we grab the build core settings here or in a plugin?
  def settings: Seq[Setting[?]] = Defaults.buildCore
  def buildLoaders: Seq[BuildLoader.Components] = Nil

  /**
   * Explicitly defines the root project.
   * If None, the root project is the first project in the build's root directory or just the first project if none are in the root directory.
   */
  def rootProject: Option[Project] = None
}

private[sbt] object BuildDef:
  val defaultEmpty: BuildDef = new BuildDef { override def projects = Nil }

  val default: BuildDef = new BuildDef {
    override def projectDefinitions(base: File) = defaultProject(defaultID(base), base) :: Nil
  }

  def defaultAggregated(id: String, aggregate: Seq[ProjectRef]): BuildDef = new BuildDef {
    override def projectDefinitions(base: File): Seq[Project] =
      defaultAggregatedProject(id, base, aggregate) :: Nil
  }

  def defaultID(base: File, prefix: String = "default"): String =
    prefix + "-" + Hash.trimHashString(base.getAbsolutePath, 6)

  def defaultProject(id: String, base: File): Project =
    Project(id, base).settings(defaultProjectSettings)

  def defaultAggregatedProject(id: String, base: File, agg: Seq[ProjectRef]): Project =
    defaultProject(id, base).aggregate(agg*)

  private[sbt] def generatedRootSkipPublish(
      id: String,
      base: File,
  ): Project =
    Project
      .mkGeneratedRoot(id, base, Nil)
      .settings(
        defaultProjectSettings,
        publish / skip := true,
        publishLocal / skip := true,
      )

  private[sbt] def defaultProjectSettings: Seq[Setting[?]] = Seq(
    // TODO - Can we move this somewhere else?  ordering of settings is causing this to get borked.
    // if the user has overridden the name, use the normal organization that is derived from the name.
    organization := {
      def isDefault(o: String) = thisProject.value.id == o
      organization.?.value match {
        case Some(o) if !isDefault(o) => o
        case _                        => "default"
      }
    },
    autoGeneratedProject := true,
  )

  def analyzed(
      in: Seq[Attributed[?]],
      converter: FileConverter
  ): Seq[xsbti.compile.CompileAnalysis] =
    in.flatMap(a => extractAnalysis(a.metadata, converter))

  private[sbt] final val localAnalysisCacheByteSize = 100 * 1024L * 1024L
  private val weigher: Weigher[String, (Option[AnalysisContents], Long, Long)] = {
    case (_, (_, _, sizeBytes)) => sizeBytes.toInt
  }
  private val inMemoryAnalysisCache: CCache[String, (Option[AnalysisContents], Long, Long)] =
    Caffeine
      .newBuilder()
      .maximumWeight(localAnalysisCacheByteSize)
      .weigher(weigher)
      .build()
  private def getOrElseUpdate(ref: VirtualFileRef, lastModified: Long, sizeBytes: Long)(
      value: => Option[AnalysisContents]
  ): Option[AnalysisContents] =
    Option(inMemoryAnalysisCache.getIfPresent(ref.id())) match
      case Some((v, mod, i)) if lastModified == mod && sizeBytes == i => v
      case _                                                          =>
        val v = value
        inMemoryAnalysisCache.put(ref.id(), (v, lastModified, sizeBytes))
        v

  private[sbt] def extractAnalysis(
      metadata: StringAttributeMap,
      converter: FileConverter
  ): Option[CompileAnalysis] =
    import sbt.OptionSyntax.*
    def asBinary(file: File) = FileAnalysisStore.binary(file).get.asScala
    def asText(file: File) = FileAnalysisStore.text(file).get.asScala
    def fallback(file: File) = asBinary(file).orElse(asText(file))
    def getContents(ref: VirtualFileRef): Option[AnalysisContents] =
      val path = converter.toPath(ref)
      val file = path.toFile()
      try
        val attrs = Files.readAttributes(path, classOf[BasicFileAttributes])
        if attrs.isDirectory then fallback(file)
        else
          val lastModified = attrs.lastModifiedTime().toMillis()
          val sizeBytes = attrs.size()
          getOrElseUpdate(ref, lastModified, sizeBytes)(fallback(file))
      catch case _: NoSuchFileException => fallback(file)
    for
      ref <- metadata.get(Keys.analysis)
      content <- getContents(VirtualFileRef.of(ref))
    yield content.getAnalysis

  private[sbt] def cachedAnalysisStore(path: NioPath, converter: FileConverter): AnalysisStore =
    CachedAnalysisStore(path, converter)

  private class CachedAnalysisStore(path: NioPath, converter: FileConverter) extends AnalysisStore:
    private val underlying = MixedAnalyzingCompiler.staticCachedStore(
      analysisFile = path,
      useTextAnalysis = false,
      cacheLast = false,
    )
    override def get: Optional[AnalysisContents] =
      val ref: VirtualFileRef = converter.toVirtualFile(path)
      try
        val attrs = Files.readAttributes(path, classOf[BasicFileAttributes])
        if attrs.isDirectory then underlying.get
        else
          val lastModified = attrs.lastModifiedTime().toMillis()
          val sizeBytes = attrs.size()
          getOrElseUpdate(ref, lastModified, sizeBytes)(underlying.get.toScala).toJava
      catch case _: NoSuchFileException => underlying.get

    override def unsafeGet: AnalysisContents = get.toScala.get

    override def set(contents: AnalysisContents): Unit =
      val ref: VirtualFileRef = converter.toVirtualFile(path)
      inMemoryAnalysisCache.invalidate(ref.id())
      underlying.set(contents)

  end CachedAnalysisStore
end BuildDef
