package cloud.nstart.terravault.ordinis.notifications.dispatcher;

import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLog;
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLogRepository;
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
import cloud.nstart.terravault.ordinis.notifications.channel.NotificationChannel;
import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage;
import cloud.nstart.terravault.ordinis.notifications.channel.UserRef;
import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
import cloud.nstart.terravault.ordinis.notifications.template.MessageRenderer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Pageable;
import org.springframework.test.util.ReflectionTestUtils;

import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * Integration-style test for {@link NotificationsDispatcher}.
 *
 * <p>Uses mocks for external dependencies (OutboxEventRepository, MessageRenderer,
 * RecipientResolver) and a stub IdempotencyGuard (because Mockito cannot subclass
 * Spring @Transactional components without mockito-subclass agent). Validates the
 * full dispatch pipeline:
 * <ol>
 *   <li>Seeded outbox event of type {@code RecordDraftApproved}</li>
 *   <li>Dispatcher sendTick() triggered manually</li>
 *   <li>IdempotencyGuard claim called — NotificationLog row created</li>
 *   <li>Mock EmailChannel dispatch() called with rendered message</li>
 * </ol>
 *
 * <p>Named *Test (not *IT) so Maven Surefire discovers it in the default include pattern.
 */
class NotificationsDispatcherTest {

  // Mocks
  private OutboxEventRepository outboxRepository;
  private MessageRenderer messageRenderer;
  private RecipientResolver recipientResolver;

  // Test doubles
  private RecordingEmailChannel emailChannel;
  private StubIdempotencyGuard idempotencyGuard;
  private StubNotificationLogRepository notificationLogRepository;

  private NotificationsDispatcher dispatcher;

  @BeforeEach
  void setUp() {
    outboxRepository = mock(OutboxEventRepository.class);
    messageRenderer = mock(MessageRenderer.class);
    recipientResolver = mock(RecipientResolver.class);
    emailChannel = new RecordingEmailChannel();
    notificationLogRepository = new StubNotificationLogRepository();
    idempotencyGuard = new StubIdempotencyGuard(notificationLogRepository);

    // Phase B: UserPreferencesGate — stub all-allow для existing tests (no
    // prefs row → defaults apply, email ON). Per-channel skip tested separately.
    UserPreferencesGate allAllowGate = new UserPreferencesGate(null) {
      @Override
      public boolean allows(String userId, cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind channel) {
        return true;
      }
    };
    dispatcher = new NotificationsDispatcher(
        outboxRepository,
        notificationLogRepository,
        idempotencyGuard,
        messageRenderer,
        List.of(emailChannel),
        recipientResolver,
        allAllowGate,
        new SimpleMeterRegistry()
    );

    ReflectionTestUtils.setField(dispatcher, "batchSize", 50);
    ReflectionTestUtils.setField(dispatcher, "pollIntervalMs", 30_000L);
    ReflectionTestUtils.setField(dispatcher, "reviewerPoolEmail", "");
    ReflectionTestUtils.setField(dispatcher, "baseUrl", "https://ordinis.example");
  }

  @Test
  @DisplayName("RecordDraftApproved event → maker receives email notification, NotificationLog row created")
  void draftApproved_makerGetsEmail() {
    UUID draftId = UUID.randomUUID();
    String makerId = "maker-user-sub-123";
    String makerEmail = "maker@nstart.space";

    OutboxEvent event = buildDraftApprovedEvent(draftId, makerId);
    when(outboxRepository.findRecentByEventTypes(any(), any(), any(Pageable.class))).thenReturn(List.of(event));

    UserRef makerRef = new UserRef(makerId, makerEmail, null, null, Locale.forLanguageTag("ru"));
    when(recipientResolver.resolveById(makerId)).thenReturn(Optional.of(makerRef));

    RenderedMessage rendered = new RenderedMessage("[НСИ] Draft одобрен — Test / SPUTNIK-1", "Ваш draft одобрен.");
    when(messageRenderer.render(anyString(), any(ChannelKind.class), any(Locale.class), any(Object[].class)))
        .thenReturn(rendered);

    // Trigger dispatcher
    dispatcher.sendTick();

    // Assert: EmailChannel.dispatch() was called once for the maker
    assertThat(emailChannel.dispatchCalls).hasSize(1);
    RecordingEmailChannel.Call call = emailChannel.dispatchCalls.get(0);
    assertThat(call.recipient.email()).isEqualTo(makerEmail);
    assertThat(call.msg.subject()).contains("Draft одобрен");

    // Assert: NotificationLog row was claimed and marked SENT
    assertThat(notificationLogRepository.savedRows).hasSize(1);
    assertThat(idempotencyGuard.markedSentCount.get()).isEqualTo(1);
  }

