/* ###
 * IP: GHIDRA
 *
 * 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 db;

import static org.junit.Assert.*;

import java.io.File;
import java.io.IOException;
import java.util.*;

import org.junit.*;

import db.buffers.*;
import generic.test.AbstractGenericTest;
import ghidra.util.exception.CancelledException;
import ghidra.util.exception.DuplicateFileException;
import utilities.util.FileUtilities;

/**
 * Test the creation of a new database with indexed and non-indexed tables.
 * Tests include the removal of tables and are performed for both a stored and
 * non-stored database.
 */
public class DBTest extends AbstractGenericTest {

	private static final int BUFFER_SIZE = 256;
	private static final int CACHE_SIZE = 4 * 1024 * 1024;

	private File testDir;
	private static final String dbName = "test";
	private static final String dbName2 = "test2";

	private BufferFileManager fileMgr;
	private DBHandle dbh;
	private BufferFile bfile;
	private MyDbListener listener;

	@Before
	public void setUp() throws Exception {

		testDir = createTempDirectory(getClass().getSimpleName());
		dbh = new DBHandle(BUFFER_SIZE, CACHE_SIZE);
		listener = new MyDbListener();
		dbh.addListener(listener);
	}

	@After
	public void tearDown() throws Exception {
		if (dbh != null) {
			dbh.close();
		}
		if (bfile != null) {
			bfile.close();
		}
		FileUtilities.deleteDir(testDir);
	}

	private void saveAsAndReopen(String name) throws IOException {
		try {
			BufferFileManager mgr = DBTestUtils.getBufferFileManager(testDir, name);
			BufferFile bf = new LocalManagedBufferFile(dbh.getBufferSize(), mgr, -1);
			dbh.saveAs(bf, true, null);
			dbh.close();
			fileMgr = mgr;
		}
		catch (CancelledException e) {
			Assert.fail("Should not happen");
		}
		bfile = new LocalManagedBufferFile(fileMgr, true, -1, -1);
		dbh = new DBHandle(bfile);
	}

	private void saveAndReopen() throws IOException {
		assertTrue(dbh.canUpdate());
		long dbId = dbh.getDatabaseId();
		try {
			dbh.save(null, null, null);
		}
		catch (CancelledException e) {
			Assert.fail("Should not happen");
		}
		dbh.close();
		bfile = new LocalManagedBufferFile(fileMgr, true, -1, -1);
		dbh = new DBHandle(bfile);
		assertEquals(dbId, dbh.getDatabaseId());
	}

	@Test
	public void testCreateDatabase() {

		assertTrue(!dbh.canUpdate());
		assertTrue(!dbh.canRedo());
		assertTrue(!dbh.canUndo());

		try {
			dbh.checkTransaction();
			Assert.fail();
		}
		catch (NoTransactionException e) {
			// expected
		}

		assertEquals(dbh.getTableCount(), 0);
	}

	@Test
	public void testCreateExistingDatabase() throws IOException {
		saveAsAndReopen(dbName);
		try {
			saveAsAndReopen(dbName);
			Assert.fail();
		}
		catch (DuplicateFileException e) {
			// good; expected
		}
	}

	@Test
	public void testCreateDatabaseCopies() throws IOException {
		long dbId = dbh.getDatabaseId();
		saveAsAndReopen(dbName);
		assertTrue(dbId == dbh.getDatabaseId());// First SaveAs should preserve ID
		try {
			saveAsAndReopen(dbName2);
			assertTrue(dbId != dbh.getDatabaseId());// Second SaveAs should change ID to avoid duplication
		}
		catch (DuplicateFileException e) {
			Assert.fail();
		}
	}

	@Test
	public void testOpenForReadOnlyDuringUpdate() throws IOException {
		saveAsAndReopen(dbName);

		BufferFile bf2 = new LocalManagedBufferFile(fileMgr, false, -1, -1);
		DBHandle dbh2 = null;
		try {
			dbh2 = new DBHandle(bf2);
			createNonIndexedTables(false, DBTestUtils.MAX_SCHEMA_TYPE + 1);
			saveAndReopen();
			assertEquals(DBTestUtils.MAX_SCHEMA_TYPE + 1, dbh.getTableCount());
			assertEquals(0, dbh2.getTableCount());
		}
		finally {
			if (dbh2 != null) {
				dbh2.close();
			}
			bf2.close();
		}
	}

