/*
 * Copyright 2014-2020 the original author or authors.
 *
 * 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
 *
 *      https://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 org.springframework.data.keyvalue.core;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.annotation.Id;
import org.springframework.data.keyvalue.SubclassOfTypeWithCustomComposedKeySpaceAnnotation;
import org.springframework.data.keyvalue.TypeWithCustomComposedKeySpaceAnnotationUsingAliasFor;
import org.springframework.data.keyvalue.core.event.KeyValueEvent;
import org.springframework.data.keyvalue.core.event.KeyValueEvent.AfterDeleteEvent;
import org.springframework.data.keyvalue.core.event.KeyValueEvent.AfterDropKeySpaceEvent;
import org.springframework.data.keyvalue.core.event.KeyValueEvent.AfterGetEvent;
import org.springframework.data.keyvalue.core.event.KeyValueEvent.AfterInsertEvent;
import org.springframework.data.keyvalue.core.event.KeyValueEvent.AfterUpdateEvent;
import org.springframework.data.keyvalue.core.event.KeyValueEvent.BeforeDeleteEvent;
import org.springframework.data.keyvalue.core.event.KeyValueEvent.BeforeGetEvent;
import org.springframework.data.keyvalue.core.event.KeyValueEvent.BeforeInsertEvent;
import org.springframework.data.keyvalue.core.event.KeyValueEvent.BeforeUpdateEvent;
import org.springframework.data.keyvalue.core.query.KeyValueQuery;

/**
 * @author Christoph Strobl
 * @author Thomas Darimont
 * @author Oliver Gierke
 * @author Mark Paluch
 */
@RunWith(MockitoJUnitRunner.Silent.class)
public class KeyValueTemplateUnitTests {

	private static final Foo FOO_ONE = new Foo("one");
	private static final Foo FOO_TWO = new Foo("two");
	private static final TypeWithCustomComposedKeySpaceAnnotationUsingAliasFor ALIASED_USING_ALIAS_FOR = new TypeWithCustomComposedKeySpaceAnnotationUsingAliasFor(
			"super");
	private static final SubclassOfTypeWithCustomComposedKeySpaceAnnotation SUBCLASS_OF_ALIASED_USING_ALIAS_FOR = new SubclassOfTypeWithCustomComposedKeySpaceAnnotation(
			"sub");
	private static final KeyValueQuery<String> STRING_QUERY = new KeyValueQuery<>("foo == 'two'");

	private @Mock KeyValueAdapter adapterMock;
	private KeyValueTemplate template;
	private @Mock ApplicationEventPublisher publisherMock;

	@Before
	public void setUp() {
		this.template = new KeyValueTemplate(adapterMock);
		this.template.setApplicationEventPublisher(publisherMock);
	}

	@Test // DATACMNS-525
	public void shouldThrowExceptionWhenCreatingNewTempateWithNullAdapter() {
		assertThatIllegalArgumentException().isThrownBy(() -> new KeyValueTemplate(null));
	}

	@Test // DATACMNS-525
	public void shouldThrowExceptionWhenCreatingNewTempateWithNullMappingContext() {
		assertThatIllegalArgumentException().isThrownBy(() -> new KeyValueTemplate(adapterMock, null));
	}

	@Test // DATACMNS-525
	public void insertShouldLookUpValuesBeforeInserting() {

		template.insert("1", FOO_ONE);

		verify(adapterMock, times(1)).contains("1", Foo.class.getName());
	}

	@Test // DATACMNS-525
	public void insertShouldInsertUseClassNameAsDefaultKeyspace() {

		template.insert("1", FOO_ONE);

		verify(adapterMock, times(1)).put("1", FOO_ONE, Foo.class.getName());
	}

	@Test // DATACMNS-225
	public void insertShouldReturnInsertedObject() {

		ClassWithStringId object = new ClassWithStringId();

		assertThat(template.insert(object)).isEqualTo(object);
		assertThat(template.insert("1", object)).isEqualTo(object);
	}

	@Test // DATACMNS-525
	public void insertShouldThrowExceptionWhenObectWithIdAlreadyExists() {

		when(adapterMock.contains(anyString(), anyString())).thenReturn(true);

		assertThatExceptionOfType(DuplicateKeyException.class).isThrownBy(() -> template.insert("1", FOO_ONE));
	}

	@Test // DATACMNS-525
	public void insertShouldThrowExceptionForNullId() {
		assertThatIllegalArgumentException().isThrownBy(() -> template.insert(null, FOO_ONE));
	}

