# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
from dataclasses import dataclass
from typing import Any, Callable, Generic, List, NewType, TypeVar

import numpy as np
import numpy.typing as npt
import pytest

import sciline as sl


def int_to_float(x: int) -> float:
    return 0.5 * x


def make_int() -> int:
    return 3


def int_float_to_str(x: int, y: float) -> str:
    return f"{x};{y}"


def test_pipeline_with_callables_can_compute_single_results() -> None:
    pipeline = sl.Pipeline([int_to_float, make_int])
    assert pipeline.compute(float) == 1.5
    assert pipeline.compute(int) == 3


def test_pipeline_does_not_autobind_types_that_can_be_default_constructed() -> None:
    # `int` can be constructed without arguments (and returns 0). Make sure that
    # the pipeline does not automatically bind `int` to `0`.
    pipeline = sl.Pipeline([int_to_float])
    with pytest.raises(sl.UnsatisfiedRequirement):
        pipeline.compute(float)


def test_intermediate_used_multiple_times_is_computed_only_once() -> None:
    ncall = 0

    def provide_int() -> int:
        nonlocal ncall
        ncall += 1
        return 3

    pipeline = sl.Pipeline([int_to_float, provide_int, int_float_to_str])
    assert pipeline.compute(str) == "3;1.5"
    assert ncall == 1


def test_multiple_keys_can_be_computed_without_repeated_calls() -> None:
    ncall = 0

    def provide_int() -> int:
        nonlocal ncall
        ncall += 1
        return 3

    pipeline = sl.Pipeline([int_to_float, provide_int, int_float_to_str])
    assert pipeline.compute((float, str)) == {float: 1.5, str: "3;1.5"}
    assert ncall == 1


def test_multiple_keys_not_in_same_path_use_same_intermediate() -> None:
    ncall = 0

    def provide_int() -> int:
        nonlocal ncall
        ncall += 1
        return 3

    def func1(x: int) -> float:
        return 0.5 * x

    def func2(x: int) -> str:
        return f"{x}"

    pipeline = sl.Pipeline([provide_int, func1, func2])
    assert pipeline.compute((float, str)) == {float: 1.5, str: "3"}
    assert ncall == 1


def test_Scope_subclass_can_be_set_as_param() -> None:
    Param = TypeVar('Param')

    class Str(sl.Scope[Param, str], str):
        ...

    pipeline = sl.Pipeline(params={Str[int]: Str[int]('1')})
    pipeline[Str[float]] = Str[float]('2.0')
    assert pipeline.compute(Str[int]) == Str[int]('1')
    assert pipeline.compute(Str[float]) == Str[float]('2.0')


def test_Scope_subclass_can_be_set_as_param_with_unbound_typevar() -> None:
    Param = TypeVar('Param')

    class Str(sl.Scope[Param, str], str):
        ...

    pipeline = sl.Pipeline()
    pipeline[Str[Param]] = Str[Param]('1')  # type: ignore[valid-type]
    assert pipeline.compute(Str[int]) == Str[int]('1')
    assert pipeline.compute(Str[float]) == Str[float]('1')


def test_ScopeTwoParam_subclass_can_be_set_as_param() -> None:
    Param1 = TypeVar('Param1')
    Param2 = TypeVar('Param2')

    class Str(sl.ScopeTwoParams[Param1, Param2, str], str):
        ...

    pipeline = sl.Pipeline(params={Str[int, float]: Str[int, float]('1')})
    pipeline[Str[float, int]] = Str[float, int]('2.0')
    assert pipeline.compute(Str[int, float]) == Str[int, float]('1')
    assert pipeline.compute(Str[float, int]) == Str[float, int]('2.0')


def test_ScopeTwoParam_subclass_can_be_set_as_param_with_unbound_typevar() -> None:
    Param1 = TypeVar('Param1')
    Param2 = TypeVar('Param2')

    class Str(sl.ScopeTwoParams[Param1, Param2, str], str):
        ...

    pipeline = sl.Pipeline()
    pipeline[Str[Param1, Param2]] = Str[Param1, Param2]('1')  # type: ignore[valid-type]
    assert pipeline.compute(Str[int, float]) == Str[int, float]('1')
    assert pipeline.compute(Str[float, int]) == Str[float, int]('1')


