"""
Edge-case and stress-style tests: validation, empty inputs, boundaries, larger scans.

Use ``pytest -m "not stress"`` to skip heavier cases (see ``pyproject.toml`` markers).
"""

from __future__ import annotations

import time

import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import pytest

import snapparquet as sp


# ---------------------------------------------------------------------------
# build_filter / validation edge cases
# ---------------------------------------------------------------------------


def test_build_filter_none_and_empty_returns_none():
    assert sp.build_filter(None) is None
    assert sp.build_filter([]) is None


def test_build_filter_rejects_bad_tuple_length():
    with pytest.raises(ValueError, match="3-tuple"):
        sp.build_filter([("a", "==")])
    with pytest.raises(ValueError, match="3-tuple"):
        sp.build_filter([("a", "==", 1, "extra")])


def test_build_filter_rejects_unknown_operator():
    with pytest.raises(ValueError, match="Unsupported"):
        sp.build_filter([("x", "~~", 1)])


def test_build_filter_rejects_in_with_string():
    with pytest.raises(ValueError, match="non-string sequence"):
        sp.build_filter([("x", "in", "abc")])


def test_build_filter_rejects_empty_column_name():
    with pytest.raises(ValueError, match="non-empty"):
        sp.build_filter([("  ", "==", 1)])


def test_build_filter_rejects_oversized_column_name():
    long_name = "c" * 513
    with pytest.raises(ValueError, match="maximum length"):
        sp.build_filter([(long_name, "==", 1)])


def test_build_filter_rejects_control_char_in_column_name():
    with pytest.raises(ValueError, match="control"):
        sp.build_filter([("bad\x00col", "==", 1)])


def test_build_filter_accepts_unicode_column_name(tmp_path):
    path = tmp_path / "u.parquet"
    df0 = pd.DataFrame({"列_α": [1, 2], "v": [10, 20]})
    pq.write_table(pa.Table.from_pandas(df0), path)
    out = sp.read_parquet(
        str(path),
        columns=["列_α", "v"],
        filters=[("列_α", "==", 2)],
    )
    assert len(out) == 1
    assert int(out["v"].iloc[0]) == 20


def test_build_filter_many_predicates_and_chains(tmp_path):
    """Many AND clauses — stresses expression merge (still small data)."""
    path = tmp_path / "many_f.parquet"
    spec = []
    data = {"id": [0], "flag": [1]}
    for i in range(12):
        col = "c{}".format(i)
        data[col] = [i]
        spec.append((col, "==", i))
    df0 = pd.DataFrame(data)
    pq.write_table(pa.Table.from_pandas(df0), path)
    cols = ["id", "flag"] + ["c{}".format(i) for i in range(12)]
    out = sp.read_parquet(str(path), columns=cols, filters=spec)
    assert len(out) == 1
    assert int(out["id"].iloc[0]) == 0


# ---------------------------------------------------------------------------
# read_parquet / tabular edge cases
# ---------------------------------------------------------------------------


def test_read_parquet_empty_table(tmp_path):
    path = tmp_path / "empty.parquet"
    pq.write_table(pa.table({"a": pa.array([], type=pa.int64())}), path)
    out = sp.read_parquet(str(path), columns=["a"])
    assert len(out) == 0
    assert list(out.columns) == ["a"]


def test_read_parquet_filters_match_zero_rows(tmp_path):
    path = tmp_path / "z.parquet"
    pq.write_table(
        pa.Table.from_pandas(pd.DataFrame({"k": [1, 2, 3], "s": ["a", "b", "c"]})),
        path,
    )
    out = sp.read_parquet(str(path), columns=["k", "s"], filters=[("k", "==", 99)])
    assert len(out) == 0
    assert list(out.columns) == ["k", "s"]


def test_read_parquet_legacy_range_single_point(tmp_path):
    path = tmp_path / "one_day.parquet"
    pq.write_table(
        pa.Table.from_pandas(pd.DataFrame({"load_date": [20250101], "x": [42]})),
        path,
    )
    out = sp.read_parquet(
        str(path),
        columns=["load_date", "x"],
        filter_column="load_date",
        range_start=20250101,
        range_end=20250101,
    )
    assert len(out) == 1
    assert int(out["x"].iloc[0]) == 42


def test_read_parquet_not_in_operator(tmp_path):
    path = tmp_path / "ni.parquet"
    pq.write_table(
        pa.Table.from_pandas(pd.DataFrame({"t": ["a", "b", "c", "d"]})),
        path,
    )
    out = sp.read_parquet(
        str(path),
        columns=["t"],
        filters=[("t", "not_in", ["b", "d"])],
    )
    assert set(out["t"].tolist()) == {"a", "c"}


