# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

from __future__ import annotations

import pyarrow as pa
import pytest
from datafusion import SessionContext, column, lit, udwf
from datafusion import functions as f
from datafusion.expr import WindowFrame
from datafusion.user_defined import WindowEvaluator


class ExponentialSmoothDefault(WindowEvaluator):
    def __init__(self, alpha: float = 0.9) -> None:
        self.alpha = alpha

    def evaluate_all(self, values: list[pa.Array], num_rows: int) -> pa.Array:
        results = []
        curr_value = 0.0
        values = values[0]
        for idx in range(num_rows):
            if idx == 0:
                curr_value = values[idx].as_py()
            else:
                curr_value = values[idx].as_py() * self.alpha + curr_value * (
                    1.0 - self.alpha
                )
            results.append(curr_value)

        return pa.array(results)


class ExponentialSmoothBounded(WindowEvaluator):
    def __init__(self, alpha: float = 0.9) -> None:
        self.alpha = alpha

    def supports_bounded_execution(self) -> bool:
        return True

    def get_range(self, idx: int, num_rows: int) -> tuple[int, int]:
        # Override the default range of current row since uses_window_frame is False
        # So for the purpose of this test we just smooth from the previous row to
        # current.
        if idx == 0:
            return (0, 0)
        return (idx - 1, idx)

    def evaluate(
        self, values: list[pa.Array], eval_range: tuple[int, int]
    ) -> pa.Scalar:
        (start, stop) = eval_range
        curr_value = 0.0
        values = values[0]
        for idx in range(start, stop + 1):
            if idx == start:
                curr_value = values[idx].as_py()
            else:
                curr_value = values[idx].as_py() * self.alpha + curr_value * (
                    1.0 - self.alpha
                )
        return pa.scalar(curr_value).cast(pa.float64())


class ExponentialSmoothRank(WindowEvaluator):
    def __init__(self, alpha: float = 0.9) -> None:
        self.alpha = alpha

    def include_rank(self) -> bool:
        return True

    def evaluate_all_with_rank(
        self, num_rows: int, ranks_in_partition: list[tuple[int, int]]
    ) -> pa.Array:
        results = []
        for idx in range(num_rows):
            if idx == 0:
                prior_value = 1.0
            matching_row = [
                i
                for i in range(len(ranks_in_partition))
                if ranks_in_partition[i][0] <= idx and ranks_in_partition[i][1] > idx
            ][0] + 1
            curr_value = matching_row * self.alpha + prior_value * (1.0 - self.alpha)
            results.append(curr_value)
            prior_value = matching_row

        return pa.array(results)


class ExponentialSmoothFrame(WindowEvaluator):
    def __init__(self, alpha: float = 0.9) -> None:
        self.alpha = alpha

    def uses_window_frame(self) -> bool:
        return True

    def evaluate(
        self, values: list[pa.Array], eval_range: tuple[int, int]
    ) -> pa.Scalar:
        (start, stop) = eval_range
        curr_value = 0.0
        if len(values) > 1:
            order_by = values[1]  # noqa: F841
            values = values[0]
        else:
            values = values[0]
        for idx in range(start, stop):
            if idx == start:
                curr_value = values[idx].as_py()
            else:
                curr_value = values[idx].as_py() * self.alpha + curr_value * (
                    1.0 - self.alpha
                )
        return pa.scalar(curr_value).cast(pa.float64())


class SmoothTwoColumn(WindowEvaluator):
    """This class demonstrates using two columns.

    If the second column is above a threshold, then smooth over the first column from
    the previous and next rows.
    """

    def __init__(self, alpha: float = 0.9) -> None:
        self.alpha = alpha

    def evaluate_all(self, values: list[pa.Array], num_rows: int) -> pa.Array:
        results = []
        values_a = values[0]
        values_b = values[1]
        for idx in range(num_rows):
            if values_b[idx].as_py() > 7:
                if idx == 0:
                    results.append(values_a[1].cast(pa.float64()))
                elif idx == num_rows - 1:
                    results.append(values_a[num_rows - 2].cast(pa.float64()))
                else:
                    results.append(
                        pa.scalar(
                            values_a[idx - 1].as_py() * self.alpha
                            + values_a[idx + 1].as_py() * (1.0 - self.alpha)
                        )
                    )
            else:
                results.append(values_a[idx].cast(pa.float64()))

        return pa.array(results)


class SimpleWindowCount(WindowEvaluator):
    """A simple window evaluator that counts rows."""

    def __init__(self, base: int = 0) -> None:
        self.base = base

    def evaluate_all(self, values: list[pa.Array], num_rows: int) -> pa.Array:
        return pa.array([self.base + i for i in range(num_rows)])


