/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.datastax.oss.driver.mapper;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;

import com.datastax.oss.driver.api.core.CqlIdentifier;
import com.datastax.oss.driver.api.core.CqlSession;
import com.datastax.oss.driver.api.core.cql.BoundStatementBuilder;
import com.datastax.oss.driver.api.core.cql.PreparedStatement;
import com.datastax.oss.driver.api.core.cql.ResultSet;
import com.datastax.oss.driver.api.core.cql.Row;
import com.datastax.oss.driver.api.core.cql.SimpleStatement;
import com.datastax.oss.driver.api.core.data.UdtValue;
import com.datastax.oss.driver.api.core.type.UserDefinedType;
import com.datastax.oss.driver.api.mapper.annotations.Dao;
import com.datastax.oss.driver.api.mapper.annotations.DaoFactory;
import com.datastax.oss.driver.api.mapper.annotations.DaoKeyspace;
import com.datastax.oss.driver.api.mapper.annotations.Entity;
import com.datastax.oss.driver.api.mapper.annotations.GetEntity;
import com.datastax.oss.driver.api.mapper.annotations.Insert;
import com.datastax.oss.driver.api.mapper.annotations.Mapper;
import com.datastax.oss.driver.api.mapper.annotations.PartitionKey;
import com.datastax.oss.driver.api.mapper.annotations.Select;
import com.datastax.oss.driver.api.mapper.annotations.SetEntity;
import com.datastax.oss.driver.api.mapper.entity.saving.NullSavingStrategy;
import com.datastax.oss.driver.api.testinfra.ccm.CcmRule;
import com.datastax.oss.driver.api.testinfra.ccm.SchemaChangeSynchronizer;
import com.datastax.oss.driver.api.testinfra.requirement.BackendRequirement;
import com.datastax.oss.driver.api.testinfra.requirement.BackendType;
import com.datastax.oss.driver.api.testinfra.session.SessionRule;
import com.datastax.oss.driver.categories.ParallelizableTests;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.assertj.core.util.Lists;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.experimental.categories.Category;
import org.junit.rules.RuleChain;
import org.junit.rules.TestRule;

/** Tests that entities with UDTs nested at various levels are properly mapped. */
@Category(ParallelizableTests.class)
@BackendRequirement(
    type = BackendType.CASSANDRA,
    minInclusive = "2.2",
    description = "support for unset values")
public class NestedUdtIT {

  private static final CcmRule CCM_RULE = CcmRule.getInstance();

  private static final SessionRule<CqlSession> SESSION_RULE = SessionRule.builder(CCM_RULE).build();

  @ClassRule
  public static final TestRule CHAIN = RuleChain.outerRule(CCM_RULE).around(SESSION_RULE);

  private static final UUID CONTAINER_ID = UUID.randomUUID();

  private static final Container SAMPLE_CONTAINER =
      new Container(
          CONTAINER_ID,
          ImmutableList.of(new Type1("a1", "a2"), new Type1("b1", "b2")),
          ImmutableMap.of(
              "cd",
              ImmutableList.of(new Type1("c1", "c2"), new Type1("d1", "d2")),
              "ef",
              ImmutableList.of(new Type1("e1", "e2"), new Type1("f1", "f2"))),
          ImmutableMap.of(
              new Type1("12", "34"),
              ImmutableSet.of(
                  ImmutableList.of(new Type2(1, 2)), ImmutableList.of(new Type2(3, 4)))),
          ImmutableMap.of(
              new Type1("12", "34"),
              ImmutableMap.of("12", ImmutableSet.of(new Type2(1, 2), new Type2(3, 4)))));

