// Copyright (C) 2015 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.gpg;

import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.BAD;
import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.OK;
import static com.google.gerrit.extensions.common.GpgKeyInfo.Status.TRUSTED;
import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
import static com.google.gerrit.gpg.PublicKeyStore.keyToString;

import com.google.common.base.Joiner;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.bouncycastle.bcpg.ArmoredInputStream;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPObjectFactory;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
import org.bouncycastle.openpgp.PGPSignature;
import org.bouncycastle.openpgp.PGPSignatureList;
import org.bouncycastle.openpgp.bc.BcPGPObjectFactory;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.PushCertificate;
import org.eclipse.jgit.transport.PushCertificate.NonceStatus;

/** Checker for push certificates. */
public abstract class PushCertificateChecker {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  public static class Result {
    private final PGPPublicKey key;
    private final CheckResult checkResult;

    private Result(PGPPublicKey key, CheckResult checkResult) {
      this.key = key;
      this.checkResult = checkResult;
    }

    public PGPPublicKey getPublicKey() {
      return key;
    }

    public CheckResult getCheckResult() {
      return checkResult;
    }
  }

  private final PublicKeyChecker publicKeyChecker;

  private boolean checkNonce;

  protected PushCertificateChecker(PublicKeyChecker publicKeyChecker) {
    this.publicKeyChecker = publicKeyChecker;
    checkNonce = true;
  }

  /** Set whether to check the status of the nonce; defaults to true. */
  public PushCertificateChecker setCheckNonce(boolean checkNonce) {
    this.checkNonce = checkNonce;
    return this;
  }

  /**
   * Check a push certificate.
   *
   * @return result of the check.
   */
  public final Result check(PushCertificate cert) {
    if (checkNonce && cert.getNonceStatus() != NonceStatus.OK) {
      return new Result(null, CheckResult.bad("Invalid nonce"));
    }
    List<CheckResult> results = new ArrayList<>(2);
    Result sigResult = null;
    try {
      PGPSignature sig = readSignature(cert);
      if (sig != null) {
        @SuppressWarnings("resource")
        Repository repo = getRepository();
        try (PublicKeyStore store = new PublicKeyStore(repo)) {
          sigResult = checkSignature(sig, cert, store);
          results.add(checkCustom(repo));
        } finally {
          if (shouldClose(repo)) {
            repo.close();
          }
        }
      } else {
        results.add(CheckResult.bad("Invalid signature format"));
      }
    } catch (PGPException | IOException e) {
      String msg = "Internal error checking push certificate";
      logger.atSevere().withCause(e).log("%s", msg);
      results.add(CheckResult.bad(msg));
    }

    return combine(sigResult, results);
  }

  private static Result combine(Result sigResult, List<CheckResult> results) {
    // Combine results:
    //  - If any input result is BAD, the final result is bad.
    //  - If sigResult is TRUSTED and no other result is BAD, the final result
    //    is TRUSTED.
    //  - Otherwise, the result is OK.
    List<String> problems = new ArrayList<>();
    boolean bad = false;
    for (CheckResult result : results) {
      problems.addAll(result.getProblems());
      bad |= result.getStatus() == BAD;
    }
    Status status = bad ? BAD : OK;

    PGPPublicKey key;
    if (sigResult != null) {
      key = sigResult.getPublicKey();
      CheckResult cr = sigResult.getCheckResult();
      problems.addAll(cr.getProblems());
      if (cr.getStatus() == BAD) {
        status = BAD;
      } else if (!bad && cr.getStatus() == TRUSTED) {
        status = TRUSTED;
      }
    } else {
      key = null;
    }
    return new Result(key, CheckResult.create(status, problems));
  }

  /**
   * Get the repository that this checker should operate on.
   *
   * <p>This method is called once per call to {@link #check(PushCertificate)}.
   *
   * @return the repository.
   * @throws IOException if an error occurred reading the repository.
   */
  protected abstract Repository getRepository() throws IOException;

  /**
   * Specifies whether this repository should be closed before returning froms {@link
   * #check(PushCertificate)}
   *
   * @param repo a repository previously returned by {@link #getRepository()}.
   * @return true if this repository should be closed before returning from {@link
   *     #check(PushCertificate)}.
   */
  protected abstract boolean shouldClose(Repository repo);

  /**
   * Perform custom checks.
   *
   * <p>Default implementation reports no problems, but may be overridden by subclasses.
   *
   * @param repo a repository previously returned by {@link #getRepository()}.
   * @return the result of the custom check.
   */
  protected CheckResult checkCustom(Repository repo) {
    return CheckResult.ok();
  }

  private PGPSignature readSignature(PushCertificate cert) throws IOException {
    ArmoredInputStream in =
        new ArmoredInputStream(new ByteArrayInputStream(Constants.encode(cert.getSignature())));
    PGPObjectFactory factory = new BcPGPObjectFactory(in);
    Object obj;
    while ((obj = factory.nextObject()) != null) {
      if (obj instanceof PGPSignatureList) {
        PGPSignatureList sigs = (PGPSignatureList) obj;
        if (!sigs.isEmpty()) {
          return sigs.get(0);
        }
      }
    }
    return null;
  }

  private Result checkSignature(PGPSignature sig, PushCertificate cert, PublicKeyStore store)
      throws PGPException, IOException {
    PGPPublicKeyRingCollection keys = store.get(sig.getKeyID());
    if (!keys.getKeyRings().hasNext()) {
      return new Result(
          null,
          CheckResult.bad("No public keys found for key ID " + keyIdToString(sig.getKeyID())));
    }
    PGPPublicKey signer = PublicKeyStore.getSigner(keys, sig, Constants.encode(cert.toText()));
    if (signer == null) {
      return new Result(
          null, CheckResult.bad("Signature by " + keyIdToString(sig.getKeyID()) + " is not valid"));
    }
    CheckResult result =
        publicKeyChecker.setStore(store).setEffectiveTime(getCreationTime(sig)).check(signer);
    if (!result.getProblems().isEmpty()) {
      StringBuilder err =
          new StringBuilder("Invalid public key ")
              .append(keyToString(signer))
              .append(":\n  ")
              .append(Joiner.on("\n  ").join(result.getProblems()));
      return new Result(signer, CheckResult.create(result.getStatus(), err.toString()));
    }
    return new Result(signer, result);
  }

  @SuppressWarnings("JdkObsolete")
  public static Instant getCreationTime(PGPSignature signature) {
    return signature.getCreationTime().toInstant();
  }
}
