/*
 * Copyright (c) 2019 Contributors to the Eclipse Foundation
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
package org.eclipse.ditto.services.utils.persistentactors;

import static org.assertj.core.api.Assertions.assertThat;
import static org.eclipse.ditto.services.utils.persistentactors.MockJournalPlugin.FAIL_DELETE_MESSAGE;
import static org.eclipse.ditto.services.utils.persistentactors.MockJournalPlugin.SLOW_DELETE;
import static org.eclipse.ditto.services.utils.persistentactors.MockSnapshotStorePlugin.FAIL_DELETE_SNAPSHOT;

import java.time.Duration;
import java.util.UUID;
import java.util.stream.IntStream;

import org.eclipse.ditto.model.base.common.HttpStatusCode;
import org.eclipse.ditto.model.base.entity.id.DefaultEntityId;
import org.eclipse.ditto.model.base.headers.DittoHeaders;
import org.eclipse.ditto.signals.commands.cleanup.CleanupCommandResponse;
import org.eclipse.ditto.signals.commands.cleanup.CleanupPersistence;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestName;

import com.typesafe.config.ConfigFactory;

import akka.actor.ActorRef;
import akka.actor.ActorSystem;
import akka.actor.Props;
import akka.japi.Creator;
import akka.persistence.SaveSnapshotSuccess;
import akka.testkit.javadsl.TestKit;

/**
 * Tests {@link AbstractPersistentActorWithTimersAndCleanup}.
 */
@SuppressWarnings("NullableProblems")
public class AbstractPersistentActorWithTimersAndCleanupTest {

    private static final int SNAPSHOT_THRESHOLD = 5;
    private static ActorSystem actorSystem;

    @Rule
    public TestName name = new TestName();

    @BeforeClass
    public static void init() {
        actorSystem = ActorSystem.create("AkkaTestSystem", ConfigFactory.load("test.conf"));
    }

    @AfterClass
    public static void tearDown() {
        if (actorSystem != null) {
            TestKit.shutdownActorSystem(actorSystem);
        }
    }

    @Test
    public void testDeleteSucceeds() {
        new TestKit(actorSystem) {{
            // GIVEN: persistence actor with some messages and snapshots
            final ActorRef persistenceActor = childActorOf(DummyPersistentActor.props(persistenceId()));
            modifyDummyAndWaitForSnapshotSuccess(this, persistenceActor, 10);

            // WHEN: cleanup is sent
            persistenceActor.tell(CleanupPersistence.of(DefaultEntityId.of(persistenceId()), DittoHeaders.empty()), getRef());

            // THEN: command is successful and plugin is called
            final CleanupCommandResponse cleanupCommandResponse = expectMsgClass(CleanupCommandResponse.class);
            assertThat(cleanupCommandResponse.getStatusCode()).isEqualTo(HttpStatusCode.OK);
            verifyPersistencePluginCalledWithCorrectArguments(persistenceId(), 9);
        }};
    }

    @Test
    public void testZeroStaleEventsKept() {
        new TestKit(actorSystem) {{
            // GIVEN: persistence actor with some messages and snapshots
            final ActorRef persistenceActor = childActorOf(DummyPersistentActor.props(persistenceId(), 0L));
            modifyDummyAndWaitForSnapshotSuccess(this, persistenceActor, 10);

            // WHEN: cleanup is sent
            persistenceActor.tell(CleanupPersistence.of(DefaultEntityId.of(persistenceId()), DittoHeaders.empty()), getRef());

            // THEN: command is successful and plugin is called
            final CleanupCommandResponse cleanupCommandResponse = expectMsgClass(CleanupCommandResponse.class);
            assertThat(cleanupCommandResponse.getStatusCode()).isEqualTo(HttpStatusCode.OK);
            verifyPersistencePluginCalledWithCorrectArguments(persistenceId(), 9, 10);
        }};
    }

    @Test
    public void testDeleteMessagesFails() {
        new TestKit(actorSystem) {{
            // GIVEN: persistence actor with some messages and snapshots
            final ActorRef persistenceActor = childActorOf(DummyPersistentActor.props(FAIL_DELETE_MESSAGE));

            modifyDummyAndWaitForSnapshotSuccess(this, persistenceActor, 8);

            persistenceActor.tell(CleanupPersistence.of(DefaultEntityId.of(FAIL_DELETE_MESSAGE), DittoHeaders.empty()), getRef());
            final CleanupCommandResponse cleanupCommandResponse = expectMsgClass(CleanupCommandResponse.class);

            assertThat(cleanupCommandResponse.getStatusCode()).isEqualTo(HttpStatusCode.INTERNAL_SERVER_ERROR);

            verifyPersistencePluginCalledWithCorrectArguments(FAIL_DELETE_MESSAGE, 4);
        }};
    }