  private static final Container SAMPLE_CONTAINER_NULL_LIST =
      new Container(
          CONTAINER_ID,
          null,
          ImmutableMap.of(
              "cd",
              ImmutableList.of(new Type1("c1", "c2"), new Type1("d1", "d2")),
              "ef",
              ImmutableList.of(new Type1("e1", "e2"), new Type1("f1", "f2"))),
          ImmutableMap.of(
              new Type1("12", "34"),
              ImmutableSet.of(
                  ImmutableList.of(new Type2(1, 2)), ImmutableList.of(new Type2(3, 4)))),
          ImmutableMap.of(
              new Type1("12", "34"),
              ImmutableMap.of("12", ImmutableSet.of(new Type2(1, 2), new Type2(3, 4)))));

  private static ContainerDao containerDao;

  @BeforeClass
  public static void setup() {
    CqlSession session = SESSION_RULE.session();

    SchemaChangeSynchronizer.withLock(
        () -> {
          for (String query :
              ImmutableList.of(
                  "CREATE TYPE type1(s1 text, s2 text)",
                  "CREATE TYPE type2(i1 int, i2 int)",
                  "CREATE TYPE type1_partial(s1 text)",
                  "CREATE TYPE type2_partial(i1 int)",
                  "CREATE TABLE container(id uuid PRIMARY KEY, "
                      + "list frozen<list<type1>>, "
                      + "map1 frozen<map<text, list<type1>>>, "
                      + "map2 frozen<map<type1, set<list<type2>>>>,"
                      + "map3 frozen<map<type1, map<text, set<type2>>>>"
                      + ")",
                  "CREATE TABLE container_partial(id uuid PRIMARY KEY, "
                      + "list frozen<list<type1_partial>>, "
                      + "map1 frozen<map<text, list<type1_partial>>>, "
                      + "map2 frozen<map<type1_partial, set<list<type2_partial>>>>,"
                      + "map3 frozen<map<type1_partial, map<text, set<type2_partial>>>>"
                      + ")")) {
            session.execute(
                SimpleStatement.builder(query)
                    .setExecutionProfile(SESSION_RULE.slowProfile())
                    .build());
          }
        });

    UserDefinedType type1Partial =
        session
            .getKeyspace()
            .flatMap(ks -> session.getMetadata().getKeyspace(ks))
            .flatMap(ks -> ks.getUserDefinedType("type1_partial"))
            .orElseThrow(AssertionError::new);

    session.execute(
        SimpleStatement.newInstance(
            "INSERT INTO container_partial (id, list) VALUES (?, ?)",
            SAMPLE_CONTAINER.getId(),
            Lists.newArrayList(type1Partial.newValue("a"), type1Partial.newValue("b"))));

    UdtsMapper udtsMapper = new NestedUdtIT_UdtsMapperBuilder(session).build();
    containerDao = udtsMapper.containerDao(SESSION_RULE.keyspace());
  }

  @Before
  public void clearContainerData() {
    CqlSession session = SESSION_RULE.session();
    session.execute(
        SimpleStatement.builder("TRUNCATE container")
            .setExecutionProfile(SESSION_RULE.slowProfile())
            .build());
  }

  @Test
  public void should_insert_and_retrieve_entity_with_nested_udts() {
    // When
    containerDao.save(SAMPLE_CONTAINER);
    Container retrievedEntity = containerDao.loadByPk(SAMPLE_CONTAINER.getId());

    // Then
    assertThat(retrievedEntity).isEqualTo(SAMPLE_CONTAINER);
  }

  @Test
  public void should_insert_do_not_set_to_null_udts() {
    // Given
    containerDao.save(SAMPLE_CONTAINER);
    Container retrievedEntity = containerDao.loadByPk(SAMPLE_CONTAINER.getId());

    assertThat(retrievedEntity.list).isNotNull();

    // When
    containerDao.saveDoNotSetNull(SAMPLE_CONTAINER_NULL_LIST);
    Container retrievedEntitySecond = containerDao.loadByPk(SAMPLE_CONTAINER.getId());
    assertThat(retrievedEntitySecond.list).isNotNull();
  }

