/*
 * Copyright 2018 ConsenSys AG.
 *
 * 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 net.consensys.cava.config;

import static net.consensys.cava.config.ConfigurationErrors.noErrors;
import static net.consensys.cava.config.ConfigurationErrors.singleError;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.nio.file.NoSuchFileException;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.Test;
import org.opentest4j.AssertionFailedError;

class TomlBackedConfigurationTest {

  @Test
  void loadSimpleConfigFile() throws Exception {
    // @formatter:off
    Configuration config = Configuration.fromToml(
        "foo = \"12\"\n"
      + "bar = 13\n"
      + "foobar = 156.34\n"
      + "amaps = [{a = 1, b = 2}, {a = 'hello'}]\n"
      + "\n"
      + "[boo]\n"
      + "baz=[1,2,3]\n"
      + "\n"
      + "[amap]\n"
      + "a=1\n"
      + "b=2\n"
      + "[deepmap]\n"
      + "a=4\n"
      + "[deepmap.deeper]\n"
      + "b=5\n"
    );
    // @formatter:on
    assertFalse(config.hasErrors());
    assertEquals("12", config.getString("foo"));
    assertEquals(13, config.getInteger("bar"));
    assertEquals(13L, config.getLong("bar"));
    assertEquals(156.34d, config.getDouble("foobar"));
    assertEquals(Arrays.asList(1, 2, 3), config.getListOfInteger("boo.baz"));
    assertEquals(Arrays.asList(1L, 2L, 3L), config.getListOfLong("boo.baz"));
    assertEquals(Arrays.asList(1L, 2L, 3L), config.getList("boo.baz"));

    Map<String, Object> expectedMap = new HashMap<>();
    expectedMap.put("a", 1L);
    expectedMap.put("b", 2L);
    assertEquals(expectedMap, config.getMap("amap"));

    assertEquals(Collections.singletonMap("baz", Arrays.asList(1L, 2L, 3L)), config.getMap("boo"));

    Map<String, Object> expectedDeepMap = new HashMap<>();
    expectedDeepMap.put("a", 4L);
    expectedDeepMap.put("deeper", Collections.singletonMap("b", 5L));
    assertEquals(expectedDeepMap, config.getMap("deepmap"));

    List<Map<String, Object>> expectedList = Arrays.asList(expectedMap, Collections.singletonMap("a", "hello"));
    assertEquals(expectedList, config.getListOfMap("amaps"));
  }

  @Test
  void testKeyPresent() throws Exception {
    Configuration config = Configuration.fromToml("foo=\"12\"\nbar=\"13\"\n[baz]\nfoobar = 156.34");
    assertFalse(config.hasErrors());
    assertEquals(new HashSet<>(Arrays.asList("foo", "bar", "baz.foobar")), config.keySet());
    assertTrue(config.contains("foo"));
    assertTrue(config.contains("bar"));
    assertTrue(config.contains("baz.foobar"));
    assertFalse(config.contains("example"));
  }

  @Test
  void testFindsValuesBasedOnCanonicalKey() throws Exception {
    Configuration config = Configuration.fromToml("foo=12\nbar=\"13\"\n[baz]\nfoobar = 156.34");
    assertFalse(config.hasErrors());
    assertEquals(new HashSet<>(Arrays.asList("foo", "bar", "baz.foobar")), config.keySet());
    assertEquals(12, config.getInteger("foo"));
    assertEquals(12, config.getInteger("   foo    "));
    assertEquals(12, config.getInteger("   'foo'    "));
    assertEquals(12, config.getInteger("   \"foo\"    "));
    assertTrue(config.contains("baz.foobar"));
    assertTrue(config.contains("baz .   foobar"));
    assertTrue(config.contains("baz .   'foobar'"));
    assertTrue(config.contains("\"baz\" .   'foobar'"));
  }

  @Test
  void throwsForMissingValue() throws Exception {
    Configuration config = Configuration.fromToml("foo=12\nbar=\"13\"\n[baz]\nfoobar = 156.34");
    assertFalse(config.hasErrors());
    Exception e = assertThrows(NoConfigurationPropertyException.class, () -> config.getString("foo.blah"));
    assertEquals("No value for property 'foo.blah'", e.getMessage());
    e = assertThrows(NoConfigurationPropertyException.class, () -> config.getInteger("  foobaz  "));
    assertEquals("No value for property 'foobaz'", e.getMessage());
  }

  @Test
  void throwsForInvalidType() throws Exception {
    Configuration config = Configuration.fromToml(
        "[foo]\n" + "bar=99\n" + "baz='buz'\n" + "biz=false\n" + "buz = [1,2,3]\n" + "sbuz = ['1', '2', '3']\n");
    assertFalse(config.hasErrors());

    assertEquals(99, config.getInteger("foo.bar"));
    InvalidConfigurationPropertyTypeException e =
        assertThrows(InvalidConfigurationPropertyTypeException.class, () -> config.getString("foo.bar"));
    assertEquals("Value of 'foo.bar' is a integer", e.getMessage());
    assertEquals(DocumentPosition.positionAt(2, 1), e.position());

    assertEquals("buz", config.getString("foo.baz"));
    e = assertThrows(InvalidConfigurationPropertyTypeException.class, () -> config.getDouble("foo.baz"));
    assertEquals("Value of 'foo.baz' is a string", e.getMessage());
    assertEquals(DocumentPosition.positionAt(3, 1), e.position());

    assertFalse(config.getBoolean("foo.biz"));
    e = assertThrows(InvalidConfigurationPropertyTypeException.class, () -> config.getLong("foo.biz"));
    assertEquals("Value of 'foo.biz' is a boolean", e.getMessage());
    assertEquals(DocumentPosition.positionAt(4, 1), e.position());

    e = assertThrows(InvalidConfigurationPropertyTypeException.class, () -> config.getLong("foo"));
    assertEquals("Value of 'foo' is a table", e.getMessage());
    assertEquals(DocumentPosition.positionAt(1, 1), e.position());

    e = assertThrows(InvalidConfigurationPropertyTypeException.class, () -> config.getListOfString("foo.buz"));
    assertEquals("List property 'foo.buz' does not contain strings", e.getMessage());
    assertEquals(DocumentPosition.positionAt(5, 1), e.position());

    e = assertThrows(InvalidConfigurationPropertyTypeException.class, () -> config.getListOfInteger("foo.sbuz"));
    assertEquals("List property 'foo.sbuz' does not contain integers", e.getMessage());
    assertEquals(DocumentPosition.positionAt(6, 1), e.position());

    e = assertThrows(InvalidConfigurationPropertyTypeException.class, () -> config.getListOfMap("foo.sbuz"));
    assertEquals("List property 'foo.sbuz' does not contain maps", e.getMessage());
    assertEquals(DocumentPosition.positionAt(6, 1), e.position());
  }

  @Test
  void throwsForImpossibleIntegerConversion() throws Exception {
    // @formatter:off
    Configuration config = Configuration.fromToml(
        "[foo]\n"
      + "\" bar\" = "
      + (1L + Integer.MAX_VALUE)
      + "\n"
      + "buz = [1, 2, "
      + (1L + Integer.MAX_VALUE)
      + ", 4]\n"
    );
    // @formatter:on
    assertFalse(config.hasErrors());
    Exception e = assertThrows(InvalidConfigurationPropertyTypeException.class, () -> config.getInteger("foo.' bar'"));
    assertEquals("Value of property 'foo.\" bar\"' is too large for an integer", e.getMessage());

    e = assertThrows(InvalidConfigurationPropertyTypeException.class, () -> config.getListOfInteger("foo.buz"));
    assertEquals("Value of property 'foo.buz', index 2, is too large for an integer", e.getMessage());
  }

  @Test
  void loadMissingFile() {
    assertThrows(NoSuchFileException.class, () -> {
      Configuration.fromToml(Paths.get("FileThatDoesntExist"));
    });
  }

  @Test
  void invalidTOMLFile() throws Exception {
    Configuration config = Configuration.fromToml("foo=\"12\"\nfoobar = \"156.34");
    assertTrue(config.hasErrors());
    assertEquals("Unexpected end of input, expected \" or a character", config.errors().get(0).getMessage());
    assertEquals(DocumentPosition.positionAt(2, 17), config.errors().get(0).position());
    assertEquals("12", config.getString("foo"));
  }

  @Test
  void getDefaultValue() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addString("foo", "goodbye", null, null);
    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml("foobar = 'hello'", schema);
    assertEquals("hello", config.getString("foobar"));
    assertTrue(config.contains("foo"));
    assertEquals("goodbye", config.getString("foo"));
  }

  @Test
  void keysContainSchemaKeys() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addString("foo", "bar", null, null);
    builder.addString("foo.bar", "buz", null, null);
    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml("foobar = 'hello'", schema);
    assertEquals(new HashSet<>(Arrays.asList("foo", "foo.bar", "foobar")), config.keySet());
  }

  @Test
  void validateConfiguration() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.validateConfiguration(config -> {
      if (config.getInteger("expenses") > config.getInteger("revenue")) {
        return singleError("Expenses cannot be larger than revenue");
      }
      return noErrors();
    });

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml("expenses = 2000\nrevenue = 1500\n", schema);
    assertTrue(config.hasErrors());
    assertEquals("Expenses cannot be larger than revenue", config.errors().get(0).getMessage());
  }

  @Test
  void validateConfigurationProperty() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addString("foo", "foobar", null, (key, position, value) -> {
      if ("bar".equals(value)) {
        return singleError(position, "No bar allowed");
      }
      return noErrors();
    });

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml(" foo = \"bar\"", schema);
    assertTrue(config.hasErrors());
    ConfigurationError error = config.errors().get(0);
    assertEquals("No bar allowed", error.getMessage());
    assertEquals(DocumentPosition.positionAt(1, 2), error.position());
  }

  @Test
  void validateIntegerProperty() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addInteger("foo", null, null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml(" foo = " + (1L + Integer.MAX_VALUE) + "\n", schema);
    assertTrue(config.hasErrors());
    ConfigurationError error = config.errors().get(0);
    assertEquals("Value of property 'foo' is too large for an integer", error.getMessage());
    assertEquals(DocumentPosition.positionAt(1, 2), error.position());
  }

  @Test
  void shouldValidateStringList() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addListOfString("foo", null, null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml(" foo = [1, 2]\n", schema);
    assertTrue(config.hasErrors());
    ConfigurationError error = config.errors().get(0);
    assertEquals("Value of property 'foo', index 0, is not a string", error.getMessage());
    assertEquals(DocumentPosition.positionAt(1, 2), error.position());
  }

  @Test
  void shouldValidateIntegerList() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addListOfInteger("foo", null, null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml(" foo = ['a', 'b']\n", schema);
    assertTrue(config.hasErrors());
    ConfigurationError error = config.errors().get(0);
    assertEquals("Value of property 'foo', index 0, is not an integer", error.getMessage());
    assertEquals(DocumentPosition.positionAt(1, 2), error.position());
  }

  @Test
  void shouldValidateLongListAsIntegers() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addListOfInteger("foo", null, null, (key, position, value) -> {
      assertEquals(Arrays.asList(1, 2, 3), value);
      return singleError("should reach here");
    });

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml(" foo = [1, 2, 3]\n", schema);
    assertTrue(config.hasErrors());
    ConfigurationError error = config.errors().get(0);
    assertEquals("should reach here", error.getMessage());
    assertNull(error.position());
  }

  @Test
  void shouldValidateWithinIntegerList() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addListOfInteger("foo", null, null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml(" foo = [1, 2, " + (1L + Integer.MAX_VALUE) + ", 3]\n", schema);
    assertTrue(config.hasErrors());
    ConfigurationError error = config.errors().get(0);
    assertEquals("Value of property 'foo', index 2, is too large for an integer", error.getMessage());
    assertEquals(DocumentPosition.positionAt(1, 2), error.position());
  }

  @Test
  void shouldValidateLongList() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addListOfLong("foo", null, null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml(" foo = ['a', 'b']\n", schema);
    assertTrue(config.hasErrors());
    ConfigurationError error = config.errors().get(0);
    assertEquals("Value of property 'foo', index 0, is not a long", error.getMessage());
    assertEquals(DocumentPosition.positionAt(1, 2), error.position());
  }

  @Test
  void shouldValidateWithIsPresent() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addString("foo.bar", null, null, PropertyValidator.isPresent());

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml(" foo = ['a', 'b']\n", schema);
    assertTrue(config.hasErrors());
    ConfigurationError error = config.errors().get(0);
    assertEquals("Required property 'foo.bar' is missing", error.getMessage());
    assertNull(error.position());
  }

  @Test
  void shouldValidateWithInRange() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addInteger("foo", null, null, PropertyValidator.inRange(0, 10));

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml(" foo = 15\n", schema);
    assertTrue(config.hasErrors());
    ConfigurationError error = config.errors().get(0);
    assertEquals("Value of property 'foo' is outside range [0,10)", error.getMessage());
    assertEquals(DocumentPosition.positionAt(1, 2), error.position());

    Configuration config2 = Configuration.fromToml(" foo = 9\n", schema);
    assertFalse(config2.hasErrors());
  }

  @Test
  void validatorNotCalledWhenDefaultUsed() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addString("fooS", "hello", null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });
    builder.addInteger("fooI", 2, null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });
    builder.addLong("fooL", 2L, null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });
    builder.addListOfString("fooLS", Collections.emptyList(), null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });
    builder.addListOfInteger("fooLI", Collections.emptyList(), null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });
    builder.addListOfLong("fooLL", Collections.emptyList(), null, (key, position, value) -> {
      throw new AssertionFailedError("should not be reached");
    });

    Schema schema = builder.toSchema();
    Configuration config = Configuration.fromToml("\n", schema);
    assertFalse(config.hasErrors());
  }

  @Test
  void writeConfigurationToToml() throws Exception {
    // @formatter:off
    Configuration config = Configuration.fromToml(
        "foo = \"12\"\n"
      + "bar = 13\n"
      + "foobar = 156.34\n"
      + "amaps = [{a = 1, b = 2}, {a = 'hello'}]\n"
      + "\n"
      + "[boo]\n"
      + "baz=[1,2,3]\n"
      + "\n"
      + "[amap]\n"
      + "a=1\n"
      + "b=2\n"
      + "[deepmap]\n"
      + "' a' = 4\n"
      + "[deepmap.' deep']\n"
      + "'' = 'emptykey'\n"
      + "[deepmap.deeper]\n"
      + "farewell='goodbye'\n"
    );
    // @formatter:on

    // @formatter:off
    String expected =
        "amaps = [{a = 1, b = 2}, {a = \"hello\"}]\n"
      + "bar = 13\n"
      + "foo = \"12\"\n"
      + "foobar = 156.34\n"
      + "\n"
      + "[amap]\n"
      + "a = 1\n"
      + "b = 2\n"
      + "\n"
      + "[boo]\n"
      + "baz = [1, 2, 3]\n"
      + "\n"
      + "[deepmap]\n"
      + "\" a\" = 4\n"
      + "\n"
      + "[deepmap.\" deep\"]\n"
      + "\"\" = \"emptykey\"\n"
      + "\n"
      + "[deepmap.deeper]\n"
      + "farewell = \"goodbye\"\n";
    // @formatter:on
    assertEquals(expected, config.toToml());
  }


  @Test
  void writeConfigurationWithDocumentationToToml() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.documentProperty("deepmap", "This table will have sub-tables");
    builder.documentProperty("boo.baz", "A list of longs");
    builder.documentProperty("bar", "Lucky number 13");
    Schema schema = builder.toSchema();

    // @formatter:off
    Configuration config = Configuration.fromToml(
        "foo = \"12\"\n"
      + "bar = 13\n"
      + "foobar = 156.34\n"
      + "amaps = [{a = 1, b = 2}, {a = 'hello'}]\n"
      + "\n"
      + "[boo]\n"
      + "baz=[1,2,3]\n"
      + "\n"
      + "[amap]\n"
      + "a=1\n"
      + "b=2\n"
      + "[deepmap]\n"
      + "' a' = 4\n"
      + "[deepmap.' deep']\n"
      + "'' = 'emptykey'\n"
      + "[deepmap.deeper]\n"
      + "farewell='goodbye'\n", schema);
    // @formatter:on

    // @formatter:off
    String expected =
        "amaps = [{a = 1, b = 2}, {a = \"hello\"}]\n"
      + "## Lucky number 13\n"
      + "bar = 13\n"
      + "foo = \"12\"\n"
      + "foobar = 156.34\n"
      + "\n"
      + "[amap]\n"
      + "a = 1\n"
      + "b = 2\n"
      + "\n"
      + "[boo]\n"
      + "## A list of longs\n"
      + "baz = [1, 2, 3]\n"
      + "\n"
      + "## This table will have sub-tables\n"
      + "[deepmap]\n"
      + "\" a\" = 4\n"
      + "\n"
      + "[deepmap.\" deep\"]\n"
      + "\"\" = \"emptykey\"\n"
      + "\n"
      + "[deepmap.deeper]\n"
      + "farewell = \"goodbye\"\n";
    // @formatter:on
    assertEquals(expected, config.toToml());
  }

  @Test
  void writeConfigurationToTomlIncludesDefaults() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addString("farewell", "goodbye", "a farewell", null);
    builder.addInteger("xxx", 10, "documentation\n  over multiple lines!", null);
    builder.addInteger("zzz", 10, null, null);
    Schema schema = builder.toSchema();

    // @formatter:off
    Configuration config = Configuration.fromToml(
        "foo = \"12\"\n"
      + "foobar = 156.34\n"
      + "xxx = 10\n"
      + "zzz = 5\n"
      + "\n"
      + "[boo]\n"
      + "baz=[1,2,3]\n"
      + "\n", schema);
    // @formatter:on

    // @formatter:off
    String expected =
        "## a farewell\n"
      + "#farewell = \"goodbye\"\n"
      + "foo = \"12\"\n"
      + "foobar = 156.34\n"
      + "## documentation\n"
      + "##   over multiple lines!\n"
      + "#xxx = 10\n"
      + "#zzz = 10\n"
      + "zzz = 5\n"
      + "\n"
      + "[boo]\n"
      + "baz = [1, 2, 3]\n";
    // @formatter:on
    assertEquals(expected.replace("\n", System.lineSeparator()), config.toToml());
  }

  @Test
  void buildSchemaAndDumpToToml() throws Exception {
    SchemaBuilder builder = SchemaBuilder.create();
    builder.addString("somekey", "somevalue", "Got milk", null);
    builder.addBoolean("foo", false, "Toggle switch", null);
    builder.addDouble("bar", 1.0, "Value of currency", null);
    builder.addLong("'Here now'", 123L, "One two three", null);
    builder.addString("'No defaults'", null, null, null);
    Configuration config = Configuration.fromToml("", builder.toSchema());
    // @formatter:off
    String expected =
        "## One two three\n"
      + "#\"Here now\" = 123\n"
      + "## Value of currency\n"
      + "#bar = 1.0\n"
      + "## Toggle switch\n"
      + "#foo = false\n"
      + "## Got milk\n"
      + "#somekey = \"somevalue\"\n";
    // @formatter:on
    assertEquals(expected.replace("\n", System.lineSeparator()), config.toToml());
  }
}
