# 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.

"""Tests for the Spark-compatible function bindings."""

import pyarrow as pa
import pytest
from datafusion import SessionContext, col, lit
from datafusion import functions as f
from datafusion.functions import spark


@pytest.fixture
def df():
    ctx = SessionContext()
    batch = pa.RecordBatch.from_arrays(
        [
            pa.array(["hello", "WORLD", "abc"]),
            pa.array([1, 2, 3]),
            pa.array([-5, 0, 7]),
            pa.array([1.0, 2.5, 3.0]),
            pa.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]),
        ],
        names=["s", "i", "n", "f", "a"],
    )
    return ctx.create_dataframe([[batch]])


def _val(df, expr):
    return df.select(expr.alias("v")).collect_column("v")[0].as_py()


def _ts():
    import datetime as _d

    naive = _d.datetime(2020, 1, 15, 14, 30, 45)  # noqa: DTZ001
    return lit(pa.scalar(naive, type=pa.timestamp("us")))


def _dt(*args):
    import datetime as _d

    return _d.datetime(*args)  # noqa: DTZ001


# ---------------------------------------------------------------------------
# Math
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("expr_factory", "expected"),
    [
        (lambda: spark.abs(lit(-5)), 5),
        (lambda: spark.ceil(lit(1.2)), 2),
        (lambda: spark.floor(lit(1.8)), 1),
        (lambda: spark.bin(lit(7)), "111"),
        (lambda: spark.hex(lit(255)), "FF"),
        (lambda: spark.modulus(lit(10), lit(3)), 1),
        (lambda: spark.pmod(lit(-1), lit(3)), 2),
        (lambda: spark.rint(lit(2.5)), 2.0),
        (lambda: spark.round(lit(2.5), lit(0)), 3.0),
        (lambda: spark.negative(lit(3)), -3),
    ],
)
def test_math(df, expr_factory, expected):
    assert _val(df, expr_factory()) == expected


def test_factorial(df):
    # factorial wants Int32; lit(int) is Int64 by default.
    expr = spark.factorial(lit(pa.scalar(5, type=pa.int32())))
    assert _val(df, expr) == 120


# ---------------------------------------------------------------------------
# String
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("expr_factory", "expected"),
    [
        (lambda: spark.ascii(lit("A")), 65),
        (lambda: spark.char(lit(65)), "A"),
        (lambda: spark.length(lit("hello")), 5),
        (lambda: spark.like(lit("hello"), lit("h%")), True),
        (lambda: spark.ilike(lit("HELLO"), lit("h%")), True),
        (lambda: spark.substring(lit("hello"), lit(1), lit(3)), "hel"),
        (lambda: spark.soundex(lit("Robert")), "R163"),
        (lambda: spark.is_valid_utf8(lit("hi")), True),
        (lambda: spark.concat(lit("a"), lit("b")), "ab"),
        (lambda: spark.elt(lit(2), lit("a"), lit("b")), "b"),
    ],
)
def test_string(df, expr_factory, expected):
    assert _val(df, expr_factory()) == expected


def test_space(df):
    expr = spark.space(lit(pa.scalar(3, type=pa.int32())))
    assert _val(df, expr) == "   "


# ---------------------------------------------------------------------------
# Hash
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("expr_factory", "expected"),
    [
        (
            lambda: spark.sha1(lit("hello")),
            "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
        ),
        (
            lambda: spark.sha2(lit("hello"), lit(256)),
            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
        ),
        (lambda: spark.crc32(lit("ABC")), 2743272264),
    ],
)
def test_hash(df, expr_factory, expected):
    assert _val(df, expr_factory()) == expected


