/*
 * JBoss, Home of Professional Open Source.
 * Copyright 2013, Red Hat, Inc., and individual contributors
 * as indicated by the @author tags. See the copyright.txt file in the
 * distribution for a full listing of individual contributors.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.jboss.as.domain.management.security;

import static org.jboss.as.domain.management.ModelDescriptionConstants.IGNORE;
import static org.jboss.as.domain.management.logging.DomainManagementLogger.SECURITY_LOGGER;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.Set;

import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.PartialResultException;
import javax.naming.Context;
import javax.naming.NameNotFoundException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.LdapReferralException;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;

import org.jboss.as.domain.management.security.BaseLdapGroupSearchResource.GroupName;

/**
 * Factory for supplying LDAP searches for groups.
 *
 * @author <a href="mailto:darran.lofthouse@jboss.com">Darran Lofthouse</a>
 */
public class LdapGroupSearcherFactory {

    private static final int searchTimeLimit = 10000;

    @Deprecated
    private static final String PARSE_ROLES_FROM_DN = "org.jboss.as.domain.management.security.parseGroupNameFromLdapDN";

    static LdapSearcher<LdapEntry[], LdapEntry> createForGroupToPrincipal(final String baseDn, final String groupDnAttribute,
            final String groupNameAttribute, final String principalAttribute, final boolean recursive,
            final GroupName searchBy, final boolean preferOriginalConnection) {
        return new GroupToPrincipalSearcher(baseDn, groupDnAttribute, groupNameAttribute, principalAttribute, recursive, searchBy, preferOriginalConnection);
    }

    static LdapSearcher<LdapEntry[], LdapEntry> createForPrincipalToGroup(final String groupAttribute, final String groupNameAttribute,
                                                                          final boolean preferOriginalConnection, final boolean skipMissingGroups, final boolean usingSimpleNames,
                                                                          final boolean shouldParseGroupFromDN) {
        return new PrincipalToGroupSearcher(groupAttribute, groupNameAttribute, preferOriginalConnection, skipMissingGroups, usingSimpleNames, shouldParseGroupFromDN);
    }

    private static SearchControls createSearchControl(final boolean recursive, final String[] attributes) {
        if (SECURITY_LOGGER.isTraceEnabled()) {
            SECURITY_LOGGER.tracef("createSearchControl recursive=%b,  attributes=%s", recursive, Arrays.toString(attributes));
        }
        // 2 - Search to identify the DN of the user connecting
        SearchControls searchControls = new SearchControls();
        if (recursive) {
            searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
        } else {
            searchControls.setSearchScope(SearchControls.ONELEVEL_SCOPE);
        }
        searchControls.setReturningAttributes(attributes);
        searchControls.setTimeLimit(searchTimeLimit);
        return searchControls;
    }

    private static String[] createArray(final String... elements) {
        ArrayList<String> elementList = new ArrayList<String>(elements.length);
        for (String current : elements) {
            if (current != null) {
                elementList.add(current);
            }
        }

        return elementList.toArray(new String[elementList.size()]);
    }

    private static class GroupToPrincipalSearcher implements LdapSearcher<LdapEntry[], LdapEntry> {

        private final String baseDn;
        private final String groupDnAttribute;
        private final String groupNameAttribute;
        private final String[] attributeArray;
        private final String filterString;
        private final boolean recursive;
        private final GroupName searchBy;
        private final boolean preferOriginalConnection;

        private GroupToPrincipalSearcher(final String baseDn, final String groupDnAttribute, final String groupNameAttribute,
                final String principalAttribute, final boolean recursive, final GroupName searchBy, final boolean preferOriginalConnection) {
            this.baseDn = baseDn;
            this.groupDnAttribute = groupDnAttribute;
            this.groupNameAttribute = groupNameAttribute;
            this.attributeArray = createArray(groupDnAttribute, groupNameAttribute);
            this.filterString = String.format("(%s={0})", principalAttribute);
            this.recursive = recursive;
            this.searchBy = searchBy;
            this.preferOriginalConnection = preferOriginalConnection;

            if (SECURITY_LOGGER.isTraceEnabled()) {
                SECURITY_LOGGER.tracef("GroupToPrincipalSearcher baseDn=%s", baseDn);
                SECURITY_LOGGER.tracef("GroupToPrincipalSearcher groupDnAttribute=%s", groupDnAttribute);
                SECURITY_LOGGER.tracef("GroupToPrincipalSearcher groupNameAttribute=%s", groupNameAttribute);
                SECURITY_LOGGER.tracef("GroupToPrincipalSearcher attributeArray=%s", Arrays.toString(attributeArray));
                SECURITY_LOGGER.tracef("GroupToPrincipalSearcher filterString=%s", filterString);
                SECURITY_LOGGER.tracef("GroupToPrincipalSearcher recursive=%b", recursive);
                SECURITY_LOGGER.tracef("GroupToPrincipalSearcher searchBy=%s", searchBy);
                SECURITY_LOGGER.tracef("GroupToPrincipalSearcher preferOriginalConnection=%b", preferOriginalConnection);
            }
        }

