// Copyright (C) 2009 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.patch;

import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.common.data.PatchScript;
import com.google.gerrit.common.data.PatchScript.DisplayMethod;
import com.google.gerrit.entities.FixReplacement;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.Patch.ChangeType;
import com.google.gerrit.entities.Patch.PatchType;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.server.fixes.FixCalculator;
import com.google.gerrit.server.mime.FileTypeRegistry;
import com.google.gerrit.server.patch.DiffContentCalculator.DiffCalculatorResult;
import com.google.gerrit.server.patch.DiffContentCalculator.TextSource;
import com.google.inject.Inject;
import eu.medsea.mimeutil.MimeType;
import eu.medsea.mimeutil.MimeUtil2;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.eclipse.jgit.diff.Edit;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;

class PatchScriptBuilder {

  private DiffPreferencesInfo diffPrefs;
  private final FileTypeRegistry registry;
  private IntraLineDiffCalculator intralineDiffCalculator;

  @Inject
  PatchScriptBuilder(FileTypeRegistry ftr) {
    registry = ftr;
  }

  void setDiffPrefs(DiffPreferencesInfo dp) {
    diffPrefs = dp;
  }

  void setIntraLineDiffCalculator(IntraLineDiffCalculator calculator) {
    intralineDiffCalculator = calculator;
  }

  PatchScript toPatchScript(Repository git, PatchList list, PatchListEntry content)
      throws IOException {

    PatchFileChange change =
        new PatchFileChange(
            content.getEdits(),
            content.getEditsDueToRebase(),
            content.getHeaderLines(),
            content.getOldName(),
            content.getNewName(),
            content.getChangeType(),
            content.getPatchType());
    SidesResolver sidesResolver = new SidesResolver(git, list.getComparisonType());
    ResolvedSides sides =
        resolveSides(
            git, sidesResolver, oldName(change), newName(change), list.getOldId(), list.getNewId());
    return build(sides.a, sides.b, change);
  }

  private ResolvedSides resolveSides(
      Repository git,
      SidesResolver sidesResolver,
      String oldName,
      String newName,
      ObjectId aId,
      ObjectId bId)
      throws IOException {
    try (ObjectReader reader = git.newObjectReader()) {
      PatchSide a = sidesResolver.resolve(registry, reader, oldName, null, aId, true);
      PatchSide b =
          sidesResolver.resolve(registry, reader, newName, a, bId, Objects.equals(aId, bId));
      return new ResolvedSides(a, b);
    }
  }

  PatchScript toPatchScript(
      Repository git, ObjectId baseId, String fileName, List<FixReplacement> fixReplacements)
      throws IOException, ResourceConflictException, ResourceNotFoundException {
    SidesResolver sidesResolver = new SidesResolver(git, ComparisonType.againstOtherPatchSet());
    PatchSide a = resolveSideA(git, sidesResolver, fileName, baseId);
    if (a.mode == FileMode.MISSING) {
      throw new ResourceNotFoundException(String.format("File %s not found", fileName));
    }
    FixCalculator.FixResult fixResult = FixCalculator.calculateFix(a.src, fixReplacements);
    PatchSide b =
        new PatchSide(
            null,
            fileName,
            ObjectId.zeroId(),
            a.mode,
            fixResult.text.getContent(),
            fixResult.text,
            a.mimeType,
            a.displayMethod,
            a.fileMode);

    PatchFileChange change =
        new PatchFileChange(
            fixResult.edits,
            ImmutableSet.of(),
            ImmutableList.of(),
            fileName,
            fileName,
            ChangeType.MODIFIED,
            PatchType.UNIFIED);

    return build(a, b, change);
  }

  private PatchSide resolveSideA(
      Repository git, SidesResolver sidesResolver, String path, ObjectId baseId)
      throws IOException {
    try (ObjectReader reader = git.newObjectReader()) {
      return sidesResolver.resolve(registry, reader, path, null, baseId, true);
    }
  }

