package de.effigenix.application.production;

import de.effigenix.application.production.command.StartProductionOrderCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.persistence.UnitOfWork;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@DisplayName("StartProductionOrder Use Case")
class StartProductionOrderTest {

    @Mock private ProductionOrderRepository productionOrderRepository;
    @Mock private BatchRepository batchRepository;
    @Mock private RecipeRepository recipeRepository;
    @Mock private BatchNumberGenerator batchNumberGenerator;
    @Mock private AuthorizationPort authPort;
    @Mock private UnitOfWork unitOfWork;

    private StartProductionOrder startProductionOrder;
    private ActorId performedBy;

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

    @BeforeEach
    void setUp() {
        startProductionOrder = new StartProductionOrder(
                productionOrderRepository, batchRepository, recipeRepository,
                batchNumberGenerator, authPort, unitOfWork);
        performedBy = ActorId.of("admin-user");
        lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
    }

    private StartProductionOrderCommand validCommand() {
        return new StartProductionOrderCommand("order-1");
    }

    private ProductionOrder releasedOrder() {
        return ProductionOrder.reconstitute(
                ProductionOrderId.of("order-1"),
                RecipeId.of("recipe-1"),
                ProductionOrderStatus.RELEASED,
                null,
                Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                PLANNED_DATE,
                Priority.NORMAL,
                null,
                null,
                OffsetDateTime.now(ZoneOffset.UTC),
                OffsetDateTime.now(ZoneOffset.UTC),
                1L
        );
    }

    private ProductionOrder plannedOrder() {
        return ProductionOrder.reconstitute(
                ProductionOrderId.of("order-1"),
                RecipeId.of("recipe-1"),
                ProductionOrderStatus.PLANNED,
                null,
                Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                PLANNED_DATE,
                Priority.NORMAL,
                null,
                null,
                OffsetDateTime.now(ZoneOffset.UTC),
                OffsetDateTime.now(ZoneOffset.UTC),
                1L
        );
    }

    private Recipe activeRecipe() {
        return Recipe.reconstitute(
                RecipeId.of("recipe-1"),
                RecipeName.of("Test Recipe").unsafeGetValue(),
                1,
                RecipeType.FINISHED_PRODUCT,
                "Test",
                YieldPercentage.of(100).unsafeGetValue(),
                SHELF_LIFE_DAYS,
                Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                "ART-001",
                RecipeStatus.ACTIVE,
                List.of(),
                List.of(),
                OffsetDateTime.now(ZoneOffset.UTC),
                OffsetDateTime.now(ZoneOffset.UTC)
        );
    }

    private Recipe draftRecipe() {
        return Recipe.reconstitute(
                RecipeId.of("recipe-1"),
                RecipeName.of("Test Recipe").unsafeGetValue(),
                1,
                RecipeType.FINISHED_PRODUCT,
                "Test",
                YieldPercentage.of(100).unsafeGetValue(),
                SHELF_LIFE_DAYS,
                Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                "ART-001",
                RecipeStatus.DRAFT,
                List.of(),
                List.of(),
                OffsetDateTime.now(ZoneOffset.UTC),
                OffsetDateTime.now(ZoneOffset.UTC)
        );
    }

    private BatchNumber batchNumber() {
        return new BatchNumber("P-" + PLANNED_DATE + "-001");
    }

