// Copyright (C) 2021 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.account.externalids;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;

import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.server.account.HashedPassword;
import com.google.gerrit.server.config.AuthConfig;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;

@Singleton
public class ExternalIdFactory {
  private final ExternalIdKeyFactory externalIdKeyFactory;
  private AuthConfig authConfig;

  @Inject
  public ExternalIdFactory(ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
    this.externalIdKeyFactory = externalIdKeyFactory;
    this.authConfig = authConfig;
  }

  /**
   * Creates an external ID.
   *
   * @param scheme the scheme name, must not contain colons (':'). E.g. {@link
   *     ExternalId#SCHEME_USERNAME}.
   * @param id the external ID, must not contain colons (':')
   * @param accountId the ID of the account to which the external ID belongs
   * @return the created external ID
   */
  public ExternalId create(String scheme, String id, Account.Id accountId) {
    return create(externalIdKeyFactory.create(scheme, id), accountId, null, null);
  }

  /**
   * Creates an external ID.
   *
   * @param scheme the scheme name, must not contain colons (':'). E.g. {@link
   *     ExternalId#SCHEME_USERNAME}.
   * @param id the external ID, must not contain colons (':')
   * @param accountId the ID of the account to which the external ID belongs
   * @param email the email of the external ID, may be {@code null}
   * @param hashedPassword the hashed password of the external ID, may be {@code null}
   * @return the created external ID
   */
  public ExternalId create(
      String scheme,
      String id,
      Account.Id accountId,
      @Nullable String email,
      @Nullable String hashedPassword) {
    return create(externalIdKeyFactory.create(scheme, id), accountId, email, hashedPassword);
  }

  /**
   * Creates an external ID.
   *
   * @param key the external Id key
   * @param accountId the ID of the account to which the external ID belongs
   * @return the created external ID
   */
  public ExternalId create(ExternalId.Key key, Account.Id accountId) {
    return create(key, accountId, null, null);
  }