def test_generic_providers_produce_use_dependencies_based_on_bound_typevar() -> None:
    Param = TypeVar('Param')

    class Str(sl.Scope[Param, str], str):
        ...

    def parametrized(x: Param) -> Str[Param]:
        return Str(f'{x}')

    def make_float() -> float:
        return 1.5

    def combine(x: Str[int], y: Str[float]) -> str:
        return f"{x};{y}"

    pipeline = sl.Pipeline([make_int, make_float, combine, parametrized])
    assert pipeline.compute(Str[int]) == Str[int]('3')
    assert pipeline.compute(Str[float]) == Str[float]('1.5')
    assert pipeline.compute(str) == '3;1.5'


def test_can_compute_result_depending_on_two_instances_of_generic_provider() -> None:
    ncall = 0

    def provide_int() -> int:
        nonlocal ncall
        ncall += 1
        return 3

    Param = TypeVar('Param')

    class Float(sl.Scope[Param, float], float):
        ...

    class Str(sl.Scope[Param, str], str):
        ...

    def int_float_to_str(x: int, y: Float[Param]) -> Str[Param]:
        return Str(f"{x};{y}")

    Run1 = NewType('Run1', int)
    Run2 = NewType('Run2', int)
    Result = NewType('Result', str)

    def float1() -> Float[Run1]:
        return Float[Run1](1.5)

    def float2() -> Float[Run2]:
        return Float[Run2](2.5)

    def use_strings(s1: Str[Run1], s2: Str[Run2]) -> Result:
        return Result(f"{s1};{s2}")

    pipeline = sl.Pipeline(
        [provide_int, float1, float2, use_strings, int_float_to_str],
    )
    assert pipeline.compute(Result) == "3;1.5;3;2.5"
    assert ncall == 1


def test_subclasses_of_generic_provider_defined_with_Scope_work() -> None:
    Param = TypeVar('Param')

    class StrT(sl.Scope[Param, str], str):
        ...

    class Str1(StrT[Param]):
        ...

    class Str2(StrT[Param]):
        ...

    class Str3(StrT[Param]):
        ...

    class Str4(Str3[Param]):
        ...

    def make_str1() -> Str1[Param]:
        return Str1('1')

    def make_str2() -> Str2[Param]:
        return Str2('2')

    # Note that mypy cannot detect if when setting params, the type of the
    # parameter does not match the key. Same problem as with NewType.
    pipeline = sl.Pipeline(
        [make_str1, make_str2],
        params={
            Str3[int]: Str3[int]('int3'),
            Str3[float]: Str3[float]('float3'),
            Str4[int]: Str2[int]('int4'),
        },
    )
    assert pipeline.compute(Str1[float]) == Str1[float]('1')
    assert pipeline.compute(Str2[float]) == Str2[float]('2')
    assert pipeline.compute(Str3[int]) == Str3[int]('int3')
    assert pipeline.compute(Str3[float]) == Str3[float]('float3')
    assert pipeline.compute(Str4[int]) == Str4[int]('int4')


def test_subclasses_of_generic_array_provider_defined_with_Scope_work() -> None:
    Param = TypeVar('Param')

    class ArrayT(sl.Scope[Param, npt.NDArray[np.int64]], npt.NDArray[np.int64]):
        ...

    class Array1(ArrayT[Param]):
        ...

    class Array2(ArrayT[Param]):
        ...

    def make_array1() -> Array1[Param]:
        return Array1(np.array([1, 2, 3]))

    def make_array2() -> Array2[Param]:
        return Array2(np.array([4, 5, 6]))

    pipeline = sl.Pipeline([make_array1, make_array2])
    # Note that the param is not the dtype
    assert np.all(pipeline.compute(Array1[str]) == np.array([1, 2, 3]))
    assert np.all(pipeline.compute(Array2[str]) == np.array([4, 5, 6]))


def test_inserting_provider_returning_None_raises() -> None:
    def provide_none() -> None:
        return None

    with pytest.raises(ValueError):
        sl.Pipeline([provide_none])
    pipeline = sl.Pipeline([])
    with pytest.raises(ValueError):
        pipeline.insert(provide_none)


def test_inserting_provider_with_no_return_type_raises() -> None:
    def provide_none():  # type: ignore[no-untyped-def]
        return None

    with pytest.raises(ValueError):
        sl.Pipeline([provide_none])
    pipeline = sl.Pipeline([])
    with pytest.raises(ValueError):
        pipeline.insert(provide_none)


