/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License (the "License").
 * You may not use this file except in compliance with the License.
 *
 * See LICENSE.txt included in this distribution for the specific
 * language governing permissions and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at LICENSE.txt.
 * If applicable, add the following below this CDDL HEADER, with the
 * fields enclosed by brackets "[]" replaced with your own identifying
 * information: Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 */

/*
 * Copyright (c) 2009, 2014, Oracle and/or its affiliates. All rights reserved.
 */

package org.opensolaris.opengrok.history;

import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import junit.framework.Test;
import junit.framework.TestCase;
import junit.framework.TestSuite;
import org.opensolaris.opengrok.configuration.Configuration;
import org.opensolaris.opengrok.configuration.RuntimeEnvironment;
import org.opensolaris.opengrok.util.Executor;
import org.opensolaris.opengrok.util.TestRepository;

/**
 * Unit tests for {@code JDBCHistoryCache}.
 */
public class JDBCHistoryCacheTest extends TestCase {

    private TestRepository repositories;
    private JDBCHistoryCache cache;

    private static final String DERBY_EMBEDDED_DRIVER =
            "org.apache.derby.jdbc.EmbeddedDriver";

    public JDBCHistoryCacheTest(String name) {
        super(name);
    }

    /**
     * Create a suite of tests to run. If the Derby classes are not present,
     * skip this test.
     *
     * @return tests to run
     */
    public static Test suite() {
        try {
            Class.forName(DERBY_EMBEDDED_DRIVER);
            return new TestSuite(JDBCHistoryCacheTest.class);
        } catch (ClassNotFoundException e) {
            return new TestSuite("JDBCHistoryCacheTest - empty (no derby.jar)");
        }
    }

    /**
     * Set up the test environment with repositories and a cache instance.
     */
    @Override protected void setUp() throws Exception {
        repositories = new TestRepository();
        repositories.create(getClass().getResourceAsStream("repositories.zip"));

        cache = new JDBCHistoryCache(
                DERBY_EMBEDDED_DRIVER, getURL() + ";create=true");
        cache.initialize();

        // Mercurial parser needs to know if the history is stored in DB.
        RuntimeEnvironment.getInstance().setStoreHistoryCacheInDB(true);

        // The tests expect support for renamed files.
        System.setProperty("org.opensolaris.opengrok.history.RenamedHandlingEnabled", "1");
    }

    /**
     * Clean up after the test. Remove the test repositories and shut down
     * the database.
     */
    @Override protected void tearDown() throws Exception {
        repositories.destroy();
        repositories = null;

        cache = null;

        try {
            DriverManager.getConnection(getURL() + ";shutdown=true");
        } catch (SQLException sqle) {
            // Expect SQLException with SQLState 08006 on successful shutdown
            if (!sqle.getSQLState().equals("08006")) {
                throw sqle;
            }
        }

        // Reset any changes the test made to the runtime environment.
        RuntimeEnvironment.getInstance().setConfiguration(new Configuration());

        // We really need to destroy the thread pool here since some of the
        // threads might have some thread-local variables cached from previous
        // test runs (like tags enabled) and this makes other tests fail in
        // very nasty fashion (what is caused by fetch of particular thread
        // with cached thread-local value appears like race condition).
        RuntimeEnvironment.destroyRenamedHistoryExecutor();
    }

    /**
     * Create a database URL to use for this test. The URL points to an
     * in-memory Derby database.
     *
     * @return a database URL
     */
    private String getURL() {
        return "jdbc:derby:memory:DB-" + getName();
    }

    /**
     * Import a new changeset into a Mercurial repository.
     *
     * @param reposRoot the root of the repository
     * @param changesetFile file that contains the changeset to import
     */
    private void importHgChangeset(File reposRoot, String changesetFile) {
        String[] cmdargs = {
            MercurialRepository.CMD_FALLBACK, "import", changesetFile
        };
        Executor exec = new Executor(Arrays.asList(cmdargs), reposRoot);
        int exitCode = exec.exec();
        if (exitCode != 0) {
            fail("hg import failed." +
                    "\nexit code: " + exitCode +
                    "\nstdout:\n" + exec.getOutputString() +
                    "\nstderr:\n" + exec.getErrorString());
        }
    }

    /**
     * Assert that two HistoryEntry objects are equal.
     * @param expected the expected entry
     * @param actual the actual entry
     * @throws AssertFailure if the two entries don't match
     */
    private void assertSameEntries(
            List<HistoryEntry> expected, List<HistoryEntry> actual) {
        assertEquals("Unexpected size", expected.size(), actual.size());
        Iterator<HistoryEntry> actualIt = actual.iterator();
        for (HistoryEntry expectedEntry : expected) {
            assertSameEntry(expectedEntry, actualIt.next());
        }
        assertFalse("More entries than expected", actualIt.hasNext());
    }

