// Copyright © 2012-2020 VLINGO LABS. All rights reserved.
//
// This Source Code Form is subject to the terms of the
// Mozilla Public License, v. 2.0. If a copy of the MPL
// was not distributed with this file, You can obtain
// one at https://mozilla.org/MPL/2.0/.

package io.vlingo.lattice.model.sourcing;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;

import java.util.Date;
import java.util.List;

import org.junit.Before;
import org.junit.Test;

import io.vlingo.actors.World;
import io.vlingo.actors.testkit.AccessSafely;
import io.vlingo.actors.testkit.TestWorld;
import io.vlingo.lattice.model.DomainEvent;
import io.vlingo.lattice.model.sourcing.SourcedTypeRegistry.Info;
import io.vlingo.symbio.BaseEntry;
import io.vlingo.symbio.Entry;
import io.vlingo.symbio.EntryAdapterProvider;
import io.vlingo.symbio.store.journal.Journal;
import io.vlingo.symbio.store.journal.inmemory.InMemoryJournalActor;

public class EventSourcedTest {
  private Entity entity;
  private Journal<String> journal;
  private MockJournalDispatcher dispatcher;
  private SourcedTypeRegistry registry;
  private Result result;
  private TestWorld testWorld;
  private World world;

  @Test
  public void testThatCtorEmits() {
    final AccessSafely resultAccess = result.afterCompleting(2);
    final AccessSafely dispatcherAccess = dispatcher.afterCompleting(1);

    entity.doTest1();

    assertTrue(resultAccess.readFrom("tested1"));
    assertEquals(1, (int) resultAccess.readFrom("appliedCount"));
    assertEquals(1, (int) dispatcherAccess.readFrom("entriesCount"));
    Object appliedAt0 = resultAccess.readFrom("appliedAt", 0);
    assertNotNull(appliedAt0);
    assertEquals(Test1Happened.class, appliedAt0.getClass());
    BaseEntry<String> appendeAt0 = dispatcherAccess.readFrom("appendedAt", 0);
    assertNotNull(appendeAt0);
    assertEquals(Test1Happened.class.getName(), appendeAt0.typeName());
    assertFalse(resultAccess.readFrom("tested2"));
  }

  @Test
  public void testThatCommandEmits() {
    final AccessSafely resultAccess = result.afterCompleting(2);
    final AccessSafely dispatcherAccess = dispatcher.afterCompleting(1);

    entity.doTest1();

    assertTrue(resultAccess.readFrom("tested1"));
    assertFalse(resultAccess.readFrom("tested2"));
    assertEquals(1, (int) resultAccess.readFrom("appliedCount"));
    assertEquals(1, (int) dispatcherAccess.readFrom("entriesCount"));
    Object appliedAt0 = resultAccess.readFrom("appliedAt", 0);
    assertNotNull(appliedAt0);
    assertEquals(Test1Happened.class, appliedAt0.getClass());
    BaseEntry<String> appendeAt0 = dispatcherAccess.readFrom("appendedAt", 0);
    assertNotNull(appendeAt0);
    assertEquals(Test1Happened.class.getName(), appendeAt0.typeName());

    final AccessSafely resultAccess2 = result.afterCompleting(2);
    final AccessSafely dispatcherAccess2 = dispatcher.afterCompleting(1);

    entity.doTest2();

    assertEquals(2, (int) resultAccess2.readFrom("appliedCount"));
    assertEquals(2, (int) dispatcherAccess.readFrom("entriesCount"));
    Object appliedAt1 = resultAccess2.readFrom("appliedAt", 1);
    assertNotNull(appliedAt1);
    assertEquals(Test2Happened.class, appliedAt1.getClass());
    BaseEntry<String> appendeAt1 = dispatcherAccess2.readFrom("appendedAt", 1);
    assertNotNull(appendeAt1);
    assertEquals(Test2Happened.class.getName(), appendeAt1.typeName());
  }

  @Test
  public void testThatOutcomeCompletes() {
    final AccessSafely resultAccess = result.afterCompleting(2);
    final AccessSafely dispatcherAccess = dispatcher.afterCompleting(1);

    entity.doTest1();

    assertTrue(resultAccess.readFrom("tested1"));
    assertFalse(resultAccess.readFrom("tested3"));
    assertEquals(1, (int) resultAccess.readFrom("appliedCount"));
    assertEquals(1, (int) dispatcherAccess.readFrom("entriesCount"));
    Object appliedAt0 = resultAccess.readFrom("appliedAt", 0);
    assertNotNull(appliedAt0);
    assertEquals(Test1Happened.class, appliedAt0.getClass());
    BaseEntry<String> appendeAt0 = dispatcherAccess.readFrom("appendedAt", 0);
    assertNotNull(appendeAt0);
    assertEquals(Test1Happened.class.getName(), appendeAt0.typeName());

    final AccessSafely resultAccess2 = result.afterCompleting(2);
    final AccessSafely dispatcherAccess2 = dispatcher.afterCompleting(1);

    entity.doTest3().andThenConsume(greeting -> {
      assertEquals("hello", greeting);
    });

    assertEquals(2, (int) resultAccess2.readFrom("appliedCount"));
    assertEquals(2, (int) dispatcherAccess2.readFrom("entriesCount"));
    Object appliedAt1 = resultAccess2.readFrom("appliedAt", 1);
    assertNotNull(appliedAt1);
    assertEquals(Test3Happened.class, appliedAt1.getClass());
    BaseEntry<String> appendeAt1 = dispatcherAccess.readFrom("appendedAt", 1);
    assertNotNull(appendeAt1);
    assertEquals(Test3Happened.class.getName(), appendeAt1.typeName());
  }