  /**
   * Creates an external ID.
   *
   * @param key the external Id key
   * @param accountId the ID of the account to which the external ID belongs
   * @param email the email of the external ID, may be {@code null}
   * @param hashedPassword the hashed password of the external ID, may be {@code null}
   * @return the created external ID
   */
  public ExternalId create(
      ExternalId.Key key,
      Account.Id accountId,
      @Nullable String email,
      @Nullable String hashedPassword) {
    return create(
        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), null);
  }

  /**
   * Creates an external ID adding a hashed password computed from a plain password.
   *
   * @param key the external Id key
   * @param accountId the ID of the account to which the external ID belongs
   * @param email the email of the external ID, may be {@code null}
   * @param plainPassword the plain HTTP password, may be {@code null}
   * @return the created external ID
   */
  public ExternalId createWithPassword(
      ExternalId.Key key,
      Account.Id accountId,
      @Nullable String email,
      @Nullable String plainPassword) {
    plainPassword = Strings.emptyToNull(plainPassword);
    String hashedPassword =
        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
    return create(key, accountId, email, hashedPassword);
  }

  /**
   * Create a external ID for a username (scheme "username").
   *
   * @param id the external ID, must not contain colons (':')
   * @param accountId the ID of the account to which the external ID belongs
   * @param plainPassword the plain HTTP password, may be {@code null}
   * @return the created external ID
   */
  public ExternalId createUsername(
      String id, Account.Id accountId, @Nullable String plainPassword) {
    return createWithPassword(
        externalIdKeyFactory.create(ExternalId.SCHEME_USERNAME, id),
        accountId,
        null,
        plainPassword);
  }

  /**
   * Creates an external ID with an email.
   *
   * @param scheme the scheme name, must not contain colons (':'). E.g. {@link
   *     ExternalId#SCHEME_USERNAME}.
   * @param id the external ID, must not contain colons (':')
   * @param accountId the ID of the account to which the external ID belongs
   * @param email the email of the external ID, may be {@code null}
   * @return the created external ID
   */
  public ExternalId createWithEmail(
      String scheme, String id, Account.Id accountId, @Nullable String email) {
    return createWithEmail(externalIdKeyFactory.create(scheme, id), accountId, email);
  }

  /**
   * Creates an external ID with an email.
   *
   * @param key the external Id key
   * @param accountId the ID of the account to which the external ID belongs
   * @param email the email of the external ID, may be {@code null}
   * @return the created external ID
   */
  public ExternalId createWithEmail(
      ExternalId.Key key, Account.Id accountId, @Nullable String email) {
    return create(key, accountId, Strings.emptyToNull(email), null);
  }

  /**
   * Creates an external ID using the `mailto`-scheme.
   *
   * @param accountId the ID of the account to which the external ID belongs
   * @param email the email of the external ID, may be {@code null}
   * @return the created external ID
   */
  public ExternalId createEmail(Account.Id accountId, String email) {
    return createWithEmail(ExternalId.SCHEME_MAILTO, email, accountId, requireNonNull(email));
  }

  ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
    return create(extId.key(), extId.accountId(), extId.email(), extId.password(), blobId);
  }

  /**
   * Creates an external ID.
   *
   * @param key the external Id key
   * @param accountId the ID of the account to which the external ID belongs
   * @param email the email of the external ID, may be {@code null}
   * @param hashedPassword the hashed password of the external ID, may be {@code null}
   * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
   *     {@code null} if the external ID was created in code and is not yet stored in Git.
   * @return the created external ID
   */
  public ExternalId create(
      ExternalId.Key key,
      Account.Id accountId,
      @Nullable String email,
      @Nullable String hashedPassword,
      @Nullable ObjectId blobId) {
    return ExternalId.create(
        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), blobId);
  }

  /**
   * Parses an external ID from a byte array that contains the external ID as a Git config file
   * text.
   *
   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
   * email and password:
   *
   * <pre>
   * [externalId "username:jdoe"]
   *   accountId = 1003407
   *   email = jdoe@example.com
   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
   * </pre>
   *
   * @param noteId the SHA-1 sum of the external ID used as the note's ID
   * @param raw a byte array that contains the external ID as a Git config file text.
   * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
   *     {@code null} if the external ID was created in code and is not yet stored in Git.
   * @return the parsed external ID
   */
  public ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
      throws ConfigInvalidException {
    requireNonNull(blobId);

    Config externalIdConfig = new Config();
    try {
      externalIdConfig.fromText(new String(raw, UTF_8));
    } catch (ConfigInvalidException e) {
      throw invalidConfig(noteId, e.getMessage());
    }

    Set<String> externalIdKeys = externalIdConfig.getSubsections(ExternalId.EXTERNAL_ID_SECTION);
    if (externalIdKeys.size() != 1) {
      throw invalidConfig(
          noteId,
          String.format(
              "Expected exactly 1 '%s' section, found %d",
              ExternalId.EXTERNAL_ID_SECTION, externalIdKeys.size()));
    }

    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
    ExternalId.Key externalIdKey = externalIdKeyFactory.parse(externalIdKeyStr);
    if (externalIdKey == null) {
      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
    }

    if (!externalIdKey.sha1().getName().equals(noteId)) {
      if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
        throw invalidConfig(
            noteId,
            String.format(
                "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
      }

      if (!externalIdKey.caseSensitiveSha1().getName().equals(noteId)) {
        throw invalidConfig(
            noteId,
            String.format(
                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID"
                    + " '%s'",
                externalIdKeyStr, noteId));
      }
      externalIdKey =
          externalIdKeyFactory.create(externalIdKey.scheme(), externalIdKey.id(), false);
    }

    String email =
        externalIdConfig.getString(
            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.EMAIL_KEY);
    String password =
        externalIdConfig.getString(
            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.PASSWORD_KEY);
    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);

    return create(
        externalIdKey,
        Account.id(accountId),
        Strings.emptyToNull(email),
        Strings.emptyToNull(password),
        blobId);
  }

  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
      throws ConfigInvalidException {
    String accountIdStr =
        externalIdConfig.getString(
            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY);
    if (accountIdStr == null) {
      throw invalidConfig(
          noteId,
          String.format(
              "Value for '%s.%s.%s' is missing, expected account ID",
              ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY));
    }

    try {
      int accountId =
          externalIdConfig.getInt(
              ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY, -1);
      if (accountId < 0) {
        throw invalidConfig(
            noteId,
            String.format(
                "Value %s for '%s.%s.%s' is invalid, expected account ID",
                accountIdStr,
                ExternalId.EXTERNAL_ID_SECTION,
                externalIdKeyStr,
                ExternalId.ACCOUNT_ID_KEY));
      }
      return accountId;
    } catch (IllegalArgumentException e) {
      ConfigInvalidException newException =
          invalidConfig(
              noteId,
              String.format(
                  "Value %s for '%s.%s.%s' is invalid, expected account ID",
                  accountIdStr,
                  ExternalId.EXTERNAL_ID_SECTION,
                  externalIdKeyStr,
                  ExternalId.ACCOUNT_ID_KEY));
      newException.initCause(e);
      throw newException;
    }
  }

  private static ConfigInvalidException invalidConfig(String noteId, String message) {
    return new ConfigInvalidException(
        String.format("Invalid external ID config for note '%s': %s", noteId, message));
  }
}