def test_TypeVar_requirement_of_provider_can_be_bound() -> None:
    T = TypeVar('T')

    def provider_int() -> int:
        return 3

    def provider(x: T) -> List[T]:
        return [x, x]

    pipeline = sl.Pipeline([provider_int, provider])
    assert pipeline.compute(List[int]) == [3, 3]


def test_TypeVar_that_cannot_be_bound_raises_UnboundTypeVar() -> None:
    T = TypeVar('T')

    def provider(_: T) -> int:
        return 1

    pipeline = sl.Pipeline([provider])
    with pytest.raises(sl.UnboundTypeVar):
        pipeline.compute(int)


def test_unsatisfiable_TypeVar_requirement_of_provider_raises() -> None:
    T = TypeVar('T')

    def provider_int() -> int:
        return 3

    def provider(x: T) -> List[T]:
        return [x, x]

    pipeline = sl.Pipeline([provider_int, provider])
    with pytest.raises(sl.UnsatisfiedRequirement):
        pipeline.compute(List[float])


def test_TypeVar_params_are_not_associated_unless_they_match() -> None:
    T1 = TypeVar('T1')
    T2 = TypeVar('T2')

    class A(Generic[T1]):
        ...

    class B(Generic[T2]):
        ...

    def source() -> A[int]:
        return A[int]()

    def not_matching(x: A[T1]) -> B[T2]:
        return B[T2]()

    def matching(x: A[T1]) -> B[T1]:
        return B[T1]()

    pipeline = sl.Pipeline([source, not_matching])
    with pytest.raises(sl.UnboundTypeVar):
        pipeline.compute(B[int])

    pipeline = sl.Pipeline([source, matching])
    pipeline.compute(B[int])


def test_multi_Generic_with_fully_bound_arguments() -> None:
    T1 = TypeVar('T1')
    T2 = TypeVar('T2')

    @dataclass
    class A(Generic[T1, T2]):
        first: T1
        second: T2

    def source() -> A[int, float]:
        return A[int, float](1, 2.0)

    pipeline = sl.Pipeline([source])
    assert pipeline.compute(A[int, float]) == A[int, float](1, 2.0)


def test_multi_Generic_with_partially_bound_arguments() -> None:
    T1 = TypeVar('T1')
    T2 = TypeVar('T2')

    @dataclass
    class A(Generic[T1, T2]):
        first: T1
        second: T2

    def source() -> float:
        return 2.0

    def partially_bound(x: T1) -> A[int, T1]:
        return A[int, T1](1, x)

    pipeline = sl.Pipeline([source, partially_bound])
    assert pipeline.compute(A[int, float]) == A[int, float](1, 2.0)


def test_multi_Generic_with_multiple_unbound() -> None:
    T1 = TypeVar('T1')
    T2 = TypeVar('T2')

    @dataclass
    class A(Generic[T1, T2]):
        first: T1
        second: T2

    def int_source() -> int:
        return 1

    def float_source() -> float:
        return 2.0

    def unbound(x: T1, y: T2) -> A[T1, T2]:
        return A[T1, T2](x, y)

    pipeline = sl.Pipeline([int_source, float_source, unbound])
    assert pipeline.compute(A[int, float]) == A[int, float](1, 2.0)
    assert pipeline.compute(A[float, int]) == A[float, int](2.0, 1)


def test_distinct_fully_bound_instances_yield_distinct_results() -> None:
    T1 = TypeVar('T1')

    @dataclass
    class A(Generic[T1]):
        value: T1

    def int_source() -> A[int]:
        return A[int](1)

    def float_source() -> A[float]:
        return A[float](2.0)

    pipeline = sl.Pipeline([int_source, float_source])
    assert pipeline.compute(A[int]) == A[int](1)
    assert pipeline.compute(A[float]) == A[float](2.0)


def test_distinct_partially_bound_instances_yield_distinct_results() -> None:
    T1 = TypeVar('T1')
    T2 = TypeVar('T2')

    @dataclass
    class A(Generic[T1, T2]):
        first: T1
        second: T2

    def str_source() -> str:
        return 'a'

    def int_source(x: T1) -> A[int, T1]:
        return A[int, T1](1, x)

    def float_source(x: T1) -> A[float, T1]:
        return A[float, T1](2.0, x)

    pipeline = sl.Pipeline([str_source, int_source, float_source])
    assert pipeline.compute(A[int, str]) == A[int, str](1, 'a')
    assert pipeline.compute(A[float, str]) == A[float, str](2.0, 'a')