	@Test
	public void testCreateLongKeyTable() throws IOException {
		long txId = dbh.startTransaction();
		Table table =
			DBTestUtils.createLongKeyTable(dbh, "TABLE1", DBTestUtils.ALL_TYPES, false, false);
		dbh.endTransaction(txId, true);
		String[] names = table.getSchema().getFieldNames();
		assertTrue(Arrays.equals(DBTestUtils.getFieldNames(DBTestUtils.ALL_TYPES), names));
	}

	@Test
	public void testCreateVarKeyTable() throws IOException {
		long txId = dbh.startTransaction();
		Table table = DBTestUtils.createBinaryKeyTable(dbh, "TABLE1", DBTestUtils.ALL_TYPES, false);
		dbh.endTransaction(txId, true);
		String[] names = table.getSchema().getFieldNames();
		assertTrue(Arrays.equals(DBTestUtils.getFieldNames(DBTestUtils.ALL_TYPES), names));
	}

	@Test
	public void testStoredCreateLongKeyTable() throws IOException {
		long txId = dbh.startTransaction();
		DBTestUtils.createLongKeyTable(dbh, "TABLE1", DBTestUtils.ALL_TYPES, false, false);
		dbh.endTransaction(txId, true);
		saveAsAndReopen(dbName);
		Table table = dbh.getTable("TABLE1");
		assertTrue(table.useLongKeys());
		String[] names = table.getSchema().getFieldNames();
		assertTrue(Arrays.equals(DBTestUtils.getFieldNames(DBTestUtils.ALL_TYPES), names));
	}

	@Test
	public void testStoredCreateVarKeyTable() throws IOException {
		long txId = dbh.startTransaction();
		DBTestUtils.createBinaryKeyTable(dbh, "TABLE1", DBTestUtils.ALL_TYPES, false);
		dbh.endTransaction(txId, true);
		saveAsAndReopen(dbName);
		Table table = dbh.getTable("TABLE1");
		assertTrue(!table.useLongKeys());
		assertTrue(table.getSchema().getKeyFieldType().getClass().equals(BinaryField.class));
		String[] names = table.getSchema().getFieldNames();
		assertTrue(Arrays.equals(DBTestUtils.getFieldNames(DBTestUtils.ALL_TYPES), names));
	}

	private void createNonIndexedTables(boolean testStoredDB, int cnt) throws IOException {

		// Create table for each schema defined by DBTestUtils
		long txId = dbh.startTransaction();
		for (int i = 0; i < cnt; i++) {
			DBTestUtils.createLongKeyTable(dbh, "TABLE" + i, i % (DBTestUtils.MAX_SCHEMA_TYPE + 1),
				false, false);
		}
		dbh.endTransaction(txId, true);
		assertEquals(cnt, dbh.getTableCount());

		if (testStoredDB) {
			saveAsAndReopen(dbName);
			assertEquals(cnt, dbh.getTableCount());
		}

		// Check Master Table entries
		TableRecord[] tableRecords = dbh.getMasterTable().getTableRecords();
		for (int i = 0; i < tableRecords.length; i++) {
			String name = "TABLE" + i;
			assertEquals(name, tableRecords[i].getName());
			assertEquals(-1, tableRecords[i].getIndexedColumn());
			assertEquals(Long.MIN_VALUE, tableRecords[i].getMaxKey());
			assertEquals(0, tableRecords[i].getRecordCount());
			assertEquals(-1, tableRecords[i].getRootBufferId());
		}
	}

	@Test
	public void testNonIndexedTables() throws IOException {
		createNonIndexedTables(false, DBTestUtils.MAX_SCHEMA_TYPE + 1);
	}

	@Test
	public void testStoredNonIndexedTables() throws IOException {
		createNonIndexedTables(true, DBTestUtils.MAX_SCHEMA_TYPE + 1);
	}