# ---------------------------------------------------------------------------
# Bitwise
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("expr_factory", "expected"),
    [
        (lambda: spark.bit_count(lit(7)), 3),
        (lambda: spark.bit_get(lit(5), lit(0)), 1),
        (lambda: spark.bitwise_not(lit(0)), -1),
        (lambda: spark.shiftleft(lit(1), lit(3)), 8),
        (lambda: spark.shiftright(lit(8), lit(2)), 2),
        (lambda: spark.shiftrightunsigned(lit(8), lit(2)), 2),
    ],
)
def test_bitwise(df, expr_factory, expected):
    assert _val(df, expr_factory()) == expected


# ---------------------------------------------------------------------------
# Array / collection / conditional
# ---------------------------------------------------------------------------


def test_array_and_size(df):
    arr = spark.array(lit(1), lit(2), lit(3))
    assert _val(df, arr) == [1, 2, 3]
    assert _val(df, spark.size(arr)) == 3
    assert _val(df, spark.array_contains(arr, lit(2))) is True


def test_slice(df):
    arr = spark.array(lit(1), lit(2), lit(3), lit(4))
    assert _val(df, spark.slice(arr, lit(2), lit(2))) == [2, 3]


def test_array_repeat(df):
    assert _val(df, spark.array_repeat(lit("a"), lit(3))) == ["a", "a", "a"]


def test_if(df):
    assert _val(df, spark.if_(lit(True), lit("yes"), lit("no"))) == "yes"
    assert _val(df, spark.if_(lit(False), lit("yes"), lit("no"))) == "no"


# ---------------------------------------------------------------------------
# Aggregate
# ---------------------------------------------------------------------------


def test_aggregate(df):
    r = df.aggregate(
        [],
        [
            spark.avg(col("f")).alias("avg"),
            spark.try_sum(col("i")).alias("sum"),
        ],
    ).collect()
    rec = pa.Table.from_batches(r)
    assert rec.column("avg")[0].as_py() == pytest.approx(2.1666666, rel=1e-3)
    assert rec.column("sum")[0].as_py() == 6


def test_collect_list_set(df):
    r = df.aggregate(
        [],
        [
            spark.collect_list(col("i")).alias("cl"),
            spark.collect_set(col("i")).alias("cs"),
        ],
    ).collect()
    rec = pa.Table.from_batches(r)
    assert sorted(rec.column("cl")[0].as_py()) == [1, 2, 3]
    assert sorted(rec.column("cs")[0].as_py()) == [1, 2, 3]


# ---------------------------------------------------------------------------
# Spark-semantics conflicts vs DataFusion defaults
# ---------------------------------------------------------------------------


def test_concat_null_propagates():
    """Spark concat returns NULL on any NULL input; default skips NULLs."""
    ctx = SessionContext()
    df = ctx.from_pydict({"x": [1]})
    default_out = (
        df.select(f.concat(lit("a"), lit(None), lit("b")).alias("v"))
        .collect_column("v")[0]
        .as_py()
    )
    spark_out = _val(df, spark.concat(lit("a"), lit(None), lit("b")))
    assert default_out == "ab"
    assert spark_out is None


def test_round_half_up():
    """Spark round uses HALF_UP rounding (2.5 → 3)."""
    ctx = SessionContext()
    df = ctx.from_pydict({"x": [1]})
    assert _val(df, spark.round(lit(2.5), lit(0))) == 3.0


# ---------------------------------------------------------------------------
# Optional parameter defaults / NotImplementedError
# ---------------------------------------------------------------------------


def test_round_scale_default():
    """spark.round defaults scale to 0."""
    ctx = SessionContext()
    df = ctx.from_pydict({"x": [1]})
    assert _val(df, spark.round(lit(2.5))) == 3.0


def test_make_dt_interval_defaults():
    """spark.make_dt_interval with no args returns a zero day-time interval."""
    import datetime as dt

    ctx = SessionContext()
    df = ctx.from_pydict({"x": [1]})
    assert _val(df, spark.make_dt_interval()) == dt.timedelta(0)


def test_make_interval_defaults():
    """spark.make_interval with no args returns a zero interval."""
    ctx = SessionContext()
    df = ctx.from_pydict({"x": [1]})
    assert _val(df, spark.make_interval()).months == 0


