/*
 * 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.plugins.document;

import static org.apache.jackrabbit.oak.plugins.document.Collection.NODES;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.jackrabbit.oak.plugins.document.DocumentMK.Builder;
import org.apache.jackrabbit.oak.plugins.document.memory.MemoryDocumentStore;
import org.apache.jackrabbit.oak.plugins.document.util.LeaseCheckDocumentStoreWrapper;
import org.apache.jackrabbit.oak.plugins.document.util.Utils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import junitx.util.PrivateAccessor;

public class Sweep2TestHelper {

    static void testPre18UpgradeSimulations(DocumentNodeStore ns, DocumentMKBuilderProvider builderProvider) {
        // make sure the DocumentNodeStore is disposed before testing if this hasn't been done yet
        ns.dispose();

        MemoryDocumentStore memStore = getMemoryDocumentStore(ns);

        Set<Integer> activeIds = new HashSet<Integer>();
        activeIds.add(ns.getClusterId());
        ClusterViewDocument.readOrUpdate(ns, activeIds, null, null);
        testPre18UpgradeSimulations(memStore, builderProvider);
    }

    static void testPre18UpgradeSimulations(MemoryDocumentStore memStore, DocumentMKBuilderProvider builderProvider) {
        doTestPre18Upgrade(memStore.copy(), builderProvider.newBuilder(), false);
        doTestPre18Upgrade(memStore.copy(), builderProvider.newBuilder(), true);
    }

    static MemoryDocumentStore getMemoryDocumentStore(DocumentNodeStore ns) {
        DocumentStore ds = ns.getDocumentStore();
        if (ds instanceof LeaseCheckDocumentStoreWrapper) {
            LeaseCheckDocumentStoreWrapper w = (LeaseCheckDocumentStoreWrapper)ds;
            try {
                ds = (DocumentStore) PrivateAccessor.getField(w, "delegate");
            } catch (NoSuchFieldException e) {
                fail("Could unwrap DocumentStore : " + w);
            }
        }
        if (!(ds instanceof MemoryDocumentStore)) {
            throw new IllegalStateException("only supporting MemoryDocumentStore for this test for now - DocumentStore cloning not yet generally supported");
        }

        MemoryDocumentStore memStore = (MemoryDocumentStore) ds;
        return memStore;
    }

    /**
     * Does a simulation of the following steps:
     * <ol>
     * <li>create content in an oak version pre 1.8</li>
     * <li>if simulatePreFixUpgrade then simulate an upgrade to a version post 1.8 but without a fix for the branch commit problem</li>
     * <li>upgrade to a fixed version</li>
     * </ol>
     * @param ns
     * @param newBuilder
     * @param simulatePreFixUpgrade if true, simulate step 2 as well, if false, leave out that step
     */
    private static void doTestPre18Upgrade(MemoryDocumentStore memStore, DocumentMK.Builder newBuilder, boolean simulatePreFixUpgrade) {
        // step 2 : keep a copy in memStoreOriginal for debugging purpose
        MemoryDocumentStore memStoreOriginal = memStore.copy();

        // step 3: simulate DocumentNodeStore<init> calling branches.init(store, this) calling doc.purgeUncommittedRevisions(context)
        // by doing another restart/shutdown cycle
        MemoryDocumentStore memStoreOriginalAdjusted = memStoreOriginal.copy();
        DocumentNodeStore ns = newBuilder
                .setClusterId(1)
                .setDocumentStore(memStoreOriginalAdjusted).build();
        ns.dispose();

        // step 4 : simulate any existing content changes done so far having happened pre 1.8
        //          by deleting "_bc" and "_sweepRev"
        //          ("_sweepRev" also didn't exist pre 1.8 and otherwise prevents
        //          backgroundSweep()/forceBackgroundSweep()
        revertToPre18State(memStore);

        // step 5 : keep a copy of the store pre18 upgrade for debugging purpose
        @SuppressWarnings("unused")
        MemoryDocumentStore memStorePre18Upgrade = memStore.copy();

        // optional step 6 : simulate an upgrade to post 1.8 without a fix yet
        if (simulatePreFixUpgrade) {
            NodeDocumentSweeper.SWEEP_ONE_PREDICATE = Utils.PROPERTY_OR_DELETED;
            ns = newBuilder
                    .setClusterId(1)
                    .setDocumentStore(memStore).build();
            ns.dispose();
            // put back the fix asap
            NodeDocumentSweeper.SWEEP_ONE_PREDICATE = Utils.PROPERTY_OR_DELETED_OR_COMMITROOT_OR_REVISIONS;

            // remove the sweep2Status - this is what manages whether the sweep2 was applied or not
            removeSweep2Status(memStore, false);
        }

        // step 7 : startup new instance (1.8+) - thanks to the above simulatePre18Behaviour
        //          this will now run backgroundSweep()/forceBackgroundSweep()
        ns = newBuilder
                .setAsyncDelay(1)
                .setClusterId(1)
                .setDocumentStore(memStore).build();

        assertTrue("sweep2 was too slow", waitForSweep2Done(ns, 10000));

        for (NodeDocument originalNode : memStoreOriginalAdjusted.query(NODES, NodeDocument.MIN_ID_VALUE, NodeDocument.MAX_ID_VALUE, Integer.MAX_VALUE)) {
            NodeDocument actualNode = memStore.find(NODES, originalNode.getId());

            assertBranchCommitsEqual(ns, ns.getSweepRevisions(), originalNode, actualNode);
        }
        assertEquals(0, scanForMissingBranchCommits(ns).size());
    }

    private static boolean waitForSweep2Done(DocumentNodeStore ns, long maxWaitMillis) {
        final long timeout = System.currentTimeMillis() + maxWaitMillis;
        while(System.currentTimeMillis() < timeout) {
            Sweep2StatusDocument sweep2StatusDoc = Sweep2StatusDocument.readFrom(ns.getDocumentStore());
            if (sweep2StatusDoc != null && sweep2StatusDoc.isSwept()) {
                return true;
            }
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                fail("interruptedException: " + e);
            }
        }
        return false;
    }

    private static void assertBranchCommitsEqual(RevisionContext rc, RevisionVector sweepRevs, NodeDocument expectedNode, NodeDocument actualNode) {
        Set<Revision> expectedBcs = new HashSet<>(expectedNode.getValueMap("_bc").keySet());
        Set<Revision> actualBcs = new HashSet<>(actualNode.getValueMap("_bc").keySet());
        if (!expectedBcs.equals(actualBcs) && expectedBcs.containsAll(actualBcs)) {
            // there might have to be more bc than we actually have.
            // check if they have been swept
            Iterator<Revision> it = expectedBcs.iterator();
            while(it.hasNext()) {
                Revision expectedBc = it.next();
                if (!sweepRevs.isRevisionNewer(expectedBc)) {
                    it.remove();
                }
            }
            it = actualBcs.iterator();
            while(it.hasNext()) {
                Revision actualBc = it.next();
                if (!sweepRevs.isRevisionNewer(actualBc)) {
                    it.remove();
                }
            }
            if (expectedBcs.equals(actualBcs)) {
                // perfect
                return;
            }
        }
        assertEquals("\"_bc\" mismatch on " + expectedNode.getId(), expectedNode.getValueMap("_bc").keySet(), actualNode.getValueMap("_bc").keySet());
    }

    /**
     * Remove data from the store bringing it to a pre Oak 1.8 state
     * @param store
     */
    static void revertToPre18State(DocumentStore store) {
        // A : remove all "_bc"
        for (NodeDocument nodeDocument :
            store.query(NODES, NodeDocument.MIN_ID_VALUE, NodeDocument.MAX_ID_VALUE, Integer.MAX_VALUE)) {
            UpdateOp removeBc = new UpdateOp(nodeDocument.getId(), false);
            removeBc.remove("_bc");
            assertNotNull(store.findAndUpdate(Collection.NODES, removeBc));
        }
        // B : remove "_sweepRev"
        UpdateOp removeSweepRev = new UpdateOp(Utils.getIdFromPath("/"), false);
        removeSweepRev.remove("_sweepRev");
        assertNotNull(store.findAndUpdate(Collection.NODES, removeSweepRev));
        // C : remove the "sweep2Status" from the settings
        removeSweep2Status(store, false);
    }

    static void removeSweep2Status(DocumentStore store) {
        // most of the tests rely on sweep2 being done for all clusterIds.
        // hence setting emptySweepRev to true by default here to achieve that.
        removeSweep2Status(store, true);
    }

    /**
     * Removes the "sweep2Status" from the settings collection as well
     * as emptying the "_sweepRev" from the "0:/" root if desired.
     * <p>
     * The reason for emptying the "_sweepRev", and not straight removing it,
     * is that it has an influence on whether Sweep2Helper.isSweep2Necessary
     * considers a sweep2 necessary or not: if it's not there, then the
     * repository never saw a sweep1 - if it is there, then it did.
     * Now for the latter, if it saw a sweep1, the sweep2 is only done
     * for clusterIds in that list, not for all. But some of the tests
     * would like to have sweep2 done for other clusterIds as well.
     * So this is to account for that fact.
     * @param store the store on which to do the modifications
     * @param emptySweepRev whether or not "_sweepRev" should be emptied.
     */
    static void removeSweep2Status(DocumentStore store, boolean emptySweepRev) {
        store.remove(Collection.SETTINGS, "sweep2Status");
        if (emptySweepRev) {
            String rootId = Utils.getIdFromPath("/");
            NodeDocument rootDoc = store.find(Collection.NODES, rootId);
            SortedMap<Revision, String> sweepRevs = rootDoc.getLocalMap(NodeDocument.SWEEP_REV);
            if (!sweepRevs.isEmpty()) {
                UpdateOp emptySweepRevOp = new UpdateOp(rootId, false);
                for (Revision rev : sweepRevs.keySet()) {
                    emptySweepRevOp.removeMapEntry("_sweepRev", rev);
                }
                assertNotNull(store.findAndUpdate(Collection.NODES, emptySweepRevOp));
            }
        }
    }

    /**
     * This simulates the provided store having done the changes
     * "pre 1.8" - ie it "ages" the document in a way that it looks
     * like they were applied "pre 1.8".
     * <p>
     * The way this is done is to remove all the "_bc" entries
     * (as they didn't exist before 1.8) - *and then* doing a normal sweep (ie "sweep1")
     */
    static DocumentNodeStore applyPre18Aging(DocumentStore memStore, DocumentMKBuilderProvider builderProvider, int newClusterId) {
        return applyPre18Aging(memStore, builderProvider, new int[] {newClusterId}).get(0);
    }

    /**
     * This simulates the provided store having done the changes
     * "pre 1.8" - ie it "ages" the document in a way that it looks
     * like they were applied "pre 1.8".
     * <p>
     * The way this is done is to remove all the "_bc" entries
     * (as they didn't exist before 1.8) - *and then* doing a normal sweep (ie "sweep1")
     */
    static List<DocumentNodeStore> applyPre18Aging(DocumentStore memStore, DocumentMKBuilderProvider builderProvider, int... newClusterId) {
        revertToPre18State(memStore);

        // mark as swept2 to avoid a proper sweep2, only makes sense for testing
        Sweep2StatusDocument.forceReleaseSweep2LockAndMarkSwept(memStore, 1);

        NodeDocumentSweeper.SWEEP_ONE_PREDICATE = Utils.PROPERTY_OR_DELETED;
        Builder builder = builderProvider.newBuilder();
        List<DocumentNodeStore> nss = new LinkedList<>();
        for (int aClusterId : newClusterId) {
            DocumentNodeStore ns = builder
                    .setClusterId(aClusterId)
                    .setDocumentStore(memStore).build();
            nss.add(ns);
        }
        NodeDocumentSweeper.SWEEP_ONE_PREDICATE = Utils.PROPERTY_OR_DELETED_OR_COMMITROOT_OR_REVISIONS;

        return nss;
    }

    static List<Path> scanForMissingBranchCommits(DocumentNodeStore ns) {
        List<NodeDocument> nodes = ns.getDocumentStore().query(Collection.NODES, NodeDocument.MIN_ID_VALUE, NodeDocument.MAX_ID_VALUE, Integer.MAX_VALUE);
        List<Path> paths = new LinkedList<>();
        for (NodeDocument nodeDocument : nodes) {
            if (nodeDocument.isSplitDocument()) {
                // as per MissingLastRevSeeker.getCandidates only documents
                // with a MODIFIED_IN_SECS (_modified) are considered for sweeping.
                // split documents do not contain that, so they are not part of sweeping.
                // hence we should filter those out for the test here
                continue;
            }
            if (containsMissingBranchCommit(ns, nodeDocument)) {
                paths.add(nodeDocument.getPath());
            }
        }
        return paths;
    }

    static boolean containsMissingBranchCommit(DocumentNodeStore ns, NodeDocument nodeDocument) {
        final RevisionVector emptySweepRevision = new RevisionVector();
        CommitValueResolver cvr = new CachingCommitValueResolver(
                0 /* to make sure each commit value is calculated explicitly, separately */,
                () -> emptySweepRevision);
        MissingBcSweeper2 sweeper = new MissingBcSweeper2(ns, cvr, null, new AtomicBoolean(false));
        final List<Map<Path, UpdateOp>> updatesList = new LinkedList<>();
        sweeper.sweep2(Arrays.asList(nodeDocument), new NodeDocumentSweepListener() {

            @Override
            public void sweepUpdate(Map<Path, UpdateOp> updates) throws DocumentStoreException {
                updatesList.add(updates);
            }
        });
        return !updatesList.isEmpty();
    }
}