class NotSubclassOfWindowEvaluator:
    pass


@pytest.fixture
def ctx():
    return SessionContext()


@pytest.fixture
def complex_window_df(ctx):
    # create a RecordBatch and a new DataFrame from it
    batch = pa.RecordBatch.from_arrays(
        [
            pa.array([0, 1, 2, 3, 4, 5, 6]),
            pa.array([7, 4, 3, 8, 9, 1, 6]),
            pa.array(["A", "A", "A", "A", "B", "B", "B"]),
        ],
        names=["a", "b", "c"],
    )
    return ctx.create_dataframe([[batch]])


@pytest.fixture
def count_window_df(ctx):
    # create a RecordBatch and a new DataFrame from it
    batch = pa.RecordBatch.from_arrays(
        [pa.array([1, 2, 3]), pa.array([4, 4, 6])],
        names=["a", "b"],
    )
    return ctx.create_dataframe([[batch]], name="test_table")


def test_udwf_errors(complex_window_df):
    with pytest.raises(TypeError):
        udwf(
            NotSubclassOfWindowEvaluator,
            pa.float64(),
            pa.float64(),
            volatility="immutable",
        )


def test_udwf_errors_with_message():
    """Test error cases for UDWF creation."""
    with pytest.raises(
        TypeError, match="`func` must implement the abstract base class WindowEvaluator"
    ):
        udwf(
            NotSubclassOfWindowEvaluator, pa.int64(), pa.int64(), volatility="immutable"
        )


def test_udwf_basic_usage(count_window_df):
    """Test basic UDWF usage with a simple counting window function."""
    simple_count = udwf(
        SimpleWindowCount, pa.int64(), pa.int64(), volatility="immutable"
    )

    df = count_window_df.select(
        simple_count(column("a"))
        .window_frame(WindowFrame("rows", None, None))
        .build()
        .alias("count")
    )
    result = df.collect()[0]
    assert result.column(0) == pa.array([0, 1, 2])


def test_udwf_with_args(count_window_df):
    """Test UDWF with constructor arguments."""
    count_base10 = udwf(
        lambda: SimpleWindowCount(10), pa.int64(), pa.int64(), volatility="immutable"
    )

    df = count_window_df.select(
        count_base10(column("a"))
        .window_frame(WindowFrame("rows", None, None))
        .build()
        .alias("count")
    )
    result = df.collect()[0]
    assert result.column(0) == pa.array([10, 11, 12])


def test_udwf_decorator_basic(count_window_df):
    """Test UDWF used as a decorator."""

    @udwf([pa.int64()], pa.int64(), "immutable")
    def window_count() -> WindowEvaluator:
        return SimpleWindowCount()

    df = count_window_df.select(
        window_count(column("a"))
        .window_frame(WindowFrame("rows", None, None))
        .build()
        .alias("count")
    )
    result = df.collect()[0]
    assert result.column(0) == pa.array([0, 1, 2])


def test_udwf_decorator_with_args(count_window_df):
    """Test UDWF decorator with constructor arguments."""

    @udwf([pa.int64()], pa.int64(), "immutable")
    def window_count_base10() -> WindowEvaluator:
        return SimpleWindowCount(10)

    df = count_window_df.select(
        window_count_base10(column("a"))
        .window_frame(WindowFrame("rows", None, None))
        .build()
        .alias("count")
    )
    result = df.collect()[0]
    assert result.column(0) == pa.array([10, 11, 12])


def test_register_udwf(ctx, count_window_df):
    """Test registering and using UDWF in SQL context."""
    window_count = udwf(
        SimpleWindowCount,
        [pa.int64()],
        pa.int64(),
        volatility="immutable",
        name="window_count",
    )

    ctx.register_udwf(window_count)
    result = ctx.sql(
        """
        SELECT window_count(a)
        OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED
        FOLLOWING) FROM test_table
        """
    ).collect()[0]
    assert result.column(0) == pa.array([0, 1, 2])


smooth_default = udwf(
    ExponentialSmoothDefault,
    pa.float64(),
    pa.float64(),
    volatility="immutable",
)

smooth_w_arguments = udwf(
    lambda: ExponentialSmoothDefault(0.8),
    pa.float64(),
    pa.float64(),
    volatility="immutable",
)

smooth_bounded = udwf(
    ExponentialSmoothBounded,
    pa.float64(),
    pa.float64(),
    volatility="immutable",
)