    private void setupHappyPath() {
        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.success(Optional.of(releasedOrder())));
        when(recipeRepository.findById(RecipeId.of("recipe-1")))
                .thenReturn(Result.success(Optional.of(activeRecipe())));
        when(batchNumberGenerator.generateNext(PLANNED_DATE))
                .thenReturn(Result.success(batchNumber()));
        when(productionOrderRepository.save(any())).thenReturn(Result.success(null));
        when(batchRepository.save(any())).thenReturn(Result.success(null));
    }

    @Test
    @DisplayName("should auto-create batch and start production when order is RELEASED")
    void should_StartProduction_When_ValidCommand() {
        setupHappyPath();

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isSuccess()).isTrue();
        var startResult = result.unsafeGetValue();
        assertThat(startResult.order().status()).isEqualTo(ProductionOrderStatus.IN_PROGRESS);
        assertThat(startResult.order().batchId()).isNotNull();
        assertThat(startResult.batch().status()).isEqualTo(BatchStatus.IN_PRODUCTION);
        assertThat(startResult.batch().batchNumber()).isEqualTo(batchNumber());
        assertThat(startResult.batch().recipeId()).isEqualTo(RecipeId.of("recipe-1"));
        verify(productionOrderRepository).save(any(ProductionOrder.class));
        verify(batchRepository).save(any(Batch.class));
    }

    @Test
    @DisplayName("should calculate bestBeforeDate from plannedDate + shelfLifeDays")
    void should_CalculateBestBeforeDate() {
        setupHappyPath();

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isSuccess()).isTrue();
        var batch = result.unsafeGetValue().batch();
        assertThat(batch.bestBeforeDate()).isEqualTo(PLANNED_DATE.plusDays(SHELF_LIFE_DAYS));
    }

    @Test
    @DisplayName("should fail when actor lacks PRODUCTION_ORDER_WRITE permission")
    void should_Fail_When_Unauthorized() {
        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(false);

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.Unauthorized.class);
        verify(productionOrderRepository, never()).save(any());
    }

    @Test
    @DisplayName("should fail when production order not found")
    void should_Fail_When_OrderNotFound() {
        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.success(Optional.empty()));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ProductionOrderNotFound.class);
        verify(productionOrderRepository, never()).save(any());
    }

    @Test
    @DisplayName("should fail when recipe not found")
    void should_Fail_When_RecipeNotFound() {
        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.success(Optional.of(releasedOrder())));
        when(recipeRepository.findById(RecipeId.of("recipe-1")))
                .thenReturn(Result.success(Optional.empty()));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class);
        assertThat(result.unsafeGetError().message()).contains("Recipe");
        assertThat(result.unsafeGetError().message()).contains("not found");
    }

    @Test
    @DisplayName("should fail when recipe is not ACTIVE")
    void should_Fail_When_RecipeNotActive() {
        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.success(Optional.of(releasedOrder())));
        when(recipeRepository.findById(RecipeId.of("recipe-1")))
                .thenReturn(Result.success(Optional.of(draftRecipe())));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class);
        assertThat(result.unsafeGetError().message()).contains("not ACTIVE");
    }

    @Test
    @DisplayName("should fail when order is not RELEASED")
    void should_Fail_When_OrderNotReleased() {
        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.success(Optional.of(plannedOrder())));
        when(recipeRepository.findById(RecipeId.of("recipe-1")))
                .thenReturn(Result.success(Optional.of(activeRecipe())));
        when(batchNumberGenerator.generateNext(PLANNED_DATE))
                .thenReturn(Result.success(batchNumber()));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidStatusTransition.class);
        verify(productionOrderRepository, never()).save(any());
    }

    @Test
    @DisplayName("should fail when order repository returns error")
    void should_Fail_When_OrderRepositoryError() {
        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class);
        verify(productionOrderRepository, never()).save(any());
    }

    @Test
    @DisplayName("should fail when recipe repository returns error")
    void should_Fail_When_RecipeRepositoryError() {
        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.success(Optional.of(releasedOrder())));
        when(recipeRepository.findById(RecipeId.of("recipe-1")))
                .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class);
        verify(productionOrderRepository, never()).save(any());
    }

    @Test
    @DisplayName("should fail when batch save fails")
    void should_Fail_When_BatchSaveFails() {
        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.success(Optional.of(releasedOrder())));
        when(recipeRepository.findById(RecipeId.of("recipe-1")))
                .thenReturn(Result.success(Optional.of(activeRecipe())));
        when(batchNumberGenerator.generateNext(PLANNED_DATE))
                .thenReturn(Result.success(batchNumber()));
        when(batchRepository.save(any()))
                .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class);
    }

    @Test
    @DisplayName("should fail when order save fails")
    void should_Fail_When_OrderSaveFails() {
        setupHappyPath();
        when(productionOrderRepository.save(any()))
                .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class);
    }

    @Test
    @DisplayName("should fail when batch number generation fails")
    void should_Fail_When_BatchNumberGenerationFails() {
        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.success(Optional.of(releasedOrder())));
        when(recipeRepository.findById(RecipeId.of("recipe-1")))
                .thenReturn(Result.success(Optional.of(activeRecipe())));
        when(batchNumberGenerator.generateNext(PLANNED_DATE))
                .thenReturn(Result.failure(new BatchError.RepositoryFailure("Sequence corrupted")));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class);
        verify(productionOrderRepository, never()).save(any());
        verify(batchRepository, never()).save(any());
    }

    @Test
    @DisplayName("should fail when recipe has null shelfLifeDays")
    void should_Fail_When_ShelfLifeDaysNull() {
        var recipeWithoutShelfLife = Recipe.reconstitute(
                RecipeId.of("recipe-1"),
                RecipeName.of("Test Recipe").unsafeGetValue(),
                1,
                RecipeType.FINISHED_PRODUCT,
                "Test",
                YieldPercentage.of(100).unsafeGetValue(),
                null,
                Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                "ART-001",
                RecipeStatus.ACTIVE,
                List.of(),
                List.of(),
                OffsetDateTime.now(ZoneOffset.UTC),
                OffsetDateTime.now(ZoneOffset.UTC)
        );

        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.success(Optional.of(releasedOrder())));
        when(recipeRepository.findById(RecipeId.of("recipe-1")))
                .thenReturn(Result.success(Optional.of(recipeWithoutShelfLife)));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class);
        assertThat(result.unsafeGetError().message()).contains("shelf life");
        verify(productionOrderRepository, never()).save(any());
    }

    @Test
    @DisplayName("should fail when recipe has zero shelfLifeDays")
    void should_Fail_When_ShelfLifeDaysZero() {
        var recipeWithZeroShelfLife = Recipe.reconstitute(
                RecipeId.of("recipe-1"),
                RecipeName.of("Test Recipe").unsafeGetValue(),
                1,
                RecipeType.FINISHED_PRODUCT,
                "Test",
                YieldPercentage.of(100).unsafeGetValue(),
                0,
                Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                "ART-001",
                RecipeStatus.ACTIVE,
                List.of(),
                List.of(),
                OffsetDateTime.now(ZoneOffset.UTC),
                OffsetDateTime.now(ZoneOffset.UTC)
        );

        when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true);
        when(productionOrderRepository.findById(ProductionOrderId.of("order-1")))
                .thenReturn(Result.success(Optional.of(releasedOrder())));
        when(recipeRepository.findById(RecipeId.of("recipe-1")))
                .thenReturn(Result.success(Optional.of(recipeWithZeroShelfLife)));

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isFailure()).isTrue();
        assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class);
        assertThat(result.unsafeGetError().message()).contains("shelf life");
    }

    @Test
    @DisplayName("should set batch plannedQuantity and UOM from order")
    void should_PropagateQuantityFromOrderToBatch() {
        setupHappyPath();

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isSuccess()).isTrue();
        var batch = result.unsafeGetValue().batch();
        assertThat(batch.plannedQuantity().amount()).isEqualByComparingTo(new BigDecimal("100"));
        assertThat(batch.plannedQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
    }

    @Test
    @DisplayName("should assign generated batch id to the order")
    void should_AssignBatchIdToOrder() {
        setupHappyPath();

        var result = startProductionOrder.execute(validCommand(), performedBy);

        assertThat(result.isSuccess()).isTrue();
        var startResult = result.unsafeGetValue();
        assertThat(startResult.order().batchId()).isEqualTo(startResult.batch().id());
    }
}