	private void createIndexedTables(boolean testStoredDB) throws IOException {

		// Create table for each schema defined by DBTestUtils
		// All schema fields are indexed
		long txId = dbh.startTransaction();
		for (int i = 0; i <= DBTestUtils.MAX_SCHEMA_TYPE; i++) {
			DBTestUtils.createLongKeyTable(dbh, "TABLE" + i, i, true, false);
		}
		assertEquals(DBTestUtils.MAX_SCHEMA_TYPE + 1, dbh.getTableCount());
		dbh.endTransaction(txId, true);

		if (testStoredDB) {
			saveAsAndReopen(dbName);
			assertEquals(DBTestUtils.MAX_SCHEMA_TYPE + 1, dbh.getTableCount());
		}

		// Check Master Table entries
		TableRecord[] tableRecords = dbh.getMasterTable().getTableRecords();
		Table lastTable = null;
		int tableCnt = 0;
		int indexCnt = 0;
		for (TableRecord tableRecord : tableRecords) {
			if (tableRecord.getIndexedColumn() < 0) {
				if (tableCnt > 0) {
					assertEquals(DBTestUtils.getIndexedColumnCount(tableCnt - 1), indexCnt);
				}
				String name = "TABLE" + tableCnt;
				lastTable = dbh.getTable(name);
				assertEquals(name, tableRecord.getName());
				assertEquals(Long.MIN_VALUE, tableRecord.getMaxKey());
				assertEquals(0, tableRecord.getRecordCount());
				assertEquals(-1, tableRecord.getRootBufferId());
				++tableCnt;
				indexCnt = 0;
			}
			else {
				if (lastTable == null) {
					Assert.fail();
				}
				int[] indexedColumns = DBTestUtils.getIndexedColumns(tableCnt - 1);
				assertTrue(indexCnt < indexedColumns.length);
				assertEquals(indexedColumns[indexCnt], tableRecord.getIndexedColumn());
				assertEquals(lastTable.getName(), tableRecord.getName());
				assertEquals(Long.MIN_VALUE, tableRecord.getMaxKey());
				assertEquals(0, tableRecord.getRecordCount());
				assertEquals(-1, tableRecord.getRootBufferId());
				++indexCnt;
			}

		}
		assertEquals(DBTestUtils.getIndexedColumnCount(tableCnt - 1), indexCnt);
		assertEquals(DBTestUtils.MAX_SCHEMA_TYPE + 1, tableCnt);

	}

	@Test
	public void testIndexedTables() throws IOException {
		createIndexedTables(false);
	}

	@Test
	public void testStoredIndexedTables() throws IOException {
		createIndexedTables(true);
	}

	private void removeNonIndexedTables(boolean testStoredDB) throws IOException {

		// Create table for each schema defined by DBTestUtils
		createNonIndexedTables(testStoredDB, DBTestUtils.MAX_SCHEMA_TYPE + 1);

		// Delete odd numbered tables
		long txId = dbh.startTransaction();
		int totalTableCnt = 0;
		for (int i = 0; i <= DBTestUtils.MAX_SCHEMA_TYPE; i++) {
			if ((i % 2) == 1) {
				dbh.deleteTable("TABLE" + i);
			}
			else {
				++totalTableCnt;
			}
		}
		dbh.endTransaction(txId, true);
		assertEquals(totalTableCnt, dbh.getTableCount());

		if (testStoredDB) {
			saveAndReopen();
			assertEquals(totalTableCnt, dbh.getTableCount());
		}

		// Check Master Table entries
		TableRecord[] tableRecords = dbh.getMasterTable().getTableRecords();
		for (int i = 0; i < tableRecords.length; i++) {
			String name = "TABLE" + (2 * i);
			assertEquals(name, tableRecords[i].getName());
			assertEquals(-1, tableRecords[i].getIndexedColumn());
			assertEquals(Long.MIN_VALUE, tableRecords[i].getMaxKey());
			assertEquals(0, tableRecords[i].getRecordCount());
			assertEquals(-1, tableRecords[i].getRootBufferId());
		}
		assertEquals(totalTableCnt, tableRecords.length);

	}

	@Test
	public void testRemoveNonIndexedTables() throws IOException {
		removeNonIndexedTables(false);
	}

	@Test
	public void testStoredRemoveNonIndexedTables() throws IOException {
		removeNonIndexedTables(true);
	}

