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

import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.validation.ValidationError;
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 java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
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.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class ReferenceValidatorTest {

  private static final ObjectMapper OM = new ObjectMapper();

  private DictionaryDefinitionRepository defRepo;
  private DictionaryRecordRepository recRepo;
  private ReferenceValidator validator;

  @BeforeEach
  void setUp() {
    defRepo = mock(DictionaryDefinitionRepository.class);
    recRepo = mock(DictionaryRecordRepository.class);
    validator = new ReferenceValidator(defRepo, recRepo);
  }

  // === parseRef static tests ===

  @Test
  void parseRefAcceptsValid() {
    var p = ReferenceValidator.parseRef("satellite_type.code");
    assertThat(p.dictionaryName()).isEqualTo("satellite_type");
    assertThat(p.fieldName()).isEqualTo("code");
    // Без property schema → onClose defaultится в BLOCK (backward-compat v1.2.1).
    assertThat(p.onClose()).isEqualTo(OnCloseAction.BLOCK);
  }

  @Test
  void parseRefReadsOnCloseFromPropertySchema() throws Exception {
    JsonNode prop = OM.readTree("""
        {"type":"string","x-references":"d.f","x-references-on-close":"cascade"}""");
    var p = ReferenceValidator.parseRef("d.f", prop);
    assertThat(p.onClose()).isEqualTo(OnCloseAction.CASCADE);
  }

  @Test
  void parseRefOnCloseMissingFallsBackToBlock() throws Exception {
    JsonNode prop = OM.readTree("""
        {"type":"string","x-references":"d.f"}""");
    var p = ReferenceValidator.parseRef("d.f", prop);
    assertThat(p.onClose()).isEqualTo(OnCloseAction.BLOCK);
  }

  @Test
  void parseRefOnCloseAcceptsAllValuesCaseInsensitive() throws Exception {
    JsonNode block = OM.readTree("""
        {"x-references-on-close":"BLOCK"}""");
    JsonNode warn = OM.readTree("""
        {"x-references-on-close":" Warn "}""");
    JsonNode cascade = OM.readTree("""
        {"x-references-on-close":"cascade"}""");
    assertThat(ReferenceValidator.parseRef("d.f", block).onClose())
        .isEqualTo(OnCloseAction.BLOCK);
    assertThat(ReferenceValidator.parseRef("d.f", warn).onClose())
        .isEqualTo(OnCloseAction.WARN);
    assertThat(ReferenceValidator.parseRef("d.f", cascade).onClose())
        .isEqualTo(OnCloseAction.CASCADE);
  }

  @Test
  void parseRefOnCloseRejectsInvalidValue() throws Exception {
    JsonNode prop = OM.readTree("""
        {"type":"string","x-references":"d.f","x-references-on-close":"yolo"}""");
    assertThatThrownBy(() -> ReferenceValidator.parseRef("d.f", prop))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessageContaining("x-references-on-close")
        .hasMessageContaining("yolo");
  }

  @Test
  void parseRefOnCloseRejectsNonString() throws Exception {
    JsonNode prop = OM.readTree("""
        {"type":"string","x-references":"d.f","x-references-on-close":42}""");
    assertThatThrownBy(() -> ReferenceValidator.parseRef("d.f", prop))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessageContaining("must be a string");
  }

  @Test
  void parseRefRejectsEmpty() {
    assertThatThrownBy(() -> ReferenceValidator.parseRef(""))
        .isInstanceOf(IllegalArgumentException.class);
    assertThatThrownBy(() -> ReferenceValidator.parseRef(null))
        .isInstanceOf(IllegalArgumentException.class);
  }

  @Test
  void parseRefRejectsMissingDot() {
    assertThatThrownBy(() -> ReferenceValidator.parseRef("noDot"))
        .hasMessageContaining("dict_name.field");
  }

  @Test
  void parseRefRejectsTrailingDot() {
    assertThatThrownBy(() -> ReferenceValidator.parseRef("dict."))
        .hasMessageContaining("dict_name.field");
    assertThatThrownBy(() -> ReferenceValidator.parseRef(".field"))
        .hasMessageContaining("dict_name.field");
  }

  @Test
  void parseRefRejectsNestedPaths() {
    assertThatThrownBy(() -> ReferenceValidator.parseRef("a.b.c"))
        .hasMessageContaining("nested paths not supported");
  }

  // === validate() tests ===

  @Test
  void schemaWithoutReferences_noErrors() throws Exception {
    JsonNode schema = OM.readTree("""
        {"type":"object","properties":{"name":{"type":"string"}}}""");
    JsonNode data = OM.readTree("{\"name\":\"foo\"}");

    var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
    assertThat(errors).isEmpty();
  }

  @Test
  void missingValue_skippedSilently() throws Exception {
    JsonNode schema = OM.readTree("""
        {"type":"object","properties":{
          "ref":{"type":"string","x-references":"satellite_type.code"}
        }}""");
    JsonNode data = OM.readTree("{\"name\":\"foo\"}"); // нет 'ref'

    var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
    assertThat(errors).isEmpty();
  }

  @Test
  void existingReferencedRecord_noErrors() throws Exception {
    UUID targetId = UUID.randomUUID();
    DictionaryDefinition target = mockDefinition("satellite_type", DataScope.PUBLIC, targetId);
    when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
    when(recRepo.findActiveByJsonField(eq(targetId), any(), eq("code"), eq("LEO-001")))
        .thenReturn(List.of(stubRecord()));

    JsonNode schema = OM.readTree("""
        {"type":"object","properties":{
          "satTypeCode":{"type":"string","x-references":"satellite_type.code"}
        }}""");
    JsonNode data = OM.readTree("{\"satTypeCode\":\"LEO-001\"}");

    var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
    assertThat(errors).isEmpty();
  }

  @Test
  void missingReferencedRecord_returnsError() throws Exception {
    UUID targetId = UUID.randomUUID();
    DictionaryDefinition target = mockDefinition("satellite_type", DataScope.PUBLIC, targetId);
    when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
    when(recRepo.findActiveByJsonField(any(), any(), anyString(), anyString()))
        .thenReturn(List.of()); // no matches

    JsonNode schema = OM.readTree("""
        {"type":"object","properties":{
          "satTypeCode":{"type":"string","x-references":"satellite_type.code"}
        }}""");
    JsonNode data = OM.readTree("{\"satTypeCode\":\"NONEXISTENT\"}");

    var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
    assertThat(errors).hasSize(1);
    assertThat(errors.get(0).code()).isEqualTo("x_references_target_not_found");
    assertThat(errors.get(0).message()).contains("satellite_type");
    assertThat(errors.get(0).message()).contains("NONEXISTENT");
  }

  @Test
  void missingReferencedDictionary_returnsError() throws Exception {
    when(defRepo.findByName("nonexistent_dict")).thenReturn(Optional.empty());

    JsonNode schema = OM.readTree("""
        {"type":"object","properties":{
          "ref":{"type":"string","x-references":"nonexistent_dict.code"}
        }}""");
    JsonNode data = OM.readTree("{\"ref\":\"any\"}");

    var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
    assertThat(errors).hasSize(1);
    assertThat(errors.get(0).code()).isEqualTo("x_references_dict_not_found");
  }

  @Test
  void targetScopeNotAccessible_returnsNotFoundError() throws Exception {
    // Caller имеет PUBLIC scope, target dict — INTERNAL.
    // Должен вернуть not_found (не "scope_denied") чтобы не leak existence.
    UUID targetId = UUID.randomUUID();
    DictionaryDefinition target = mockDefinition("internal_dict", DataScope.INTERNAL, targetId);
    when(defRepo.findByName("internal_dict")).thenReturn(Optional.of(target));

    JsonNode schema = OM.readTree("""
        {"type":"object","properties":{
          "ref":{"type":"string","x-references":"internal_dict.code"}
        }}""");
    JsonNode data = OM.readTree("{\"ref\":\"X\"}");

    var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
    assertThat(errors).hasSize(1);
    assertThat(errors.get(0).code()).isEqualTo("x_references_target_not_found");
  }

  @Test
  void nonStringValue_returnsError() throws Exception {
    JsonNode schema = OM.readTree("""
        {"type":"object","properties":{
          "ref":{"type":"integer","x-references":"d.f"}
        }}""");
    JsonNode data = OM.readTree("{\"ref\":42}");

    var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
    assertThat(errors).hasSize(1);
    assertThat(errors.get(0).code()).isEqualTo("x_references_non_string");
  }

  @Test
  void malformedRefSpec_returnsError() throws Exception {
    JsonNode schema = OM.readTree("""
        {"type":"object","properties":{
          "ref":{"type":"string","x-references":"malformed-no-dot"}
        }}""");
    JsonNode data = OM.readTree("{\"ref\":\"any\"}");

    var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
    assertThat(errors).hasSize(1);
    assertThat(errors.get(0).code()).isEqualTo("x_references_malformed");
  }

  @Test
  void multipleReferenceFields_validatedIndependently() throws Exception {
    UUID dictAId = UUID.randomUUID();
    UUID dictBId = UUID.randomUUID();
    when(defRepo.findByName("dict_a")).thenReturn(
        Optional.of(mockDefinition("dict_a", DataScope.PUBLIC, dictAId)));
    when(defRepo.findByName("dict_b")).thenReturn(
        Optional.of(mockDefinition("dict_b", DataScope.PUBLIC, dictBId)));
    // Только в dict_a есть match. dict_b — пусто.
    when(recRepo.findActiveByJsonField(eq(dictAId), any(), eq("c"), eq("X")))
        .thenReturn(List.of(stubRecord()));
    when(recRepo.findActiveByJsonField(eq(dictBId), any(), any(), any()))
        .thenReturn(List.of());

    JsonNode schema = OM.readTree("""
        {"type":"object","properties":{
          "refA":{"type":"string","x-references":"dict_a.c"},
          "refB":{"type":"string","x-references":"dict_b.c"}
        }}""");
    JsonNode data = OM.readTree("{\"refA\":\"X\",\"refB\":\"Y\"}");

    var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
    // refA OK, refB fails → 1 error
    assertThat(errors).hasSize(1);
    assertThat(errors.get(0).path()).isEqualTo("/refB");
  }

  @Test
  void nullSchema_returnsEmptyErrors() {
    var errors = validator.validate(null, null, List.of(DataScope.PUBLIC), OffsetDateTime.now());
    assertThat(errors).isEmpty();
  }

  // === Helpers ===

  /**
   * Mockito не может mock'нуть DictionaryDefinition на JDK 25 (см. memo
   * "JDK 25 + Mockito subclass mock-maker incompat"). Используем real
   * entity — у неё public ctor с минимальными полями.
   */
  private static DictionaryDefinition mockDefinition(String name, DataScope scope, UUID id) {
    return new DictionaryDefinition(id, name, scope, null);
  }

  /** Аналогично — real DictionaryRecord vместо mock. Validator только
   *  проверяет {@code isEmpty()}, fields этого record нам не важны. */
  private static DictionaryRecord stubRecord() {
    return new DictionaryRecord(
        UUID.randomUUID(), UUID.randomUUID(), "stub-key",
        OM.createObjectNode(), DataScope.PUBLIC, OffsetDateTime.now());
  }
}
