package com.capgemini.framework.common.activedirectory;

import com.capgemini.framework.common.SettingsHelper;
import com.capgemini.framework.common.SettingsHelper.ActiveDirectoryAdministration;
import com.capgemini.framework.common.SettingsHelper.ActiveDirectoryAttributes;
import com.capgemini.framework.common.SettingsHelper.ActiveDirectoryCredentials;
import com.capgemini.framework.common.StringHelper;
import com.capgemini.reports.database.HibernateUtil;
import com.capgemini.reports.database.javabean.DbUser;
import com.capgemini.reports.database.javabean.DbUser.UserData;
import com.capgemini.reports.database.manager.MngUser;
import com.capgemini.reports.exceptions.InvalidActiveDirectorySearchException;
import com.capgemini.reports.exceptions.InvalidInputException;
import com.capgemini.reports.exceptions.NoConnectionToActiveDirectoryException;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.Session;
import org.hibernate.SessionFactory;

import javax.naming.Context;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.PartialResultException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import java.io.Closeable;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;

/**
 * Query Active Directory for user data.
 */
@Slf4j
public class ActiveDirectory implements Closeable {

  /**
   * The default login for non-authenticated users.
   */
  public static final String GUEST_LOGIN = "Invité(e)";
  // Attribute names
  static final ActiveDirectoryAttributes ATTRIBUTES = SettingsHelper.getActiveDirectoryAttributes();
  // Admin data
  static final ActiveDirectoryAdministration ADMINISTRATION = SettingsHelper.getActiveDirectoryAdministration();
  private static final int SEARCH_TIMEOUT = 30000; // 30s
  private static final String AD_SEPARATOR = ": ";
  // Technical account data
  private static final ActiveDirectoryCredentials CREDENTIALS = SettingsHelper.getActiveDirectoryCredentials();
  /**
   * Domain Controller. Ex: "DC=SNECMA,DC=LOCAL".
   */
  static final String DOMAIN_CONTROLLER = getDomainBase(CREDENTIALS.getDomainName());
  private static final String MEMBER_OF_ATTRIBUTE = "memberOf"; // Default value for this attribute in LDAP
  private static final String[] REQUESTED_ATTRIBUTES =
    {ATTRIBUTES.getLogin(), ATTRIBUTES.getEmail(), ATTRIBUTES.getFirstName(), ATTRIBUTES.getLastName(), MEMBER_OF_ATTRIBUTE};
  // Guest
  private static final String DEFAULT_DESTINATOR_EMAIL = SettingsHelper.getDefaultDestinatorEmailFull();
  private static final UserData GUEST = new UserData(GUEST_LOGIN, DEFAULT_DESTINATOR_EMAIL, "Not", "Found", false);
  // required private variables
  private final DirContext dirContext;

  /*
   * Working request exemple:
   *
   * ========= REQUEST ==========
   * Base DN   : DC=SNECMA,DC=LOCAL
   * Filter    : (&(objectClass=user)(cn=snecmauser))
   * Scope     : Subtree
   * Attributes: cn;mail;
   * ========= RESPONSE =========
   * Result:
   * Searching...
   * ldap_search_s(ld, "DC=SNECMA,DC=LOCAL", 2, "(&(objectClass=user)(cn=snecmauser))", attrList,  0, &msg)
   * Getting 1 entries:
   * Dn: CN=SnecmaUser,OU=Snecma,OU=Utilisateurs,DC=snecma,DC=local
   * cn: SnecmaUser;
   * mail: SnecmaUser@snecma.local;
   */