  @Test
  @DisplayName("Non-draft event types are ignored")
  void nonDraftEvents_areIgnored() {
    OutboxEvent event = new OutboxEvent(
        "DictionaryRecordCreated", "DictionaryRecord", "spacecraft:test",
        DataScope.PUBLIC,
        new ObjectMapper().createObjectNode(),
        "ordinis.cuod.events.public", "spacecraft:test");

    when(outboxRepository.findRecentByEventTypes(any(), any(), any(Pageable.class))).thenReturn(List.of(event));

    dispatcher.sendTick();

    assertThat(emailChannel.dispatchCalls).isEmpty();
    verify(recipientResolver, never()).resolveById(anyString());
  }

  @Test
  @DisplayName("Idempotency conflict → no dispatch, markSent never called")
  void idempotencyConflict_skipsDispatch() {
    UUID draftId = UUID.randomUUID();
    String makerId = "maker-sub-456";
    OutboxEvent event = buildDraftApprovedEvent(draftId, makerId);
    when(outboxRepository.findRecentByEventTypes(any(), any(), any(Pageable.class))).thenReturn(List.of(event));

    UserRef makerRef = new UserRef(makerId, "maker@nstart.space", null, null, Locale.forLanguageTag("ru"));
    when(recipientResolver.resolveById(makerId)).thenReturn(Optional.of(makerRef));

    // Simulate idempotency conflict — guard returns empty
    idempotencyGuard.simulateConflict = true;

    dispatcher.sendTick();

    assertThat(emailChannel.dispatchCalls).isEmpty();
    assertThat(idempotencyGuard.markedSentCount.get()).isZero();
  }

  @Test
  @DisplayName("Event key mapping is correct for all draft event types")
  void eventKeyMapping_allTypes() {
    assertThat(NotificationsDispatcher.toEventKey("RecordDraftRejected")).isEqualTo("draft_decision");
    assertThat(NotificationsDispatcher.toEventKey("RecordDraftApproved")).isEqualTo("draft_decision");
    assertThat(NotificationsDispatcher.toEventKey("RecordDraftSubmitted")).isEqualTo("draft_submitted");
    assertThat(NotificationsDispatcher.toEventKey("RecordDraftWithdrawn")).isEqualTo("draft_withdrawn");
  }

  @Test
  @DisplayName("Unknown user in display cache → no recipients → no dispatch")
  void unknownMaker_noDispatch() {
    UUID draftId = UUID.randomUUID();
    OutboxEvent event = buildDraftApprovedEvent(draftId, "unknown-sub");
    when(outboxRepository.findRecentByEventTypes(any(), any(), any(Pageable.class))).thenReturn(List.of(event));

    when(recipientResolver.resolveById("unknown-sub")).thenReturn(Optional.empty());

    dispatcher.sendTick();

    assertThat(emailChannel.dispatchCalls).isEmpty();
    assertThat(notificationLogRepository.savedRows).isEmpty();
  }

  // --- helpers ---

  private OutboxEvent buildDraftApprovedEvent(UUID draftId, String makerId) {
    ObjectNode payload = new ObjectMapper().createObjectNode();
    payload.put("draftId", draftId.toString());
    payload.put("dictionaryName", "spacecraft");
    payload.put("businessKey", "SPUTNIK-1");
    payload.put("operation", "CREATE");
    payload.put("status", "APPROVED");
    payload.put("makerId", makerId);
    payload.put("reviewerId", "reviewer-sub-789");
    payload.put("reviewComment", "Looks good");
    payload.put("reviewedAt", "2026-05-14T10:00:00Z");

    return new OutboxEvent(
        "RecordDraftApproved",
        "RecordDraft",
        draftId.toString(),
        DataScope.PUBLIC,
        payload,
        "ordinis.cuod.events.public",
        "record-draft:" + draftId);
  }

  // --- Test doubles ---

  /**
   * Stub IdempotencyGuard — avoids mocking a Spring @Transactional component.
   * Delegates to a shared StubNotificationLogRepository.
   */
  static class StubIdempotencyGuard extends IdempotencyGuard {

    boolean simulateConflict = false;
    final AtomicInteger markedSentCount = new AtomicInteger(0);
    private final StubNotificationLogRepository repo;

    StubIdempotencyGuard(StubNotificationLogRepository repo) {
      super(repo);
      this.repo = repo;
    }

    @Override
    public Optional<NotificationLog> tryClaim(
        NotificationEvent event, ChannelKind channel, String recipient) {
      if (simulateConflict) return Optional.empty();

      NotificationLog row = new NotificationLog(
          event.eventId() != null ? event.eventId() : UUID.randomUUID(),
          event.eventType(),
          event.draft() != null ? event.draft().id() : null,
          channel.wireName(),
          recipient);
      repo.savedRows.add(row);
      return Optional.of(row);
    }

    @Override
    public void markSent(Long rowId) {
      markedSentCount.incrementAndGet();
    }