  private PatchScript build(PatchSide a, PatchSide b, PatchFileChange content) {
    ImmutableList<Edit> contentEdits = content.getEdits();
    ImmutableSet<Edit> editsDueToRebase = content.getEditsDueToRebase();

    IntraLineDiffCalculatorResult intralineResult = IntraLineDiffCalculatorResult.NO_RESULT;

    if (isModify(content) && intralineDiffCalculator != null && isIntralineModeAllowed(b)) {
      intralineResult =
          intralineDiffCalculator.calculateIntraLineDiff(
              contentEdits, editsDueToRebase, a.id, b.id, a.src, b.src, b.treeId, b.path);
    }
    ImmutableList<Edit> finalEdits = intralineResult.edits.orElse(contentEdits);
    DiffContentCalculator calculator = new DiffContentCalculator(diffPrefs);
    DiffCalculatorResult diffCalculatorResult =
        calculator.calculateDiffContent(new TextSource(a.src), new TextSource(b.src), finalEdits);

    return new PatchScript(
        content.getChangeType(),
        content.getOldName(),
        content.getNewName(),
        a.fileMode,
        b.fileMode,
        content.getHeaderLines(),
        diffPrefs,
        diffCalculatorResult.diffContent.a,
        diffCalculatorResult.diffContent.b,
        diffCalculatorResult.edits,
        editsDueToRebase,
        a.displayMethod,
        b.displayMethod,
        a.mimeType,
        b.mimeType,
        intralineResult.failure,
        intralineResult.timeout,
        content.getPatchType() == Patch.PatchType.BINARY,
        a.treeId == null ? null : a.treeId.getName(),
        b.treeId == null ? null : b.treeId.getName());
  }

  private static boolean isModify(PatchFileChange content) {
    switch (content.getChangeType()) {
      case MODIFIED:
      case COPIED:
      case RENAMED:
      case REWRITE:
        return true;

      case ADDED:
      case DELETED:
      default:
        return false;
    }
  }

  private static String oldName(PatchFileChange entry) {
    switch (entry.getChangeType()) {
      case ADDED:
        return null;
      case DELETED:
      case MODIFIED:
      case REWRITE:
        return entry.getNewName();
      case COPIED:
      case RENAMED:
      default:
        return entry.getOldName();
    }
  }

  private static String newName(PatchFileChange entry) {
    switch (entry.getChangeType()) {
      case DELETED:
        return null;
      case ADDED:
      case MODIFIED:
      case COPIED:
      case RENAMED:
      case REWRITE:
      default:
        return entry.getNewName();
    }
  }

  private static boolean isIntralineModeAllowed(PatchSide side) {
    // The intraline diff cache keys are the same for these cases. It's better to not show
    // intraline results than showing completely wrong diffs or to run into a server error.
    return !Patch.isMagic(side.path) && !isSubmoduleCommit(side.mode);
  }

  private static boolean isSubmoduleCommit(FileMode mode) {
    return mode.getObjectType() == Constants.OBJ_COMMIT;
  }

  private static class PatchSide {
    final ObjectId treeId;
    final String path;
    final ObjectId id;
    final FileMode mode;
    final byte[] srcContent;
    final Text src;
    final String mimeType;
    final DisplayMethod displayMethod;
    final PatchScript.FileMode fileMode;

    private PatchSide(
        ObjectId treeId,
        String path,
        ObjectId id,
        FileMode mode,
        byte[] srcContent,
        Text src,
        String mimeType,
        DisplayMethod displayMethod,
        PatchScript.FileMode fileMode) {
      this.treeId = treeId;
      this.path = path;
      this.id = id;
      this.mode = mode;
      this.srcContent = srcContent;
      this.src = src;
      this.mimeType = mimeType;
      this.displayMethod = displayMethod;
      this.fileMode = fileMode;
    }
  }

  private static class ResolvedSides {
    // Not an @AutoValue because PatchSide can't be AutoValue
    public final PatchSide a;
    public final PatchSide b;