def test_str_to_map_defaults():
    """spark.str_to_map defaults delimiters to ',' and ':'."""
    ctx = SessionContext()
    df = ctx.from_pydict({"x": [1]})
    assert _val(df, spark.str_to_map(lit("a:1,b:2"))) == [("a", "1"), ("b", "2")]


def test_shuffle_seed_raises():
    """spark.shuffle(seed=...) raises NotImplementedError until Rust supports it."""
    with pytest.raises(NotImplementedError, match="seed"):
        spark.shuffle(spark.array(lit(1), lit(2)), seed=1)


def test_like_escape_raises():
    """spark.like/ilike escapeChar raises NotImplementedError until Rust supports."""
    with pytest.raises(NotImplementedError, match="escapeChar"):
        spark.like(lit("a"), lit("a"), escapeChar="\\")
    with pytest.raises(NotImplementedError, match="escapeChar"):
        spark.ilike(lit("a"), lit("a"), escapeChar="\\")


def test_parse_url_three_arg():
    """parse_url(url, partToExtract, key=...) extracts query parameters."""
    ctx = SessionContext()
    df = ctx.from_pydict({"x": [1]})
    url = lit("http://example.com/p?q=hello&n=1")
    assert _val(df, spark.parse_url(url, lit("QUERY"), key=lit("q"))) == "hello"
    assert _val(df, spark.try_parse_url(url, lit("QUERY"), key=lit("n"))) == "1"


def test_format_string_plain_str_format():
    """format_string accepts a plain str format that is auto-promoted to lit."""
    ctx = SessionContext()
    df = ctx.from_pydict({"x": [1]})
    assert _val(df, spark.format_string("%d-%s", lit(42), lit("hi"))) == "42-hi"


@pytest.mark.parametrize(
    ("native_expr", "wrapped_expr", "expected"),
    [
        # int literals coerced internally
        (
            lambda: spark.array_repeat(lit("a"), 3),
            lambda: spark.array_repeat(lit("a"), lit(3)),
            ["a", "a", "a"],
        ),
        (
            lambda: spark.shiftleft(lit(1), 3),
            lambda: spark.shiftleft(lit(1), lit(3)),
            8,
        ),
        (
            lambda: spark.sha2(lit("hello"), 256),
            lambda: spark.sha2(lit("hello"), lit(256)),
            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
        ),
        (
            lambda: spark.round(lit(2.345), 2),
            lambda: spark.round(lit(2.345), lit(2)),
            2.35,
        ),
        (
            lambda: spark.substring(lit("hello"), 1, 3),
            lambda: spark.substring(lit("hello"), lit(1), lit(3)),
            "hel",
        ),
        (
            lambda: spark.width_bucket(lit(5.0), lit(0.0), lit(10.0), 5),
            lambda: spark.width_bucket(lit(5.0), lit(0.0), lit(10.0), lit(5)),
            3,
        ),
        # str literals coerced internally
        (
            lambda: spark.date_trunc("month", _ts()),
            lambda: spark.date_trunc(lit("month"), _ts()),
            _dt(2020, 1, 1),
        ),
        (
            lambda: spark.spark_cast(lit(1579098645), "timestamp"),
            lambda: spark.spark_cast(lit(1579098645), lit("timestamp")),
            None,
        ),  # compared separately
        # int32-requiring args coerced to int32 internally
        (
            lambda: spark.space(3),
            lambda: spark.space(lit(pa.scalar(3, type=pa.int32()))),
            "   ",
        ),
        # Any-typed value coerced internally
        (
            lambda: spark.array_contains(spark.array(lit(1), lit(2)), 1),
            lambda: spark.array_contains(spark.array(lit(1), lit(2)), lit(1)),
            True,
        ),
    ],
)
def test_native_literal_coercion(df, native_expr, wrapped_expr, expected):
    """Native Python literals produce the same result as explicit lit() wrapping."""
    if expected is None:
        # spark_cast returns a timestamp; just assert native == wrapped.
        assert _val(df, native_expr()) == _val(df, wrapped_expr())
        return
    assert _val(df, native_expr()) == expected
    assert _val(df, wrapped_expr()) == expected


