/*
   Copyright 2010 Aaron J. Radke

   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.
*/
// vim: set ts=3 sw=3 et:

package cc.drx

object Git{
  private lazy val gitDirFiles:List[String] = "HEAD config objects refs".split(' ').toList

  private def check(dir:File)(implicit ec:ExecutionContext):Option[Git] = if(gitDirFiles forall {f => (dir/f).exists}) Some(new Git(dir)) else None

  def find(f:File)(implicit ec:ExecutionContext):Option[Git] = check(f) || check(f/".git") || check(f companion "git") || check(f/"../.git") || check(f/"../../.git")
  def apply(f:File)(implicit ec:ExecutionContext):Git = find(f.canon) | new Git(f)
  def apply()(implicit ec:ExecutionContext):Git = Git(File.cwd)

  //TODO add clone capability and shallow clones
  /*
  def clone(f:File) = ???
  def clone(url:URL) = ???
  */

  //TODO use the following classes to make reprts and logs cleaner
  /*
  case class Rev(sha1:String)
  */
   // def main(args:Array[String]):Unit = {
   // }
}

class Git private(val gitDir:File)(implicit ec:ExecutionContext){
  override def toString = "Git("+gitDir.path+")"


  //--type aliases
  private type Result[A] = Future[List[A]]
  // private val emptyResult = Future.successful(List.empty[String])

  //TODO make this private again
  private val BareTruePat = """(\s*)(bare\s*=\s*true\s*)""".r.ignoreCase
  private val BareFalsePat = """(\s*)(bare\s*=\s*false\s*)""".r.ignoreCase
  //--helper vals
  val config = gitDir/"config"
  private lazy val configLines = config.in.lines.toList

  //bare features
  lazy val isBare:Boolean = configLines exists (BareTruePat matchesFully _)

  def syncBareTo(dst:File,dryRun:Boolean = false):Git = {
    Sync(gitDir, dst, dryRun)
    Git(dst).makeBare
  }
  def makeBare:Git = {
    def explicitlyNotBare = configLines exists (BareFalsePat matchesFully _)

    if(isBare) println(Orange("Warn, this repo is already bare `bare = true`"))
    else if(!explicitlyNotBare) println(Red("Error, this can only make repo's bare that explictly set `bare = false` in .git/config"))
    else {
      config.out.print{f =>
        configLines.map{
          case BareFalsePat(space,_) => space + "bare = true"
          case line => line
        } foreach f.println
      }
    }
    Git(gitDir) //make a new one so the config is scanned again
  }

  private val gitCmd = if(isBare) List("git","--git-dir", gitDir.path)
                       else       List("git","--git-dir", gitDir.path, "--work-tree", workTree.path) //TODO add --work-tree in order to be entirely explicit
  //--helper defs
  def git(cmd:String, args:String*):Shell = {
    val shell = Shell(gitCmd ++ cmd.split("\\s+") ++ args)
    // println(shell) //debug executed shell command
    shell
  }

  //--tests

  /**check if a git repo has modified files */
  def isDirty:Future[Boolean] = if(isBare) Future.successful(false)
        else git("diff-index --quiet HEAD --").exitCode.map{_ != 0}
   //exit code for git diff-index (0 == no differences; 1 == differences
   // http://stackoverflow.com/a/2659808/622016

  //--commands
  def autoCommit(msg:String="auto commit"):Result[String] =
    for( dirt <- isDirty; if(dirt); lines <- git("commit -a -m",msg).lines) yield lines

  def commit(msg:String):Result[String] = git("commit -m", msg).lines
  def push(args:String*):Result[String] = git("push", args:_*).lines
  def pull(args:String*):Result[String] = git("pull", args:_*).lines
  def merge(args:String*):Result[String] = git("merge", args:_*).lines
  def fetch(args:String*):Result[String] = {
    for( fs <- git("fetch", args:_*).lines) yield fs
  }
  def prune(remote:String="origin"):Result[String] = git("remote prune", remote).lines
  // def merge(args:String*):Result[String] = git("merge", args:_*).lines
  def clean:Result[File] = git("clean -fd").linesAs{line =>
    File( line.replace("Removing ","").replace("Would remove ","")) //get the actual file name
  }

  def remotes:Result[Remote] = git("remote -v").linesAs{line =>
    val cs = line.split("""\s+""").toList
    Remote(cs(0), cs(1))
  }

  //--the base dir if a working dir otherwise the .git dir if bare
  //--work-tree
  lazy val dir:File = workTree
  lazy val workTree = if(isBare) gitDir else (gitDir/"..").canon

  //--drx-ified commands
  // FIXME remove private def as[A](lineF:String => A)(res:Result[String]):Result[File] = res.map{_ map lineF}
  // FIXME remove debug private def asFiles(res:Result[String]):Result[File] = as(File.apply) //res.map{_.map File.apply}
  // def branch:Try[String] = for(lines <- git("rev-parse --abbrev-ref HEAD"); head <- lines.headOption) yield head //http://stackoverflow.com/a/12142066/622016
  // def branch:Future[String] = git("rev-parse --abbrev-ref HEAD") map {_.headOption | "<error>"} //http://stackoverflow.com/a/12142066/622016
  def branch:Future[String] = for(lines <- git("rev-parse --abbrev-ref HEAD").lines; if lines.nonEmpty) yield lines.head //http://stackoverflow.com/a/12142066/622016
  def staged:Result[File] = git("diff --name-only --cached").linesAs(File.apply)
  def diff:Result[File] = git("diff --name-only").linesAs(File.apply)
  def patch:Future[String] = git("diff").lines map {_ mkString "\n"}
  def show(date:Date,file:File):Result[String] = git("show", "HEAD@{"+date.isoDay+"}:"+file.path).lines
  // def status(args:String*):Result = git("status", args:_*)
  // def log(args:String*):Result = git("log", args:_*)
  def log(format:String):Result[String] = git("log",s"--format=$format","--decorate=full").lines //TODO maybe quotes are needed aounrd the format for the windows version

