/*
 * 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.core.persistence.bundle;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;

import javax.jcr.RepositoryException;

import org.apache.jackrabbit.core.cluster.ClusterException;
import org.apache.jackrabbit.core.cluster.Update;
import org.apache.jackrabbit.core.cluster.UpdateEventChannel;
import org.apache.jackrabbit.core.id.NodeId;
import org.apache.jackrabbit.core.observation.EventState;
import org.apache.jackrabbit.core.persistence.check.ConsistencyCheckListener;
import org.apache.jackrabbit.core.persistence.check.ConsistencyReport;
import org.apache.jackrabbit.core.persistence.check.ConsistencyReportImpl;
import org.apache.jackrabbit.core.persistence.check.ReportItem;
import org.apache.jackrabbit.core.persistence.util.NodeInfo;
import org.apache.jackrabbit.core.persistence.util.NodePropBundle;
import org.apache.jackrabbit.core.state.ChangeLog;
import org.apache.jackrabbit.core.state.DummyUpdateEventChannel;
import org.apache.jackrabbit.core.state.ItemState;
import org.apache.jackrabbit.core.state.ItemStateException;
import org.apache.jackrabbit.core.state.NodeState;
import org.apache.jackrabbit.spi.Name;
import org.apache.jackrabbit.spi.NameFactory;
import org.apache.jackrabbit.spi.commons.name.NameConstants;
import org.apache.jackrabbit.spi.commons.name.NameFactoryImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ConsistencyCheckerImpl {

    private static Logger log = LoggerFactory.getLogger(ConsistencyCheckerImpl.class);

    /**
     * The number of nodes to fetch at once from the persistence manager. Defaults to 8kb
     */
    private static final int NODESATONCE = Integer.getInteger("org.apache.jackrabbit.checker.nodesatonce", 1024 * 8);

    /**
     * Attribute name used to store the size of the update.
     */
    private static final String ATTRIBUTE_UPDATE_SIZE = "updateSize";

    private final AbstractBundlePersistenceManager pm;
    private final ConsistencyCheckListener listener;
    private NodeId lostNFoundId;
    private UpdateEventChannel eventChannel = new DummyUpdateEventChannel();
    private Map<NodeId, NodePropBundle> bundles;
    private List<ConsistencyCheckerError> errors;
    private int nodeCount;
    private long elapsedTime;

    public ConsistencyCheckerImpl(AbstractBundlePersistenceManager pm, ConsistencyCheckListener listener,
                                  String lostNFoundId, final UpdateEventChannel eventChannel) {
        this.pm = pm;
        this.listener = listener;
        if (lostNFoundId != null) {
            this.lostNFoundId = new NodeId(lostNFoundId);
        }
        if (eventChannel != null) {
            this.eventChannel = eventChannel;
        }
    }

    /**
     * Check the database for inconsistencies.
     *
     * @param uuids a list of node identifiers to check or {@code null} in order to check all nodes
     * @param recursive  whether to recursively check the subtrees below the nodes identified by the provided uuids
     * @throws RepositoryException
     */
    public void check(String[] uuids, boolean recursive) throws RepositoryException {
        errors = new ArrayList<ConsistencyCheckerError>();
        long tstart = System.currentTimeMillis();
        nodeCount = internalCheckConsistency(uuids, recursive);
        elapsedTime = System.currentTimeMillis() - tstart;
    }

    /**
     * Do a double check on the errors found during {@link #check}.
     * Removes all false positives from the report.
     */
    public void doubleCheckErrors() {
        if (hasErrors()) {
            final Iterator<ConsistencyCheckerError> errorIterator = errors.iterator();
            while (errorIterator.hasNext()) {
                final ConsistencyCheckerError error = errorIterator.next();
                try {
                    if (!error.doubleCheck()) {
                        info(null, "False positive: " + error);
                        errorIterator.remove();
                    }
                } catch (ItemStateException e) {
                    error(null, "Failed to double check error: " + error, e);
                }
            }
        }
    }

    /**
     * Return the report of a consistency {@link #check} / {@link #doubleCheckErrors()} / {@link #repair}
     */
    public ConsistencyReport getReport() {
        final Set<ReportItem> reportItems = new HashSet<ReportItem>();
        if (hasErrors()) {
            for (ConsistencyCheckerError error : errors) {
                reportItems.add(error.getReportItem());
            }
        }
        return new ConsistencyReportImpl(nodeCount, elapsedTime, reportItems);
    }

    /**
     * Repair any errors found during a {@link #check}. Should be run after a {#check} and
     * (if needed) {@link #doubleCheckErrors}.
     *
     * @throws RepositoryException
     */
    public void repair() throws RepositoryException {
        checkLostNFound();
        bundles = new HashMap<NodeId, NodePropBundle>();
        if (hasRepairableErrors()) {
            boolean successful = false;
            final CheckerUpdate update = new CheckerUpdate();
            try {
                eventChannel.updateCreated(update);
                for (ConsistencyCheckerError error : errors) {
                    if (error.isRepairable()) {
                        try {
                            error.repair(update.getChanges());
                            info(null, "Repairing " + error);
                        } catch (ItemStateException e) {
                            error(null, "Failed to repair error: " + error, e);
                        }
                    }
                }

                final ChangeLog changes = update.getChanges();
                if (changes.hasUpdates()) {
                    eventChannel.updatePrepared(update);
                    for (NodePropBundle bundle : bundles.values()) {
                        storeBundle(bundle);
                    }
                    update.setAttribute(ATTRIBUTE_UPDATE_SIZE, changes.getUpdateSize());
                    successful = true;
                }
            } catch (ClusterException e) {
                throw new RepositoryException("Cannot create update", e);
            } finally {
                if (successful) {
                    eventChannel.updateCommitted(update, "checker@");
                } else {
                    eventChannel.updateCancelled(update);
                }
            }
        }
    }

    private boolean hasErrors() {
        return errors != null && !errors.isEmpty();
    }

    private boolean hasRepairableErrors() {
        if (hasErrors()) {
            for (ConsistencyCheckerError error : errors) {
                if (error.isRepairable()) {
                    return true;
                }
            }
        }
        return false;
    }

    private void checkLostNFound() {
        if (lostNFoundId != null) {
            // do we have a "lost+found" node?
            try {
                NodePropBundle lfBundle = pm.loadBundle(lostNFoundId);
                if (lfBundle == null) {
                    error(lostNFoundId.toString(), "Specified 'lost+found' node does not exist");
                    lostNFoundId = null;
                } else if (!NameConstants.NT_UNSTRUCTURED.equals(lfBundle .getNodeTypeName())) {
                    error(lostNFoundId.toString(), "Specified 'lost+found' node is not of type nt:unstructured");
                    lostNFoundId = null;
                }
            } catch (Exception ex) {
                error(lostNFoundId.toString(), "finding 'lost+found' folder", ex);
                lostNFoundId = null;
            }
        } else {
            info(null, "No 'lost+found' node specified: orphans cannot be fixed");
        }
    }

    private int internalCheckConsistency(String[] uuids, boolean recursive) throws RepositoryException {
        int count = 0;

        if (uuids == null) {
            // check all nodes
            try {
                Map<NodeId, NodeInfo> batch = pm.getAllNodeInfos(null, NODESATONCE);
                Map<NodeId, NodeInfo> allInfos = batch;

                NodeId lastId = null;
                while (!batch.isEmpty()) {

                    for (Map.Entry<NodeId, NodeInfo> entry : batch.entrySet()) {
                        lastId = entry.getKey();

                        count++;
                        if (count % 1000 == 0) {
                            log.info(pm + ": loaded " + count + " infos...");
                        }

                    }

                    batch = pm.getAllNodeInfos(lastId, NODESATONCE);

                    allInfos.putAll(batch);
                }

                if (pm.exists(lastId)) {
                    for (Map.Entry<NodeId, NodeInfo> entry : allInfos.entrySet()) {
                        checkBundleConsistency(entry.getKey(), entry.getValue(), allInfos);
                    }
                } else {
                    log.info("Failed to read all nodes, starting over");
                    internalCheckConsistency(uuids, recursive);
                }

            } catch (ItemStateException e) {
                throw new RepositoryException("Error loading nodes", e);
            } finally {
                NodeInfo.clearPool();
            }
        } else {
            // check only given uuids, handle recursive flag

            List<NodeId> idList = new ArrayList<NodeId>(uuids.length);
            for (final String uuid : uuids) {
                try {
                    idList.add(new NodeId(uuid));
                } catch (IllegalArgumentException e) {
                    error(uuid, "Invalid id for consistency check, skipping: '" + uuid + "': " + e);
                }
            }

            for (int i = 0; i < idList.size(); i++) {
                NodeId id = idList.get(i);
                try {
                    final NodePropBundle bundle = pm.loadBundle(id);
                    if (bundle == null) {
                        if (!isVirtualNode(id)) {
                            error(id.toString(), "No bundle found for id '" + id + "'");
                        }
                    } else {
                        checkBundleConsistency(id, new NodeInfo(bundle), Collections.<NodeId, NodeInfo>emptyMap());

                        if (recursive) {
                            for (NodePropBundle.ChildNodeEntry entry : bundle.getChildNodeEntries()) {
                                idList.add(entry.getId());
                            }
                        }

                        count++;
                        if (count % 1000 == 0 && listener == null) {
                            log.info(pm + ": checked " + count + "/" + idList.size() + " bundles...");
                        }
                    }
                } catch (ItemStateException ignored) {
                    // problem already logged
                }
            }
        }

        log.info(pm + ": checked " + count + " bundles.");

        return count;
    }

    /**
     * Checks a single bundle for inconsistencies, ie. inexistent child nodes, inexistent parents, and other
     * structural inconsistencies.
     *
     * @param nodeId node id for the bundle to check
     * @param nodeInfo the node info for the node to check
     * @param infos all the {@link NodeInfo}s loaded in the current batch
     */
    private void checkBundleConsistency(NodeId nodeId, NodeInfo nodeInfo, Map<NodeId, NodeInfo> infos) {

        // skip all virtual nodes
        if (!isRoot(nodeId) && isVirtualNode(nodeId)) {
            return;
        }

        if (listener != null) {
            listener.startCheck(nodeId.toString());
        }

        // check the children
        for (final NodeId childNodeId : nodeInfo.getChildren()) {

            if (isVirtualNode(childNodeId)) {
                continue;
            }

            NodeInfo childNodeInfo = infos.get(childNodeId);

            if (childNodeInfo == null) {
                addError(new MissingChild(nodeId, childNodeId));
            } else {
                if (!nodeId.equals(childNodeInfo.getParentId())) {
                    addError(new DisconnectedChild(nodeId, childNodeId, childNodeInfo.getParentId()));
                }
            }
        }

        // check the parent
        NodeId parentId = nodeInfo.getParentId();
        // skip root nodes
        if (parentId != null && !isRoot(nodeId)) {
            NodeInfo parentInfo = infos.get(parentId);

            if (parentInfo == null) {
                addError(new OrphanedNode(nodeId, parentId));
            } else {
                // if the parent exists, does it have a child node entry for us?
                boolean found = false;

                for (NodeId childNodeId : parentInfo.getChildren()) {
                    if (childNodeId.equals(nodeId)){
                        found = true;
                        break;
                    }
                }

                if (!found) {
                    addError(new AbandonedNode(nodeId, parentId));
                }

            }
        }
    }

    protected boolean isVirtualNode(NodeId nodeId) {
        return nodeId.toString().endsWith("babecafebabe");
    }

    private boolean isRoot(NodeId nodeId) {
        return "cafebabe-cafe-babe-cafe-babecafebabe".equals(nodeId.toString());
    }

    private void addError(ConsistencyCheckerError error) {
        if (listener != null) {
            listener.report(error.getReportItem());
        }
        errors.add(error);
    }

    private void info(String id, String message) {
        if (this.listener == null) {
            String idstring = id == null ? "" : ("Node " + id + ": ");
            log.info(idstring + message);
        } else {
            listener.info(id, message);
        }
    }

    private void error(String id, String message) {
        if (this.listener == null) {
            String idstring = id == null ? "" : ("Node " + id + ": ");
            log.error(idstring + message);
        } else {
            listener.error(id, message);
        }
    }

    private void error(String id, String message, Throwable ex) {
        String idstring = id == null ? "" : ("Node " + id + ": ");
        log.error(idstring + message, ex);
        if (listener != null) {
            listener.error(id, message);
        }
    }

    private void storeBundle(NodePropBundle bundle) {
        try {
            bundle.markOld();
            bundle.setModCount((short) (bundle.getModCount()+1));
            pm.storeBundle(bundle);
            pm.evictBundle(bundle.getId());
        } catch (ItemStateException e) {
            log.error(pm + ": Error storing fixed bundle: " + e);
        }
    }

    private NodePropBundle getBundle(NodeId nodeId) throws ItemStateException {
        if (bundles.containsKey(nodeId)) {
            return bundles.get(nodeId);
        }
        return pm.loadBundle(nodeId);
    }

    private void saveBundle(NodePropBundle bundle) {
        bundles.put(bundle.getId(), bundle);
    }

    /**
     * A missing child is when the node referred to by a child node entry
     * does not exist.
     *
     * This type of error is repaired by removing the corrupted child node entry.
     */
    private class MissingChild extends ConsistencyCheckerError {

        private final NodeId childNodeId;

        private MissingChild(final NodeId nodeId, final NodeId childNodeId) {
            super(nodeId, "NodeState '" + nodeId + "' references inexistent child '" + childNodeId + "'");
            this.childNodeId = childNodeId;
        }

        @Override
        ReportItem.Type getType() {
            return ReportItem.Type.MISSING;
        }

        @Override
        boolean isRepairable() {
            return true;
        }

        @Override
        void doRepair(final ChangeLog changes) throws ItemStateException {
            final NodePropBundle bundle = getBundle(nodeId);
            final Iterator<NodePropBundle.ChildNodeEntry> entryIterator = bundle.getChildNodeEntries().iterator();
            while (entryIterator.hasNext()) {
                final NodePropBundle.ChildNodeEntry childNodeEntry = entryIterator.next();
                if (childNodeEntry.getId().equals(childNodeId)) {
                    entryIterator.remove();
                    saveBundle(bundle);
                    changes.modified(new NodeState(nodeId, null, null, ItemState.STATUS_EXISTING, false));
                }
            }
        }

        @Override
        boolean doubleCheck() throws ItemStateException {
            final NodePropBundle childBundle = pm.loadBundle(childNodeId);
            if (childBundle == null) {
                final NodePropBundle bundle = pm.loadBundle(nodeId);
                if (bundle != null) {
                    for (NodePropBundle.ChildNodeEntry entry : bundle.getChildNodeEntries()) {
                        if (entry.getId().equals(childNodeId)) {
                            return true;
                        }
                    }
                }
            }
            return false;
        }
    }

    /**
     * A disconnected child is when a child node entry refers to a node
     * that exists, but that node actually has a different parent.
     *
     * This type of error is repaired by removing the corrupted child node entry.
     */
    private class DisconnectedChild extends ConsistencyCheckerError {

        private final NodeId childNodeId;

        DisconnectedChild(final NodeId nodeId, final NodeId childNodeId, final NodeId invalidParentId) {
            super(nodeId, "Node has invalid parent id: '" + invalidParentId + "' (instead of '" + nodeId + "')");
            this.childNodeId = childNodeId;
        }

        @Override
        ReportItem.Type getType() {
            return ReportItem.Type.DISCONNECTED;
        }

        @Override
        boolean isRepairable() {
            return true;
        }

        @Override
        void doRepair(final ChangeLog changes) throws ItemStateException {
            NodePropBundle bundle = getBundle(nodeId);
            final Iterator<NodePropBundle.ChildNodeEntry> entryIterator = bundle.getChildNodeEntries().iterator();
            while (entryIterator.hasNext()) {
                final NodePropBundle.ChildNodeEntry childNodeEntry = entryIterator.next();
                if (childNodeEntry.getId().equals(childNodeId)) {
                    entryIterator.remove();
                    saveBundle(bundle);
                    changes.modified(new NodeState(nodeId, null, null, ItemState.STATUS_EXISTING, false));
                    break;
                }
            }
        }

        @Override
        boolean doubleCheck() throws ItemStateException {
            final NodePropBundle childBundle = pm.loadBundle(childNodeId);
            if (childBundle != null && !childBundle.getParentId().equals(nodeId)) {
                final NodePropBundle bundle = pm.loadBundle(nodeId);
                if (bundle != null) {
                    // double check if the child node entry is still there
                    for (NodePropBundle.ChildNodeEntry entry : bundle.getChildNodeEntries()) {
                        if (entry.getId().equals(childNodeId)) {
                            return true;
                        }
                    }
                }
            }
            return false;
        }
    }

    /**
     * An orphaned node is a node whose parent does not exist.
     *
     * This type of error is repaired by reattaching the orphan to
     * a special purpose 'lost and found' node.
     */
    private class OrphanedNode extends ConsistencyCheckerError {

        private final NodeId parentNodeId;

        OrphanedNode(final NodeId nodeId, final NodeId parentNodeId) {
            super(nodeId, "NodeState '" + nodeId + "' references inexistent parent id '" + parentNodeId + "'");
            this.parentNodeId = parentNodeId;
        }

        @Override
        ReportItem.Type getType() {
            return ReportItem.Type.ORPHANED;
        }

        @Override
        boolean isRepairable() {
            return lostNFoundId != null;
        }

        @Override
        void doRepair(final ChangeLog changes) throws ItemStateException {
            if (lostNFoundId != null) {
                final NodePropBundle bundle = getBundle(nodeId);
                final NodePropBundle lfBundle = getBundle(lostNFoundId);

                final String nodeName = nodeId + "-" + System.currentTimeMillis();
                final NameFactory nameFactory = NameFactoryImpl.getInstance();
                lfBundle.addChildNodeEntry(nameFactory.create("", nodeName), nodeId);
                bundle.setParentId(lostNFoundId);

                saveBundle(bundle);
                saveBundle(lfBundle);

                changes.modified(new NodeState(lostNFoundId, null, null, ItemState.STATUS_EXISTING, false));
                changes.modified(new NodeState(nodeId, null, null, ItemState.STATUS_EXISTING, false));
            }
        }

        @Override
        boolean doubleCheck() throws ItemStateException {
            final NodePropBundle parentBundle = pm.loadBundle(parentNodeId);
            if (parentBundle == null) {
                final NodePropBundle bundle = pm.loadBundle(nodeId);
                if (bundle != null) {
                    if (parentNodeId.equals(bundle.getParentId())) {
                        return true;
                    }
                }
            }
            return false;
        }
    }

    /**
     * An abandoned node is a node that points to an existing node
     * as its parent, but that parent node does not have a corresponding
     * child node entry for the child.
     *
     * This type of error is repaired by adding the missing child node entry
     * to the parent.
     */
    private class AbandonedNode extends ConsistencyCheckerError {

        private final NodeId nodeId;
        private final NodeId parentNodeId;

        AbandonedNode(final NodeId nodeId, final NodeId parentNodeId) {
            super(nodeId, "NodeState '" + nodeId + "' is not referenced by its parent node '" + parentNodeId + "'");
            this.nodeId = nodeId;
            this.parentNodeId = parentNodeId;
        }

        @Override
        ReportItem.Type getType() {
            return ReportItem.Type.ABANDONED;
        }

        @Override
        boolean isRepairable() {
            return true;
        }

        @Override
        void doRepair(final ChangeLog changes) throws ItemStateException {
            final NodePropBundle parentBundle = getBundle(parentNodeId);

            parentBundle.addChildNodeEntry(createNodeName(), nodeId);

            saveBundle(parentBundle);
            changes.modified(new NodeState(parentNodeId, null, null, ItemState.STATUS_EXISTING, false));
        }

        private Name createNodeName() {
            int n = (int) System.currentTimeMillis() + new Random().nextInt();
            final String localName = Integer.toHexString(n);
            final NameFactory nameFactory = NameFactoryImpl.getInstance();
            return nameFactory.create("{}" + localName);
        }

        @Override
        boolean doubleCheck() throws ItemStateException {
            final NodePropBundle parentBundle = pm.loadBundle(parentNodeId);
            if (parentBundle != null) {
                for (NodePropBundle.ChildNodeEntry entry : parentBundle.getChildNodeEntries()) {
                    if (entry.getId().equals(nodeId)) {
                        return false;
                    }
                }
            }
            final NodePropBundle bundle = pm.loadBundle(nodeId);
            if (bundle != null) {
                if (parentNodeId.equals(bundle.getParentId())) {
                    return true;
                }
            }
            return false;
        }
    }

    private class CheckerUpdate implements Update {

        private final Map<String, Object> attributes = new HashMap<String, Object>();
        private final ChangeLog changeLog = new ChangeLog();
        private final long timestamp = System.currentTimeMillis();

        @Override
        public void setAttribute(final String name, final Object value) {
            attributes.put(name, value);
        }

        @Override
        public Object getAttribute(final String name) {
            return attributes.get(name);
        }

        @Override
        public ChangeLog getChanges() {
            return changeLog;
        }

        @Override
        public List<EventState> getEvents() {
            return Collections.emptyList();
        }

        @Override
        public long getTimestamp() {
            return timestamp;
        }

        @Override
        public String getUserData() {
            return null;
        }
    }
}
