// Copyright (C) 2016 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;

import static com.google.common.base.Preconditions.checkState;
import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;

import com.google.common.base.Strings;
import com.google.common.collect.Ordering;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.InvalidSshKeyException;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.git.meta.VersionedMetaData;
import com.google.gerrit.server.ssh.SshKeyCreator;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import com.google.inject.assistedinject.Assisted;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Repository;

/**
 * 'authorized_keys' file in the refs/users/CD/ABCD branches of the All-Users repository.
 *
 * <p>The `authorized_keys' files stores the public SSH keys of the user. The file format matches
 * the standard SSH file format, which means that each key is stored on a separate line (see
 * https://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys).
 *
 * <p>The order of the keys in the file determines the sequence numbers of the keys. The first line
 * corresponds to sequence number 1.
 *
 * <p>Invalid keys are marked with the prefix <code># INVALID</code>.
 *
 * <p>To keep the sequence numbers intact when a key is deleted, a <code># DELETED</code> line is
 * inserted at the position where the key was deleted.
 *
 * <p>Other comment lines are ignored on read, and are not written back when the file is modified.
 */
public class VersionedAuthorizedKeys extends VersionedMetaData {

  /** Read/write SSH keys by user ID. */
  @Singleton
  public static class Accessor {
    private final GitRepositoryManager repoManager;
    private final AllUsersName allUsersName;
    private final VersionedAuthorizedKeys.Factory authorizedKeysFactory;
    private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
    private final IdentifiedUser.GenericFactory userFactory;

    @Inject
    Accessor(
        GitRepositoryManager repoManager,
        AllUsersName allUsersName,
        VersionedAuthorizedKeys.Factory authorizedKeysFactory,
        Provider<MetaDataUpdate.User> metaDataUpdateFactory,
        IdentifiedUser.GenericFactory userFactory) {
      this.repoManager = repoManager;
      this.allUsersName = allUsersName;
      this.authorizedKeysFactory = authorizedKeysFactory;
      this.metaDataUpdateFactory = metaDataUpdateFactory;
      this.userFactory = userFactory;
    }

    public List<AccountSshKey> getKeys(Account.Id accountId)
        throws IOException, ConfigInvalidException {
      return read(accountId).getKeys();
    }

    public AccountSshKey getKey(Account.Id accountId, int seq)
        throws IOException, ConfigInvalidException {
      return read(accountId).getKey(seq);
    }

    public synchronized AccountSshKey addKey(Account.Id accountId, String pub)
        throws IOException, ConfigInvalidException, InvalidSshKeyException {
      VersionedAuthorizedKeys authorizedKeys = read(accountId);
      AccountSshKey key = authorizedKeys.addKey(pub);
      commit(authorizedKeys);
      return key;
    }

    public synchronized void deleteKey(Account.Id accountId, int seq)
        throws IOException, ConfigInvalidException {
      VersionedAuthorizedKeys authorizedKeys = read(accountId);
      if (authorizedKeys.deleteKey(seq)) {
        commit(authorizedKeys);
      }
    }

    public synchronized void markKeyInvalid(Account.Id accountId, int seq)
        throws IOException, ConfigInvalidException {
      VersionedAuthorizedKeys authorizedKeys = read(accountId);
      if (authorizedKeys.markKeyInvalid(seq)) {
        commit(authorizedKeys);
      }
    }

    private VersionedAuthorizedKeys read(Account.Id accountId)
        throws IOException, ConfigInvalidException {
      try (Repository git = repoManager.openRepository(allUsersName)) {
        VersionedAuthorizedKeys authorizedKeys = authorizedKeysFactory.create(accountId);
        authorizedKeys.load(allUsersName, git);
        return authorizedKeys;
      }
    }

    private void commit(VersionedAuthorizedKeys authorizedKeys) throws IOException {
      try (MetaDataUpdate md =
          metaDataUpdateFactory
              .get()
              .create(allUsersName, userFactory.create(authorizedKeys.accountId))) {
        authorizedKeys.commit(md);
      }
    }
  }

  public static class SimpleSshKeyCreator implements SshKeyCreator {
    @Override
    public AccountSshKey create(Account.Id accountId, int seq, String encoded) {
      return AccountSshKey.create(accountId, seq, encoded);
    }
  }

