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

import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.reference.CascadeCloseService.RecordCloseSink;
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService.PerSourceSummary;
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService.RecordDependent;
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService.RecordDependentsPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;

import java.time.OffsetDateTime;
import java.util.ArrayList;
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;

/**
 * Тесты {@link CascadeCloseService}: branching по onClose mode, blockers
 * → 409, cascade-required → 409, atomic execute, max size guard.
 *
 * <p>JDK 25 + Mockito не мокают concrete classes (LineageIndexService,
 * DictionaryDefinitionService и др.). Используем seam {@link RecordCloseSink}
 * + anonymous subclass LineageIndexService — никаких реальных deps.
 */
class CascadeCloseServiceTest {

  private ScopeContext scopeContext;
  /** Stub state — каждый тест перезаписывает. */
  private RecordDependentsPage stubPage;
  private final List<CloseCall> closeCalls = new ArrayList<>();

  @BeforeEach
  void setUp() {
    // ScopeContext concrete class — JDK 25 + Mockito incompat. Anonymous
    // subclass возвращает statically PUBLIC для всех тестов.
    scopeContext = new ScopeContext(null) {
      @Override
      public Set<DataScope> currentScopes() {
        return Set.of(DataScope.PUBLIC);
      }
    };
    stubPage = emptyPage("dict_t", "k");
    closeCalls.clear();
  }

  /** Создаёт CascadeCloseService с stub-LineageIndexService и tracking sink. */
  private CascadeCloseService makeService() {
    LineageIndexService stubLineage = new LineageIndexService(null, null, null) {
      @Override
      public RecordDependentsPage findRecordDependents(
          String targetDictName, String targetBusinessKey,
          Set<DataScope> allowedScopes, int page, int size) {
        return stubPage;
      }
    };
    RecordCloseSink trackingSink = (dict, key, when, reason) ->
        closeCalls.add(new CloseCall(dict, key, when, reason));
    return new CascadeCloseService(stubLineage, trackingSink, scopeContext);
  }

  // === evaluatePlan ===

  @Test
  void evaluatePlan_emptyDependents_returnsEmptyPlan() {
    var plan = makeService().evaluatePlan("dict_t", "k", null);
    assertThat(plan.blockers()).isEmpty();
    assertThat(plan.warnings()).isEmpty();
    assertThat(plan.cascade()).isEmpty();
    assertThat(plan.totalDependents()).isZero();
  }

  @Test
  void evaluatePlan_splitsByOnCloseMode() {
    stubPage = pageWith(List.of(
        recordDep("src_block", "rec1", "f1", OnCloseAction.BLOCK),
        recordDep("src_warn", "rec2", "f2", OnCloseAction.WARN),
        recordDep("src_cascade", "rec3", "f3", OnCloseAction.CASCADE),
        recordDep("src_block", "rec4", "f1", OnCloseAction.BLOCK)),
        "dict_t", "k", 4L);

    var plan = makeService().evaluatePlan("dict_t", "k", null);

    assertThat(plan.blockers()).hasSize(2);
    assertThat(plan.warnings()).hasSize(1);
    assertThat(plan.cascade()).hasSize(1);
    assertThat(plan.totalDependents()).isEqualTo(4L);
  }

  // === executeCascade ===

  @Test
  void executeCascade_throws409WhenBlockersExist() {
    stubPage = pageWith(
        List.of(recordDep("src", "rec1", "f", OnCloseAction.BLOCK)),
        "dict_t", "k", 1L);

    assertThatThrownBy(() -> makeService().executeCascade("dict_t", "k", null, "test", true))
        .isInstanceOf(OrdinisException.class)
        .satisfies(e -> assertThat(((OrdinisException) e).getStatus())
            .isEqualTo(HttpStatus.CONFLICT))
        .hasMessageContaining("block close");
    assertThat(closeCalls).isEmpty(); // Nothing closed.
  }

  @Test
  void executeCascade_requiresConfirmWhenCascadeNonEmpty() {
    stubPage = pageWith(
        List.of(recordDep("src", "rec1", "f", OnCloseAction.CASCADE)),
        "dict_t", "k", 1L);

    assertThatThrownBy(() -> makeService().executeCascade("dict_t", "k", null, "test", false))
        .isInstanceOf(OrdinisException.class)
        .hasMessageContaining("Re-call with confirmed=true");
    assertThat(closeCalls).isEmpty();
  }