def test_native_literal_int32_datetime(df):
    """add_months/date_add/date_sub accept native ints (coerced to int32)."""
    import datetime as dt

    d = lit(pa.scalar(dt.date(2020, 1, 15), type=pa.date32()))
    assert _val(df, spark.add_months(d, 2)) == dt.date(2020, 3, 15)
    assert _val(df, spark.date_add(d, 5)) == dt.date(2020, 1, 20)
    assert _val(df, spark.date_sub(d, 5)) == dt.date(2020, 1, 10)
    assert _val(df, spark.next_day(d, "Mon")) == dt.date(2020, 1, 20)


def test_native_literal_make_interval(df):
    """make_dt_interval/make_interval accept native int/float parts."""
    import datetime as dt

    assert _val(
        df, spark.make_dt_interval(days=1, hours=2, mins=3, secs=4.5)
    ) == dt.timedelta(days=1, seconds=7384, microseconds=500000)
    assert _val(df, spark.make_interval(years=1)).months == 12


def test_str_to_map_pyspark_param_names(df):
    """str_to_map uses pyspark parameter names (pairDelim/keyValueDelim)."""
    out = _val(
        df, spark.str_to_map(lit("a=1;b=2"), pairDelim=lit(";"), keyValueDelim=lit("="))
    )
    assert out == [("a", "1"), ("b", "2")]


def test_column_or_name_str_is_column_ref():
    """ColumnOrName args treat a bare str as a column name (matching pyspark)."""
    ctx = SessionContext()
    df = ctx.from_pydict({"s": ["hello", "world"], "pat": ["h%", "z%"]})
    # pattern given as a column name -> per-row pattern from column "pat"
    out = (
        df.select(spark.like(col("s"), "pat").alias("v"))
        .collect_column("v")
        .to_pylist()
    )
    assert out == [True, False]


def test_aggregate_positional_compat():
    """Pyspark-style positional calls still work after the rename to ``col``."""
    ctx = SessionContext()
    df = ctx.from_pydict({"a": [1.0, 2.0, 3.0]})
    out = df.aggregate(
        [],
        [
            spark.avg(col("a")).alias("av"),
            spark.try_sum(col("a")).alias("ts"),
            spark.collect_list(col("a")).alias("cl"),
            spark.collect_set(col("a")).alias("cs"),
        ],
    ).collect()
    rec = pa.Table.from_batches(out)
    assert rec.column("av")[0].as_py() == 2.0
    assert rec.column("ts")[0].as_py() == 6.0


# ---------------------------------------------------------------------------
# SQL path via enable_spark_functions
# ---------------------------------------------------------------------------


def test_sql_requires_enable():
    """Spark-only function (xxhash64) is not in SQL namespace by default."""
    ctx = SessionContext()
    with pytest.raises(Exception, match=r"(?i)xxhash64|Invalid function"):
        ctx.sql("SELECT xxhash64('hello')").collect()


def test_sql_after_enable():
    ctx = SessionContext()
    ctx.enable_spark_functions()
    out = ctx.sql("SELECT sha2('hello', 256) AS h").collect_column("h")[0].as_py()
    assert out == ("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")


def test_sql_concat_semantics_override():
    """After enable, SQL concat propagates NULL like Spark."""
    ctx = SessionContext()
    default_out = (
        ctx.sql("SELECT concat('a', NULL, 'b') AS c").collect_column("c")[0].as_py()
    )
    assert default_out == "ab"

    ctx2 = SessionContext()
    ctx2.enable_spark_functions()
    spark_out = (
        ctx2.sql("SELECT concat('a', NULL, 'b') AS c").collect_column("c")[0].as_py()
    )
    assert spark_out is None