        @Override
        public LdapEntry[] search(LdapConnectionHandler originalConnectionHandler, LdapEntry entry) throws IOException, NamingException {
            SearchControls searchControls = createSearchControl(recursive, attributeArray); // TODO - Can we create this in
                                                                                            // advance?
            Set<LdapEntry> foundEntries = new HashSet<LdapEntry>();

            LdapConnectionHandler connectionHandler = originalConnectionHandler;
            URI referralAddress = null;
            if ((referralAddress = entry.getReferralUri()) != null && preferOriginalConnection==false) {
                connectionHandler = connectionHandler.findForReferral(referralAddress);
                if (connectionHandler == null) {
                    SECURITY_LOGGER.tracef("Unable to obtain connection handler for referral URI %s", referralAddress);
                    return foundEntries.toArray(new LdapEntry[foundEntries.size()]);
                }
            } else {
                // Be sure it is back to null to prevent leaking into LdapEntry for results that may be used for an iterative search.
                referralAddress = null;
            }

            Object[] searchParameter = getSearchParameter(entry);
            boolean trace = SECURITY_LOGGER.isTraceEnabled();
            if (trace) {
                SECURITY_LOGGER.tracef("Performing search baseDn=%s, filterString=%s, searchParameter=%s", baseDn,
                        filterString, Arrays.toString(searchParameter));
            }
            NamingEnumeration<SearchResult> searchResults = connectionHandler.getConnection().search(baseDn, filterString, searchParameter, searchControls);
            if (trace && searchResults.hasMore() == false) {
                SECURITY_LOGGER.trace("No search results found.");
            }
            while (searchResults.hasMore()) {
                SearchResult current = searchResults.next();
                Attributes attributes = current.getAttributes();
                if (attributes != null) {
                    LdapEntry newEntry = convertToLdapEntry(current, attributes, referralAddress);
                    SECURITY_LOGGER.tracef("Adding %s", newEntry);
                    foundEntries.add(newEntry);
                } else {
                    SECURITY_LOGGER.tracef("No attributes found for %s", current);
                }
            }

            return foundEntries.toArray(new LdapEntry[foundEntries.size()]);
        }

        private LdapEntry convertToLdapEntry(SearchResult searchResult, Attributes attributes, final URI referralAddress) throws NamingException {
            String simpleName = null;
            String distinguishedName = null;

            if (groupNameAttribute != null) {
                SECURITY_LOGGER.tracef("Getting groupNameAttribute=%s", groupNameAttribute);
                Attribute groupNameAttr = attributes.get(groupNameAttribute);
                if (groupNameAttr != null) {
                    simpleName = (String) groupNameAttr.get();
                }
            }

            if (groupDnAttribute != null) {
                if ("dn".equals(groupDnAttribute)) {
                    SECURITY_LOGGER.trace("Obtaining dn using getNameInNamespace()");
                    distinguishedName = searchResult.getNameInNamespace();
                } else {
                    SECURITY_LOGGER.tracef("Getting groupDnAttribute=%s", groupDnAttribute);
                    Attribute groupDnAttr = attributes.get(groupDnAttribute);
                    if (groupDnAttr != null) {
                        distinguishedName = (String) groupDnAttr.get();
                    }
                }
            }

            return new LdapEntry(simpleName, distinguishedName, referralAddress);
        }

        private Object[] getSearchParameter(final LdapEntry entry) {
            switch (searchBy) {
                case SIMPLE:
                    return new String[] { entry.getSimpleName() };
                default:
                    return new String[] { entry.getDistinguishedName() };
            }
        }

    }

    private static class PrincipalToGroupSearcher implements LdapSearcher<LdapEntry[], LdapEntry> {

        private final String groupAttribute; // The attribute on the principal that references the group it is a member of.
        private final String groupNameAttribute; // The attribute on the group that is it's simple name.
        private final boolean preferOriginalConnection; // After a referral should we still prefer the original connection?
        private final boolean skipMissingGroups;
        private final boolean usingSimpleNames; // If set to true the groups are going to be mapped using a simple name so essential all entries have one.
        private final boolean shouldParseGroupFromDN;

