/**
 * Copyright (C) 2015 Red Hat, Inc.
 *
 * 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 io.fabric8.elasticsearch.plugin.acl;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

import org.apache.commons.lang.math.NumberUtils;
import org.apache.logging.log4j.Logger;
import org.elasticsearch.action.DocWriteRequest.OpType;
import org.elasticsearch.action.bulk.BulkRequest;
import org.elasticsearch.action.bulk.BulkRequestBuilder;
import org.elasticsearch.action.bulk.BulkResponse;
import org.elasticsearch.action.index.IndexRequestBuilder;
import org.elasticsearch.action.support.WriteRequest.RefreshPolicy;
import org.elasticsearch.action.update.UpdateRequestBuilder;
import org.elasticsearch.client.Client;
import org.elasticsearch.common.bytes.BytesArray;
import org.elasticsearch.common.collect.Tuple;
import org.elasticsearch.common.logging.Loggers;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.common.util.concurrent.ThreadContext.StoredContext;
import org.elasticsearch.common.xcontent.ToXContent;
import org.elasticsearch.common.xcontent.XContentHelper;
import org.elasticsearch.threadpool.ThreadPool;

import com.floragunn.searchguard.action.configupdate.ConfigUpdateAction;
import com.floragunn.searchguard.action.configupdate.ConfigUpdateRequest;
import com.floragunn.searchguard.action.configupdate.ConfigUpdateResponse;
import com.floragunn.searchguard.support.ConfigConstants;

import io.fabric8.elasticsearch.plugin.ConfigurationSettings;
import io.fabric8.elasticsearch.plugin.OpenshiftRequestContextFactory.OpenshiftRequestContext;
import io.fabric8.elasticsearch.plugin.PluginClient;
import io.fabric8.elasticsearch.plugin.PluginSettings;
import io.fabric8.elasticsearch.plugin.acl.SearchGuardRoles.Roles;
import io.fabric8.elasticsearch.plugin.acl.SearchGuardRolesMapping.RolesMapping;

/**
 * Manages process of loading and updating the ACL Documents
 * for a user request 
 *
 */
public class ACLDocumentManager implements ConfigurationSettings {
    
    private static final String [] CONFIG_DOCS = {SEARCHGUARD_ROLE_TYPE, SEARCHGUARD_MAPPING_TYPE};
    private static final Logger LOGGER = Loggers.getLogger(ACLDocumentManager.class);
    private final ReentrantLock lock = new ReentrantLock();
    private final String searchGuardIndex;
    private final PluginClient client;
    private final SearchGuardSyncStrategyFactory documentFactory;
    private final ConfigurationLoader configLoader;
    private final ThreadContext threadContext;

    public ACLDocumentManager(final PluginClient client, final PluginSettings settings, final SearchGuardSyncStrategyFactory documentFactory, ThreadPool threadPool) {
        this.searchGuardIndex = settings.getSearchGuardIndex();
        this.client = client;
        this.documentFactory = documentFactory;
        this.threadContext = threadPool.getThreadContext();
        this.configLoader = new ConfigurationLoader(client.getClient(), threadPool, settings.getSettings());
    }
    
    @SuppressWarnings("rawtypes")
    interface ACLDocumentOperation{
        
        void execute(Collection<SearchGuardACLDocument> docs);
        
        BulkRequest buildRequest(Client client, BulkRequestBuilder builder, Collection<SearchGuardACLDocument> docs) throws IOException;
    }
    
    private void logContent(final String message, final String type, final ToXContent content) throws IOException{
        if(LOGGER.isDebugEnabled()) {
            LOGGER.debug(message, type, XContentHelper.toString(content));
        }
    }
    
    private void logDebug(final String message, final Object obj) {
        if(LOGGER.isDebugEnabled()) {
            LOGGER.debug(message, obj);
        }
    }
    
    @SuppressWarnings("rawtypes")
    class SyncAndExpireOperation implements ACLDocumentOperation {
        
        private SyncFromContextOperation sync;
        private ExpireOperation expire;
        
        SyncAndExpireOperation(OpenshiftRequestContext context){
            long now = System.currentTimeMillis();
            sync = new SyncFromContextOperation(context, now);
            expire = new ExpireOperation(now);
        }

        @Override
        public void execute(Collection<SearchGuardACLDocument> docs) {
            //purposely expire and then sync to add back in
            expire.execute(docs);
            sync.execute(docs);
        }

