// Copyright 2019 Roman Perepelitsa.
//
// This file is part of GitStatus.
//
// GitStatus is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GitStatus is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with GitStatus. If not, see <https://www.gnu.org/licenses/>.

#include "repo.h"

#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>

#include <algorithm>
#include <atomic>
#include <cstdlib>
#include <cstring>
#include <exception>
#include <iterator>
#include <memory>
#include <type_traits>
#include <utility>

#include "arena.h"
#include "check.h"
#include "check_dir_mtime.h"
#include "dir.h"
#include "git.h"
#include "print.h"
#include "scope_guard.h"
#include "stat.h"
#include "string_cmp.h"
#include "thread_pool.h"
#include "timer.h"

namespace gitstatus {

namespace {

using namespace std::string_literals;

template <class T>
T Load(const std::atomic<T>& x) {
  return x.load(std::memory_order_relaxed);
}

template <class T>
void Store(std::atomic<T>& x, T v) {
  x.store(v, std::memory_order_relaxed);
}

template <class T>
T Inc(std::atomic<T>& x, T by = 1) {
  return x.fetch_add(by, std::memory_order_relaxed);
}

template <class T>
T Dec(std::atomic<T>& x) {
  return x.fetch_sub(1, std::memory_order_relaxed);
}

template <class T>
T Exchange(std::atomic<T>& x, T v) {
  return x.exchange(v, std::memory_order_relaxed);
}

const char* DeltaStr(git_delta_t t) {
  switch (t) {
    case GIT_DELTA_UNMODIFIED: return "unmodified";
    case GIT_DELTA_ADDED: return "added";
    case GIT_DELTA_DELETED: return "deleted";
    case GIT_DELTA_MODIFIED: return "modified";
    case GIT_DELTA_RENAMED: return "renamed";
    case GIT_DELTA_COPIED: return "copied";
    case GIT_DELTA_IGNORED: return "ignored";
    case GIT_DELTA_UNTRACKED: return "untracked";
    case GIT_DELTA_TYPECHANGE: return "typechange";
    case GIT_DELTA_UNREADABLE: return "unreadable";
    case GIT_DELTA_CONFLICTED: return "conflicted";
  }
  return "unknown";
}

}  // namespace

bool Repo::Shard::Contains(Str<> str, StringView path) const {
  if (str.Lt(path, start_s)) return false;
  if (end_s.empty()) return true;
  path.len = std::min(path.len, end_s.size());
  return !str.Lt(end_s, path);
}

Repo::Repo(git_repository* repo, Limits lim) : lim_(std::move(lim)), repo_(repo), tag_db_(repo) {
  if (lim_.max_num_untracked) {
    GlobalThreadPool()->Schedule([this] {
      bool check = CheckDirMtime(git_repository_path(repo_));
      std::unique_lock<std::mutex> lock(mutex_);
      CHECK(Load(untracked_cache_) == Tribool::kUnknown);
      Store(untracked_cache_, check ? Tribool::kTrue : Tribool::kFalse);
      cv_.notify_one();
    });
  } else {
    untracked_cache_ = Tribool::kFalse;
  }
}

Repo::~Repo() {
  {
    std::unique_lock<std::mutex> lock(mutex_);
    while (untracked_cache_ == Tribool::kUnknown) cv_.wait(lock);
  }
  if (git_index_) git_index_free(git_index_);
  git_repository_free(repo_);
}

IndexStats Repo::GetIndexStats(const git_oid* head, git_config* cfg) {
  ON_SCOPE_EXIT(this, orig_lim = lim_) { lim_ = orig_lim; };
  auto Off = [&](const char* name) {
    int val;
    if (git_config_get_bool(&val, cfg, name) || val) return false;
    LOG(INFO) << "Honoring git config option: " << name << " = false";
    return true;
  };
  if (!lim_.ignore_status_show_untracked_files && Off("status.showUntrackedFiles")) {
    lim_.max_num_untracked = 0;
  }
  if (!lim_.ignore_bash_show_untracked_files && Off("bash.showUntrackedFiles")) {
    lim_.max_num_untracked = 0;
  }
  if (!lim_.ignore_bash_show_dirty_state && Off("bash.showDirtyState")) {
    lim_.max_num_staged = 0;
    lim_.max_num_unstaged = 0;
    lim_.max_num_conflicted = 0;
  }

  if (git_index_) {
    int new_index;
    VERIFY(!git_index_read_ex(git_index_, 0, &new_index)) << GitError();
    if (new_index) {
      head_ = {};
      index_.reset();
    }
  } else {
    VERIFY(!git_repository_index(&git_index_, repo_)) << GitError();
    // Query an attribute (doesn't matter which) to initialize repo's attribute
    // cache. It's a workaround for synchronization bugs (data races) in libgit2
    // that result from lazy cache initialization without synchronization.
    // Thankfully, subsequent cache reads and writes are properly synchronized.
    const char* attr;
    VERIFY(!git_attr_get(&attr, repo_, 0, "x", "x")) << GitError();
  }

  UpdateShards();
  Store(error_, false);
  Store(unstaged_, {});
  Store(untracked_, {});
  Store(unstaged_deleted_, {});

  std::vector<const char*> dirty_candidates;
  const size_t index_size = git_index_entrycount(git_index_);

  if (!lim_.max_num_staged && !lim_.max_num_conflicted) {
    head_ = {};
    Store(staged_, {});
    Store(conflicted_, {});
    Store(staged_new_, {});
    Store(staged_deleted_, {});
    Store(skip_worktree_, {});
    Store(assume_unchanged_, {});
  } else if (head) {
    if (git_oid_equal(head, &head_)) {
      LOG(INFO) << "Index and HEAD unchanged; staged = " << Load(staged_)
                << ", conflicted = " << Load(conflicted_);
    } else {
      head_ = *head;
      Store(staged_, {});
      Store(conflicted_, {});
      Store(staged_new_, {});
      Store(staged_deleted_, {});
      Store(skip_worktree_, {});
      Store(assume_unchanged_, {});
      StartStagedScan(head);
    }
  } else {
    head_ = {};
    size_t staged = 0;
    size_t skip_worktree = 0;
    size_t assume_unchanged = 0;
    for (size_t i = 0; i != index_size; ++i) {
      const git_index_entry* entry = git_index_get_byindex_no_sort(git_index_, i);
      if (!(entry->flags_extended & GIT_INDEX_ENTRY_INTENT_TO_ADD)) ++staged;
      if (entry->flags_extended & GIT_INDEX_ENTRY_SKIP_WORKTREE) ++skip_worktree;
      if (entry->flags & GIT_INDEX_ENTRY_VALID) ++assume_unchanged;
    }
    Store(staged_, staged);
    Store(conflicted_, {});
    Store(staged_new_, staged);
    Store(staged_deleted_, {});
    Store(skip_worktree_, skip_worktree);
    Store(assume_unchanged_, assume_unchanged);
  }

  if (index_size <= lim_.dirty_max_index_size &&
      (lim_.max_num_unstaged || lim_.max_num_untracked)) {
    if (!index_) index_ = std::make_unique<Index>(repo_, git_index_);
    dirty_candidates = index_->GetDirtyCandidates({.include_untracked = lim_.max_num_untracked > 0,
                                                   .untracked_cache = Load(untracked_cache_)});
    if (dirty_candidates.empty()) {
      LOG(INFO) << "Clean repo: no dirty candidates";
    } else {
      LOG(INFO) << "Found " << dirty_candidates.size() << " dirty candidate(s) spanning from "
                << Print(dirty_candidates.front()) << " to " << Print(dirty_candidates.back());
    }
    StartDirtyScan(dirty_candidates);
  }

  Wait();
  VERIFY(!Load(error_));

  size_t num_staged = std::min(Load(staged_), lim_.max_num_staged);
  size_t num_unstaged = std::min(Load(unstaged_), lim_.max_num_unstaged);
  return {.index_size = index_size,
          .num_staged = num_staged,
          .num_unstaged = num_unstaged,
          .num_conflicted = std::min(Load(conflicted_), lim_.max_num_conflicted),
          .num_untracked = std::min(Load(untracked_), lim_.max_num_untracked),
          .num_staged_new = std::min(Load(staged_new_), num_staged),
          .num_staged_deleted = std::min(Load(staged_deleted_), num_staged),
          .num_unstaged_deleted = std::min(Load(unstaged_deleted_), num_unstaged),
          .num_skip_worktree = Load(skip_worktree_),
          .num_assume_unchanged = Load(assume_unchanged_)};
}

int Repo::OnDelta(const char* type, const git_diff_delta& d, std::atomic<size_t>& c1, size_t m1,
                  const std::atomic<size_t>& c2, size_t m2) {
  auto Msg = [&]() {
    const char* status = DeltaStr(d.status);
    std::ostringstream strm;
    strm << "Found " << type << " file";
    if (strcmp(status, type)) strm << " (" << status << ")";
    strm << ": " << Print(d.new_file.path);
    return strm.str();
  };

  size_t v = Inc(c1);
  if (v) {
    LOG(DEBUG) << Msg();
  } else {
    LOG(INFO) << Msg();
  }
  if (v + 1 < m1) return GIT_DIFF_DELTA_DO_NOT_INSERT;
  if (Load(c2) < m2) return GIT_DIFF_DELTA_DO_NOT_INSERT | GIT_DIFF_DELTA_SKIP_TYPE;
  return GIT_EUSER;
}

void Repo::StartDirtyScan(const std::vector<const char*>& paths) {
  if (paths.empty()) return;

  git_diff_options opt = GIT_DIFF_OPTIONS_INIT;
  opt.payload = this;
  opt.flags = GIT_DIFF_INCLUDE_TYPECHANGE_TREES | GIT_DIFF_SKIP_BINARY_CHECK |
              GIT_DIFF_DISABLE_PATHSPEC_MATCH | GIT_DIFF_EXEMPLARS;
  if (lim_.max_num_untracked) {
    opt.flags |= GIT_DIFF_INCLUDE_UNTRACKED;
    if (lim_.recurse_untracked_dirs) opt.flags |= GIT_DIFF_RECURSE_UNTRACKED_DIRS;
  } else {
    opt.flags |= GIT_DIFF_ENABLE_FAST_UNTRACKED_DIRS;
  }
  opt.ignore_submodules = GIT_SUBMODULE_IGNORE_DIRTY;
  opt.notify_cb = +[](const git_diff* diff, const git_diff_delta* delta,
                      const char* matched_pathspec, void* payload) -> int {
    if (delta->status == GIT_DELTA_CONFLICTED) return GIT_DIFF_DELTA_DO_NOT_INSERT;
    Repo* repo = static_cast<Repo*>(payload);
    if (Load(repo->error_)) return GIT_EUSER;
    if (delta->status == GIT_DELTA_UNTRACKED) {
      return repo->OnDelta("untracked", *delta, repo->untracked_, repo->lim_.max_num_untracked,
                           repo->unstaged_, repo->lim_.max_num_unstaged);
    } else {
      if (delta->status == GIT_DELTA_DELETED) Inc(repo->unstaged_deleted_);
      return repo->OnDelta("unstaged", *delta, repo->unstaged_, repo->lim_.max_num_unstaged,
                           repo->untracked_, repo->lim_.max_num_untracked);
    }
  };

  const Str<> str(git_index_is_case_sensitive(git_index_));
  auto shard = shards_.begin();
  for (auto p = paths.begin(); p != paths.end();) {
    opt.range_start = *p;
    opt.range_end = *p;
    opt.pathspec.strings = const_cast<char**>(&*p);
    opt.pathspec.count = 1;
    while (!shard->Contains(str, StringView(*p))) ++shard;
    while (++p != paths.end() && shard->Contains(str, StringView(*p))) {
      opt.range_end = *p;
      ++opt.pathspec.count;
    }
    RunAsync([this, opt]() {
      git_diff* diff = nullptr;
      LOG(DEBUG) << "git_diff_index_to_workdir from " << Print(opt.range_start) << " to "
                 << Print(opt.range_end);
      switch (git_diff_index_to_workdir(&diff, repo_, git_index_, &opt)) {
        case 0:
          git_diff_free(diff);
          break;
        case GIT_EUSER:
          break;
        default:
          LOG(ERROR) << "git_diff_index_to_workdir: " << GitError();
          throw Exception();
      }
    });
  }
}

void Repo::StartStagedScan(const git_oid* head) {
  git_commit* commit = nullptr;
  VERIFY(!git_commit_lookup(&commit, repo_, head)) << GitError();
  ON_SCOPE_EXIT(=) { git_commit_free(commit); };
  git_tree* tree = nullptr;
  VERIFY(!git_commit_tree(&tree, commit)) << GitError();

  git_diff_options opt = GIT_DIFF_OPTIONS_INIT;
  opt.flags = GIT_DIFF_EXEMPLARS | GIT_DIFF_INCLUDE_TYPECHANGE_TREES;
  opt.payload = this;
  opt.notify_cb = +[](const git_diff* diff, const git_diff_delta* delta,
                      const char* matched_pathspec, void* payload) -> int {
    Repo* repo = static_cast<Repo*>(payload);
    if (Load(repo->error_)) return GIT_EUSER;
    if (delta->status == GIT_DELTA_CONFLICTED) {
      return repo->OnDelta("conflicted", *delta, repo->conflicted_, repo->lim_.max_num_conflicted,
                           repo->staged_, repo->lim_.max_num_staged);
    } else {
      if (delta->status == GIT_DELTA_ADDED) Inc(repo->staged_new_);
      if (delta->status == GIT_DELTA_DELETED) Inc(repo->staged_deleted_);
      return repo->OnDelta("staged", *delta, repo->staged_, repo->lim_.max_num_staged,
                           repo->conflicted_, repo->lim_.max_num_conflicted);
    }
  };

  for (const Shard& shard : shards_) {
    RunAsync([this, tree, opt, shard]() mutable {
      size_t skip_worktree = 0;
      size_t assume_unchanged = 0;
      for (size_t i = shard.start_i; i != shard.end_i; ++i) {
        const git_index_entry* entry = git_index_get_byindex_no_sort(git_index_, i);
        if (entry->flags_extended & GIT_INDEX_ENTRY_SKIP_WORKTREE) ++skip_worktree;
        if (entry->flags & GIT_INDEX_ENTRY_VALID) ++assume_unchanged;
      }
      Inc(skip_worktree_, skip_worktree);
      Inc(assume_unchanged_, assume_unchanged);
      opt.range_start = shard.start_s.c_str();
      opt.range_end = shard.end_s.c_str();
      git_diff* diff = nullptr;
      LOG(DEBUG) << "git_diff_tree_to_index from " << Print(opt.range_start) << " to "
                 << Print(opt.range_end);
      switch (git_diff_tree_to_index(&diff, repo_, tree, git_index_, &opt)) {
        case 0:
          git_diff_free(diff);
          break;
        case GIT_EUSER:
          break;
        default:
          LOG(ERROR) << "git_diff_tree_to_index: " << GitError();
          throw Exception();
      }
    });
  }
}

void Repo::UpdateShards() {
  constexpr size_t kEntriesPerShard = 512;

  const Str<> str(git_index_is_case_sensitive(git_index_));
  size_t index_size = git_index_entrycount(git_index_);
  ON_SCOPE_EXIT(&) {
    LOG(INFO) << "Splitting " << index_size << " object(s) into " << shards_.size() << " shard(s)";
  };

  if (index_size <= kEntriesPerShard || GlobalThreadPool()->num_threads() < 2) {
    shards_ = {{
      .start_s = "",
      .end_s = "",
      .start_i = 0,
      .end_i = index_size}};
    return;
  }

  size_t shards =
      std::min(index_size / kEntriesPerShard + 1, 2 * GlobalThreadPool()->num_threads());
  shards_.clear();
  shards_.reserve(shards);
  std::string last_s;
  size_t last_i = 0;

  for (size_t i = 0; i != shards - 1; ++i) {
    size_t idx = (i + 1) * index_size / shards;
    std::string split = git_index_get_byindex_no_sort(git_index_, idx)->path;
    auto pos = split.find_last_of('/');
    if (pos == std::string::npos) continue;
    split = split.substr(0, pos + 1);
    Shard shard;
    shard.end_s = split;
    --shard.end_s.back();
    if (!str.Lt(last_s, shard.end_s)) continue;
    shard.start_s = std::move(last_s);
    last_s = std::move(split);
    shard.start_i = last_i;
    shard.end_i = idx;
    last_i = idx;
    shards_.push_back(std::move(shard));
  }
  shards_.push_back({
    .start_s = std::move(last_s),
    .end_s = "",
    .start_i = last_i,
    .end_i = index_size});

  CHECK(!shards_.empty());
  CHECK(shards_.size() <= shards);
  CHECK(shards_.front().start_s.empty());
  CHECK(shards_.front().start_i == 0);
  CHECK(shards_.back().end_s.empty());
  CHECK(shards_.back().end_i == index_size);
  for (size_t i = 0; i != shards_.size(); ++i) {
    if (i) {
      const git_index_entry* entry = git_index_get_byindex_no_sort(git_index_, shards_[i].start_i);
      CHECK(!std::memcmp(shards_[i].start_s.c_str(), entry->path, shards_[i].start_s.size()));
      CHECK(str.Lt(shards_[i - 1].end_s, shards_[i].start_s));
      CHECK(shards_[i - 1].end_i == shards_[i].start_i);
    }
    if (i != shards_.size() - 1) {
      CHECK(shards_[i].start_i < shards_[i].end_i);
      CHECK(str.Lt(shards_[i].start_s, shards_[i].end_s));
    }
  }
}

void Repo::DecInflight() {
  std::unique_lock<std::mutex> lock(mutex_);
  CHECK(Load(inflight_) > 0);
  if (Dec(inflight_) == 1) cv_.notify_one();
}

void Repo::RunAsync(std::function<void()> f) {
  Inc(inflight_);
  try {
    GlobalThreadPool()->Schedule([this, f = std::move(f)] {
      try {
        ON_SCOPE_EXIT(&) { DecInflight(); };
        f();
      } catch (const Exception&) {
        if (!Load(error_)) {
          std::unique_lock<std::mutex> lock(mutex_);
          if (!Load(error_)) {
            Store(error_, true);
            cv_.notify_one();
          }
        }
      }
    });
  } catch (...) {
    DecInflight();
    throw;
  }
}

void Repo::Wait() {
  std::unique_lock<std::mutex> lock(mutex_);
  while (inflight_) cv_.wait(lock);
}

std::future<std::string> Repo::GetTagName(const git_oid* target) {
  auto* promise = new std::promise<std::string>;
  std::future<std::string> res = promise->get_future();

  GlobalThreadPool()->Schedule([=] {
    ON_SCOPE_EXIT(&) { delete promise; };
    if (!target) {
      promise->set_value("");
      return;
    }
    try {
      promise->set_value(tag_db_.TagForCommit(*target));
    } catch (const Exception&) {
      promise->set_exception(std::current_exception());
    }
  });

  return res;
}

}  // namespace gitstatus