def test_multiple_matching_partial_providers_raises() -> None:
    T1 = TypeVar('T1')
    T2 = TypeVar('T2')

    @dataclass
    class A(Generic[T1, T2]):
        first: T1
        second: T2

    def int_source() -> int:
        return 1

    def float_source() -> float:
        return 2.0

    def provider1(x: T1) -> A[int, T1]:
        return A[int, T1](1, x)

    def provider2(x: T2) -> A[T2, float]:
        return A[T2, float](x, 2.0)

    pipeline = sl.Pipeline([int_source, float_source, provider1, provider2])
    assert pipeline.compute(A[int, int]) == A[int, int](1, 1)
    assert pipeline.compute(A[float, float]) == A[float, float](2.0, 2.0)
    with pytest.raises(sl.AmbiguousProvider):
        pipeline.compute(A[int, float])


def test_TypeVar_params_track_to_multiple_sources() -> None:
    T1 = TypeVar('T1')
    T2 = TypeVar('T2')

    @dataclass
    class A(Generic[T1]):
        value: T1

    @dataclass
    class B(Generic[T1]):
        value: T1

    @dataclass
    class C(Generic[T1, T2]):
        first: T1
        second: T2

    def provide_int() -> int:
        return 1

    def provide_float() -> float:
        return 2.0

    def provide_A(x: T1) -> A[T1]:
        return A[T1](x)

    # Note that it currently does not matter which TypeVar instance we use here:
    # Container tracks uses of TypeVar within a single provider, but does not carry
    # the information beyond the scope of a single call.
    def provide_B(x: T1) -> B[T1]:
        return B[T1](x)

    def provide_C(x: A[T1], y: B[T2]) -> C[T1, T2]:
        return C[T1, T2](x.value, y.value)

    pipeline = sl.Pipeline(
        [provide_int, provide_float, provide_A, provide_B, provide_C]
    )
    assert pipeline.compute(C[int, float]) == C[int, float](1, 2.0)


def test_instance_provider() -> None:
    Result = NewType('Result', float)

    def f(x: int, y: float) -> Result:
        return Result(x / y)

    pl = sl.Pipeline([f])
    pl[int] = 3
    pl[float] = 2.0
    assert pl.compute(int) == 3
    assert pl.compute(float) == 2.0
    assert pl.compute(Result) == 1.5


def test_provider_NewType_instance() -> None:
    A = NewType('A', int)
    pl = sl.Pipeline([])
    pl[A] = A(3)
    assert pl.compute(A) == 3


def test_setitem_generic_sets_up_working_subproviders() -> None:
    T = TypeVar('T')

    @dataclass
    class A(Generic[T]):
        value: T

    pl = sl.Pipeline()
    pl[A[int]] = A[int](3)
    pl[A[float]] = A[float](2.0)
    assert pl.compute(A[int]) == A[int](3)
    assert pl.compute(A[float]) == A[float](2.0)
    with pytest.raises(sl.UnsatisfiedRequirement):
        pl.compute(A[str])


def test_setitem_generic_works_without_params() -> None:
    T = TypeVar('T')

    @dataclass
    class A(Generic[T]):
        value: T

    pl = sl.Pipeline()
    pl[A] = A(3)
    assert pl.compute(A) == A(3)


def test_setitem_raises_TypeError_if_instance_does_not_match_key() -> None:
    A = NewType('A', int)
    T = TypeVar('T')

    @dataclass
    class B(Generic[T]):
        value: T

    pl = sl.Pipeline()
    with pytest.raises(TypeError):
        pl[int] = 1.0
    with pytest.raises(TypeError):
        pl[A] = 1.0
    with pytest.raises(TypeError):
        pl[B[int]] = 1.0


def test_setitem_can_replace_param_with_param() -> None:
    pl = sl.Pipeline()
    pl[int] = 1
    pl[int] = 2
    assert pl.compute(int) == 2


def test_insert_can_replace_param_with_provider() -> None:
    def func() -> int:
        return 2

    pl = sl.Pipeline()
    pl[int] = 1
    pl.insert(func)
    assert pl.compute(int) == 2


def test_setitem_can_replace_provider_with_param() -> None:
    def func() -> int:
        return 2

    pl = sl.Pipeline()
    pl.insert(func)
    pl[int] = 1
    assert pl.compute(int) == 1