    @Override
    public void markFailed(Long rowId, String errorMsg) {}
  }

  /**
   * Stub NotificationLogRepository — in-memory list.
   */
  static class StubNotificationLogRepository implements NotificationLogRepository {

    final List<NotificationLog> savedRows = new ArrayList<>();
    private long nextId = 1L;

    @Override
    public Optional<Long> tryClaim(UUID eventId, String eventType, UUID draftId,
                                   String channel, String recipient) {
      // Used by real IdempotencyGuard; our stub overrides tryClaim directly
      return Optional.of(nextId++);
    }

    @Override
    public NotificationLog save(NotificationLog entity) {
      savedRows.add(entity);
      return entity;
    }

    @Override
    public Optional<NotificationLog> findById(Long aLong) {
      return savedRows.stream()
          .filter(r -> aLong.equals(r.getId()))
          .findFirst();
    }

    // --- unused stubs ---

    @Override public <S extends NotificationLog> List<S> saveAll(Iterable<S> entities) { return List.of(); }
    @Override public void flush() {}
    @Override public <S extends NotificationLog> S saveAndFlush(S entity) { return entity; }
    @Override public <S extends NotificationLog> List<S> saveAllAndFlush(Iterable<S> entities) { return List.of(); }
    @Override public void deleteAllInBatch(Iterable<NotificationLog> entities) {}
    @Override public void deleteAllByIdInBatch(Iterable<Long> longs) {}
    @Override public void deleteAllInBatch() {}
    @Override public NotificationLog getOne(Long aLong) { return null; }
    @Override public NotificationLog getById(Long aLong) { return null; }
    @Override public NotificationLog getReferenceById(Long aLong) { return null; }
    @Override public <S extends NotificationLog> Optional<S> findOne(org.springframework.data.domain.Example<S> example) { return Optional.empty(); }
    @Override public <S extends NotificationLog> List<S> findAll(org.springframework.data.domain.Example<S> example) { return List.of(); }
    @Override public <S extends NotificationLog> List<S> findAll(org.springframework.data.domain.Example<S> example, org.springframework.data.domain.Sort sort) { return List.of(); }
    @Override public <S extends NotificationLog> org.springframework.data.domain.Page<S> findAll(org.springframework.data.domain.Example<S> example, Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
    @Override public <S extends NotificationLog> long count(org.springframework.data.domain.Example<S> example) { return 0; }
    @Override public <S extends NotificationLog> boolean exists(org.springframework.data.domain.Example<S> example) { return false; }
    @Override public <S extends NotificationLog, R> R findBy(org.springframework.data.domain.Example<S> example, java.util.function.Function<org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery<S>, R> queryFunction) { return null; }
    @Override public boolean existsById(Long aLong) { return false; }
    @Override public List<NotificationLog> findAll() { return savedRows; }
    @Override public List<NotificationLog> findAllById(Iterable<Long> longs) { return List.of(); }
    @Override public long count() { return savedRows.size(); }
    @Override public void deleteById(Long aLong) {}
    @Override public void delete(NotificationLog entity) {}
    @Override public void deleteAllById(Iterable<? extends Long> longs) {}
    @Override public void deleteAll(Iterable<? extends NotificationLog> entities) {}
    @Override public void deleteAll() { savedRows.clear(); }
    @Override public List<NotificationLog> findAll(org.springframework.data.domain.Sort sort) { return savedRows; }
    @Override public org.springframework.data.domain.Page<NotificationLog> findAll(Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
    @Override public int deletePendingOlderThan(OffsetDateTime threshold) { return 0; }
    @Override public int deleteRetainedOlderThan(OffsetDateTime threshold) { return 0; }
    @Override public Optional<OffsetDateTime> findLastSentAt(String recipient, String channel) { return Optional.empty(); }
    @Override public org.springframework.data.domain.Page<NotificationLog> findByRecipientOrderBySentAtDesc(String recipient, Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
    @Override public org.springframework.data.domain.Page<NotificationLog> findByEventIdOrderBySentAtDesc(UUID eventId, Pageable pageable) { return org.springframework.data.domain.Page.empty(); }
  }

  /**
   * Recording EmailChannel — captures all dispatch calls for assertion.
   */
  static class RecordingEmailChannel implements NotificationChannel {

    record Call(NotificationEvent event, UserRef recipient, RenderedMessage msg) {}

    final List<Call> dispatchCalls = new ArrayList<>();

    @Override
    public ChannelKind kind() { return ChannelKind.EMAIL; }

    @Override
    public boolean isEnabled() { return true; }

    @Override
    public DispatchResult dispatch(NotificationEvent event, UserRef recipient, RenderedMessage msg) {
      dispatchCalls.add(new Call(event, recipient, msg));
      return DispatchResult.success();
    }
  }
}