    /**
     * Assert that two lists of HistoryEntry objects are equal.
     * @param expected the expected list of entries
     * @param actual the actual list of entries
     * @throws AssertFailure if the two lists don't match
     */
    private void assertSameEntry(HistoryEntry expected, HistoryEntry actual) {
        assertEquals(expected.getAuthor(), actual.getAuthor());
        assertEquals(expected.getRevision(), actual.getRevision());
        assertEquals(expected.getDate(), actual.getDate());
        assertEquals(expected.getMessage(), actual.getMessage());
        assertEquals(expected.getFiles(), actual.getFiles());
    }

    /**
     * Basic tests for the {@code store()} and {@code get()} methods.
     */
    public void testStoreAndGet() throws Exception {
        File reposRoot = new File(repositories.getSourceRoot(), "mercurial");

        Repository repos = RepositoryFactory.getRepository(reposRoot);

        History historyToStore = repos.getHistory(reposRoot);

        cache.store(historyToStore, repos);
        cache.optimize();

        // test reindex
        History historyNull = new History();
        cache.store(historyNull, repos);
        cache.optimize();

        // test get history for single file

        File makefile = new File(reposRoot, "Makefile");
        assertTrue(makefile.exists());

        History retrievedHistory = cache.get(makefile, repos, true);

        List<HistoryEntry> entries = retrievedHistory.getHistoryEntries();

        assertEquals("Unexpected number of entries", 2, entries.size());

        final String TROND = "Trond Norbye <trond.norbye@sun.com>";

        Iterator<HistoryEntry> entryIt = entries.iterator();

        HistoryEntry e1 = entryIt.next();
        assertEquals(TROND, e1.getAuthor());
        assertEquals("2:585a1b3f2efb", e1.getRevision());
        assertEquals(2, e1.getFiles().size());

        HistoryEntry e2 = entryIt.next();
        assertEquals(TROND, e2.getAuthor());
        assertEquals("1:f24a5fd7a85d", e2.getRevision());
        assertEquals(3, e2.getFiles().size());

        assertFalse(entryIt.hasNext());

        // test get history for renamed file

        File novel = new File(reposRoot, "novel.txt");
        assertTrue(novel.exists());

        retrievedHistory = cache.get(novel, repos, true);

        entries = retrievedHistory.getHistoryEntries();

        assertEquals("Unexpected number of entries", 6, entries.size());

        // test get history for directory

        History dirHistory = cache.get(reposRoot, repos, true);
        assertSameEntries(
                historyToStore.getHistoryEntries(),
                dirHistory.getHistoryEntries());

        // test incremental update

        importHgChangeset(
                reposRoot, getClass().getResource("hg-export.txt").getPath());

        repos.createCache(cache, cache.getLatestCachedRevision(repos));
        cache.optimize();

        History updatedHistory = cache.get(reposRoot, repos, true);

        HistoryEntry newEntry1 = new HistoryEntry(
                "10:1e392ef0b0ed",
                new Date(1245446973L / 60 * 60 * 1000), // whole minutes only
                "xyz", null, "Return failure when executed with no arguments",
                true);
        newEntry1.addFile("/mercurial/main.c");
        HistoryEntry newEntry2 = new HistoryEntry(
                "11:bbb3ce75e1b8",
                new Date(1245447973L / 60 * 60 * 1000), // whole minutes only
                "xyz", null, "Do something else",
                true);
        newEntry2.addFile("/mercurial/main.c");

        LinkedList<HistoryEntry> updatedEntries = new LinkedList<HistoryEntry>(
                updatedHistory.getHistoryEntries());
        assertSameEntry(newEntry2, updatedEntries.removeFirst());
        assertSameEntry(newEntry1, updatedEntries.removeFirst());
        assertSameEntries(historyToStore.getHistoryEntries(), updatedEntries);

        // test clearing of cache
        cache.clear(repos);
        History clearedHistory = cache.get(reposRoot, repos, true);
        assertTrue("History should be empty",
                clearedHistory.getHistoryEntries().isEmpty());

        cache.store(historyToStore, repos);
        assertSameEntries(historyToStore.getHistoryEntries(),
                cache.get(reposRoot, repos, true).getHistoryEntries());
    }

