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

import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.DictionarySchemaDraft;
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftRepository;
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftStatus;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.CreateSchemaDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.DecisionRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SubmitForReviewRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.repository.query.FluentQuery;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * Unit tests {@link SchemaDraftService}.
 *
 * <p>JDK 25 Mockito incompat → in-memory stubs (см. record-level
 * DraftServiceTest, same pattern). Auth simулируем через
 * SecurityContextHolder set/clear в каждом тесте.
 */
class SchemaDraftServiceTest {

  private static final ObjectMapper OM = new ObjectMapper();

  private final UUID dictId = UUID.randomUUID();
  private DictionaryDefinition def;
  private DictionaryDefinitionService defService;
  private StubSchemaDraftRepo repo;
  private OutboxRecorder outbox;
  private SchemaDraftService service;
  private AtomicReference<String> lastEventType;

  @BeforeEach
  void setUp() throws Exception {
    def = new DictionaryDefinition(dictId, "test_dict", DataScope.PUBLIC,
        OM.readTree("{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}"));
    def.setSchemaVersion("1.4.0");
    def.setBundle("test");

    defService = new DictionaryDefinitionService(null, null) {
      @Override
      public DictionaryDefinition findByName(String name) {
        if ("test_dict".equals(name)) return def;
        throw OrdinisException.notFound("dictionary_not_found", "no: " + name);
      }
      @Override
      public DictionaryDefinition findById(UUID id) {
        if (id.equals(dictId)) return def;
        throw OrdinisException.notFound("dictionary_not_found", "no id: " + id);
      }
    };
    repo = new StubSchemaDraftRepo();
    lastEventType = new AtomicReference<>();
    // ObjectProvider<Tracer> stub — always returns null чтобы skip tracing path в record().
    org.springframework.beans.factory.ObjectProvider<io.micrometer.tracing.Tracer> nullTracer =
        new StubTracerProvider();
    outbox = new OutboxRecorder(null, OM, nullTracer) {
      @Override
      public cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent record(
          String eventType, String aggregateType, String aggregateId,
          String dictionaryName, DataScope scope, String bundle,
          String dedupKey, Object envelope) {
        lastEventType.set(eventType);
        return null;
      }
    };
    service = new SchemaDraftService(repo, defService, outbox, OM);
    setUser("alice");
  }

  @AfterEach
  void tearDown() {
    SecurityContextHolder.clearContext();
  }

  // ─── create() ──────────────────────────────────────────────────────