def test_insert_can_replace_provider_with_provider() -> None:
    def func1() -> int:
        return 1

    def func2() -> int:
        return 2

    pl = sl.Pipeline()
    pl.insert(func1)
    pl.insert(func2)
    assert pl.compute(int) == 2


def test_insert_can_replace_generic_provider_with_generic_provider() -> None:
    T = TypeVar('T', int, float)

    @dataclass
    class A(Generic[T]):
        value: T

    def func1(x: T) -> A[T]:
        return A[T](x)

    def func2(x: T) -> A[T]:
        return A[T](x + x)

    pl = sl.Pipeline()
    pl[int] = 1
    pl.insert(func1)
    pl.insert(func2)
    assert pl.compute(A[int]) == A[int](2)


def test_insert_can_replace_generic_param_with_generic_provider() -> None:
    T = TypeVar('T', int, float)

    @dataclass
    class A(Generic[T]):
        value: T

    def func(x: T) -> A[T]:
        return A[T](x + x)

    pl = sl.Pipeline()
    pl[int] = 1
    pl[A[T]] = A[T](1)  # type: ignore[valid-type]
    assert pl.compute(A[int]) == A[int](1)
    pl.insert(func)
    assert pl.compute(A[int]) == A[int](2)


def test_setitem_can_replace_generic_provider_with_generic_param() -> None:
    T = TypeVar('T', int, float)

    @dataclass
    class A(Generic[T]):
        value: T

    def func(x: T) -> A[T]:
        return A[T](x + x)

    pl = sl.Pipeline()
    pl[int] = 1
    pl.insert(func)
    assert pl.compute(A[int]) == A[int](2)
    pl[A[T]] = A[T](1)  # type: ignore[valid-type]
    assert pl.compute(A[int]) == A[int](1)


def test_setitem_can_replace_generic_param_with_generic_param() -> None:
    T = TypeVar('T')

    @dataclass
    class A(Generic[T]):
        value: T

    pl = sl.Pipeline()
    pl[A[T]] = A[T](1)  # type: ignore[valid-type]
    assert pl.compute(A[int]) == A[int](1)
    pl[A[T]] = A[T](2)  # type: ignore[valid-type]
    assert pl.compute(A[int]) == A[int](2)


def test_init_with_params() -> None:
    pl = sl.Pipeline(params={int: 1, float: 2.0})
    assert pl.compute(int) == 1
    assert pl.compute(float) == 2.0


def test_init_with_providers_and_params() -> None:
    def func(x: int, y: float) -> str:
        return f'{x} {y}'

    pl = sl.Pipeline(providers=[func], params={int: 1, float: 2.0})
    assert pl.compute(str) == "1 2.0"


def test_init_with_sciline_Scope_subclass_param_works() -> None:
    T = TypeVar('T')

    class A(sl.Scope[T, int], int):
        ...

    pl = sl.Pipeline(params={A[float]: A(1), A[str]: A(2)})
    assert pl.compute(A[float]) == A(1)
    assert pl.compute(A[str]) == A(2)


def test_building_graph_with_cycle_succeeds() -> None:
    def f(x: int) -> float:
        return float(x)

    def g(x: float) -> int:
        return int(x)

    pipeline = sl.Pipeline([f, g])
    _ = pipeline.get(int)


def test_computing_graph_with_cycle_raises_CycleError() -> None:
    def f(x: int) -> float:
        return float(x)

    def g(x: float) -> int:
        return int(x)

    pipeline = sl.Pipeline([f, g])
    with pytest.raises(sl.scheduler.CycleError):
        pipeline.compute(int)


def test_get_with_single_key_return_task_graph_that_computes_value() -> None:
    pipeline = sl.Pipeline([int_to_float, make_int, int_float_to_str])
    task = pipeline.get(str)
    assert task.compute() == '3;1.5'


@pytest.mark.parametrize('key_type', [tuple, list, iter])
def test_get_with_key_iterable_return_task_graph_that_computes_dict_of_values(
    key_type: Callable[[Any], Any],
) -> None:
    pipeline = sl.Pipeline([int_to_float, make_int])
    task = pipeline.get(key_type((float, int)))
    assert task.compute() == {float: 1.5, int: 3}


def test_task_graph_compute_can_override_single_key() -> None:
    pipeline = sl.Pipeline([int_to_float, make_int])
    task = pipeline.get(float)
    assert task.compute(int) == 3