    /**
     * Test that {@code getLatestCachedRevision()} returns the correct
     * revision.
     */
    public void testGetLatestCachedRevision() throws Exception {
        File reposRoot = new File(repositories.getSourceRoot(), "mercurial");
        Repository repos = RepositoryFactory.getRepository(reposRoot);
        History history = repos.getHistory(reposRoot);
        cache.store(history, repos);
        cache.optimize();

        List<HistoryEntry> entries = history.getHistoryEntries();
        HistoryEntry oldestEntry = entries.get(entries.size() - 1);
        HistoryEntry mostRecentEntry = entries.get(0);

        assertTrue("Unexpected order of history entries",
                oldestEntry.getDate().before(mostRecentEntry.getDate()));

        String latestRevision = mostRecentEntry.getRevision();
        assertNotNull("Unknown latest revision", latestRevision);
        assertEquals("Incorrect latest revision",
                latestRevision, cache.getLatestCachedRevision(repos));

        // test incremental update

        importHgChangeset(
                reposRoot, getClass().getResource("hg-export.txt").getPath());
        repos.createCache(cache, latestRevision);
        assertEquals("11:bbb3ce75e1b8", cache.getLatestCachedRevision(repos));
    }

    /**
     * Test that {@code hasCacheForDirectory()} works.
     */
    public void testHasCacheForDirectory() throws Exception {
        // Use a Mercurial repository and a Subversion repository in this test.
        File hgRoot = new File(repositories.getSourceRoot(), "mercurial");
        Repository hgRepos = RepositoryFactory.getRepository(hgRoot);
        File svnRoot = new File(repositories.getSourceRoot(), "svn");
        Repository svnRepos = RepositoryFactory.getRepository(svnRoot);

        // None of the repositories should have any history.
        assertFalse(cache.hasCacheForDirectory(hgRoot, hgRepos));
        assertFalse(cache.hasCacheForDirectory(svnRoot, svnRepos));

        // Store empty history, so still expect false.
        cache.store(new History(), hgRepos);
        cache.store(new History(), svnRepos);
        assertFalse(cache.hasCacheForDirectory(hgRoot, hgRepos));
        assertFalse(cache.hasCacheForDirectory(svnRoot, svnRepos));

        // Store history for Mercurial repository.
        cache.store(hgRepos.getHistory(hgRoot), hgRepos);
        assertTrue(cache.hasCacheForDirectory(hgRoot, hgRepos));
        assertFalse(cache.hasCacheForDirectory(svnRoot, svnRepos));

        // Store history for Subversion repository.
        cache.store(svnRepos.getHistory(svnRoot), svnRepos);
        assertTrue(cache.hasCacheForDirectory(hgRoot, hgRepos));
        assertTrue(cache.hasCacheForDirectory(svnRoot, svnRepos));

        // Bug #19230: Test that it works for sub-directories too, not only
        // for the repository root.
        assertTrue(
            cache.hasCacheForDirectory(new File(svnRoot, "c"), svnRepos));
        assertTrue(
            cache.hasCacheForDirectory(new File(svnRoot, "lisp"), svnRepos));

        // Also test that we don't claim to have history for a directory
        // that's not under version control, even if it lives in a version
        // controlled source tree.
        File notCheckedIn = new File(svnRoot, "not-checked-in");
        assertTrue(notCheckedIn.mkdir());
        assertFalse(cache.hasCacheForDirectory(notCheckedIn, svnRepos));
    }