  @Test
  void create_happyPath() throws Exception {
    JsonNode newSchema = OM.readTree("{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"description\":{\"type\":\"string\"}}}");
    var draft = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "add description field", newSchema));

    assertThat(draft.getStatus()).isEqualTo(SchemaDraftStatus.DRAFT);
    assertThat(draft.getMakerId()).isEqualTo("alice");
    assertThat(draft.getBranchedFromVersion()).isEqualTo("1.4.0");
    assertThat(draft.getReason()).isEqualTo("add description field");
    assertThat(lastEventType.get()).isEqualTo("SchemaDraftCreated");
  }

  @Test
  void create_versionMismatch_throws409() throws Exception {
    JsonNode newSchema = OM.readTree("{}");
    assertThatThrownBy(() -> service.create("test_dict",
        new CreateSchemaDraftRequest("1.3.0", "stale", newSchema)))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("version_mismatch"));
  }

  @Test
  void create_activeDraftExists_throws409() throws Exception {
    JsonNode newSchema = OM.readTree("{}");
    service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "first", newSchema));

    assertThatThrownBy(() -> service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "second", newSchema)))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("active_draft_exists"));
  }

  // ─── submitForReview() ─────────────────────────────────────────────

  @Test
  void submitForReview_draftToReviewPending() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));

    var submitted = service.submitForReview(created.getId(),
        new SubmitForReviewRequest(List.of("bob", "carol"), "check name field"));

    assertThat(submitted.getStatus()).isEqualTo(SchemaDraftStatus.REVIEW_PENDING);
    assertThat(submitted.getSubmittedAt()).isNotNull();
    assertThat(submitted.getReviewNote()).isEqualTo("check name field");
    assertThat(lastEventType.get()).isEqualTo("SchemaDraftReviewRequested");
  }

  @Test
  void submitForReview_nullReviewers_uses_pool() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    var submitted = service.submitForReview(created.getId(),
        new SubmitForReviewRequest(null, null));
    assertThat(submitted.getReviewerAssignments()).isNull();
  }

  @Test
  void submitForReview_nonMaker_forbidden() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    setUser("bob");
    assertThatThrownBy(() -> service.submitForReview(created.getId(),
        new SubmitForReviewRequest(null, null)))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("maker_only"));
  }

  @Test
  void submitForReview_alreadyApproved_invalidTransition() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
    setUser("bob");
    service.decide(created.getId(), new DecisionRequest(DecisionRequest.Verdict.APPROVE, "ok"));

    setUser("alice");
    assertThatThrownBy(() -> service.submitForReview(created.getId(),
        new SubmitForReviewRequest(null, null)))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("invalid_transition"));
  }

  @Test
  void submitForReview_resubmitAfterChangesRequested() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
    setUser("bob");
    service.decide(created.getId(), new DecisionRequest(
        DecisionRequest.Verdict.REQUEST_CHANGES, "rename"));

    setUser("alice");
    var resub = service.submitForReview(created.getId(),
        new SubmitForReviewRequest(null, "fixed"));
    assertThat(resub.getStatus()).isEqualTo(SchemaDraftStatus.REVIEW_PENDING);
  }

  // ─── decide() ──────────────────────────────────────────────────────

  @Test
  void decide_approve() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
    setUser("bob");

    var decided = service.decide(created.getId(),
        new DecisionRequest(DecisionRequest.Verdict.APPROVE, "ok"));

    assertThat(decided.getStatus()).isEqualTo(SchemaDraftStatus.APPROVED);
    assertThat(decided.getDecisionUserId()).isEqualTo("bob");
    assertThat(decided.getDecisionComment()).isEqualTo("ok");
    assertThat(lastEventType.get()).isEqualTo("SchemaDraftDecided");
  }

  @Test
  void decide_selfApprove_forbidden() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
    // Still alice (maker) — should be forbidden.

    assertThatThrownBy(() -> service.decide(created.getId(),
        new DecisionRequest(DecisionRequest.Verdict.APPROVE, "self")))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("self_approve_forbidden"));
  }

  @Test
  void decide_notInReview_invalidTransition() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    setUser("bob");
    assertThatThrownBy(() -> service.decide(created.getId(),
        new DecisionRequest(DecisionRequest.Verdict.APPROVE, "")))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("invalid_transition"));
  }

  @Test
  void decide_requestChanges() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
    setUser("bob");

    var d = service.decide(created.getId(),
        new DecisionRequest(DecisionRequest.Verdict.REQUEST_CHANGES, "rename"));
    assertThat(d.getStatus()).isEqualTo(SchemaDraftStatus.CHANGES_REQUESTED);
  }

  @Test
  void decide_reject() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
    setUser("bob");

    var d = service.decide(created.getId(),
        new DecisionRequest(DecisionRequest.Verdict.REJECT, "bad idea"));
    assertThat(d.getStatus()).isEqualTo(SchemaDraftStatus.REJECTED);
  }

  // ─── publish() ─────────────────────────────────────────────────────

  @Test
  void publish_happyPath_bumpsMinor() throws Exception {
    JsonNode newSchema = OM.readTree("{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"description\":{\"type\":\"string\"}}}");
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "add description", newSchema));
    service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
    setUser("bob");
    service.decide(created.getId(), new DecisionRequest(DecisionRequest.Verdict.APPROVE, "ok"));

    var published = service.publish(created.getId(),
        new PublishDraftRequest("1.4.0", "added optional description"));

    assertThat(published.getStatus()).isEqualTo(SchemaDraftStatus.PUBLISHED);
    assertThat(published.getPublishedVersion()).isEqualTo("1.5.0");
    assertThat(def.getSchemaVersion()).isEqualTo("1.5.0");
    assertThat(def.getSchemaJson()).isEqualTo(newSchema);
    assertThat(lastEventType.get()).isEqualTo("SchemaDraftPublished");
  }

  @Test
  void publish_versionMismatch_throws409() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
    setUser("bob");
    service.decide(created.getId(), new DecisionRequest(DecisionRequest.Verdict.APPROVE, ""));

    assertThatThrownBy(() -> service.publish(created.getId(),
        new PublishDraftRequest("1.3.0", "stale lock")))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("version_mismatch"));
  }

  @Test
  void publish_notApproved_invalidTransition() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    // Skip submit + approve — still in DRAFT state.
    assertThatThrownBy(() -> service.publish(created.getId(),
        new PublishDraftRequest("1.4.0", "skip queue")))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("invalid_transition"));
  }

  @Test
  void publish_breakingChange_bumpsMajor() throws Exception {
    // Old schema has required 'name' of type 'string'. New schema changes type → number.
    JsonNode breaking = OM.readTree(
        "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"number\"}}}");
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "type change", breaking));
    service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
    setUser("bob");
    service.decide(created.getId(), new DecisionRequest(DecisionRequest.Verdict.APPROVE, "ok"));

    var published = service.publish(created.getId(),
        new PublishDraftRequest("1.4.0", "breaking type change"));
    assertThat(published.getPublishedVersion()).isEqualTo("2.0.0");
  }

  // ─── withdraw() ────────────────────────────────────────────────────

  @Test
  void withdraw_happyPath() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    var withdrawn = service.withdraw(created.getId());
    assertThat(withdrawn.getStatus()).isEqualTo(SchemaDraftStatus.WITHDRAWN);
    assertThat(lastEventType.get()).isEqualTo("SchemaDraftWithdrawn");
  }

  @Test
  void withdraw_terminalStatus_invalidTransition() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
    setUser("bob");
    service.decide(created.getId(), new DecisionRequest(DecisionRequest.Verdict.REJECT, "no"));

    setUser("alice");
    assertThatThrownBy(() -> service.withdraw(created.getId()))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("invalid_transition"));
  }

  @Test
  void withdraw_nonMaker_forbidden() throws Exception {
    var created = service.create("test_dict",
        new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
    setUser("bob");
    assertThatThrownBy(() -> service.withdraw(created.getId()))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("maker_only"));
  }

  // ─── helpers ───────────────────────────────────────────────────────

  private static void setUser(String username) {
    SecurityContextHolder.getContext().setAuthentication(
        new UsernamePasswordAuthenticationToken(username, "n/a", List.of()));
  }

  /** ObjectProvider stub — нужен ctor'у OutboxRecorder; всегда возвращает null tracer. */
  private static class StubTracerProvider
      implements org.springframework.beans.factory.ObjectProvider<io.micrometer.tracing.Tracer> {
    @Override public io.micrometer.tracing.Tracer getObject() { return null; }
    @Override public io.micrometer.tracing.Tracer getObject(Object... args) { return null; }
    @Override public io.micrometer.tracing.Tracer getIfAvailable() { return null; }
    @Override public io.micrometer.tracing.Tracer getIfUnique() { return null; }
  }

  /** In-memory stub repo with EXCLUDE-like active-draft constraint. */
  private static class StubSchemaDraftRepo implements SchemaDraftRepository {
    private final java.util.Map<UUID, DictionarySchemaDraft> store = new java.util.LinkedHashMap<>();

    @Override
    public <S extends DictionarySchemaDraft> S save(S entity) {
      // Mimic EXCLUDE constraint: max one active draft per dict.
      var active = findActiveByDictionary(entity.getDictionaryId());
      if (active.isPresent() && !active.get().getId().equals(entity.getId())) {
        throw new org.springframework.dao.DataIntegrityViolationException(
            "schema_drafts_one_active_per_dict");
      }
      store.put(entity.getId(), entity);
      return entity;
    }

    @Override
    public Optional<DictionarySchemaDraft> findById(UUID id) {
      return Optional.ofNullable(store.get(id));
    }

    @Override
    public Optional<DictionarySchemaDraft> findActiveByDictionary(UUID dictionaryId) {
      return store.values().stream()
          .filter(d -> d.getDictionaryId().equals(dictionaryId) && d.getStatus().isActive())
          .findFirst();
    }

    @Override
    public List<DictionarySchemaDraft> findByDictionary(UUID dictionaryId) {
      return store.values().stream()
          .filter(d -> d.getDictionaryId().equals(dictionaryId))
          .toList();
    }

    @Override
    public Page<DictionarySchemaDraft> findPendingReview(Pageable pageable) {
      var list = store.values().stream()
          .filter(d -> d.getStatus() == SchemaDraftStatus.REVIEW_PENDING)
          .toList();
      return new PageImpl<>(list, pageable, list.size());
    }

    @Override
    public Page<DictionarySchemaDraft> findByMaker(String makerId, Pageable pageable) {
      var list = store.values().stream()
          .filter(d -> d.getMakerId().equals(makerId))
          .toList();
      return new PageImpl<>(list, pageable, list.size());
    }

    // Unused JpaRepository methods stubbed.
    @Override public DictionarySchemaDraft saveAndFlush(DictionarySchemaDraft d) { return save(d); }
    @Override public <S extends DictionarySchemaDraft> List<S> saveAll(Iterable<S> entities) {
      List<S> out = new ArrayList<>();
      for (S e : entities) out.add(save(e));
      return out;
    }
    @Override public <S extends DictionarySchemaDraft> List<S> saveAllAndFlush(Iterable<S> entities) { return saveAll(entities); }
    @Override public List<DictionarySchemaDraft> findAll() { return store.values().stream().toList(); }
    @Override public List<DictionarySchemaDraft> findAll(Sort sort) { return findAll(); }
    @Override public Page<DictionarySchemaDraft> findAll(Pageable pageable) { throw new UnsupportedOperationException(); }
    @Override public List<DictionarySchemaDraft> findAllById(Iterable<UUID> ids) { throw new UnsupportedOperationException(); }
    @Override public long count() { return store.size(); }
    @Override public void deleteById(UUID id) { store.remove(id); }
    @Override public void delete(DictionarySchemaDraft entity) { store.remove(entity.getId()); }
    @Override public void deleteAllById(Iterable<? extends UUID> ids) { ids.forEach(store::remove); }
    @Override public void deleteAll(Iterable<? extends DictionarySchemaDraft> entities) { entities.forEach(this::delete); }
    @Override public void deleteAll() { store.clear(); }
    @Override public boolean existsById(UUID id) { return store.containsKey(id); }
    @Override public void flush() {}
    @Override public void deleteAllInBatch(Iterable<DictionarySchemaDraft> entities) { deleteAll(entities); }
    @Override public void deleteAllByIdInBatch(Iterable<UUID> ids) { deleteAllById(ids); }
    @Override public void deleteAllInBatch() { deleteAll(); }
    @Override public DictionarySchemaDraft getOne(UUID id) { return store.get(id); }
    @Override public DictionarySchemaDraft getById(UUID id) { return store.get(id); }
    @Override public DictionarySchemaDraft getReferenceById(UUID id) { return store.get(id); }
    @Override public <S extends DictionarySchemaDraft> List<S> findAll(Example<S> ex) { throw new UnsupportedOperationException(); }
    @Override public <S extends DictionarySchemaDraft> List<S> findAll(Example<S> ex, Sort s) { throw new UnsupportedOperationException(); }
    @Override public <S extends DictionarySchemaDraft> Page<S> findAll(Example<S> ex, Pageable p) { throw new UnsupportedOperationException(); }
    @Override public <S extends DictionarySchemaDraft> long count(Example<S> ex) { return 0; }
    @Override public <S extends DictionarySchemaDraft> boolean exists(Example<S> ex) { return false; }
    @Override public <S extends DictionarySchemaDraft> Optional<S> findOne(Example<S> ex) { return Optional.empty(); }
    @Override public <S extends DictionarySchemaDraft, R> R findBy(Example<S> ex, Function<FluentQuery.FetchableFluentQuery<S>, R> fn) { throw new UnsupportedOperationException(); }
  }
}