smooth_rank = udwf(
    ExponentialSmoothRank,
    pa.utf8(),
    pa.float64(),
    volatility="immutable",
)

smooth_frame = udwf(
    ExponentialSmoothFrame,
    pa.float64(),
    pa.float64(),
    volatility="immutable",
)

smooth_two_col = udwf(
    SmoothTwoColumn,
    [pa.int64(), pa.int64()],
    pa.float64(),
    volatility="immutable",
)

data_test_udwf_functions = [
    (
        "default_udwf_no_arguments",
        smooth_default(column("a")),
        [0, 0.9, 1.89, 2.889, 3.889, 4.889, 5.889],
    ),
    (
        "default_udwf_w_arguments",
        smooth_w_arguments(column("a")),
        [0, 0.8, 1.76, 2.752, 3.75, 4.75, 5.75],
    ),
    (
        "default_udwf_partitioned",
        smooth_default(column("a")).partition_by(column("c")).build(),
        [0, 0.9, 1.89, 2.889, 4.0, 4.9, 5.89],
    ),
    (
        "default_udwf_ordered",
        smooth_default(column("a")).order_by(column("b")).build(),
        [0.551, 1.13, 2.3, 2.755, 3.876, 5.0, 5.513],
    ),
    (
        "bounded_udwf",
        smooth_bounded(column("a")),
        [0, 0.9, 1.9, 2.9, 3.9, 4.9, 5.9],
    ),
    (
        "bounded_udwf_ignores_frame",
        smooth_bounded(column("a"))
        .window_frame(WindowFrame("rows", None, None))
        .build(),
        [0, 0.9, 1.9, 2.9, 3.9, 4.9, 5.9],
    ),
    (
        "rank_udwf",
        smooth_rank(column("c")).order_by(column("c")).build(),
        [1, 1, 1, 1, 1.9, 2, 2],
    ),
    (
        "frame_unbounded_udwf",
        smooth_frame(column("a")).window_frame(WindowFrame("rows", None, None)).build(),
        [5.889, 5.889, 5.889, 5.889, 5.889, 5.889, 5.889],
    ),
    (
        "frame_bounded_udwf",
        smooth_frame(column("a")).window_frame(WindowFrame("rows", None, 0)).build(),
        [0.0, 0.9, 1.89, 2.889, 3.889, 4.889, 5.889],
    ),
    (
        "frame_bounded_udwf",
        smooth_frame(column("a"))
        .window_frame(WindowFrame("rows", None, 0))
        .order_by(column("b"))
        .build(),
        [0.551, 1.13, 2.3, 2.755, 3.876, 5.0, 5.513],
    ),
    (
        "two_column_udwf",
        smooth_two_col(column("a"), column("b")),
        [0.0, 1.0, 2.0, 2.2, 3.2, 5.0, 6.0],
    ),
]


@pytest.mark.parametrize(("name", "expr", "expected"), data_test_udwf_functions)
def test_udwf_functions(complex_window_df, name, expr, expected):
    df = complex_window_df.select("a", "b", f.round(expr, lit(3)).alias(name))

    # execute and collect the first (and only) batch
    result = df.sort(column("a")).select(column(name)).collect()[0]

    assert result.column(0) == pa.array(expected)


@pytest.mark.parametrize(
    "udwf_func",
    [
        udwf(SimpleWindowCount, pa.int64(), pa.int64(), "immutable"),
        udwf(SimpleWindowCount, [pa.int64()], pa.int64(), "immutable"),
        udwf([pa.int64()], pa.int64(), "immutable")(SimpleWindowCount),
        udwf(pa.int64(), pa.int64(), "immutable")(SimpleWindowCount),
    ],
)
def test_udwf_overloads(udwf_func, count_window_df):
    df = count_window_df.select(
        udwf_func(column("a"))
        .window_frame(WindowFrame("rows", None, None))
        .build()
        .alias("count")
    )
    result = df.collect()[0]
    assert result.column(0) == pa.array([0, 1, 2])


def test_udwf_named_function(ctx, count_window_df):
    """Test UDWF with explicit name parameter."""
    window_count = udwf(
        SimpleWindowCount,
        pa.int64(),
        pa.int64(),
        volatility="immutable",
        name="my_custom_counter",
    )

    ctx.register_udwf(window_count)
    result = ctx.sql(
        """
        SELECT my_custom_counter(a)
        OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED
        FOLLOWING) FROM test_table"""
    ).collect()[0]
    assert result.column(0) == pa.array([0, 1, 2])
