/*
 * Copyright (c) "Neo4j"
 * Neo4j Sweden AB [http://neo4j.com]
 *
 * This file is part of Neo4j.
 *
 * 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 apoc.hashing;

import static apoc.util.MapUtil.map;
import static java.util.Collections.EMPTY_LIST;
import static java.util.Collections.singletonList;
import static java.util.Collections.singletonMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import apoc.coll.Coll;
import apoc.graph.Graphs;
import apoc.util.TestUtil;
import apoc.util.Util;
import com.neo4j.test.extension.EnterpriseDbmsExtension;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.neo4j.graphdb.GraphDatabaseService;
import org.neo4j.graphdb.QueryExecutionException;
import org.neo4j.test.extension.Inject;

@EnterpriseDbmsExtension()
public class FingerprintingTest {

    @Inject
    GraphDatabaseService db;

    @BeforeAll
    void setup() {
        TestUtil.registerProcedure(db, Fingerprinting.class, Graphs.class, Coll.class);
    }

    @Test
    public void fingerprintScalars() {

        String hashOfAString = TestUtil.singleResultFirstColumn(db, "return apoc.hashing.fingerprint('some string');");
        assertEquals("5AC749FBEEC93607FC28D666BE85E73A", hashOfAString);

        String hashOfALong = TestUtil.singleResultFirstColumn(db, "return apoc.hashing.fingerprint(123);");
        assertEquals("202CB962AC59075B964B07152D234B70", hashOfALong);

        String hashOfADouble = TestUtil.singleResultFirstColumn(db, "return apoc.hashing.fingerprint(123.456);");
        assertEquals("B316DF1D65EE42FF51A5393DF1F86105", hashOfADouble);

        String hashOfABoolean = TestUtil.singleResultFirstColumn(db, "return apoc.hashing.fingerprint(true);");
        assertEquals("B326B5062B2F0E69046810717534CB09", hashOfABoolean);

        String hashOfNull = TestUtil.singleResultFirstColumn(db, "return apoc.hashing.fingerprint(null);");
        assertEquals("D41D8CD98F00B204E9800998ECF8427E", hashOfNull);
    }

    @Test
    public void fingerprintMap() {
        Map<String, Object> params = map("string", "some string", "boolean", true, "long", 123l, "double", 1234.456d);
        String hashOfAMap = TestUtil.singleResultFirstColumn(
                db, "return apoc.hashing.fingerprint($map);", singletonMap("map", params));
        assertEquals("040B354004871F76A693DEC2E5DD8F51", hashOfAMap);
    }

    @Test
    public void fingerprintNodeWithArrayProperty() {
        db.executeTransactionally(
                "CREATE (:Person{name:'ABC',emails:['aa@bb.de', 'cc@dd.ee'], integers:[1,2,3], floats:[0.9,1.1]})");
        String value = TestUtil.singleResultFirstColumn(db, "MATCH (n) RETURN apoc.hashing.fingerprint(n) AS hash");
        String value2 = TestUtil.singleResultFirstColumn(db, "MATCH (n) RETURN apoc.hashing.fingerprint(n) AS hash");
        assertEquals(value, value2);
    }

    @Test
    public void fingerprintRelationships() {
        db.executeTransactionally("CREATE (:Person{name:'ABC'})-[:KNOWS{since:12345}]->(:Person{name:'DEF'})");

        String value =
                TestUtil.singleResultFirstColumn(db, "MATCH ()-[r]->() RETURN apoc.hashing.fingerprint(r) AS hash");
        String value2 =
                TestUtil.singleResultFirstColumn(db, "MATCH ()-[r]->() RETURN apoc.hashing.fingerprint(r) AS hash");
        assertEquals(value, value2);
    }

    @Test
    public void fingerprintGraph() {
        compareGraph("CREATE (:Person{name:'ABC'})-[:KNOWS{since:12345}]->(:Person{name:'DEF'})", EMPTY_LIST, true);
    }

    @Test
    public void fingerprintGraphShouldFailUponDifferentProperties() {
        compareGraph("CREATE (:Person{name:'ABC', created:timestamp()})", EMPTY_LIST, false);
    }

    @Test
    public void fingerprintGraphShouldNotFailWithEqualsNodes() {
        db.executeTransactionally("CREATE (:Person{name:'ABC'}), (:Person{name:'ABC'})");
        String valueAfter = TestUtil.singleResultFirstColumn(db, "return apoc.hashing.fingerprintGraph()");
        db.executeTransactionally("CREATE (:Person{name:'ABC'}), (:Person{name:'ABC'})");
        String valueBefore = TestUtil.singleResultFirstColumn(db, "return apoc.hashing.fingerprintGraph()");
        assertNotEquals(valueAfter, valueBefore);
        String valueWithExclude =
                TestUtil.singleResultFirstColumn(db, "return apoc.hashing.fingerprintGraph(['name'])");
        assertNotEquals(valueAfter, valueWithExclude);
    }

    @Test
    public void testExcludes() {
        compareGraph("CREATE (:Person{name:'ABC', created:timestamp()})", singletonList("created"), true);
    }

    @Test
    public void fingerprintByteArray() {
        byte[] byteArray = "hello, world".getBytes();

        db.executeTransactionally("CREATE (n:NodeTest {value: $value})", Map.of("value", byteArray));

        TestUtil.testCall(
                db,
                "MATCH (n:NodeTest) RETURN apoc.hashing.fingerprinting(n) as hash",
                r -> assertEquals("D41D8CD98F00B204E9800998ECF8427E", r.get("hash")));

        TestUtil.testCall(
                db,
                "MATCH (n:NodeTest) RETURN apoc.hashing.fingerprint(n) as hash",
                r -> assertEquals("3F8674B1C91F6D033C7183DB8F90936F", r.get("hash")));
    }

    @Test
    public void testFingerprintingNodeConf() {
        db.executeTransactionally(
                "CREATE (:Person{name:'Andrea',emails:['aa@bb.de', 'cc@dd.ee'], integers:[1,2,3], floats:[0.9,1.1]})");
        String all = TestUtil.singleResultFirstColumn(
                db,
                "MATCH (p:Person) return apoc.hashing.fingerprinting(p, $conf) as hash",
                Collections.singletonMap("conf", Collections.emptyMap()));
        String emails = TestUtil.singleResultFirstColumn(
                db,
                "MATCH (p:Person) return apoc.hashing.fingerprinting(p, $conf) as hash",
                Collections.singletonMap(
                        "conf", Util.map("nodeAllowMap", Util.map("Person", Collections.singletonList("emails")))));
        String emailsByDisallowMap = TestUtil.singleResultFirstColumn(
                db,
                "MATCH (p:Person) return apoc.hashing.fingerprinting(p, $conf) as hash",
                Collections.singletonMap(
                        "conf",
                        Util.map("nodeDisallowMap", Util.map("Person", Arrays.asList("name", "integers", "floats")))));
        String floats = TestUtil.singleResultFirstColumn(
                db,
                "MATCH (p:Person) return apoc.hashing.fingerprinting(p, $conf) as hash",
                Collections.singletonMap(
                        "conf",
                        Util.map("nodeDisallowMap", Util.map("Person", Arrays.asList("name", "emails", "integers")))));
        Set<String> hashes = new HashSet<>(Arrays.asList(all, emails, emailsByDisallowMap, floats));
        assertEquals(3, hashes.size()); // 3 because emails = emailsByDisallowMap
    }

    @Test
    public void testFingerprintingRelConf() {
        db.executeTransactionally("CREATE (p1:Person{name:'Andrea'}), (p2:Person{name:'Stefan'}), "
                + "(p1)-[:KNOWS{since: 2014, where: 'Stackoverflow'}]->(p2)");
        String all = TestUtil.singleResultFirstColumn(
                db,
                "MATCH (n)-[r]->(m) RETURN apoc.hashing.fingerprinting(r, $conf) as hash",
                Collections.singletonMap("conf", Collections.emptyMap()));
        String since = TestUtil.singleResultFirstColumn(
                db,
                "MATCH (n)-[r]->(m) RETURN apoc.hashing.fingerprinting(r, $conf) as hash",
                Collections.singletonMap(
                        "conf", Util.map("relAllowMap", Util.map("KNOWS", Collections.singletonList("since")))));
        String where = TestUtil.singleResultFirstColumn(
                db,
                "MATCH (n)-[r]->(m) RETURN apoc.hashing.fingerprinting(r, $conf) as hash",
                Collections.singletonMap(
                        "conf", Util.map("relDisallowMap", Util.map("KNOWS", Arrays.asList("since")))));
        Set<String> hashes = new HashSet<>(Arrays.asList(all, since, where));
        assertEquals(3, hashes.size());
    }

    @Test
    public void testFingerprintingMapConf() {
        db.executeTransactionally(
                "CREATE (p1:Person{name:'Andrea', surname:'Santurbano'}), (p2:Person{name:'Stefan', surname:'Armbruster'}), "
                        + "(pr:Product{sku: 'Nintendo Switch'}), "
                        + "(p1)-[:KNOWS{since: 2014}]->(p2), (p2)-[:KNOWS{since: 2018}]->(p1), "
                        + "(p1)-[:BOUGHT{when: 2017}]->(pr)");
        String all = TestUtil.singleResultFirstColumn(
                db,
                "MATCH p = (n)-[r]->(m) " + "WITH collect(p) AS paths "
                        + "CALL apoc.graph.fromPaths(paths, '', {}) yield graph AS g "
                        + "WITH {nodes: apoc.coll.toSet(g.nodes), rels: apoc.coll.toSet(g.relationships)} AS map "
                        + "RETURN apoc.hashing.fingerprinting(map, $conf) as hash ",
                Collections.singletonMap("conf", Collections.emptyMap()));
    }

    @Test
    public void testFingerprintingWithLazyEvaluation() {
        db.executeTransactionally(
                "CREATE (p1:Person{name:'Andrea', surname:'Santurbano'}), (p2:Person{name:'Stefan', surname:'Armbruster'}), "
                        + "(pr:Product{sku: 'Nintendo Switch'}), "
                        + "(p1)-[:KNOWS{since: 2014}]->(p2), (p2)-[:KNOWS{since: 2018}]->(p1), "
                        + "(p1)-[:BOUGHT{when: 2017}]->(pr)");
        String all = TestUtil.singleResultFirstColumn(
                db,
                "MATCH p = (n)-[r]->(m) " + "WITH collect(p) AS paths "
                        + "CALL apoc.graph.fromPaths(paths, '', {}) yield graph AS g "
                        + "WITH {nodes: apoc.coll.toSet(g.nodes), rels: apoc.coll.toSet(g.relationships)} AS map "
                        + "RETURN apoc.hashing.fingerprinting(map, $conf) as hash ",
                Collections.singletonMap(
                        "conf", Util.map("nodeAllowMap", Util.map("Person", Collections.singletonList("name")))));
        String filtered = TestUtil.singleResultFirstColumn(
                db,
                "MATCH (p:Person) "
                        + "RETURN apoc.hashing.fingerprinting({nodes: collect(p), rels: []}, $conf) as hash ",
                Collections.singletonMap(
                        "conf", Util.map("nodeAllowMap", Util.map("Person", Collections.singletonList("name")))));
        assertEquals(all, filtered);
    }

    @Test
    public void testConfigExceptionOnTheSameLabels() {
        db.executeTransactionally(
                "CREATE (p1:Person{name:'Andrea', surname:'Santurbano'}), (p2:Person{name:'Stefan', surname:'Armbruster'}), "
                        + "(pr:Product{sku: 'Nintendo Switch'}), "
                        + "(p1)-[:KNOWS{since: 2014}]->(p2), (p2)-[:KNOWS{since: 2018}]->(p1), "
                        + "(p1)-[:BOUGHT{when: 2017}]->(pr)");
        final Map<String, Object> conf = Util.map(
                "nodeAllowMap",
                Util.map("Person", Arrays.asList("name")),
                "nodeDisallowMap",
                Util.map("Person", Arrays.asList("surname")));

        QueryExecutionException e = assertThrows(
                QueryExecutionException.class,
                () -> TestUtil.singleResultFirstColumn(
                        db,
                        "MATCH p = (n)-[r]->(m) " + "WITH collect(p) AS paths "
                                + "CALL apoc.graph.fromPaths(paths, '', {}) yield graph AS g "
                                + "WITH {nodes: apoc.coll.toSet(g.nodes), rels: apoc.coll.toSet(g.relationships)} AS map "
                                + "RETURN apoc.hashing.fingerprinting(map, $conf) as hash ",
                        Collections.singletonMap("conf", conf)));

        String expected = "You can't set the same labels for allow and disallow lists for nodes";
        assertEquals(expected, ExceptionUtils.getRootCause(e).getMessage());
    }

    private void compareGraph(String cypher, List<String> excludes, boolean shouldBeEqual) {
        Map<String, Object> params = singletonMap("excludes", excludes);

        db.executeTransactionally(cypher);
        String value =
                TestUtil.singleResultFirstColumn(db, "return apoc.hashing.fingerprintGraph($excludes) as hash", params);

        db.executeTransactionally("match (n) detach delete n");
        db.executeTransactionally(cypher);
        String value2 =
                TestUtil.singleResultFirstColumn(db, "return apoc.hashing.fingerprintGraph($excludes) as hash", params);

        if (shouldBeEqual) {
            assertEquals(value, value2);
        } else {
            assertNotEquals(value, value2);
        }
    }
}
