package cloud.nstart.terravault.ordinis.restapi.service;

import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLog;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Pageable;

import java.util.Optional;

import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class ChangelogServiceTest {

  private static final ObjectMapper OM = new ObjectMapper();

  private AuditLogRepository auditRepo;
  private DictionaryDefinitionRepository defRepo;
  private DictionaryDefinitionService defService;
  private TestScopeContext scopeCtx;
  private ChangelogService service;

  @BeforeEach
  void setUp() {
    auditRepo = mock(AuditLogRepository.class);
    defRepo = mock(DictionaryDefinitionRepository.class);
    // Real service с mocked repo — Mockito не может subclass'ить
    // DictionaryDefinitionService на JDK 25, поэтому строим реальный.
    // OutboxRecorder=null — findChangelog не вызывает outbox path.
    defService = new DictionaryDefinitionService(defRepo, null);
    // Test stub для ScopeContext (Mockito не subclass'ит её на JDK 25).
    scopeCtx = new TestScopeContext();
    service = new ChangelogService(auditRepo, defService, scopeCtx);
  }

  /** Test-friendly ScopeContext subclass — позволяет программируемо tweak
   *  результат canAccess'а без Mockito (incompatible на JDK 25). */
  static final class TestScopeContext extends ScopeContext {
    private DataScope blockedScope;
    TestScopeContext() { super(null); }
    @Override public boolean canAccess(DataScope dataScope) {
      return dataScope != blockedScope;
    }
    @Override public java.util.Set<DataScope> currentScopes() {
      return java.util.Set.of(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
    }
    void block(DataScope s) { this.blockedScope = s; }
  }

  @Test
  void changelog_dictNotFound_404() {
    when(defRepo.findByName("missing")).thenReturn(Optional.empty());
    assertThatThrownBy(() -> service.findChangelog("missing", 50))
        .isInstanceOf(OrdinisException.class);
  }

  @Test
  void changelog_scopeHidden_404() {
    var d = def("private", DataScope.RESTRICTED);
    when(defRepo.findByName("private")).thenReturn(Optional.of(d));
    scopeCtx.block(DataScope.RESTRICTED);
    assertThatThrownBy(() -> service.findChangelog("private", 50))
        .isInstanceOf(OrdinisException.class);
  }

  @Test
  void changelog_emptyAudit_returnsEmptyEntries() {
    var d = def("dict", DataScope.PUBLIC);
    when(defRepo.findByName("dict")).thenReturn(Optional.of(d));
    when(auditRepo.findByDictionaryIdAndEventTypes(any(), anyList(), any()))
        .thenReturn(List.of());

    var result = service.findChangelog("dict", 50);

    assertThat(result.dictionary()).isEqualTo("dict");
    assertThat(result.currentVersion()).isEqualTo("1.0.0");
    assertThat(result.entries()).isEmpty();
  }

  @Test
  void changelog_firstEntryIsCurrent() throws Exception {
    var d = def("dict", DataScope.PUBLIC);
    d.setSchemaVersion("1.3.2");
    when(defRepo.findByName("dict")).thenReturn(Optional.of(d));

    var newer = audit(d.getId(), "DEFINITION_UPDATED", "u1",
        OffsetDateTime.parse("2026-05-01T10:00:00Z"),
        OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}"),
        OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}"));
    var older = audit(d.getId(), "DEFINITION_CREATED", "u0",
        OffsetDateTime.parse("2026-04-01T10:00:00Z"),
        null,
        OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}"));
    when(auditRepo.findByDictionaryIdAndEventTypes(any(), anyList(), any(Pageable.class)))
        .thenReturn(List.of(newer, older));

    var result = service.findChangelog("dict", 50);

    assertThat(result.entries()).hasSize(2);
    assertThat(result.entries().get(0).isCurrent()).isTrue();
    assertThat(result.entries().get(0).version()).isEqualTo("1.3.2");
    assertThat(result.entries().get(1).isCurrent()).isFalse();
    assertThat(result.entries().get(1).version()).isNull();
  }

  // === Diff computation ===

  @Test
  void diff_addedKey() throws Exception {
    JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
    JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}");
    var diff = ChangelogService.computeDiff(before, after);
    assertThat(diff.added()).containsExactly("b");
    assertThat(diff.removed()).isEmpty();
    assertThat(diff.changed()).isEmpty();
  }

  @Test
  void diff_removedKey() throws Exception {
    JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}");
    JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
    var diff = ChangelogService.computeDiff(before, after);
    assertThat(diff.removed()).containsExactly("b");
    assertThat(diff.added()).isEmpty();
    assertThat(diff.changed()).isEmpty();
  }

  @Test
  void diff_changedKey() throws Exception {
    JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
    JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"integer\"}}}");
    var diff = ChangelogService.computeDiff(before, after);
    assertThat(diff.changed()).containsExactly("a");
    assertThat(diff.added()).isEmpty();
    assertThat(diff.removed()).isEmpty();
  }

  @Test
  void diff_nullBefore_treatedAsCreation() throws Exception {
    JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}");
    var diff = ChangelogService.computeDiff(null, after);
    assertThat(diff.added()).containsExactlyInAnyOrder("a", "b");
    assertThat(diff.removed()).isEmpty();
    assertThat(diff.changed()).isEmpty();
  }

  @Test
  void diff_identicalSchemas_emptyDiff() throws Exception {
    JsonNode schema = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
    var diff = ChangelogService.computeDiff(schema, schema);
    assertThat(diff.added()).isEmpty();
    assertThat(diff.removed()).isEmpty();
    assertThat(diff.changed()).isEmpty();
  }

  // === findEntryDiff(name, entryId) ==========================================

  @Test
  void findEntryDiff_returnsFullBeforeAfter() throws Exception {
    DictionaryDefinition d = def("dict_diff", DataScope.PUBLIC);
    when(defRepo.findByName("dict_diff")).thenReturn(Optional.of(d));
    JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
    JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"number\"}}}");
    AuditLog row = audit(d.getId(), "DEFINITION_UPDATED", "alice",
        OffsetDateTime.parse("2026-05-12T10:00:00Z"), before, after);
    // Stub id reflection — AuditLog setId is package-private, обходим через
    // мокинг findById напрямую возвращая row с whatever id audit_log хранит.
    when(auditRepo.findById(42L)).thenReturn(Optional.of(row));

    var diff = service.findEntryDiff("dict_diff", 42L);
    assertThat(diff.dictionaryName()).isEqualTo("dict_diff");
    assertThat(diff.eventType()).isEqualTo("DEFINITION_UPDATED");
    assertThat(diff.kind()).isEqualTo("definition_update");
    assertThat(diff.before()).isEqualTo(before);
    assertThat(diff.after()).isEqualTo(after);
    assertThat(diff.summary().added()).containsExactly("b");
  }

  @Test
  void findEntryDiff_otherDict_throws404() throws Exception {
    DictionaryDefinition d = def("dict_X", DataScope.PUBLIC);
    when(defRepo.findByName("dict_X")).thenReturn(Optional.of(d));
    UUID otherDictId = UUID.randomUUID();
    AuditLog row = audit(otherDictId, "DEFINITION_UPDATED", "alice",
        OffsetDateTime.now(), null, null);
    when(auditRepo.findById(7L)).thenReturn(Optional.of(row));

    assertThatThrownBy(() -> service.findEntryDiff("dict_X", 7L))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("changelog_entry_not_found"));
  }

  @Test
  void findEntryDiff_nonSchemaEvent_throws404() throws Exception {
    DictionaryDefinition d = def("dict_R", DataScope.PUBLIC);
    when(defRepo.findByName("dict_R")).thenReturn(Optional.of(d));
    // Record-level event (RECORD_CREATED) — diff endpoint только для schema timeline.
    AuditLog row = audit(d.getId(), "RECORD_CREATED", "alice",
        OffsetDateTime.now(), null, null);
    when(auditRepo.findById(99L)).thenReturn(Optional.of(row));

    assertThatThrownBy(() -> service.findEntryDiff("dict_R", 99L))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("changelog_entry_not_found"));
  }

  @Test
  void findEntryDiff_missingId_throws404() throws Exception {
    DictionaryDefinition d = def("dict_M", DataScope.PUBLIC);
    when(defRepo.findByName("dict_M")).thenReturn(Optional.of(d));
    when(auditRepo.findById(404L)).thenReturn(Optional.empty());

    assertThatThrownBy(() -> service.findEntryDiff("dict_M", 404L))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("changelog_entry_not_found"));
  }

  // === Helpers ===

  private static DictionaryDefinition def(String name, DataScope scope) {
    JsonNode schema;
    try {
      schema = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
    return new DictionaryDefinition(UUID.randomUUID(), name, scope, schema);
  }

  private static AuditLog audit(
      UUID dictId, String eventType, String userId,
      OffsetDateTime at, JsonNode before, JsonNode after) {
    AuditLog row = new AuditLog();
    row.setDictionaryId(dictId);
    row.setEventType(eventType);
    row.setUserId(userId);
    row.setEventTime(at);
    row.setPayloadBefore(before);
    row.setPayloadAfter(after);
    return row;
  }
}
