/*
 * #%L
 * ontop-rdb2rdf-compliance
 * %%
 * Copyright (C) 2009 - 2014 Free University of Bozen-Bolzano
 * %%
 * 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.
 * #L%
 */

import com.google.common.base.Charsets;
import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.io.Resources;
import it.unibz.inf.ontop.exception.MappingBootstrappingException;
import it.unibz.inf.ontop.exception.MappingException;
import it.unibz.inf.ontop.exception.OntopResultConversionException;
import it.unibz.inf.ontop.injection.*;
import it.unibz.inf.ontop.injection.OntopSQLOWLAPIConfiguration.Builder;
import it.unibz.inf.ontop.rdf4j.repository.OntopRepository;
import it.unibz.inf.ontop.spec.mapping.bootstrap.DirectMappingBootstrapper;
import it.unibz.inf.ontop.spec.mapping.bootstrap.DirectMappingBootstrapper.BootstrappingResults;
import it.unibz.inf.ontop.spec.mapping.pp.SQLPPMapping;
import it.unibz.inf.ontop.utils.ImmutableCollectors;
import org.eclipse.rdf4j.model.*;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.model.util.Models;
import org.eclipse.rdf4j.model.vocabulary.RDF;
import org.eclipse.rdf4j.query.*;
import org.eclipse.rdf4j.repository.Repository;
import org.eclipse.rdf4j.repository.RepositoryConnection;
import org.eclipse.rdf4j.rio.*;
import org.eclipse.rdf4j.rio.helpers.AbstractRDFHandler;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.semanticweb.owlapi.model.OWLOntologyCreationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.*;
import java.net.URL;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.*;

import static org.junit.Assert.fail;
import static org.junit.Assume.assumeTrue;

/**
 * Test suite for RDB2RDF tests.
 *
 * @author Evren Sirin (evren@complexible.com)
 */
@RunWith(Parameterized.class)
public class RDB2RDFTest {
	/**
	 * Following tests are failing due to various different reasons and bugs and are excluded manually.
	 */
	private static final Set<String> IGNORE = ImmutableSet.of(
			// Should reject an undefined SQL version
			"tc0003a",
			// Limitation of bnode isomorphism detection + xsd:double encoding (engineering notation was expected)
			"dg0005",
			// Limitation of bnode isomorphism detection
			"dg0005-modified",
			// Different XSD.DOUBLE lexical form; was expecting the engineering notation. Modified version added.
			"tc0005a",
			// Different XSD.DOUBLE lexical form; was expecting the engineering notation. Modified version added.
			"tc0005b",
			// Modified (different XSD.DOUBLE lexical form)
			"dg0012",
			// Direct mapping and bnodes: row unique ids are not considered, leadinq to incomplete results
			// (e.g. Bob-London should appear twice). TODO: fix it
			"dg0012-modified",
			// Modified (different XSD.DOUBLE lexical form)
			"tc0012a",
			// Modified (different XSD.DOUBLE lexical form)
			"tc0012e",
			// Double + timezone was not expected to be added. Same for milliseconds.
			"dg0016",
			// Different XSD.DOUBLE lexical form. Modified version added.
			"tc0016b",
			// Timezone was not expected to be added. Same for milliseconds (not so relevant test)
			"tc0016c",
			// H2 does not store the implicit trailing spaces in CHAR(15) and does not output them.
			"dg0018",
			// H2 does not store the implicit trailing spaces in CHAR(15) and does not output them.
			"tc0018a",
			// Should create an IRI based on a column and the base IRI. TODO: support the base IRI in R2RML
			"tc0019a"
	);

	private static List<String> FAILURES = Lists.newArrayList();

	private static final String BASE_IRI = "http://example.com/base/";

	private static String LAST_SQL_SCRIPT = null;

	private static Connection SQL_CONN;

	private static ValueFactory FACTORY = SimpleValueFactory.getInstance();

	private static Properties PROPERTIES;