	@Test // DATACMNS-525
	public void insertShouldThrowExceptionForNullObject() {
		assertThatIllegalArgumentException().isThrownBy(() -> template.insert("some-id", null));
	}

	@Test // DATACMNS-525
	public void insertShouldGenerateId() {

		ClassWithStringId target = template.insert(new ClassWithStringId());

		assertThat(target.id).isNotNull();
	}

	@Test // DATACMNS-525
	public void insertShouldThrowErrorWhenIdCannotBeResolved() {
		assertThatIllegalArgumentException().isThrownBy(() -> template.insert(FOO_ONE));
	}

	@Test // DATACMNS-525
	public void insertShouldReturnSameInstanceGenerateId() {

		ClassWithStringId source = new ClassWithStringId();
		ClassWithStringId target = template.insert(source);

		assertThat(target).isSameAs(source);
	}

	@Test // DATACMNS-525
	public void insertShouldRespectExistingId() {

		ClassWithStringId source = new ClassWithStringId();
		source.id = "one";

		template.insert(source);

		verify(adapterMock, times(1)).put("one", source, ClassWithStringId.class.getName());
	}

	@Test // DATACMNS-525
	public void findByIdShouldReturnOptionalEmptyWhenNoElementsPresent() {
		assertThat(template.findById("1", Foo.class)).isEmpty();
	}

	@Test // DATACMNS-525
	public void findByIdShouldReturnObjectWithMatchingIdAndType() {

		template.findById("1", Foo.class);

		verify(adapterMock, times(1)).get("1", Foo.class.getName(), Foo.class);
	}

	@Test // DATACMNS-525, DATAKV-187
	public void findByIdShouldThrowExceptionWhenGivenNullId() {
		assertThatIllegalArgumentException().isThrownBy(() -> template.findById(null, Foo.class));
	}

	@Test // DATACMNS-525
	public void findAllOfShouldReturnEntireCollection() {

		template.findAll(Foo.class);

		verify(adapterMock, times(1)).getAllOf(Foo.class.getName());
	}

	@Test // DATACMNS-525
	public void findAllOfShouldThrowExceptionWhenGivenNullType() {
		assertThatIllegalArgumentException().isThrownBy(() -> template.findAll(null));
	}

	@Test // DATACMNS-525
	public void findShouldCallFindOnAdapterToResolveMatching() {

		template.find(STRING_QUERY, Foo.class);

		verify(adapterMock, times(1)).find(STRING_QUERY, Foo.class.getName(), Foo.class);
	}

	@Test // DATACMNS-525
	@SuppressWarnings("rawtypes")
	public void findInRangeShouldRespectOffset() {

		ArgumentCaptor<KeyValueQuery> captor = ArgumentCaptor.forClass(KeyValueQuery.class);

		template.findInRange(1, 5, Foo.class);

		verify(adapterMock, times(1)).find(captor.capture(), eq(Foo.class.getName()), eq(Foo.class));
		assertThat(captor.getValue().getOffset()).isEqualTo(1L);
		assertThat(captor.getValue().getRows()).isEqualTo(5);
		assertThat(captor.getValue().getCriteria()).isNull();
	}

	@Test // DATACMNS-525
	public void updateShouldReplaceExistingObject() {

		template.update("1", FOO_TWO);

		verify(adapterMock, times(1)).put("1", FOO_TWO, Foo.class.getName());
	}

	@Test // DATAKV-225
	public void updateShouldReturnUpdatedObject() {

		ClassWithStringId object = new ClassWithStringId();
		object.id = "foo";

		assertThat(template.update(object)).isEqualTo(object);
		assertThat(template.update("1", object)).isEqualTo(object);
	}

	@Test // DATACMNS-525
	public void updateShouldThrowExceptionWhenGivenNullId() {
		assertThatIllegalArgumentException().isThrownBy(() -> template.update(null, FOO_ONE));
	}

	@Test // DATACMNS-525
	public void updateShouldThrowExceptionWhenGivenNullObject() {
		assertThatIllegalArgumentException().isThrownBy(() -> template.update("1", null));
	}

	@Test // DATACMNS-525
	public void updateShouldUseExtractedIdInformation() {

		ClassWithStringId source = new ClassWithStringId();
		source.id = "some-id";

		template.update(source);

		verify(adapterMock, times(1)).put(source.id, source, ClassWithStringId.class.getName());
	}