  @Test
  public void should_insert_set_to_null_udts() {
    // Given
    containerDao.save(SAMPLE_CONTAINER);
    Container retrievedEntity = containerDao.loadByPk(SAMPLE_CONTAINER.getId());

    assertThat(retrievedEntity.list).isNotNull();

    // When
    containerDao.saveSetToNull(SAMPLE_CONTAINER_NULL_LIST);
    Container retrievedEntitySecond = containerDao.loadByPk(SAMPLE_CONTAINER.getId());
    assertThat(retrievedEntitySecond.list).isEmpty();
  }

  @Test
  public void should_get_entity_from_complete_row() {
    CqlSession session = SESSION_RULE.session();
    containerDao.save(SAMPLE_CONTAINER);
    ResultSet rs =
        session.execute(
            SimpleStatement.newInstance(
                "SELECT * FROM container WHERE id = ?", SAMPLE_CONTAINER.getId()));
    Row row = rs.one();
    assertThat(row).isNotNull();
    Container actual = containerDao.get(row);
    assertThat(actual).isEqualTo(SAMPLE_CONTAINER);
  }

  @Test
  public void should_not_get_entity_from_partial_row_when_not_lenient() {
    CqlSession session = SESSION_RULE.session();
    containerDao.save(SAMPLE_CONTAINER);
    ResultSet rs =
        session.execute(
            SimpleStatement.newInstance(
                "SELECT id FROM container WHERE id = ?", SAMPLE_CONTAINER.getId()));
    Row row = rs.one();
    assertThat(row).isNotNull();
    Throwable error = catchThrowable(() -> containerDao.get(row));
    assertThat(error).hasMessage("list is not a column in this row");
  }

  @Test
  public void should_get_entity_from_partial_row_when_lenient() {
    CqlSession session = SESSION_RULE.session();
    ResultSet rs =
        session.execute(
            SimpleStatement.newInstance(
                "SELECT id, list FROM container_partial WHERE id = ?", SAMPLE_CONTAINER.getId()));
    Row row = rs.one();
    assertThat(row).isNotNull();
    Container actual = containerDao.getLenient(row);
    assertThat(actual.getId()).isEqualTo(SAMPLE_CONTAINER.getId());
    assertThat(actual.getList()).containsExactly(new Type1("a", null), new Type1("b", null));
    assertThat(actual.getMap1()).isNull();
    assertThat(actual.getMap2()).isNull();
    assertThat(actual.getMap3()).isNull();
  }

  @Test
  public void should_set_entity_on_partial_statement_builder_when_lenient() {
    CqlSession session = SESSION_RULE.session();
    PreparedStatement ps =
        session.prepare("INSERT INTO container_partial (id, list) VALUES (?, ?)");
    BoundStatementBuilder builder = ps.boundStatementBuilder();
    containerDao.setLenient(SAMPLE_CONTAINER, builder);
    assertThat(builder.getUuid(0)).isEqualTo(SAMPLE_CONTAINER.getId());
    assertThat(builder.getList(1, UdtValue.class)).hasSize(2);
  }

  @Test
  public void should_not_set_entity_on_partial_statement_builder_when_not_lenient() {
    CqlSession session = SESSION_RULE.session();
    PreparedStatement ps = session.prepare("INSERT INTO container (id, list) VALUES (?, ?)");
    Throwable error =
        catchThrowable(() -> containerDao.set(SAMPLE_CONTAINER, ps.boundStatementBuilder()));
    assertThat(error).hasMessage("map1 is not a variable in this bound statement");
  }

  @Mapper
  public interface UdtsMapper {
    @DaoFactory
    ContainerDao containerDao(@DaoKeyspace CqlIdentifier keyspace);
  }

  @Dao
  public interface ContainerDao {

    @Select
    Container loadByPk(UUID id);

    @Insert
    void save(Container container);

    @Insert(nullSavingStrategy = NullSavingStrategy.DO_NOT_SET)
    void saveDoNotSetNull(Container container);

    @Insert(nullSavingStrategy = NullSavingStrategy.SET_TO_NULL)
    void saveSetToNull(Container container);