  /**
   * Constructor with parameter for initializing a LDAP context.
   */
  protected ActiveDirectory() throws NoConnectionToActiveDirectoryException {
    Properties properties = new Properties();

    // Provider URL+port. Ex: "LDAP://snecmadc.snecma.local:389/"
    String providerUrlBase = CREDENTIALS.getProviderURL() + ":" + CREDENTIALS.getProviderPort() + "/";
    String providerURL = providerUrlBase + DOMAIN_CONTROLLER;
    // Precise security login. Ex: "sa-bind@snecma.local"
    String securityPrincipal = CREDENTIALS.getTechLogin() + "@" + CREDENTIALS.getDomainName();

    properties.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
    properties.put(Context.PROVIDER_URL, providerURL);
    properties.put(Context.SECURITY_AUTHENTICATION, "simple");
    properties.put(Context.SECURITY_PRINCIPAL, securityPrincipal);
    properties.put(Context.SECURITY_CREDENTIALS, CREDENTIALS.getTechPassword());
    properties.put("sun.security.krb5.debug", SettingsHelper.getPropertyWithDefaultValue("devmode", "false"));
    properties.put("java.naming.ldap.derefAliases", "never");
    properties.put("java.naming.ldap.version", "3");

    // Initializing active directory LDAP connection
    try {
      dirContext = new InitialDirContext(properties);
    }
    catch (NamingException e) {
      throw new NoConnectionToActiveDirectoryException("Could not connect the app to the Active Directory", e);
    }
  }

  /**
   * Returns the GUEST user, to be used when authentication failed, for any reason.
   *
   * @return the GUEST user.
   */
  public static UserData getGuestUser() {
    return GUEST;
  }

  /**
   * Returns the user's email address.
   *
   * @param userLogin the user login
   * @return the user data. Never null, would (throw an exception first but may contain null fields
   * @throws NoConnectionToActiveDirectoryException thrown if no connection could be found (appli might not be in same doain as AD?)
   * @throws InvalidActiveDirectorySearchException  thrown if search was rejected by AD (check syntax?)
   * @throws InvalidInputException                  thrown if provided username was null: no data may be retrieved then
   */
  public static UserData obtainUserData(final String userLogin)
    throws NoConnectionToActiveDirectoryException, InvalidActiveDirectorySearchException, InvalidInputException {
    if (userLogin == null) {
      throw new InvalidInputException("Null login received from request header. Cannot identify the user.");
    }

    // Connect to the AD
    try (ActiveDirectory activeDirectory = new ActiveDirectory()) {

      // Search the user
      SearchFilter searchLogin = SearchFilter.searchLogin(userLogin);

      // Obtain user data
      UserData userData;
      Attributes attrs = activeDirectory.getUserAttributes(searchLogin);
      if (attrs == null) {
        log.info("Obtained no results from ActiveDirectory search for login '{}', defaulting to GUEST", userLogin);
        userData = GUEST;
      }
      else {
        // Retrieve AD login, because teamcenter uses lower case, but in AD its Capitalization may vary!
        String loginAD = getAttribute(attrs, ATTRIBUTES.getLogin());
        String firstName = getAttribute(attrs, ATTRIBUTES.getFirstName());
        String lastName = getAttribute(attrs, ATTRIBUTES.getLastName());
        String userEmail = getAttribute(attrs, ATTRIBUTES.getEmail());

        // If the user was found, keep searching for its potential admin status
        boolean adminStatus = isAdmin(activeDirectory, searchLogin);

        userData = new UserData(loginAD, userEmail, firstName, lastName, adminStatus); // Use only AD-provided data
        log.info("Identified user: {}={}, {}={}, {}={}", ATTRIBUTES.getLogin(), userLogin, ATTRIBUTES.getFirstName(), firstName,
          ATTRIBUTES.getLastName(), lastName, ATTRIBUTES.getEmail(), userEmail);
      }
      return userData;
    }
  }

  /**
   * Connect to Active Directory and return a list of Admins Report.
   *
   * @return List DbUser
   * @throws NoConnectionToActiveDirectoryException if unable to connect to the AD
   * @throws InvalidActiveDirectorySearchException  if the search for the admin group is ill-formed
   * @throws NamingException                        if the name of the admin group is invalid
   */
  private static List<UserData> obtainAdminReport()
    throws NoConnectionToActiveDirectoryException, InvalidActiveDirectorySearchException, NamingException {
    List<UserData> adminDbUserList = new ArrayList<>();

    // Connect to the AD
    try (ActiveDirectory activeDirectory = new ActiveDirectory()) {

      // Filter Search in Group Administrator
      NamingEnumeration<SearchResult> searchEnum = activeDirectory.search(SearchFilter.searchInGroup(ADMINISTRATION.getAdminGroup()));

      while (searchEnum.hasMoreElements()) {
        Attributes attrs = searchEnum.next().getAttributes();

        if (attrs == null) {
          log.info("Obtained no results from ActiveDirectory search for users of ou='{}' ou='{}' ", ADMINISTRATION.getAdminOU(),
            ADMINISTRATION.getAdminGroup());
        }
        else {
          String login = getAttribute(attrs, ATTRIBUTES.getLogin());
          String firstName = getAttribute(attrs, ATTRIBUTES.getFirstName());
          String lastName = getAttribute(attrs, ATTRIBUTES.getLastName());
          String email = getAttribute(attrs, ATTRIBUTES.getEmail());
          UserData admin = new UserData(login, email, firstName, lastName, true); // these are all admins of course

          // add to list admin report
          adminDbUserList.add(admin);
        }
      }
    }
    return adminDbUserList;
  }