def test_read_parquet_backend_invalid_raises(tmp_path):
    path = tmp_path / "z1.parquet"
    pq.write_table(pa.table({"z": [1]}), path)
    with pytest.raises(ValueError, match="backend"):
        sp.read_parquet(str(path), backend="cuda")


def test_read_parquet_dictionary_as_categorical_disabled(tmp_path):
    path = tmp_path / "nd.parquet"
    idx = pa.array([0, 1], type=pa.int8())
    dictionary = pa.array(["p", "q"])
    dict_arr = pa.DictionaryArray.from_arrays(idx, dictionary)
    pq.write_table(pa.table({"d": dict_arr}), path)
    out = sp.read_parquet(
        str(path),
        columns=["d"],
        dictionary_as_categorical=False,
    )
    # Skips snapparquet's dict→categorical pass; Arrow's to_pandas may still use category.
    assert list(out["d"]) == ["p", "q"]


def test_read_parquet_chunked_dictionary_column(tmp_path):
    """Multiple record batches → chunked arrays; conversion must not crash."""
    path = tmp_path / "chunk_dict.parquet"
    idx1 = pa.array([0, 1], type=pa.int8())
    idx2 = pa.array([1, 0], type=pa.int8())
    dictionary = pa.array(["aa", "bb"])
    d1 = pa.DictionaryArray.from_arrays(idx1, dictionary)
    d2 = pa.DictionaryArray.from_arrays(idx2, dictionary)
    chunked = pa.chunked_array([d1, d2])
    tbl = pa.table({"tag": chunked, "n": [1, 2, 3, 4]})
    pq.write_table(tbl, path)
    out = sp.read_parquet(
        str(path),
        columns=["tag", "n"],
        dictionary_as_categorical=True,
    )
    assert str(out["tag"].dtype) == "category"
    assert len(out) == 4


@pytest.mark.stress
def test_read_tabular_many_unified_many_fragments_row_count(tmp_path):
    """Many small Parquet files — typical of daily partitions; unified scan must match."""
    n_files = 32
    paths = []
    for i in range(n_files):
        p = tmp_path / "part_{:03d}.parquet".format(i)
        pq.write_table(
            pa.Table.from_pandas(
                pd.DataFrame(
                    {
                        "load_date": [20250101 + i, 20250101 + i],
                        "v": [i * 10, i * 10 + 1],
                    }
                )
            ),
            p,
        )
        paths.append(str(p))
    t0 = time.perf_counter()
    out = sp.read_tabular_many(
        paths,
        columns=["load_date", "v"],
        filter_column="load_date",
        range_start=20250105,
        range_end=20250120,
        unified_parquet=True,
    )
    elapsed = time.perf_counter() - t0
    assert elapsed < 30.0, "sanity: unified scan should finish quickly on tiny files"
    assert len(out) == 32
    assert int(out["v"].min()) == 40
    assert int(out["v"].max()) == 191


@pytest.mark.stress
def test_read_tabular_many_parallel_high_worker_count(tmp_path):
    paths = []
    for i in range(8):
        p = tmp_path / "w{}.parquet".format(i)
        pq.write_table(
            pa.Table.from_pandas(pd.DataFrame({"i": [i], "x": [i * 100]})),
            p,
        )
        paths.append(str(p))
    out = sp.read_tabular_many(
        paths,
        columns=["i", "x"],
        unified_parquet=False,
        parallel_workers=8,
    )
    assert len(out) == 8
    assert set(out["i"].tolist()) == set(range(8))


def test_read_tabular_many_empty_path_list():
    out = sp.read_tabular_many([])
    assert len(out) == 0
    assert list(out.columns) == []


def test_read_tabular_many_ignore_errors_skips_bad_parquet(tmp_path):
    good = tmp_path / "ok.parquet"
    pq.write_table(pa.Table.from_pandas(pd.DataFrame({"a": [1]})), good)
    bad = tmp_path / "bad.parquet"
    bad.write_bytes(b"not a parquet file")
    out = sp.read_tabular_many(
        [str(bad), str(good)],
        columns=["a"],
        ignore_errors=True,
        unified_parquet=False,
        parallel_workers=1,
    )
    assert len(out) == 1
    assert int(out["a"].iloc[0]) == 1