	private void removeIndexedTables(boolean testStoredDB) throws IOException {

		// Create table for each schema defined by DBTestUtils
		// All schema fields are indexed
		createIndexedTables(testStoredDB);

		// Delete odd numbered tables
		long txId = dbh.startTransaction();
		int totalTableCnt = 0;
		for (int i = 0; i <= DBTestUtils.MAX_SCHEMA_TYPE; i++) {
			if ((i % 2) == 1) {
				dbh.deleteTable("TABLE" + i);
			}
			else {
				++totalTableCnt;
			}
		}
		dbh.endTransaction(txId, true);
		assertEquals(totalTableCnt, dbh.getTableCount());

		if (testStoredDB) {
			saveAndReopen();
			assertEquals(totalTableCnt, dbh.getTableCount());
		}

		// Check Master Table entries
		TableRecord[] tableRecords = dbh.getMasterTable().getTableRecords();
		Table lastTable = null;
		int tableCnt = 0;
		int indexCnt = 0;
		for (TableRecord tableRecord : tableRecords) {
			if (tableRecord.getIndexedColumn() < 0) {
				if (tableCnt > 0) {
					assertEquals(DBTestUtils.getIndexedColumnCount(2 * (tableCnt - 1)), indexCnt);
				}
				String name = "TABLE" + (2 * tableCnt);
				lastTable = dbh.getTable(name);
				assertEquals(name, tableRecord.getName());
				assertEquals(Long.MIN_VALUE, tableRecord.getMaxKey());
				assertEquals(0, tableRecord.getRecordCount());
				assertEquals(-1, tableRecord.getRootBufferId());
				++tableCnt;
				indexCnt = 0;
			}
			else {
				if (lastTable == null) {
					Assert.fail();
				}
				int[] indexedColumns = DBTestUtils.getIndexedColumns(2 * (tableCnt - 1));
				assertTrue(indexCnt < indexedColumns.length);
				assertEquals(indexedColumns[indexCnt], tableRecord.getIndexedColumn());
				assertEquals(lastTable.getName(), tableRecord.getName());
				assertEquals(Long.MIN_VALUE, tableRecord.getMaxKey());
				assertEquals(0, tableRecord.getRecordCount());
				assertEquals(-1, tableRecord.getRootBufferId());
				++indexCnt;
			}

		}
		Schema schema = lastTable.getSchema();
		assertEquals(schema.getFields().length - 2, indexCnt); // ByteField and BooleanField do not support indexing
		assertEquals(totalTableCnt, tableCnt);

	}

	@Test
	public void testRemoveIndexedTables() throws IOException {
		removeIndexedTables(false);
	}

	@Test
	public void testStoredRemoveIndexedTables() throws IOException {
		removeIndexedTables(true);
	}

	@Test
	public void testLargeMasterTable() throws IOException {
		createNonIndexedTables(false, 1000);
	}

	@Test
	public void testStoredLargeMasterTable() throws IOException {
		createNonIndexedTables(true, 1000);
	}

	@Test
	public void testMasterTableUndo() throws IOException {
		createIndexedTables(false);
		Table[] tables = dbh.getTables();
		String table1Name = tables[1].getName();

		// delete a table - and rollback
		long txId = dbh.startTransaction();
		for (Table table : tables) {
			dbh.deleteTable(table.getName());
		}
		dbh.endTransaction(txId, false);

		assertTrue(dbh.getBufferMgr().atCheckpoint());

		try {
			tables[1].getName();
			Assert.fail();
		}
		catch (Exception e) {
			// Ignore
		}

		Schema s = tables[1].getSchema();
		DBRecord rec = s.createRecord(1);

		txId = dbh.startTransaction();
		try {
			tables[1].putRecord(rec);
			Assert.fail();
		}
		catch (Exception e) {
			// Ignore
		}
		assertTrue(dbh.getBufferMgr().atCheckpoint());

		Table t = dbh.getTable(table1Name);
		assertEquals(table1Name, t.getName());

		t.putRecord(rec);
		assertTrue(!dbh.getBufferMgr().atCheckpoint());
		dbh.endTransaction(txId, true);

		assertEquals(rec, t.getRecord(1));

		// delete a table - and commit
		txId = dbh.startTransaction();
		dbh.deleteTable(table1Name);
		dbh.endTransaction(txId, true);

		assertTrue(dbh.undo());

		t = dbh.getTable(table1Name);
		assertEquals(table1Name, t.getName());

		assertTrue(dbh.redo());

		t = dbh.getTable(table1Name);
		assertNull(t);

	}

