/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.ambari.server.serveraction.kerberos;

import java.io.File;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock;

import org.apache.ambari.server.AmbariException;
import org.apache.ambari.server.actionmanager.HostRoleStatus;
import org.apache.ambari.server.agent.CommandReport;
import org.apache.ambari.server.audit.event.kerberos.CreatePrincipalKerberosAuditEvent;
import org.apache.ambari.server.orm.dao.KerberosKeytabPrincipalDAO;
import org.apache.ambari.server.orm.dao.KerberosPrincipalDAO;
import org.apache.ambari.server.orm.entities.KerberosKeytabPrincipalEntity;
import org.apache.ambari.server.orm.entities.KerberosPrincipalEntity;
import org.apache.ambari.server.security.SecurePasswordHelper;
import org.apache.ambari.server.serveraction.ActionLog;
import org.apache.ambari.server.serveraction.kerberos.stageutils.ResolvedKerberosPrincipal;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.util.concurrent.Striped;
import com.google.inject.Inject;

/**
 * CreatePrincipalsServerAction is a ServerAction implementation that creates principals as instructed.
 * <p/>
 * This class mainly relies on the KerberosServerAction to iterate through metadata identifying
 * the Kerberos principals that need to be created. For each identity in the metadata, this implementation's
 * {@link KerberosServerAction#processIdentity(ResolvedKerberosPrincipal, KerberosOperationHandler, Map, boolean, Map)}
 * is invoked attempting the creation of the relevant principal.
 */
@SuppressWarnings("UnstableApiUsage")
public class CreatePrincipalsServerAction extends KerberosServerAction {
  private final static Logger LOG = LoggerFactory.getLogger(CreatePrincipalsServerAction.class);

  /**
   * KerberosPrincipalDAO used to set and get Kerberos principal details
   */
  @Inject
  private KerberosPrincipalDAO kerberosPrincipalDAO;

  /**
   * SecurePasswordHelper used to generate secure passwords for newly created principals
   */
  @Inject
  private SecurePasswordHelper securePasswordHelper;

  @Inject
  private KerberosKeytabPrincipalDAO kerberosKeytabPrincipalDAO;

  /**
   * Used to prevent multiple threads from working with the same principal.
   */
  private Striped<Lock> locksByPrincipal = Striped.lazyWeakLock(25);

  /**
   * A set of visited principal names used to prevent unnecessary processing on already processed
   * principal names
   */
  private Set<String> seenPrincipals = new HashSet<>();

  /**
   * Called to execute this action. Upon invocation, calls
   * {@link org.apache.ambari.server.serveraction.kerberos.KerberosServerAction#processIdentities(java.util.Map)}
   * to iterate through the Kerberos identity metadata and call
   * {@link org.apache.ambari.server.serveraction.kerberos.CreatePrincipalsServerAction#processIdentities(java.util.Map)}
   * for each identity to process.
   *
   * @param requestSharedDataContext a Map to be used as shared data among all ServerActions related
   *                                 to a given request
   * @return a CommandReport indicating the result of this action
   * @throws AmbariException
   * @throws InterruptedException
   */
  @Override
  public CommandReport execute(ConcurrentMap<String, Object> requestSharedDataContext) throws
      AmbariException, InterruptedException {
    return processIdentities(requestSharedDataContext);
  }