    @Test
    public void testDeleteSnapshotFails() {
        new TestKit(actorSystem) {{
            // GIVEN: persistence actor with some messages and snapshots
            final ActorRef persistenceActor = childActorOf(DummyPersistentActor.props(FAIL_DELETE_SNAPSHOT));
            modifyDummyAndWaitForSnapshotSuccess(this, persistenceActor, 5);

            // WHEN: cleanup is sent
            persistenceActor.tell(CleanupPersistence.of(DefaultEntityId.of(FAIL_DELETE_SNAPSHOT), DittoHeaders.empty()), getRef());

            // THEN: expect success response with correct status and persistence plugin is called
            final CleanupCommandResponse cleanupCommandResponse = expectMsgClass(CleanupCommandResponse.class);
            assertThat(cleanupCommandResponse.getStatusCode()).isEqualTo(HttpStatusCode.INTERNAL_SERVER_ERROR);
            verifyPersistencePluginCalledWithCorrectArguments(FAIL_DELETE_SNAPSHOT, 4);
        }};
    }

    @Test
    public void testDeleteNotCalledWhenRevisionDidNotChange() {
        new TestKit(actorSystem) {{
            // GIVEN: persistence actor with some messages and snapshots
            final ActorRef persistenceActor = childActorOf(DummyPersistentActor.props(persistenceId()));
            modifyDummyAndWaitForSnapshotSuccess(this, persistenceActor, 20);

            // WHEN: cleanup is sent
            persistenceActor.tell(CleanupPersistence.of(DefaultEntityId.of(persistenceId()), DittoHeaders.empty()), getRef());
            final CleanupCommandResponse response1 = expectMsgClass(CleanupCommandResponse.class);
            assertThat(response1.getStatusCode()).isEqualTo(HttpStatusCode.OK);

            // and entity is not changed in the meantime
            persistenceActor.tell(CleanupPersistence.of(DefaultEntityId.of(persistenceId()), DittoHeaders.empty()), getRef());
            final CleanupCommandResponse response2 = expectMsgClass(CleanupCommandResponse.class);
            assertThat(response2.getStatusCode()).isEqualTo(HttpStatusCode.OK);

            // THEN: verify that persistence plugin is only called once
            verifyPersistencePluginCalledWithCorrectArguments(persistenceId(), 19);
        }};
    }

    @Test
    public void testConcurrentCleanupCommandFailsWhenAnotherCleanupIsRunning() {
        new TestKit(actorSystem) {{
            // GIVEN: persistence actor with some messages and snapshots
            final ActorRef persistenceActor = childActorOf(DummyPersistentActor.props(SLOW_DELETE));
            modifyDummyAndWaitForSnapshotSuccess(this, persistenceActor, 10);

            // WHEN: concurrent cleanup is sent
            persistenceActor.tell(CleanupPersistence.of(DefaultEntityId.of(SLOW_DELETE), DittoHeaders.empty()), getRef());
            persistenceActor.tell(CleanupPersistence.of(DefaultEntityId.of(SLOW_DELETE), DittoHeaders.empty()), getRef());
            final CleanupCommandResponse cleanupFailed =
                    expectMsgClass(Duration.ofSeconds(10), CleanupCommandResponse.class);

            // THEN: second command is rejected and only first command ist executed by plugin
            assertThat(cleanupFailed.getStatusCode()).isEqualTo(HttpStatusCode.INTERNAL_SERVER_ERROR);
            verifyPersistencePluginCalledWithCorrectArguments(SLOW_DELETE, 9);
        }};
    }