        private PrincipalToGroupSearcher(final String groupAttribute, final String groupNameAttribute, final boolean preferOriginalConnection,
                                         final boolean skipMissingGroups, final boolean usingSimpleName, final boolean shouldParseGroupFromDN) {
            this.groupAttribute = groupAttribute;
            this.groupNameAttribute = groupNameAttribute;
            this.preferOriginalConnection = preferOriginalConnection;
            this.skipMissingGroups = skipMissingGroups;
            this.usingSimpleNames = usingSimpleName;
            if (!shouldParseGroupFromDN) {
                String propertyValue = SecurityActions.getSystemProperty(PARSE_ROLES_FROM_DN, null);
                if (propertyValue != null) {
                    this.shouldParseGroupFromDN = Boolean.valueOf(propertyValue);
                    SECURITY_LOGGER.usingDeprecatedSystemProperty(PARSE_ROLES_FROM_DN);
                } else {
                    this.shouldParseGroupFromDN = shouldParseGroupFromDN;
                }
            } else {
                this.shouldParseGroupFromDN = shouldParseGroupFromDN;
            }

            if (SECURITY_LOGGER.isTraceEnabled()) {
                SECURITY_LOGGER.tracef("PrincipalToGroupSearcher groupAttribute=%s", groupAttribute);
                SECURITY_LOGGER.tracef("PrincipalToGroupSearcher groupNameAttribute=%s", groupNameAttribute);
                SECURITY_LOGGER.tracef("PrincipalToGroupSearcher preferOriginalConnection=%b", preferOriginalConnection);
                SECURITY_LOGGER.tracef("PrincipalToGroupSearcher skipMissingGroups=%b", skipMissingGroups);
                SECURITY_LOGGER.tracef("PrincipalToGroupSearcher usingSimpleNames=%b", usingSimpleNames);
                SECURITY_LOGGER.tracef("PrincipalToGroupSearcher shouldParseGroupFromDN=%b", this.shouldParseGroupFromDN);
            }
        }