def test_task_graph_compute_can_override_key_tuple() -> None:
    pipeline = sl.Pipeline([int_to_float, make_int])
    task = pipeline.get(float)
    assert task.compute((int, float)) == {int: 3, float: 1.5}


def test_task_graph_compute_raises_if_override_keys_outside_graph() -> None:
    pipeline = sl.Pipeline([int_to_float, make_int])
    task = pipeline.get(int)
    # The pipeline knows how to compute int, but the task graph does not
    # as the task graph is fixed at this point.
    with pytest.raises(KeyError):
        task.compute(float)


def test_get_with_NaiveScheduler() -> None:
    pipeline = sl.Pipeline([int_to_float, make_int])
    task = pipeline.get(float, scheduler=sl.scheduler.NaiveScheduler())
    assert task.compute() == 1.5


def test_bind_and_call_no_function() -> None:
    pipeline = sl.Pipeline([make_int])
    assert pipeline.bind_and_call(()) == ()


def test_bind_and_call_function_without_args() -> None:
    def func() -> str:
        return "func"

    pipeline = sl.Pipeline([make_int])
    assert pipeline.bind_and_call(func) == "func"


def test_bind_and_call_function_with_1_arg() -> None:
    def func(i: int) -> int:
        return i * 2

    pipeline = sl.Pipeline([make_int])
    assert pipeline.bind_and_call(func) == 6


def test_bind_and_call_function_with_2_arg2() -> None:
    def func(i: int, f: float) -> float:
        return i + f

    pipeline = sl.Pipeline([make_int, int_to_float])
    assert pipeline.bind_and_call(func) == 4.5


def test_bind_and_call_overrides_default_args() -> None:
    def func(i: int, f: float = -0.5) -> float:
        return i + f

    pipeline = sl.Pipeline([make_int, int_to_float])
    assert pipeline.bind_and_call(func) == 4.5


def test_bind_and_call_function_in_iterator() -> None:
    def func(i: int) -> int:
        return i * 2

    pipeline = sl.Pipeline([make_int])
    assert pipeline.bind_and_call(iter((func,))) == (6,)


def test_bind_and_call_dataclass_without_args() -> None:
    @dataclass
    class C:
        ...

    pipeline = sl.Pipeline([make_int])
    assert pipeline.bind_and_call(C) == C()


def test_bind_and_call_dataclass_with_1_arg() -> None:
    @dataclass
    class C:
        i: int

    pipeline = sl.Pipeline([make_int])
    assert pipeline.bind_and_call(C) == C(i=3)


def test_bind_and_call_dataclass_with_2_arg2() -> None:
    @dataclass
    class C:
        i: int
        f: float

    pipeline = sl.Pipeline([make_int, int_to_float])
    assert pipeline.bind_and_call(C) == C(i=3, f=1.5)


def test_bind_and_call_two_functions() -> None:
    def func1(i: int) -> int:
        return 2 * i

    def func2(f: float) -> float:
        return f + 1

    pipeline = sl.Pipeline([make_int, int_to_float])
    assert pipeline.bind_and_call((func1, func2)) == (6, 2.5)


def test_bind_and_call_two_functions_in_iterator() -> None:
    def func1(i: int) -> int:
        return 2 * i

    def func2(f: float) -> float:
        return f + 1

    pipeline = sl.Pipeline([make_int, int_to_float])
    assert pipeline.bind_and_call(iter((func1, func2))) == (6, 2.5)


def test_bind_and_call_function_and_dataclass() -> None:
    def func(i: int) -> int:
        return 2 * i

    @dataclass
    class C:
        i: int
        f: float

    pipeline = sl.Pipeline([make_int, int_to_float])
    assert pipeline.bind_and_call((func, C)) == (6, C(i=3, f=1.5))


def test_bind_and_call_function_without_return_annotation() -> None:
    def func(i: int):  # type: ignore[no-untyped-def]
        return 2 * i

    pipeline = sl.Pipeline([make_int])
    assert pipeline.bind_and_call(func) == 6


def test_bind_and_call_generic_function() -> None:
    T = TypeVar('T')
    A = NewType('A', int)
    B = NewType('B', int)

    class G(sl.Scope[T, int], int):
        ...

    def func(a: G[A]) -> int:
        return -4 * a

    pipeline = sl.Pipeline([], params={G[A]: 3, G[B]: 4})
    assert pipeline.bind_and_call(func) == -12