  /**
   * For each identity, generate a unique password, and create a new or update an existing principal in
   * an assumed to be configured KDC.
   * <p/>
   * If a password has not been previously created for the current evaluatedPrincipal, create a "secure"
   * password using {@link SecurePasswordHelper#createSecurePassword()}. Then if the principal
   * does not exist in the KDC, create it using the generated password; else if it does exist update
   * its password.  Finally store the generated password in the shared principal-to-password map and
   * store the new key numbers in the shared principal-to-key_number map so that subsequent process
   * may use the data if necessary.
   *
   * @param resolvedPrincipal        a ResolvedKerberosPrincipal object to process
   * @param operationHandler         a KerberosOperationHandler used to perform Kerberos-related
   *                                 tasks for specific Kerberos implementations
   *                                 (MIT, Active Directory, etc...)
   * @param kerberosConfiguration    a Map of configuration properties from kerberos-env
   * @param includedInFilter         a Boolean value indicating whather the principal is included in
   *                                 the current filter or not
   * @param requestSharedDataContext a Map to be used as shared data among all ServerActions related
   *                                 to a given request  @return a CommandReport, indicating an error
   *                                 condition; or null, indicating a success condition
   * @throws AmbariException if an error occurs while processing the identity record
   */
  @Override
  protected CommandReport processIdentity(ResolvedKerberosPrincipal resolvedPrincipal,
                                          KerberosOperationHandler operationHandler,
                                          Map<String, String> kerberosConfiguration,
                                          boolean includedInFilter,
                                          Map<String, Object> requestSharedDataContext)
      throws AmbariException {
    CommandReport commandReport = null;

    //  Only process this principal name if we haven't already processed it
    // TODO optimize - split invalidation and principal creation to separate stages
    if (!seenPrincipals.contains(resolvedPrincipal.getPrincipal())) {
      seenPrincipals.add(resolvedPrincipal.getPrincipal());

      boolean processPrincipal;

      KerberosPrincipalEntity kerberosPrincipalEntity = kerberosPrincipalDAO.find(resolvedPrincipal.getPrincipal());

      boolean regenerateKeytabs = getOperationType(getCommandParameters()) == OperationType.RECREATE_ALL;
      boolean servicePrincipal = resolvedPrincipal.isService();

      if(!includedInFilter) {
        // If this principal is to be filtered out, skip... unless is has not yet been created...
        regenerateKeytabs = false;
      }

      if (regenerateKeytabs) {
        // force recreation of principal due to keytab regeneration
        // regenerate only service principals if request filtered by hosts
        processPrincipal = !hasHostFilters() || servicePrincipal;
      } else if (kerberosPrincipalEntity == null) {
        // This principal has not been processed before, process it.
        processPrincipal = true;
      } else if (!StringUtils.isEmpty(kerberosPrincipalEntity.getCachedKeytabPath())) {
        // This principal has been processed, process again only if there is no physical keytab file.
        File file = new File(kerberosPrincipalEntity.getCachedKeytabPath());
        processPrincipal = !file.exists();
      } else {
        // This principal has been processed but a keytab file for it has not been distributed... process it.
        processPrincipal = true;
      }

      if (processPrincipal) {
        Map<String, String> principalPasswordMap = getPrincipalPasswordMap(requestSharedDataContext);

        String principal = resolvedPrincipal.getPrincipal();
        Lock lock = locksByPrincipal.get(principal);
        lock.lock();

        String password = principalPasswordMap.get(principal);

        try {
          if (password == null) {
            CreatePrincipalResult result = createPrincipal(resolvedPrincipal.getPrincipal(), servicePrincipal, kerberosConfiguration, operationHandler, regenerateKeytabs, actionLog);
            if (result == null) {
              commandReport = createCommandReport(1, HostRoleStatus.FAILED, "{}", actionLog.getStdOut(), actionLog.getStdErr());
            } else {
              Map<String, Integer> principalKeyNumberMap = getPrincipalKeyNumberMap(requestSharedDataContext);

              principalPasswordMap.put(resolvedPrincipal.getPrincipal(), result.getPassword());
              principalKeyNumberMap.put(resolvedPrincipal.getPrincipal(), result.getKeyNumber());
              // invalidate given principal for all keytabs to make them redistributed again
              for (KerberosKeytabPrincipalEntity kkpe: kerberosKeytabPrincipalDAO.findByPrincipal(resolvedPrincipal.getPrincipal())) {
                kkpe.setDistributed(false);
                kerberosKeytabPrincipalDAO.merge(kkpe);
              }
              // invalidate principal cache
              KerberosPrincipalEntity principalEntity = kerberosPrincipalDAO.find(resolvedPrincipal.getPrincipal());
              try {
                new File(principalEntity.getCachedKeytabPath()).delete();
              } catch (Exception e) {
                LOG.debug("Failed to delete cache file '{}'", principalEntity.getCachedKeytabPath());
              }
              principalEntity.setCachedKeytabPath(null);
              kerberosPrincipalDAO.merge(principalEntity);
            }
          }
        } finally {
          lock.unlock();
        }
      }
    }

    return commandReport;
  }