  /* dangerous commands but gets it back to a non-dirty state https://stackoverflow.com/a/31321921/622016 */
  def resetAndClean():Result[String] = git("reset --hard").lines.flatMap{_ => git("clean -dfx").lines } //TODO pipe all results through the monad results

  case class Remote(name:String, url:String)
  //TODO make the sha1 a sha1 type rather than a string
  case class Ref(path:String) {
    lazy val short = path strip "refs/tags/ refs/heads/ refs/remotes/".split(" ").toList
    lazy val isTag = path startsWith "refs/tags/"
    lazy val isRemote = path startsWith "refs/remotes/"
    lazy val isLocal = path startsWith "refs/heads/"
    lazy val isVersion = isTag && (
      short.startsWith("v")  || VersionPat.matchesFully(short)
    )
    lazy val isKrypton = isTag && KryptonPat.matchesFully(short)

    def nice = (if(isVersion) Red else if(isTag) Orange else if(isLocal) Green else Blue) ansi short
    private lazy val VersionPat = """v?[\d\.]+""".r
    private lazy val KryptonPat = """@[0-9a-zA-Z]+""".r
  }
  case class Commit(id:String, author:String, email:String, date:Date, subject:String, body:String, refs:List[Ref]){
    lazy val msg:String = if(body.trim.nonEmpty) subject + "\n" + body else subject
    //TODO add link to a parent commit???
    lazy val lastName = {
      val names = author.split("\\s+")
      if(author contains ",") names.headOption.getOrElse(author)  //last name is listed first when a comma appears
      else names.lastOption.getOrElse(author)                     //last name is last in normal notation
    }
    lazy val shortId = id.take(10)
    private def niceRefs = if(refs.isEmpty) "" else refs.map{_.nice}.mkString("(",", ",")")
    def nice:String = f"$shortId ${lastName take 8}%8s ${date.toRelativeString}%10s ${niceRefs} ${msg truncate 80}"
  }
  2017-07-30("use commmits instead","di4")
  def log:Result[Commit] = commits

  def head:Future[Commit] = logCommits("HEAD","-1").map{_.head}
  def commits:Result[Commit] = logCommits()
  def commits(lastN:Int):Result[Commit] = logCommits(s"-$lastN") //warning the "-100" count limit needs to happen before --no-walk
  val tags:Result[(Ref,Commit)] = logCommitRefs("--no-walk", "--tags")
  val refs:Result[(Ref,Commit)] = logCommitRefs()
  def versions:Result[(Version,Commit)] = tags.map{ts =>
    ts.collect{case (ref,c) if ref.isVersion =>
      val r = ref.short
      val ver = if(r.startsWith("v") || r.startsWith("@")) r.drop(1) else r
      Version(ver) -> c
    }.sortBy{_._1}
  }//filter tags with a tag filter on version type

  private def logCommitRefs(optFlags:String*):Result[(Ref,Commit)] =
    logCommits(optFlags:_*).map{cs =>  //map the future commits
      for(c <- cs; r <- c.refs) yield r -> c  //unfold each ref (some commits may have multiple refs)
    }

  //http://blog.lost-theory.org/post/how-to-parse-git-log-output/
  private def logCommits(optFlags:String*):Result[Commit] = {
    val fieldSep = Data.Ansi.Field
    val recordSep = Data.Ansi.Record
    //name:an email:ae date:at msg:s d:ref list
    val format = "H an ae at s b d".split(' ').map("%"+_).mkString(fieldSep) + recordSep
    val logCmd = git("log",s"--format=$format","--decorate=full") ++ optFlags //TODO maybe quotes are needed aounrd the format for the windows version
    val logResult = logCmd.lines
    logResult.map{ lines =>
      val records = lines.mkString("").split(recordSep)
      for(record <- records.toList; f = record split fieldSep/*; if f.size >= 6 */) yield {
        val (body,refs) = {
          val noRefs = List.empty[Git.this.Ref]
          f.size match {
            case 5 =>  ("",   noRefs)
            case 6 =>  (f(5), noRefs)
            case 7 =>  (f(5), {
              val stripValues = "HEAD ->,(,),tag:".split(',')
              f(6).strip(stripValues).trim
                 .split("""\s*,\s*""").toList
                 .map(Ref.apply)
              }
            )
          }
        }
        // debug println(s"git f.size: ${f.size} ${record truncate 200}")
        Commit(f(0), f(1), f(2), Date.fromSec(f(3).toLong), f(4), body, refs)
      }
    }
  }
  def krypton:Future[Version] = for(h <- head; ts <- tags) yield {
      h.refs.find(_.isKrypton) match {
        //--if the current head is tagged as a krypton version use this as the version
        case Some(headRef) => Version(headRef.short drop 1)
        case None =>
          ts.map{_._1}.filter(_.isKrypton).map{_.short}.sorted.lastOption match {
            //--find the latest tagged krypton version and increment the version based on the current date
            case Some(short) => Version.kryptonNext(Version(short drop 1), Date.now)
            //--the first release just uses the top level krypton date
            case None => Version(Date.now.krypton take 1) //no version has been defined yet
          }
     }
  }

  def undo(file:File):Result[String] = git("checkout","--",file.path).lines

  def sync(remote:String="origin", branch:String="master"):Result[String] =
    for(_ <- autoCommit(); _ <- pull(remote,branch);  res <- push(remote,branch) ) yield res

}