    ResolvedSides(PatchSide a, PatchSide b) {
      this.a = a;
      this.b = b;
    }
  }

  static class SidesResolver {

    private final Repository db;
    private final ComparisonType comparisonType;

    SidesResolver(Repository db, ComparisonType comparisonType) {
      this.db = db;
      this.comparisonType = comparisonType;
    }

    PatchSide resolve(
        final FileTypeRegistry registry,
        final ObjectReader reader,
        final String path,
        final PatchSide other,
        final ObjectId within,
        final boolean isWithinEqualsA)
        throws IOException {
      try {
        boolean isCommitMsg = Patch.COMMIT_MSG.equals(path);
        boolean isMergeList = Patch.MERGE_LIST.equals(path);
        if (isCommitMsg || isMergeList) {
          if (comparisonType.isAgainstParentOrAutoMerge() && isWithinEqualsA) {
            return createSide(
                within,
                path,
                ObjectId.zeroId(),
                FileMode.MISSING,
                Text.NO_BYTES,
                Text.EMPTY,
                MimeUtil2.UNKNOWN_MIME_TYPE.toString(),
                DisplayMethod.NONE,
                false);
          }
          Text src =
              isCommitMsg
                  ? Text.forCommit(reader, within)
                  : Text.forMergeList(comparisonType, reader, within);
          byte[] srcContent = src.getContent();
          DisplayMethod displayMethod;
          FileMode mode;
          if (src == Text.EMPTY) {
            mode = FileMode.MISSING;
            displayMethod = DisplayMethod.NONE;
          } else {
            mode = FileMode.REGULAR_FILE;
            displayMethod = DisplayMethod.DIFF;
          }
          return createSide(
              within,
              path,
              within,
              mode,
              srcContent,
              src,
              MimeUtil2.UNKNOWN_MIME_TYPE.toString(),
              displayMethod,
              false);
        }
        final TreeWalk tw = find(reader, path, within);
        ObjectId id = tw != null ? tw.getObjectId(0) : ObjectId.zeroId();
        FileMode mode = tw != null ? tw.getFileMode(0) : FileMode.MISSING;
        boolean reuse =
            other != null
                && other.id.equals(id)
                && (other.mode == mode || isBothFile(other.mode, mode));
        Text src = null;
        byte[] srcContent;
        if (reuse) {
          srcContent = other.srcContent;

        } else if (mode.getObjectType() == Constants.OBJ_BLOB) {
          srcContent = Text.asByteArray(db.open(id, Constants.OBJ_BLOB));

        } else if (mode.getObjectType() == Constants.OBJ_COMMIT) {
          String strContent = "Subproject commit " + ObjectId.toString(id);
          srcContent = strContent.getBytes(UTF_8);

        } else {
          srcContent = Text.NO_BYTES;
        }
        String mimeType = MimeUtil2.UNKNOWN_MIME_TYPE.toString();
        DisplayMethod displayMethod = DisplayMethod.DIFF;
        if (reuse) {
          mimeType = other.mimeType;
          displayMethod = other.displayMethod;
          src = other.src;

        } else if (srcContent.length > 0 && FileMode.SYMLINK != mode) {
          MimeType registryMimeType = registry.getMimeType(path, srcContent);
          if ("image".equals(registryMimeType.getMediaType())
              && registry.isSafeInline(registryMimeType)) {
            displayMethod = DisplayMethod.IMG;
          }
          mimeType = registryMimeType.toString();
        }
        return createSide(within, path, id, mode, srcContent, src, mimeType, displayMethod, reuse);

      } catch (IOException err) {
        throw new IOException("Cannot read " + within.name() + ":" + path, err);
      }
    }

