/*
 * 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.jackrabbit.oak.composite.checks;

import static org.apache.jackrabbit.oak.plugins.index.IndexConstants.INDEX_CONTENT_NODE_NAME;
import static org.apache.jackrabbit.oak.plugins.index.IndexConstants.INDEX_DEFINITIONS_NAME;
import static org.apache.jackrabbit.oak.plugins.index.IndexConstants.UNIQUE_PROPERTY_NAME;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;

import org.apache.felix.scr.annotations.Component;
import org.apache.felix.scr.annotations.Service;
import org.apache.jackrabbit.oak.api.Tree;
import org.apache.jackrabbit.oak.composite.MountedNodeStore;
import org.apache.jackrabbit.oak.plugins.index.property.Multiplexers;
import org.apache.jackrabbit.oak.plugins.index.property.strategy.IndexEntry;
import org.apache.jackrabbit.oak.plugins.index.property.strategy.UniqueEntryStoreStrategy;
import org.apache.jackrabbit.oak.spi.mount.Mount;
import org.apache.jackrabbit.oak.spi.mount.MountInfoProvider;
import org.apache.jackrabbit.oak.spi.query.Filter;
import org.apache.jackrabbit.oak.spi.state.ChildNodeEntry;
import org.apache.jackrabbit.oak.spi.state.NodeState;
import org.apache.jackrabbit.oak.spi.state.NodeStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.jackrabbit.guava.common.collect.Maps;

/**
 * Checker that ensures the consistency of unique entries in the various mounts
 * 
 * <p>For all unique indexes, it checks that the uniqueness constraint holds when
 * taking into account the combined index from all the mounts, including the global one.</p>
 * 
 * <p>Being a one-off check, it does not strictly implement the {@link #check(MountedNodeStore, Tree, ErrorHolder, Context)}
 * contract in terms of navigating the specified tree, but instead accesses the index definitions node directly
 * on first access and skips all subsequent executions.</p>
 *
 */
@Component
@Service(MountedNodeStoreChecker.class)
public class UniqueIndexNodeStoreChecker implements MountedNodeStoreChecker<UniqueIndexNodeStoreChecker.Context> {
    
    private static final Logger LOG = LoggerFactory.getLogger(UniqueIndexNodeStoreChecker.class);
    
    @Override
    public Context createContext(NodeStore globalStore, MountInfoProvider mip) {

        Context ctx = new Context(mip);
        
        // read definitions from oak:index, and pick all unique indexes
        NodeState indexDefs = globalStore.getRoot().getChildNode(INDEX_DEFINITIONS_NAME);
        for ( ChildNodeEntry indexDef : indexDefs.getChildNodeEntries() ) {
            if ( indexDef.getNodeState().hasProperty(UNIQUE_PROPERTY_NAME) &&
                    indexDef.getNodeState().getBoolean(UNIQUE_PROPERTY_NAME) ) {
                ctx.add(indexDef, mip.getDefaultMount(), indexDefs, this);
                ctx.track(new MountedNodeStore(mip.getDefaultMount() , globalStore));
            }
        }
        
        return ctx;
    }
    
    @Override
    public boolean check(MountedNodeStore mountedStore, Tree tree, ErrorHolder errorHolder, Context context) {
        context.track(mountedStore);
        
        // gather index definitions owned by this mount
        NodeState indexDefs = mountedStore.getNodeStore().getRoot().getChildNode(INDEX_DEFINITIONS_NAME);
        
        for ( ChildNodeEntry indexDef : indexDefs.getChildNodeEntries() ) {
            if ( indexDef.getNodeState().hasProperty(UNIQUE_PROPERTY_NAME) &&
                    indexDef.getNodeState().getBoolean(UNIQUE_PROPERTY_NAME) ) {
                
                String mountIndexDefName = Multiplexers.getNodeForMount(mountedStore.getMount(), INDEX_CONTENT_NODE_NAME);
                
                NodeState mountIndexDef = indexDef.getNodeState().getChildNode(mountIndexDefName);
                
                if ( mountIndexDef.exists() ) {
                    context.add(indexDef, mountedStore.getMount(), indexDefs, this);
                }
            }
        }

        // execute checks
        context.runChecks(context, errorHolder);
        
        return false;
    }
    
    static class Context {
        private final MountInfoProvider mip;
        private final Map<String, IndexCombination> combinations = new HashMap<>();
        private final Map<String, MountedNodeStore> mountedNodeStoresByName = Maps.newHashMap();
        
        Context(MountInfoProvider mip) {
            this.mip = mip;
        }

        public void track(MountedNodeStore mountedNodeStore) {
            mountedNodeStoresByName.put(mountedNodeStore.getMount().getName(), mountedNodeStore);
        }

        public void add(ChildNodeEntry rootIndexDef, Mount mount, NodeState indexDef, UniqueIndexNodeStoreChecker checker) {

            IndexCombination combination = combinations.get(rootIndexDef.getName());
            if ( combination == null ) {
                combination = new IndexCombination(rootIndexDef, checker);
                combinations.put(rootIndexDef.getName(), combination);
            }
            
            combination.addEntry(mount, indexDef);
        }
        
        public MountInfoProvider getMountInfoProvider() {
            
            return mip;
        }
        
        public void runChecks(Context context, ErrorHolder errorHolder) {
            for ( IndexCombination combination: combinations.values() ) {
                combination.runCheck(context, errorHolder);
            }
        }
    }
    
    static class IndexCombination {
        private final ChildNodeEntry rootIndexDef;
        private final UniqueIndexNodeStoreChecker checker;
        private final Map<Mount, NodeState> indexEntries = Maps.newHashMap();
        private final List<Mount[]> checked = new ArrayList<>();
        // bounded as the ErrorHolder has a reasonable limit of entries before it fails immediately
        private final Set<String> reportedConflictingValues = new HashSet<>();
        