	@Test // DATACMNS-525
	public void updateShouldThrowErrorWhenIdInformationCannotBeExtracted() {
		assertThatExceptionOfType(InvalidDataAccessApiUsageException.class).isThrownBy(() -> template.update(FOO_ONE));
	}

	@Test // DATACMNS-525
	public void deleteShouldRemoveObjectCorrectly() {

		template.delete("1", Foo.class);

		verify(adapterMock, times(1)).delete("1", Foo.class.getName(), Foo.class);
	}

	@Test // DATACMNS-525
	public void deleteRemovesObjectUsingExtractedId() {

		ClassWithStringId source = new ClassWithStringId();
		source.id = "some-id";

		template.delete(source);

		verify(adapterMock, times(1)).delete("some-id", ClassWithStringId.class.getName(), ClassWithStringId.class);
	}

	@Test // DATACMNS-525
	public void deleteThrowsExceptionWhenIdCannotBeExctracted() {
		assertThatIllegalArgumentException().isThrownBy(() -> template.delete(FOO_ONE));
	}

	@Test // DATACMNS-525
	public void countShouldReturnZeroWhenNoElementsPresent() {
		template.count(Foo.class);
	}

	@Test // DATACMNS-525
	public void countShouldReturnCollectionSize() {

		when(adapterMock.count(Foo.class.getName())).thenReturn(2L);

		assertThat(template.count(Foo.class)).isEqualTo(2L);
	}

	@Test // DATACMNS-525
	public void countShouldThrowErrorOnNullType() {
		assertThatIllegalArgumentException().isThrownBy(() -> template.count(null));
	}

	@Test // DATACMNS-525
	public void insertShouldRespectTypeAlias() {

		template.insert("1", ALIASED_USING_ALIAS_FOR);

		verify(adapterMock, times(1)).put("1", ALIASED_USING_ALIAS_FOR, "aliased");
	}

	@Test // DATACMNS-525
	public void insertShouldRespectTypeAliasOnSubClass() {

		template.insert("1", SUBCLASS_OF_ALIASED_USING_ALIAS_FOR);

		verify(adapterMock, times(1)).put("1", SUBCLASS_OF_ALIASED_USING_ALIAS_FOR, "aliased");
	}

	@SuppressWarnings({ "rawtypes", "unchecked" })
	@Test // DATACMNS-525
	public void findAllOfShouldRespectTypeAliasAndFilterNonMatchingTypes() {

		Collection foo = Arrays.asList(ALIASED_USING_ALIAS_FOR, SUBCLASS_OF_ALIASED_USING_ALIAS_FOR);
		when(adapterMock.getAllOf("aliased")).thenReturn(foo);

		assertThat((Iterable) template.findAll(SUBCLASS_OF_ALIASED_USING_ALIAS_FOR.getClass()))
				.contains(SUBCLASS_OF_ALIASED_USING_ALIAS_FOR);
	}

	@Test // DATACMNS-525
	public void insertSouldRespectTypeAliasAndFilterNonMatching() {

		template.insert("1", ALIASED_USING_ALIAS_FOR);
		assertThat(template.findById("1", SUBCLASS_OF_ALIASED_USING_ALIAS_FOR.getClass())).isEmpty();
	}

	@Test // DATACMNS-525
	public void setttingNullPersistenceExceptionTranslatorShouldThrowException() {
		assertThatIllegalArgumentException().isThrownBy(() -> template.setExceptionTranslator(null));
	}

	@Test // DATAKV-91
	public void shouldNotPublishEventWhenNoApplicationContextSet() {

		template.setApplicationEventPublisher(null);

		template.insert("1", FOO_ONE);

		verifyZeroInteractions(publisherMock);
	}

	@Test // DATAKV-104
	public void shouldNotPublishEventsWhenEventsToPublishIsSetToNull() {

		template.setEventTypesToPublish(null);

		template.insert("1", FOO_ONE);

		verifyZeroInteractions(publisherMock);
	}

	@Test // DATAKV-104
	@SuppressWarnings("rawtypes")
	public void shouldNotPublishEventsWhenEventsToPublishIsSetToEmptyList() {

		template.setEventTypesToPublish(Collections.emptySet());

		template.insert("1", FOO_ONE);

		verifyZeroInteractions(publisherMock);
	}

