/*
 * This file is part of git-as-svn. It is subject to the license terms
 * in the LICENSE file found in the top-level directory of this distribution
 * and at http://www.gnu.org/licenses/gpl-2.0.html. No part of git-as-svn,
 * including this file, may be copied, modified, propagated, or distributed
 * except according to the terms contained in the LICENSE file.
 */
package svnserver.ext.gitlab.mapping;

import com.google.common.hash.Hashing;
import org.eclipse.jgit.util.StringUtils;
import org.gitlab.api.GitlabAPI;
import org.gitlab.api.GitlabAPIException;
import org.gitlab.api.models.GitlabProject;
import org.gitlab.api.models.GitlabSystemHook;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.tmatesoft.svn.core.SVNException;
import svnserver.Loggers;
import svnserver.StringHelper;
import svnserver.config.ConfigHelper;
import svnserver.context.LocalContext;
import svnserver.context.SharedContext;
import svnserver.ext.gitlab.config.GitLabContext;
import svnserver.ext.web.server.WebServer;
import svnserver.repository.RepositoryMapping;
import svnserver.repository.VcsAccess;
import svnserver.repository.git.GitBranch;
import svnserver.repository.git.GitRepository;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.Reader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.NavigableMap;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.stream.Collectors;

/**
 * Simple repository mapping by predefined list.
 *
 * @author Artem V. Navrotskiy <[email protected]>
 */
final class GitLabMapping implements RepositoryMapping<GitLabProject> {
  @NotNull
  private static final String tagPrefix = "git-as-svn:";
  @NotNull
  private static final Logger log = Loggers.gitlab;
  @NotNull
  private static final String HASHED_PATH = "@hashed";
  @NotNull
  private final NavigableMap<String, GitLabProject> mapping = new ConcurrentSkipListMap<>();
  @NotNull
  private final SharedContext context;
  @NotNull
  private final GitLabMappingConfig config;
  private final GitLabContext gitLabContext;

  GitLabMapping(@NotNull SharedContext context, @NotNull GitLabMappingConfig config, @NotNull GitLabContext gitLabContext) {
    this.context = context;
    this.config = config;
    this.gitLabContext = gitLabContext;
  }

  @NotNull
  public NavigableMap<String, GitLabProject> getMapping() {
    return mapping;
  }

  @Nullable
  GitLabProject updateRepository(@NotNull GitlabProject project) throws IOException {
    final Set<String> branches = getBranchesToExpose(project);
    if (branches.isEmpty()) {
      removeRepository(project.getId(), project.getPathWithNamespace());
      return null;
    }

    final String projectKey = StringHelper.normalizeDir(project.getPathWithNamespace());
    final GitLabProject oldProject = mapping.get(projectKey);

    if (oldProject != null && oldProject.getProjectId() == project.getId()) {
      final Set<String> oldBranches = oldProject.getBranches().values().stream().map(GitBranch::getShortBranchName).collect(Collectors.toSet());
      if (oldBranches.equals(branches))
        // Old project is good enough already
        return oldProject;
    }

    // TODO: do not drop entire repo here, instead only apply diff - add missing branches and remove unneeded
    removeRepository(project.getId(), project.getPathWithNamespace());

    final Path basePath = ConfigHelper.joinPath(context.getBasePath(), config.getPath());
    final String sha256 = Hashing.sha256().hashString(project.getId().toString(), Charset.defaultCharset()).toString();
    Path repoPath = basePath.resolve(HASHED_PATH).resolve(sha256.substring(0, 2)).resolve(sha256.substring(2, 4)).resolve(sha256 + ".git");
    if (!Files.exists(repoPath))
      repoPath = ConfigHelper.joinPath(basePath, project.getPathWithNamespace() + ".git");
    final LocalContext local = new LocalContext(context, project.getPathWithNamespace());
    local.add(VcsAccess.class, new GitLabAccess(local, config, project.getId()));
    final GitRepository repository = config.getTemplate().create(local, repoPath, branches);
    final GitLabProject newProject = new GitLabProject(local, repository, project.getId());
    if (mapping.compute(projectKey, (key, value) -> value != null && value.getProjectId() == project.getId() ? value : newProject) == newProject) {
      return newProject;
    }
    return null;
  }