	private static final String JDBC_URL = "jdbc:h2:mem:questrepository";
	private static final String DB_USER = "sa";
	private static final String DB_PASSWORD = "";
	private static final String JDBC_DRIVER = "org.h2.Driver";

	/**
	 * Terms used in the manifest files of RDB2RDF test suite
	 */
	private static class TestVocabulary  {
		public static final String NS = "http://purl.org/NET/rdb2rdf-test#";

		public static final IRI SQL_SCRIPT_FILE = FACTORY.createIRI(NS, "sqlScriptFile");

		public static final IRI DIRECT_MAPPING = FACTORY.createIRI(NS, "DirectMapping");

		public static final IRI R2RML = FACTORY.createIRI(NS, "R2RML");

		public static final IRI MAPPING_DOCUMENT = FACTORY.createIRI(NS, "mappingDocument");

		public static final IRI OUTPUT = FACTORY.createIRI(NS, "output");
	}

	/**
	 * Returns the list of parameters created automatically from RDB2RDF manifest files
	 */
	@Parameterized.Parameters(name="{0}")
	public static Collection<Object[]> parameters() throws Exception {
		final List<Object[]> params = Lists.newArrayList();

		// There are 25 directories in the test suite so we'll iterate them all
		for (int i = 0; i < 26; i++) {
			final String dir = String.format("D%03d/", i);
			final String manifest = dir + "manifest.ttl";

			// simpler handler for manifest files that takes advantage of the fact that triples in
			// manifest files are ordered in a certain way (otherwise we'll get an explicit error)
            RDFHandler manifestHandler = new AbstractRDFHandler() {
				protected String name;
				protected String sqlFile;
				protected String mappingFile;
				protected String outputFile;

				@Override
				public void handleStatement(final Statement st) throws RDFHandlerException {
					IRI pred = st.getPredicate();
					// the first thing we'll see in the file is the SQL script file
					if (pred.equals(TestVocabulary.SQL_SCRIPT_FILE)) {
						// make sure there is a single SQL script in each manifest
						Preconditions.checkState(sqlFile == null, "Multiple SQL files defined");
						sqlFile = dir + st.getObject().stringValue();
					}
					else if (pred.equals(RDF.TYPE)) {
						Value aType = st.getObject();
						// definition for a new test is beginning
						if (aType.equals(TestVocabulary.DIRECT_MAPPING) || aType.equals(TestVocabulary.R2RML)) {
							// create parameter for the previous test case
							createTestCase();
							// reset state
							name = ((IRI) st.getSubject()).getLocalName();
							mappingFile = outputFile = null;
						}
					}
					else if (pred.equals(TestVocabulary.MAPPING_DOCUMENT)) {
						// record the mapping file
						mappingFile = dir + st.getObject().stringValue();
					}
					else if (pred.equals(TestVocabulary.OUTPUT)) {
						// record the output file
						outputFile = dir + st.getObject().stringValue();
					}
				}

				@Override
				public void endRDF() throws RDFHandlerException {
					// generate the parameter corresponding to the last test case
					createTestCase();
				}

				private void createTestCase() {
					if (name != null) {
						Preconditions.checkState(sqlFile != null, "SQL file not defined");
						params.add(new Object[] { name, sqlFile, mappingFile, outputFile });
					}
				}
			};

			// parse the manifest file
			RDFParser parser = Rio.createParser(RDFFormat.TURTLE);
			parser.setRDFHandler(manifestHandler);
			parser.parse(stream(manifest), TestVocabulary.NS);
		}

		return params;
	}

	private static URL url(String path)  {
		return path == null ? null : RDB2RDFTest.class.getResource(path);
	}

	private static InputStream stream(String path) {
		return RDB2RDFTest.class.getResourceAsStream(path);
	}

	protected final String sqlFile;
	protected final String mappingFile;
	protected final String outputFile;
	protected final String name;
	static final Logger logger = LoggerFactory.getLogger(RDB2RDFTest.class);
	
	public RDB2RDFTest(String name, String sqlFile, String mappingFile, String outputFile) throws FileNotFoundException {
		this.name = name;
		this.sqlFile = sqlFile;
		this.mappingFile = mappingFile;
		this.outputFile =  outputFile;
	}

