package de.effigenix.infrastructure.production.web;

import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;

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.result.MockMvcResultMatchers.*;

@DisplayName("Batch Controller Integration Tests")
class BatchControllerIntegrationTest extends AbstractIntegrationTest {

    private String adminToken;
    private String viewerToken;

    private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
    private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);

    @BeforeEach
    void setUp() throws Exception {
        String adminRoleId = createRole(RoleName.ADMIN, "Admin");
        String viewerRoleId = createRole(RoleName.PRODUCTION_WORKER, "Viewer");

        String adminId = createUser("batch.admin", "batch.admin@test.com", Set.of(adminRoleId), "BRANCH-01");
        String viewerId = createUser("batch.viewer", "batch.viewer@test.com", Set.of(viewerRoleId), "BRANCH-01");

        adminToken = generateToken(adminId, "batch.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
        viewerToken = generateToken(viewerId, "batch.viewer", "USER_READ");
    }

    @Nested
    @DisplayName("POST /api/production/batches – Charge planen")
    class PlanBatchEndpoint {

        @Test
        @DisplayName("Charge planen mit gültigen Daten → 201")
        void planBatch_withValidData_returns201() throws Exception {
            String recipeId = createActiveRecipe();

            var request = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.id").isNotEmpty())
                    .andExpect(jsonPath("$.batchNumber").isNotEmpty())
                    .andExpect(jsonPath("$.recipeId").value(recipeId))
                    .andExpect(jsonPath("$.status").value("PLANNED"))
                    .andExpect(jsonPath("$.plannedQuantity").value("100.000000"))
                    .andExpect(jsonPath("$.plannedQuantityUnit").value("KILOGRAM"))
                    .andExpect(jsonPath("$.productionDate").value("2026-03-01"))
                    .andExpect(jsonPath("$.bestBeforeDate").value("2026-06-01"))
                    .andExpect(jsonPath("$.createdAt").isNotEmpty())
                    .andExpect(jsonPath("$.updatedAt").isNotEmpty());
        }

        @Test
        @DisplayName("BatchNumber hat Format P-YYYY-MM-DD-XXX")
        void planBatch_batchNumberFormat() throws Exception {
            String recipeId = createActiveRecipe();

            var request = new PlanBatchRequest(
                    recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-001"));
        }

        @Test
        @DisplayName("Zweite Charge am selben Tag → inkrementierte Sequenznummer")
        void planBatch_secondBatchSameDay_incrementsSequence() throws Exception {
            String recipeId = createActiveRecipe();

            var request = new PlanBatchRequest(
                    recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-001"));

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-002"));
        }

        @Test
        @DisplayName("PlannedQuantity 0 → 400")
        void planBatch_zeroQuantity_returns400() throws Exception {
            String recipeId = createActiveRecipe();

            var request = new PlanBatchRequest(
                    recipeId, "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY"));
        }

        @Test
        @DisplayName("Negative Menge → 400")
        void planBatch_negativeQuantity_returns400() throws Exception {
            String recipeId = createActiveRecipe();

            var request = new PlanBatchRequest(
                    recipeId, "-10", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY"));
        }

        @Test
        @DisplayName("Ungültige Unit → 400")
        void planBatch_invalidUnit_returns400() throws Exception {
            String recipeId = createActiveRecipe();

            var request = new PlanBatchRequest(
                    recipeId, "100", "INVALID_UNIT", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY"));
        }

        @Test
        @DisplayName("BestBeforeDate vor ProductionDate → 400")
        void planBatch_bestBeforeBeforeProduction_returns400() throws Exception {
            String recipeId = createActiveRecipe();

            var request = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM",
                    LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1));

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_DATES"));
        }

        @Test
        @DisplayName("BestBeforeDate gleich ProductionDate → 400")
        void planBatch_sameDate_returns400() throws Exception {
            String recipeId = createActiveRecipe();
            var sameDate = LocalDate.of(2026, 3, 1);

            var request = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", sameDate, sameDate);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_DATES"));
        }

        @Test
        @DisplayName("Rezept nicht gefunden → 400")
        void planBatch_recipeNotFound_returns400() throws Exception {
            var request = new PlanBatchRequest(
                    UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("BATCH_VALIDATION_ERROR"));
        }

        @Test
        @DisplayName("Rezept nicht ACTIVE (DRAFT) → 409")
        void planBatch_recipeNotActive_returns409() throws Exception {
            String recipeId = createDraftRecipe();

            var request = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("BATCH_RECIPE_NOT_ACTIVE"));
        }

        @Test
        @DisplayName("recipeId leer → 400 (Bean Validation)")
        void planBatch_blankRecipeId_returns400() throws Exception {
            var request = new PlanBatchRequest(
                    "", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isBadRequest());
        }

        @Test
        @DisplayName("plannedQuantity leer → 400 (Bean Validation)")
        void planBatch_blankQuantity_returns400() throws Exception {
            var request = new PlanBatchRequest(
                    UUID.randomUUID().toString(), "", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isBadRequest());
        }
    }

    @Nested
    @DisplayName("Authorization")
    class AuthTests {

        @Test
        @DisplayName("Ohne BATCH_WRITE → 403")
        void planBatch_withViewerToken_returns403() throws Exception {
            var request = new PlanBatchRequest(
                    UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + viewerToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isForbidden());
        }

        @Test
        @DisplayName("Ohne Token → 401")
        void planBatch_withoutToken_returns401() throws Exception {
            var request = new PlanBatchRequest(
                    UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            mockMvc.perform(post("/api/production/batches")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isUnauthorized());
        }
    }

    @Nested
    @DisplayName("POST /api/production/batches/{id}/complete – Charge abschließen")
    class CompleteBatchEndpoint {

        private String completeToken;

        @BeforeEach
        void setUpCompleteToken() {
            completeToken = generateToken(UUID.randomUUID().toString(), "complete.admin",
                    "BATCH_WRITE,BATCH_READ,BATCH_COMPLETE,RECIPE_WRITE,RECIPE_READ");
        }

        private String createInProductionBatchWithConsumption() throws Exception {
            String recipeId = createActiveRecipeWith(completeToken);

            // Plan batch
            var planRequest = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
            var planResult = mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(planRequest)))
                    .andExpect(status().isCreated())
                    .andReturn();
            String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();

            // Start production
            mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
                            .header("Authorization", "Bearer " + completeToken))
                    .andExpect(status().isOk());

            // Record consumption
            String consumptionJson = """
                    {"inputBatchId": "%s", "articleId": "%s", "quantityUsed": "5.0", "quantityUnit": "KILOGRAM"}
                    """.formatted(UUID.randomUUID().toString(), UUID.randomUUID().toString());
            mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(consumptionJson))
                    .andExpect(status().isCreated());

            return batchId;
        }

        @Test
        @DisplayName("Charge abschließen mit Ist-Menge und Ausschuss → 200, Status COMPLETED")
        void completeBatch_withValidData_returns200() throws Exception {
            String batchId = createInProductionBatchWithConsumption();

            String completeJson = """
                    {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM", "remarks": "Alles ok"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.status").value("COMPLETED"))
                    .andExpect(jsonPath("$.actualQuantity").value("95.000000"))
                    .andExpect(jsonPath("$.actualQuantityUnit").value("KILOGRAM"))
                    .andExpect(jsonPath("$.waste").value("5.000000"))
                    .andExpect(jsonPath("$.wasteUnit").value("KILOGRAM"))
                    .andExpect(jsonPath("$.remarks").value("Alles ok"))
                    .andExpect(jsonPath("$.completedAt").isNotEmpty());
        }

        @Test
        @DisplayName("Ohne Consumptions → 409 (MissingConsumptions)")
        void completeBatch_withoutConsumptions_returns400() throws Exception {
            String recipeId = createActiveRecipeWith(completeToken);

            // Plan + Start, aber keine Consumption
            var planRequest = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
            var planResult = mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(planRequest)))
                    .andExpect(status().isCreated())
                    .andReturn();
            String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();

            mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
                            .header("Authorization", "Bearer " + completeToken))
                    .andExpect(status().isOk());

            String completeJson = """
                    {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("BATCH_MISSING_CONSUMPTIONS"));
        }

        @Test
        @DisplayName("ActualQuantity 0 → 400")
        void completeBatch_zeroActualQuantity_returns400() throws Exception {
            String batchId = createInProductionBatchWithConsumption();

            String completeJson = """
                    {"actualQuantity": "0", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_ACTUAL_QUANTITY"));
        }

        @Test
        @DisplayName("Negativer Waste → 400")
        void completeBatch_negativeWaste_returns400() throws Exception {
            String batchId = createInProductionBatchWithConsumption();

            String completeJson = """
                    {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "-1", "wasteUnit": "KILOGRAM"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_WASTE"));
        }

        @Test
        @DisplayName("PLANNED Batch (nicht gestartet) → 409")
        void completeBatch_plannedBatch_returns409() throws Exception {
            String recipeId = createActiveRecipeWith(completeToken);

            var planRequest = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
            var planResult = mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(planRequest)))
                    .andExpect(status().isCreated())
                    .andReturn();
            String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();

            String completeJson = """
                    {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("Bereits COMPLETED → 409")
        void completeBatch_alreadyCompleted_returns409() throws Exception {
            String batchId = createInProductionBatchWithConsumption();

            String completeJson = """
                    {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
                    """;

            // First complete → success
            mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isOk());

            // Second complete → conflict
            mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("Batch nicht gefunden → 404")
        void completeBatch_notFound_returns404() throws Exception {
            String completeJson = """
                    {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/complete", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isNotFound())
                    .andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
        }

        @Test
        @DisplayName("Bean Validation: actualQuantity leer → 400")
        void completeBatch_blankActualQuantity_returns400() throws Exception {
            String completeJson = """
                    {"actualQuantity": "", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/complete", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + completeToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isBadRequest());
        }

        @Test
        @DisplayName("Ohne Token → 401")
        void completeBatch_withoutToken_returns401() throws Exception {
            String completeJson = """
                    {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/complete", UUID.randomUUID().toString())
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isUnauthorized());
        }

        @Test
        @DisplayName("Ohne BATCH_COMPLETE → 403")
        void completeBatch_withoutPermission_returns403() throws Exception {
            String completeJson = """
                    {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/complete", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + viewerToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isForbidden());
        }
    }

    @Nested
    @DisplayName("POST /api/production/batches/{id}/cancel – Charge stornieren")
    class CancelBatchEndpoint {

        private String cancelToken;

        @BeforeEach
        void setUpCancelToken() {
            cancelToken = generateToken(UUID.randomUUID().toString(), "cancel.admin",
                    "BATCH_WRITE,BATCH_READ,BATCH_CANCEL,RECIPE_WRITE,RECIPE_READ");
        }

        private String createPlannedBatch() throws Exception {
            String recipeId = createActiveRecipeWith(cancelToken);
            var planRequest = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
            var planResult = mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + cancelToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(planRequest)))
                    .andExpect(status().isCreated())
                    .andReturn();
            return objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();
        }

        private String createInProductionBatch() throws Exception {
            String batchId = createPlannedBatch();
            mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
                            .header("Authorization", "Bearer " + cancelToken))
                    .andExpect(status().isOk());
            return batchId;
        }

        @Test
        @DisplayName("PLANNED Charge stornieren → 200, Status CANCELLED")
        void cancelBatch_planned_returns200() throws Exception {
            String batchId = createPlannedBatch();

            String cancelJson = """
                    {"reason": "Fehlplanung"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId)
                            .header("Authorization", "Bearer " + cancelToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(cancelJson))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.status").value("CANCELLED"))
                    .andExpect(jsonPath("$.cancellationReason").value("Fehlplanung"))
                    .andExpect(jsonPath("$.cancelledAt").isNotEmpty());
        }

        @Test
        @DisplayName("IN_PRODUCTION Charge stornieren → 200, Status CANCELLED")
        void cancelBatch_inProduction_returns200() throws Exception {
            String batchId = createInProductionBatch();

            String cancelJson = """
                    {"reason": "Qualitätsproblem"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId)
                            .header("Authorization", "Bearer " + cancelToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(cancelJson))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.status").value("CANCELLED"))
                    .andExpect(jsonPath("$.cancellationReason").value("Qualitätsproblem"));
        }

        @Test
        @DisplayName("Bereits stornierte Charge erneut stornieren → 409")
        void cancelBatch_alreadyCancelled_returns409() throws Exception {
            String batchId = createPlannedBatch();

            String cancelJson = """
                    {"reason": "Erster Grund"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId)
                            .header("Authorization", "Bearer " + cancelToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(cancelJson))
                    .andExpect(status().isOk());

            mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId)
                            .header("Authorization", "Bearer " + cancelToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(cancelJson))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("Reason leer → 400 (Bean Validation)")
        void cancelBatch_blankReason_returns400() throws Exception {
            String cancelJson = """
                    {"reason": ""}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + cancelToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(cancelJson))
                    .andExpect(status().isBadRequest());
        }

        @Test
        @DisplayName("Batch nicht gefunden → 404")
        void cancelBatch_notFound_returns404() throws Exception {
            String cancelJson = """
                    {"reason": "Grund"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + cancelToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(cancelJson))
                    .andExpect(status().isNotFound())
                    .andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
        }

        @Test
        @DisplayName("Ohne Token → 401")
        void cancelBatch_withoutToken_returns401() throws Exception {
            String cancelJson = """
                    {"reason": "Grund"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString())
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(cancelJson))
                    .andExpect(status().isUnauthorized());
        }

        @Test
        @DisplayName("Ohne BATCH_CANCEL → 403")
        void cancelBatch_withoutPermission_returns403() throws Exception {
            String cancelJson = """
                    {"reason": "Grund"}
                    """;

            mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + viewerToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(cancelJson))
                    .andExpect(status().isForbidden());
        }
    }

    @Nested
    @DisplayName("GET /api/production/batches/{id}/trace-forward – Vorwärts-Tracing")
    class TraceForwardEndpoint {

        private String traceToken;

        @BeforeEach
        void setUpTraceToken() {
            traceToken = generateToken(UUID.randomUUID().toString(), "trace.admin",
                    "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
        }

        @Test
        @DisplayName("Kein Downstream → 200, leere Liste")
        void traceForward_noDownstream_returnsEmptyList() throws Exception {
            String batchId = createPlannedBatchWith(traceToken);

            mockMvc.perform(get("/api/production/batches/{id}/trace-forward", batchId)
                            .header("Authorization", "Bearer " + traceToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.originBatchId").value(batchId))
                    .andExpect(jsonPath("$.tracedBatches").isArray())
                    .andExpect(jsonPath("$.tracedBatches").isEmpty())
                    .andExpect(jsonPath("$.totalCount").value(0));
        }

        @Test
        @DisplayName("Single-Level Kette → 200, depth=1")
        void traceForward_singleLevel_returnsDepth1() throws Exception {
            // Rohstoff-Charge (Input)
            String inputBatchId = createPlannedBatchWith(traceToken);

            // Endprodukt-Charge verbraucht die Input-Charge
            String outputBatchId = createBatchWithConsumption(traceToken, inputBatchId);

            mockMvc.perform(get("/api/production/batches/{id}/trace-forward", inputBatchId)
                            .header("Authorization", "Bearer " + traceToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.originBatchId").value(inputBatchId))
                    .andExpect(jsonPath("$.totalCount").value(1))
                    .andExpect(jsonPath("$.tracedBatches[0].id").value(outputBatchId))
                    .andExpect(jsonPath("$.tracedBatches[0].depth").value(1))
                    .andExpect(jsonPath("$.tracedBatches[0].batchNumber").isNotEmpty())
                    .andExpect(jsonPath("$.tracedBatches[0].status").isNotEmpty());
        }

        @Test
        @DisplayName("Batch nicht gefunden → 404")
        void traceForward_notFound_returns404() throws Exception {
            mockMvc.perform(get("/api/production/batches/{id}/trace-forward", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + traceToken))
                    .andExpect(status().isNotFound())
                    .andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
        }

        @Test
        @DisplayName("Ohne Token → 401")
        void traceForward_withoutToken_returns401() throws Exception {
            mockMvc.perform(get("/api/production/batches/{id}/trace-forward", UUID.randomUUID().toString()))
                    .andExpect(status().isUnauthorized());
        }

        @Test
        @DisplayName("Ohne BATCH_READ → 403")
        void traceForward_withoutPermission_returns403() throws Exception {
            mockMvc.perform(get("/api/production/batches/{id}/trace-forward", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + viewerToken))
                    .andExpect(status().isForbidden());
        }

        private String createPlannedBatchWith(String token) throws Exception {
            String recipeId = createActiveRecipeWith(token);
            var planRequest = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
            var planResult = mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + token)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(planRequest)))
                    .andExpect(status().isCreated())
                    .andReturn();
            return objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();
        }

        private String createBatchWithConsumption(String token, String inputBatchId) throws Exception {
            String recipeId = createActiveRecipeWith(token);
            var planRequest = new PlanBatchRequest(
                    recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
            var planResult = mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + token)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(planRequest)))
                    .andExpect(status().isCreated())
                    .andReturn();
            String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();

            // Start production
            mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
                            .header("Authorization", "Bearer " + token))
                    .andExpect(status().isOk());

            // Record consumption with inputBatchId
            String consumptionJson = """
                    {"inputBatchId": "%s", "articleId": "%s", "quantityUsed": "10.0", "quantityUnit": "KILOGRAM"}
                    """.formatted(inputBatchId, UUID.randomUUID().toString());
            mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
                            .header("Authorization", "Bearer " + token)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(consumptionJson))
                    .andExpect(status().isCreated());

            return batchId;
        }
    }

    // ==================== GET /api/production/batches/{id}/trace-backward – Rückwärts-Tracing ====================

    @Nested
    @DisplayName("GET /api/production/batches/{id}/trace-backward – Rückwärts-Tracing")
    class TraceBackwardEndpoint {

        private String traceToken;

        @BeforeEach
        void setUpTraceToken() {
            traceToken = generateToken(UUID.randomUUID().toString(), "trace.admin",
                    "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
        }

        @Test
        @DisplayName("Kein Upstream → 200, leere Liste")
        void traceBackward_noUpstream_returnsEmptyList() throws Exception {
            String batchId = createPlannedBatchWith(traceToken);

            mockMvc.perform(get("/api/production/batches/{id}/trace-backward", batchId)
                            .header("Authorization", "Bearer " + traceToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.originBatchId").value(batchId))
                    .andExpect(jsonPath("$.tracedBatches").isArray())
                    .andExpect(jsonPath("$.tracedBatches").isEmpty())
                    .andExpect(jsonPath("$.totalCount").value(0));
        }

        @Test
        @DisplayName("Single-Level Kette → 200, depth=1, upstream Chargen")
        void traceBackward_singleLevel_returnsDepth1() throws Exception {
            // Rohstoff-Charge (Input)
            String inputBatchId = createPlannedBatchWith(traceToken);

            // Endprodukt-Charge verbraucht die Input-Charge
            String outputBatchId = createBatchWithConsumption(traceToken, inputBatchId);

            mockMvc.perform(get("/api/production/batches/{id}/trace-backward", outputBatchId)
                            .header("Authorization", "Bearer " + traceToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.originBatchId").value(outputBatchId))
                    .andExpect(jsonPath("$.totalCount").value(1))
                    .andExpect(jsonPath("$.tracedBatches[0].id").value(inputBatchId))
                    .andExpect(jsonPath("$.tracedBatches[0].depth").value(1))
                    .andExpect(jsonPath("$.tracedBatches[0].batchNumber").isNotEmpty())
                    .andExpect(jsonPath("$.tracedBatches[0].status").isNotEmpty());
        }

        @Test
        @DisplayName("Batch nicht gefunden → 404")
        void traceBackward_notFound_returns404() throws Exception {
            mockMvc.perform(get("/api/production/batches/{id}/trace-backward", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + traceToken))
                    .andExpect(status().isNotFound())
                    .andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
        }

        @Test
        @DisplayName("Ohne Token → 401")
        void traceBackward_withoutToken_returns401() throws Exception {
            mockMvc.perform(get("/api/production/batches/{id}/trace-backward", UUID.randomUUID().toString()))
                    .andExpect(status().isUnauthorized());
        }

        @Test
        @DisplayName("Ohne BATCH_READ → 403")
        void traceBackward_withoutPermission_returns403() throws Exception {
            mockMvc.perform(get("/api/production/batches/{id}/trace-backward", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + viewerToken))
                    .andExpect(status().isForbidden());
        }

        private String createPlannedBatchWith(String token) throws Exception {
            String recipeId = createActiveRecipeWith(token);
            var planRequest = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
            var planResult = mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + token)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(planRequest)))
                    .andExpect(status().isCreated())
                    .andReturn();
            return objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();
        }

        private String createBatchWithConsumption(String token, String inputBatchId) throws Exception {
            String recipeId = createActiveRecipeWith(token);
            var planRequest = new PlanBatchRequest(
                    recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
            var planResult = mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + token)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(planRequest)))
                    .andExpect(status().isCreated())
                    .andReturn();
            String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();

            // Start production
            mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
                            .header("Authorization", "Bearer " + token))
                    .andExpect(status().isOk());

            // Record consumption with inputBatchId
            String consumptionJson = """
                    {"inputBatchId": "%s", "articleId": "%s", "quantityUsed": "10.0", "quantityUnit": "KILOGRAM"}
                    """.formatted(inputBatchId, UUID.randomUUID().toString());
            mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
                            .header("Authorization", "Bearer " + token)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(consumptionJson))
                    .andExpect(status().isCreated());

            return batchId;
        }
    }

    // ==================== POST /api/production/batches/{id}/start – ungültige Übergänge ====================

    @Nested
    @DisplayName("POST /api/production/batches/{id}/start – Charge starten")
    class StartBatchEndpoint {

        private String startToken;

        @BeforeEach
        void setUpStartToken() {
            startToken = generateToken(java.util.UUID.randomUUID().toString(), "start.admin",
                    "BATCH_WRITE,BATCH_READ,BATCH_COMPLETE,BATCH_CANCEL,RECIPE_WRITE,RECIPE_READ");
        }

        @Test
        @DisplayName("COMPLETED Charge starten → 409")
        void startBatch_fromCompleted_returns409() throws Exception {
            String recipeId = createActiveRecipeWith(startToken);

            // Plan batch
            var planRequest = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
            var planResult = mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + startToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(planRequest)))
                    .andExpect(status().isCreated())
                    .andReturn();
            String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();

            // Start
            mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
                            .header("Authorization", "Bearer " + startToken))
                    .andExpect(status().isOk());

            // Record consumption
            String consumptionJson = """
                    {"inputBatchId": "%s", "articleId": "%s", "quantityUsed": "5.0", "quantityUnit": "KILOGRAM"}
                    """.formatted(UUID.randomUUID().toString(), UUID.randomUUID().toString());
            mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
                            .header("Authorization", "Bearer " + startToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(consumptionJson))
                    .andExpect(status().isCreated());

            // Complete
            String completeJson = """
                    {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
                    """;
            mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
                            .header("Authorization", "Bearer " + startToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isOk());

            // Try to start again → 409
            mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
                            .header("Authorization", "Bearer " + startToken))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("COMPLETED Charge stornieren → 409")
        void cancelBatch_fromCompleted_returns409() throws Exception {
            String recipeId = createActiveRecipeWith(startToken);

            // Plan batch
            var planRequest = new PlanBatchRequest(
                    recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
            var planResult = mockMvc.perform(post("/api/production/batches")
                            .header("Authorization", "Bearer " + startToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(planRequest)))
                    .andExpect(status().isCreated())
                    .andReturn();
            String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();

            // Start
            mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
                            .header("Authorization", "Bearer " + startToken))
                    .andExpect(status().isOk());

            // Record consumption
            String consumptionJson = """
                    {"inputBatchId": "%s", "articleId": "%s", "quantityUsed": "5.0", "quantityUnit": "KILOGRAM"}
                    """.formatted(UUID.randomUUID().toString(), UUID.randomUUID().toString());
            mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
                            .header("Authorization", "Bearer " + startToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(consumptionJson))
                    .andExpect(status().isCreated());

            // Complete
            String completeJson = """
                    {"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
                    """;
            mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
                            .header("Authorization", "Bearer " + startToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(completeJson))
                    .andExpect(status().isOk());

            // Try to cancel → 409
            String cancelJson = """
                    {"reason": "Nachträgliche Stornierung"}
                    """;
            mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId)
                            .header("Authorization", "Bearer " + startToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(cancelJson))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION"));
        }
    }

    // ==================== Hilfsmethoden ====================

    private String createActiveRecipe() throws Exception {
        return createActiveRecipeWith(adminToken);
    }

    private String createActiveRecipeWith(String token) throws Exception {
        String recipeId = createDraftRecipeWith(token);

        // Add ingredient (required for activation)
        String ingredientJson = """
                {"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false}
                """.formatted(UUID.randomUUID().toString());

        mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId)
                        .header("Authorization", "Bearer " + token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(ingredientJson))
                .andExpect(status().isCreated());

        // Activate
        mockMvc.perform(post("/api/recipes/{id}/activate", recipeId)
                        .header("Authorization", "Bearer " + token))
                .andExpect(status().isOk());

        return recipeId;
    }

    private String createDraftRecipe() throws Exception {
        return createDraftRecipeWith(adminToken);
    }

    private String createDraftRecipeWith(String token) throws Exception {
        String json = """
                {
                    "name": "Test-Rezept-%s",
                    "version": 1,
                    "type": "FINISHED_PRODUCT",
                    "description": "Testrezept",
                    "yieldPercentage": 85,
                    "shelfLifeDays": 14,
                    "outputQuantity": "100",
                    "outputUom": "KILOGRAM",
                    "articleId": "article-123"
                }
                """.formatted(UUID.randomUUID().toString().substring(0, 8));

        var result = mockMvc.perform(post("/api/recipes")
                        .header("Authorization", "Bearer " + token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(json))
                .andExpect(status().isCreated())
                .andReturn();

        return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
    }
}
