// file      : libbuild2/version-snapshot-git.cxx -*- C++ -*-
// license   : MIT; see accompanying LICENSE file

#include <ctime> // time_t

#include <libbutl/sha1.hxx>

#include <libbuild2/version-snapshot.hxx>

using namespace std;
using namespace butl;

namespace build2
{
  // We have to run git twice to extract the information we need and doing it
  // repetitively is quite expensive, especially for larger repositories. So,
  // unless the committed version is requested, we cache it, which helps
  // multi-package repositories.
  //
  static global_cache<version_snapshot, dir_path> cache;

  version_snapshot
  extract_version_snapshot_git (context& ctx,
                                dir_path rep_root,
                                bool committed_version)
  {
    if (!committed_version)
    {
      if (const version_snapshot* r = cache.find (rep_root))
        return *r;
    }

    version_snapshot r;
    const char* d (rep_root.string ().c_str ());

    // On startup git prepends the PATH environment variable value with the
    // computed directory path where its sub-programs are supposedly located
    // (--exec-path option, GIT_EXEC_PATH environment variable, etc; see
    // cmd_main() in git's git.c for details).
    //
    // Then, when git needs to run itself or one of its components as a child
    // process, it resolves the full executable path searching in directories
    // listed in PATH (see locate_in_PATH() in git's run-command.c for
    // details).
    //
    // On Windows we install git and its components into a place where it is
    // not expected to be, which results in the wrong path in PATH as set by
    // git (for example, c:/build2/libexec/git-core) which in turn may lead to
    // running some other git that appear in the PATH variable. To prevent
    // this we pass the git's exec directory via the --exec-path option
    // explicitly.
    //
    // Note also that git has quite a few GIT_* environment variables and
    // stray values for some of them could break our commands. So it may seem
    // like a good idea to unset them. But on the other hand, they may be
    // there for a reason: after all, we are operating on user's projects and
    // user's environment may be setup to handle them.
    //
    path p ("git");
    process_path pp (run_search (p, true /* init */));

#ifdef _WIN32
    string ep ("--exec-path=" + pp.effect.directory ().string ());
#endif

    size_t args_i (3); // First reserved.
    const char* args[] {
      pp.recall_string (),
#ifdef _WIN32
        (++args_i, ep.c_str ()),
#endif
        "-C",
        d,
        nullptr, nullptr, nullptr, // Reserve.
        nullptr};

    // First check whether the working directory is clean, unless the
    // committed version is requested. There doesn't seem to be a way to do
    // everything in a single invocation (the porcelain v2 gives us the commit
    // id but not timestamp).
    //
    if (!committed_version)
    {
      // If git status --porcelain returns anything, then the working
      // directory is not clean.
      //
      args[args_i    ] = "status";
      args[args_i + 1] = "--porcelain";
      args[args_i + 2] = nullptr;

      // @@ PERF: redo with custom stream reading code (then could also get
      //    rid of context).
      //
      r.committed = run<string> (
        ctx,
        3 /* verbosity */,
        pp,
        args,
        [](string& s, bool) {return move (s);}).empty ();
    }
    else
    {
      // Note that if this is a new repository without any commits, we will
      // reset this flag later (see below).
      //
      r.committed = true;
    }

    // Now extract the commit id and date. One might think that would be
    // easy... Commit id is a SHA1 hash of the commit object. And commit
    // object looks like this:
    //
    // commit <len>\0
    // <data>
    //
    // Where <len> is the size of <data> and <data> is the output of:
    //
    // git cat-file commit HEAD
    //
    // There is also one annoying special case: new repository without any
    // commits. In this case the above command will fail (with diagnostics and
    // non-zero exit code) because there is no HEAD. Of course, it can also
    // fail for other reason (like broken repository) which would be hard to
    // distinguish. Note, however, that normally we just ran git status and it
    // would have most likely failed if this were the case. So here we
    // (reluctantly) assume that the only reason git cat-file fails is if
    // there is no HEAD (that we equal with the "new repository" condition
    // which is, strictly speaking, might not be the case either). So we
    // suppress any diagnostics, and handle non-zero exit code (and so no
    // diagnostics buffering is needed, plus we are in the load phase).
    //
    string data;

    args[args_i    ] = "cat-file";
    args[args_i + 1] = "commit";
    args[args_i + 2] = "HEAD";
    args[args_i + 3] = nullptr;

    process pr (run_start (3       /* verbosity */,
                           pp,
                           args,
                           0       /* stdin  */,
                           -1      /* stdout */,
                           1       /* stderr (to stdout) */));

    string l;
    try
    {
      ifdstream is (move (pr.in_ofd), ifdstream::badbit);

      while (!eof (getline (is, l)))
      {
        data += l;
        data += '\n'; // We assume there is always a newline.

        if (r.sn == 0 && l.compare (0, 10, "committer ") == 0)
        try
        {
          // The line format is:
          //
          // committer <noise> <timestamp> <timezone>
          //
          // For example:
          //
          // committer John Doe <john@example.org> 1493117819 +0200
          //
          // The timestamp is in seconds since UNIX epoch. The timezone
          // appears to be always numeric (+0000 for UTC). Note that timestamp
          // appears to be already in UTC with timezone being just for
          // information it seems.
          //
          size_t p1 (l.rfind (' ')); // Can't be npos.

          size_t p2 (l.rfind (' ', p1 - 1));
          if (p2 == string::npos)
            throw invalid_argument ("missing timestamp");

          string ts (l, p2 + 1, p1 - p2 - 1);
          time_t t (static_cast<time_t> (stoull (ts)));

#if 0
          string tz (l, p1 + 1);

          if (tz.size () != 5)
            throw invalid_argument ("invalid timezone");

          unsigned long h (stoul (string (tz, 1, 2)));
          unsigned long m (stoul (string (tz, 3, 2)));
          unsigned long s (h * 3600 + m * 60);

          // The timezone indicates where the timestamp was generated so to
          // convert to UTC we need to invert the sign.
          //
          switch (tz[0])
          {
          case '+': t -= s; break;
          case '-': t += s; break;
          default: throw invalid_argument ("invalid timezone sign");
          }
#endif
          // Represent as YYYYMMDDhhmmss.
          //
          r.sn = stoull (to_string (system_clock::from_time_t (t),
                                    "%Y%m%d%H%M%S",
                                    false /* special */,
                                    false /* local (already in UTC) */));
        }
        catch (const invalid_argument& e)
        {
          fail << "unable to extract git commit date from '" << l << "': "
               << e;
        }
      }

      is.close ();
    }
    catch (const io_error&)
    {
      // Presumably the child process failed. Let run_finish() deal with that.
    }

    if (run_finish_code (args, pr, l, 2 /* verbosity */))
    {
      if (r.sn == 0)
        fail << "unable to extract git commit id/date for " << rep_root;

      if (r.committed)
      {
        sha1 cs;
        cs.append ("commit " + to_string (data.size ())); // Includes '\0'.
        cs.append (data.c_str (), data.size ());
        r.id.assign (cs.string (), 12); // 12-char abbreviated commit id.
      }
      else
        r.sn++; // Add a second.
    }
    else
    {
      // Presumably new repository without HEAD. Return uncommitted snapshot
      // with UNIX epoch as timestamp.
      //
      r.sn = 19700101000000ULL;
      r.committed = false;
    }

    return !committed_version
           ? cache.insert (move (rep_root), move (r))
           : move (r);
  }
}