    @GetEntity
    Container get(Row source);

    @GetEntity(lenient = true)
    Container getLenient(Row source);

    @SetEntity
    void set(Container container, BoundStatementBuilder target);

    @SetEntity(lenient = true)
    void setLenient(Container container, BoundStatementBuilder target);
  }

  @Entity
  public static class Container {

    @PartitionKey private UUID id;
    private List<Type1> list;
    private Map<String, List<Type1>> map1;
    private Map<Type1, Set<List<Type2>>> map2;
    private Map<Type1, Map<String, Set<Type2>>> map3;

    public Container() {}

    public Container(
        UUID id,
        List<Type1> list,
        Map<String, List<Type1>> map1,
        Map<Type1, Set<List<Type2>>> map2,
        Map<Type1, Map<String, Set<Type2>>> map3) {
      this.id = id;
      this.list = list;
      this.map1 = map1;
      this.map2 = map2;
      this.map3 = map3;
    }

    public UUID getId() {
      return id;
    }

    public void setId(UUID id) {
      this.id = id;
    }

    public List<Type1> getList() {
      return list;
    }

    public void setList(List<Type1> list) {
      this.list = list;
    }

    public Map<String, List<Type1>> getMap1() {
      return map1;
    }

    public void setMap1(Map<String, List<Type1>> map1) {
      this.map1 = map1;
    }

    public Map<Type1, Set<List<Type2>>> getMap2() {
      return map2;
    }

    public void setMap2(Map<Type1, Set<List<Type2>>> map2) {
      this.map2 = map2;
    }

    public Map<Type1, Map<String, Set<Type2>>> getMap3() {
      return map3;
    }

    public void setMap3(Map<Type1, Map<String, Set<Type2>>> map3) {
      this.map3 = map3;
    }

    @Override
    public boolean equals(Object other) {
      if (other == this) {
        return true;
      } else if (other instanceof Container) {
        Container that = (Container) other;
        return Objects.equals(this.id, that.id)
            && Objects.equals(this.list, that.list)
            && Objects.equals(this.map1, that.map1)
            && Objects.equals(this.map2, that.map2)
            && Objects.equals(this.map3, that.map3);
      } else {
        return false;
      }
    }

    @Override
    public int hashCode() {
      return Objects.hash(id, list, map1, map2, map3);
    }
  }

  @Entity
  public static class Type1 {
    private String s1;
    private String s2;

    public Type1() {}

    public Type1(String s1, String s2) {
      this.s1 = s1;
      this.s2 = s2;
    }

    public String getS1() {
      return s1;
    }

    public void setS1(String s1) {
      this.s1 = s1;
    }

    public String getS2() {
      return s2;
    }

    public void setS2(String s2) {
      this.s2 = s2;
    }

    @Override
    public boolean equals(Object o) {
      if (this == o) {
        return true;
      }
      if (!(o instanceof Type1)) {
        return false;
      }
      Type1 type1 = (Type1) o;
      return Objects.equals(s1, type1.s1) && Objects.equals(s2, type1.s2);
    }

    @Override
    public int hashCode() {
      return Objects.hash(s1, s2);
    }
  }

  @Entity
  public static class Type2 {
    private int i1;
    private int i2;

    public Type2() {}

    public Type2(int i1, int i2) {
      this.i1 = i1;
      this.i2 = i2;
    }

    public int getI1() {
      return i1;
    }

    public void setI1(int i1) {
      this.i1 = i1;
    }

    public int getI2() {
      return i2;
    }

    public void setI2(int i2) {
      this.i2 = i2;
    }

    @Override
    public boolean equals(Object o) {
      if (this == o) {
        return true;
      }
      if (!(o instanceof Type2)) {
        return false;
      }
      Type2 type2 = (Type2) o;
      return i1 == type2.i1 && i2 == type2.i2;
    }

    @Override
    public int hashCode() {
      return Objects.hash(i1, i2);
    }
  }
}
