package de.effigenix.domain.inventory;

import de.effigenix.domain.masterdata.article.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class StockTest {

    // ==================== Create ====================

    @Nested
    @DisplayName("create()")
    class Create {

        @Test
        @DisplayName("should create Stock with all fields")
        void shouldCreateWithAllFields() {
            var draft = new StockDraft(
                    "article-1", "location-1", "10.5", "KILOGRAM", 30);

            var result = Stock.create(draft);

            assertThat(result.isSuccess()).isTrue();
            var stock = result.unsafeGetValue();
            assertThat(stock.id()).isNotNull();
            assertThat(stock.articleId().value()).isEqualTo("article-1");
            assertThat(stock.storageLocationId().value()).isEqualTo("location-1");
            assertThat(stock.minimumLevel()).isNotNull();
            assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("10.5"));
            assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
            assertThat(stock.minimumShelfLife()).isNotNull();
            assertThat(stock.minimumShelfLife().days()).isEqualTo(30);
            assertThat(stock.batches()).isEmpty();
        }

        @Test
        @DisplayName("should create Stock with only required fields")
        void shouldCreateWithOnlyRequiredFields() {
            var draft = new StockDraft("article-1", "location-1", null, null, null);

            var result = Stock.create(draft);

            assertThat(result.isSuccess()).isTrue();
            var stock = result.unsafeGetValue();
            assertThat(stock.articleId().value()).isEqualTo("article-1");
            assertThat(stock.storageLocationId().value()).isEqualTo("location-1");
            assertThat(stock.minimumLevel()).isNull();
            assertThat(stock.minimumShelfLife()).isNull();
        }

        @Test
        @DisplayName("should fail when articleId is null")
        void shouldFailWhenArticleIdNull() {
            var draft = new StockDraft(null, "location-1", null, null, null);

            var result = Stock.create(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidArticleId.class);
        }

        @Test
        @DisplayName("should fail when articleId is blank")
        void shouldFailWhenArticleIdBlank() {
            var draft = new StockDraft("", "location-1", null, null, null);

            var result = Stock.create(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidArticleId.class);
        }

        @Test
        @DisplayName("should fail when storageLocationId is null")
        void shouldFailWhenStorageLocationIdNull() {
            var draft = new StockDraft("article-1", null, null, null, null);

            var result = Stock.create(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidStorageLocationId.class);
        }

        @Test
        @DisplayName("should fail when storageLocationId is blank")
        void shouldFailWhenStorageLocationIdBlank() {
            var draft = new StockDraft("article-1", "", null, null, null);

            var result = Stock.create(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidStorageLocationId.class);
        }

        @Test
        @DisplayName("should fail when minimumLevel amount is negative")
        void shouldFailWhenMinimumLevelNegative() {
            var draft = new StockDraft("article-1", "location-1", "-1", "KILOGRAM", null);

            var result = Stock.create(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
        }

        @Test
        @DisplayName("should fail when minimumLevel amount is not a number")
        void shouldFailWhenMinimumLevelNotNumber() {
            var draft = new StockDraft("article-1", "location-1", "abc", "KILOGRAM", null);

            var result = Stock.create(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
        }

        @Test
        @DisplayName("should fail when minimumLevel unit is invalid")
        void shouldFailWhenMinimumLevelUnitInvalid() {
            var draft = new StockDraft("article-1", "location-1", "10", "INVALID_UNIT", null);

            var result = Stock.create(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
        }

        @Test
        @DisplayName("should accept minimumLevel amount of zero")
        void shouldAcceptMinimumLevelZero() {
            var draft = new StockDraft("article-1", "location-1", "0", "KILOGRAM", null);

            var result = Stock.create(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue().minimumLevel().quantity().amount())
                    .isEqualByComparingTo(BigDecimal.ZERO);
        }

        @Test
        @DisplayName("should fail when minimumShelfLife is zero")
        void shouldFailWhenMinimumShelfLifeZero() {
            var draft = new StockDraft("article-1", "location-1", null, null, 0);

            var result = Stock.create(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class);
        }

        @Test
        @DisplayName("should fail when minimumShelfLife is negative")
        void shouldFailWhenMinimumShelfLifeNegative() {
            var draft = new StockDraft("article-1", "location-1", null, null, -5);

            var result = Stock.create(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class);
        }

        @Test
        @DisplayName("should accept minimumShelfLife of 1")
        void shouldAcceptMinimumShelfLifeOne() {
            var draft = new StockDraft("article-1", "location-1", null, null, 1);

            var result = Stock.create(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue().minimumShelfLife().days()).isEqualTo(1);
        }

        @Test
        @DisplayName("should create with all UnitOfMeasure values")
        void shouldCreateWithAllUnits() {
            for (UnitOfMeasure unit : UnitOfMeasure.values()) {
                var draft = new StockDraft("article-1", "location-1", "5", unit.name(), null);
                var result = Stock.create(draft);
                assertThat(result.isSuccess()).as("UnitOfMeasure %s should be valid", unit).isTrue();
            }
        }
    }

    // ==================== Update ====================

    @Nested
    @DisplayName("update()")
    class Update {

        @Test
        @DisplayName("should update minimumLevel")
        void shouldUpdateMinimumLevel() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft("20", "LITER", null);

            var result = stock.update(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("20"));
            assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.LITER);
        }

        @Test
        @DisplayName("should update minimumShelfLife")
        void shouldUpdateMinimumShelfLife() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft(null, null, 14);

            var result = stock.update(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.minimumShelfLife().days()).isEqualTo(14);
        }

        @Test
        @DisplayName("should update both parameters")
        void shouldUpdateBothParameters() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft("5", "PIECE", 7);

            var result = stock.update(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("5"));
            assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.PIECE);
            assertThat(stock.minimumShelfLife().days()).isEqualTo(7);
        }

        @Test
        @DisplayName("should not change fields when null")
        void shouldNotChangeFieldsWhenNull() {
            var stock = createValidStock(); // has minimumLevel=10 KILOGRAM, minimumShelfLife=30
            var originalLevel = stock.minimumLevel();
            var originalShelfLife = stock.minimumShelfLife();

            var draft = new StockUpdateDraft(null, null, null);
            var result = stock.update(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.minimumLevel()).isSameAs(originalLevel);
            assertThat(stock.minimumShelfLife()).isSameAs(originalShelfLife);
        }

        @Test
        @DisplayName("should overwrite existing minimumLevel with new value")
        void shouldOverwriteExistingMinimumLevel() {
            var stock = createValidStock(); // has minimumLevel=10 KILOGRAM
            var draft = new StockUpdateDraft("99.9", "GRAM", null);

            var result = stock.update(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("99.9"));
            assertThat(stock.minimumLevel().quantity().uom()).isEqualTo(UnitOfMeasure.GRAM);
            // minimumShelfLife unverändert
            assertThat(stock.minimumShelfLife().days()).isEqualTo(30);
        }

        @Test
        @DisplayName("should accept minimumLevel amount of zero")
        void shouldAcceptMinimumLevelZero() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft("0", "KILOGRAM", null);

            var result = stock.update(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(BigDecimal.ZERO);
        }

        @Test
        @DisplayName("should fail when minimumLevel amount is negative")
        void shouldFailWhenMinimumLevelNegative() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft("-1", "KILOGRAM", null);

            var result = stock.update(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
        }

        @Test
        @DisplayName("should fail when minimumLevel amount is not a number")
        void shouldFailWhenMinimumLevelNotNumber() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft("abc", "KILOGRAM", null);

            var result = stock.update(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
        }

        @Test
        @DisplayName("should fail when minimumLevel unit is invalid")
        void shouldFailWhenMinimumLevelUnitInvalid() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft("10", "INVALID_UNIT", null);

            var result = stock.update(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
        }

        @Test
        @DisplayName("should fail when minimumLevel amount provided without unit")
        void shouldFailWhenAmountWithoutUnit() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft("10", null, null);

            var result = stock.update(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
        }

        @Test
        @DisplayName("should fail when minimumLevel unit provided without amount")
        void shouldFailWhenUnitWithoutAmount() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft(null, "KILOGRAM", null);

            var result = stock.update(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumLevel.class);
        }

        @Test
        @DisplayName("should fail when minimumShelfLife is zero")
        void shouldFailWhenMinimumShelfLifeZero() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft(null, null, 0);

            var result = stock.update(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class);
        }

        @Test
        @DisplayName("should fail when minimumShelfLife is negative")
        void shouldFailWhenMinimumShelfLifeNegative() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft(null, null, -5);

            var result = stock.update(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class);
        }

        @Test
        @DisplayName("should accept minimumShelfLife of 1")
        void shouldAcceptMinimumShelfLifeOne() {
            var stock = createValidStock();
            var draft = new StockUpdateDraft(null, null, 1);

            var result = stock.update(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.minimumShelfLife().days()).isEqualTo(1);
        }

        @Test
        @DisplayName("should not rollback minimumLevel when minimumShelfLife validation fails")
        void shouldNotRollbackMinimumLevelOnShelfLifeFailure() {
            var stock = createValidStock();
            var originalLevel = stock.minimumLevel();
            var draft = new StockUpdateDraft("50", "KILOGRAM", 0); // valid level, invalid shelfLife

            var result = stock.update(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidMinimumShelfLife.class);
            // NOTE: minimumLevel was already mutated – this is consistent with how create() works
            // (fail-fast, no rollback). The use case should not save on failure.
        }

        @Test
        @DisplayName("should update stock that had no optional fields")
        void shouldUpdateStockWithoutOptionalFields() {
            var stock = Stock.create(new StockDraft("article-1", "location-1", null, null, null)).unsafeGetValue();
            assertThat(stock.minimumLevel()).isNull();
            assertThat(stock.minimumShelfLife()).isNull();

            var draft = new StockUpdateDraft("15", "KILOGRAM", 10);
            var result = stock.update(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.minimumLevel()).isNotNull();
            assertThat(stock.minimumLevel().quantity().amount()).isEqualByComparingTo(new BigDecimal("15"));
            assertThat(stock.minimumShelfLife()).isNotNull();
            assertThat(stock.minimumShelfLife().days()).isEqualTo(10);
        }
    }

    // ==================== addBatch ====================

    @Nested
    @DisplayName("addBatch()")
    class AddBatch {

        @Test
        @DisplayName("should add batch successfully")
        void shouldAddBatch() {
            var stock = createValidStock();
            var draft = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");

            var result = stock.addBatch(draft);

            assertThat(result.isSuccess()).isTrue();
            var batch = result.unsafeGetValue();
            assertThat(batch.batchReference().batchId()).isEqualTo("BATCH-001");
            assertThat(batch.batchReference().batchType()).isEqualTo(BatchType.PRODUCED);
            assertThat(batch.status()).isEqualTo(StockBatchStatus.AVAILABLE);
            assertThat(stock.batches()).hasSize(1);
        }

        @Test
        @DisplayName("should fail when duplicate batch reference")
        void shouldFailOnDuplicateBatchReference() {
            var stock = createValidStock();
            var draft1 = new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31");
            stock.addBatch(draft1);

            var draft2 = new StockBatchDraft("BATCH-001", "PRODUCED", "5", "KILOGRAM", "2027-01-15");
            var result = stock.addBatch(draft2);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.DuplicateBatchReference.class);
        }

        @Test
        @DisplayName("should allow same batchId with different batchType")
        void shouldAllowSameBatchIdDifferentType() {
            var stock = createValidStock();
            stock.addBatch(new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"));

            var result = stock.addBatch(new StockBatchDraft("BATCH-001", "PURCHASED", "5", "KILOGRAM", "2027-01-15"));

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.batches()).hasSize(2);
        }

        @Test
        @DisplayName("should return unmodifiable batches list")
        void shouldReturnUnmodifiableBatchesList() {
            var stock = createValidStock();
            stock.addBatch(new StockBatchDraft("BATCH-001", "PRODUCED", "10", "KILOGRAM", "2026-12-31"));

            var batches = stock.batches();

            assertThat(batches).hasSize(1);
            org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class,
                    () -> batches.add(null));
        }
    }

    // ==================== Reconstitute ====================

    @Nested
    @DisplayName("reconstitute()")
    class Reconstitute {

        @Test
        @DisplayName("should reconstitute Stock from persistence")
        void shouldReconstitute() {
            var id = StockId.generate();
            var articleId = ArticleId.of("article-1");
            var locationId = StorageLocationId.of("location-1");
            var quantity = Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM);
            var minimumLevel = new MinimumLevel(quantity);
            var minimumShelfLife = new MinimumShelfLife(30);

            var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife, List.of(), List.of());

            assertThat(stock.id()).isEqualTo(id);
            assertThat(stock.articleId()).isEqualTo(articleId);
            assertThat(stock.storageLocationId()).isEqualTo(locationId);
            assertThat(stock.minimumLevel()).isEqualTo(minimumLevel);
            assertThat(stock.minimumShelfLife()).isEqualTo(minimumShelfLife);
            assertThat(stock.batches()).isEmpty();
        }

        @Test
        @DisplayName("should reconstitute Stock without optional fields")
        void shouldReconstituteWithoutOptionals() {
            var id = StockId.generate();
            var articleId = ArticleId.of("article-1");
            var locationId = StorageLocationId.of("location-1");

            var stock = Stock.reconstitute(id, articleId, locationId, null, null, List.of(), List.of());

            assertThat(stock.minimumLevel()).isNull();
            assertThat(stock.minimumShelfLife()).isNull();
        }
    }

    // ==================== Equality ====================

    @Nested
    @DisplayName("equals / hashCode")
    class Equality {

        @Test
        @DisplayName("should be equal if same ID")
        void shouldBeEqualBySameId() {
            var id = StockId.generate();
            var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null, List.of(), List.of());
            var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of(), List.of());

            assertThat(stock1).isEqualTo(stock2);
            assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode());
        }

        @Test
        @DisplayName("should not be equal if different ID")
        void shouldNotBeEqualByDifferentId() {
            var stock1 = createValidStock();
            var stock2 = createValidStock();

            assertThat(stock1).isNotEqualTo(stock2);
        }
    }

    // ==================== removeBatch ====================

    @Nested
    @DisplayName("removeBatch()")
    class RemoveBatch {

        @Test
        @DisplayName("should reduce quantity on partial removal")
        void shouldReduceQuantityOnPartialRemoval() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE);
            var batchId = stock.batches().getFirst().id();
            var removeQty = Quantity.of(new BigDecimal("3"), UnitOfMeasure.KILOGRAM).unsafeGetValue();

            var result = stock.removeBatch(batchId, removeQty);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.batches()).hasSize(1);
            assertThat(stock.batches().getFirst().quantity().amount())
                    .isEqualByComparingTo(new BigDecimal("7"));
        }

        @Test
        @DisplayName("should remove batch when full quantity is removed")
        void shouldRemoveBatchOnFullRemoval() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE);
            var batchId = stock.batches().getFirst().id();
            var removeQty = Quantity.of(new BigDecimal("10"), UnitOfMeasure.KILOGRAM).unsafeGetValue();

            var result = stock.removeBatch(batchId, removeQty);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.batches()).isEmpty();
        }

        @Test
        @DisplayName("should fail when batch is BLOCKED")
        void shouldFailWhenBatchBlocked() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.BLOCKED);
            var batchId = stock.batches().getFirst().id();
            var removeQty = Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).unsafeGetValue();

            var result = stock.removeBatch(batchId, removeQty);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotAvailable.class);
        }

        @Test
        @DisplayName("should fail when batch is EXPIRED")
        void shouldFailWhenBatchExpired() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.EXPIRED);
            var batchId = stock.batches().getFirst().id();
            var removeQty = Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).unsafeGetValue();

            var result = stock.removeBatch(batchId, removeQty);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotAvailable.class);
        }

        @Test
        @DisplayName("should allow removal from EXPIRING_SOON batch")
        void shouldAllowRemovalFromExpiringSoon() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.EXPIRING_SOON);
            var batchId = stock.batches().getFirst().id();
            var removeQty = Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).unsafeGetValue();

            var result = stock.removeBatch(batchId, removeQty);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.batches()).hasSize(1);
            assertThat(stock.batches().getFirst().quantity().amount())
                    .isEqualByComparingTo(new BigDecimal("5"));
        }

        @Test
        @DisplayName("should fail when removal exceeds available quantity")
        void shouldFailWhenRemovalExceedsQuantity() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE);
            var batchId = stock.batches().getFirst().id();
            var removeQty = Quantity.of(new BigDecimal("15"), UnitOfMeasure.KILOGRAM).unsafeGetValue();

            var result = stock.removeBatch(batchId, removeQty);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.NegativeStockNotAllowed.class);
        }

        @Test
        @DisplayName("should fail when batch not found")
        void shouldFailWhenBatchNotFound() {
            var stock = createValidStock();
            var removeQty = Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).unsafeGetValue();

            var result = stock.removeBatch(StockBatchId.of("nonexistent"), removeQty);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class);
        }

        @Test
        @DisplayName("should fail when unit of measure does not match")
        void shouldFailWhenUomMismatch() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE);
            var batchId = stock.batches().getFirst().id();
            var removeQty = Quantity.of(new BigDecimal("5"), UnitOfMeasure.LITER).unsafeGetValue();

            var result = stock.removeBatch(batchId, removeQty);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
        }
    }

    // ==================== blockBatch ====================

    @Nested
    @DisplayName("blockBatch()")
    class BlockBatch {

        @Test
        @DisplayName("should block AVAILABLE batch")
        void shouldBlockAvailableBatch() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE);
            var batchId = stock.batches().getFirst().id();

            var result = stock.blockBatch(batchId);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED);
        }

        @Test
        @DisplayName("should block EXPIRING_SOON batch")
        void shouldBlockExpiringSoonBatch() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.EXPIRING_SOON);
            var batchId = stock.batches().getFirst().id();

            var result = stock.blockBatch(batchId);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED);
        }

        @Test
        @DisplayName("should fail when batch is already BLOCKED")
        void shouldFailWhenAlreadyBlocked() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.BLOCKED);
            var batchId = stock.batches().getFirst().id();

            var result = stock.blockBatch(batchId);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchAlreadyBlocked.class);
        }

        @Test
        @DisplayName("should fail when batch is EXPIRED")
        void shouldFailWhenExpired() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.EXPIRED);
            var batchId = stock.batches().getFirst().id();

            var result = stock.blockBatch(batchId);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotAvailable.class);
        }

        @Test
        @DisplayName("should fail when batch not found")
        void shouldFailWhenBatchNotFound() {
            var stock = createValidStock();

            var result = stock.blockBatch(StockBatchId.of("nonexistent"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class);
        }
    }

    // ==================== unblockBatch ====================

    @Nested
    @DisplayName("unblockBatch()")
    class UnblockBatch {

        @Test
        @DisplayName("should unblock to AVAILABLE when no minimum shelf life")
        void shouldUnblockToAvailable() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.BLOCKED);
            var batchId = stock.batches().getFirst().id();

            var result = stock.unblockBatch(batchId, LocalDate.now());

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE);
        }

        @Test
        @DisplayName("should unblock to EXPIRING_SOON when MHD check triggers")
        void shouldUnblockToExpiringSoon() {
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null),
                    LocalDate.now().plusDays(5),
                    StockBatchStatus.BLOCKED,
                    Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(),
                    ArticleId.of("article-1"),
                    StorageLocationId.of("location-1"),
                    null, new MinimumShelfLife(30),
                    new ArrayList<>(List.of(batch)), List.of()
            );
            var batchId = stock.batches().getFirst().id();

            var result = stock.unblockBatch(batchId, LocalDate.now());

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRING_SOON);
        }

        @Test
        @DisplayName("should unblock to AVAILABLE when MHD check passes")
        void shouldUnblockToAvailableWhenMhdPasses() {
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null),
                    LocalDate.now().plusDays(60),
                    StockBatchStatus.BLOCKED,
                    Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(),
                    ArticleId.of("article-1"),
                    StorageLocationId.of("location-1"),
                    null, new MinimumShelfLife(30),
                    new ArrayList<>(List.of(batch)), List.of()
            );
            var batchId = stock.batches().getFirst().id();

            var result = stock.unblockBatch(batchId, LocalDate.now());

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE);
        }

        @Test
        @DisplayName("should fail when batch is not BLOCKED")
        void shouldFailWhenNotBlocked() {
            var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE);
            var batchId = stock.batches().getFirst().id();

            var result = stock.unblockBatch(batchId, LocalDate.now());

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotBlocked.class);
        }

        @Test
        @DisplayName("should fail when batch not found")
        void shouldFailWhenBatchNotFound() {
            var stock = createValidStock();

            var result = stock.unblockBatch(StockBatchId.of("nonexistent"), LocalDate.now());

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class);
        }
    }

    // ==================== markExpiredBatches ====================

    @Nested
    @DisplayName("markExpiredBatches()")
    class MarkExpiredBatches {

        @Test
        @DisplayName("should mark batch with expiryDate before today as EXPIRED")
        void shouldMarkExpiredBatch() {
            var today = LocalDate.of(2026, 6, 15);
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 6, 14));

            var result = stock.markExpiredBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).hasSize(1);
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRED);
        }

        @Test
        @DisplayName("should not mark BLOCKED batch as EXPIRED")
        void shouldNotMarkBlockedBatch() {
            var today = LocalDate.of(2026, 6, 15);
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.BLOCKED, LocalDate.of(2026, 6, 14));

            var result = stock.markExpiredBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED);
        }

        @Test
        @DisplayName("should not mark batch with expiryDate equal to today")
        void shouldNotMarkBatchExpiringToday() {
            var today = LocalDate.of(2026, 6, 15);
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, today);

            var result = stock.markExpiredBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE);
        }

        @Test
        @DisplayName("should not mark batch with expiryDate after today")
        void shouldNotMarkFutureBatch() {
            var today = LocalDate.of(2026, 6, 15);
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.markExpiredBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE);
        }

        @Test
        @DisplayName("should not change already EXPIRED batch")
        void shouldNotChangeAlreadyExpiredBatch() {
            var today = LocalDate.of(2026, 6, 15);
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.EXPIRED, LocalDate.of(2026, 6, 10));

            var result = stock.markExpiredBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRED);
        }

        @Test
        @DisplayName("should handle multiple batches with mixed statuses")
        void shouldHandleMultipleBatchesMixedStatuses() {
            var today = LocalDate.of(2026, 6, 15);
            var expiredAvailable = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 6, 10), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var expiredExpiringSoon = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 6, 14), StockBatchStatus.EXPIRING_SOON, Instant.now()
            );
            var blockedExpired = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-003", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("3"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 6, 1), StockBatchStatus.BLOCKED, Instant.now()
            );
            var futureAvailable = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-004", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("7"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null,
                    new ArrayList<>(List.of(expiredAvailable, expiredExpiringSoon, blockedExpired, futureAvailable)), List.of()
            );

            var result = stock.markExpiredBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).hasSize(2);
            assertThat(stock.batches().get(0).status()).isEqualTo(StockBatchStatus.EXPIRED);
            assertThat(stock.batches().get(1).status()).isEqualTo(StockBatchStatus.EXPIRED);
            assertThat(stock.batches().get(2).status()).isEqualTo(StockBatchStatus.BLOCKED);
            assertThat(stock.batches().get(3).status()).isEqualTo(StockBatchStatus.AVAILABLE);
        }

        @Test
        @DisplayName("should return empty list when no batches exist")
        void shouldReturnEmptyWhenNoBatches() {
            var today = LocalDate.of(2026, 6, 15);
            var stock = createValidStock();

            var result = stock.markExpiredBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
        }

        @Test
        @DisplayName("should mark EXPIRING_SOON batch as EXPIRED when past expiry")
        void shouldMarkExpiringSoonAsExpired() {
            var today = LocalDate.of(2026, 6, 15);
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.EXPIRING_SOON, LocalDate.of(2026, 6, 14));

            var result = stock.markExpiredBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).hasSize(1);
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRED);
        }
    }

    // ==================== markExpiringSoonBatches ====================

    @Nested
    @DisplayName("markExpiringSoonBatches()")
    class MarkExpiringSoonBatches {

        @Test
        @DisplayName("should mark batch within minimumShelfLife threshold as EXPIRING_SOON")
        void shouldMarkExpiringSoon() {
            var today = LocalDate.of(2026, 6, 15);
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 7, 1), // 16 days from today, within 30-day threshold
                    StockBatchStatus.AVAILABLE,
                    Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
            );

            var result = stock.markExpiringSoonBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).hasSize(1);
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRING_SOON);
        }

        @Test
        @DisplayName("should not mark batch outside minimumShelfLife threshold")
        void shouldNotMarkBatchOutsideThreshold() {
            var today = LocalDate.of(2026, 6, 15);
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), // well outside 30-day threshold
                    StockBatchStatus.AVAILABLE,
                    Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
            );

            var result = stock.markExpiringSoonBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE);
        }

        @Test
        @DisplayName("should return empty list when no minimumShelfLife configured")
        void shouldReturnEmptyWithoutMinimumShelfLife() {
            var today = LocalDate.of(2026, 6, 15);
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 6, 20));

            var result = stock.markExpiringSoonBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE);
        }

        @Test
        @DisplayName("should not mark already EXPIRING_SOON batch again")
        void shouldNotDoubleMarkExpiringSoon() {
            var today = LocalDate.of(2026, 6, 15);
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 7, 1),
                    StockBatchStatus.EXPIRING_SOON,
                    Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
            );

            var result = stock.markExpiringSoonBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRING_SOON);
        }

        @Test
        @DisplayName("should not mark batch exactly on threshold boundary (expiryDate == today + minimumShelfLife)")
        void shouldNotMarkBatchExactlyOnThreshold() {
            var today = LocalDate.of(2026, 6, 15);
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 7, 15), // exactly today + 30 days
                    StockBatchStatus.AVAILABLE,
                    Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
            );

            var result = stock.markExpiringSoonBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE);
        }

        @Test
        @DisplayName("should mark batch one day before threshold boundary")
        void shouldMarkBatchOneDayBeforeThreshold() {
            var today = LocalDate.of(2026, 6, 15);
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 7, 14), // today + 29 days, within 30-day threshold
                    StockBatchStatus.AVAILABLE,
                    Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
            );

            var result = stock.markExpiringSoonBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).hasSize(1);
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRING_SOON);
        }

        @Test
        @DisplayName("should not mark BLOCKED batch within threshold as EXPIRING_SOON")
        void shouldNotMarkBlockedBatchWithinThreshold() {
            var today = LocalDate.of(2026, 6, 15);
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 7, 1), // within threshold
                    StockBatchStatus.BLOCKED,
                    Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
            );

            var result = stock.markExpiringSoonBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
            assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED);
        }

        @Test
        @DisplayName("should handle multiple batches with mixed statuses")
        void shouldHandleMultipleBatchesMixedStatuses() {
            var today = LocalDate.of(2026, 6, 15);
            var withinThreshold = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 7, 1), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var alreadyExpiringSoon = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 6, 20), StockBatchStatus.EXPIRING_SOON, Instant.now()
            );
            var outsideThreshold = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-003", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("7"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, new MinimumShelfLife(30),
                    new ArrayList<>(List.of(withinThreshold, alreadyExpiringSoon, outsideThreshold)), List.of()
            );

            var result = stock.markExpiringSoonBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).hasSize(1);
            assertThat(stock.batches().get(0).status()).isEqualTo(StockBatchStatus.EXPIRING_SOON);
            assertThat(stock.batches().get(1).status()).isEqualTo(StockBatchStatus.EXPIRING_SOON);
            assertThat(stock.batches().get(2).status()).isEqualTo(StockBatchStatus.AVAILABLE);
        }

        @Test
        @DisplayName("should not mark already expired batch as EXPIRING_SOON")
        void shouldNotMarkExpiredAsExpiringSoon() {
            var today = LocalDate.of(2026, 6, 15);
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 6, 10), // already expired
                    StockBatchStatus.AVAILABLE,
                    Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
            );

            var result = stock.markExpiringSoonBatches(today);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue()).isEmpty();
        }
    }

    // ==================== availableQuantity ====================

    @Nested
    @DisplayName("availableQuantity()")
    class AvailableQuantity {

        @Test
        @DisplayName("should sum AVAILABLE and EXPIRING_SOON batch quantities")
        void shouldSumAvailableAndExpiringSoon() {
            var available = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var expiringSoon = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 7, 1), StockBatchStatus.EXPIRING_SOON, Instant.now()
            );
            var blocked = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-003", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("3"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
            );
            var expired = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-004", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("2"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 1, 1), StockBatchStatus.EXPIRED, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null,
                    new ArrayList<>(List.of(available, expiringSoon, blocked, expired)), List.of()
            );

            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("15"));
        }

        @Test
        @DisplayName("should return zero when no batches")
        void shouldReturnZeroWhenNoBatches() {
            var stock = createValidStock();
            assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
        }

        @Test
        @DisplayName("should return zero when only BLOCKED and EXPIRED batches")
        void shouldReturnZeroWhenOnlyBlockedAndExpired() {
            var blocked = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
            );
            var expired = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 1, 1), StockBatchStatus.EXPIRED, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null,
                    new ArrayList<>(List.of(blocked, expired)), List.of()
            );

            assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
        }
    }

    // ==================== isBelowMinimumLevel ====================

    @Nested
    @DisplayName("isBelowMinimumLevel()")
    class IsBelowMinimumLevel {

        @Test
        @DisplayName("should return false when no minimumLevel configured")
        void shouldReturnFalseWithoutMinimumLevel() {
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, List.of(), List.of()
            );

            assertThat(stock.isBelowMinimumLevel()).isFalse();
        }

        @Test
        @DisplayName("should return false when available > minimum")
        void shouldReturnFalseWhenAboveMinimum() {
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("15"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
            );

            assertThat(stock.isBelowMinimumLevel()).isFalse();
        }

        @Test
        @DisplayName("should return false when available == minimum")
        void shouldReturnFalseWhenEqualToMinimum() {
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
            );

            assertThat(stock.isBelowMinimumLevel()).isFalse();
        }

        @Test
        @DisplayName("should return true when available < minimum")
        void shouldReturnTrueWhenBelowMinimum() {
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
            );

            assertThat(stock.isBelowMinimumLevel()).isTrue();
        }

        @Test
        @DisplayName("should return true when no batches and minimumLevel > 0")
        void shouldReturnTrueWhenNoBatchesAndMinimumLevelPositive() {
            var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    minimumLevel, null, List.of(), List.of()
            );

            assertThat(stock.isBelowMinimumLevel()).isTrue();
        }

        @Test
        @DisplayName("should return false when minimumLevel == 0")
        void shouldReturnFalseWhenMinimumLevelZero() {
            var minimumLevel = new MinimumLevel(Quantity.reconstitute(BigDecimal.ZERO, UnitOfMeasure.KILOGRAM));
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    minimumLevel, null, List.of(), List.of()
            );

            assertThat(stock.isBelowMinimumLevel()).isFalse();
        }

        @Test
        @DisplayName("should return true when only BLOCKED and EXPIRED batches with minimumLevel > 0")
        void shouldReturnTrueWhenOnlyBlockedAndExpiredBatches() {
            var blocked = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
            );
            var expired = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 1, 1), StockBatchStatus.EXPIRED, Instant.now()
            );
            var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    minimumLevel, null, new ArrayList<>(List.of(blocked, expired)), List.of()
            );

            assertThat(stock.isBelowMinimumLevel()).isTrue();
        }

        @Test
        @DisplayName("should return false when batches have mixed units different from minimumLevel unit")
        void shouldReturnFalseWhenMixedUnits() {
            var kgBatch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("3"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var literBatch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("2"), UnitOfMeasure.LITER),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    minimumLevel, null, new ArrayList<>(List.of(kgBatch, literBatch)), List.of()
            );

            assertThat(stock.isBelowMinimumLevel()).isFalse();
        }
    }

    // ==================== reserve (FEFO) ====================

    @Nested
    @DisplayName("reserve()")
    class Reserve {

        @Test
        @DisplayName("should reserve from single batch")
        void shouldReserveFromSingleBatch() {
            var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
            var batchId = stock.batches().getFirst().id();

            var draft = new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
            var result = stock.reserve(draft);

            assertThat(result.isSuccess()).isTrue();
            var reservation = result.unsafeGetValue();
            assertThat(reservation.id()).isNotNull();
            assertThat(reservation.referenceType()).isEqualTo(ReferenceType.PRODUCTION_ORDER);
            assertThat(reservation.referenceId()).isEqualTo("PO-001");
            assertThat(reservation.quantity().amount()).isEqualByComparingTo(new BigDecimal("5"));
            assertThat(reservation.quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
            assertThat(reservation.priority()).isEqualTo(ReservationPriority.NORMAL);
            assertThat(reservation.reservedAt()).isNotNull();
            assertThat(reservation.allocations()).hasSize(1);
            assertThat(reservation.allocations().getFirst().stockBatchId()).isEqualTo(batchId);
            assertThat(reservation.allocations().getFirst().allocatedQuantity().amount())
                    .isEqualByComparingTo(new BigDecimal("5"));
        }

        @Test
        @DisplayName("should allocate FEFO – earliest expiry first")
        void shouldAllocateFefoEarliestFirst() {
            var earlyBatch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-EARLY", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var lateBatch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-LATE", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(lateBatch, earlyBatch)), List.of()
            );

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));

            assertThat(result.isSuccess()).isTrue();
            var allocs = result.unsafeGetValue().allocations();
            assertThat(allocs).hasSize(1);
            assertThat(allocs.getFirst().stockBatchId()).isEqualTo(earlyBatch.id());
            assertThat(allocs.getFirst().allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("8"));
        }

        @Test
        @DisplayName("should split allocation across multiple batches (FEFO)")
        void shouldSplitAcrossMultipleBatches() {
            var batch1 = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("6"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var batch2 = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("8"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 9, 30), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(batch1, batch2)), List.of()
            );

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));

            assertThat(result.isSuccess()).isTrue();
            var allocs = result.unsafeGetValue().allocations();
            assertThat(allocs).hasSize(2);
            assertThat(allocs.get(0).stockBatchId()).isEqualTo(batch1.id());
            assertThat(allocs.get(0).allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("6"));
            assertThat(allocs.get(1).stockBatchId()).isEqualTo(batch2.id());
            assertThat(allocs.get(1).allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("4"));
        }

        @Test
        @DisplayName("should include EXPIRING_SOON batches in allocation")
        void shouldIncludeExpiringSoonBatches() {
            var expiringSoon = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 7, 1), StockBatchStatus.EXPIRING_SOON, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(expiringSoon)), List.of()
            );

            var result = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "URGENT"));

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue().referenceType()).isEqualTo(ReferenceType.SALE_ORDER);
            assertThat(result.unsafeGetValue().priority()).isEqualTo(ReservationPriority.URGENT);
            assertThat(result.unsafeGetValue().allocations()).hasSize(1);
        }

        @Test
        @DisplayName("should skip BLOCKED and EXPIRED batches")
        void shouldSkipBlockedAndExpiredBatches() {
            var blocked = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-BLOCKED", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("100"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
            );
            var expired = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-EXPIRED", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("100"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 1, 1), StockBatchStatus.EXPIRED, Instant.now()
            );
            var available = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-AVAIL", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(blocked, expired, available)), List.of()
            );

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue().allocations()).hasSize(1);
            assertThat(result.unsafeGetValue().allocations().getFirst().stockBatchId()).isEqualTo(available.id());
        }

        @Test
        @DisplayName("should fail with InsufficientStock when not enough available")
        void shouldFailWhenInsufficientStock() {
            var stock = createStockWithBatchAndExpiry("5", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
        }

        @Test
        @DisplayName("should fail with InsufficientStock when all stock is already reserved")
        void shouldFailWhenAllStockAlreadyReserved() {
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var existingReservation = new Reservation(
                    ReservationId.generate(), ReferenceType.PRODUCTION_ORDER, "PO-OLD",
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    ReservationPriority.NORMAL, Instant.now(),
                    List.of(new StockBatchAllocation(AllocationId.generate(), batch.id(), Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM)))
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(batch)), List.of(existingReservation)
            );

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-002", "1", "KILOGRAM", "NORMAL"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
        }

        @Test
        @DisplayName("should consider existing reservations when allocating")
        void shouldConsiderExistingReservations() {
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("20"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var existingReservation = new Reservation(
                    ReservationId.generate(), ReferenceType.PRODUCTION_ORDER, "PO-OLD",
                    Quantity.reconstitute(new BigDecimal("15"), UnitOfMeasure.KILOGRAM),
                    ReservationPriority.NORMAL, Instant.now(),
                    List.of(new StockBatchAllocation(AllocationId.generate(), batch.id(), Quantity.reconstitute(new BigDecimal("15"), UnitOfMeasure.KILOGRAM)))
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(batch)), List.of(existingReservation)
            );

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-002", "5", "KILOGRAM", "NORMAL"));

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue().allocations()).hasSize(1);
            assertThat(result.unsafeGetValue().allocations().getFirst().allocatedQuantity().amount())
                    .isEqualByComparingTo(new BigDecimal("5"));
        }

        @Test
        @DisplayName("should update availableQuantity after reservation")
        void shouldUpdateAvailableQuantityAfterReservation() {
            var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("20"));

            stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));

            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("12"));
        }

        @Test
        @DisplayName("should add reservation to reservations list")
        void shouldAddReservationToList() {
            var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            assertThat(stock.reservations()).isEmpty();

            stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
            stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "3", "KILOGRAM", "URGENT"));

            assertThat(stock.reservations()).hasSize(2);
        }

        @Test
        @DisplayName("should fail with InsufficientStock when stock has no batches")
        void shouldFailWhenNoBatches() {
            var stock = createValidStock();

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "1", "KILOGRAM", "NORMAL"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
        }

        @Test
        @DisplayName("should reserve exact full amount of batch")
        void shouldReserveExactFullAmount() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue().allocations()).hasSize(1);
            assertThat(result.unsafeGetValue().allocations().getFirst().allocatedQuantity().amount())
                    .isEqualByComparingTo(new BigDecimal("10"));
        }

        // ==================== Validation Edge Cases ====================

        @Test
        @DisplayName("should fail with InvalidReferenceType when null")
        void shouldFailWhenReferenceTypeNull() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft(null, "PO-001", "5", "KILOGRAM", "NORMAL"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceType.class);
        }

        @Test
        @DisplayName("should fail with InvalidReferenceType when invalid value")
        void shouldFailWhenReferenceTypeInvalid() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("INVALID_TYPE", "PO-001", "5", "KILOGRAM", "NORMAL"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceType.class);
        }

        @Test
        @DisplayName("should fail with InvalidReferenceId when referenceId is blank")
        void shouldFailWhenReferenceIdBlank() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "", "5", "KILOGRAM", "NORMAL"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceId.class);
        }

        @Test
        @DisplayName("should fail with InvalidReferenceId when referenceId is null")
        void shouldFailWhenReferenceIdNull() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", null, "5", "KILOGRAM", "NORMAL"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceId.class);
        }

        @Test
        @DisplayName("should fail with InvalidQuantity when amount is not a number")
        void shouldFailWhenQuantityNotNumber() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "abc", "KILOGRAM", "NORMAL"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
        }

        @Test
        @DisplayName("should fail with InvalidQuantity when amount is negative")
        void shouldFailWhenQuantityNegative() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "-5", "KILOGRAM", "NORMAL"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
        }

        @Test
        @DisplayName("should fail with InvalidQuantity when unit is invalid")
        void shouldFailWhenUnitInvalid() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "INVALID_UNIT", "NORMAL"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
        }

        @Test
        @DisplayName("should fail with InvalidReservationPriority when null")
        void shouldFailWhenPriorityNull() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", null));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReservationPriority.class);
        }

        @Test
        @DisplayName("should fail with InvalidReservationPriority when invalid value")
        void shouldFailWhenPriorityInvalid() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "SUPER_URGENT"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReservationPriority.class);
        }

        @Test
        @DisplayName("should accept LOW priority")
        void shouldAcceptLowPriority() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "LOW"));

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue().priority()).isEqualTo(ReservationPriority.LOW);
        }
    }

    // ==================== confirmReservation ====================

    @Nested
    @DisplayName("confirmReservation()")
    class ConfirmReservation {

        @Test
        @DisplayName("should deduct allocated quantities and remove reservation")
        void shouldDeductAllocatedQuantitiesAndRemoveReservation() {
            var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
            var batchId = stock.batches().getFirst().id();

            var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
            assertThat(reserveResult.isSuccess()).isTrue();
            var reservationId = reserveResult.unsafeGetValue().id();

            var result = stock.confirmReservation(reservationId);

            assertThat(result.isSuccess()).isTrue();
            var confirmed = result.unsafeGetValue();
            assertThat(confirmed.referenceType()).isEqualTo(ReferenceType.PRODUCTION_ORDER);
            assertThat(confirmed.referenceId()).isEqualTo("PO-001");
            assertThat(confirmed.allocations()).hasSize(1);
            assertThat(confirmed.allocations().getFirst().stockBatchId()).isEqualTo(batchId);
            assertThat(confirmed.allocations().getFirst().allocatedQuantity().amount())
                    .isEqualByComparingTo(new BigDecimal("8"));
            assertThat(stock.reservations()).isEmpty();
            assertThat(stock.batches()).hasSize(1);
            assertThat(stock.batches().getFirst().quantity().amount())
                    .isEqualByComparingTo(new BigDecimal("12"));
        }

        @Test
        @DisplayName("should deduct from multiple batches correctly")
        void shouldDeductFromMultipleBatches() {
            var batch1 = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("6"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var batch2 = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("8"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(batch1, batch2)), List.of()
            );

            // Reserve 10kg: 6kg from batch1 (FEFO) + 4kg from batch2
            var reserveResult = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "10", "KILOGRAM", "NORMAL"));
            assertThat(reserveResult.isSuccess()).isTrue();
            assertThat(reserveResult.unsafeGetValue().allocations()).hasSize(2);

            var result = stock.confirmReservation(reserveResult.unsafeGetValue().id());

            assertThat(result.isSuccess()).isTrue();
            var confirmed = result.unsafeGetValue();
            assertThat(confirmed.referenceType()).isEqualTo(ReferenceType.SALE_ORDER);
            assertThat(confirmed.allocations()).hasSize(2);
            assertThat(stock.reservations()).isEmpty();
            // batch1 fully consumed (0 kg), batch2 has 4kg remaining
            assertThat(stock.batches()).hasSize(2);
            assertThat(stock.batches().get(0).quantity().amount())
                    .isEqualByComparingTo(BigDecimal.ZERO);
            assertThat(stock.batches().get(1).quantity().amount())
                    .isEqualByComparingTo(new BigDecimal("4"));
        }

        @Test
        @DisplayName("should keep batch with zero quantity after full deduction")
        void shouldKeepBatchWithZeroQuantity() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
            assertThat(reserveResult.isSuccess()).isTrue();

            var result = stock.confirmReservation(reserveResult.unsafeGetValue().id());

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.batches()).hasSize(1);
            assertThat(stock.batches().getFirst().quantity().amount()).isEqualByComparingTo(BigDecimal.ZERO);
            assertThat(stock.reservations()).isEmpty();
        }

        @Test
        @DisplayName("should fail with ReservationNotFound when reservation does not exist")
        void shouldFailWhenReservationNotFound() {
            var stock = createValidStock();

            var result = stock.confirmReservation(ReservationId.of("nonexistent"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.class);
        }

        @Test
        @DisplayName("should return correct batch references in confirmed allocations")
        void shouldReturnCorrectBatchReferences() {
            var batchRef = new BatchReference("MY-BATCH-42", BatchType.PURCHASED);
            var batch = StockBatch.reconstitute(
                    StockBatchId.generate(), batchRef,
                    Quantity.reconstitute(new BigDecimal("20"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(batch)), List.of()
            );

            var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
            var result = stock.confirmReservation(reserveResult.unsafeGetValue().id());

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue().allocations().getFirst().batchReference()).isEqualTo(batchRef);
        }
    }

    // ==================== releaseReservation ====================

    @Nested
    @DisplayName("releaseReservation()")
    class ReleaseReservation {

        @Test
        @DisplayName("should release reservation and restore available quantity")
        void shouldReleaseReservationAndRestoreAvailableQuantity() {
            var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
            assertThat(reserveResult.isSuccess()).isTrue();
            var reservationId = reserveResult.unsafeGetValue().id();

            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("12"));
            assertThat(stock.reservations()).hasSize(1);

            var result = stock.releaseReservation(reservationId);

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.reservations()).isEmpty();
            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("20"));
        }

        @Test
        @DisplayName("should fail with ReservationNotFound when reservation does not exist")
        void shouldFailWhenReservationNotFound() {
            var stock = createValidStock();

            var result = stock.releaseReservation(ReservationId.of("nonexistent"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.class);
        }

        @Test
        @DisplayName("should keep other reservations intact when releasing one of multiple")
        void shouldKeepOtherReservationsWhenReleasingOne() {
            var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var res1 = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
            var res2 = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "7", "KILOGRAM", "URGENT"));
            assertThat(stock.reservations()).hasSize(2);
            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("5"));

            var result = stock.releaseReservation(res1.unsafeGetValue().id());

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.reservations()).hasSize(1);
            assertThat(stock.reservations().getFirst().referenceId()).isEqualTo("SO-001");
            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("13"));
        }

        @Test
        @DisplayName("should release reservation with cross-batch FEFO allocations")
        void shouldReleaseCrossBatchAllocations() {
            var batch1 = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("6"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var batch2 = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("8"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(batch1, batch2)), List.of()
            );

            // Reservierung über beide Batches (6kg batch1 + 4kg batch2)
            var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
            assertThat(reserveResult.unsafeGetValue().allocations()).hasSize(2);
            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("4"));

            var result = stock.releaseReservation(reserveResult.unsafeGetValue().id());

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.reservations()).isEmpty();
            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("14"));
        }

        @Test
        @DisplayName("should allow new reservation after releasing previous one")
        void shouldAllowNewReservationAfterRelease() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            // Vollständig reservieren
            var res1 = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
            assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);

            // Neue Reservierung sollte fehlschlagen
            var failResult = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "1", "KILOGRAM", "NORMAL"));
            assertThat(failResult.isFailure()).isTrue();

            // Freigeben und erneut reservieren
            stock.releaseReservation(res1.unsafeGetValue().id());
            var res2 = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "10", "KILOGRAM", "URGENT"));
            assertThat(res2.isSuccess()).isTrue();
            assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
        }

        @Test
        @DisplayName("should handle release when batch was later blocked")
        void shouldReleaseWhenBatchLaterBlocked() {
            var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
            var batchId = stock.batches().getFirst().id();

            var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
            stock.blockBatch(batchId);

            // availableQuantity == 0 (blocked batch)
            assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);

            var result = stock.releaseReservation(reserveResult.unsafeGetValue().id());

            assertThat(result.isSuccess()).isTrue();
            assertThat(stock.reservations()).isEmpty();
            // Batch still blocked → availableQuantity remains 0
            assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
        }

        @Test
        @DisplayName("should fail with ReservationNotFound on empty reservations list")
        void shouldFailOnEmptyReservationsList() {
            var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var result = stock.releaseReservation(ReservationId.of("any-id"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.class);
        }

        @Test
        @DisplayName("should restore fractional quantity after release")
        void shouldRestoreFractionalQuantity() {
            var stock = createStockWithBatchAndExpiry("10.5", UnitOfMeasure.KILOGRAM,
                    StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));

            var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5.25", "KILOGRAM", "NORMAL"));
            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("5.25"));

            stock.releaseReservation(reserveResult.unsafeGetValue().id());

            assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("10.5"));
        }
    }

    // ==================== uniformUnitOfMeasure ====================

    @Nested
    @DisplayName("uniformUnitOfMeasure()")
    class UniformUnitOfMeasure {

        @Test
        @DisplayName("should return empty when no batches")
        void should_returnEmpty_when_noBatches() {
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, List.of(), List.of()
            );

            assertThat(stock.uniformUnitOfMeasure()).isEmpty();
        }

        @Test
        @DisplayName("should return UoM when all batches have same unit")
        void should_returnUoM_when_allBatchesSameUnit() {
            var batch1 = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var batch2 = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(batch1, batch2)), List.of()
            );

            assertThat(stock.uniformUnitOfMeasure()).contains(UnitOfMeasure.KILOGRAM);
        }

        @Test
        @DisplayName("should return empty when batches have different units")
        void should_returnEmpty_when_heterogeneousUnits() {
            var kgBatch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-001", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var literBatch = StockBatch.reconstitute(
                    StockBatchId.generate(),
                    new BatchReference("BATCH-002", BatchType.PRODUCED),
                    Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.LITER),
                    LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
            );
            var stock = Stock.reconstitute(
                    StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
                    null, null, new ArrayList<>(List.of(kgBatch, literBatch)), List.of()
            );

            assertThat(stock.uniformUnitOfMeasure()).isEmpty();
        }

        @Test
        @DisplayName("should return UoM when single batch present")
        void should_returnUoM_when_singleBatch() {
            var stock = createStockWithBatch("10", UnitOfMeasure.PIECE, StockBatchStatus.AVAILABLE);

            assertThat(stock.uniformUnitOfMeasure()).contains(UnitOfMeasure.PIECE);
        }
    }

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

    private Stock createValidStock() {
        var draft = new StockDraft("article-1", "location-1", "10", "KILOGRAM", 30);
        return Stock.create(draft).unsafeGetValue();
    }

    private Stock createStockWithBatch(String amount, UnitOfMeasure uom, StockBatchStatus status) {
        return createStockWithBatchAndExpiry(amount, uom, status, LocalDate.of(2026, 12, 31));
    }

    private Stock createStockWithBatchAndExpiry(String amount, UnitOfMeasure uom, StockBatchStatus status, LocalDate expiryDate) {
        var batch = StockBatch.reconstitute(
                StockBatchId.generate(),
                new BatchReference("BATCH-001", BatchType.PRODUCED),
                Quantity.reconstitute(new BigDecimal(amount), uom),
                expiryDate,
                status,
                Instant.now()
        );
        return Stock.reconstitute(
                StockId.generate(),
                ArticleId.of("article-1"),
                StorageLocationId.of("location-1"),
                null, null,
                new ArrayList<>(List.of(batch)), List.of()
        );
    }
}