        @Override
        public BulkRequest buildRequest(Client client, BulkRequestBuilder builder,
                Collection<SearchGuardACLDocument> docs) throws IOException {
            return expire.buildRequest(client, builder, docs);
        }
        
    }
    
    @SuppressWarnings("rawtypes")
    class SyncFromContextOperation implements ACLDocumentOperation {

        private OpenshiftRequestContext context;
        private long now;

        public SyncFromContextOperation(OpenshiftRequestContext context, final long now) {
            this.context = context;
            this.now = now;
        }
        
        @Override
        public void execute(Collection<SearchGuardACLDocument> docs) {
            LOGGER.debug("Syncing from context to ACL...");
            for (SearchGuardACLDocument doc : docs) {
                if(ConfigurationSettings.SEARCHGUARD_MAPPING_TYPE.equals(doc.getType())){
                    RolesMappingSyncStrategy rolesMappingSync = documentFactory.createRolesMappingSyncStrategy((SearchGuardRolesMapping) doc, now);
                    rolesMappingSync.syncFrom(context);
                } else if(ConfigurationSettings.SEARCHGUARD_ROLE_TYPE.equals(doc.getType())) {
                    RolesSyncStrategy rolesSync = documentFactory.createRolesSyncStrategy((SearchGuardRoles) doc, now);
                    rolesSync.syncFrom(context);
                }
            }
        }

        @Override
        public BulkRequest buildRequest(Client client, BulkRequestBuilder builder, Collection<SearchGuardACLDocument> docs) throws IOException{
            
            for (SearchGuardACLDocument doc : docs) {
                logContent("Updating {} to: {}", doc.getType(), doc);
                Map<String, Object> content = new HashMap<>();
                content.put(doc.getType(), new BytesArray(XContentHelper.toString(doc)));
                UpdateRequestBuilder update = client
                        .prepareUpdate(searchGuardIndex, doc.getType(), SEARCHGUARD_CONFIG_ID)
                        .setDoc(content);
                if(doc.getVersion() != null) {
                    update.setVersion(doc.getVersion());
                }
                builder.add(update.request());
            }
            return builder.request();
        }
    }
    
    @SuppressWarnings("rawtypes")
    class ExpireOperation implements ACLDocumentOperation {

        private long now;

        public ExpireOperation(long currentTimeMillis) {
            this.now = currentTimeMillis;
        }

        @Override
        public void execute(Collection<SearchGuardACLDocument> docs) {
            LOGGER.debug("Expiring ACLs older then {}", now);
            for (SearchGuardACLDocument doc : docs) {
                if(ConfigurationSettings.SEARCHGUARD_MAPPING_TYPE.equals(doc.getType())){
                    SearchGuardRolesMapping mappings = (SearchGuardRolesMapping) doc;
                    for (RolesMapping mapping : mappings) {
                        //assume if the value is there its intentional
                        String expire = mapping.getExpire();
                        if(expire != null && NumberUtils.isNumber(expire) && Long.parseLong(expire) < now) {
                            logDebug("Expiring rolesMapping: {}", mapping);
                            mappings.removeRolesMapping(mapping);
                        }
                    }
                } else if(ConfigurationSettings.SEARCHGUARD_ROLE_TYPE.equals(doc.getType())) {
                    SearchGuardRoles roles = (SearchGuardRoles) doc;
                    for (Roles role : roles) {
                        //assume if the value is there its intentional
                        String expire = role.getExpire();
                        if(expire != null && Long.parseLong(expire) < now) {
                            logDebug("Expiring role: {}", role);
                            roles.removeRole(role);
                        }
                    }
                }
            }
        }

        @Override
        public BulkRequest buildRequest(Client client, BulkRequestBuilder builder, Collection<SearchGuardACLDocument> docs) throws IOException{
            for (SearchGuardACLDocument doc : docs) {
                logContent("Expired doc {} to be: {}", doc.getType(), doc);
                Map<String, Object> content = new HashMap<>();
                content.put(doc.getType(), new BytesArray(XContentHelper.toString(doc)));
                IndexRequestBuilder indexBuilder = client
                        .prepareIndex(searchGuardIndex, doc.getType(), SEARCHGUARD_CONFIG_ID)
                        .setOpType(OpType.INDEX)
                        .setVersion(doc.getVersion())
                        .setSource(content);
                builder.add(indexBuilder.request());
            }
            return builder.request();
        }
    }