def test_read_tabular_mixed_parquet_and_csv(tmp_path):
    p = tmp_path / "m1.parquet"
    c = tmp_path / "m2.csv"
    pq.write_table(pa.Table.from_pandas(pd.DataFrame({"load_date": [1], "v": [10]})), p)
    pd.DataFrame({"load_date": [2], "v": [20]}).to_csv(c, index=False)
    out = sp.read_tabular_many([str(p), str(c)], columns=["load_date", "v"])
    assert len(out) == 2
    assert set(out["v"].tolist()) == {10, 20}


def test_read_csv_with_tuple_filters(tmp_path):
    csv_path = tmp_path / "t.csv"
    pd.DataFrame({"a": [1, 2, 3], "s": ["x", "y", "z"]}).to_csv(csv_path, index=False)
    out = sp.read_csv(
        str(csv_path),
        columns=["a", "s"],
        filters=[("a", ">=", 2), ("s", "in", ["y", "z"])],
    )
    assert len(out) == 2
    assert set(out["s"].tolist()) == {"y", "z"}


def test_apply_filters_pandas_empty_dataframe():
    empty = pd.DataFrame({"a": pd.Series([], dtype="int64")})
    out = sp.apply_filters_pandas(empty, [("a", "==", 1)])
    assert len(out) == 0


def test_apply_filters_pandas_no_filters_returns_same():
    df = pd.DataFrame({"a": [1]})
    assert sp.apply_filters_pandas(df, None) is df
    assert len(sp.apply_filters_pandas(df, [])) == 1


def test_read_parquet_wide_column_prune_stress(tmp_path):
    """Many columns on disk, read only a few — ensures prune path stays valid."""
    path = tmp_path / "wide.parquet"
    ncols = 80
    data = {"c{}".format(i): [i] * 500 for i in range(ncols)}
    data["load_date"] = [20250101] * 500
    pq.write_table(pa.Table.from_pandas(pd.DataFrame(data)), path)
    want = ["load_date", "c0", "c79"]
    out = sp.read_parquet(
        str(path),
        columns=want,
        filter_column="load_date",
        range_start=20250101,
        range_end=20250101,
    )
    assert list(out.columns) == want
    assert len(out) == 500


def test_read_parquet_int64_boundary_filter(tmp_path):
    path = tmp_path / "i64.parquet"
    big = 2**62
    pq.write_table(
        pa.Table.from_pandas(pd.DataFrame({"x": [big, big + 1], "tag": [1, 2]})),
        path,
    )
    out = sp.read_parquet(
        str(path),
        columns=["x", "tag"],
        filters=[("x", ">", big)],
    )
    assert len(out) == 1
    assert int(out["tag"].iloc[0]) == 2


def test_read_parquet_float_filter(tmp_path):
    path = tmp_path / "flt.parquet"
    pq.write_table(
        pa.Table.from_pandas(pd.DataFrame({"f": [0.5, 1.5, 2.5]})),
        path,
    )
    out = sp.read_parquet(str(path), columns=["f"], filters=[("f", ">=", 1.5)])
    assert out["f"].tolist() == pytest.approx([1.5, 2.5])


@pytest.mark.stress
def test_read_tabular_many_concat_order_preserved_parallel(tmp_path):
    """Parallel per-file reads must re-assemble in path order (regression guard)."""
    paths = []
    for i in range(5):
        p = tmp_path / "ord{}.parquet".format(i)
        pq.write_table(pa.Table.from_pandas(pd.DataFrame({"seq": [i]})), p)
        paths.append(str(p))
    out = sp.read_tabular_many(
        paths,
        columns=["seq"],
        unified_parquet=False,
        parallel_workers=4,
    )
    assert out["seq"].tolist() == [0, 1, 2, 3, 4]


def test_backend_pyarrow_emits_warning_or_arrow_dtype(tmp_path):
    path = tmp_path / "bk.parquet"
    pq.write_table(pa.Table.from_pandas(pd.DataFrame({"u": [1, 2]})), path)
    major = int(pd.__version__.split(".", 1)[0])
    if major >= 2:
        out = sp.read_parquet(str(path), columns=["u"], backend="pyarrow")
        assert len(out) == 2
        assert int(out["u"].iloc[0]) == 1 and int(out["u"].iloc[1]) == 2
    else:
        with pytest.warns(UserWarning, match="pandas>=2"):
            out = sp.read_parquet(str(path), columns=["u"], backend="pyarrow")
        assert len(out) == 2
