package de.effigenix.domain.production;

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.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;

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

@DisplayName("Batch Aggregate")
class BatchTest {

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

    private BatchDraft validDraft() {
        return new BatchDraft("recipe-123", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
    }

    @Nested
    @DisplayName("plan()")
    class Plan {

        @Test
        @DisplayName("should create batch with valid draft")
        void should_CreateBatch_When_ValidDraft() {
            var result = Batch.plan(validDraft(), BATCH_NUMBER);

            assertThat(result.isSuccess()).isTrue();
            var batch = result.unsafeGetValue();
            assertThat(batch.id()).isNotNull();
            assertThat(batch.batchNumber()).isEqualTo(BATCH_NUMBER);
            assertThat(batch.recipeId().value()).isEqualTo("recipe-123");
            assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
            assertThat(batch.plannedQuantity().amount()).isEqualByComparingTo(new BigDecimal("100"));
            assertThat(batch.plannedQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
            assertThat(batch.productionDate()).isEqualTo(PRODUCTION_DATE);
            assertThat(batch.bestBeforeDate()).isEqualTo(BEST_BEFORE_DATE);
            assertThat(batch.createdAt()).isNotNull();
            assertThat(batch.updatedAt()).isNotNull();
        }

        @Test
        @DisplayName("should fail when recipeId is blank")
        void should_Fail_When_RecipeIdBlank() {
            var draft = new BatchDraft("", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
        }

        @Test
        @DisplayName("should fail when recipeId is null")
        void should_Fail_When_RecipeIdNull() {
            var draft = new BatchDraft(null, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
        }

        @Test
        @DisplayName("should fail when plannedQuantity is zero")
        void should_Fail_When_QuantityZero() {
            var draft = new BatchDraft("recipe-123", "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
        }

        @Test
        @DisplayName("should fail when plannedQuantity is negative")
        void should_Fail_When_QuantityNegative() {
            var draft = new BatchDraft("recipe-123", "-5", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
        }

        @Test
        @DisplayName("should fail when plannedQuantity is not a number")
        void should_Fail_When_QuantityNotANumber() {
            var draft = new BatchDraft("recipe-123", "abc", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
        }

        @Test
        @DisplayName("should fail when unit is invalid")
        void should_Fail_When_UnitInvalid() {
            var draft = new BatchDraft("recipe-123", "100", "INVALID_UNIT", PRODUCTION_DATE, BEST_BEFORE_DATE);

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
        }

        @Test
        @DisplayName("should fail when bestBeforeDate is before productionDate")
        void should_Fail_When_BestBeforeDateBeforeProductionDate() {
            var draft = new BatchDraft("recipe-123", "100", "KILOGRAM",
                    LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1));

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class);
        }

        @Test
        @DisplayName("should fail when bestBeforeDate equals productionDate")
        void should_Fail_When_BestBeforeDateEqualsProductionDate() {
            var sameDate = LocalDate.of(2026, 3, 1);
            var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", sameDate, sameDate);

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class);
        }

        @Test
        @DisplayName("should fail when productionDate is null")
        void should_Fail_When_ProductionDateNull() {
            var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", null, BEST_BEFORE_DATE);

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
        }

        @Test
        @DisplayName("should fail when bestBeforeDate is null")
        void should_Fail_When_BestBeforeDateNull() {
            var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", PRODUCTION_DATE, null);

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
        }

        @Test
        @DisplayName("should accept decimal quantity")
        void should_Accept_When_DecimalQuantity() {
            var draft = new BatchDraft("recipe-123", "50.75", "LITER", PRODUCTION_DATE, BEST_BEFORE_DATE);

            var result = Batch.plan(draft, BATCH_NUMBER);

            assertThat(result.isSuccess()).isTrue();
            assertThat(result.unsafeGetValue().plannedQuantity().amount())
                    .isEqualByComparingTo(new BigDecimal("50.75"));
            assertThat(result.unsafeGetValue().plannedQuantity().uom()).isEqualTo(UnitOfMeasure.LITER);
        }
    }

    @Nested
    @DisplayName("startProduction()")
    class StartProduction {

        @Test
        @DisplayName("should transition PLANNED to IN_PRODUCTION")
        void should_TransitionToInProduction_When_Planned() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);

            var result = batch.startProduction();

            assertThat(result.isSuccess()).isTrue();
            assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION);
        }

        @Test
        @DisplayName("should update updatedAt on transition")
        void should_UpdateTimestamp_When_StartingProduction() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            var beforeUpdate = batch.updatedAt();

            batch.startProduction();

            assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeUpdate);
        }