    @Test
    public void testDeleteIsCalledWhenRevisionChanged() {
        new TestKit(actorSystem) {{
            // GIVEN: persistence actor with some messages and snapshots
            final ActorRef persistenceActor = childActorOf(DummyPersistentActor.props(persistenceId()));
            modifyDummyAndWaitForSnapshotSuccess(this, persistenceActor, 10);

            // WHEN: cleanup command is sent
            persistenceActor.tell(CleanupPersistence.of(DefaultEntityId.of(persistenceId()), DittoHeaders.empty()), getRef());

            // THEN: command is successful
            final CleanupCommandResponse cleanupCommandResponse1 = expectMsgClass(CleanupCommandResponse.class);
            assertThat(cleanupCommandResponse1.getStatusCode()).isEqualTo(HttpStatusCode.OK);

            // WHEN: more updates occur and another cleanup command is sent
            modifyDummyAndWaitForSnapshotSuccess(this, persistenceActor, 10);
            persistenceActor.tell(CleanupPersistence.of(DefaultEntityId.of(persistenceId()), DittoHeaders.empty()), getRef());

            // THEN: command is successful and deletes are executed with correct seq number
            final CleanupCommandResponse cleanupCommandResponse2 = expectMsgClass(CleanupCommandResponse.class);
            assertThat(cleanupCommandResponse2.getStatusCode()).isEqualTo(HttpStatusCode.OK);

            verifyPersistencePluginCalledWithCorrectArguments(persistenceId(), 9);
            verifyPersistencePluginCalledWithCorrectArguments(persistenceId(), 19);
        }};
    }

    private void modifyDummyAndWaitForSnapshotSuccess(final TestKit testKit,
            final ActorRef persistenceActor, final int times) {
        IntStream.range(0, times).forEach(i -> persistenceActor.tell("SAVE", ActorRef.noSender()));
        IntStream.range(0, times / SNAPSHOT_THRESHOLD).forEach(i -> testKit.expectMsgClass(Duration.ofSeconds(10),
                SaveSnapshotSuccess.class));
    }

    private void verifyPersistencePluginCalledWithCorrectArguments(final String persistenceId, final int toSn) {
        verifyPersistencePluginCalledWithCorrectArguments(persistenceId, toSn, toSn);
    }

    private void verifyPersistencePluginCalledWithCorrectArguments(final String persistenceId, final int snapsSn,
            final int journalSn) {

        MockSnapshotStorePlugin.verify(persistenceId, snapsSn);
        MockJournalPlugin.verify(persistenceId, journalSn);
    }

    private String persistenceId() {
        return name.getMethodName();
    }

    static class DummyPersistentActor extends AbstractPersistentActorWithTimersAndCleanup {

        private final String persistenceId;
        private final long staleEventsKept;
        private long lastSnapshotSeqNo = 0;

        private DummyPersistentActor(final String persistenceId, final long staleEventsKept) {
            this.persistenceId = persistenceId;
            this.staleEventsKept = staleEventsKept;
        }

        static Props props(final String persistenceId) {
            return props(persistenceId, 1L);
        }

        static Props props(final String persistenceId, final long staleEventsKept) {
            return Props.create(
                    DummyPersistentActor.class,
                    (Creator<DummyPersistentActor>) () -> new DummyPersistentActor(persistenceId, staleEventsKept));
        }

        @Override
        protected long staleEventsKeptAfterCleanup() {
            return staleEventsKept;
        }

        @Override
        protected long getLatestSnapshotSequenceNumber() {
            return lastSnapshotSeqNo;
        }

        @Override
        public Receive createReceiveRecover() {
            return receiveBuilder()
                    .matchAny(m -> log.debug("Received: {}", m))
                    .build();
        }

        @Override
        public Receive createReceive() {
            return super.createReceive()
                    .orElse(receiveBuilder()
                            .match(SaveSnapshotSuccess.class, sss -> {
                                log.debug("Snapshot success: {}", sss);
                                lastSnapshotSeqNo = sss.metadata().sequenceNr();
                                log.debug("Last snapshot sequence number is now {}", lastSnapshotSeqNo);
                                // forward to testkit, it waits for it before sending the cleanup command
                                getContext().getParent().tell(sss, ActorRef.noSender());
                            })
                            // simulates update on the entity
                            .matchEquals("SAVE", s -> persist(UUID.randomUUID().toString(), r -> {
                                log.debug("Persisted {}", r);
                                final long lastSequenceNo = lastSequenceNr();
                                log.debug("Sequence number is now {}", lastSequenceNo);
                                if (lastSequenceNo % SNAPSHOT_THRESHOLD == 0) {
                                    saveSnapshot(r);
                                }
                            }))
                            .build());
        }

        @Override
        public String persistenceId() {
            return persistenceId;
        }

    }

}