        IndexCombination(ChildNodeEntry rootIndexDef, UniqueIndexNodeStoreChecker checker) {
            this.checker = checker;
            this.rootIndexDef = rootIndexDef;
        }
        
        public void addEntry(Mount mount, NodeState indexDef) {
            
            if ( !indexEntries.containsKey(mount) )
                indexEntries.put(mount, indexDef);
        }
        
        public void runCheck(Context context, ErrorHolder errorHolder) {
            
            for ( Map.Entry<Mount, NodeState> indexEntry : indexEntries.entrySet() ) {
                for ( Map.Entry<Mount, NodeState> indexEntry2 : indexEntries.entrySet() ) {
                    
                    // same entry, skip
                    if ( indexEntry.getKey().equals(indexEntry2.getKey()) ) {
                        continue;
                    }
                    
                    // ensure we don't check twice
                    if ( wasChecked(indexEntry.getKey(), indexEntry2.getKey() )) {
                        continue;
                    }
                    
                    check(indexEntry, indexEntry2, context, errorHolder);
                    
                    recordChecked(indexEntry.getKey(), indexEntry2.getKey());
                }
            }
        }

        private boolean wasChecked(Mount first, Mount second) {

            for ( Mount[] checkedEntry : checked ) {
                if ( ( checkedEntry[0].equals(first) && checkedEntry[1].equals(second) ) ||
                        ( checkedEntry[1].equals(first) && checkedEntry[0].equals(second))) {
                    return true;
                }
            }
            
            return false;
        }
        
        private void recordChecked(Mount first, Mount second) {
            
            checked.add(new Mount[] { first, second });
        }
        
        private void check(Entry<Mount, NodeState> indexEntry, Entry<Mount, NodeState> indexEntry2, Context ctx, ErrorHolder errorHolder) {

            String indexName = rootIndexDef.getName();
            
            // optimisation: sort strategies to ensuer that we query all the entries from the mount and check them
            // against the default. this way we'll run a significantly smaller number of checks. The assumption is
            // that the non-default mount will always hold a larger number of entries
            TreeSet<StrategyWrapper> wrappers = new TreeSet<>();
            wrappers.add(getWrapper(indexEntry, indexName, ctx));
            wrappers.add(getWrapper(indexEntry2, indexName, ctx));
            
            StrategyWrapper wrapper = wrappers.first();
            StrategyWrapper wrapper2 = wrappers.last();
            
            LOG.info("Checking index definitions for {} between mounts {} and {}", indexName, wrapper.mount.getName(), wrapper2.mount.getName());
            
            for ( IndexEntry hit : wrapper.queryAll() ) {
                Optional<IndexEntry> result = wrapper2.queryOne(hit.getPropertyValue());
                if ( result.isPresent() ) {
                    IndexEntry hit2 = result.get();
                    if ( reportedConflictingValues.add(hit.getPropertyValue())) {
                        errorHolder.report(wrapper.nodeStore, hit.getPath(), wrapper2.nodeStore, hit2.getPath(), 
                                hit.getPropertyValue(), "duplicate unique index entry", checker);
                    }
                }
            }
        }

        private StrategyWrapper getWrapper(Entry<Mount, NodeState> indexEntry, String indexName, Context ctx) {

            NodeState indexNode = indexEntry.getValue();
            Mount mount = indexEntry.getKey();
            MountedNodeStore mountedNodeStore = ctx.mountedNodeStoresByName.get(mount.getName());
            
            UniqueEntryStoreStrategy strategy = mount.isDefault() ? new UniqueEntryStoreStrategy() : 
                new UniqueEntryStoreStrategy(Multiplexers.getNodeForMount(mount, INDEX_CONTENT_NODE_NAME));
            
            return new StrategyWrapper(strategy, indexNode.getChildNode(indexName), indexName, mountedNodeStore);
        }
    }
    
    private static class StrategyWrapper implements Comparable<StrategyWrapper> {
        
        private static final int PRIORITY_DEFAULT = 0;
        private static final int PRIORITY_MOUNT = 100;
        
        private final UniqueEntryStoreStrategy strategy;
        private final int priority;
        private final NodeState indexNode;
        private final Mount mount;
        private final MountedNodeStore nodeStore;
        private final String indexName;
        
        private StrategyWrapper(UniqueEntryStoreStrategy strategy, NodeState indexNode, String indexName, MountedNodeStore nodeStore) {
            this.strategy = strategy;
            this.mount = nodeStore.getMount();
            this.indexNode = indexNode;
            this.nodeStore = nodeStore;
            this.indexName = indexName;
            this.priority = mount.isDefault() ? PRIORITY_MOUNT : PRIORITY_DEFAULT;            
        }

        @Override
        public int compareTo(StrategyWrapper o) {
            int prioCmp = Integer.compare(priority, o.priority);
            if ( prioCmp != 0 ) {
                return prioCmp;
            }
            return mount.getName().compareTo(o.mount.getName());
        }
        
        public Iterable<IndexEntry> queryAll() {
            return strategy.queryEntries(Filter.EMPTY_FILTER, indexName, indexNode, null);
        }
        
        public Optional<IndexEntry> queryOne(String value) {
            
            Iterable<IndexEntry> results = strategy.queryEntries(Filter.EMPTY_FILTER, indexName, indexNode, Collections.singleton(value));
            if ( !results.iterator().hasNext() ) {
                return Optional.empty();
            }
            
            return Optional.of(results.iterator().next());
        }
    }
}