	@BeforeClass
	public static void beforeClass() throws Exception {
		SQL_CONN = DriverManager.getConnection("jdbc:h2:mem:questrepository","sa", "");
		//Server.startWebServer(SQL_CONN);

		PROPERTIES = new Properties();

		PROPERTIES.setProperty(OntopSQLCredentialSettings.JDBC_USER, DB_USER);
		PROPERTIES.setProperty(OntopSQLCredentialSettings.JDBC_PASSWORD, DB_PASSWORD);
		PROPERTIES.setProperty(OntopSQLCoreSettings.JDBC_URL, JDBC_URL);
		PROPERTIES.setProperty(OntopSQLCoreSettings.JDBC_DRIVER, JDBC_DRIVER);
		PROPERTIES.setProperty(OntopMappingSettings.BASE_IRI, BASE_IRI);
		PROPERTIES.setProperty(OntopOBDASettings.ALLOW_RETRIEVING_BLACK_BOX_VIEW_METADATA_FROM_DB, "true");
	}

	@Before
	public void beforeTest() throws Exception {
		// Several tests use the same backing database so no need to recreate the database if the previous test already did so
		if (Objects.equal(LAST_SQL_SCRIPT, sqlFile)) {
			return;
		}

		// new test so clear the database first
		if (LAST_SQL_SCRIPT != null) {
			clearDB();
		}

		LAST_SQL_SCRIPT = sqlFile;

        try (java.sql.Statement s = SQL_CONN.createStatement()) {
            String text = Resources.toString(url(sqlFile), Charsets.UTF_8);
            s.execute(text);
        } catch (SQLException sqle) {
            System.out.println("Exception in creating db from script");
        }
	}

	protected Repository createRepository() throws Exception {
		logger.info("RDB2RDFTest " + name + " " + mappingFile);

		OntopSQLOWLAPIConfiguration configuration;
		if (mappingFile != null) {
			String absoluteFilePath = Optional.ofNullable(getClass().getResource(mappingFile))
					.map(URL::getFile)
					.orElseThrow(() -> new IllegalArgumentException("The mappingFile " + mappingFile
							+ " has not been found"));
			configuration = createStandardConfigurationBuilder()
					.r2rmlMappingFile(absoluteFilePath)
					.build();
		}
		else {
			configuration = bootstrapDMConfiguration();
		}
		OntopRepository repo = OntopRepository.defaultRepository(configuration);
		repo.init();
		return repo;
	}

	Builder<? extends Builder<?>> createStandardConfigurationBuilder() {
		  return OntopSQLOWLAPIConfiguration.defaultBuilder()
				 .properties(PROPERTIES)
				  .enableDefaultDatatypeInference(true);
	}

	Builder<? extends Builder<?>> createInMemoryBuilder() {
		return createStandardConfigurationBuilder()
				.jdbcUrl(JDBC_URL)
				.jdbcDriver(JDBC_DRIVER)
				.jdbcUser(DB_USER)
				.jdbcPassword(DB_PASSWORD)
				.enableDefaultDatatypeInference(true)
				.enableTestMode();
	}

	/**
	 * Bootstraps the mapping and returns a new configuration
	 */
	OntopSQLOWLAPIConfiguration bootstrapDMConfiguration()
			throws OWLOntologyCreationException, MappingException, MappingBootstrappingException {

		OntopSQLOWLAPIConfiguration initialConfiguration = createInMemoryBuilder().build();
		DirectMappingBootstrapper bootstrapper = DirectMappingBootstrapper.defaultBootstrapper();
		BootstrappingResults results = bootstrapper.bootstrap(initialConfiguration, BASE_IRI);

		SQLPPMapping bootstrappedMapping = results.getPPMapping();

		return createInMemoryBuilder()
				.ppMapping(bootstrappedMapping)
				.build();
	}

