/*
 * Copyright (c) 2012-2023 Red Hat, Inc.
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *   Red Hat, Inc. - initial API and implementation
 */
package org.eclipse.che.core.db.schema.impl.flyway;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotNull;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Sets;
import java.io.IOException;
import java.io.StringReader;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import javax.sql.DataSource;
import org.eclipse.che.commons.lang.IoUtil;
import org.eclipse.che.core.db.schema.SchemaInitializationException;
import org.eclipse.che.core.db.schema.SchemaInitializer;
import org.flywaydb.core.internal.util.PlaceholderReplacer;
import org.h2.jdbcx.JdbcDataSource;
import org.h2.tools.RunScript;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

/**
 * Tests {@link FlywaySchemaInitializer}.
 *
 * @author Yevhenii Voevodin
 */
public class FlywaySchemaInitializerTest {

  private static final String SCRIPTS_ROOT = "flyway/sql";

  private JdbcDataSource dataSource;

  @BeforeMethod
  public void setUp() throws URISyntaxException {
    dataSource = new JdbcDataSource();
    dataSource.setUrl("jdbc:h2:mem:flyway_test;DB_CLOSE_DELAY=-1");
  }

  @AfterMethod
  public void cleanup() throws SQLException, URISyntaxException {
    try (Connection conn = dataSource.getConnection()) {
      RunScript.execute(conn, new StringReader("SHUTDOWN"));
    }
    IoUtil.deleteRecursive(targetDir().resolve(Paths.get(SCRIPTS_ROOT)).toFile());
  }

  @Test
  public void initializesSchemaWhenDatabaseIsEmpty() throws Exception {
    createScript("1.0/1__init.sql", "CREATE TABLE test (id INT, text TEXT, PRIMARY KEY (id));");
    createScript(
        "1.0/2__add_data.sql",
        "INSERT INTO test VALUES(1, 'test1');"
            + "INSERT INTO test VALUES(2, 'test2');"
            + "INSERT INTO test VALUES(3, 'test3');");
    createScript("2.0/1__add_more_data.sql", "INSERT INTO test VALUES(4, 'test4');");
    createScript(
        "2.0/postgresql/1__add_more_data.sql", "INSERT INTO test VALUES(4, 'postgresql-data');");

    final SchemaInitializer initializer = FlywayInitializerBuilder.from(dataSource).build();
    initializer.init();

    assertEquals(
        queryEntities(),
        Sets.newHashSet(
            new TestEntity(1, "test1"),
            new TestEntity(2, "test2"),
            new TestEntity(3, "test3"),
            new TestEntity(4, "test4")));

    // second init must do nothing, so there are no conflicts
    initializer.init();
  }

  @Test(expectedExceptions = SchemaInitializationException.class)
  public void failsIfBaseLineIsNotConfiguredProperly() throws Exception {
    execQuery(
        "CREATE TABLE test (id INT, text TEXT, PRIMARY KEY (id));"
            + "INSERT INTO test VALUES(1, 'test1');"
            + "INSERT INTO test VALUES(2, 'test2');"
            + "INSERT INTO test VALUES(3, 'test3');");
    createScript("1.0/1__init.sql", "CREATE TABLE test (id INT, text TEXT, PRIMARY KEY (id));");

    FlywayInitializerBuilder.from(dataSource)
        .setBaselineOnMigrate(true)
        .setBaselineVersion("1.0")
        .build()
        .init();
  }

  @Test
  public void executesOnlyThoseMigrationsWhichGoAfterBaseline() throws Exception {
    createScript("1.0/1__init.sql", "CREATE TABLE test (id INT, text TEXT, PRIMARY KEY (id));");
    createScript(
        "2.0/1__add_data.sql",
        "INSERT INTO test VALUES(1, 'test1');"
            + "INSERT INTO test VALUES(2, 'test2');"
            + "INSERT INTO test VALUES(3, 'test3');");
    final FlywaySchemaInitializer initializer =
        FlywayInitializerBuilder.from(dataSource)
            .setBaselineOnMigrate(true)
            .setBaselineVersion("1.0.1")
            .build();

    initializer.init();

    assertEquals(
        queryEntities(),
        Sets.newHashSet(
            new TestEntity(1, "test1"), new TestEntity(2, "test2"), new TestEntity(3, "test3")));

    // second init must do nothing, so there are no conflicts
    initializer.init();
  }

  @Test
  public void initializesSchemaWhenDatabaseIsEmptyAndBaselineIsConfigured() throws Exception {
    createScript("1.0/1__init.sql", "CREATE TABLE test (id INT, text TEXT, PRIMARY KEY (id));");
    createScript(
        "2.0/1__add_data.sql",
        "INSERT INTO test VALUES(1, 'test1');"
            + "INSERT INTO test VALUES(2, 'test2');"
            + "INSERT INTO test VALUES(3, 'test3');");

    final FlywaySchemaInitializer initializer =
        FlywayInitializerBuilder.from(dataSource)
            .setBaselineOnMigrate(true)
            .setBaselineVersion("1.0.1")
            .build();
    initializer.init();

    assertEquals(
        queryEntities(),
        Sets.newHashSet(
            new TestEntity(1, "test1"), new TestEntity(2, "test2"), new TestEntity(3, "test3")));

    // second init must do nothing, so there are no conflicts
    initializer.init();
  }

