//
// Copyright (c) 2006, Brian Frank and Andy Frank
// Licensed under the Academic Free License version 3.0
//
// History:
//   3 Nov 06  Brian Frank  Creation
//

using compiler

**
** BuildScript is the base class for build scripts - it manages
** the command line interface, argument parsing, environment, and
** target execution.
**
** See `docTools::Build` for details.
**
abstract class BuildScript
{

//////////////////////////////////////////////////////////////////////////
// Env
//////////////////////////////////////////////////////////////////////////

  **
  ** Log used for error reporting and tracing
  **
  BuildLog log := BuildLog()

  **
  ** The source file of this script
  **
  const File scriptFile := File(typeof->sourceFile.toStr.toUri).normalize

  **
  ** The directory containing the this script
  **
  const File scriptDir := scriptFile.parent

  **
  ** Home directory of development installation.  By default this
  ** value is initialized by 'devHome' config prop, otherwise
  ** `sys::Env.homeDir` is used.
  **
  const File devHomeDir := configDir("devHome", Env.cur.homeDir)

//////////////////////////////////////////////////////////////////////////
// Targets
//////////////////////////////////////////////////////////////////////////

  **
  ** Lookup a target by name.  If not found and checked is
  ** false return null, otherwise throw an exception.
  **
  TargetMethod? target(Str name, Bool checked := true)
  {
    t := targets.find |t| { t.name == name }
    if (t != null) return t
    if (checked) throw Err("Target not found '$name' in $scriptFile")
    return null
  }

