package de.effigenix.application.production;

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

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
@DisplayName("RecipeCycleChecker")
class RecipeCycleCheckerTest {

    @Mock private RecipeRepository recipeRepository;

    private RecipeCycleChecker cycleChecker;

    @BeforeEach
    void setUp() {
        cycleChecker = new RecipeCycleChecker(recipeRepository);
    }

    private Recipe recipeWithSubRecipes(String id, String... subRecipeIds) {
        var ingredients = new java.util.ArrayList<Ingredient>();
        int pos = 1;
        for (var subId : subRecipeIds) {
            ingredients.add(Ingredient.reconstitute(
                    IngredientId.generate(), pos++, "article-1",
                    Quantity.of(new BigDecimal("1"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                    subId, false));
        }
        return Recipe.reconstitute(
                RecipeId.of(id), new RecipeName("Recipe-" + id), 1, RecipeType.FINISHED_PRODUCT,
                null, new YieldPercentage(100), 14,
                Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                "article-123", RecipeStatus.DRAFT, ingredients, List.of(),
                OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC));
    }

    private Recipe recipeWithoutSubRecipes(String id) {
        return recipeWithSubRecipes(id);
    }

    @Test
    @DisplayName("should_Allow_When_LinearChainWithoutCycle")
    void should_Allow_When_LinearChainWithoutCycle() {
        // A -> B -> C (no cycle)
        when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.of(recipeWithSubRecipes("B", "C"))));
        when(recipeRepository.findById(RecipeId.of("C"))).thenReturn(Result.success(Optional.of(recipeWithoutSubRecipes("C"))));

        var result = cycleChecker.check("A", "B");

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

    @Test
    @DisplayName("should_DetectCycle_When_SelfReference")
    void should_DetectCycle_When_SelfReference() {
        var result = cycleChecker.check("A", "A");

        assertThat(result.isFailure()).isTrue();
        var error = (RecipeError.CyclicDependencyDetected) result.unsafeGetError();
        assertThat(error.cyclePath()).containsExactly("A", "A");
    }

    @Test
    @DisplayName("should_DetectCycle_When_DirectCycle")
    void should_DetectCycle_When_DirectCycle() {
        // A -> B -> A
        when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.of(recipeWithSubRecipes("B", "A"))));

        var result = cycleChecker.check("A", "B");

        assertThat(result.isFailure()).isTrue();
        var error = (RecipeError.CyclicDependencyDetected) result.unsafeGetError();
        assertThat(error.cyclePath()).containsExactly("A", "B", "A");
    }

    @Test
    @DisplayName("should_DetectCycle_When_IndirectCycle")
    void should_DetectCycle_When_IndirectCycle() {
        // A -> B -> C -> A
        when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.of(recipeWithSubRecipes("B", "C"))));
        when(recipeRepository.findById(RecipeId.of("C"))).thenReturn(Result.success(Optional.of(recipeWithSubRecipes("C", "A"))));

        var result = cycleChecker.check("A", "B");

        assertThat(result.isFailure()).isTrue();
        var error = (RecipeError.CyclicDependencyDetected) result.unsafeGetError();
        assertThat(error.cyclePath()).containsExactly("A", "B", "C", "A");
    }

    @Test
    @DisplayName("should_Allow_When_SubRecipeNotFound")
    void should_Allow_When_SubRecipeNotFound() {
        when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.empty()));

        var result = cycleChecker.check("A", "B");

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

    @Test
    @DisplayName("should_Allow_When_DiamondDependency")
    void should_Allow_When_DiamondDependency() {
        // A -> B -> D, A -> C -> D (diamond, no cycle)
        when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.of(recipeWithSubRecipes("B", "D"))));
        when(recipeRepository.findById(RecipeId.of("D"))).thenReturn(Result.success(Optional.of(recipeWithoutSubRecipes("D"))));

        var result = cycleChecker.check("A", "B");

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

    @Test
    @DisplayName("should_Allow_When_MixedIngredientsWithAndWithoutSubRecipeId")
    void should_Allow_When_MixedIngredientsWithAndWithoutSubRecipeId() {
        // B has ingredients: one with subRecipeId=null, one with subRecipeId=C
        var ingredients = List.of(
                Ingredient.reconstitute(
                        IngredientId.generate(), 1, "article-1",
                        Quantity.of(new BigDecimal("1"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                        null, false),
                Ingredient.reconstitute(
                        IngredientId.generate(), 2, "article-2",
                        Quantity.of(new BigDecimal("2"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                        "C", false)
        );
        var recipeB = Recipe.reconstitute(
                RecipeId.of("B"), new RecipeName("Recipe-B"), 1, RecipeType.FINISHED_PRODUCT,
                null, new YieldPercentage(100), 14,
                Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
                "article-123", RecipeStatus.DRAFT, ingredients, List.of(),
                OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC));

        when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.of(recipeB)));
        when(recipeRepository.findById(RecipeId.of("C"))).thenReturn(Result.success(Optional.of(recipeWithoutSubRecipes("C"))));

        var result = cycleChecker.check("A", "B");

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

    @Test
    @DisplayName("should_DetectCycle_When_OnlyOneBranchIsCyclic")
    void should_DetectCycle_When_OnlyOneBranchIsCyclic() {
        // A -> B, B has sub-recipes C and D, C is a leaf, D -> A (cycle)
        when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.of(recipeWithSubRecipes("B", "C", "D"))));
        lenient().when(recipeRepository.findById(RecipeId.of("C"))).thenReturn(Result.success(Optional.of(recipeWithoutSubRecipes("C"))));
        when(recipeRepository.findById(RecipeId.of("D"))).thenReturn(Result.success(Optional.of(recipeWithSubRecipes("D", "A"))));

        var result = cycleChecker.check("A", "B");

        assertThat(result.isFailure()).isTrue();
        var error = (RecipeError.CyclicDependencyDetected) result.unsafeGetError();
        assertThat(error.cyclePath()).endsWith("A");
        assertThat(error.cyclePath().getFirst()).isEqualTo("A");
    }

    @Test
    @DisplayName("should_FailWithRepositoryFailure_When_RepositoryError")
    void should_FailWithRepositoryFailure_When_RepositoryError() {
        when(recipeRepository.findById(RecipeId.of("B")))
                .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));

        var result = cycleChecker.check("A", "B");

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