def test_bind_and_call_function_runs_at_end() -> None:
    calls = []

    def a() -> int:
        calls.append('a')
        return 2

    def b() -> float:
        calls.append('b')
        return 3.1

    def c(_i: int) -> None:
        calls.append('c')

    def d(_f: float) -> None:
        calls.append('d')

    pipeline = sl.Pipeline([a, b])
    pipeline.bind_and_call([c, d])

    assert calls.index('a') in (0, 1)
    assert calls.index('b') in (0, 1)
    assert calls.index('c') in (2, 3)
    assert calls.index('d') in (2, 3)


def test_prioritizes_specialized_provider_over_generic() -> None:
    A = NewType('A', str)
    B = NewType('B', str)
    V = TypeVar('V', A, B)

    class H(sl.Scope[V, str], str):
        pass

    def p1(x: V) -> H[V]:
        return H[V]("Generic")

    def p2(x: B) -> H[B]:
        return H[B]("Special")

    pl = sl.Pipeline([p1, p2], params={A: 'A', B: 'B'})

    assert str(pl.compute(H[A])) == "Generic"
    assert str(pl.compute(H[B])) == "Special"


def test_prioritizes_specialized_provider_over_generic_several_typevars() -> None:
    A = NewType('A', str)
    B = NewType('B', str)
    T1 = TypeVar('T1')
    T2 = TypeVar('T2')

    @dataclass
    class C(Generic[T1, T2]):
        first: T1
        second: T2
        third: str

    def p1(x: T1, y: T2) -> C[T1, T2]:
        return C(x, y, 'generic')

    def p2(x: A, y: T2) -> C[A, T2]:
        return C(x, y, 'medium generic')

    def p3(x: T2, y: B) -> C[T2, B]:
        return C(x, y, 'generic medium')

    def p4(x: A, y: B) -> C[A, B]:
        return C(x, y, 'special')

    pl = sl.Pipeline([p1, p2, p3, p4], params={A: A('A'), B: B('B')})

    assert pl.compute(C[B, A]) == C('B', 'A', 'generic')
    assert pl.compute(C[A, A]) == C('A', 'A', 'medium generic')
    assert pl.compute(C[B, B]) == C('B', 'B', 'generic medium')
    assert pl.compute(C[A, B]) == C('A', 'B', 'special')


def test_prioritizes_specialized_provider_raises() -> None:
    A = NewType('A', str)
    B = NewType('B', str)
    T1 = TypeVar('T1')
    T2 = TypeVar('T2')

    @dataclass
    class C(Generic[T1, T2]):
        first: T1
        second: T2

    def p1(x: A, y: T1) -> C[A, T1]:
        return C(x, y)

    def p2(x: T1, y: B) -> C[T1, B]:
        return C(x, y)

    pl = sl.Pipeline([p1, p2], params={A: A('A'), B: B('B')})

    with pytest.raises(sl.AmbiguousProvider):
        pl.compute(C[A, B])

    with pytest.raises(sl.UnsatisfiedRequirement):
        pl.compute(C[B, A])


def test_compute_time_handler_allows_for_building_but_not_computing() -> None:
    def func(x: int) -> float:
        return 0.5 * x

    pipeline = sl.Pipeline([func])
    graph = pipeline.get(float, handler=sl.HandleAsComputeTimeException())
    with pytest.raises(sl.UnsatisfiedRequirement):
        graph.compute()


def test_pipeline_copy_simple() -> None:
    a = sl.Pipeline([int_to_float, make_int])
    b = a.copy()
    assert b.compute(int) == 3
    assert b.compute(float) == 1.5


def test_pipeline_copy_dunder() -> None:
    a = sl.Pipeline([int_to_float, make_int])
    from copy import copy

    b = copy(a)
    assert b.compute(int) == 3
    assert b.compute(float) == 1.5


def test_pipeline_copy_with_params() -> None:
    a = sl.Pipeline([int_to_float], params={int: 99})
    b = a.copy()
    assert b.compute(int) == 99
    assert b.compute(float) == 49.5


def test_pipeline_copy_after_insert() -> None:
    a = sl.Pipeline([int_to_float])
    a.insert(make_int)
    b = a.copy()
    assert b.compute(int) == 3
    assert b.compute(float) == 1.5


def test_pipeline_copy_after_setitem() -> None:
    a = sl.Pipeline([int_to_float])
    a[int] = 99
    b = a.copy()
    assert b.compute(int) == 99
    assert b.compute(float) == 49.5