    private PatchSide createSide(
        ObjectId treeId,
        String path,
        ObjectId id,
        FileMode mode,
        byte[] srcContent,
        Text src,
        String mimeType,
        DisplayMethod displayMethod,
        boolean reuse) {
      if (!reuse) {
        if (srcContent == Text.NO_BYTES) {
          src = Text.EMPTY;
        } else {
          src = new Text(srcContent);
        }
      }
      if (mode == FileMode.MISSING) {
        displayMethod = DisplayMethod.NONE;
      }
      PatchScript.FileMode fileMode = PatchScript.FileMode.FILE;
      if (mode == FileMode.SYMLINK) {
        fileMode = PatchScript.FileMode.SYMLINK;
      } else if (mode == FileMode.GITLINK) {
        fileMode = PatchScript.FileMode.GITLINK;
      }
      return new PatchSide(
          treeId, path, id, mode, srcContent, src, mimeType, displayMethod, fileMode);
    }

    private TreeWalk find(ObjectReader reader, String path, ObjectId within) throws IOException {
      if (path == null || within == null) {
        return null;
      }
      try (RevWalk rw = new RevWalk(reader)) {
        final RevTree tree = rw.parseTree(within);
        return TreeWalk.forPath(reader, path, tree);
      }
    }
  }

  private static boolean isBothFile(FileMode a, FileMode b) {
    return (a.getBits() & FileMode.TYPE_FILE) == FileMode.TYPE_FILE
        && (b.getBits() & FileMode.TYPE_FILE) == FileMode.TYPE_FILE;
  }

  static class IntraLineDiffCalculatorResult {
    // Not an @AutoValue because Edit is mutable
    final boolean failure;
    final boolean timeout;
    private final Optional<ImmutableList<Edit>> edits;

    private IntraLineDiffCalculatorResult(
        Optional<ImmutableList<Edit>> edits, boolean failure, boolean timeout) {
      this.failure = failure;
      this.timeout = timeout;
      this.edits = edits;
    }

    static final IntraLineDiffCalculatorResult NO_RESULT =
        new IntraLineDiffCalculatorResult(Optional.empty(), false, false);
    static final IntraLineDiffCalculatorResult FAILURE =
        new IntraLineDiffCalculatorResult(Optional.empty(), true, false);
    static final IntraLineDiffCalculatorResult TIMEOUT =
        new IntraLineDiffCalculatorResult(Optional.empty(), false, true);

    static IntraLineDiffCalculatorResult success(ImmutableList<Edit> edits) {
      return new IntraLineDiffCalculatorResult(Optional.of(edits), false, false);
    }
  }

  interface IntraLineDiffCalculator {

    IntraLineDiffCalculatorResult calculateIntraLineDiff(
        ImmutableList<Edit> edits,
        Set<Edit> editsDueToRebase,
        ObjectId aId,
        ObjectId bId,
        Text aSrc,
        Text bSrc,
        ObjectId bTreeId,
        String bPath);
  }

  static class PatchFileChange {
    private final ImmutableList<Edit> edits;
    private final ImmutableSet<Edit> editsDueToRebase;
    private final ImmutableList<String> headerLines;
    private final String oldName;
    private final String newName;
    private final ChangeType changeType;
    private final Patch.PatchType patchType;

    public PatchFileChange(
        ImmutableList<Edit> edits,
        ImmutableSet<Edit> editsDueToRebase,
        ImmutableList<String> headerLines,
        String oldName,
        String newName,
        ChangeType changeType,
        Patch.PatchType patchType) {
      this.edits = edits;
      this.editsDueToRebase = editsDueToRebase;
      this.headerLines = headerLines;
      this.oldName = oldName;
      this.newName = newName;
      this.changeType = changeType;
      this.patchType = patchType;
    }

    ImmutableList<Edit> getEdits() {
      return edits;
    }

    ImmutableSet<Edit> getEditsDueToRebase() {
      return editsDueToRebase;
    }

    ImmutableList<String> getHeaderLines() {
      return headerLines;
    }

    String getNewName() {
      return newName;
    }

    String getOldName() {
      return oldName;
    }

    ChangeType getChangeType() {
      return changeType;
    }

    Patch.PatchType getPatchType() {
      return patchType;
    }
  }
}