    public void syncAcl(OpenshiftRequestContext context) {
        if(!syncAcl(new SyncAndExpireOperation(context))){
            LOGGER.warn("Unable to sync ACLs for request from user: {}", context.getUser());
        }
    }    
    
    private boolean syncAcl(ACLDocumentOperation operation) {
        //try up to 30 seconds and then continue
        for (int n : new int [] {1 , 1 , 2 , 3 , 5 , 8}) {
            if(trySyncAcl(operation)) {
                return true;
            }
            try {
                if(LOGGER.isTraceEnabled()) {
                    LOGGER.trace("Sleeping for {}(s)", n);
                }
                Thread.sleep(n * 1000);
            } catch (InterruptedException e) {
                LOGGER.error("There was an error while trying the sleep the syncACL operation", e);
            }
        }
        return false;
    }
    
    public boolean trySyncAcl(ACLDocumentOperation operation) {
        LOGGER.debug("Syncing the ACL to ElasticSearch");
        try (StoredContext ctx = threadContext.stashContext()) {
            threadContext.putHeader(ConfigConstants.SG_CONF_REQUEST_HEADER, "true");
            lock.lock();
            @SuppressWarnings("rawtypes")
            Collection<SearchGuardACLDocument> docs = loadAcls();
            if(docs.size() < 2) {
                return false;
            }
            operation.execute(docs);

            return isSuccessfulWrite(writeAcl(operation, docs));
        } catch (Exception e) {
            LOGGER.error("Exception while syncing ACL to Elasticsearch", e);
        } finally {
            lock.unlock();
        }
        return false;
    }
    
    @SuppressWarnings("rawtypes")
    private Collection<SearchGuardACLDocument> loadAcls() throws Exception {
        LOGGER.debug("Loading SearchGuard ACL...waiting up to 30s");
        Map<String, Tuple<Settings, Long>> loadedDocs = configLoader.load(CONFIG_DOCS, 30, TimeUnit.SECONDS);
        Collection<SearchGuardACLDocument> docs = new ArrayList<>(loadedDocs.size());
        for (Entry<String, Tuple<Settings, Long>> item : loadedDocs.entrySet()) {
            Settings settings = item.getValue().v1();
            Long version = item.getValue().v2();
            Map<String, Object> original = settings.getAsStructuredMap();
            if(LOGGER.isDebugEnabled()){
                logContent("Read in {}: {}", item.getKey(), settings);
            }
            switch (item.getKey()) {
            case SEARCHGUARD_ROLE_TYPE:
                docs.add(new SearchGuardRoles(version).load(original));
                break;
            case SEARCHGUARD_MAPPING_TYPE:
                docs.add(new SearchGuardRolesMapping(version).load(original));
                break;
            }
        }
        return docs;
    }
    
    @SuppressWarnings("rawtypes")
    private BulkResponse writeAcl(ACLDocumentOperation operation, Collection<SearchGuardACLDocument> docs) throws Exception {
        BulkRequestBuilder builder = client.getClient().prepareBulk().setRefreshPolicy(RefreshPolicy.WAIT_UNTIL);
        BulkRequest request = operation.buildRequest(this.client.getClient(), builder, docs);
        client.addCommonHeaders();
        return this.client.getClient().bulk(request).actionGet();
    }
    
    private boolean isSuccessfulWrite(BulkResponse response) {
        if(!response.hasFailures()) {
            ConfigUpdateRequest confRequest = new ConfigUpdateRequest(SEARCHGUARD_INITIAL_CONFIGS);
            client.addCommonHeaders();
            try {
                ConfigUpdateResponse cur = this.client.getClient().execute(ConfigUpdateAction.INSTANCE, confRequest).actionGet();
                final int totNodes = cur.getNodes().size();
                if (totNodes > 0) {
                    LOGGER.debug("Successfully reloaded config with '{}' nodes", totNodes);
                }else {
                    LOGGER.warn("Failed to reloaded configs", totNodes);
                }
            }catch(Exception e) {
                LOGGER.error("Unable to notify of an ACL config update", e);
            }
            return true;
        } else {
            LOGGER.debug("Unable to write ACL {}", response.buildFailureMessage());
        }
        return false;
    }
}