// Copyright (C) 2013 The Android Open Source Project
//
// 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.

package com.google.gerrit.server.change;

import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;

import com.google.common.base.Strings;
import com.google.common.hash.Hasher;
import com.google.common.hash.Hashing;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.PatchScript.FileMode;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.mime.FileTypeRegistry;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import eu.medsea.mimeutil.MimeType;
import java.io.IOException;
import java.io.OutputStream;
import java.security.SecureRandom;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.eclipse.jgit.errors.LargeObjectException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.util.NB;

@Singleton
public class FileContentUtil {
  public static final String TEXT_X_GERRIT_COMMIT_MESSAGE = "text/x-gerrit-commit-message";
  public static final String TEXT_X_GERRIT_MERGE_LIST = "text/x-gerrit-merge-list";
  private static final String X_GIT_SYMLINK = "x-git/symlink";
  private static final String X_GIT_GITLINK = "x-git/gitlink";
  private static final int MAX_SIZE = 5 << 20;
  private static final String ZIP_TYPE = "application/zip";
  private static final SecureRandom rng = new SecureRandom();

  private final GitRepositoryManager repoManager;
  private final FileTypeRegistry registry;

  @Inject
  FileContentUtil(GitRepositoryManager repoManager, FileTypeRegistry ftr) {
    this.repoManager = repoManager;
    this.registry = ftr;
  }

  /**
   * Get the content of a file at a specific commit or one of it's parent commits.
   *
   * @param project A {@code Project} that this request refers to.
   * @param revstr An {@code ObjectId} specifying the commit.
   * @param path A string specifying the filepath.
   * @param parent A 1-based parent index to get the content from instead. Null if the content
   *     should be obtained from {@code revstr} instead.
   * @return Content of the file as {@code BinaryResult}.
   * @throws ResourceNotFoundException
   * @throws IOException
   */
  public BinaryResult getContent(
      ProjectState project, ObjectId revstr, String path, @Nullable Integer parent)
      throws BadRequestException, ResourceNotFoundException, IOException {
    try (Repository repo = openRepository(project);
        RevWalk rw = new RevWalk(repo)) {
      if (parent != null) {
        RevCommit revCommit = rw.parseCommit(revstr);
        if (revCommit == null) {
          throw new ResourceNotFoundException("commit not found");
        }
        if (parent > revCommit.getParentCount()) {
          throw new BadRequestException("invalid parent");
        }
        revstr = rw.parseCommit(revstr).getParent(Integer.max(0, parent - 1)).toObjectId();
      }
      return getContent(repo, project, revstr, path);
    }
  }