  @NotNull
  private static Set<String> getBranchesToExpose(@NotNull GitlabProject project) {
    final Set<String> result = new TreeSet<>();
    for (String tag : project.getTagList()) {
      if (!tag.startsWith(tagPrefix))
        continue;

      final String branch = tag.substring(tagPrefix.length());
      if (branch.isEmpty())
        continue;

      result.add(branch);
    }

    return result;
  }

  private void removeRepository(int projectId, @NotNull String projectName) {
    final String projectKey = StringHelper.normalizeDir(projectName);
    final GitLabProject project = mapping.get(projectKey);
    if (project != null && project.getProjectId() == projectId) {
      if (mapping.remove(projectKey, project)) {
        project.close();
      }
    }
  }

  @Override
  public void ready(@NotNull SharedContext context) throws IOException {
    final GitlabAPI api = gitLabContext.connect();

    // Web hook for repository list update.
    final WebServer webServer = context.sure(WebServer.class);
    final URL hookUrl = webServer.toUrl(gitLabContext.getHookPath());
    final String path = hookUrl.getPath();
    webServer.addServlet(StringUtils.isEmptyOrNull(path) ? "/" : path, new GitLabHookServlet());

    try {
      if (!isHookInstalled(api, hookUrl.toString())) {
        api.addSystemHook(hookUrl.toString());
      }
    } catch (GitlabAPIException e) {
      if (e.getResponseCode() == HttpURLConnection.HTTP_FORBIDDEN) {
        log.warn("Unable to install gitlab hook {}: {}", hookUrl, e.getMessage());
      } else {
        throw e;
      }
    }

  }

  private boolean isHookInstalled(@NotNull GitlabAPI api, @NotNull String hookUrl) throws IOException {
    final List<GitlabSystemHook> hooks = api.getSystemHooks();
    for (GitlabSystemHook hook : hooks) {
      if (hook.getUrl().equals(hookUrl)) {
        return true;
      }
    }
    return false;
  }

  private class GitLabHookServlet extends HttpServlet {

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
      log.info("GitLab system hook fire ...");
      final GitLabHookEvent event = parseEvent(req);
      final String msg = "Can't parse event data";
      if (event == null || event.getEventName() == null) {
        log.warn(msg);
        resp.sendError(HttpServletResponse.SC_BAD_REQUEST, msg);
        return;
      }
      try {
        log.debug(event.getEventName() + " event happened, process ...");
        switch (event.getEventName()) {
          case "project_create":
          case "project_update":
          case "project_rename":
          case "project_transfer":
            if (event.getProjectId() == null || event.getPathWithNamespace() == null) {
              log.warn(msg);
              resp.sendError(HttpServletResponse.SC_BAD_REQUEST, msg);
              return;
            }
            final GitlabAPI api = gitLabContext.connect();
            final GitLabProject project = updateRepository(api.getProject(event.getProjectId()));
            if (project != null) {
              log.info(event.getEventName() + " event happened, init project revisions ...");
              project.initRevisions();
            } else {
              log.warn(event.getEventName() + " event happened, but can not found project!");
            }
            return;
          case "project_destroy":
            if (event.getProjectId() == null || event.getPathWithNamespace() == null) {
              resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Can't parse event data");
              return;
            }
            removeRepository(event.getProjectId(), event.getPathWithNamespace());
            break;
          default:
            // Ignore hook.
            log.info(event.getEventName() + " event not process, ignore this hook event.");
            return;
        }
        super.doPost(req, resp);
      } catch (FileNotFoundException inored) {
        log.warn("Event repository not exists: " + event.getProjectId());
      } catch (SVNException e) {
        log.error("Event processing error: " + event.getEventName(), e);
        resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage());
      }
    }

    @Nullable
    private GitLabHookEvent parseEvent(@NotNull HttpServletRequest req) {
      try (final Reader reader = req.getReader()) {
        return GitLabHookEvent.parseEvent(reader);
      } catch (IOException e) {
        log.warn("Can't read hook data", e);
        return null;
      }
    }
  }
}