	@Test // DATAKV-104
	public void shouldPublishEventsByDefault() {

		template.insert("1", FOO_ONE);

		verify(publisherMock, atLeastOnce()).publishEvent(any());
	}

	@Test // DATAKV-91, DATAKV-104
	@SuppressWarnings({ "unchecked", })
	public void shouldNotPublishEventWhenNotExplicitlySetForPublication() {

		setEventsToPublish(BeforeDeleteEvent.class);

		template.insert("1", FOO_ONE);

		verifyZeroInteractions(publisherMock);
	}

	@Test // DATAKV-91, DATAKV-104, DATAKV-187
	@SuppressWarnings({ "unchecked", "rawtypes" })
	public void shouldPublishBeforeInsertEventCorrectly() {

		setEventsToPublish(BeforeInsertEvent.class);

		template.insert("1", FOO_ONE);

		ArgumentCaptor<BeforeInsertEvent> captor = ArgumentCaptor.forClass(BeforeInsertEvent.class);

		verify(publisherMock, times(1)).publishEvent(captor.capture());
		verifyNoMoreInteractions(publisherMock);

		assertThat(captor.getValue().getKey()).isEqualTo("1");
		assertThat(captor.getValue().getKeyspace()).isEqualTo(Foo.class.getName());
		assertThat(captor.getValue().getPayload()).isEqualTo(FOO_ONE);
	}

	@Test // DATAKV-91, DATAKV-104, DATAKV-187
	@SuppressWarnings({ "unchecked", "rawtypes" })
	public void shouldPublishAfterInsertEventCorrectly() {

		setEventsToPublish(AfterInsertEvent.class);

		template.insert("1", FOO_ONE);

		ArgumentCaptor<AfterInsertEvent> captor = ArgumentCaptor.forClass(AfterInsertEvent.class);

		verify(publisherMock, times(1)).publishEvent(captor.capture());
		verifyNoMoreInteractions(publisherMock);

		assertThat(captor.getValue().getKey()).isEqualTo("1");
		assertThat(captor.getValue().getKeyspace()).isEqualTo(Foo.class.getName());
		assertThat(captor.getValue().getPayload()).isEqualTo(FOO_ONE);
	}

	@Test // DATAKV-91, DATAKV-104, DATAKV-187
	@SuppressWarnings({ "unchecked", "rawtypes" })
	public void shouldPublishBeforeUpdateEventCorrectly() {

		setEventsToPublish(BeforeUpdateEvent.class);

		template.update("1", FOO_ONE);

		ArgumentCaptor<BeforeUpdateEvent> captor = ArgumentCaptor.forClass(BeforeUpdateEvent.class);

		verify(publisherMock, times(1)).publishEvent(captor.capture());
		verifyNoMoreInteractions(publisherMock);

		assertThat(captor.getValue().getKey()).isEqualTo("1");
		assertThat(captor.getValue().getKeyspace()).isEqualTo(Foo.class.getName());
		assertThat(captor.getValue().getPayload()).isEqualTo(FOO_ONE);
	}

	@Test // DATAKV-91, DATAKV-104, DATAKV-187
	@SuppressWarnings({ "unchecked", "rawtypes" })
	public void shouldPublishAfterUpdateEventCorrectly() {

		setEventsToPublish(AfterUpdateEvent.class);

		template.update("1", FOO_ONE);

		ArgumentCaptor<AfterUpdateEvent> captor = ArgumentCaptor.forClass(AfterUpdateEvent.class);

		verify(publisherMock, times(1)).publishEvent(captor.capture());
		verifyNoMoreInteractions(publisherMock);

		assertThat(captor.getValue().getKey()).isEqualTo("1");
		assertThat(captor.getValue().getKeyspace()).isEqualTo(Foo.class.getName());
		assertThat(captor.getValue().getPayload()).isEqualTo(FOO_ONE);
	}

	@Test // DATAKV-91, DATAKV-104, DATAKV-187
	@SuppressWarnings({ "rawtypes", "unchecked" })
	public void shouldPublishBeforeDeleteEventCorrectly() {

		setEventsToPublish(BeforeDeleteEvent.class);

		template.delete("1", FOO_ONE.getClass());

		ArgumentCaptor<BeforeDeleteEvent> captor = ArgumentCaptor.forClass(BeforeDeleteEvent.class);

		verify(publisherMock, times(1)).publishEvent(captor.capture());
		verifyNoMoreInteractions(publisherMock);

		assertThat(captor.getValue().getKey()).isEqualTo("1");
		assertThat(captor.getValue().getKeyspace()).isEqualTo(Foo.class.getName());
	}