        @Test
        @DisplayName("should fail when already IN_PRODUCTION")
        void should_Fail_When_AlreadyInProduction() {
            var batch = Batch.reconstitute(
                    BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
                    BatchStatus.IN_PRODUCTION,
                    Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                    null, null, null,
                    PRODUCTION_DATE, BEST_BEFORE_DATE,
                    OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
                    null,
                    null,
                    null,
                    0L, List.of()
            );

            var result = batch.startProduction();

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
            var err = (BatchError.InvalidStatusTransition) result.unsafeGetError();
            assertThat(err.current()).isEqualTo(BatchStatus.IN_PRODUCTION);
            assertThat(err.target()).isEqualTo(BatchStatus.IN_PRODUCTION);
        }

        @Test
        @DisplayName("should fail when COMPLETED")
        void should_Fail_When_Completed() {
            var batch = Batch.reconstitute(
                    BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
                    BatchStatus.COMPLETED,
                    Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                    null, null, null,
                    PRODUCTION_DATE, BEST_BEFORE_DATE,
                    OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
                    null,
                    null,
                    null,
                    0L, List.of()
            );

            var result = batch.startProduction();

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
        }

        @Test
        @DisplayName("should fail when CANCELLED")
        void should_Fail_When_Cancelled() {
            var batch = Batch.reconstitute(
                    BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
                    BatchStatus.CANCELLED,
                    Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                    null, null, null,
                    PRODUCTION_DATE, BEST_BEFORE_DATE,
                    OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
                    null,
                    null,
                    null,
                    0L, List.of()
            );

            var result = batch.startProduction();

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
        }
    }

    @Nested
    @DisplayName("recordConsumption()")
    class RecordConsumption {

        private Batch inProductionBatch() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            batch.startProduction();
            return batch;
        }

        @Test
        @DisplayName("should record consumption when IN_PRODUCTION")
        void should_RecordConsumption_When_InProduction() {
            var batch = inProductionBatch();
            var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");

            var result = batch.recordConsumption(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(batch.consumptions()).hasSize(1);
            assertThat(result.unsafeGetValue().inputBatchId().value()).isEqualTo("input-1");
        }

        @Test
        @DisplayName("should record multiple different consumptions")
        void should_RecordMultiple_When_DifferentInputBatches() {
            var batch = inProductionBatch();
            batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
            batch.recordConsumption(new ConsumptionDraft("input-2", "article-2", "3.0", "LITER"));

            assertThat(batch.consumptions()).hasSize(2);
        }

        @Test
        @DisplayName("should fail when not IN_PRODUCTION (PLANNED)")
        void should_Fail_When_Planned() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");

            var result = batch.recordConsumption(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.NotInProduction.class);
        }

        @Test
        @DisplayName("should fail when not IN_PRODUCTION (COMPLETED)")
        void should_Fail_When_Completed() {
            var batch = Batch.reconstitute(
                    BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
                    BatchStatus.COMPLETED,
                    Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                    null, null, null,
                    PRODUCTION_DATE, BEST_BEFORE_DATE,
                    OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
                    null,
                    null,
                    null,
                    0L, List.of()
            );
            var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");

            var result = batch.recordConsumption(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.NotInProduction.class);
        }

        @Test
        @DisplayName("should fail when duplicate inputBatchId")
        void should_Fail_When_DuplicateInputBatch() {
            var batch = inProductionBatch();
            batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));

            var result = batch.recordConsumption(
                    new ConsumptionDraft("input-1", "article-2", "3.0", "LITER"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.DuplicateInputBatch.class);
            assertThat(batch.consumptions()).hasSize(1);
        }

        @Test
        @DisplayName("should fail when consumption quantity is invalid")
        void should_Fail_When_InvalidQuantity() {
            var batch = inProductionBatch();
            var draft = new ConsumptionDraft("input-1", "article-1", "0", "KILOGRAM");

            var result = batch.recordConsumption(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidConsumptionQuantity.class);
            assertThat(batch.consumptions()).isEmpty();
        }

        @Test
        @DisplayName("should update updatedAt when recording consumption")
        void should_UpdateTimestamp_When_RecordingConsumption() {
            var batch = inProductionBatch();
            var beforeRecord = batch.updatedAt();

            batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));

            assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeRecord);
        }
    }

    @Nested
    @DisplayName("complete()")
    class Complete {

        private Batch inProductionBatchWithConsumption() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            batch.startProduction();
            batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
            return batch;
        }

        @Test
        @DisplayName("should complete batch with actual quantity, waste, and remarks")
        void should_Complete_When_ValidDraft() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", "Alles ok");

            var result = batch.complete(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(batch.status()).isEqualTo(BatchStatus.COMPLETED);
            assertThat(batch.actualQuantity().amount()).isEqualByComparingTo(new BigDecimal("95"));
            assertThat(batch.actualQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
            assertThat(batch.waste().amount()).isEqualByComparingTo(new BigDecimal("5"));
            assertThat(batch.waste().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
            assertThat(batch.remarks()).isEqualTo("Alles ok");
            assertThat(batch.completedAt()).isNotNull();
        }

        @Test
        @DisplayName("should complete batch with zero waste")
        void should_Complete_When_ZeroWaste() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("100", "KILOGRAM", "0", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(batch.status()).isEqualTo(BatchStatus.COMPLETED);
            assertThat(batch.waste().amount()).isEqualByComparingTo(BigDecimal.ZERO);
        }

        @Test
        @DisplayName("should complete batch without remarks")
        void should_Complete_When_NoRemarks() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(batch.remarks()).isNull();
        }

        @Test
        @DisplayName("should fail when not IN_PRODUCTION (PLANNED)")
        void should_Fail_When_Planned() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
        }

        @Test
        @DisplayName("should fail when already COMPLETED")
        void should_Fail_When_AlreadyCompleted() {
            var batch = Batch.reconstitute(
                    BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
                    BatchStatus.COMPLETED,
                    Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                    null, null, null,
                    PRODUCTION_DATE, BEST_BEFORE_DATE,
                    OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
                    null,
                    null,
                    null,
                    0L, List.of()
            );
            var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
        }

        @Test
        @DisplayName("should fail when no consumptions recorded")
        void should_Fail_When_NoConsumptions() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            batch.startProduction();
            var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.MissingConsumptions.class);
        }

        @Test
        @DisplayName("should fail when actualQuantity is zero")
        void should_Fail_When_ActualQuantityZero() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("0", "KILOGRAM", "5", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidActualQuantity.class);
        }

        @Test
        @DisplayName("should fail when actualQuantity is negative")
        void should_Fail_When_ActualQuantityNegative() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("-10", "KILOGRAM", "5", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidActualQuantity.class);
        }

        @Test
        @DisplayName("should fail when waste is negative")
        void should_Fail_When_WasteNegative() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("95", "KILOGRAM", "-1", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidWaste.class);
        }

        @Test
        @DisplayName("should fail when CANCELLED")
        void should_Fail_When_Cancelled() {
            var batch = Batch.reconstitute(
                    BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
                    BatchStatus.CANCELLED,
                    Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                    null, null, null,
                    PRODUCTION_DATE, BEST_BEFORE_DATE,
                    OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
                    null,
                    null,
                    null,
                    0L, List.of()
            );
            var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
            var err = (BatchError.InvalidStatusTransition) result.unsafeGetError();
            assertThat(err.current()).isEqualTo(BatchStatus.CANCELLED);
            assertThat(err.target()).isEqualTo(BatchStatus.COMPLETED);
        }

        @Test
        @DisplayName("should fail when waste format is invalid")
        void should_Fail_When_InvalidWasteFormat() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("95", "KILOGRAM", "abc", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidWaste.class);
        }

        @Test
        @DisplayName("should fail when waste unit is invalid")
        void should_Fail_When_InvalidWasteUnit() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "INVALID", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidWaste.class);
        }

        @Test
        @DisplayName("should not mutate state on validation failure")
        void should_NotMutateState_When_ValidationFails() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("0", "KILOGRAM", "5", "KILOGRAM", null);

            batch.complete(draft);

            assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION);
            assertThat(batch.actualQuantity()).isNull();
            assertThat(batch.waste()).isNull();
            assertThat(batch.remarks()).isNull();
            assertThat(batch.completedAt()).isNull();
        }

        @Test
        @DisplayName("should not mutate state when missing consumptions")
        void should_NotMutateState_When_MissingConsumptions() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            batch.startProduction();
            var beforeUpdate = batch.updatedAt();

            batch.complete(new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null));

            assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION);
            assertThat(batch.actualQuantity()).isNull();
            assertThat(batch.updatedAt()).isEqualTo(beforeUpdate);
        }

        @Test
        @DisplayName("should fail when actualQuantity format is invalid")
        void should_Fail_When_InvalidActualQuantityFormat() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("abc", "KILOGRAM", "5", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidActualQuantity.class);
        }

        @Test
        @DisplayName("should fail when actualQuantity unit is invalid")
        void should_Fail_When_InvalidActualQuantityUnit() {
            var batch = inProductionBatchWithConsumption();
            var draft = new CompleteBatchDraft("95", "INVALID", "5", "KILOGRAM", null);

            var result = batch.complete(draft);

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidActualQuantity.class);
        }

        @Test
        @DisplayName("should update updatedAt and set completedAt on completion")
        void should_UpdateTimestamps_When_Completing() {
            var batch = inProductionBatchWithConsumption();
            var beforeComplete = batch.updatedAt();

            batch.complete(new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null));

            assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeComplete);
            assertThat(batch.completedAt()).isNotNull();
            assertThat(batch.completedAt()).isEqualTo(batch.updatedAt());
        }
    }

    @Nested
    @DisplayName("cancel()")
    class Cancel {

        @Test
        @DisplayName("should cancel PLANNED batch")
        void should_Cancel_When_Planned() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            var draft = new CancelBatchDraft("Fehlplanung");

            var result = batch.cancel(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(batch.status()).isEqualTo(BatchStatus.CANCELLED);
            assertThat(batch.cancellationReason()).isEqualTo("Fehlplanung");
            assertThat(batch.cancelledAt()).isNotNull();
        }

        @Test
        @DisplayName("should cancel IN_PRODUCTION batch")
        void should_Cancel_When_InProduction() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            batch.startProduction();
            var draft = new CancelBatchDraft("Qualitätsproblem");

            var result = batch.cancel(draft);

            assertThat(result.isSuccess()).isTrue();
            assertThat(batch.status()).isEqualTo(BatchStatus.CANCELLED);
            assertThat(batch.cancellationReason()).isEqualTo("Qualitätsproblem");
        }

        @Test
        @DisplayName("should fail when COMPLETED")
        void should_Fail_When_Completed() {
            var batch = Batch.reconstitute(
                    BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
                    BatchStatus.COMPLETED,
                    Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                    null, null, null,
                    PRODUCTION_DATE, BEST_BEFORE_DATE,
                    OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
                    null, null, null,
                    0L, List.of()
            );

            var result = batch.cancel(new CancelBatchDraft("Grund"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
            var err = (BatchError.InvalidStatusTransition) result.unsafeGetError();
            assertThat(err.current()).isEqualTo(BatchStatus.COMPLETED);
            assertThat(err.target()).isEqualTo(BatchStatus.CANCELLED);
        }

        @Test
        @DisplayName("should fail when already CANCELLED")
        void should_Fail_When_AlreadyCancelled() {
            var batch = Batch.reconstitute(
                    BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
                    BatchStatus.CANCELLED,
                    Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                    null, null, null,
                    PRODUCTION_DATE, BEST_BEFORE_DATE,
                    OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
                    null, null, null,
                    0L, List.of()
            );

            var result = batch.cancel(new CancelBatchDraft("Grund"));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
        }

        @Test
        @DisplayName("should fail when reason is null")
        void should_Fail_When_ReasonNull() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();

            var result = batch.cancel(new CancelBatchDraft(null));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonRequired.class);
        }

        @Test
        @DisplayName("should fail when reason is blank")
        void should_Fail_When_ReasonBlank() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();

            var result = batch.cancel(new CancelBatchDraft("   "));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonRequired.class);
        }

        @Test
        @DisplayName("should fail when reason exceeds 500 characters")
        void should_Fail_When_ReasonTooLong() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            var longReason = "A".repeat(501);

            var result = batch.cancel(new CancelBatchDraft(longReason));

            assertThat(result.isFailure()).isTrue();
            assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonTooLong.class);
            assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
        }

        @Test
        @DisplayName("should accept reason with exactly 500 characters")
        void should_Accept_When_ReasonExactly500() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            var reason = "A".repeat(500);

            var result = batch.cancel(new CancelBatchDraft(reason));

            assertThat(result.isSuccess()).isTrue();
            assertThat(batch.cancellationReason()).isEqualTo(reason);
        }

        @Test
        @DisplayName("should strip whitespace from reason")
        void should_StripWhitespace_When_Cancelling() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();

            var result = batch.cancel(new CancelBatchDraft("  Fehlplanung  "));

            assertThat(result.isSuccess()).isTrue();
            assertThat(batch.cancellationReason()).isEqualTo("Fehlplanung");
        }

        @Test
        @DisplayName("should set cancelledAt and updatedAt timestamps")
        void should_SetTimestamps_When_Cancelling() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            var beforeCancel = batch.updatedAt();

            batch.cancel(new CancelBatchDraft("Grund"));

            assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeCancel);
            assertThat(batch.cancelledAt()).isNotNull();
            assertThat(batch.cancelledAt()).isEqualTo(batch.updatedAt());
        }

        @Test
        @DisplayName("should not mutate state on validation failure")
        void should_NotMutateState_When_ValidationFails() {
            var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
            var beforeUpdate = batch.updatedAt();

            batch.cancel(new CancelBatchDraft(null));

            assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
            assertThat(batch.cancellationReason()).isNull();
            assertThat(batch.cancelledAt()).isNull();
            assertThat(batch.updatedAt()).isEqualTo(beforeUpdate);
        }
    }

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

        @Test
        @DisplayName("should reconstitute batch from persistence")
        void should_Reconstitute_When_ValidData() {
            var batch = Batch.reconstitute(
                    BatchId.of("batch-1"),
                    BATCH_NUMBER,
                    RecipeId.of("recipe-123"),
                    BatchStatus.IN_PRODUCTION,
                    Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                    null, null, null,
                    PRODUCTION_DATE,
                    BEST_BEFORE_DATE,
                    OffsetDateTime.now(ZoneOffset.UTC),
                    OffsetDateTime.now(ZoneOffset.UTC),
                    null,
                    null,
                    null,
                    0L, List.of()
            );

            assertThat(batch.id().value()).isEqualTo("batch-1");
            assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION);
        }
    }
}