  @Test
  public void testBaseClassBehavior() {
    final Product product = world.actorFor(Product.class, ProductEntity.class);

    final AccessSafely access = dispatcher.afterCompleting(4);

    product.define("dice", "fuz", "dice-fuz-1", "Fuzzy dice.", 999);

    product.declareType("Type1");

    product.categorize("Category2");

    product.changeName("Fuzzy, fuzzy dice!");

    final List<Entry<String>> entries = access.readFrom("entries");

    assertEquals("ProductDefined", innerToSimple(entries.get(0).typeName()));
    assertEquals("ProductTyped", innerToSimple(entries.get(1).typeName()));
    assertEquals("ProductCategorized", innerToSimple(entries.get(2).typeName()));
    assertEquals("ProductNameChanged", innerToSimple(entries.get(3).typeName()));
  }

  @Before
  @SuppressWarnings({ "unchecked", "rawtypes" })
  public void setUp() {
    testWorld = TestWorld.startWithDefaults("test-es");

    world = testWorld.world();

    dispatcher = new MockJournalDispatcher();

    EntryAdapterProvider entryAdapterProvider = EntryAdapterProvider.instance(world);

    entryAdapterProvider.registerAdapter(Test1Happened.class, new Test1HappenedAdapter());
    entryAdapterProvider.registerAdapter(Test2Happened.class, new Test2HappenedAdapter());
    entryAdapterProvider.registerAdapter(Test3Happened.class, new Test3HappenedAdapter());

    journal = world.actorFor(Journal.class, InMemoryJournalActor.class, dispatcher);

    registry = new SourcedTypeRegistry(world);
    registry.register(new Info(journal, TestEventSourcedEntity.class, TestEventSourcedEntity.class.getSimpleName()));
    registry.register(new Info(journal, ProductEntity.class, ProductEntity.class.getSimpleName()));
    registry.register(new Info(journal, ProductParent.class, ProductParent.class.getSimpleName()));
    registry.register(new Info(journal, ProductGrandparent.class, ProductGrandparent.class.getSimpleName()));

    result = new Result();
    entity = world.actorFor(Entity.class, TestEventSourcedEntity.class, result);
  }

  private String innerToSimple(final String fqcn) {
    final String simpleName = fqcn.substring(fqcn.lastIndexOf('$') + 1);
    return simpleName;
  }

  //===========================
  // HIERARCHICAL TEST TYPES
  //===========================

  public static interface Product {
    void define(final String type, final String category, final String name, final String description, final long price);
    void declareType(final String type);
    void categorize(final String category);
    void changeDescription(String description);
    void changeName(String name);
    void changePrice(long price);
  }

  public static abstract class ProductGrandparent extends EventSourced implements Product {
    private String type;

    public ProductGrandparent(String streamName) {
      super(streamName);
    }

    @Override
    public void declareType(final String type) {
      apply(new ProductTyped(type));
    }

    @Override
    public String toString() {
      return "Grandparent [type=" + type + "]";
    }

    private void whenProductTyped(final ProductTyped event) {
      this.type = event.type;
    }

    static {
      registerConsumer(ProductGrandparent.class, ProductTyped.class, ProductGrandparent::whenProductTyped);
    }
  }

  public static abstract class ProductParent extends ProductGrandparent {
    private String category;

    public ProductParent(String streamName) {
      super(streamName);
    }

    @Override
    public void categorize(final String category) {
      apply(new ProductCategorized(category));
    }

    @Override
    public String toString() {
      return "ProductParent [category=" + category + "]";
    }

    private void whenProductCategorized(final ProductCategorized event) {
      this.category = event.category;
    }

    static {
      registerConsumer(ProductParent.class, ProductCategorized.class, ProductParent::whenProductCategorized);
    }
  }

  public static class ProductEntity extends ProductParent {
    public String name;
    public String description;
    public long price;

    public ProductEntity() {
      super(null);
    }

    @Override
    public void define(String type, String category, String name, String description, long price) {
      apply(new ProductDefined(name, description, price));
    }

    /* (non-Javadoc)
     * @see io.vlingo.lattice.model.sourcing.Product#changeDescription(java.lang.String)
     */
    @Override
    public void changeDescription(final String description) {
      apply(new ProductDescriptionChanged(description));
    }

    /* (non-Javadoc)
     * @see io.vlingo.lattice.model.sourcing.Product#changeName(java.lang.String)
     */
    @Override
    public void changeName(final String name) {
      apply(new ProductNameChanged(name));
    }