	@Test
	public void testTableWithIndexRecreateUndo() throws IOException {

		createIndexedTables(false);

		Table[] tables = dbh.getTables();
		String table1Name = tables[1].getName();

		Table t1 = dbh.getTable(table1Name);
		int[] indexedColumns = t1.getIndexedColumns();
		assertEquals(t1.getSchema().getFieldCount(), indexedColumns.length);
		for (int i = 0; i < indexedColumns.length; i++) {
			assertEquals(i, indexedColumns[i]);
		}

		Schema schema = t1.getSchema();

		long txId = dbh.startTransaction();
		try {
			t1.putRecord(schema.createRecord(1));
		}
		finally {
			dbh.endTransaction(txId, true);
		}

		txId = dbh.startTransaction();
		try {
			dbh.deleteTable(table1Name);
			dbh.createTable(table1Name, schema, indexedColumns);
		}
		finally {
			dbh.endTransaction(txId, true);
		}

		dbh.undo();

		Table t1prime = dbh.getTable(table1Name);
		indexedColumns = t1prime.getIndexedColumns();
		assertEquals(t1prime.getSchema().getFieldCount(), indexedColumns.length);
		for (int i = 0; i < indexedColumns.length; i++) {
			assertEquals(i, indexedColumns[i]);
		}

		assertNotNull(t1prime.getRecord(1));

	}

	@Test
	public void testEvents() throws IOException {

		createIndexedTables(false);

		// Verify table added events
		Table[] tables = dbh.getTables();
		assertEquals(11, tables.length);
		assertEquals(tables.length, listener.events.size());
		int ix = 0;
		for (DbEvent evt : listener.events) {
			assertEquals(DbNotifyType.TABLE_ADDED, evt.type);
			assertEquals("TABLE" + ix, evt.table.getName());
			++ix;
		}

		listener.events.clear();

		// Verify table deleted event
		long txId = dbh.startTransaction();
		Table t = dbh.getTable("TABLE5");
		dbh.deleteTable("TABLE5");
		dbh.endTransaction(txId, true);
		assertEquals(1, listener.events.size());
		DbEvent evt = listener.events.get(0);
		assertEquals(DbNotifyType.TABLE_DELETED, evt.type);
		assertTrue(t == evt.table);

		listener.events.clear();

		dbh.undo();

		assertEquals(1, listener.events.size());
		evt = listener.events.get(0);
		assertEquals(DbNotifyType.RESTORED, evt.type);

		listener.events.clear();

		dbh.redo();

		assertEquals(1, listener.events.size());
		evt = listener.events.get(0);
		assertEquals(DbNotifyType.RESTORED, evt.type);

		listener.events.clear();

		dbh.close();
		dbh = null;

		assertEquals(1, listener.events.size());
		evt = listener.events.get(0);
		assertEquals(DbNotifyType.CLOSED, evt.type);

	}

	private enum DbNotifyType {
		RESTORED, CLOSED, TABLE_DELETED, TABLE_ADDED;
	}

	private static class DbEvent {

		final DbNotifyType type;
		final Table table;

		DbEvent(DbNotifyType type, Table table) {
			this.type = type;
			this.table = table;
		}

		@Override
		public String toString() {
			if (type != DbNotifyType.TABLE_ADDED) {
				return type.toString();
			}
			return type + ": " + table.getName();
		}
	}

	private static class MyDbListener implements DBListener {

		private List<DbEvent> events = new ArrayList<>();

		@Override
		public void dbRestored(DBHandle dbh) {
			events.add(new DbEvent(DbNotifyType.RESTORED, null));
		}

		@Override
		public void dbClosed(DBHandle dbh) {
			events.add(new DbEvent(DbNotifyType.CLOSED, null));
		}

		@Override
		public void tableDeleted(DBHandle dbh, Table table) {
			events.add(new DbEvent(DbNotifyType.TABLE_DELETED, table));
		}

		@Override
		public void tableAdded(DBHandle dbh, Table table) {
			events.add(new DbEvent(DbNotifyType.TABLE_ADDED, table));
		}

	}
}