        @Override
        public LdapEntry[] search(LdapConnectionHandler originalConnectionHandler, LdapEntry entry) throws IOException, NamingException {
            Set<LdapEntry> foundEntries = new HashSet<LdapEntry>();

            LdapConnectionHandler connectionHandler = originalConnectionHandler;
            URI originalReferralAddress = null;
            if ((originalReferralAddress = entry.getReferralUri()) != null) {
                // To load the list of groups references we will always need to have followed the referral.
                connectionHandler = connectionHandler.findForReferral(originalReferralAddress);
                if (connectionHandler == null) {
                    SECURITY_LOGGER.tracef("Unable to obtain connection handler for referral URI %s", originalReferralAddress);
                    return foundEntries.toArray(new LdapEntry[foundEntries.size()]);
                }
            }

            DirContext dirContext = connectionHandler.getConnection();
            // Load the list of group - before reaching this point any referrals should have already been followed so we
            // do not prepare to follow a referral here.
            Attributes groups = dirContext.getAttributes(entry.getDistinguishedName(), new String[] { groupAttribute });
            Attribute groupRef = groups.get(groupAttribute);

            if (preferOriginalConnection) {
                // If needed reset the connection handler back to the original in
                // preparation for loading the actual groups.
                connectionHandler = originalConnectionHandler;
                originalReferralAddress = null;
            }
            if (groupRef != null && groupRef.size() > 0) {
                NamingEnumeration<String> groupRefValues = (NamingEnumeration<String>) groupRef.getAll();
                while (groupRefValues.hasMore()) {
                    String distingushedName = groupRefValues.next().replace("\\", "\\\\").replace("/", "\\/");
                    SECURITY_LOGGER.tracef("Group found with distinguishedName=%s", distingushedName);

                    LdapConnectionHandler groupLoadHandler = connectionHandler;
                    URI groupReferralAddress = originalReferralAddress;

                    if (shouldParseGroupFromDN) {
                        // skip extra ldap search and instead parse group from DN.  similar to parseRoleNameFromDN in LdapExtLoginModule
                        LdapEntry parsedGroup = parseRole(distingushedName, groupNameAttribute, groupReferralAddress);
                        if (parsedGroup != null) {
                            SECURITY_LOGGER.tracef("Parsed group %s for group with distinguishedName=%s", parsedGroup.getSimpleName(), parsedGroup.getDistinguishedName());
                            foundEntries.add(parsedGroup);
                        } else {
                            SECURITY_LOGGER.tracef("Failed to parse %s from distinguishedName=%s", groupNameAttribute, distingushedName);
                        }
                        continue;
                    }

                    boolean retry = false;

                    String simpleName = null;

                    do {
                        retry = false;

                        try {
                            dirContext = groupLoadHandler.getConnection();
                            // Load the Name
                            Attributes groupNameAttrs = dirContext.getAttributes(distingushedName,
                                    groupNameAttribute != null ? new String[] { groupNameAttribute } : new String[] {});

                            if (groupNameAttribute != null) {
                                Attribute groupNameAttr = groupNameAttrs.get(groupNameAttribute);
                                if (groupNameAttr != null) {
                                    simpleName = (String) groupNameAttr.get();
                                    SECURITY_LOGGER.tracef("simpleName %s loaded for group with distinguishedName=%s", simpleName,
                                            distingushedName);
                                } else {
                                    SECURITY_LOGGER.tracef("attribute %s not loaded for group with distinguishedName=%s", groupNameAttr,
                                            distingushedName);
                                }
                            } else {
                                SECURITY_LOGGER.trace("No groupNameAttribute to load simpleName");
                            }
                            if (simpleName == null && usingSimpleNames) {
                                if (skipMissingGroups == false) {
                                    throw SECURITY_LOGGER.unableToLoadSimpleNameForGroup(distingushedName);
                                }
                            } else {
                                foundEntries.add(new LdapEntry(simpleName, distingushedName, groupReferralAddress));
                            }
                        } catch (NameNotFoundException e) {
                            SECURITY_LOGGER.tracef("Failed to query roleNameAttrName: %s", e.getMessage());
                            if (skipMissingGroups == false) {
                                throw e;
                            }
                        } catch (PartialResultException e) {
                            Hashtable<?, ?> env = null;
                            if (dirContext != null && (env = dirContext.getEnvironment()) != null && IGNORE.equals(env.get(Context.REFERRAL))) {
                                /* We can ignore the PartialResultException if we asked for ignoring the referrals
                                 * - see WFCORE-2341 */
                                SECURITY_LOGGER.tracef(e, "Ignoring a failed referral query roleNameAttrName");
                            } else {
                                throw e;
                            }
                        } catch (LdapReferralException e) {
                            Object info = e.getReferralInfo();
                            try {
                                URI fullUri = new URI(info.toString());
                                groupReferralAddress = new URI(fullUri.getScheme(), null, fullUri.getHost(), fullUri.getPort(),
                                        null, null, null);
                                distingushedName = fullUri.getPath().substring(1);
                                SECURITY_LOGGER.tracef("Received referral with address '%s' for dn '%s'",
                                        groupReferralAddress.toString(), distingushedName);

                                groupLoadHandler = groupLoadHandler.findForReferral(groupReferralAddress);
                                if (groupLoadHandler == null) {
                                    SECURITY_LOGGER.tracef("Unable to follow referral to '%s'", fullUri);
                                    //exception is propagated to avoid nullPointerException in next iteration
                                    throw e;
                                }
                                retry = true;
                            } catch (URISyntaxException ue) {
                                SECURITY_LOGGER.tracef("Unable to construct URI from referral: %s", info);
                            }
                        }
                    } while (retry);
                }
            } else {
                SECURITY_LOGGER.tracef("No groups found for %s", entry);
            }

            return foundEntries.toArray(new LdapEntry[foundEntries.size()]);
        }

        private LdapEntry parseRole(String dn, String groupNameAttribute, URI groupReferralAddress) {

            try {
                LdapName ldapName = new LdapName(Rdn.unescapeValue(dn).toString());
                for (int i = ldapName.size() - 1; i >= 0; i--) {
                    String rdnString = ldapName.get(i);
                    Rdn rdn = new Rdn(rdnString);
                    Attribute attr = rdn.toAttributes().get(groupNameAttribute);
                    if (attr != null) {
                        Object value = attr.get();
                        if (value != null) {
                            return new LdapEntry( (value instanceof byte[]) ? new String((byte[]) value, StandardCharsets.UTF_8) : value.toString(), dn, groupReferralAddress);
                        }
                    }
                }
            } catch (NamingException e) {
                SECURITY_LOGGER.tracef("Unable to parse role from DN (%s): %s", dn, e.getMessage());
            }
            return null;
        }

    }

}