    /* (non-Javadoc)
     * @see io.vlingo.lattice.model.sourcing.Product#changePrice(long)
     */
    @Override
    public void changePrice(final long price) {
      apply(new ProductPriceChanged(price));
    }

    public void whenProductDefined(final ProductDefined event) {
      this.name = event.name;
      this.description = event.description;
      this.price = event.price;
    }

    public void whenProductDescriptionChanged(final ProductDescriptionChanged event) {
      this.description = event.description;
    }

    public void whenProductNameChanged(final ProductNameChanged event) {
      this.name = event.name;
    }

    public void whenProductPriceChanged(final ProductPriceChanged event) {
      this.price = event.price;
    }

    static {
      registerConsumer(ProductEntity.class, ProductDefined.class, ProductEntity::whenProductDefined);
      registerConsumer(ProductEntity.class, ProductDescriptionChanged.class, ProductEntity::whenProductDescriptionChanged);
      registerConsumer(ProductEntity.class, ProductNameChanged.class, ProductEntity::whenProductNameChanged);
      registerConsumer(ProductEntity.class, ProductPriceChanged.class, ProductEntity::whenProductPriceChanged);
    }
  }

  public static final class ProductTyped extends DomainEvent {
    public final String type;

    public ProductTyped(final String type) {
      this.type = type;
    }

    @Override
    public boolean equals(Object other) {
      if (other == null || other.getClass() != ProductTyped.class) {
        return false;
      }

      final ProductTyped otherProductTyped = (ProductTyped) other;

      return this.type.equals(otherProductTyped.type);
    }
  }

  public static final class ProductCategorized extends DomainEvent {
    public final String category;

    public ProductCategorized(final String category) {
      this.category = category;
    }

    @Override
    public boolean equals(Object other) {
      if (other == null || other.getClass() != ProductCategorized.class) {
        return false;
      }

      final ProductCategorized otherProductCategorized = (ProductCategorized) other;

      return this.category.equals(otherProductCategorized.category);
    }
  }

  public static final class ProductDefined extends DomainEvent {
    public final String description;
    public final String name;
    public final Date occurredOn;
    public final long price;
    public final int version;

    ProductDefined(final String name, final String description, final long price) {
      this.name = name;
      this.description = description;
      this.price = price;
      this.occurredOn = new Date();
      this.version = 1;
    }

    public Date occurredOn() {
      return occurredOn;
    }

    public int eventVersion() {
      return version;
    }

    @Override
    public boolean equals(Object other) {
      if (other == null || other.getClass() != ProductDefined.class) {
        return false;
      }

      final ProductDefined otherProductDefined = (ProductDefined) other;

      return this.name.equals(otherProductDefined.name) &&
          this.description.equals(otherProductDefined.description) &&
          this.price == otherProductDefined.price &&
          this.version == otherProductDefined.version;
    }
  }

  public static final class ProductDescriptionChanged extends DomainEvent {
    public final String description;
    public final Date occurredOn;
    public final int version;

    ProductDescriptionChanged(final String description) {
      this.description = description;
      this.occurredOn = new Date();
      this.version = 1;
    }

    public Date occurredOn() {
      return occurredOn;
    }

    public int eventVersion() {
      return version;
    }

    @Override
    public boolean equals(Object other) {
      if (other == null || other.getClass() != ProductDescriptionChanged.class) {
        return false;
      }

      final ProductDescriptionChanged otherProductDescriptionChanged = (ProductDescriptionChanged) other;

      return this.description.equals(otherProductDescriptionChanged.description) &&
          this.version == otherProductDescriptionChanged.version;
    }
  }

  public static final class ProductNameChanged extends DomainEvent {
    public final String name;
    public final Date occurredOn;
    public final int version;

    ProductNameChanged(final String name) {
      this.name = name;
      this.occurredOn = new Date();
      this.version = 1;
    }

    public Date occurredOn() {
      return occurredOn;
    }

    public int eventVersion() {
      return version;
    }

    @Override
    public boolean equals(Object other) {
      if (other == null || other.getClass() != ProductNameChanged.class) {
        return false;
      }

      final ProductNameChanged otherProductNameChanged = (ProductNameChanged) other;

      return this.name.equals(otherProductNameChanged.name) &&
          this.version == otherProductNameChanged.version;
    }
  }

  public static final class ProductPriceChanged extends DomainEvent {
    public final long price;
    public final Date occurredOn;
    public final int version;

    ProductPriceChanged(final long price) {
      this.price = price;
      this.occurredOn = new Date();
      this.version = 1;
    }

    public Date occurredOn() {
      return occurredOn;
    }

    public int eventVersion() {
      return version;
    }

    @Override
    public boolean equals(Object other) {
      if (other == null || other.getClass() != ProductPriceChanged.class) {
        return false;
      }

      final ProductPriceChanged otherProductPriceChanged = (ProductPriceChanged) other;

      return this.price == otherProductPriceChanged.price &&
          this.version == otherProductPriceChanged.version;
    }
  }
}