  private static boolean isAdmin(final ActiveDirectory activeDirectory, final SearchFilter searchLogin)
    throws InvalidActiveDirectorySearchException {
    SearchFilter searchInAdminGroup = SearchFilter.searchInGroup(ADMINISTRATION.getAdminGroup());
    NamingEnumeration<SearchResult> adminResult = activeDirectory.search(searchLogin, searchInAdminGroup);
    return adminResult.hasMoreElements();
  }

  private static String getAttribute(final Attributes attrs, final String attribute) {
    if (!isValidAttribute(attribute)) {
      log.warn("Attribute '{}' is asked from the Active Directory search results, but is not part of the attributes list", attribute);
    }
    Attribute keyValue = attrs.get(attribute);
    if (keyValue == null) {
      return StringHelper.EMPTY;
    }
    else {
      String userEmailResponse = keyValue.toString();
      String email;
      if (userEmailResponse.contains(AD_SEPARATOR)) {
        // expecting 'KEY: VALUE' pair from AD (note the double double dots, AND the whitespace)
        email = userEmailResponse.substring(userEmailResponse.indexOf(AD_SEPARATOR) + AD_SEPARATOR.length());
      }
      else {
        email = StringHelper.EMPTY;
      }
      return email;
    }
  }

  private static boolean isValidAttribute(final String attribute) {
    for (String requestedAttribute : REQUESTED_ATTRIBUTES) {
      if (requestedAttribute.equals(attribute)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Creating a domain base value (e.g. DC=SNECMA,DC=LOCAL) from domain controller name (e.g. snecma.local)
   *
   * @param domain a {@link String} object - name of the domain controller
   * @return a {@link String} object - base name for eg. DC=SNECMA,DC=LOCAL
   */
  private static String getDomainBase(final String domain) {
    return makeBase(domain, "DC");
  }

  /**
   * Creating a Organisational Unit base value.<br>
   * ex:
   * <ul>
   * <li>'TechnicalAccounts' becomes 'OU=TECHNICALACCOUNTS'</li>
   * <li>'snecma.utilisateurs' becomes 'OU=Snecma,OU=Utilisateurs'</li>
   * </ul>
   *
   * @param organisationalUnit a {@link String} object - name of the domain controller
   * @return a {@link String} object - base name for eg. DC=SNECMA,DC=LOCAL
   */
  static String getOrganisationalUnitBase(final String organisationalUnit) {
    return makeBase(organisationalUnit, "OU");
  }

  private static String makeBase(final String original, final String key) {
    String separator = ',' + key + '=';
    StringBuilder result = new StringBuilder(key).append('=');
    String upper = original.toUpperCase();
    result.append(upper.replace(".", separator));
    return result.toString();
  }

  /**
   * Return list contains object {@link DbUser} which are administrators report, quiet fail. <br>
   * If unable to connect to active directory, or unable to obtain a user from ActiveDirectory, returns the default user (Guest).
   *
   * @return a {@link List} of admins {@link DbUser}s
   */
  public static List<DbUser> getAdminsReportUpdateDatabase() {
    SessionFactory sessionFactory = HibernateUtil.getSessionFactory();
    Session session = sessionFactory.openSession();
    try {
      try {
        List<UserData> adAdmins = ActiveDirectory.obtainAdminReport();
        List<DbUser> dbAdmins = new MngUser().fetchOrUpdateOrCreateUsers(session, adAdmins);
        if (!dbAdmins.isEmpty()) {
          return dbAdmins;
        }
        else {
          log.warn("No administrators retrieved from AD! inserting default user (guest)");
        }
      }
      catch (NoConnectionToActiveDirectoryException e) {
        log.warn("Could not connect to the AD to findAll the list of Administrators ", e);
      }
      catch (NamingException e) {
        log.warn("AD Could not locate the Administrators group in the AD", e);
      }
      catch (InvalidActiveDirectorySearchException e) {
        log.warn("Could not perform a search on the AD, to findAll the list of Administrators", e);
      }
      // Use default user (something happened)
      ArrayList<DbUser> defaultUsers = new ArrayList<>();
      defaultUsers.add(new MngUser().fetchOrUpdateOrCreateUser(session, ActiveDirectory.getGuestUser()));
      return defaultUsers;
    }
    finally {
      session.close();
    }
  }

  private Attributes getUserAttributes(final SearchFilter searchLogin) throws InvalidActiveDirectorySearchException {
    NamingEnumeration<SearchResult> result = search(searchLogin);
    if (result == null) {
      log.debug("Filter '{}' provided null results from the Active Directory, defaulting to GUEST", searchLogin.makeFilterComponent());
      return null;
    }
    // Parse the results
    try {
      if (result.hasMoreElements()) {
        SearchResult searchResult = result.next();
        return searchResult.getAttributes();
      }
      else {
        log.info("Filter '{}' provided no result from the Active Directory, defaulting to GUEST", searchLogin.makeFilterComponent());
        return null;
      }
    }
    catch (PartialResultException pre) {
      // If a result exists, it is correctly handled in the 'try' above. if the user was not found, we will end up here upon calling hasMore()
      String errorMsg = "PartialResultException caught while obtaining data from the Active Directory";
      throw new InvalidActiveDirectorySearchException(errorMsg, pre);
    }
    catch (NamingException e) {
      String errorMsg = "Could not obtain further results from the Active Directory search results";
      throw new InvalidActiveDirectorySearchException(errorMsg, e);
    }
  }

  /**
   * Search the Active directory by username/email id for given search base.
   *
   * @param searchValues one or more {@link SearchFilter}s - to search things like login, group membership etc.
   * @return search result a {@link javax.naming.NamingEnumeration} object - active directory search result
   * @throws InvalidActiveDirectorySearchException if the search is ill-formed
   */

  public NamingEnumeration<SearchResult> search(final SearchFilter... searchValues) throws InvalidActiveDirectorySearchException {
    String filter = getFilter(searchValues);
    log.trace("Trying to perform a search with filter: " + filter);
    // Initializing search controls
    SearchControls searchCtls = new SearchControls();
    searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
    searchCtls.setReturningAttributes(REQUESTED_ATTRIBUTES);
    searchCtls.setTimeLimit(SEARCH_TIMEOUT);

    if (this.dirContext == null) {
      log.warn("Trying to perform an ActiveDirectory search, but DirContext is null");
      return null;
    }
    else {
      try {
        log.info("Performing search Using filter: " + filter);
        return this.dirContext.search("", filter, searchCtls);
      }
      catch (NamingException e) {
        throw new InvalidActiveDirectorySearchException("Could not perform a login search in the Active Directory", e);
      }
    }
  }

  /**
   * Closes the LDAP connection with Domain controller.
   */
  @Override
  public void close() {
    if (dirContext != null) {
      try {
        dirContext.close();
      }
      catch (NamingException e) {
        log.warn("Could not close LDAP connection properly", e);
      }
    }
  }

  /**
   * Active directory filter.
   *
   * @param searchBy a series of {@link SearchFilter} object - scope of search by username or email id
   * @return a {@link String} object - filter string
   */
  private String getFilter(final SearchFilter... searchBy) {
    // Open filter: search for a user
    StringBuilder filter = new StringBuilder("(&(objectClass=User)"); // <-- verifier avec SNECMA
    for (SearchFilter criteria : searchBy) {
      filter.append(criteria.makeFilterComponent());
    }
    // End filter
    filter.append(')');
    return filter.toString();
  }
}
