package cloud.nstart.terravault.ordinis.app.e2e;

import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

import java.util.List;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
 * Approval Workflow v2 — Phase 3 e2e coverage.
 *
 * <p>Полный maker-checker lifecycle на реальном Postgres + Spring Security
 * (JWT через {@link JwtTestSupport}). Maker и reviewer = разные subjects → проверяем
 * self-approve guard, exclusion constraint, regression на dict без approval_required,
 * outbox event после approve.
 *
 * <p>Coverage matrix (соответствует Phase 3 чеклисту в design doc):
 * <ol>
 *   <li>{@link #happyFlow_submitApprove_recordLiveAndOutboxEvent} — full maker→checker</li>
 *   <li>{@link #rejectFlow_keepsDraft_noLiveRecord} — reject path</li>
 *   <li>{@link #selfApproveBlocked_409} — no_self_approve constraint</li>
 *   <li>{@link #twoPendingDraftsForSameBusinessKey_blocked} — one_pending_per_key</li>
 *   <li>{@link #directCrudBlockedWhenApprovalRequired} — D2=A gate (409 draft_required)</li>
 *   <li>{@link #regression_approvalNotRequired_directCrudStillWorks} — non-regression</li>
 *   <li>{@link #withdrawByMaker_keepsDraftStatus} — maker withdraw</li>
 *   <li>{@link #rejectWithoutComment_400} — reject_reason_required</li>
 * </ol>
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ApprovalWorkflowE2ETest {

  @DynamicPropertySource
  static void props(DynamicPropertyRegistry registry) {
    E2ESupport.registerProperties(registry);
    // JWT auth ON — для self-approve guard нужны разные subjects.
    registry.add("ordinis.auth.jwk-set-uri", JwtTestSupport::jwksUrl);
    registry.add("ordinis.auth.require-authentication", () -> "true");
    registry.add("ordinis.auth.allow-query-scope", () -> "false");
    registry.add("ordinis.auth.issuer-uri", () -> "");
  }

  @Autowired MockMvc mvc;
  @Autowired ObjectMapper om;
  @Autowired DictionaryRecordRepository recordRepo;
  @Autowired OutboxEventRepository outboxRepo;

  // === Helpers ===

  /** Internal-scope JWT для maker'а (без workflow ролей). */
  private static String tokenFor(String subject) {
    return JwtTestSupport.signJwt(List.of("ordinis:client:internal"), subject);
  }

  /**
   * JWT для reviewer'а — internal scope + Phase 1d {@code ordinis:record:reviewer}
   * role. Без этой роли backend гейт ({@code WorkflowRoles.RECORD_REVIEWER})
   * вернёт {@code 403 forbidden_role} до того как тест проверит другие
   * invariants (self-approve, missing comment etc).
   */
  private static String reviewerTokenFor(String subject) {
    return JwtTestSupport.signJwt(
        List.of("ordinis:client:internal", "ordinis:record:reviewer"),
        subject);
  }

  private static final String SCHEMA_JSON = """
      {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "additionalProperties": false,
        "x-id-source": "code",
        "required": ["code", "label"],
        "properties": {
          "code":  { "type": "string", "x-unique": true },
          "label": { "type": "string", "minLength": 1 }
        }
      }
      """;

  private String createDict(String token, String dictName, boolean approvalRequired) throws Exception {
    JsonNode schema = om.readTree(SCHEMA_JSON);
    var body = om.createObjectNode()
        .put("name", dictName)
        .put("scope", "INTERNAL")
        .put("schemaVersion", "1.0.0")
        .put("bundle", "test")
        .put("approvalRequired", approvalRequired);
    body.set("schemaJson", schema);
    mvc.perform(post("/api/v1/dictionaries")
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .contentType(MediaType.APPLICATION_JSON)
            .content(om.writeValueAsBytes(body)))
        .andExpect(status().is2xxSuccessful());
    return dictName;
  }

  /**
   * Создать dict без approval, затем через PUT включить approval_required.
   * Тестируем path "включили approval после создания".
   */
  private String createDictThenEnableApproval(String token, String dictName) throws Exception {
    createDict(token, dictName, false);
    JsonNode schema = om.readTree(SCHEMA_JSON);
    var body = om.createObjectNode()
        .put("name", dictName)
        .put("scope", "INTERNAL")
        .put("schemaVersion", "1.0.0")
        .put("bundle", "test")
        .put("approvalRequired", true);
    body.set("schemaJson", schema);
    mvc.perform(put("/api/v1/dictionaries/{name}", dictName)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .contentType(MediaType.APPLICATION_JSON)
            .content(om.writeValueAsBytes(body)))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.approvalRequired").value(true));
    return dictName;
  }

  private static String randDictName(String prefix) {
    return prefix + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
  }

  private static String randBk() {
    return "BK_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
  }

  private JsonNode submitDraft(String token, String dictName, String bk, String label) throws Exception {
    var data = om.createObjectNode().put("code", bk).put("label", label);
    var body = om.createObjectNode().put("businessKey", bk).put("operation", "CREATE");
    body.set("data", data);
    MvcResult res = mvc.perform(post("/api/v1/dictionaries/{n}/records/drafts", dictName)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .contentType(MediaType.APPLICATION_JSON)
            .content(om.writeValueAsBytes(body)))
        .andExpect(status().isCreated())
        .andExpect(jsonPath("$.status").value("PENDING"))
        .andReturn();
    return om.readTree(res.getResponse().getContentAsString());
  }

  // === Tests ===

  @Test
  void happyFlow_submitApprove_recordLiveAndOutboxEvent() throws Exception {
    String makerToken = tokenFor("alice-maker");
    String reviewerToken = reviewerTokenFor("bob-reviewer");

    String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_h"));
    String bk = randBk();
    long outboxBefore = outboxRepo.count();

    // 1. Maker submits CREATE draft.
    JsonNode draft = submitDraft(makerToken, dictName, bk, "Alpha");
    String draftId = draft.get("id").asText();
    assertThat(draft.get("makerId").asText()).isEqualTo("alice-maker");

    // 2. Until approved — НЕТ live записи.
    long liveBefore = recordRepo.findAll().stream()
        .filter(r -> bk.equals(r.getBusinessKey())).count();
    assertThat(liveBefore).isZero();

    // 3. Reviewer approves.
    mvc.perform(post("/api/v1/drafts/{id}/approve", draftId)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken)
            .param("comment", "LGTM"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.status").value("APPROVED"))
        .andExpect(jsonPath("$.reviewerId").value("bob-reviewer"))
        .andExpect(jsonPath("$.reviewComment").value("LGTM"));

    // 4. Live record persisted.
    var liveRecs = recordRepo.findAll().stream()
        .filter(r -> bk.equals(r.getBusinessKey()))
        .toList();
    assertThat(liveRecs)
        .as("approved CREATE → 1 live record")
        .hasSize(1);

    // 5. Outbox event записан (RecordCreated). countAll вырос.
    assertThat(outboxRepo.count())
        .as("approve должен породить outbox event")
        .isGreaterThan(outboxBefore);

    // 6. Reader endpoint видит запись (sanity — reviewer scope INTERNAL).
    mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, bk)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.businessKey").value(bk))
        .andExpect(jsonPath("$.data.label").value("Alpha"));
  }

  @Test
  void rejectFlow_keepsDraft_noLiveRecord() throws Exception {
    String makerToken = tokenFor("carol-maker");
    String reviewerToken = reviewerTokenFor("dave-reviewer");

    String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_rej"));
    String bk = randBk();

    JsonNode draft = submitDraft(makerToken, dictName, bk, "Beta");
    String draftId = draft.get("id").asText();

    // Reject с reason — должен пройти.
    mvc.perform(post("/api/v1/drafts/{id}/reject", draftId)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken)
            .param("comment", "Не соответствует регламенту"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.status").value("REJECTED"))
        .andExpect(jsonPath("$.reviewComment").value("Не соответствует регламенту"));

    // Live record НЕТ.
    var liveRecs = recordRepo.findAll().stream()
        .filter(r -> bk.equals(r.getBusinessKey()))
        .toList();
    assertThat(liveRecs).as("rejected draft не публикует live record").isEmpty();

    // Re-approve того же draft → 409 draft_not_pending (status !=PENDING).
    mvc.perform(post("/api/v1/drafts/{id}/approve", draftId)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken))
        .andExpect(status().isConflict())
        .andExpect(jsonPath("$.code").value("draft_not_pending"));
  }

  @Test
  void selfApproveBlocked_409() throws Exception {
    // eve-solo даёт BOTH scope И reviewer role — иначе backend ответит
    // 403 forbidden_role до того как мы достигнем self-approve check.
    // Тест specifically проверяет 409 self_approve_forbidden (maker == reviewer
    // даже при наличии role).
    String token = reviewerTokenFor("eve-solo");
    String dictName = createDictThenEnableApproval(token, randDictName("appr_self"));
    String bk = randBk();

    JsonNode draft = submitDraft(token, dictName, bk, "Gamma");
    String draftId = draft.get("id").asText();

    // Тот же subject пробует approve свой draft.
    mvc.perform(post("/api/v1/drafts/{id}/approve", draftId)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
        .andExpect(status().isConflict())
        .andExpect(jsonPath("$.code").value("self_approve_forbidden"));

    // Reject себя — также блок.
    mvc.perform(post("/api/v1/drafts/{id}/reject", draftId)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .param("comment", "self-reject test"))
        .andExpect(status().isConflict())
        .andExpect(jsonPath("$.code").value("self_reject_forbidden"));
  }

  @Test
  void twoPendingDraftsForSameBusinessKey_blocked() throws Exception {
    String makerToken = tokenFor("frank-maker");
    String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_excl"));
    String bk = randBk();

    submitDraft(makerToken, dictName, bk, "First proposal");

    // Второй submit на тот же business_key — exclusion constraint → 409.
    var data2 = om.createObjectNode().put("code", bk).put("label", "Second");
    var body2 = om.createObjectNode().put("businessKey", bk).put("operation", "CREATE");
    body2.set("data", data2);
    mvc.perform(post("/api/v1/dictionaries/{n}/records/drafts", dictName)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + makerToken)
            .contentType(MediaType.APPLICATION_JSON)
            .content(om.writeValueAsBytes(body2)))
        .andExpect(status().isConflict())
        .andExpect(jsonPath("$.code").value("draft_already_pending"));
  }

  @Test
  void directCrudBlockedWhenApprovalRequired() throws Exception {
    String token = tokenFor("grace-admin");
    String dictName = createDictThenEnableApproval(token, randDictName("appr_gate"));
    String bk = randBk();

    var recBody = om.createObjectNode().put("businessKey", bk);
    recBody.set("data", om.createObjectNode().put("code", bk).put("label", "X"));

    // Direct POST → 409 draft_required (D2=A).
    mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .contentType(MediaType.APPLICATION_JSON)
            .content(om.writeValueAsBytes(recBody)))
        .andExpect(status().isConflict())
        .andExpect(jsonPath("$.code").value("draft_required"));

    // Direct PUT тоже блокируется. Сначала надо создать запись через approval
    // path — но проще: PUT по несуществующему ключу вернёт draft_required
    // ДО проверки existence (gate стоит первым в service).
    mvc.perform(put("/api/v1/dictionaries/{n}/records/{k}", dictName, bk)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .contentType(MediaType.APPLICATION_JSON)
            .content(om.writeValueAsBytes(recBody)))
        .andExpect(status().isConflict())
        .andExpect(jsonPath("$.code").value("draft_required"));

    // DELETE тоже блокируется approval gate'ом.
    mvc.perform(delete("/api/v1/dictionaries/{n}/records/{k}", dictName, bk)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
        .andExpect(status().isConflict())
        .andExpect(jsonPath("$.code").value("draft_required"));
  }

  @Test
  void regression_approvalNotRequired_directCrudStillWorks() throws Exception {
    String token = tokenFor("henry-admin");
    String dictName = createDict(token, randDictName("appr_reg"), false);
    String bk = randBk();

    // Direct POST на dict БЕЗ approval — работает как до Approval v2.
    var recBody = om.createObjectNode().put("businessKey", bk);
    recBody.set("data", om.createObjectNode().put("code", bk).put("label", "Regression OK"));
    mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .contentType(MediaType.APPLICATION_JSON)
            .content(om.writeValueAsBytes(recBody)))
        .andExpect(status().is2xxSuccessful())
        .andExpect(jsonPath("$.businessKey").value(bk));

    // GET возвращает запись.
    mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, bk)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.data.label").value("Regression OK"));
  }

  @Test
  void withdrawByMaker_keepsDraftStatus() throws Exception {
    String makerToken = tokenFor("ivan-maker");
    String reviewerToken = reviewerTokenFor("julia-reviewer");

    String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_wd"));
    String bk = randBk();

    JsonNode draft = submitDraft(makerToken, dictName, bk, "Delta");
    String draftId = draft.get("id").asText();

    // Reviewer пробует withdraw — 403 not_draft_maker.
    mvc.perform(delete("/api/v1/drafts/{id}", draftId)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken))
        .andExpect(status().isForbidden())
        .andExpect(jsonPath("$.code").value("not_draft_maker"));

    // Maker withdraws — 200 + status=WITHDRAWN.
    mvc.perform(delete("/api/v1/drafts/{id}", draftId)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + makerToken))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.status").value("WITHDRAWN"));

    // После withdraw — re-submit на тот же business_key РАБОТАЕТ (exclusion
    // снят, status более не PENDING).
    submitDraft(makerToken, dictName, bk, "Delta v2");
  }

  @Test
  void rejectWithoutComment_400() throws Exception {
    String makerToken = tokenFor("kate-maker");
    String reviewerToken = reviewerTokenFor("leo-reviewer");

    String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_rc"));
    String bk = randBk();

    JsonNode draft = submitDraft(makerToken, dictName, bk, "Epsilon");
    String draftId = draft.get("id").asText();

    // Reject без comment — 400 reject_reason_required.
    mvc.perform(post("/api/v1/drafts/{id}/reject", draftId)
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken)
            .param("comment", ""))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.code").value("reject_reason_required"));
  }
}
