package de.renew.unify;

import java.util.Iterator;
import java.util.stream.Stream;

import org.assertj.core.util.Arrays;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

/**
 * Test class for the unification algorithm with {@link Unify} at its core.
 * <p>
 * Contains unit tests to check different unification scenarios
 * involving {@code Unknown}, {@code Tuple}, {@code List}, and {@code Variable}.
 * Also verifies listener behavior and failure cases.
 */
public class UnificationTest {

    private static final String STRING_1 = "abc";
    private static final String STRING_2 = "def";
    private static final String STRING_OBJECT = "string";
    private static final int ARITY = 2;

    /**
     * Tests unification of two {@code Unknown} objects and checks
     * if backlink is updated correctly.
     */
    @Test
    public void testUnifyUnknown() throws Impossible {
        //given
        IStateRecorder recorder = new StateRecorder();
        Unknown left = new Unknown();
        Unknown right = new Unknown();
        new Variable(right, recorder);
        new Variable(right, recorder);

        //when
        Variable referer = new Variable(left, recorder);

        //then
        assertThat(referer.getValue()).isSameAs(left);

        //when
        Unify.unify(left, right, recorder);

        //then
        assertThat(referer.getValue()).isSameAs(right);
    }

    /**
     * Tests that unification moves the backlinks of the object with fewer backlinks.
     */
    @Test
    public void testUnifyUnknownMoveFewerBacklinks() throws Impossible {
        //given
        IStateRecorder recorder = new StateRecorder();
        Unknown left = new Unknown();
        Unknown right = new Unknown();
        new Variable(right, recorder);
        new Variable(right, recorder);

        //when
        Variable referer = new Variable(left, recorder);

        //then
        assertThat(referer.getValue()).isSameAs(left);

        //when
        Unify.unify(right, left, recorder);

        //then
        assertThat(referer.getValue()).isSameAs(right);
    }

    /**
     * Tests unification of two {@code Tuple} objects and checks
     * if backlink is updated correctly.
     */
    @Test
    public void testUnifyTuple() throws Impossible {
        //given
        IStateRecorder recorder = new StateRecorder();
        Tuple left = new Tuple(Arrays.array(STRING_1, STRING_2), recorder);
        Tuple right = new Tuple(ARITY);

        //when
        Variable referer = new Variable(right, recorder);

        //then
        assertThat(referer.getValue()).isSameAs(right);

        //when
        Unify.unify(left, right, recorder);

        //then
        Iterator<Object> it1 = ((Tuple) referer.getValue()).iterator();
        Iterator<Object> it2 = left.iterator();
        while (it1.hasNext() && it2.hasNext()) {
            assertThat(it1.next()).isSameAs(it2.next());
        }
    }

    /**
     * Tests unification of two {@code List} objects with same length and checks
     * if backlink is updated correctly.
     */
    @Test
    public void testUnifyList() throws Impossible {
        //given
        IStateRecorder recorder = new StateRecorder();
        List left = new List(STRING_1, STRING_2, recorder);
        List right = new List(ARITY);

        //when
        Variable referer = new Variable(right, recorder);

        //then
        assertThat(referer.getValue()).isSameAs(right);

        //when
        Unify.unify(left, right, recorder);

        //then
        Iterator<Object> it1 = ((List) referer.getValue()).iterator();
        Iterator<Object> it2 = left.iterator();
        while (it1.hasNext() && it2.hasNext()) {
            assertThat(it1.next()).isSameAs(it2.next());
        }
    }

    /**
     * Tests unification of two {@code Variable} objects with the same value and checks
     * if backlink is updated correctly.
     */
    @Test
    public void testUnifyVariable() throws Impossible {
        //given
        IStateRecorder recorder = new StateRecorder();
        Variable left = new Variable(STRING_1, recorder);
        Variable right = new Variable(STRING_1, recorder);

        //when
        Variable referer = new Variable(left, recorder);

        //then
        assertThat(referer.getValue()).isSameAs(left.getValue());

        //when
        Unify.unify(left, right, recorder);

        //then
        assertThat(referer.getValue()).isSameAs(right.getValue());
    }

    /**
     * Tests unification with multiple steps. First unify a {@code Variable} and a {@code Unknown} object.
     * Then unify the {@code Unknown} with another {@code Variable}, the initial {@code Variable} should then
     * refer to that new value.
     */
    @Test
    public void testUnifyMultipleTimes() throws Impossible {
        //given
        IStateRecorder recorder = new StateRecorder();
        Variable left = new Variable();
        Unknown right = new Unknown();
        Variable valueVariable = new Variable(STRING_1, recorder);

        // add backlink for right
        // necessary, otherwise left would not be updated because right has fewer backlinks
        new Variable(right, recorder);

        //when
        Unify.unify(left, right, recorder);

        //then
        assertThat(left.getValue()).isSameAs(right);

        //when
        Unify.unify(right, valueVariable, recorder);

        //then
        assertThat(left.getValue()).isSameAs(valueVariable.getValue());
    }

    /**
     * Tests if listeners are notified correctly after unification.
     */
    @Test
    public void testUnifyListeners() throws Impossible {
        //given
        IStateRecorder recorder = new StateRecorder();
        Variable left = new Variable(STRING_1, recorder);
        Variable right = new Variable(STRING_1, recorder);
        Variable referer = new Variable(left, recorder);

        Notifiable listener = Mockito.spy(Notifiable.class);
        Notifiable listener2 = Mockito.spy(Notifiable.class);
        referer.addListener(listener, recorder);
        left.addListener(listener2, recorder);
        right.addListener(listener2, recorder);

        //when
        Unify.unify(left, right, recorder);

        //then
        verify(listener2, times(2)).boundNotify(recorder);
        verify(listener).boundNotify(recorder);
    }

    /**
     * Tests various unification cases that should throw {@code Impossible} exceptions.
     */
    @ParameterizedTest
    @MethodSource("impossibleCases")
    public void testUnifyImpossibleCases(Object left, Object right) {
        //given
        IStateRecorder recorder = new StateRecorder();

        //when/then
        assertThatThrownBy(() -> Unify.unify(left, right, recorder)).isInstanceOf(Impossible.class)
            .hasMessage(null);
    }

    /**
     * Provides test data for {@link #testUnifyImpossibleCases(Object, Object)}.
     *
     * @return stream of argument pairs that are not unifiable
     */
    private static Stream<Arguments> impossibleCases() {
        IStateRecorder recorder = new StateRecorder();

        return Stream.of(
            // Tuple with non-Tuple
            Arguments.of(new Tuple(Arrays.array(STRING_1, STRING_2), recorder), STRING_OBJECT),

            // List with non-List
            Arguments.of(new List(STRING_1, STRING_2, recorder), STRING_OBJECT),

            // Null cases (when not identical)
            Arguments.of(null, STRING_OBJECT), Arguments.of(STRING_OBJECT, null),

            // Calculator cases
            Arguments.of(new Calculator(String.class, STRING_1, recorder), STRING_OBJECT),
            Arguments.of(STRING_OBJECT, new Calculator(String.class, STRING_1, recorder)),

            // Non-equal objects
            Arguments.of(STRING_1, STRING_2), Arguments.of(STRING_OBJECT, 42),
            Arguments.of(true, false), Arguments.of(STRING_OBJECT, true));
    }

}