  public BinaryResult getContent(
      Repository repo, ProjectState project, ObjectId revstr, String path)
      throws IOException, ResourceNotFoundException, BadRequestException {
    try (RevWalk rw = new RevWalk(repo)) {
      RevCommit commit = rw.parseCommit(revstr);
      try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, commit.getTree())) {
        if (tw == null) {
          throw new ResourceNotFoundException();
        }

        org.eclipse.jgit.lib.FileMode mode = tw.getFileMode(0);
        ObjectId id = tw.getObjectId(0);
        if (mode == org.eclipse.jgit.lib.FileMode.GITLINK) {
          return BinaryResult.create(id.name()).setContentType(X_GIT_GITLINK).base64();
        }

        if (mode == org.eclipse.jgit.lib.FileMode.TREE) {
          throw new BadRequestException("cannot retrieve content of directories");
        }

        ObjectLoader obj = repo.open(id, OBJ_BLOB);
        byte[] raw;
        try {
          raw = obj.getCachedBytes(MAX_SIZE);
        } catch (LargeObjectException e) {
          raw = null;
        }

        String type;
        if (mode == org.eclipse.jgit.lib.FileMode.SYMLINK) {
          type = X_GIT_SYMLINK;
        } else {
          type = registry.getMimeType(path, raw).toString();
          type = resolveContentType(project, path, FileMode.FILE, type);
        }

        return asBinaryResult(raw, obj).setContentType(type).base64();
      }
    }
  }

  private static BinaryResult asBinaryResult(byte[] raw, ObjectLoader obj) {
    if (raw != null) {
      return BinaryResult.create(raw);
    }
    BinaryResult result =
        new BinaryResult() {
          @Override
          public void writeTo(OutputStream os) throws IOException {
            obj.copyTo(os);
          }
        };
    result.setContentLength(obj.getSize());
    return result;
  }

  public BinaryResult downloadContent(
      ProjectState project, ObjectId revstr, String path, @Nullable Integer parent)
      throws ResourceNotFoundException, IOException {
    try (Repository repo = openRepository(project);
        RevWalk rw = new RevWalk(repo)) {
      String suffix = "new";
      RevCommit commit = rw.parseCommit(revstr);
      if (parent != null && parent > 0) {
        if (commit.getParentCount() == 1) {
          suffix = "old";
        } else {
          suffix = "old" + parent;
        }
        commit = rw.parseCommit(commit.getParent(parent - 1));
      }
      try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, commit.getTree())) {
        if (tw == null) {
          throw new ResourceNotFoundException();
        }

        int mode = tw.getFileMode(0).getObjectType();
        if (mode != Constants.OBJ_BLOB) {
          throw new ResourceNotFoundException();
        }

        ObjectId id = tw.getObjectId(0);
        ObjectLoader obj = repo.open(id, OBJ_BLOB);
        byte[] raw;
        try {
          raw = obj.getCachedBytes(MAX_SIZE);
        } catch (LargeObjectException e) {
          raw = null;
        }

        MimeType contentType = registry.getMimeType(path, raw);
        return registry.isSafeInline(contentType)
            ? wrapBlob(path, obj, raw, contentType, suffix)
            : zipBlob(path, obj, commit, suffix);
      }
    }
  }

  private BinaryResult wrapBlob(
      String path,
      final ObjectLoader obj,
      byte[] raw,
      MimeType contentType,
      @Nullable String suffix) {
    return asBinaryResult(raw, obj)
        .setContentType(contentType.toString())
        .setAttachmentName(safeFileName(path, suffix));
  }

  @SuppressWarnings("resource")
  private BinaryResult zipBlob(
      final String path, ObjectLoader obj, RevCommit commit, @Nullable final String suffix) {
    final String commitName = commit.getName();
    final long when = commit.getCommitTime() * 1000L;
    return new BinaryResult() {
      @Override
      public void writeTo(OutputStream os) throws IOException {
        try (ZipOutputStream zipOut = new ZipOutputStream(os)) {
          String decoration = randSuffix();
          if (!Strings.isNullOrEmpty(suffix)) {
            decoration = suffix + '-' + decoration;
          }
          ZipEntry e = new ZipEntry(safeFileName(path, decoration));
          e.setComment(commitName + ":" + path);
          e.setSize(obj.getSize());
          e.setTime(when);
          zipOut.putNextEntry(e);
          obj.copyTo(zipOut);
          zipOut.closeEntry();
        }
      }
    }.setContentType(ZIP_TYPE).setAttachmentName(safeFileName(path, suffix) + ".zip").disableGzip();
  }

  private static String safeFileName(String fileName, @Nullable String suffix) {
    // Convert a file path (e.g. "src/Init.c") to a safe file name with
    // no meta-characters that might be unsafe on any given platform.
    //
    int slash = fileName.lastIndexOf('/');
    if (slash >= 0) {
      fileName = fileName.substring(slash + 1);
    }

    StringBuilder r = new StringBuilder(fileName.length());
    for (int i = 0; i < fileName.length(); i++) {
      final char c = fileName.charAt(i);
      if (c == '_' || c == '-' || c == '.' || c == '@') {
        r.append(c);
      } else if ('0' <= c && c <= '9') {
        r.append(c);
      } else if ('A' <= c && c <= 'Z') {
        r.append(c);
      } else if ('a' <= c && c <= 'z') {
        r.append(c);
      } else if (c == ' ' || c == '\n' || c == '\r' || c == '\t') {
        r.append('-');
      } else {
        r.append('_');
      }
    }
    fileName = r.toString();

    int ext = fileName.lastIndexOf('.');
    if (suffix == null) {
      return fileName;
    } else if (ext <= 0) {
      return fileName + "_" + suffix;
    } else {
      return fileName.substring(0, ext) + "_" + suffix + fileName.substring(ext);
    }
  }

  private static String randSuffix() {
    // Produce a random suffix that is difficult (or nearly impossible)
    // for an attacker to guess in advance. This reduces the risk that
    // an attacker could upload a *.class file and have us send a ZIP
    // that can be invoked through an applet tag in the victim's browser.
    //
    Hasher h = Hashing.murmur3_128().newHasher();
    byte[] buf = new byte[8];

    NB.encodeInt64(buf, 0, TimeUtil.nowMs());
    h.putBytes(buf);

    rng.nextBytes(buf);
    h.putBytes(buf);

    return h.hash().toString();
  }

  public static String resolveContentType(
      ProjectState project, String path, FileMode fileMode, String mimeType) {
    switch (fileMode) {
      case FILE:
        if (Patch.COMMIT_MSG.equals(path)) {
          return TEXT_X_GERRIT_COMMIT_MESSAGE;
        }
        if (Patch.MERGE_LIST.equals(path)) {
          return TEXT_X_GERRIT_MERGE_LIST;
        }
        if (project != null) {
          for (ProjectState p : project.tree()) {
            String t = p.getConfig().getMimeTypes().getMimeType(path);
            if (t != null) {
              return t;
            }
          }
        }
        return mimeType;
      case GITLINK:
        return X_GIT_GITLINK;
      case SYMLINK:
        return X_GIT_SYMLINK;
      default:
        throw new IllegalStateException("file mode: " + fileMode);
    }
  }

  private Repository openRepository(ProjectState project)
      throws RepositoryNotFoundException, IOException {
    return repoManager.openRepository(project.getNameKey());
  }
}