def test_copy_with_generic_providers() -> None:
    Param = TypeVar('Param')

    class Str(sl.Scope[Param, str], str):
        ...

    def parametrized(x: Param) -> Str[Param]:
        return Str(f'{x}')

    def make_float() -> float:
        return 1.5

    def combine(x: Str[int], y: Str[float]) -> str:
        return f"{x};{y}"

    a = sl.Pipeline([make_int, make_float, combine, parametrized])
    b = a.copy()
    assert b.compute(Str[int]) == Str[int]('3')
    assert b.compute(Str[float]) == Str[float]('1.5')
    assert b.compute(str) == '3;1.5'


def test_pipeline_insert_on_copy_does_not_affect_original() -> None:
    a = sl.Pipeline([int_to_float])
    with pytest.raises(sl.UnsatisfiedRequirement):
        a.compute(int)
    b = a.copy()
    b.insert(make_int)
    assert b.compute(int) == 3
    assert b.compute(float) == 1.5
    with pytest.raises(sl.UnsatisfiedRequirement):
        a.compute(int)


def test_pipeline_insert_on_original_does_not_affect_copy() -> None:
    a = sl.Pipeline([int_to_float])
    b = a.copy()
    a.insert(make_int)
    assert a.compute(int) == 3
    assert a.compute(float) == 1.5
    with pytest.raises(sl.UnsatisfiedRequirement):
        b.compute(int)


def test_pipeline_setitem_on_copy_does_not_affect_original() -> None:
    a = sl.Pipeline([int_to_float])
    with pytest.raises(sl.UnsatisfiedRequirement):
        a.compute(int)
    b = a.copy()
    b[int] = 99
    assert b.compute(int) == 99
    assert b.compute(float) == 49.5
    with pytest.raises(sl.UnsatisfiedRequirement):
        a.compute(int)


def test_pipeline_setitem_on_original_does_not_affect_copy() -> None:
    a = sl.Pipeline([int_to_float])
    b = a.copy()
    a[int] = 99
    assert a.compute(int) == 99
    assert a.compute(float) == 49.5
    with pytest.raises(sl.UnsatisfiedRequirement):
        b.compute(int)


def test_pipeline_with_generics_setitem_on_original_does_not_affect_copy() -> None:
    RunType = TypeVar('RunType')

    class RawData(sl.Scope[RunType, int], int):
        ...

    class SquaredData(sl.Scope[RunType, int], int):
        ...

    Sample = NewType('Sample', int)
    Background = NewType('Background', int)
    Result = NewType('Result', int)

    def square(x: RawData[RunType]) -> SquaredData[RunType]:
        return SquaredData[RunType](x * x)

    def process(
        sample: SquaredData[Sample], background: SquaredData[Background]
    ) -> Result:
        return Result(sample + background)

    a = sl.Pipeline(
        [square, process],
        params={
            RawData[Sample]: 5,
            RawData[Background]: 2,
        },
    )
    assert a.compute(Result) == 29
    b = a.copy()
    assert b.compute(Result) == 29
    a[RawData[Sample]] = 7
    assert a.compute(Result) == 53
    assert b.compute(Result) == 29


def test_pipeline_with_generics_setitem_on_copy_does_not_affect_original() -> None:
    RunType = TypeVar('RunType')

    class RawData(sl.Scope[RunType, int], int):
        ...

    class SquaredData(sl.Scope[RunType, int], int):
        ...

    Sample = NewType('Sample', int)
    Background = NewType('Background', int)
    Result = NewType('Result', int)

    def square(x: RawData[RunType]) -> SquaredData[RunType]:
        return SquaredData[RunType](x * x)

    def process(
        sample: SquaredData[Sample], background: SquaredData[Background]
    ) -> Result:
        return Result(sample + background)

    a = sl.Pipeline(
        [square, process],
        params={
            RawData[Sample]: 5,
            RawData[Background]: 2,
        },
    )
    assert a.compute(Result) == 29
    b = a.copy()
    assert b.compute(Result) == 29
    b[RawData[Sample]] = 7
    assert a.compute(Result) == 29
    assert b.compute(Result) == 53


def test_html_repr() -> None:
    pipeline = sl.Pipeline([make_int], params={float: 5.0})
    assert isinstance(pipeline._repr_html_(), str)