	@Test // DATAKV-91, DATAKV-104, DATAKV-187
	@SuppressWarnings({ "rawtypes", "unchecked" })
	public void shouldPublishAfterDeleteEventCorrectly() {

		setEventsToPublish(AfterDeleteEvent.class);
		when(adapterMock.delete(eq("1"), eq(FOO_ONE.getClass().getName()), eq(Foo.class))).thenReturn(FOO_ONE);

		template.delete("1", FOO_ONE.getClass());

		ArgumentCaptor<AfterDeleteEvent> captor = ArgumentCaptor.forClass(AfterDeleteEvent.class);

		verify(publisherMock, times(1)).publishEvent(captor.capture());
		verifyNoMoreInteractions(publisherMock);

		assertThat(captor.getValue().getKey()).isEqualTo("1");
		assertThat(captor.getValue().getKeyspace()).isEqualTo(Foo.class.getName());
		assertThat(captor.getValue().getPayload()).isEqualTo(FOO_ONE);
	}

	@Test // DATAKV-91, DATAKV-104, DATAKV-187
	@SuppressWarnings({ "rawtypes", "unchecked" })
	public void shouldPublishBeforeGetEventCorrectly() {

		setEventsToPublish(BeforeGetEvent.class);

		when(adapterMock.get(eq("1"), eq(FOO_ONE.getClass().getName()))).thenReturn(FOO_ONE);

		template.findById("1", FOO_ONE.getClass());

		ArgumentCaptor<BeforeGetEvent> captor = ArgumentCaptor.forClass(BeforeGetEvent.class);

		verify(publisherMock, times(1)).publishEvent(captor.capture());
		verifyNoMoreInteractions(publisherMock);

		assertThat(captor.getValue().getKey()).isEqualTo("1");
		assertThat(captor.getValue().getKeyspace()).isEqualTo(Foo.class.getName());
	}

	@Test // DATAKV-91, DATAKV-104
	@SuppressWarnings({ "rawtypes", "unchecked" })
	public void shouldPublishAfterGetEventCorrectly() {

		setEventsToPublish(AfterGetEvent.class);

		when(adapterMock.get(eq("1"), eq(FOO_ONE.getClass().getName()), eq(Foo.class))).thenReturn(FOO_ONE);

		template.findById("1", FOO_ONE.getClass());

		ArgumentCaptor<AfterGetEvent> captor = ArgumentCaptor.forClass(AfterGetEvent.class);

		verify(publisherMock, times(1)).publishEvent(captor.capture());
		verifyNoMoreInteractions(publisherMock);

		assertThat(captor.getValue().getKey()).isEqualTo("1");
		assertThat(captor.getValue().getKeyspace()).isEqualTo(Foo.class.getName());
		assertThat(captor.getValue().getPayload()).isEqualTo(FOO_ONE);
	}

	@Test // DATAKV-91, DATAKV-104, DATAKV-187
	@SuppressWarnings({ "rawtypes", "unchecked" })
	public void shouldPublishDropKeyspaceEventCorrectly() {

		setEventsToPublish(AfterDropKeySpaceEvent.class);

		template.delete(FOO_ONE.getClass());

		ArgumentCaptor<AfterDropKeySpaceEvent> captor = ArgumentCaptor.forClass(AfterDropKeySpaceEvent.class);

		verify(publisherMock, times(1)).publishEvent(captor.capture());
		verifyNoMoreInteractions(publisherMock);

		assertThat(captor.getValue().getKeyspace()).isEqualTo(Foo.class.getName());
	}

	@Test // DATAKV-129
	public void insertShouldRespectTypeAliasUsingAliasFor() {

		template.insert("1", ALIASED_USING_ALIAS_FOR);

		verify(adapterMock, times(1)).put("1", ALIASED_USING_ALIAS_FOR, "aliased");
	}

	@SafeVarargs
	@SuppressWarnings("rawtypes")
	private final void setEventsToPublish(Class<? extends KeyValueEvent>... events) {
		template.setEventTypesToPublish(new HashSet<>(Arrays.asList(events)));
	}

	@Data
	@AllArgsConstructor
	static class Foo {

		String foo;
	}

	@Data
	@AllArgsConstructor
	class Bar {

		String bar;
	}

	@Data
	static class ClassWithStringId {

		@Id String id;
		String value;
	}
}