"""Tests for the per-device wide-format TsFile builder."""

from __future__ import annotations

import logging
from datetime import date, datetime, timedelta, timezone
from typing import Any, Sequence

import pyarrow as pa
import pytest


# `tsfile` requires pyarrow<20 for python<3.14, which conflicts with datasets'
# pyarrow>=21.0.0. It is therefore only installed in the py3.14 CI. Skip this
# whole module (at collection time) when tsfile is not importable.
pytest.importorskip("tsfile")

from tsfile import ColumnCategory, ColumnSchema, TableSchema, Tablet, TsFileWriter  # noqa: E402
from tsfile.constants import TSDataType  # noqa: E402

from datasets import IterableDataset, load_dataset  # noqa: E402
from datasets.builder import InvalidConfigName  # noqa: E402
from datasets.data_files import DataFilesList  # noqa: E402
from datasets.packaged_modules.tsfile.tsfile import TsFileConfig, _to_epoch  # noqa: E402


# ---------------------------------------------------------------------------
# Time-base constants
# ---------------------------------------------------------------------------
#
# Every fixture's timestamps live in a disjoint epoch-ms slice off ``T0`` so
# that, when two files of the same device are merged, the resulting
# time-sorted order is fully determined by writer-side timestamps. This lets
# the assertions below check both *content* and *order* unambiguously.
T0 = 1_700_000_000_000  # base: single_device, multi_device, all_types
T_EVOLVED = T0 + 500_000  # evolved file (after single_device's 5 points)
T_INT32 = T0 + 1_000_000
T_INT64 = T0 + 2_000_000
T_FLOAT = T0 + 3_000_000


# ---------------------------------------------------------------------------
# Generic writer
# ---------------------------------------------------------------------------


# A row maps column name -> Python value, plus a special "time" -> int (epoch).
Row = dict[str, Any]
ColumnSpec = tuple[str, TSDataType, ColumnCategory]


def _write_tsfile(path: str, tables: Sequence[tuple[str, Sequence[ColumnSpec], Sequence[Sequence[Row]]]]) -> None:
    """Write one or more tables, each as one or more tablets, to ``path``.

    Each ``tables`` entry is ``(table_name, columns, tablets)`` where:

    - ``columns`` is the table schema as ``[(name, TSDataType, ColumnCategory), ...]``;
      it must include exactly one TIME column called ``"time"``.
    - ``tablets`` is a list of tablets; each tablet is a list of row dicts. A
      row dict must carry ``"time"`` plus every TAG/FIELD column in the table.
    """
    writer = TsFileWriter(path)
    try:
        # Register schemas first so multiple-table files validate up-front.
        for table_name, columns, _ in tables:
            writer.register_table(TableSchema(table_name, [ColumnSchema(*c) for c in columns]))

        for table_name, columns, tablets in tables:
            non_time = [(n, t) for (n, t, c) in columns if c != ColumnCategory.TIME]
            col_names = [n for n, _ in non_time]
            col_types = [t for _, t in non_time]
            for rows in tablets:
                tablet = Tablet(col_names, col_types, len(rows))
                tablet.set_table_name(table_name)
                for i, row in enumerate(rows):
                    tablet.add_timestamp(i, row["time"])
                    for name in col_names:
                        tablet.add_value_by_name(name, i, row[name])
                writer.write_table(tablet)
    finally:
        writer.close()


# ---------------------------------------------------------------------------
# Per-fixture writers (each declarative + tiny)
# ---------------------------------------------------------------------------