  @Test
  public void selectsProviderSpecificScriptsInPreferenceToDefaultOnes() throws Exception {
    createScript("1.0/1__init.sql", "CREATE TABLE test (id INT, text TEXT, PRIMARY KEY (id));");
    createScript("2.0/1__add_data.sql", "INSERT INTO test VALUES(1, 'default data');");
    createScript("2.0/h2/1__add_data.sql", "INSERT INTO test VALUES(1, 'h2 data');");

    final FlywaySchemaInitializer initializer = FlywayInitializerBuilder.from(dataSource).build();
    initializer.init();

    assertEquals(queryEntities(), Sets.newHashSet(new TestEntity(1, "h2 data")));

    // second init must do nothing, so there are no conflicts
    initializer.init();
  }

  @Test
  public void replacesVariablesWhenPlaceholderReplacerIsConfigured() throws Exception {
    createScript(
        "1.0/1__init.sql",
        "CREATE TABLE test (id INT, text TEXT, PRIMARY KEY (id));"
            + "INSERT INTO test VALUES(1, '${variable}');");

    FlywayInitializerBuilder.from(dataSource)
        .setReplacer(new PlaceholderReplacer(ImmutableMap.of("variable", "test"), "${", "}"))
        .build()
        .init();

    assertEquals(queryEntities(), Sets.newHashSet(new TestEntity(1, "test")));
  }

  private Set<TestEntity> queryEntities() throws SQLException {
    final Set<TestEntity> entities = new HashSet<>();
    try (Connection conn = dataSource.getConnection()) {
      final ResultSet result = RunScript.execute(conn, new StringReader("SELECT * FROM test"));
      while (result.next()) {
        entities.add(new TestEntity(result.getLong("id"), result.getString("text")));
      }
    }
    return entities;
  }

  private ResultSet execQuery(String query) throws SQLException {
    try (Connection conn = dataSource.getConnection()) {
      return RunScript.execute(conn, new StringReader(query));
    }
  }

  private static Path createScript(String relativePath, String content)
      throws URISyntaxException, IOException {
    return createFile(
        targetDir().resolve(Paths.get(SCRIPTS_ROOT)).resolve(relativePath).toString(), content);
  }

  private static Path createFile(String filepath, String content)
      throws URISyntaxException, IOException {
    final Path path = targetDir().resolve(Paths.get(filepath));
    if (!Files.exists(path.getParent())) {
      Files.createDirectories(path.getParent());
    }
    Files.write(path, content.getBytes(StandardCharsets.UTF_8));
    return path;
  }

  private static Path targetDir() throws URISyntaxException {
    final URL url = Thread.currentThread().getContextClassLoader().getResource(".");
    assertNotNull(url);
    return Paths.get(url.toURI()).getParent();
  }

  private static class TestEntity {
    final long id;
    final String text;

    private TestEntity(long id, String text) {
      this.id = id;
      this.text = text;
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj) {
        return true;
      }
      if (!(obj instanceof TestEntity)) {
        return false;
      }
      final TestEntity that = (TestEntity) obj;
      return id == that.id && Objects.equals(text, that.text);
    }

    @Override
    public int hashCode() {
      int hash = 7;
      hash = 31 * hash + Long.hashCode(id);
      hash = 31 * hash + Objects.hashCode(text);
      return hash;
    }

    @Override
    public String toString() {
      return "TestEntity{" + "id=" + id + ", text='" + text + '\'' + '}';
    }
  }

  private static class FlywayInitializerBuilder {

    public static FlywayInitializerBuilder from(DataSource dataSource) {
      try {
        final String scriptsRoot = targetDir().resolve(Paths.get(SCRIPTS_ROOT)).toString();
        return new FlywayInitializerBuilder()
            .setDataSource(dataSource)
            .setScriptsPrefix("")
            .setScriptsSuffix(".sql")
            .setVersionSeparator("__")
            .setReplacer(PlaceholderReplacer.NO_PLACEHOLDERS)
            .setBaselineOnMigrate(false)
            .addLocation("filesystem:" + scriptsRoot);
      } catch (Exception x) {
        throw new RuntimeException(x.getMessage(), x);
      }
    }

    private DataSource dataSource;
    private List<String> locations;
    private String scriptsPrefix;
    private String scriptsSuffix;
    private String versionSeparator;
    private boolean baselineOnMigrate;
    private String baselineVersion;
    private PlaceholderReplacer replacer;

    public FlywayInitializerBuilder setDataSource(DataSource dataSource) {
      this.dataSource = dataSource;
      return this;
    }

    public FlywayInitializerBuilder setReplacer(PlaceholderReplacer replacer) {
      this.replacer = replacer;
      return this;
    }

    public FlywayInitializerBuilder addLocation(String location) {
      if (locations == null) {
        locations = new ArrayList<>();
      }
      locations.add(location);
      return this;
    }

    public FlywayInitializerBuilder setScriptsPrefix(String scriptsPrefix) {
      this.scriptsPrefix = scriptsPrefix;
      return this;
    }

    public FlywayInitializerBuilder setScriptsSuffix(String scriptsSuffix) {
      this.scriptsSuffix = scriptsSuffix;
      return this;
    }

    public FlywayInitializerBuilder setVersionSeparator(String versionSeparator) {
      this.versionSeparator = versionSeparator;
      return this;
    }

    public FlywayInitializerBuilder setBaselineOnMigrate(boolean baselineOnMigrate) {
      this.baselineOnMigrate = baselineOnMigrate;
      return this;
    }

    public FlywayInitializerBuilder setBaselineVersion(String baselineVersion) {
      this.baselineVersion = baselineVersion;
      return this;
    }

    public FlywaySchemaInitializer build() {
      if (locations == null) {
        throw new IllegalStateException("locations required");
      }
      return new FlywaySchemaInitializer(
          locations.toArray(new String[locations.size()]),
          scriptsPrefix,
          scriptsSuffix,
          versionSeparator,
          baselineOnMigrate,
          baselineVersion,
          dataSource,
          replacer);
    }
  }
}