  @Test
  void executeCascade_warnOnly_proceedsWithoutCascade() {
    stubPage = pageWith(
        List.of(recordDep("src", "rec1", "f", OnCloseAction.WARN)),
        "dict_t", "k", 1L);

    var result = makeService().executeCascade("dict_t", "k", null, "test", false);

    assertThat(result.cascadedRecords()).isEmpty();
    assertThat(result.warnings()).hasSize(1);
    assertThat(result.totalClosed()).isEqualTo(1); // только target
    assertThat(closeCalls).hasSize(1);
    assertThat(closeCalls.get(0).dict()).isEqualTo("dict_t");
    assertThat(closeCalls.get(0).reason()).isEqualTo("test");
  }

  @Test
  void executeCascade_cascadeMode_closesTargetAndDependentsAtomically() {
    stubPage = pageWith(List.of(
        recordDep("src_a", "rec1", "f", OnCloseAction.CASCADE),
        recordDep("src_b", "rec2", "f", OnCloseAction.CASCADE)),
        "dict_t", "k", 2L);

    var result = makeService().executeCascade("dict_t", "k", null, "manual", true);

    assertThat(result.cascadedRecords()).hasSize(2);
    assertThat(result.warnings()).isEmpty();
    assertThat(result.totalClosed()).isEqualTo(3); // target + 2 cascaded
    assertThat(closeCalls).hasSize(3);
    // Order: target first, then cascade entries.
    assertThat(closeCalls.get(0).dict()).isEqualTo("dict_t");
    assertThat(closeCalls.get(0).reason()).isEqualTo("manual");
    assertThat(closeCalls.get(1).reason()).contains("cascaded from dict_t/k");
    assertThat(closeCalls.get(2).reason()).contains("cascaded from dict_t/k");
    var cascadedDicts = closeCalls.subList(1, 3).stream()
        .map(CloseCall::dict).toList();
    assertThat(cascadedDicts).containsExactlyInAnyOrder("src_a", "src_b");
  }

  @Test
  void executeCascade_blockOverridesCascade_409EvenWithConfirm() {
    stubPage = pageWith(List.of(
        recordDep("src_block", "rec1", "f", OnCloseAction.BLOCK),
        recordDep("src_cascade", "rec2", "f", OnCloseAction.CASCADE)),
        "dict_t", "k", 2L);

    assertThatThrownBy(() -> makeService().executeCascade("dict_t", "k", null, "test", true))
        .isInstanceOf(OrdinisException.class)
        .hasMessageContaining("block close");
    assertThat(closeCalls).isEmpty();
  }

  @Test
  void executeCascade_emptyPlan_closesOnlyTarget() {
    var result = makeService().executeCascade("dict_t", "k", null, "manual", false);

    assertThat(result.cascadedRecords()).isEmpty();
    assertThat(result.warnings()).isEmpty();
    assertThat(result.totalClosed()).isEqualTo(1);
    assertThat(closeCalls).hasSize(1);
    assertThat(closeCalls.get(0).dict()).isEqualTo("dict_t");
  }

  // === Helpers ===

  private record CloseCall(String dict, String key, OffsetDateTime when, String reason) {}

  private static RecordDependent recordDep(
      String sourceDict, String businessKey, String sourceField, OnCloseAction onClose) {
    return new RecordDependent(
        sourceDict, sourceDict + " (display)", sourceField, onClose,
        UUID.randomUUID(), businessKey,
        OffsetDateTime.parse("2026-01-01T00:00:00Z"),
        OffsetDateTime.MAX,
        OffsetDateTime.parse("2026-01-01T00:00:00Z"));
  }

  private static RecordDependentsPage emptyPage(String targetDict, String targetKey) {
    return new RecordDependentsPage(
        targetDict, targetKey, List.of(), List.of(), 0L, 0, 50, null);
  }

  private static RecordDependentsPage pageWith(
      List<RecordDependent> items, String targetDict, String targetKey, long total) {
    List<PerSourceSummary> perSource = new ArrayList<>();
    var grouped = new java.util.LinkedHashMap<String, Long>();
    for (var d : items) grouped.merge(d.sourceDict() + "." + d.sourceField(), 1L, Long::sum);
    for (var entry : grouped.entrySet()) {
      var match = items.stream()
          .filter(it -> (it.sourceDict() + "." + it.sourceField()).equals(entry.getKey()))
          .findFirst().orElseThrow();
      perSource.add(new PerSourceSummary(
          match.sourceDict(), match.sourceDisplayName(),
          match.sourceField(), match.onClose(), entry.getValue()));
    }
    return new RecordDependentsPage(
        targetDict, targetKey, items, perSource, total, 0, 50, null);
  }
}