    /**
     * Test that get() is able to continue and return successfully after a lock
     * timeout when accessing the database.
     */
    public void testRetryGetOnTimeout() throws Exception {
        File reposRoot = new File(repositories.getSourceRoot(), "mercurial");
        Repository repos = RepositoryFactory.getRepository(reposRoot);
        History history = repos.getHistory(reposRoot);
        cache.store(history, repos);

        assertSameEntries(
                history.getHistoryEntries(),
                cache.get(reposRoot, repos, true).getHistoryEntries());

        // Set the lock timeout to one second to make it go faster.
        final Connection c = DriverManager.getConnection(getURL());
        Statement s = c.createStatement();
        s.execute("call syscs_util.syscs_set_database_property" +
                "('derby.locks.waitTimeout', '1')");

        // Lock one of the tables exclusively in order to block get().
        c.setAutoCommit(false);
        // Originally, we locked the FILECHANGES table here, but that triggered
        // a Derby bug (https://issues.apache.org/jira/browse/DERBY-4330), so
        // now we lock the AUTHORS table instead.
        s.execute("lock table opengrok.authors in exclusive mode");
        s.close();

        // Roll back the transaction in 1.5 seconds so that get() is able to
        // continue after the first timeout.
        final Exception[] ex = new Exception[1];
        Thread t = new Thread() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1500);
                    c.rollback();
                    c.close();
                } catch (Exception e) {
                    ex[0] = e;
                }
            }
        };

        t.start();

        // get() should be able to continue after a timeout.
        assertSameEntries(
                history.getHistoryEntries(),
                cache.get(reposRoot, repos, true).getHistoryEntries());

        t.join();

        // Expose any exception thrown in the helper thread.
        if (ex[0] != null) {
            throw ex[0];
        }
    }

    /**
     * Regression test for bug #11663. If the commit message was longer than
     * the maximum VARCHAR length, a truncation error would be raised by the
     * database. Now the message should be truncated if such a message is
     * encountered.
     */
    public void testVeryLongCommitMessage() throws Exception {
        File reposRoot = new File(repositories.getSourceRoot(), "mercurial");
        Repository r = RepositoryFactory.getRepository(reposRoot);
        HistoryEntry e0 = new HistoryEntry();
        e0.setAuthor("dummy");
        e0.setDate(new Date());
        e0.addFile("/mercurial/readme.txt");
        e0.setRevision("12:abcdef123456");
        for (int msgLength = 0; msgLength < 40000; msgLength += 48) {
            e0.appendMessage(
                    "this is a line with 48 chars, including newline");
        }

        // Used to get a truncation error from the database here.
        cache.store(new History(Collections.singletonList(e0)), r);

        History h = cache.get(reposRoot, r, false);
        assertEquals("One entry expected", 1, h.getHistoryEntries().size());
        HistoryEntry e1 = h.getHistoryEntries().get(0);
        assertEquals("Author", e0.getAuthor(), e1.getAuthor());
        assertEquals("Date", e0.getDate(), e1.getDate());
        assertEquals("Revision", e0.getRevision(), e1.getRevision());
        assertTrue("No file list requested", e1.getFiles().isEmpty());
        assertFalse("Long message should be truncated",
                e0.getMessage().equals(e1.getMessage()));
        assertEquals("Start of message should be equal to original",
                e0.getMessage().substring(0, 1000),
                e1.getMessage().substring(0, 1000));
    }

    public void testGetInfo() throws HistoryException {
        String info = cache.getInfo();
        assertTrue("Info should contain name of history cache",
                info.startsWith("JDBCHistoryCache"));
        assertTrue("Info should contain driver class",
                info.contains(DERBY_EMBEDDED_DRIVER));
    }

    /**
     * Test that it is possible to store an entry with no author.
     * Bug #11662.
     */
    public void testNullAuthor() throws Exception {
        File reposRoot = new File(repositories.getSourceRoot(), "svn");
        Repository r = RepositoryFactory.getRepository(reposRoot);
        // Create an entry where author is null
        HistoryEntry e = new HistoryEntry(
                "1", new Date(), null, null, "Initial revision", true);
        e.addFile("/svn/c/main.c");
        List<HistoryEntry> entries = Collections.singletonList(e);
        cache.store(new History(entries), r);
        assertSameEntries(
                entries,
                cache.get(reposRoot, r, true).getHistoryEntries());
    }

    /**
     * Test that tags work. Issue #101.
     */
    public void testTags() throws Exception {
        // Enable tags
        RuntimeEnvironment.getInstance().setTagsEnabled(true);

        File reposRoot = new File(repositories.getSourceRoot(), "mercurial");
        Repository repos = RepositoryFactory.getRepository(reposRoot);
        History historyToStore = repos.getHistory(reposRoot);
        cache.store(historyToStore, repos);
        cache.optimize();

        List<HistoryEntry> dirHistory =
                cache.get(reposRoot, repos, false).getHistoryEntries();
        assertEquals("Size of history", 10, dirHistory.size());
        assertEquals(null, dirHistory.get(0).getTags());
        assertNull(dirHistory.get(1).getTags());
        assertEquals("start_of_novel", dirHistory.get(2).getTags());
        assertNull(dirHistory.get(3).getTags());

        List<HistoryEntry> novelHistory =
                cache.get(new File(reposRoot, "novel.txt"),
                          repos, false).getHistoryEntries();
        assertEquals("Size of history", 6, novelHistory.size());
        assertEquals(null, novelHistory.get(0).getTags());
        assertEquals("start_of_novel", novelHistory.get(1).getTags());

        List<HistoryEntry> maincHistory =
                cache.get(new File(reposRoot, "main.c"),
                          repos, false).getHistoryEntries();
        assertEquals("Size of history", 2, maincHistory.size());
        assertEquals("start_of_novel", maincHistory.get(0).getTags());
        assertNull(maincHistory.get(1).getTags());
    }
}