  public interface Factory {
    VersionedAuthorizedKeys create(Account.Id accountId);
  }

  private final SshKeyCreator sshKeyCreator;
  private final Account.Id accountId;
  private final String ref;
  private List<Optional<AccountSshKey>> keys;

  @Inject
  public VersionedAuthorizedKeys(SshKeyCreator sshKeyCreator, @Assisted Account.Id accountId) {
    this.sshKeyCreator = sshKeyCreator;
    this.accountId = accountId;
    this.ref = RefNames.refsUsers(accountId);
  }

  @Override
  protected String getRefName() {
    return ref;
  }

  @Override
  protected void onLoad() throws IOException {
    keys = AuthorizedKeys.parse(accountId, readUTF8(AuthorizedKeys.FILE_NAME));
  }

  @Override
  protected boolean onSave(CommitBuilder commit) throws IOException {
    if (Strings.isNullOrEmpty(commit.getMessage())) {
      commit.setMessage("Updated SSH keys\n");
    }

    saveUTF8(AuthorizedKeys.FILE_NAME, AuthorizedKeys.serialize(keys));
    return true;
  }

  /** Returns all SSH keys. */
  private List<AccountSshKey> getKeys() {
    checkLoaded();
    return keys.stream().filter(Optional::isPresent).map(Optional::get).collect(toList());
  }

  /**
   * Returns the SSH key with the given sequence number.
   *
   * @param seq sequence number
   * @return the SSH key, <code>null</code> if there is no SSH key with this sequence number, or if
   *     the SSH key with this sequence number has been deleted
   */
  private AccountSshKey getKey(int seq) {
    checkLoaded();
    return keys.get(seq - 1).orElse(null);
  }

  /**
   * Adds a new public SSH key.
   *
   * <p>If the specified public key exists already, the existing key is returned.
   *
   * @param pub the public SSH key to be added
   * @return the new SSH key
   * @throws InvalidSshKeyException
   */
  private AccountSshKey addKey(String pub) throws InvalidSshKeyException {
    checkLoaded();

    for (Optional<AccountSshKey> key : keys) {
      if (key.isPresent() && key.get().sshPublicKey().trim().equals(pub.trim())) {
        return key.get();
      }
    }

    int seq = keys.size() + 1;
    AccountSshKey key = sshKeyCreator.create(accountId, seq, pub);
    keys.add(Optional.of(key));
    return key;
  }

  /**
   * Deletes the SSH key with the given sequence number.
   *
   * @param seq the sequence number
   * @return <code>true</code> if a key with this sequence number was found and deleted, <code>false
   *     </code> if no key with the given sequence number exists
   */
  private boolean deleteKey(int seq) {
    checkLoaded();
    if (seq <= keys.size() && keys.get(seq - 1).isPresent()) {
      keys.set(seq - 1, Optional.empty());
      return true;
    }
    return false;
  }

  /**
   * Marks the SSH key with the given sequence number as invalid.
   *
   * @param seq the sequence number
   * @return <code>true</code> if a key with this sequence number was found and marked as invalid,
   *     <code>false</code> if no key with the given sequence number exists or if the key was
   *     already marked as invalid
   */
  private boolean markKeyInvalid(int seq) {
    checkLoaded();

    Optional<AccountSshKey> key = keys.get(seq - 1);
    if (key.isPresent() && key.get().valid()) {
      keys.set(seq - 1, Optional.of(AccountSshKey.createInvalid(key.get())));
      return true;
    }
    return false;
  }

  /**
   * Sets new SSH keys.
   *
   * <p>The existing SSH keys are overwritten.
   *
   * @param newKeys the new public SSH keys
   */
  public void setKeys(Collection<AccountSshKey> newKeys) {
    Ordering<AccountSshKey> o = Ordering.from(comparing(AccountSshKey::seq));
    keys = new ArrayList<>(Collections.nCopies(o.max(newKeys).seq(), Optional.empty()));
    for (AccountSshKey key : newKeys) {
      keys.set(key.seq() - 1, Optional.of(key));
    }
  }

  private void checkLoaded() {
    checkState(keys != null, "SSH keys not loaded yet");
  }
}