  **
  ** Get the list of published targets for this script.  The
  ** first target should be the default.  The list of targets
  ** is defined by all the methods with the `Target` facet.
  **
  virtual once TargetMethod[] targets()
  {
    acc := TargetMethod[,]
    typeof.methods.each |m|
    {
      if (!m.hasFacet(Target#)) return
      acc.add(TargetMethod(this, m))
    }
    return acc
  }

//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////

  **
  ** Get a config property using the following rules:
  **   1. `sys::Env.vars` with 'FAN_BUILD_$name.upper'
  **   2. `sys::Env.config` for build pod
  **   3. fallback to 'def' parameter
  **
  Str? config(Str name, Str? def := null)
  {
    Env.cur.vars["FAN_BUILD_$name.upper"] ?:
    Env.cur.config(BuildScript#.pod, name, def)
  }

  **
  ** Get a `config` prop which identifies a directory.
  ** If the prop isn't configured or doesn't map to a
  ** valid directory, then return def.
  **
  File? configDir(Str name, File? def := null)
  {
    c := config(name)
    if (c == null) return def
    try
    {
      f := File(c.toUri)
      if (!f.exists || !f.isDir) throw Err()
      return f
    }
    catch (Err e) log.err("Invalid configDir URI for '$name': $c\n  $e")
    return def
  }

  **
  ** Get the key/value map of config props which are loaded
  ** from "etc/build/config.props".
  **
  once Str:Str configs()
  {
    Env.cur.props(BuildScript#.pod, `config.props`, 10sec).ro
  }

  **
  ** Apply a set of macro substitutions to the given pattern.
  ** Substitution keys are indicated in the pattern using "@{key}"
  ** and replaced by definition in macros map.  If a substitution
  ** key is undefined then raise an exception.  The `configs`
  ** method is used for default macro key/value map.
  **
  Str applyMacros(Str pattern, Str:Str macros := this.configs)
  {
    // short circuit if we don't have @
    at := pattern.index("@")
    if (at == null) return pattern

    // rebuild string
    s := pattern
    for (i:=0; i<s.size-3; ++i)
    {
      if (s[i] == '@' && s[i+1] == '{')
      {
        c := s.index("}", i+2)
        if (c == null) throw Err("Unclosed macro: $pattern")
        key := s[i+2..<c]
        val := macros[key]
        if (val == null) throw Err("Undefined macro key: $key")
        s = s[0..<i] + val + s[c+1..-1]
      }
    }
    return s
  }

  **
  ** Resolve a set of URIs to files relative to scriptDir.
  **
  internal File[] resolveFiles(Uri[] uris)
  {
    uris.map |uri->File|
    {
      f := scriptDir + uri
      if (!f.exists || f.isDir) throw fatal("Invalid file: $uri")
      return f
    }
  }

  **
  ** Resolve a set of URIs to directories relative to scriptDir.
  **
  internal File[] resolveDirs(Uri[] uris)
  {
    uris.map |uri->File|
    {
      f := scriptDir + uri
      if (!f.exists || !f.isDir) throw fatal("Invalid dir: $uri")
      return f
    }
  }

  **
  ** Resolve a set of URIs to files/dirs relative to scriptDir.
  **
  internal File[] resolveFilesOrDirs(Uri[] uris)
  {
    uris.map |uri->File|
    {
      f := scriptDir + uri
      if (!f.exists) throw fatal("Invalid file: $uri")
      return f
    }
  }

  **
  ** Dump script environment for debug.
  **
  virtual Void dumpEnv()
  {
    log.printLine("---------------")
    log.printLine("  scriptFile:    $scriptFile")
    log.printLine("  typeof:        $typeof.base")
    log.printLine("  env.homeDir:   $Env.cur.homeDir")
    log.printLine("  env.workDir:   $Env.cur.workDir")
    log.printLine("  devHomeDir:    $devHomeDir")
    typeof.fields.each |f|
    {
      if (f.isPublic && !f.isStatic && f.parent != BuildScript#)
        log.printLine("  " + (f.name+ ":").padr(14) + " " + f.get(this))
    }
  }

  **
  ** Log an error and return a FatalBuildErr instance
  **
  FatalBuildErr fatal(Str msg, Err? err := null)
  {
    log.err(msg, err)
    return FatalBuildErr(msg, err)
  }

  **
  ** Return this script's source file path.
  **
  override Str toStr()
  {
    return typeof->sourceFile.toStr
  }

//////////////////////////////////////////////////////////////////////////
// Arguments
//////////////////////////////////////////////////////////////////////////

  **
  ** Parse the arguments passed from the command line.
  **
  private TargetMethod[]? parseArgs(Str[] args)
  {
    // check for -? or -dumpEnv
    if (args.contains("-?") || args.contains("-help")) { usage; return null }
    if (args.contains("-dumpEnv")) { dumpEnv; return null }

    success := true
    toRun := TargetMethod[,]

    // get published targetss
    published := targets
    if (published.isEmpty)
    {
      log.err("No targets available for script")
      return null
    }

    // process each argument
    for (i:=0; i<args.size; ++i)
    {
      arg := args[i]
      if (arg == "-v") { log.level = LogLevel.debug; dumpEnv }
      else if (arg.startsWith("-")) log.warn("Unknown build option $arg")
      else
      {
        // add target to our run list
        target := published.find |t| { t.name == arg }
        if (target == null)
        {
          log.err("Unknown build target '$arg'")
          success = false
        }
        else
        {
          toRun.add(target)
        }
      }
    }
    if (!success) return null

    // if no targets specified, then use the default
    if (toRun.isEmpty) toRun.add(published.first)
    return toRun
  }

  **
  ** Dump usage including all this script's published targets.
  **
  private Void usage()
  {
    log.printLine("usage: ")
    log.printLine("  build [options] <target>*")
    log.printLine("options:")
    log.printLine("  -? -help       Print usage summary")
    log.printLine("  -v             Verbose debug logging")
    log.printLine("  -dumpEnv       Debug dump of script env")
    log.printLine("targets:")
    targets.each |t, i|
    {
      n := i == 0 ? "${t.name}*" : "${t.name} "
      log.print("  ${n.justl(14)} $t.help")
      log.printLine
    }
  }

//////////////////////////////////////////////////////////////////////////
// Main
//////////////////////////////////////////////////////////////////////////

  **
  ** Run the script with the specified arguments.
  ** Return 0 on success or -1 on failure.
  **
  Int main(Str[] args := Env.cur.args)
  {
    t1 := Duration.now
    success := false
    try
    {
      targetsToRun := parseArgs(args)
      if (targetsToRun == null) return 1
      targetsToRun.each |t| { t.run }
      success = true
    }
    catch (FatalBuildErr err)
    {
      // error should have alredy been logged
    }
    catch (Err err)
    {
      log.err("Internal build error [$toStr]")
      err.trace
    }
    t2 := Duration.now

    if (success)
    {
      if (log.level <= LogLevel.info)
        log.out.printLine("BUILD SUCCESS [${(t2-t1).toMillis}ms]!")
    }
    else
    {
      log.out.printLine("BUILD FAILED [${(t2-t1).toMillis}ms]!")
    }
    return success ? 0 : -1
  }

}