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

import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation;
import cloud.nstart.terravault.ordinis.domain.draft.DraftStatus;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraft;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraftRepository;
import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;

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

/**
 * Unit tests {@link DraftService} Phase 0 skeleton.
 *
 * <p>JDK 25 + Mockito incompat для concrete classes — используем anonymous
 * subclass для DictionaryDefinitionService и in-memory stub repository.
 */
class DraftServiceTest {

  private static final ObjectMapper OM = new ObjectMapper();

  private final UUID dictId = UUID.randomUUID();
  private DictionaryDefinitionService defService;
  private StubDraftRepo draftRepo;
  private ScopeContext scopeContext;
  private DraftService service;

  @BeforeEach
  void setUp() {
    DictionaryDefinition def = new DictionaryDefinition(
        dictId, "test_dict", DataScope.PUBLIC, OM.createObjectNode());
    def.setApprovalRequired(true);
    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);
      }
    };
    draftRepo = new StubDraftRepo();
    scopeContext = new ScopeContext(null) {
      @Override
      public Set<DataScope> currentScopes() {
        return Set.of(DataScope.PUBLIC);
      }
    };
    service = new DraftService(draftRepo, defService, scopeContext);
  }

  @Test
  void submit_createsDraftWithPendingStatus() throws Exception {
    var req = new SubmitDraftRequest(
        "BK-001", DraftOperation.CREATE,
        OM.readTree("{\"name\":\"test\"}"),
        null, null, null, "initial submit");

    RecordDraft saved = service.submit("test_dict", req);

    assertThat(saved.getStatus()).isEqualTo(DraftStatus.PENDING);
    assertThat(saved.getDictionaryId()).isEqualTo(dictId);
    assertThat(saved.getBusinessKey()).isEqualTo("BK-001");
    assertThat(saved.getOperation()).isEqualTo(DraftOperation.CREATE);
    assertThat(saved.getMakerComment()).isEqualTo("initial submit");
    // makerId = "anonymous" если auth context пустой (test env).
    assertThat(saved.getMakerId()).isEqualTo("anonymous");
    assertThat(saved.getSubmittedAt()).isNotNull();
  }

  @Test
  void submit_409WhenPendingExists() throws Exception {
    var req = new SubmitDraftRequest(
        "BK-001", DraftOperation.CREATE,
        OM.readTree("{}"), null, null, null, null);
    service.submit("test_dict", req); // first OK

    // Stub raises DataIntegrityViolationException на second insert с тем же business_key.
    assertThatThrownBy(() -> service.submit("test_dict", req))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("draft_already_pending"));
  }

  @Test
  void submit_invalidGeometryWkt_throwsBadRequest() throws Exception {
    var req = new SubmitDraftRequest(
        "BK-001", DraftOperation.CREATE,
        OM.readTree("{}"),
        "NOT-A-WKT-STRING",
        null, null, null);

    assertThatThrownBy(() -> service.submit("test_dict", req))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("invalid_geometry_wkt"));
  }

  @Test
  void submit_dictNotFound_throwsNotFound() throws Exception {
    var req = new SubmitDraftRequest(
        "BK-001", DraftOperation.CREATE,
        OM.readTree("{}"), null, null, null, null);

    assertThatThrownBy(() -> service.submit("missing_dict", req))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("dictionary_not_found"));
  }

  @Test
  void findById_unknownThrowsNotFound() {
    UUID id = UUID.randomUUID();
    assertThatThrownBy(() -> service.findById(id))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("draft_not_found"));
  }

  // === Phase 1 lifecycle tests ===

  /** Stub recordService — overrides applyApproved* to record calls без real DB. */
  private static class TrackingRecordService extends DictionaryRecordService {
    java.util.List<String> calls = new java.util.ArrayList<>();
    TrackingRecordService() { super(null, null, null, null, null, null, null); }
    @Override public cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord
        applyApprovedCreate(String dict, cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest req) {
      calls.add("CREATE:" + dict + ":" + req.businessKey()); return null;
    }
    @Override public cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord
        applyApprovedUpdate(String dict, String bk, cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest req) {
      calls.add("UPDATE:" + dict + ":" + bk); return null;
    }
    @Override public void applyApprovedClose(
        String dict, String bk, java.time.OffsetDateTime closeAt, String reason) {
      calls.add("CLOSE:" + dict + ":" + bk + ":" + reason);
    }
  }

  private DraftService makeServiceWithRecord(TrackingRecordService rs) {
    return new DraftService(draftRepo, defService, rs, scopeContext);
  }

  private static void setAuth(String userId) {
    var auth = new UsernamePasswordAuthenticationToken(userId, "n/a", java.util.List.of());
    SecurityContextHolder.getContext().setAuthentication(auth);
  }

  private static void clearAuth() { SecurityContextHolder.clearContext(); }

  @Test
  void approve_dispatchesCorrectApplyMethodPerOperation() throws Exception {
    var rs = new TrackingRecordService();
    var svc = makeServiceWithRecord(rs);
    // Maker = makerA submits. Reviewer = reviewerB approves.
    setAuth("makerA");
    RecordDraft created = svc.submit("test_dict", new SubmitDraftRequest(
        "BK-CREATE", DraftOperation.CREATE,
        OM.readTree("{\"name\":\"x\"}"), null, null, null, null));
    setAuth("reviewerB");
    var approved = svc.approve(created.getId(), "looks good");

    assertThat(approved.getStatus()).isEqualTo(DraftStatus.APPROVED);
    assertThat(approved.getReviewerId()).isEqualTo("reviewerB");
    assertThat(approved.getReviewComment()).isEqualTo("looks good");
    assertThat(rs.calls).contains("CREATE:test_dict:BK-CREATE");
    clearAuth();
  }

  @Test
  void approve_blockedWhenSelfApprove() throws Exception {
    var rs = new TrackingRecordService();
    var svc = makeServiceWithRecord(rs);
    setAuth("sameUser");
    RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
        "BK-X", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
    // Same user тries to approve — должно блокироваться.
    assertThatThrownBy(() -> svc.approve(d.getId(), "self"))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("self_approve_forbidden"));
    assertThat(rs.calls).isEmpty();
    clearAuth();
  }

  @Test
  void approve_409IfDraftNotPending() throws Exception {
    var rs = new TrackingRecordService();
    var svc = makeServiceWithRecord(rs);
    setAuth("makerA");
    RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
        "BK-Y", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
    setAuth("reviewerB");
    svc.approve(d.getId(), "ok"); // first approve OK
    // Second approve same draft — already APPROVED, cannot re-approve.
    assertThatThrownBy(() -> svc.approve(d.getId(), "again"))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("draft_not_pending"));
    clearAuth();
  }

  @Test
  void reject_marksDraftRejectedWithReason() throws Exception {
    var rs = new TrackingRecordService();
    var svc = makeServiceWithRecord(rs);
    setAuth("makerA");
    RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
        "BK-R", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
    setAuth("reviewerB");
    var rej = svc.reject(d.getId(), "missing data");

    assertThat(rej.getStatus()).isEqualTo(DraftStatus.REJECTED);
    assertThat(rej.getReviewerId()).isEqualTo("reviewerB");
    assertThat(rej.getReviewComment()).isEqualTo("missing data");
    assertThat(rs.calls).isEmpty(); // никаких applyApproved calls.
    clearAuth();
  }

  @Test
  void reject_400IfReasonBlank() throws Exception {
    var rs = new TrackingRecordService();
    var svc = makeServiceWithRecord(rs);
    setAuth("makerA");
    RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
        "BK-R2", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
    setAuth("reviewerB");
    assertThatThrownBy(() -> svc.reject(d.getId(), ""))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("reject_reason_required"));
    clearAuth();
  }

  @Test
  void withdraw_marksDraftWithdrawnByMaker() throws Exception {
    var rs = new TrackingRecordService();
    var svc = makeServiceWithRecord(rs);
    setAuth("makerA");
    RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
        "BK-W", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
    var wd = svc.withdraw(d.getId());

    assertThat(wd.getStatus()).isEqualTo(DraftStatus.WITHDRAWN);
    clearAuth();
  }

  @Test
  void withdraw_403IfNotMaker() throws Exception {
    var rs = new TrackingRecordService();
    var svc = makeServiceWithRecord(rs);
    setAuth("makerA");
    RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
        "BK-W2", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
    setAuth("notMaker");
    assertThatThrownBy(() -> svc.withdraw(d.getId()))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getCode())
            .isEqualTo("not_draft_maker"));
    clearAuth();
  }

  @Test
  void listPendingByDictionary_returnsOnlyPendingForThisDict() throws Exception {
    service.submit("test_dict", new SubmitDraftRequest(
        "BK-001", DraftOperation.CREATE, OM.readTree("{}"),
        null, null, null, null));
    // Another business_key — should also be pending.
    service.submit("test_dict", new SubmitDraftRequest(
        "BK-002", DraftOperation.CREATE, OM.readTree("{}"),
        null, null, null, null));

    var list = service.listPendingByDictionary("test_dict");
    assertThat(list).hasSize(2);
    assertThat(list).allSatisfy(d ->
        assertThat(d.getStatus()).isEqualTo(DraftStatus.PENDING));
  }

  // === In-memory stub repo ===

  /** Mimics DB exclusion constraint: one_pending_per_key per (dict, key). */
  private static class StubDraftRepo implements RecordDraftRepository {
    private final java.util.Map<UUID, RecordDraft> store = new java.util.LinkedHashMap<>();

    @Override
    public RecordDraft saveAndFlush(RecordDraft draft) {
      // Simulate exclusion constraint.
      boolean conflict = store.values().stream().anyMatch(d ->
          d.getStatus() == DraftStatus.PENDING
              && d.getDictionaryId().equals(draft.getDictionaryId())
              && d.getBusinessKey().equals(draft.getBusinessKey())
              && !d.getId().equals(draft.getId()));
      if (conflict) {
        // Wrap a SQLException with SQLState 23P01 — DraftService.submit
        // distinguishes exclusion violations from other DataIntegrity errors
        // by walking the cause chain (Phase 3 fix: don't blanket-mask check
        // constraint failures as draft_already_pending).
        java.sql.SQLException sql = new java.sql.SQLException(
            "exclusion violation", "23P01");
        throw new DataIntegrityViolationException(
            "record_drafts_one_pending_per_key", sql);
      }
      store.put(draft.getId(), draft);
      return draft;
    }

    @Override
    public <S extends RecordDraft> S save(S entity) {
      saveAndFlush(entity);
      return entity;
    }

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

    @Override
    public Optional<RecordDraft> findPendingByKey(UUID dictionaryId, String businessKey) {
      return store.values().stream()
          .filter(d -> d.getStatus() == DraftStatus.PENDING
              && d.getDictionaryId().equals(dictionaryId)
              && d.getBusinessKey().equals(businessKey))
          .findFirst();
    }

    @Override
    public org.springframework.data.domain.Page<RecordDraft> findPending(
        org.springframework.data.domain.Pageable pageable) {
      var list = store.values().stream()
          .filter(d -> d.getStatus() == DraftStatus.PENDING)
          .toList();
      return new org.springframework.data.domain.PageImpl<>(list, pageable, list.size());
    }

    @Override
    public java.util.List<RecordDraft> findPendingByDictionary(UUID dictionaryId) {
      return store.values().stream()
          .filter(d -> d.getStatus() == DraftStatus.PENDING
              && d.getDictionaryId().equals(dictionaryId))
          .toList();
    }

    @Override
    public long countPending() {
      return store.values().stream()
          .filter(d -> d.getStatus() == DraftStatus.PENDING)
          .count();
    }

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

    // Unused JpaRepository methods stubbed.
    @Override public java.util.List<RecordDraft> findAll() { return store.values().stream().toList(); }
    @Override public java.util.List<RecordDraft> findAll(org.springframework.data.domain.Sort sort) { return findAll(); }
    @Override public org.springframework.data.domain.Page<RecordDraft> findAll(org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException(); }
    @Override public java.util.List<RecordDraft> 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(RecordDraft entity) { store.remove(entity.getId()); }
    @Override public void deleteAllById(Iterable<? extends UUID> ids) { ids.forEach(store::remove); }
    @Override public void deleteAll(Iterable<? extends RecordDraft> entities) { entities.forEach(this::delete); }
    @Override public void deleteAll() { store.clear(); }
    @Override public boolean existsById(UUID id) { return store.containsKey(id); }
    @Override public <S extends RecordDraft> java.util.List<S> saveAll(Iterable<S> entities) {
      java.util.List<S> out = new java.util.ArrayList<>();
      for (S e : entities) out.add(save(e));
      return out;
    }
    @Override public <S extends RecordDraft> java.util.List<S> saveAllAndFlush(Iterable<S> entities) { return saveAll(entities); }
    @Override public void flush() {}
    @Override public void deleteAllInBatch(Iterable<RecordDraft> entities) { deleteAll(entities); }
    @Override public void deleteAllByIdInBatch(Iterable<UUID> ids) { deleteAllById(ids); }
    @Override public void deleteAllInBatch() { deleteAll(); }
    @Override public RecordDraft getOne(UUID id) { return store.get(id); }
    @Override public RecordDraft getById(UUID id) { return store.get(id); }
    @Override public RecordDraft getReferenceById(UUID id) { return store.get(id); }
    @Override public <S extends RecordDraft> java.util.List<S> findAll(org.springframework.data.domain.Example<S> example) { throw new UnsupportedOperationException(); }
    @Override public <S extends RecordDraft> java.util.List<S> findAll(org.springframework.data.domain.Example<S> example, org.springframework.data.domain.Sort sort) { throw new UnsupportedOperationException(); }
    @Override public <S extends RecordDraft> org.springframework.data.domain.Page<S> findAll(org.springframework.data.domain.Example<S> example, org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException(); }
    @Override public <S extends RecordDraft> long count(org.springframework.data.domain.Example<S> example) { return 0; }
    @Override public <S extends RecordDraft> boolean exists(org.springframework.data.domain.Example<S> example) { return false; }
    @Override public <S extends RecordDraft> Optional<S> findOne(org.springframework.data.domain.Example<S> example) { return Optional.empty(); }
    @Override public <S extends RecordDraft, R> R findBy(org.springframework.data.domain.Example<S> example, java.util.function.Function<org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery<S>, R> queryFunction) { throw new UnsupportedOperationException(); }
  }

  /** ScopeContext getter — match service's protected accessor through field. */
  @SuppressWarnings("unused")
  private final AtomicReference<Object> _silenceUnusedScope = new AtomicReference<>();
}