  /**
   * Creates a principal in the relevant KDC
   *
   * @param principal                the principal name to create
   * @param isServicePrincipal       true if the principal is a service principal; false if the
   *                                 principal is a user principal
   * @param kerberosConfiguration    the kerberos-env configuration properties
   * @param kerberosOperationHandler the KerberosOperationHandler for the relevant KDC
   * @param regenerateKeytabs        true if this was triggered in response to regenerating keytab files; false otherwise
   * @param actionLog                the logger (may be null if no logging is desired)
   * @return a CreatePrincipalResult containing the generated password and key number value
   */
  public CreatePrincipalResult createPrincipal(String principal, boolean isServicePrincipal,
                                               Map<String, String> kerberosConfiguration,
                                               KerberosOperationHandler kerberosOperationHandler,
                                               boolean regenerateKeytabs, ActionLog actionLog) {

    // in case this is called directly from TopologyManager there's no HostRoleCommand
    CreatePrincipalKerberosAuditEvent.CreatePrincipalKerberosAuditEventBuilder auditEventBuilder = CreatePrincipalKerberosAuditEvent.builder()
        .withTimestamp(System.currentTimeMillis())
        .withRequestId(getHostRoleCommand() != null ? getHostRoleCommand().getRequestId() : -1)
        .withTaskId(getHostRoleCommand() != null ? getHostRoleCommand().getTaskId() : -1)
        .withPrincipal(principal);
    CreatePrincipalResult result = null;
    String message = null;

    try {
      message = String.format("Processing principal, %s", principal);
      LOG.info(message);
      if (actionLog != null) {
        actionLog.writeStdOut(message);
      }

      Integer length;
      Integer minLowercaseLetters;
      Integer minUppercaseLetters;
      Integer minDigits;
      Integer minPunctuation;
      Integer minWhitespace;

      if (kerberosConfiguration == null) {
        length = null;
        minLowercaseLetters = null;
        minUppercaseLetters = null;
        minDigits = null;
        minPunctuation = null;
        minWhitespace = null;
      } else {
        length = toInt(kerberosConfiguration.get("password_length"));
        minLowercaseLetters = toInt(kerberosConfiguration.get("password_min_lowercase_letters"));
        minUppercaseLetters = toInt(kerberosConfiguration.get("password_min_uppercase_letters"));
        minDigits = toInt(kerberosConfiguration.get("password_min_digits"));
        minPunctuation = toInt(kerberosConfiguration.get("password_min_punctuation"));
        minWhitespace = toInt(kerberosConfiguration.get("password_min_whitespace"));
      }

      String password = securePasswordHelper.createSecurePassword(length, minLowercaseLetters, minUppercaseLetters, minDigits, minPunctuation, minWhitespace);

      try {
        /*
         * true indicates a new principal was created, false indicates an existing principal was updated
         */
        boolean created;
        Integer keyNumber;

        if (regenerateKeytabs) {
          try {
            keyNumber = kerberosOperationHandler.setPrincipalPassword(principal, password, isServicePrincipal);
            created = false;
          } catch (KerberosPrincipalDoesNotExistException e) {
            message = String.format("Principal, %s, does not exist, creating new principal", principal);
            LOG.warn(message);
            if (actionLog != null) {
              actionLog.writeStdOut(message);
            }

            keyNumber = kerberosOperationHandler.createPrincipal(principal, password, isServicePrincipal);
            created = true;
          }
        } else {
          try {
            keyNumber = kerberosOperationHandler.createPrincipal(principal, password, isServicePrincipal);
            created = true;
          } catch (KerberosPrincipalAlreadyExistsException e) {
            message = String.format("Principal, %s, already exists, setting new password", principal);
            LOG.warn(message);
            if (actionLog != null) {
              actionLog.writeStdOut(message);
            }

            keyNumber = kerberosOperationHandler.setPrincipalPassword(principal, password, isServicePrincipal);
            created = false;
          }
        }

        if (keyNumber != null) {
          result = new CreatePrincipalResult(principal, password, keyNumber);

          if (created) {
            message = String.format("Successfully created new principal, %s", principal);
          } else {
            message = String.format("Successfully set password for %s", principal);
          }
          LOG.debug(message);
        } else {
          if (created) {
            message = String.format("Failed to create principal, %s - unknown reason", principal);
          } else {
            message = String.format("Failed to set password for %s - unknown reason", principal);
          }
          LOG.error(message);
          if (actionLog != null) {
            actionLog.writeStdErr(message);
          }
        }

        if (!kerberosPrincipalDAO.exists(principal)) {
          kerberosPrincipalDAO.create(principal, isServicePrincipal);
        }

      } catch (KerberosOperationException e) {
        message = String.format("Failed to create principal, %s - %s", principal, e.getMessage());
        LOG.error(message, e);
        if (actionLog != null) {
          actionLog.writeStdErr(message);
        }
      }
    } finally {
      if (result == null) {
        auditEventBuilder.withReasonOfFailure(message == null ? "Unknown error" : message);
      }
      auditLog(auditEventBuilder.build());
    }

    return result;
  }

  /**
   * Translates a String containing an integer value to an Integer.
   * <p/>
   * If the string is null, empty or not a number, returns null; otherwise returns an Integer value
   * representing the integer value of the string.
   *
   * @param string the string to parse
   * @return an Integer or null
   */
  private static Integer toInt(String string) {
    if ((string == null) || string.isEmpty()) {
      return null;
    } else {
      try {
        return Integer.parseInt(string);
      } catch (NumberFormatException e) {
        return null;
      }
    }
  }

  /**
   * CreatePrincipalResult holds values created as a result of creating a principal in a KDC.
   */
  public static class CreatePrincipalResult {
    final private String principal;
    final private String password;
    final private Integer keyNumber;

    /**
     * Constructor
     *
     * @param principal a principal name
     * @param password  a password
     * @param keyNumber a key number
     */
    public CreatePrincipalResult(String principal, String password, Integer keyNumber) {
      this.principal = principal;
      this.password = password;
      this.keyNumber = keyNumber;
    }

    /**
     * Gets the principal name
     *
     * @return the principal name
     */
    public String getPrincipal() {
      return principal;
    }

    /**
     * Gets the principal's password
     *
     * @return the principal's password
     */
    public String getPassword() {
      return password;
    }

    /**
     * Gets the password's key number
     *
     * @return the password's key number
     */
    public Integer getKeyNumber() {
      return keyNumber;
    }
  }
}