	protected static void clearDB()  {
        try (java.sql.Statement s = SQL_CONN.createStatement()) {
            s.execute("DROP ALL OBJECTS DELETE FILES");
        } catch (SQLException sqle) {
            System.out.println("Table not found, not dropping");
        }
	}

	@AfterClass
	public static void afterClass() throws Exception {
		System.out.println("RDB2RDF Summary");
		System.out.println("IGNORED " + IGNORE.size() + " " + IGNORE);
		System.out.println("FAILED " + FAILURES.size() + " " + FAILURES);

		try {
			clearDB();
		}
		finally {
			SQL_CONN.close();
		}
	}

	@Test
	public void runTest() throws Exception {
		assumeTrue(!IGNORE.contains(name));

		try {
 			runTestWithoutIgnores();
		}
		catch (Throwable e) {
			FAILURES.add('"' + name + '"');
			throw e;
		}
	}

	private void runTestWithoutIgnores() throws Exception {
		boolean outputExpected = (outputFile != null);

		Repository dataRep;

		try {
			dataRep = createRepository();
		}
		catch (Exception e) {
			// if the test is for invalid mappings then there won't be any expected output and exception here is fine
			// otherwise it is a test failure
			if (outputExpected) {
				e.printStackTrace();
				fail("Error creating repository: " + e.getMessage());
			}
			return;
		}


		try (RepositoryConnection con = dataRep.getConnection()) {
			String tripleQuery = "CONSTRUCT {?s ?p ?o} WHERE {?s ?p ?o}";
			GraphQuery gquery = con.prepareGraphQuery(QueryLanguage.SPARQL, tripleQuery);
			Set<Statement> triples = QueryResults.asSet(gquery.evaluate());

			TupleQuery namedGraphQuery = con.prepareTupleQuery(QueryLanguage.SPARQL,
					"SELECT DISTINCT ?g WHERE { GRAPH ?g {?s ?p ?o } }");
			ImmutableSet<Resource> namedGraphs = QueryResults.asSet(namedGraphQuery.evaluate()).stream()
					.map(bs -> bs.getBinding("g"))
					.map(Binding::getValue)
					.map(v -> (Resource) v)
					.collect(ImmutableCollectors.toSet());

			String quadQuery = "CONSTRUCT {?s ?p ?o} WHERE { GRAPH ?g {?s ?p ?o} }";
			Set<Statement> actual = new HashSet<>(triples);
			for (Resource namedGraph : namedGraphs) {
				GraphQuery query = con.prepareGraphQuery(quadQuery);
				query.setBinding("g", namedGraph);
				QueryResults.asSet(query.evaluate()).stream()
						.map(s -> FACTORY.createStatement(s.getSubject(), s.getPredicate(), s.getObject(), namedGraph))
						.forEach(actual::add);
			}

			Set<Statement> expected = ImmutableSet.of();
			if (outputExpected) {
				expected = Rio.parse(stream(outputFile), BASE_IRI, Rio.getParserFormatForFileName(outputFile).get());
			}

			if (!Models.isomorphic(expected, actual)) {
				String msg = failureMessage(expected, actual);
				System.out.println(msg);

				fail(msg);
			}
		}
		catch (QueryEvaluationException e) {
			if (e.getCause() != null && e.getCause() instanceof OntopResultConversionException) {
				if (outputExpected) {
					e.printStackTrace();
					fail("Unexpected result conversion exception: " + e.getMessage());
				}
			}
			else {
				fail("Unexpected exception: " + e.getMessage());
			}
		}
		finally {
			dataRep.shutDown();
		}
	}

	/**
	 * Pretty print expected and actual results
	 */
	private String failureMessage(Set<Statement> expected, Set<Statement> actual) throws RDFHandlerException {
		StringWriter sw = new StringWriter();
		PrintWriter pw = new PrintWriter(sw);
		pw.println("Failed Test: " + name);
		pw.println("========== Expected =============");
		Rio.write(expected, pw, RDFFormat.NQUADS);
		pw.println("========== Actual ===============");
		Rio.write(actual, pw, RDFFormat.NQUADS);
		pw.flush();
		return sw.toString();
	}
}