def _write_single_device(path: str) -> None:
    """One device 'd1', two DOUBLE fields, 5 points starting at T0."""
    cols = [
        ("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
        ("device", TSDataType.STRING, ColumnCategory.TAG),
        ("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
        ("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
    ]
    rows = [{"time": T0 + i * 1000, "device": "d1", "temperature": 20.0 + i, "humidity": 50.0 + i} for i in range(5)]
    _write_tsfile(path, [("mytable", cols, [rows])])


def _write_multi_device(path: str) -> None:
    """Three devices, 3 points each, all sharing the same field schema."""
    cols = [
        ("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
        ("device", TSDataType.STRING, ColumnCategory.TAG),
        ("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
        ("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
    ]
    tablets = [
        [{"time": T0 + i * 1000, "device": dev, "temperature": 10.0 + i, "humidity": 50.0 + i} for i in range(3)]
        for dev in ("d1", "d2", "d3")
    ]
    _write_tsfile(path, [("plant", cols, tablets)])


def _write_evolved(path: str) -> None:
    """Same table+device as single_device, plus a new ``voltage`` field."""
    cols = [
        ("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
        ("device", TSDataType.STRING, ColumnCategory.TAG),
        ("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
        ("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
        ("voltage", TSDataType.DOUBLE, ColumnCategory.FIELD),
    ]
    rows = [
        {
            "time": T_EVOLVED + i * 1000,
            "device": "d1",
            "temperature": 30.0 + i,
            "humidity": 60.0 + i,
            "voltage": 220.0 + i,
        }
        for i in range(3)
    ]
    _write_tsfile(path, [("mytable", cols, [rows])])


def _write_numeric_field(path: str, ts_type: TSDataType, base_ts: int, value_fn) -> None:
    """One device 'd1' with a single numeric ``temperature`` field."""
    cols = [
        ("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
        ("device", TSDataType.STRING, ColumnCategory.TAG),
        ("temperature", ts_type, ColumnCategory.FIELD),
    ]
    rows = [{"time": base_ts + i * 1000, "device": "d1", "temperature": value_fn(i)} for i in range(3)]
    _write_tsfile(path, [("mytable", cols, [rows])])


def _write_two_tables(path: str) -> None:
    """Two distinct tables in one file: ``table_a`` (registered first) and ``table_b``."""
    a_cols = [
        ("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
        ("device", TSDataType.STRING, ColumnCategory.TAG),
        ("a", TSDataType.DOUBLE, ColumnCategory.FIELD),
    ]
    b_cols = [
        ("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
        ("device", TSDataType.STRING, ColumnCategory.TAG),
        ("b", TSDataType.DOUBLE, ColumnCategory.FIELD),
    ]
    a_rows = [{"time": 1_000 + i, "device": "d1", "a": float(i)} for i in range(2)]
    b_rows = [{"time": 2_000 + i, "device": "d1", "b": 100.0 + i} for i in range(2)]
    _write_tsfile(path, [("table_a", a_cols, [a_rows]), ("table_b", b_cols, [b_rows])])


def _write_all_types(path: str) -> None:
    """Every supported TSDataType represented as a FIELD."""
    cols = [
        ("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
        ("tag", TSDataType.STRING, ColumnCategory.TAG),
        ("col_boolean", TSDataType.BOOLEAN, ColumnCategory.FIELD),
        ("col_int32", TSDataType.INT32, ColumnCategory.FIELD),
        ("col_int64", TSDataType.INT64, ColumnCategory.FIELD),
        ("col_float", TSDataType.FLOAT, ColumnCategory.FIELD),
        ("col_double", TSDataType.DOUBLE, ColumnCategory.FIELD),
        ("col_text", TSDataType.TEXT, ColumnCategory.FIELD),
        ("col_string", TSDataType.STRING, ColumnCategory.FIELD),
        ("col_timestamp", TSDataType.TIMESTAMP, ColumnCategory.FIELD),
        ("col_date", TSDataType.DATE, ColumnCategory.FIELD),
        ("col_blob", TSDataType.BLOB, ColumnCategory.FIELD),
    ]
    rows = [
        {
            "time": T0 + i * 1000,
            "tag": "d1",
            "col_boolean": i % 2 == 0,
            "col_int32": 100 + i,
            "col_int64": 1_000_000 + i,
            "col_float": 1.5 + i,
            "col_double": 100.5 + i,
            "col_text": f"text_{i}",
            "col_string": f"str_{i}",
            "col_timestamp": 1_600_000_000_000 + i * 1000,
            "col_date": date(2024, 1, 1 + i),
            "col_blob": f"blob{i}".encode(),
        }
        for i in range(3)
    ]
    _write_tsfile(path, [("alltypes", cols, [rows])])


def _write_large_device(path: str, n_points: int = 200) -> None:
    """Single device with many points, used to exercise multi-batch concat."""
    cols = [
        ("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
        ("device", TSDataType.STRING, ColumnCategory.TAG),
        ("v", TSDataType.INT64, ColumnCategory.FIELD),
    ]
    rows = [{"time": T0 + i, "device": "d1", "v": i} for i in range(n_points)]
    _write_tsfile(path, [("mytable", cols, [rows])])


def _write_two_devices_subset(path: str, devices: Sequence[str], base_ts: int) -> None:
    """A multi-device fixture used to assemble cross-file device sets."""
    cols = [
        ("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
        ("device", TSDataType.STRING, ColumnCategory.TAG),
        ("v", TSDataType.DOUBLE, ColumnCategory.FIELD),
    ]
    tablets = [[{"time": base_ts + i * 1000, "device": dev, "v": float(i)} for i in range(3)] for dev in devices]
    _write_tsfile(path, [("mytable", cols, tablets)])


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def make_tsfile(tmp_path):
    """Factory fixture: ``make_tsfile("name", writer_fn, *args, **kwargs)``."""

    def _make(name: str, writer_fn, *args, **kwargs) -> str:
        p = str(tmp_path / f"{name}.tsfile")
        writer_fn(p, *args, **kwargs)
        return p

    return _make


@pytest.fixture
def tsfile_path(make_tsfile):
    return make_tsfile("sample", _write_single_device)


@pytest.fixture
def multi_device_tsfile_path(make_tsfile):
    return make_tsfile("multi", _write_multi_device)


@pytest.fixture
def evolved_tsfile_path(make_tsfile):
    return make_tsfile("evolved", _write_evolved)


@pytest.fixture
def two_tables_tsfile_path(make_tsfile):
    return make_tsfile("two_tables", _write_two_tables)


@pytest.fixture
def all_types_tsfile_path(make_tsfile):
    return make_tsfile("alltypes", _write_all_types)


# ---------------------------------------------------------------------------
# Config-level
# ---------------------------------------------------------------------------


def test_config_raises_when_invalid_name():
    with pytest.raises(InvalidConfigName, match="Bad characters"):
        TsFileConfig(name="name-with-*-invalid-character")


@pytest.mark.parametrize("data_files", ["str_path", ["str_path"], DataFilesList(["str_path"], [()])])
def test_config_raises_when_invalid_data_files(data_files):
    with pytest.raises(ValueError, match="Expected a DataFilesDict"):
        TsFileConfig(name="name", data_files=data_files)


@pytest.mark.parametrize(
    "kwargs, match",
    [
        ({"input_batch_size": 0}, "input_batch_size"),
        ({"output_batch_size": 0}, "output_batch_size"),
        ({"columns": []}, "non-empty"),
        ({"timestamp_unit": "minute"}, "timestamp_unit"),
        ({"on_bad_files": "boom"}, "on_bad_files"),
    ],
)
def test_config_rejects_invalid_values(kwargs, match):
    with pytest.raises(ValueError, match=match):
        TsFileConfig(name="x", **kwargs)


def test_config_normalizes_time_bounds():
    cfg = TsFileConfig(
        name="x",
        start_time=pa.scalar(1500, type=pa.timestamp("ms")),
        end_time=2000,
    )
    assert cfg.start_time == 1500
    assert cfg.end_time == 2000


# ---------------------------------------------------------------------------
# _to_epoch unit tests
# ---------------------------------------------------------------------------


def test_to_epoch_int_passthrough():
    assert _to_epoch(1234, "ms") == 1234


def test_to_epoch_naive_datetime():
    assert _to_epoch(datetime(1970, 1, 1, 0, 0, 1), "ms") == 1000


@pytest.mark.parametrize(
    "aware",
    [
        datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=8))),
        "2024-01-01T00:00:00+08:00",
    ],
    ids=["datetime", "iso_string"],
)
def test_to_epoch_aware_inputs_normalized_to_utc(aware):
    # 2024-01-01T00:00:00 in UTC+8 == 2023-12-31T16:00:00 UTC.
    naive_utc = datetime(2023, 12, 31, 16, 0, 0)
    assert _to_epoch(aware, "ms") == _to_epoch(naive_utc, "ms")


def test_to_epoch_date():
    assert _to_epoch(date(1970, 1, 2), "ms") == 86_400_000


def test_to_epoch_iso_string():
    assert _to_epoch("1970-01-01T00:00:01", "ms") == 1000


def test_to_epoch_pa_scalar():
    assert _to_epoch(pa.scalar(1500, type=pa.timestamp("ms")), "ms") == 1500


def test_to_epoch_rejects_bool():
    with pytest.raises(TypeError, match="bool"):
        _to_epoch(True, "ms")


@pytest.mark.parametrize("value", [object(), b"bytes", "not-a-date"])
def test_to_epoch_rejects_garbage(value):
    with pytest.raises(TypeError, match="must be a"):
        _to_epoch(value, "ms")


# ---------------------------------------------------------------------------
# End-to-end: single device, full table
# ---------------------------------------------------------------------------


def test_load_full_table(tsfile_path):
    ds = load_dataset("tsfile", data_files=tsfile_path)["train"]

    # One row per device. TAG = scalar string; time + fields = lists.
    assert ds.column_names == ["device", "time", "temperature", "humidity"]
    assert len(ds) == 1
    row = ds[0]
    assert row["device"] == "d1"
    assert len(row["time"]) == 5
    assert row["time"][0] == datetime(2023, 11, 14, 22, 13, 20)
    assert row["time"][-1] == datetime(2023, 11, 14, 22, 13, 24)
    assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
    assert row["humidity"] == [50.0, 51.0, 52.0, 53.0, 54.0]


def test_load_with_field_subset(tsfile_path):
    ds = load_dataset("tsfile", data_files=tsfile_path, columns=["temperature"])["train"]
    assert ds.column_names == ["device", "time", "temperature"]
    assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]


def test_columns_are_lowercased(tsfile_path):
    ds = load_dataset("tsfile", data_files=tsfile_path, columns=["TEMPERATURE", "Humidity"])["train"]
    assert ds.column_names == ["device", "time", "temperature", "humidity"]


def test_columns_request_tag_is_silently_ignored(tsfile_path):
    """Passing a TAG name in `columns` is a no-op (TAGs are always emitted)."""
    ds = load_dataset("tsfile", data_files=tsfile_path, columns=["device", "temperature"])["train"]

    assert ds.column_names == ["device", "time", "temperature"]
    assert ds.features["device"].dtype == "string"
    assert ds.features["temperature"].feature.dtype == "float64"
    assert ds["device"] == ["d1"]
    assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]


def test_columns_request_time_is_silently_ignored(tsfile_path):
    """Passing the TIME column name in `columns` is a no-op (TIME is always emitted)."""
    ds = load_dataset("tsfile", data_files=tsfile_path, columns=["time", "temperature"])["train"]

    # `time` should appear exactly once, and as the real timestamp list — not
    # as a duplicate all-null float64 list column.
    assert ds.column_names == ["device", "time", "temperature"]
    assert ds.features["time"].feature.dtype.startswith("timestamp")
    row = ds[0]
    assert len(row["time"]) == 5
    assert row["time"][0] == datetime(2023, 11, 14, 22, 13, 20)
    assert row["time"][-1] == datetime(2023, 11, 14, 22, 13, 24)
    assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]


def test_columns_request_only_time(tsfile_path):
    """`columns=["time"]` should still produce TAG + TIME, with no FIELD list columns."""
    ds = load_dataset("tsfile", data_files=tsfile_path, columns=["time"])["train"]

    assert ds.column_names == ["device", "time"]
    assert ds.features["time"].feature.dtype.startswith("timestamp")
    row = ds[0]
    assert row["device"] == "d1"
    assert len(row["time"]) == 5
    assert row["time"][0] == datetime(2023, 11, 14, 22, 13, 20)
    assert row["time"][-1] == datetime(2023, 11, 14, 22, 13, 24)


def test_columns_unknown_field_filled_with_null(tsfile_path):
    ds = load_dataset(
        "tsfile",
        data_files=tsfile_path,
        columns=["temperature", "voltage"],  # voltage is absent
    )["train"]

    assert ds.column_names == ["device", "time", "temperature", "voltage"]
    row = ds[0]
    assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
    assert row["voltage"] == [None] * 5


def test_columns_all_unknown_still_returns_time_and_tags(tsfile_path):
    ds = load_dataset(
        "tsfile",
        data_files=tsfile_path,
        columns=["nonexistent_a", "nonexistent_b"],
    )["train"]

    assert ds.column_names == ["device", "time", "nonexistent_a", "nonexistent_b"]
    row = ds[0]
    assert row["device"] == "d1"
    assert len(row["time"]) == 5
    assert row["nonexistent_a"] == [None] * 5
    assert row["nonexistent_b"] == [None] * 5


# ---------------------------------------------------------------------------
# Time-range filtering
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "start, end",
    [
        # pa.scalar from datetime
        (
            pa.scalar(datetime(2023, 11, 14, 22, 13, 21), type=pa.timestamp("ms")),
            pa.scalar(datetime(2023, 11, 14, 22, 13, 23), type=pa.timestamp("ms")),
        ),
        # pa.scalar from int epoch
        (
            pa.scalar(T0 + 1000, type=pa.timestamp("ms")),
            pa.scalar(T0 + 3000, type=pa.timestamp("ms")),
        ),
        # plain int epoch
        (T0 + 1000, T0 + 3000),
        # datetime
        (datetime(2023, 11, 14, 22, 13, 21), datetime(2023, 11, 14, 22, 13, 23)),
        # ISO-8601 string
        ("2023-11-14T22:13:21", "2023-11-14T22:13:23"),
    ],
)
def test_load_with_time_range_inputs(tsfile_path, start, end):
    ds = load_dataset("tsfile", data_files=tsfile_path, start_time=start, end_time=end)["train"]
    assert len(ds[0]["time"]) == 3


# ---------------------------------------------------------------------------
# Multi-device & cross-file folding
# ---------------------------------------------------------------------------


def test_load_multi_device_one_row_per_device(multi_device_tsfile_path):
    ds = load_dataset("tsfile", data_files=multi_device_tsfile_path)["train"]

    assert len(ds) == 3
    assert sorted(ds["device"]) == ["d1", "d2", "d3"]
    for row in ds:
        assert len(row["time"]) == 3
        assert row["temperature"] == [10.0, 11.0, 12.0]
        assert row["humidity"] == [50.0, 51.0, 52.0]


def test_schema_evolution_merges_same_device(tsfile_path, evolved_tsfile_path):
    """Same device d1 in two files → one row, lists merged in time order."""
    ds = load_dataset("tsfile", data_files=[tsfile_path, evolved_tsfile_path])["train"]

    assert "voltage" in ds.column_names
    assert len(ds) == 1
    row = ds[0]
    assert row["device"] == "d1"
    # 5 (old) + 3 (new) points, fully time-ordered.
    assert len(row["time"]) == 8
    # Old file lacked `voltage` → null on its 5 points; new file fills the rest.
    assert row["voltage"] == [None] * 5 + [220.0, 221.0, 222.0]
    # `temperature` is present in both files, contiguous in the merged order.
    assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0, 30.0, 31.0, 32.0]


def test_multi_file_multi_device_partial_overlap(make_tsfile):
    """Two files × two devices each, with one device shared.

    File A: devices {d1, d2}; file B: devices {d2, d3}. The merged dataset
    must have 3 rows (one per unique device), and d2 must have *6* points
    (3 from each file) sorted by time.
    """
    fa = make_tsfile("a", _write_two_devices_subset, devices=["d1", "d2"], base_ts=T0)
    fb = make_tsfile("b", _write_two_devices_subset, devices=["d2", "d3"], base_ts=T0 + 100_000)

    ds = load_dataset("tsfile", data_files=[fa, fb])["train"]
    by_dev = {row["device"]: row for row in ds}
    assert set(by_dev) == {"d1", "d2", "d3"}
    assert len(by_dev["d1"]["time"]) == 3
    assert len(by_dev["d3"]["time"]) == 3
    # Shared device gets all 6 points, time-sorted (file A first, then B).
    assert len(by_dev["d2"]["time"]) == 6
    assert by_dev["d2"]["v"] == [0.0, 1.0, 2.0, 0.0, 1.0, 2.0]


# ---------------------------------------------------------------------------
# Type promotion across files
# ---------------------------------------------------------------------------


def test_type_promotion_int32_to_int64(make_tsfile):
    int32_path = make_tsfile("narrow", _write_numeric_field, TSDataType.INT32, T_INT32, lambda i: 10 + i)
    int64_path = make_tsfile("wide", _write_numeric_field, TSDataType.INT64, T_INT64, lambda i: 1_000_000 + i)

    ds = load_dataset("tsfile", data_files=[int32_path, int64_path])["train"]
    assert len(ds) == 1
    assert ds.features["temperature"].feature.dtype == "int64"
    # int32 timestamps come earlier (T_INT32 < T_INT64).
    assert ds[0]["temperature"] == [10, 11, 12, 1_000_000, 1_000_001, 1_000_002]


def test_type_promotion_float_to_double(make_tsfile):
    float_path = make_tsfile("narrow", _write_numeric_field, TSDataType.FLOAT, T_FLOAT, lambda i: 1.5 + i)
    double_path = make_tsfile("wide", _write_single_device)

    ds = load_dataset("tsfile", data_files=[float_path, double_path])["train"]
    assert len(ds) == 1
    assert ds.features["temperature"].feature.dtype == "float64"
    # double fixture lives at T0..T0+4s; float at T_FLOAT (later).
    assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0, 1.5, 2.5, 3.5]


def test_type_promotion_int32_to_double(make_tsfile):
    int32_path = make_tsfile("int", _write_numeric_field, TSDataType.INT32, T_INT32, lambda i: 10 + i)
    double_path = make_tsfile("double", _write_single_device)

    ds = load_dataset("tsfile", data_files=[int32_path, double_path])["train"]
    assert len(ds) == 1
    # INT32 + DOUBLE → DOUBLE (two-step widening).
    assert ds.features["temperature"].feature.dtype == "float64"
    assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0, 10.0, 11.0, 12.0]


# ---------------------------------------------------------------------------
# All-types
# ---------------------------------------------------------------------------


def test_load_all_supported_types(all_types_tsfile_path):
    ds = load_dataset("tsfile", data_files=all_types_tsfile_path)["train"]

    assert len(ds) == 1
    assert ds.column_names == [
        "tag",
        "time",
        "col_boolean",
        "col_int32",
        "col_int64",
        "col_float",
        "col_double",
        "col_text",
        "col_string",
        "col_timestamp",
        "col_date",
        "col_blob",
    ]
    row = ds[0]
    assert row["tag"] == "d1"
    assert row["col_boolean"] == [True, False, True]
    assert row["col_int32"] == [100, 101, 102]
    assert row["col_int64"] == [1_000_000, 1_000_001, 1_000_002]
    assert row["col_float"] == [1.5, 2.5, 3.5]
    assert row["col_double"] == [100.5, 101.5, 102.5]
    assert row["col_text"] == ["text_0", "text_1", "text_2"]
    assert row["col_string"] == ["str_0", "str_1", "str_2"]
    assert row["col_timestamp"][0] == datetime(2020, 9, 13, 12, 26, 40)
    assert row["col_date"][0] == date(2024, 1, 1)
    assert row["col_date"][2] == date(2024, 1, 3)
    assert row["col_blob"][0] == b"blob0"
    assert row["col_blob"][2] == b"blob2"


# ---------------------------------------------------------------------------
# Multi-table file: explicit `table_name` selection
# ---------------------------------------------------------------------------


def test_default_table_is_first(two_tables_tsfile_path):
    ds = load_dataset("tsfile", data_files=two_tables_tsfile_path)["train"]
    # `table_a` registered first → default pick.
    assert "a" in ds.column_names
    assert "b" not in ds.column_names


def test_explicit_table_name(two_tables_tsfile_path):
    ds = load_dataset("tsfile", data_files=two_tables_tsfile_path, table_name="table_b")["train"]
    assert "b" in ds.column_names
    assert "a" not in ds.column_names


# ---------------------------------------------------------------------------
# Streaming (IterableDataset)
# ---------------------------------------------------------------------------


def test_streaming_yields_same_rows(multi_device_tsfile_path):
    ds = load_dataset("tsfile", data_files=multi_device_tsfile_path, streaming=True)["train"]
    assert isinstance(ds, IterableDataset)
    rows = list(ds)
    assert len(rows) == 3
    assert sorted(r["device"] for r in rows) == ["d1", "d2", "d3"]
    for r in rows:
        assert r["temperature"] == [10.0, 11.0, 12.0]


# ---------------------------------------------------------------------------
# Timezone
# ---------------------------------------------------------------------------


def test_load_with_timezone(make_tsfile):
    """`timestamp_tz="UTC"` round-trips: list values come back tz-aware."""
    path = make_tsfile("tz", _write_single_device)
    ds = load_dataset("tsfile", data_files=path, timestamp_tz="UTC")["train"]
    ts = ds[0]["time"][0]
    assert ts.tzinfo is not None
    # Same wall-clock as the naive case, attached to UTC.
    assert ts == datetime(2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc)


# ---------------------------------------------------------------------------
# Large-batch / multi-chunk concat
# ---------------------------------------------------------------------------


def test_large_device_with_small_batch_size(make_tsfile):
    """Force multiple Arrow batches per device → exercise the concat path."""
    path = make_tsfile("big", _write_large_device, n_points=200)
    ds = load_dataset("tsfile", data_files=path, input_batch_size=64)["train"]
    assert len(ds) == 1
    row = ds[0]
    assert len(row["time"]) == 200
    assert row["v"] == list(range(200))


# ---------------------------------------------------------------------------
# Duplicate-timestamp detection (cross-file)
# ---------------------------------------------------------------------------


def test_duplicate_timestamp_across_files_raises(make_tsfile):
    """Same device, same ts in two files → `_finalize_device` must raise."""
    cols = [
        ("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
        ("device", TSDataType.STRING, ColumnCategory.TAG),
        ("v", TSDataType.DOUBLE, ColumnCategory.FIELD),
    ]
    rows = [{"time": 5_000, "device": "d1", "v": 1.0}]

    a = make_tsfile("dupA", lambda p: _write_tsfile(p, [("mytable", cols, [rows])]))
    b = make_tsfile("dupB", lambda p: _write_tsfile(p, [("mytable", cols, [rows])]))

    with pytest.raises(Exception) as excinfo:
        load_dataset("tsfile", data_files=[a, b])
    # The ValueError is wrapped by `_prepare_split_single` into a
    # DatasetGenerationError; check the cause chain for the original message.
    chain = [excinfo.value, *(_iter_causes(excinfo.value))]
    assert any("Duplicate timestamp" in str(e) for e in chain)


def _iter_causes(exc: BaseException):
    while exc.__cause__ is not None:
        exc = exc.__cause__
        yield exc


# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------


def test_on_bad_files_skip(tmp_path, tsfile_path):
    bad = tmp_path / "broken.tsfile"
    bad.write_bytes(b"not a real tsfile")

    ds = load_dataset(
        "tsfile",
        data_files=[tsfile_path, str(bad)],
        on_bad_files="skip",
    )["train"]
    assert len(ds) == 1
    assert len(ds[0]["time"]) == 5


def test_on_bad_files_warn(tmp_path, tsfile_path, caplog):
    bad = tmp_path / "broken.tsfile"
    bad.write_bytes(b"not a real tsfile")

    with caplog.at_level(logging.WARNING, logger="datasets.packaged_modules.tsfile.tsfile"):
        ds = load_dataset(
            "tsfile",
            data_files=[tsfile_path, str(bad)],
            on_bad_files="warn",
        )["train"]
    assert len(ds) == 1
    assert any("Skipping bad file" in rec.message for rec in caplog.records)


def test_on_bad_files_default_raises(tmp_path, tsfile_path):
    bad = tmp_path / "broken.tsfile"
    bad.write_bytes(b"not a real tsfile")

    with pytest.raises(Exception) as excinfo:
        load_dataset("tsfile", data_files=[tsfile_path, str(bad)])
    chain = [excinfo.value, *(_iter_causes(excinfo.value))]
    assert any("not a valid TsFile" in str(e) for e in chain)
