package de.effigenix.infrastructure.inventory.web;

import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
import de.effigenix.infrastructure.inventory.web.dto.RecordStockMovementRequest;
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.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("StockMovement Controller Integration Tests")
class StockMovementControllerIntegrationTest extends AbstractIntegrationTest {

    private String adminToken;
    private String viewerToken;

    private String storageLocationId;
    private String stockId;
    private String articleId;
    private String stockBatchId;

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

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

        adminToken = generateToken(adminId, "movement.admin",
                "STOCK_WRITE,STOCK_READ,STOCK_MOVEMENT_WRITE,STOCK_MOVEMENT_READ");
        viewerToken = generateToken(viewerId, "movement.viewer", "USER_READ");

        storageLocationId = createStorageLocation();
        articleId = createArticleId();
        stockId = createStock(articleId);
        stockBatchId = addBatchToStock(stockId);
    }

    // ==================== Bewegung erfassen ====================

    @Nested
    @DisplayName("POST /api/inventory/stock-movements")
    class RecordMovement {

        @Test
        @DisplayName("GOODS_RECEIPT erfassen → 201")
        void recordGoodsReceipt_returns201() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "GOODS_RECEIPT", null,
                    "10.5", "KILOGRAM",
                    null, null
            );

            mockMvc.perform(post("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.id").isNotEmpty())
                    .andExpect(jsonPath("$.stockId").value(stockId))
                    .andExpect(jsonPath("$.articleId").value(articleId))
                    .andExpect(jsonPath("$.movementType").value("GOODS_RECEIPT"))
                    .andExpect(jsonPath("$.direction").value("IN"))
                    .andExpect(jsonPath("$.quantityAmount").value(10.5))
                    .andExpect(jsonPath("$.quantityUnit").value("KILOGRAM"))
                    .andExpect(jsonPath("$.performedBy").isNotEmpty())
                    .andExpect(jsonPath("$.performedAt").isNotEmpty());
        }

        @Test
        @DisplayName("WASTE ohne Reason → 400")
        void recordWasteWithoutReason_returns400() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "WASTE", null,
                    "5.0", "KILOGRAM",
                    null, null
            );

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

        @Test
        @DisplayName("WASTE mit Reason → 201")
        void recordWasteWithReason_returns201() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "WASTE", null,
                    "2.0", "KILOGRAM",
                    "Expired product", null
            );

            mockMvc.perform(post("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.movementType").value("WASTE"))
                    .andExpect(jsonPath("$.direction").value("OUT"))
                    .andExpect(jsonPath("$.reason").value("Expired product"));
        }

        @Test
        @DisplayName("ADJUSTMENT IN mit Reason → 201")
        void recordAdjustmentIn_returns201() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "ADJUSTMENT", "IN",
                    "1.0", "KILOGRAM",
                    "Inventory correction", null
            );

            mockMvc.perform(post("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.movementType").value("ADJUSTMENT"))
                    .andExpect(jsonPath("$.direction").value("IN"));
        }

        @Test
        @DisplayName("INTER_BRANCH_TRANSFER ohne referenceDocumentId → 400")
        void recordTransferWithoutRefDoc_returns400() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "INTER_BRANCH_TRANSFER", null,
                    "3.0", "KILOGRAM",
                    null, null
            );

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

        @Test
        @DisplayName("PRODUCTION_OUTPUT erfassen → 201 (IN)")
        void recordProductionOutput_returns201() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "PRODUCTION_OUTPUT", null,
                    "8.0", "KILOGRAM",
                    null, null
            );

            mockMvc.perform(post("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.movementType").value("PRODUCTION_OUTPUT"))
                    .andExpect(jsonPath("$.direction").value("IN"));
        }

        @Test
        @DisplayName("PRODUCTION_CONSUMPTION erfassen → 201 (OUT)")
        void recordProductionConsumption_returns201() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "PRODUCTION_CONSUMPTION", null,
                    "4.0", "KILOGRAM",
                    null, null
            );

            mockMvc.perform(post("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.movementType").value("PRODUCTION_CONSUMPTION"))
                    .andExpect(jsonPath("$.direction").value("OUT"));
        }

        @Test
        @DisplayName("SALE erfassen → 201 (OUT)")
        void recordSale_returns201() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "SALE", null,
                    "2.0", "KILOGRAM",
                    null, null
            );

            mockMvc.perform(post("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.movementType").value("SALE"))
                    .andExpect(jsonPath("$.direction").value("OUT"));
        }

        @Test
        @DisplayName("RETURN erfassen → 201 (IN)")
        void recordReturn_returns201() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "RETURN", null,
                    "1.0", "KILOGRAM",
                    null, null
            );

            mockMvc.perform(post("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.movementType").value("RETURN"))
                    .andExpect(jsonPath("$.direction").value("IN"));
        }

        @Test
        @DisplayName("INTER_BRANCH_TRANSFER mit referenceDocumentId → 201 (OUT)")
        void recordTransferWithRefDoc_returns201() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "INTER_BRANCH_TRANSFER", null,
                    "3.0", "KILOGRAM",
                    null, "TRANSFER-001"
            );

            mockMvc.perform(post("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.movementType").value("INTER_BRANCH_TRANSFER"))
                    .andExpect(jsonPath("$.direction").value("OUT"))
                    .andExpect(jsonPath("$.referenceDocumentId").value("TRANSFER-001"));
        }

        @Test
        @DisplayName("ADJUSTMENT OUT mit Reason → 201")
        void recordAdjustmentOut_returns201() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "ADJUSTMENT", "OUT",
                    "0.5", "KILOGRAM",
                    "Shrinkage correction", null
            );

            mockMvc.perform(post("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsString(request)))
                    .andExpect(status().isCreated())
                    .andExpect(jsonPath("$.movementType").value("ADJUSTMENT"))
                    .andExpect(jsonPath("$.direction").value("OUT"))
                    .andExpect(jsonPath("$.reason").value("Shrinkage correction"));
        }

        @Test
        @DisplayName("ADJUSTMENT ohne Direction → 400")
        void recordAdjustmentWithoutDirection_returns400() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "ADJUSTMENT", null,
                    "1.0", "KILOGRAM",
                    "Correction", null
            );

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

        @Test
        @DisplayName("Negative Menge → 400")
        void recordNegativeQuantity_returns400() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "GOODS_RECEIPT", null,
                    "-5.0", "KILOGRAM",
                    null, null
            );

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

        @Test
        @DisplayName("Ungültige Einheit → 400")
        void recordInvalidUnit_returns400() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "GOODS_RECEIPT", null,
                    "10.0", "INVALID_UNIT",
                    null, null
            );

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

        @Test
        @DisplayName("Ungültiger movementType → 400")
        void recordInvalidMovementType_returns400() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "INVALID_TYPE", null,
                    "10.0", "KILOGRAM",
                    null, null
            );

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

        @Test
        @DisplayName("Ohne STOCK_MOVEMENT_WRITE → 403")
        void recordWithoutPermission_returns403() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "GOODS_RECEIPT", null,
                    "10.0", "KILOGRAM",
                    null, null
            );

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

        @Test
        @DisplayName("Ohne Token → 401")
        void recordWithoutToken_returns401() throws Exception {
            var request = new RecordStockMovementRequest(
                    stockId, articleId, stockBatchId,
                    "CHARGE-001", "PRODUCED",
                    "GOODS_RECEIPT", null,
                    "10.0", "KILOGRAM",
                    null, null
            );

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

    // ==================== Bewegungen abfragen ====================

    @Nested
    @DisplayName("GET /api/inventory/stock-movements")
    class ListMovements {

        @Test
        @DisplayName("Alle Bewegungen auflisten → 200")
        void listAll_returns200() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray())
                    .andExpect(jsonPath("$.length()").value(1));
        }

        @Test
        @DisplayName("Nach stockId filtern → 200")
        void filterByStockId_returns200() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("stockId", stockId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray());
        }

        @Test
        @DisplayName("Nach movementType filtern → 200")
        void filterByMovementType_returns200() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("movementType", "GOODS_RECEIPT")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray());
        }

        @Test
        @DisplayName("Nach articleId filtern → 200")
        void filterByArticleId_returns200() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("articleId", articleId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray())
                    .andExpect(jsonPath("$.length()").value(1));
        }

        @Test
        @DisplayName("Ungültiger movementType-Filter → 400")
        void filterByInvalidMovementType_returns400() throws Exception {
            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("movementType", "INVALID")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("INVALID_MOVEMENT_TYPE"));
        }

        @Test
        @DisplayName("Ohne STOCK_MOVEMENT_READ → 403")
        void listWithoutPermission_returns403() throws Exception {
            mockMvc.perform(get("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + viewerToken))
                    .andExpect(status().isForbidden());
        }

        @Test
        @DisplayName("Leere Liste → 200 mit []")
        void listEmpty_returns200() throws Exception {
            mockMvc.perform(get("/api/inventory/stock-movements")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray())
                    .andExpect(jsonPath("$.length()").value(0));
        }

        @Test
        @DisplayName("Nach batchReference filtern → 200")
        void filterByBatchReference_returns200() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("batchReference", "CHARGE-001")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray())
                    .andExpect(jsonPath("$.length()").value(1));
        }

        @Test
        @DisplayName("Nach Zeitraum (from + to) filtern → 200")
        void filterByDateRange_returns200() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("from", "2020-01-01T00:00:00Z")
                            .param("to", "2030-12-31T23:59:59Z")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray())
                    .andExpect(jsonPath("$.length()").value(1));
        }

        @Test
        @DisplayName("Nur from (open-ended) → 200")
        void filterByFromOnly_returns200() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("from", "2020-01-01T00:00:00Z")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray())
                    .andExpect(jsonPath("$.length()").value(1));
        }

        @Test
        @DisplayName("Nur to (open-ended) → 200")
        void filterByToOnly_returns200() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("to", "2030-12-31T23:59:59Z")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray())
                    .andExpect(jsonPath("$.length()").value(1));
        }

        @Test
        @DisplayName("from nach to → 400")
        void filterByInvalidDateRange_returns400() throws Exception {
            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("from", "2030-01-01T00:00:00Z")
                            .param("to", "2020-01-01T00:00:00Z")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("INVALID_DATE_RANGE"));
        }

        @Test
        @DisplayName("stockId hat Priorität über batchReference")
        void stockIdPriorityOverBatchReference_returns200() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("stockId", stockId)
                            .param("batchReference", "CHARGE-001")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray());
        }

        @Test
        @DisplayName("Leere batchReference → 400")
        void filterByBlankBatchReference_returns400() throws Exception {
            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("batchReference", "  ")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isBadRequest())
                    .andExpect(jsonPath("$.code").value("INVALID_BATCH_REFERENCE"));
        }

        @Test
        @DisplayName("Unbekannte batchReference → 200 mit []")
        void filterByUnknownBatchReference_returns200Empty() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("batchReference", "UNKNOWN-CHARGE")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray())
                    .andExpect(jsonPath("$.length()").value(0));
        }

        @Test
        @DisplayName("Zeitraum in Zukunft → 200 mit []")
        void filterByFutureDateRange_returns200Empty() throws Exception {
            recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements")
                            .param("from", "2099-01-01T00:00:00Z")
                            .param("to", "2099-12-31T23:59:59Z")
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$").isArray())
                    .andExpect(jsonPath("$.length()").value(0));
        }
    }

    // ==================== Einzelne Bewegung ====================

    @Nested
    @DisplayName("GET /api/inventory/stock-movements/{id}")
    class GetMovement {

        @Test
        @DisplayName("Einzelne Bewegung per ID → 200")
        void getById_returns200() throws Exception {
            String movementId = recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements/{id}", movementId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.id").value(movementId))
                    .andExpect(jsonPath("$.movementType").value("GOODS_RECEIPT"));
        }

        @Test
        @DisplayName("Nicht vorhandene ID → 404")
        void getByInvalidId_returns404() throws Exception {
            mockMvc.perform(get("/api/inventory/stock-movements/{id}", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isNotFound())
                    .andExpect(jsonPath("$.code").value("STOCK_MOVEMENT_NOT_FOUND"));
        }

        @Test
        @DisplayName("Ohne STOCK_MOVEMENT_READ → 403")
        void getWithoutPermission_returns403() throws Exception {
            mockMvc.perform(get("/api/inventory/stock-movements/{id}", UUID.randomUUID().toString())
                            .header("Authorization", "Bearer " + viewerToken))
                    .andExpect(status().isForbidden());
        }

        @Test
        @DisplayName("Vollständige Response-Struktur verifizieren")
        void getById_fullResponseValidation() throws Exception {
            String movementId = recordMovement("GOODS_RECEIPT");

            mockMvc.perform(get("/api/inventory/stock-movements/{id}", movementId)
                            .header("Authorization", "Bearer " + adminToken))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.id").value(movementId))
                    .andExpect(jsonPath("$.stockId").value(stockId))
                    .andExpect(jsonPath("$.articleId").value(articleId))
                    .andExpect(jsonPath("$.stockBatchId").value(stockBatchId))
                    .andExpect(jsonPath("$.batchId").value("CHARGE-001"))
                    .andExpect(jsonPath("$.batchType").value("PRODUCED"))
                    .andExpect(jsonPath("$.movementType").value("GOODS_RECEIPT"))
                    .andExpect(jsonPath("$.direction").value("IN"))
                    .andExpect(jsonPath("$.quantityAmount").value(10.0))
                    .andExpect(jsonPath("$.quantityUnit").value("KILOGRAM"))
                    .andExpect(jsonPath("$.performedBy").isNotEmpty())
                    .andExpect(jsonPath("$.performedAt").isNotEmpty());
        }
    }

    // ==================== Helpers ====================

    private String createStorageLocation() throws Exception {
        String json = """
                {"name": "Testlager-%s", "storageType": "DRY_STORAGE"}
                """.formatted(UUID.randomUUID().toString().substring(0, 8));

        var result = mockMvc.perform(post("/api/inventory/storage-locations")
                        .header("Authorization", "Bearer " + adminToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(json))
                .andExpect(status().isCreated())
                .andReturn();

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

    private String createStock(String artId) throws Exception {
        var request = new CreateStockRequest(artId, storageLocationId, null, null, null);

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

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

    private String addBatchToStock(String sId) throws Exception {
        var request = new AddStockBatchRequest(
                "BATCH-" + UUID.randomUUID().toString().substring(0, 8),
                "PRODUCED", "100", "KILOGRAM", "2026-12-31");

        var result = mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", sId)
                        .header("Authorization", "Bearer " + adminToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andReturn();

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

    private String recordMovement(String movementType) throws Exception {
        var request = new RecordStockMovementRequest(
                stockId, articleId, stockBatchId,
                "CHARGE-001", "PRODUCED",
                movementType, null,
                "10.0", "KILOGRAM",
                null, null
        );

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

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