/*
 * Copyright 2017 Google Inc.
 *
 * 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 com.google.firebase.database.snapshot;

import static com.google.firebase.database.TestHelpers.fromSingleQuotedString;
import static com.google.firebase.database.TestHelpers.path;
import static com.google.firebase.database.snapshot.NodeUtilities.NodeFromJSON;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import com.google.firebase.database.MapBuilder;
import com.google.firebase.database.core.Path;
import com.google.firebase.database.utilities.Utilities;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.junit.Test;

public class CompoundHashTest {

  private static final CompoundHash.SplitStrategy NEVER_SPLIT_STRATEGY =
      new CompoundHash.SplitStrategy() {
        @Override
        public boolean shouldSplit(CompoundHash.CompoundHashBuilder state) {
          return false;
        }
      };

  private static CompoundHash.SplitStrategy splitAtPaths(String... paths) {
    final List<Path> pathList = new ArrayList<>();
    for (String path : paths) {
      pathList.add(path(path));
    }
    return new CompoundHash.SplitStrategy() {
      @Override
      public boolean shouldSplit(CompoundHash.CompoundHashBuilder state) {
        return pathList.contains(state.currentPath());
      }
    };
  }

  private static void assertWithinPercent(int expected, int actual, double percent) {
    double percentDecimal = percent / 100.0;
    double lowerBound = expected * (1 - percentDecimal);
    double upperBound = expected * (1 + percentDecimal);
    assertTrue(
        String.format("Not within range: (%02f, %02f): %d", lowerBound, upperBound, actual),
        actual > lowerBound);
    assertTrue(
        String.format("Not within range: (%02f, %02f): %d", lowerBound, upperBound, actual),
        actual < upperBound);
  }

  @Test
  public void emptyNodeYieldsEmptyHash() {
    CompoundHash hash = CompoundHash.fromNode(EmptyNode.Empty());
    assertEquals(Collections.<Path>emptyList(), hash.getPosts());
    assertEquals(Arrays.asList(""), hash.getHashes());
  }

  @Test
  public void compoundHashIsAlwaysFollowedByEmptyHash() {
    Node node = NodeFromJSON(fromSingleQuotedString("{'foo': 'bar'}"));
    CompoundHash hash = CompoundHash.fromNode(node, NEVER_SPLIT_STRATEGY);
    String expectedHash = Utilities.sha1HexDigest("(\"foo\":(string:\"bar\"))");
    assertEquals(Arrays.asList(path("foo")), hash.getPosts());
    assertEquals(Arrays.asList(expectedHash, ""), hash.getHashes());
  }

  @Test
  public void compoundHashCanSplitAtPriority() {
    Node node =
        NodeFromJSON(
            fromSingleQuotedString(
                "{'foo': {'!beforePriority': 'before', '.priority': 'prio', 'afterPriority': "
                    + "'after'}, 'qux': 'qux'}"));
    CompoundHash hash = CompoundHash.fromNode(node, splitAtPaths("foo/.priority"));
    String firstHash =
        Utilities.sha1HexDigest(
            "(\"foo\":(\"!beforePriority\":(string:\"before\"),\".priority\":"
                + "(string:\"prio\")))");
    String secondHash =
        Utilities.sha1HexDigest(
            "(\"foo\":(\"afterPriority\":(string:\"after\")),\"qux\":(string:\"qux\"))");
    assertEquals(Arrays.asList(path("foo/.priority"), path("qux")), hash.getPosts());
    assertEquals(Arrays.asList(firstHash, secondHash, ""), hash.getHashes());
  }

  @Test
  public void hashesPriorityLeafNodes() {
    Node node =
        NodeFromJSON(fromSingleQuotedString("{'foo': {'.value': 'bar', '.priority': 'baz'}}"));
    CompoundHash hash = CompoundHash.fromNode(node, NEVER_SPLIT_STRATEGY);
    String expectedHash =
        Utilities.sha1HexDigest("(\"foo\":(priority:string:\"baz\":string:\"bar\"))");
    assertEquals(Arrays.asList(path("foo")), hash.getPosts());
    assertEquals(Arrays.asList(expectedHash, ""), hash.getHashes());
  }

  @Test
  public void hashingFollowsFirebaseKeySemantics() {
    Node node = NodeFromJSON(fromSingleQuotedString("{'1': 'one', '2': 'two', '10': 'ten'}"));
    // 10 is after 2 in Firebase key semantics, but would be before 2 in string semantics
    CompoundHash hash = CompoundHash.fromNode(node, splitAtPaths("2"));
    String firstHash =
        Utilities.sha1HexDigest("(\"1\":(string:\"one\"),\"2\":" + "(string:\"two\"))");
    String secondHash = Utilities.sha1HexDigest("(\"10\":(string:\"ten\"))");
    assertEquals(Arrays.asList(path("2"), path("10")), hash.getPosts());
    assertEquals(Arrays.asList(firstHash, secondHash, ""), hash.getHashes());
  }

  @Test
  public void hashingOnChildBoundariesWorks() {
    Node node =
        NodeFromJSON(
            fromSingleQuotedString(
                "{'bar': {'deep': 'value'}, 'foo': {'other-deep': " + "'value'}}"));
    CompoundHash hash = CompoundHash.fromNode(node, splitAtPaths("bar/deep"));
    String firstHash = Utilities.sha1HexDigest("(\"bar\":(\"deep\":(string:\"value\")))");
    String secondHash =
        Utilities.sha1HexDigest("(\"foo\":(\"other-deep\":(string:\"value\"))" + ")");
    assertEquals(Arrays.asList(path("bar/deep"), path("foo/other-deep")), hash.getPosts());
    assertEquals(Arrays.asList(firstHash, secondHash, ""), hash.getHashes());
  }

  @Test
  public void commasAreSetForNestedChildren() {
    Node node =
        NodeFromJSON(
            fromSingleQuotedString(
                "{'bar': {'deep': 'value'}, 'foo': {'other-deep': " + "'value'}}"));
    CompoundHash hash = CompoundHash.fromNode(node, NEVER_SPLIT_STRATEGY);
    String hashValue =
        Utilities.sha1HexDigest(
            "(\"bar\":(\"deep\":(string:\"value\")),\"foo\":(\"other-deep\":"
                + "(string:\"value\")))");
    assertEquals(Arrays.asList(path("foo/other-deep")), hash.getPosts());
    assertEquals(Arrays.asList(hashValue, ""), hash.getHashes());
  }

  @Test
  public void quotedStringsAndKeys() {
    Map<String, Object> data = new MapBuilder().put("\"\\\"\\", "\"\\\"\\").put("\"", "\\").build();
    Node node = NodeFromJSON(data);
    CompoundHash hash = CompoundHash.fromNode(node, NEVER_SPLIT_STRATEGY);
    String hashValue =
        Utilities.sha1HexDigest(
            "(\"\\\"\":(string:\"\\\\\"),\"\\\"\\\\\\\"\\\\\":(string:\"\\\"\\\\\\\"\\\\\"))");
    assertEquals(Arrays.asList(path("\"\\\"\\")), hash.getPosts());
    assertEquals(Arrays.asList(hashValue, ""), hash.getHashes());
  }

  @Test
  public void defaultSplitHasSensibleAmountOfHashes() {
    Node node10k = EmptyNode.Empty();
    Node node100k = EmptyNode.Empty();
    Node node1M = EmptyNode.Empty();
    for (int i = 0; i < 500; i++) {
      // roughly 15-20 bytes serialized per node, 100k total
      node10k =
          node10k.updateImmediateChild(ChildKey.fromString("key-" + i), NodeFromJSON("value"));
    }
    for (int i = 0; i < 5000; i++) {
      // roughly 15-20 bytes serialized per node, 100k total
      node100k =
          node100k.updateImmediateChild(ChildKey.fromString("key-" + i), NodeFromJSON("value"));
    }
    for (int i = 0; i < 50000; i++) {
      // roughly 15-20 bytes serialized per node, 1M total
      node1M = node1M.updateImmediateChild(ChildKey.fromString("key-" + i), NodeFromJSON("value"));
    }
    CompoundHash hash10K = CompoundHash.fromNode(node10k);
    CompoundHash hash100K = CompoundHash.fromNode(node100k);
    CompoundHash hash1M = CompoundHash.fromNode(node1M);
    assertWithinPercent(15, hash10K.getHashes().size(), /*percent=*/ 10);
    assertWithinPercent(50, hash100K.getHashes().size(), /*percent=*/ 10);
    assertWithinPercent(150, hash1M.getHashes().size(), /*percent=*/ 10);
  }

  @Test
  public void defaultSplitHandlesLargeLeafNodeAtRoot() {
    StringBuilder largeString = new StringBuilder();
    for (int i = 0; i < 50 * 1024; i++) {
      largeString.append("x");
    }
    Node leafNode = NodeFromJSON(largeString.toString(), EmptyNode.Empty());
    CompoundHash hash = CompoundHash.fromNode(leafNode);
    assertEquals(2, hash.getHashes().size());
  }
}