package de.effigenix.infrastructure.production.web;

import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest;
import de.effigenix.infrastructure.production.web.dto.CreateProductionOrderRequest;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import de.effigenix.infrastructure.production.web.dto.RecordConsumptionRequest;
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("ProductionOrder Controller Integration Tests")
class ProductionOrderControllerIntegrationTest extends AbstractIntegrationTest {

    private String adminToken;
    private String viewerToken;

    private static final LocalDate PLANNED_DATE = LocalDate.now().plusDays(7);

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

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

        adminToken = generateToken(adminId, "po.admin",
                "PRODUCTION_ORDER_WRITE,PRODUCTION_ORDER_READ,RECIPE_WRITE,RECIPE_READ,BATCH_WRITE,BATCH_READ,BATCH_COMPLETE,BATCH_CANCEL");
        viewerToken = generateToken(viewerId, "po.viewer", "USER_READ");
    }

    @Nested
    @DisplayName("POST /api/production/production-orders – Produktionsauftrag anlegen")
    class CreateProductionOrderEndpoint {

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

            var request = new CreateProductionOrderRequest(
                    recipeId, "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

            mockMvc.perform(post("/api/production/production-orders")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.id").isNotEmpty())
                    .andExpect(jsonPath("$.recipeId").value(recipeId))
                    .andExpect(jsonPath("$.status").value("PLANNED"))
                    .andExpect(jsonPath("$.plannedQuantity").value("100.000000"))
                    .andExpect(jsonPath("$.plannedQuantityUnit").value("KILOGRAM"))
                    .andExpect(jsonPath("$.plannedDate").value(PLANNED_DATE.toString()))
                    .andExpect(jsonPath("$.priority").value("NORMAL"))
                    .andExpect(jsonPath("$.notes").doesNotExist())
                    .andExpect(jsonPath("$.createdAt").isNotEmpty())
                    .andExpect(jsonPath("$.updatedAt").isNotEmpty());
        }

        @Test
        @DisplayName("Produktionsauftrag mit Notes → 201")
        void createOrder_withNotes_returns201() throws Exception {
            String recipeId = createActiveRecipe();

            var request = new CreateProductionOrderRequest(
                    recipeId, "50", "LITER", PLANNED_DATE, "URGENT", "Eilauftrag Kunde X");

            mockMvc.perform(post("/api/production/production-orders")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.priority").value("URGENT"))
                    .andExpect(jsonPath("$.notes").value("Eilauftrag Kunde X"));
        }

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

            var request = new CreateProductionOrderRequest(
                    recipeId, "0", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

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

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

            var request = new CreateProductionOrderRequest(
                    recipeId, "-10", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

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

        @Test
        @DisplayName("PlannedDate in der Vergangenheit → 400")
        void createOrder_pastDate_returns400() throws Exception {
            String recipeId = createActiveRecipe();

            var request = new CreateProductionOrderRequest(
                    recipeId, "100", "KILOGRAM", LocalDate.now().minusDays(1), "NORMAL", null);

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

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

            var request = new CreateProductionOrderRequest(
                    recipeId, "100", "KILOGRAM", PLANNED_DATE, "SUPER_HIGH", null);

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

        @Test
        @DisplayName("Rezept nicht gefunden → 400")
        void createOrder_recipeNotFound_returns400() throws Exception {
            var request = new CreateProductionOrderRequest(
                    UUID.randomUUID().toString(), "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

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

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

            var request = new CreateProductionOrderRequest(
                    recipeId, "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

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

        @Test
        @DisplayName("recipeId leer → 400 (Bean Validation)")
        void createOrder_blankRecipeId_returns400() throws Exception {
            var request = new CreateProductionOrderRequest(
                    "", "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

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

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

            var request = new CreateProductionOrderRequest(
                    recipeId, "100", "INVALID_UNIT", PLANNED_DATE, "NORMAL", null);

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

        @Test
        @DisplayName("PlannedDate heute → 201 (Grenzwert)")
        void createOrder_todayDate_returns201() throws Exception {
            String recipeId = createActiveRecipe();

            var request = new CreateProductionOrderRequest(
                    recipeId, "100", "KILOGRAM", LocalDate.now(), "NORMAL", null);

            mockMvc.perform(post("/api/production/production-orders")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.plannedDate").value(LocalDate.now().toString()));
        }

        @Test
        @DisplayName("priority leer → 400 (Bean Validation)")
        void createOrder_blankPriority_returns400() throws Exception {
            var request = new CreateProductionOrderRequest(
                    UUID.randomUUID().toString(), "100", "KILOGRAM", PLANNED_DATE, "", null);

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

        @Test
        @DisplayName("plannedDate null → 400 (Bean Validation)")
        void createOrder_nullDate_returns400() throws Exception {
            String json = """
                    {"recipeId": "%s", "plannedQuantity": "100", "plannedQuantityUnit": "KILOGRAM", "priority": "NORMAL"}
                    """.formatted(UUID.randomUUID().toString());

            mockMvc.perform(post("/api/production/production-orders")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isBadRequest());
        }

        @Test
        @DisplayName("Alle Priority-Werte → 201")
        void createOrder_allPriorities_returns201() throws Exception {
            for (String priority : new String[]{"LOW", "NORMAL", "HIGH", "URGENT"}) {
                String recipeId = createActiveRecipe();

                var request = new CreateProductionOrderRequest(
                        recipeId, "100", "KILOGRAM", PLANNED_DATE, priority, null);

                mockMvc.perform(post("/api/production/production-orders")
                                .header("Authorization", "Bearer " + adminToken)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(objectMapper.writeValueAsString(request)))
                        .andExpect(status().isCreated())
                        .andExpect(jsonPath("$.priority").value(priority));
            }
        }

        @Test
        @DisplayName("plannedQuantity leer → 400 (Bean Validation)")
        void createOrder_blankQuantity_returns400() throws Exception {
            var request = new CreateProductionOrderRequest(
                    UUID.randomUUID().toString(), "", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

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

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

        @Test
        @DisplayName("Ohne PRODUCTION_ORDER_WRITE → 403")
        void createOrder_withViewerToken_returns403() throws Exception {
            var request = new CreateProductionOrderRequest(
                    UUID.randomUUID().toString(), "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

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

        @Test
        @DisplayName("Ohne Token → 401")
        void createOrder_withoutToken_returns401() throws Exception {
            var request = new CreateProductionOrderRequest(
                    UUID.randomUUID().toString(), "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

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

    @Nested
    @DisplayName("POST /api/production/production-orders/{id}/release – Produktionsauftrag freigeben")
    class ReleaseProductionOrderEndpoint {

        @Test
        @DisplayName("PLANNED Order freigeben → 200, Status RELEASED")
        void releaseOrder_planned_returns200() throws Exception {
            String orderId = createPlannedOrder();

            mockMvc.perform(post("/api/production/production-orders/{id}/release", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.id").value(orderId))
                    .andExpect(jsonPath("$.status").value("RELEASED"));
        }

        @Test
        @DisplayName("Bereits RELEASED Order erneut freigeben → 409")
        void releaseOrder_alreadyReleased_returns409() throws Exception {
            String orderId = createPlannedOrder();

            // First release
            mockMvc.perform(post("/api/production/production-orders/{id}/release", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk());

            // Second release
            mockMvc.perform(post("/api/production/production-orders/{id}/release", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("Order nicht gefunden → 404")
        void releaseOrder_notFound_returns404() throws Exception {
            mockMvc.perform(post("/api/production/production-orders/{id}/release", "non-existent-id")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isNotFound())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_NOT_FOUND"));
        }

        @Test
        @DisplayName("Ohne PRODUCTION_ORDER_WRITE → 403")
        void releaseOrder_withViewerToken_returns403() throws Exception {
            mockMvc.perform(post("/api/production/production-orders/{id}/release", "any-id")
                            .header("Authorization", "Bearer " + viewerToken))
                    .andExpect(status().isForbidden());
        }

        @Test
        @DisplayName("Ohne Token → 401")
        void releaseOrder_withoutToken_returns401() throws Exception {
            mockMvc.perform(post("/api/production/production-orders/{id}/release", "any-id"))
                    .andExpect(status().isUnauthorized());
        }

        @Test
        @DisplayName("Order mit archiviertem Rezept freigeben → 409")
        void releaseOrder_archivedRecipe_returns409() throws Exception {
            String orderId = createOrderWithArchivedRecipe();

            mockMvc.perform(post("/api/production/production-orders/{id}/release", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_RECIPE_NOT_ACTIVE"));
        }
    }

    @Nested
    @DisplayName("POST /api/production/production-orders/{id}/start – Produktion starten")
    class StartProductionOrderEndpoint {

        @Test
        @DisplayName("RELEASED Order starten → 200, Batch automatisch erstellt, Status IN_PROGRESS")
        void startOrder_released_returns200() throws Exception {
            String orderId = createReleasedOrder();

            mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.id").value(orderId))
                    .andExpect(jsonPath("$.status").value("IN_PROGRESS"))
                    .andExpect(jsonPath("$.batchId").isNotEmpty())
                    .andExpect(jsonPath("$.batchNumber").isNotEmpty());
        }

        @Test
        @DisplayName("PLANNED Order starten → 409 (InvalidStatusTransition)")
        void startOrder_plannedOrder_returns409() throws Exception {
            String orderId = createPlannedOrder();

            mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("Order nicht gefunden → 404")
        void startOrder_notFound_returns404() throws Exception {
            mockMvc.perform(post("/api/production/production-orders/{id}/start", "non-existent-id")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isNotFound())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_NOT_FOUND"));
        }

        @Test
        @DisplayName("Ohne PRODUCTION_ORDER_WRITE → 403")
        void startOrder_withViewerToken_returns403() throws Exception {
            mockMvc.perform(post("/api/production/production-orders/{id}/start", "any-id")
                            .header("Authorization", "Bearer " + viewerToken))
                    .andExpect(status().isForbidden());
        }

        @Test
        @DisplayName("Ohne Token → 401")
        void startOrder_withoutToken_returns401() throws Exception {
            mockMvc.perform(post("/api/production/production-orders/{id}/start", "any-id"))
                    .andExpect(status().isUnauthorized());
        }

        @Test
        @DisplayName("Bereits gestartete Order erneut starten → 409")
        void startOrder_alreadyStarted_returns409() throws Exception {
            String orderId = createReleasedOrder();

            // First start
            mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk());

            // Second start
            mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("Order mit archiviertem Rezept starten → 400")
        void startOrder_archivedRecipe_returns400() throws Exception {
            String orderId = createReleasedOrderThenArchiveRecipe();

            mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_VALIDATION_ERROR"))
                    .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("not ACTIVE")));
        }
    }

    @Nested
    @DisplayName("POST /api/production/production-orders/{id}/complete – Produktionsauftrag abschließen")
    class CompleteProductionOrderEndpoint {

        @Test
        @DisplayName("IN_PROGRESS Order mit COMPLETED Batch abschließen → 200")
        void completeOrder_withCompletedBatch_returns200() throws Exception {
            String[] orderAndBatch = createInProgressOrderWithCompletedBatch();
            String orderId = orderAndBatch[0];

            mockMvc.perform(post("/api/production/production-orders/{id}/complete", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.id").value(orderId))
                    .andExpect(jsonPath("$.status").value("COMPLETED"));
        }

        @Test
        @DisplayName("IN_PROGRESS Order mit nicht-COMPLETED Batch → 409")
        void completeOrder_batchNotCompleted_returns409() throws Exception {
            String orderId = createInProgressOrder();

            mockMvc.perform(post("/api/production/production-orders/{id}/complete", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_BATCH_NOT_COMPLETED"));
        }

        @Test
        @DisplayName("PLANNED Order abschließen → 400 (kein Batch zugewiesen)")
        void completeOrder_plannedOrder_returns400() throws Exception {
            String orderId = createPlannedOrder();

            mockMvc.perform(post("/api/production/production-orders/{id}/complete", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_VALIDATION_ERROR"));
        }

        @Test
        @DisplayName("RELEASED Order abschließen → 400 (kein Batch zugewiesen)")
        void completeOrder_releasedOrder_returns400() throws Exception {
            String orderId = createReleasedOrder();

            mockMvc.perform(post("/api/production/production-orders/{id}/complete", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_VALIDATION_ERROR"));
        }

        @Test
        @DisplayName("Order nicht gefunden → 404")
        void completeOrder_notFound_returns404() throws Exception {
            mockMvc.perform(post("/api/production/production-orders/{id}/complete", "non-existent-id")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isNotFound())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_NOT_FOUND"));
        }

        @Test
        @DisplayName("Bereits COMPLETED Order erneut abschließen → 409")
        void completeOrder_alreadyCompleted_returns409() throws Exception {
            String[] orderAndBatch = createInProgressOrderWithCompletedBatch();
            String orderId = orderAndBatch[0];

            // First complete
            mockMvc.perform(post("/api/production/production-orders/{id}/complete", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk());

            // Second complete
            mockMvc.perform(post("/api/production/production-orders/{id}/complete", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("Ohne PRODUCTION_ORDER_WRITE → 403")
        void completeOrder_withViewerToken_returns403() throws Exception {
            mockMvc.perform(post("/api/production/production-orders/{id}/complete", "any-id")
                            .header("Authorization", "Bearer " + viewerToken))
                    .andExpect(status().isForbidden());
        }

        @Test
        @DisplayName("Ohne Token → 401")
        void completeOrder_withoutToken_returns401() throws Exception {
            mockMvc.perform(post("/api/production/production-orders/{id}/complete", "any-id"))
                    .andExpect(status().isUnauthorized());
        }
    }

    @Nested
    @DisplayName("POST /api/production/production-orders/{id}/cancel – Produktionsauftrag stornieren")
    class CancelProductionOrderEndpoint {

        @Test
        @DisplayName("PLANNED Order stornieren → 200")
        void cancelOrder_planned_returns200() throws Exception {
            String orderId = createPlannedOrder();

            String json = """
                    {"reason": "Kunde hat storniert"}
                    """;

            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.id").value(orderId))
                    .andExpect(jsonPath("$.status").value("CANCELLED"))
                    .andExpect(jsonPath("$.cancelledReason").value("Kunde hat storniert"));
        }

        @Test
        @DisplayName("RELEASED Order stornieren → 200")
        void cancelOrder_released_returns200() throws Exception {
            String orderId = createReleasedOrder();

            String json = """
                    {"reason": "Material nicht verfügbar"}
                    """;

            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.id").value(orderId))
                    .andExpect(jsonPath("$.status").value("CANCELLED"))
                    .andExpect(jsonPath("$.cancelledReason").value("Material nicht verfügbar"));
        }

        @Test
        @DisplayName("IN_PROGRESS Order stornieren → 409")
        void cancelOrder_inProgress_returns409() throws Exception {
            String orderId = createInProgressOrder();

            String json = """
                    {"reason": "Storno-Versuch"}
                    """;

            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("COMPLETED Order stornieren → 409")
        void cancelOrder_completed_returns409() throws Exception {
            String[] orderAndBatch = createInProgressOrderWithCompletedBatch();
            String orderId = orderAndBatch[0];

            // Complete it first
            mockMvc.perform(post("/api/production/production-orders/{id}/complete", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk());

            String json = """
                    {"reason": "Storno-Versuch nach Abschluss"}
                    """;

            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("Bereits CANCELLED Order erneut stornieren → 409")
        void cancelOrder_alreadyCancelled_returns409() throws Exception {
            String orderId = createPlannedOrder();

            String json = """
                    {"reason": "Erster Storno"}
                    """;

            // First cancel
            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isOk());

            // Second cancel
            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION"));
        }

        @Test
        @DisplayName("Ohne reason → 400 (Bean Validation)")
        void cancelOrder_blankReason_returns400() throws Exception {
            String json = """
                    {"reason": ""}
                    """;

            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", "any-id")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isBadRequest());
        }

        @Test
        @DisplayName("Order nicht gefunden → 404")
        void cancelOrder_notFound_returns404() throws Exception {
            String json = """
                    {"reason": "Test"}
                    """;

            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", "non-existent-id")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isNotFound())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_NOT_FOUND"));
        }

        @Test
        @DisplayName("Ohne PRODUCTION_ORDER_WRITE → 403")
        void cancelOrder_withViewerToken_returns403() throws Exception {
            String json = """
                    {"reason": "Test"}
                    """;

            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", "any-id")
                            .header("Authorization", "Bearer " + viewerToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isForbidden());
        }

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

            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", "any-id")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isUnauthorized());
        }
    }

    @Nested
    @DisplayName("POST /api/production/production-orders/{id}/reschedule – Umterminieren")
    class RescheduleProductionOrderEndpoint {

        @Test
        @DisplayName("PLANNED Order umterminieren → 200")
        void reschedule_plannedOrder_returns200() throws Exception {
            String orderId = createPlannedOrder();
            LocalDate newDate = LocalDate.now().plusDays(30);

            String json = """
                    {"newPlannedDate": "%s"}
                    """.formatted(newDate);

            mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.plannedDate").value(newDate.toString()))
                    .andExpect(jsonPath("$.status").value("PLANNED"));
        }

        @Test
        @DisplayName("RELEASED Order umterminieren → 200")
        void reschedule_releasedOrder_returns200() throws Exception {
            String orderId = createReleasedOrder();
            LocalDate newDate = LocalDate.now().plusDays(30);

            String json = """
                    {"newPlannedDate": "%s"}
                    """.formatted(newDate);

            mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.plannedDate").value(newDate.toString()))
                    .andExpect(jsonPath("$.status").value("RELEASED"));
        }

        @Test
        @DisplayName("IN_PROGRESS Order umterminieren → 409")
        void reschedule_inProgressOrder_returns409() throws Exception {
            String orderId = createInProgressOrder();
            LocalDate newDate = LocalDate.now().plusDays(30);

            String json = """
                    {"newPlannedDate": "%s"}
                    """.formatted(newDate);

            mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_RESCHEDULE_NOT_ALLOWED"));
        }

        @Test
        @DisplayName("Vergangenes Datum → 400")
        void reschedule_pastDate_returns400() throws Exception {
            String orderId = createPlannedOrder();
            LocalDate pastDate = LocalDate.now().minusDays(1);

            String json = """
                    {"newPlannedDate": "%s"}
                    """.formatted(pastDate);

            mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_PLANNED_DATE_IN_PAST"));
        }

        @Test
        @DisplayName("COMPLETED Order umterminieren → 409")
        void reschedule_completedOrder_returns409() throws Exception {
            String[] orderAndBatch = createInProgressOrderWithCompletedBatch();
            String orderId = orderAndBatch[0];

            // Complete the order
            mockMvc.perform(post("/api/production/production-orders/{id}/complete", orderId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk());

            String json = """
                    {"newPlannedDate": "%s"}
                    """.formatted(LocalDate.now().plusDays(30));

            mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_RESCHEDULE_NOT_ALLOWED"));
        }

        @Test
        @DisplayName("CANCELLED Order umterminieren → 409")
        void reschedule_cancelledOrder_returns409() throws Exception {
            String orderId = createPlannedOrder();

            // Cancel the order
            String cancelJson = """
                    {"reason": "Testgrund"}
                    """;
            mockMvc.perform(post("/api/production/production-orders/{id}/cancel", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(cancelJson))
                    .andExpect(status().isOk());

            String json = """
                    {"newPlannedDate": "%s"}
                    """.formatted(LocalDate.now().plusDays(30));

            mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isConflict())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_RESCHEDULE_NOT_ALLOWED"));
        }

        @Test
        @DisplayName("Reschedule auf heute (Grenzwert) → 200")
        void reschedule_today_returns200() throws Exception {
            String orderId = createPlannedOrder();

            String json = """
                    {"newPlannedDate": "%s"}
                    """.formatted(LocalDate.now());

            mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId)
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.plannedDate").value(LocalDate.now().toString()));
        }

        @Test
        @DisplayName("Nicht existierende Order → 400")
        void reschedule_notFound_returns400() throws Exception {
            String json = """
                    {"newPlannedDate": "%s"}
                    """.formatted(LocalDate.now().plusDays(30));

            mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", java.util.UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isNotFound())
                    .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_NOT_FOUND"));
        }

        @Test
        @DisplayName("Nicht autorisiert → 403")
        void reschedule_unauthorized_returns403() throws Exception {
            String orderId = createPlannedOrder();
            LocalDate newDate = LocalDate.now().plusDays(30);

            String json = """
                    {"newPlannedDate": "%s"}
                    """.formatted(newDate);

            mockMvc.perform(post("/api/production/production-orders/{id}/reschedule", orderId)
                            .header("Authorization", "Bearer " + viewerToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(json))
                    .andExpect(status().isForbidden());
        }
    }

    @Nested
    @DisplayName("GET /api/production/production-orders – Auflisten")
    class ListProductionOrdersEndpoint {

        @Test
        @DisplayName("Alle Orders auflisten → 200")
        void listAll_returns200() throws Exception {
            createPlannedOrder();
            createPlannedOrder();

            mockMvc.perform(get("/api/production/production-orders")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.length()").value(org.hamcrest.Matchers.greaterThanOrEqualTo(2)));
        }

        @Test
        @DisplayName("Nach Status filtern → 200")
        void listByStatus_returns200() throws Exception {
            createPlannedOrder();
            createReleasedOrder();

            mockMvc.perform(get("/api/production/production-orders")
                            .param("status", "PLANNED")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$[0].status").value("PLANNED"));
        }

        @Test
        @DisplayName("Nach Datumsbereich filtern → 200")
        void listByDateRange_returns200() throws Exception {
            createPlannedOrder();

            mockMvc.perform(get("/api/production/production-orders")
                            .param("dateFrom", LocalDate.now().toString())
                            .param("dateTo", LocalDate.now().plusDays(30).toString())
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.length()").value(org.hamcrest.Matchers.greaterThanOrEqualTo(1)));
        }

        @Test
        @DisplayName("Nach Datumsbereich und Status filtern → 200")
        void listByDateRangeAndStatus_returns200() throws Exception {
            createPlannedOrder();

            mockMvc.perform(get("/api/production/production-orders")
                            .param("dateFrom", LocalDate.now().toString())
                            .param("dateTo", LocalDate.now().plusDays(30).toString())
                            .param("status", "PLANNED")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$[0].status").value("PLANNED"));
        }

        @Test
        @DisplayName("Leere Ergebnisse bei nicht matchendem Datumsbereich → 200 mit []")
        void listByDateRange_noMatch_returnsEmptyList() throws Exception {
            createPlannedOrder();

            mockMvc.perform(get("/api/production/production-orders")
                            .param("dateFrom", LocalDate.now().plusYears(10).toString())
                            .param("dateTo", LocalDate.now().plusYears(11).toString())
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.length()").value(0));
        }

        @Test
        @DisplayName("Nur dateFrom ohne dateTo → 400 Bad Request")
        void listWithOnlyDateFrom_returns400() throws Exception {
            mockMvc.perform(get("/api/production/production-orders")
                            .param("dateFrom", LocalDate.now().toString())
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isBadRequest());
        }

        @Test
        @DisplayName("Nicht autorisiert → 403")
        void list_unauthorized_returns403() throws Exception {
            mockMvc.perform(get("/api/production/production-orders")
                            .header("Authorization", "Bearer " + viewerToken))
                    .andExpect(status().isForbidden());
        }
    }

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

    private String createPlannedOrder() throws Exception {
        return createPlannedOrderWithRecipe()[0];
    }

    /** Returns [orderId, recipeId] */
    private String[] createPlannedOrderWithRecipe() throws Exception {
        String recipeId = createActiveRecipe();
        var request = new CreateProductionOrderRequest(
                recipeId, "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

        var result = mockMvc.perform(post("/api/production/production-orders")
                        .header("Authorization", "Bearer " + adminToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andReturn();

        String orderId = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
        return new String[]{orderId, recipeId};
    }

    private String createOrderWithArchivedRecipe() throws Exception {
        // Create active recipe, create order, then archive the recipe
        String recipeId = createActiveRecipe();
        var request = new CreateProductionOrderRequest(
                recipeId, "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null);

        var result = mockMvc.perform(post("/api/production/production-orders")
                        .header("Authorization", "Bearer " + adminToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andReturn();

        String orderId = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();

        // Archive the recipe
        mockMvc.perform(post("/api/recipes/{id}/archive", recipeId)
                        .header("Authorization", "Bearer " + adminToken))
                .andExpect(status().isOk());

        return orderId;
    }

    private String createActiveRecipe() throws Exception {
        String recipeId = createDraftRecipe();

        // 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 " + adminToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(ingredientJson))
                .andExpect(status().isCreated());

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

        return recipeId;
    }

    private String createReleasedOrder() throws Exception {
        return createReleasedOrderWithRecipe()[0];
    }

    /** Returns [orderId, recipeId] */
    private String[] createReleasedOrderWithRecipe() throws Exception {
        String[] orderAndRecipe = createPlannedOrderWithRecipe();

        mockMvc.perform(post("/api/production/production-orders/{id}/release", orderAndRecipe[0])
                        .header("Authorization", "Bearer " + adminToken))
                .andExpect(status().isOk());

        return orderAndRecipe;
    }

    /** Creates an IN_PROGRESS order (batch auto-created by start). Returns orderId. */
    private String createInProgressOrder() throws Exception {
        String orderId = createReleasedOrder();

        mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId)
                        .header("Authorization", "Bearer " + adminToken))
                .andExpect(status().isOk());

        return orderId;
    }

    /** Creates an IN_PROGRESS order with a COMPLETED batch. Returns [orderId, batchId]. */
    private String[] createInProgressOrderWithCompletedBatch() throws Exception {
        String[] orderAndRecipe = createReleasedOrderWithRecipe();
        String orderId = orderAndRecipe[0];
        String recipeId = orderAndRecipe[1];

        var startResult = mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId)
                        .header("Authorization", "Bearer " + adminToken))
                .andExpect(status().isOk())
                .andReturn();

        String batchId = objectMapper.readTree(startResult.getResponse().getContentAsString()).get("batchId").asText();

        // Record a consumption (required to complete batch)
        String inputBatchId = createPlannedBatch(recipeId);
        var consumptionRequest = new RecordConsumptionRequest(
                inputBatchId, UUID.randomUUID().toString(), "10", "KILOGRAM");
        mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
                        .header("Authorization", "Bearer " + adminToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(consumptionRequest)))
                .andExpect(status().isCreated());

        // Complete the batch
        var completeBatchRequest = new CompleteBatchRequest("95", "KILOGRAM", "5", "KILOGRAM", "Fertig");
        mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
                        .header("Authorization", "Bearer " + adminToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(completeBatchRequest)))
                .andExpect(status().isOk());

        return new String[]{orderId, batchId};
    }

    /** Creates a RELEASED order, then archives its recipe. Returns orderId. */
    private String createReleasedOrderThenArchiveRecipe() throws Exception {
        String[] orderAndRecipe = createReleasedOrderWithRecipe();
        String orderId = orderAndRecipe[0];
        String recipeId = orderAndRecipe[1];

        // Archive the recipe
        mockMvc.perform(post("/api/recipes/{id}/archive", recipeId)
                        .header("Authorization", "Bearer " + adminToken))
                .andExpect(status().isOk());

        return orderId;
    }

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

    private String createDraftRecipe() 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 " + adminToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(json))
                .andExpect(status().isCreated())
                .andReturn();

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