// Copyright (C) 2022 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.restapi.change;

import static com.google.common.collect.ImmutableList.toImmutableList;

import com.google.auto.factory.AutoFactory;
import com.google.auto.factory.Provided;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.Table.Cell;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.LabelId;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.approval.ApprovalCopier;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import java.io.IOException;
import java.util.Optional;

/**
 * Batch update operation that copy approvals that have been newly applied on outdated patch sets to
 * the follow-up patch sets if they are copyable and no non-copied approvals prevent the copying.
 *
 * <p>Must be invoked after the batch update operation which applied new approvals on outdated patch
 * sets (e.g. after {@link PostReviewOp}.
 */
@AutoFactory
public class PostReviewCopyApprovalsOp implements BatchUpdateOp {
  private final ApprovalCopier approvalCopier;
  private final PatchSetUtil patchSetUtil;
  private final PatchSet.Id patchSetId;

  private ChangeContext ctx;
  private ImmutableList<PatchSet.Id> followUpPatchSets;

  PostReviewCopyApprovalsOp(
      @Provided ApprovalCopier approvalCopier,
      @Provided PatchSetUtil patchSetUtil,
      PatchSet.Id patchSetId) {
    this.approvalCopier = approvalCopier;
    this.patchSetUtil = patchSetUtil;
    this.patchSetId = patchSetId;
  }

  @Override
  public boolean updateChange(ChangeContext ctx) throws IOException {
    if (ctx.getNotes().getCurrentPatchSet().id().equals(patchSetId)) {
      // the updated patch set is the current patch, there a no follow-up patch set to which new
      // approvals could be copied
      return false;
    }

    init(ctx);

    boolean dirty = false;
    ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals =
        ctx.getUpdate(patchSetId).getApprovals();
    for (Cell<String, Account.Id, Optional<PatchSetApproval>> cell : newApprovals.cellSet()) {
      String label = cell.getRowKey();
      Account.Id approverId = cell.getColumnKey();
      PatchSetApproval.Key psaKey =
          PatchSetApproval.key(patchSetId, approverId, LabelId.create(label));

      if (isRemoval(cell)) {
        if (removeCopies(psaKey)) {
          dirty = true;
        }
        continue;
      }

      PatchSet patchSet = patchSetUtil.get(ctx.getNotes(), patchSetId);
      PatchSetApproval psaOrig = cell.getValue().get();

      // Target patch sets to which the approval is copyable.
      ImmutableList<PatchSet.Id> targetPatchSets =
          approvalCopier.forApproval(
              ctx.getNotes(),
              patchSet,
              psaKey.accountId(),
              psaKey.labelId().get(),
              psaOrig.value());

      // Iterate over all follow-up patch sets, in patch set order.
      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
        if (hasOverrideOf(followUpPatchSetId, psaKey)) {
          // a non-copied approval exists that overrides any copied approval
          // -> do not copy the approval to this patch set nor to any follow-up patch sets
          break;
        }

        if (targetPatchSets.contains(followUpPatchSetId)) {
          // The approval is copyable to the new patch set.

          if (hasCopyOfWithValue(followUpPatchSetId, psaKey, psaOrig.value())) {
            // a copy approval with the exact value already exists
            continue;
          }

          // add/update the copied approval on the target patch set
          PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
          ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
          dirty = true;
        } else {
          // The approval is not copyable to the new patch set.

          if (hasCopyOf(followUpPatchSetId, psaKey)) {
            // a copy approval exists and should be removed
            removeCopy(followUpPatchSetId, psaKey);
            dirty = true;
          }
        }
      }
    }

    return dirty;
  }

  private void init(ChangeContext ctx) {
    this.ctx = ctx;

    // compute follow-up patch sets (sorted by patch set ID)
    this.followUpPatchSets =
        ctx.getNotes().getPatchSets().keySet().stream()
            .filter(psId -> psId.get() > patchSetId.get())
            .collect(toImmutableList());
  }

  /**
   * Whether the given cell entry from the approval table represents the removal of an approval.
   *
   * @param cell cell entry from the approval table
   * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
   *     otherwise {@code false}
   */
  private boolean isRemoval(Cell<String, Account.Id, Optional<PatchSetApproval>> cell) {
    return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
  }

  /**
   * Removes copies of the given approval from all follow-up patch sets.
   *
   * @param psaKey the key of the patch set approval for which copies should be removed from all
   *     follow-up patch sets
   * @return whether any copy approval has been removed
   */
  private boolean removeCopies(PatchSetApproval.Key psaKey) {
    boolean dirty = false;
    for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
      if (hasCopyOf(followUpPatchSet, psaKey)) {
        removeCopy(followUpPatchSet, psaKey);
      } else {
        // Do not remove copy from this follow-up patch sets and also not from any further follow-up
        // patch sets (if the further follow-up patch sets have copies they are copies of a
        // non-copied approval on this follow-up patch set and hence those should not be removed).
        break;
      }
    }
    return dirty;
  }

  /**
   * Removes the copy approval with the given key from the given patch set.
   *
   * @param patchSet patch set from which the copy approval with the given key should be removed
   * @param psaKey the key of the patch set approval for which copies should be removed from the
   *     given patch set
   */
  private void removeCopy(PatchSet.Id patchSet, PatchSetApproval.Key psaKey) {
    ctx.getUpdate(patchSet)
        .removeCopiedApprovalFor(
            ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
                ? ctx.getIdentifiedUser().getRealUser().getAccountId()
                : null,
            psaKey.accountId(),
            psaKey.labelId().get());
  }

  /**
   * Whether the given patch set has a copy approval with the given key.
   *
   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
   *     approval with the given key
   * @param psaKey the key of the patch set approval
   */
  private boolean hasCopyOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
  }

  /**
   * Whether the given patch set has a copy approval with the given key and value.
   *
   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
   *     approval with the given key and value
   * @param psaKey the key of the patch set approval
   */
  private boolean hasCopyOfWithValue(
      PatchSet.Id patchSetId, PatchSetApproval.Key psaKey, short value) {
    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey) && psa.value() == value);
  }

  /**
   * Whether the given patch set has a normal approval with the given key that overrides copy
   * approvals with that key.
   *
   * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
   *     approval with the given key that overrides copy approvals with that key
   * @param psaKey the key of the patch set approval
   */
  private boolean hasOverrideOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
    return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
  }

  private boolean areAccountAndLabelTheSame(
      PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
    return psaKey1.accountId().equals(psaKey2.accountId())
        && psaKey1.labelId().equals(psaKey2.labelId());
  }
}
