#!/usr/bin/env python

# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed 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 types import SimpleNamespace
from unittest.mock import Mock, patch

import numpy as np
import pytest
import torch

pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")

import lerobot.datasets.streaming_dataset as streaming_dataset_module
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
from lerobot.datasets.streaming_dataset import StreamingLeRobotDataset
from lerobot.datasets.utils import safe_shard
from lerobot.utils.constants import ACTION
from tests.fixtures.constants import DUMMY_REPO_ID


def get_frames_expected_order(streaming_ds: StreamingLeRobotDataset) -> list[int]:
    """Replicates the shuffling logic of StreamingLeRobotDataset to get the expected order of indices."""
    rng = np.random.default_rng(streaming_ds.seed)
    buffer_size = streaming_ds.buffer_size
    num_shards = streaming_ds.num_shards

    shards_indices = []
    for shard_idx in range(num_shards):
        shard = streaming_ds.hf_dataset.shard(num_shards, index=shard_idx)
        shard_indices = [item["index"] for item in shard]
        shards_indices.append(shard_indices)

    shard_iterators = {i: iter(s) for i, s in enumerate(shards_indices)}

    buffer_indices_generator = streaming_ds._iter_random_indices(rng, buffer_size)

    frames_buffer = []
    expected_indices = []

    while shard_iterators:  # While there are still available shards
        available_shard_keys = list(shard_iterators.keys())
        if not available_shard_keys:
            break

        # Call _infinite_generator_over_elements with current available shards (key difference!)
        shard_key = next(streaming_ds._infinite_generator_over_elements(rng, available_shard_keys))

        try:
            frame_index = next(shard_iterators[shard_key])

            if len(frames_buffer) == buffer_size:
                i = next(buffer_indices_generator)
                expected_indices.append(frames_buffer[i])
                frames_buffer[i] = frame_index
            else:
                frames_buffer.append(frame_index)

        except StopIteration:
            del shard_iterators[shard_key]  # Remove exhausted shard

    rng.shuffle(frames_buffer)
    expected_indices.extend(frames_buffer)

    return expected_indices


@pytest.mark.parametrize("token", ["hf_test_token", True, False])
@pytest.mark.parametrize("from_local", [False, True])
def test_streaming_dataset_forwards_hub_token_only_for_remote_data(tmp_path, monkeypatch, token, from_local):
    requested_root = tmp_path / "local" if from_local else None
    metadata = SimpleNamespace(
        root=requested_root or tmp_path / "snapshot",
        revision=streaming_dataset_module.CODEBASE_VERSION,
        _version=streaming_dataset_module.CODEBASE_VERSION,
        features={},
        depth_keys=[],
        image_keys=[],
        rescale_depth_stats=Mock(),
    )
    metadata_cls = Mock(return_value=metadata)
    load_dataset = Mock(return_value=SimpleNamespace(num_shards=1))
    monkeypatch.setattr(streaming_dataset_module, "LeRobotDatasetMetadata", metadata_cls)
    monkeypatch.setattr(streaming_dataset_module, "load_dataset", load_dataset)

    dataset = StreamingLeRobotDataset(DUMMY_REPO_ID, root=requested_root, token=token)

    metadata_cls.assert_called_once_with(
        DUMMY_REPO_ID,
        requested_root,
        streaming_dataset_module.CODEBASE_VERSION,
        force_cache_sync=False,
        repo_type="dataset",
        token=token,
    )
    if from_local:
        assert "token" not in load_dataset.call_args.kwargs
    else:
        assert load_dataset.call_args.kwargs["token"] is token
    assert not hasattr(dataset, "_token")


def test_single_frame_consistency(tmp_path, lerobot_dataset_factory):
    """Test if are correctly accessed"""
    ds_num_frames = 400
    ds_num_episodes = 10
    buffer_size = 100

    local_path = tmp_path / "test"
    repo_id = f"{DUMMY_REPO_ID}"

    ds = lerobot_dataset_factory(
        root=local_path,
        repo_id=repo_id,
        total_episodes=ds_num_episodes,
        total_frames=ds_num_frames,
    )

    streaming_ds = iter(StreamingLeRobotDataset(repo_id=repo_id, root=local_path, buffer_size=buffer_size))

    key_checks = []
    for _ in range(ds_num_frames):
        streaming_frame = next(streaming_ds)
        frame_idx = streaming_frame["index"]
        target_frame = ds[frame_idx]

        for key in streaming_frame:
            left = streaming_frame[key]
            right = target_frame[key]

            if isinstance(left, str):
                check = left == right

            elif isinstance(left, torch.Tensor):
                check = torch.allclose(left, right) and left.shape == right.shape

            elif isinstance(left, float):
                check = left == right.item()  # right is a torch.Tensor

            key_checks.append((key, check))

        assert all(t[1] for t in key_checks), (
            f"Checking {list(filter(lambda t: not t[1], key_checks))[0][0]} left and right were found different (frame_idx: {frame_idx})"
        )


@pytest.mark.parametrize(
    "shuffle",
    [False, True],
)
def test_frames_order_over_epochs(tmp_path, lerobot_dataset_factory, shuffle):
    """Test if streamed frames correspond to shuffling operations over in-memory dataset."""
    ds_num_frames = 400
    ds_num_episodes = 10
    buffer_size = 100
    seed = 42
    n_epochs = 3

    local_path = tmp_path / "test"
    repo_id = f"{DUMMY_REPO_ID}"

    lerobot_dataset_factory(
        root=local_path,
        repo_id=repo_id,
        total_episodes=ds_num_episodes,
        total_frames=ds_num_frames,
    )

    streaming_ds = StreamingLeRobotDataset(
        repo_id=repo_id, root=local_path, buffer_size=buffer_size, seed=seed, shuffle=shuffle
    )

    first_epoch_indices = [frame["index"] for frame in streaming_ds]
    expected_indices = get_frames_expected_order(streaming_ds)

    assert first_epoch_indices == expected_indices, "First epoch indices do not match expected indices"

    expected_indices = get_frames_expected_order(streaming_ds)
    for _ in range(n_epochs):
        streaming_indices = [frame["index"] for frame in streaming_ds]
        frames_match = all(
            s_index == e_index for s_index, e_index in zip(streaming_indices, expected_indices, strict=True)
        )

        if shuffle:
            assert not frames_match
        else:
            assert frames_match


@pytest.mark.parametrize(
    "shuffle",
    [False, True],
)
def test_frames_order_with_shards(tmp_path, lerobot_dataset_factory, shuffle):
    """Test if streamed frames correspond to shuffling operations over in-memory dataset with multiple shards."""
    ds_num_frames = 100
    ds_num_episodes = 10
    buffer_size = 10

    seed = 42
    n_epochs = 3
    data_file_size_mb = 0.001

    chunks_size = 1

    local_path = tmp_path / "test"
    repo_id = f"{DUMMY_REPO_ID}-ciao"

    lerobot_dataset_factory(
        root=local_path,
        repo_id=repo_id,
        total_episodes=ds_num_episodes,
        total_frames=ds_num_frames,
        data_files_size_in_mb=data_file_size_mb,
        chunks_size=chunks_size,
    )

    streaming_ds = StreamingLeRobotDataset(
        repo_id=repo_id,
        root=local_path,
        buffer_size=buffer_size,
        seed=seed,
        shuffle=shuffle,
        max_num_shards=4,
    )

    first_epoch_indices = [frame["index"] for frame in streaming_ds]
    expected_indices = get_frames_expected_order(streaming_ds)

    assert first_epoch_indices == expected_indices, "First epoch indices do not match expected indices"

    for _ in range(n_epochs):
        streaming_indices = [
            frame["index"] for frame in streaming_ds
        ]  # NOTE: this is the same as first_epoch_indices
        frames_match = all(
            s_index == e_index for s_index, e_index in zip(streaming_indices, expected_indices, strict=True)
        )
        if shuffle:
            assert not frames_match
        else:
            assert frames_match


def test_iter_raises_on_frame_error(tmp_path, lerobot_dataset_factory, monkeypatch):
    """Video decode failures must propagate instead of being silently ignored."""
    ds_num_frames = 20
    ds_num_episodes = 2
    buffer_size = 10

    local_path = tmp_path / "test"
    repo_id = f"{DUMMY_REPO_ID}"

    lerobot_dataset_factory(
        root=local_path,
        repo_id=repo_id,
        total_episodes=ds_num_episodes,
        total_frames=ds_num_frames,
    )

    streaming_ds = StreamingLeRobotDataset(repo_id=repo_id, root=local_path, buffer_size=buffer_size)

    def broken_video_decode(*args, **kwargs):
        raise RuntimeError("Could not load libtorchcodec")

    monkeypatch.setattr(streaming_dataset_module, "decode_video_frames_torchcodec", broken_video_decode)

    with pytest.raises(RuntimeError, match="libtorchcodec"):
        next(iter(streaming_ds))


def test_iter_raises_on_nested_generator_error(tmp_path, lerobot_dataset_factory, monkeypatch):
    """PEP 479 errors below frame construction must not be mistaken for shard exhaustion."""
    local_path = tmp_path / "test"
    repo_id = DUMMY_REPO_ID

    lerobot_dataset_factory(root=local_path, repo_id=repo_id, total_episodes=2, total_frames=20)
    streaming_ds = StreamingLeRobotDataset(repo_id=repo_id, root=local_path, buffer_size=10)

    def broken_frame_generator():
        raise StopIteration("decoder internal failure")
        yield

    def broken_video_decode(*args, **kwargs):
        return next(broken_frame_generator())

    monkeypatch.setattr(streaming_dataset_module, "decode_video_frames_torchcodec", broken_video_decode)

    with pytest.raises(RuntimeError, match="generator raised StopIteration"):
        next(iter(streaming_ds))


@pytest.mark.parametrize(
    "state_deltas, action_deltas",
    [
        ([-1, -0.5, -0.20, 0], [0, 1, 2, 3]),
        ([-1, -0.5, -0.20, 0], [-1.5, -1, -0.5, -0.20, -0.10, 0]),
        ([-2, -1, -0.5, 0], [0, 1, 2, 3]),
        ([-2, -1, -0.5, 0], [-1.5, -1, -0.5, -0.20, -0.10, 0]),
    ],
)
def test_frames_with_delta_consistency(tmp_path, lerobot_dataset_factory, state_deltas, action_deltas):
    ds_num_frames = 500
    ds_num_episodes = 10
    buffer_size = 100

    seed = 42

    local_path = tmp_path / "test"
    repo_id = f"{DUMMY_REPO_ID}-ciao"
    camera_key = "phone"

    delta_timestamps = {
        camera_key: state_deltas,
        "state": state_deltas,
        ACTION: action_deltas,
    }

    ds = lerobot_dataset_factory(
        root=local_path,
        repo_id=repo_id,
        total_episodes=ds_num_episodes,
        total_frames=ds_num_frames,
        delta_timestamps=delta_timestamps,
    )

    streaming_ds = iter(
        StreamingLeRobotDataset(
            repo_id=repo_id,
            root=local_path,
            buffer_size=buffer_size,
            seed=seed,
            shuffle=False,
            delta_timestamps=delta_timestamps,
        )
    )

    for i in range(ds_num_frames):
        streaming_frame = next(streaming_ds)
        frame_idx = streaming_frame["index"]
        target_frame = ds[frame_idx]

        assert set(streaming_frame.keys()) == set(target_frame.keys()), (
            f"Keys differ between streaming frame and target one. Differ at: {set(streaming_frame.keys()) - set(target_frame.keys())}"
        )

        key_checks = []
        for key in streaming_frame:
            left = streaming_frame[key]
            right = target_frame[key]

            if isinstance(left, str):
                check = left == right

            elif isinstance(left, torch.Tensor):
                if (
                    key not in ds.meta.camera_keys
                    and "is_pad" not in key
                    and f"{key}_is_pad" in streaming_frame
                ):
                    # comparing frames only on non-padded regions. Padding is applied to last-valid broadcasting
                    left = left[~streaming_frame[f"{key}_is_pad"]]
                    right = right[~target_frame[f"{key}_is_pad"]]

                check = torch.allclose(left, right) and left.shape == right.shape

            key_checks.append((key, check))

        assert all(t[1] for t in key_checks), (
            f"Checking {list(filter(lambda t: not t[1], key_checks))[0][0]} left and right were found different (i: {i}, frame_idx: {frame_idx})"
        )


@pytest.mark.parametrize(
    "state_deltas, action_deltas",
    [
        ([-1, -0.5, -0.20, 0], [0, 1, 2, 3, 10, 20]),
        ([-1, -0.5, -0.20, 0], [-20, -1.5, -1, -0.5, -0.20, -0.10, 0]),
        ([-2, -1, -0.5, 0], [0, 1, 2, 3, 10, 20]),
        ([-2, -1, -0.5, 0], [-20, -1.5, -1, -0.5, -0.20, -0.10, 0]),
    ],
)
def test_frames_with_delta_consistency_with_shards(
    tmp_path, lerobot_dataset_factory, state_deltas, action_deltas
):
    ds_num_frames = 100
    ds_num_episodes = 10
    buffer_size = 10
    data_file_size_mb = 0.001
    chunks_size = 1

    seed = 42

    local_path = tmp_path / "test"
    repo_id = f"{DUMMY_REPO_ID}-ciao"
    camera_key = "phone"

    delta_timestamps = {
        camera_key: state_deltas,
        "state": state_deltas,
        ACTION: action_deltas,
    }

    ds = lerobot_dataset_factory(
        root=local_path,
        repo_id=repo_id,
        total_episodes=ds_num_episodes,
        total_frames=ds_num_frames,
        delta_timestamps=delta_timestamps,
        data_files_size_in_mb=data_file_size_mb,
        chunks_size=chunks_size,
    )
    streaming_ds = StreamingLeRobotDataset(
        repo_id=repo_id,
        root=local_path,
        buffer_size=buffer_size,
        seed=seed,
        shuffle=False,
        delta_timestamps=delta_timestamps,
        max_num_shards=4,
    )

    iter(streaming_ds)

    num_shards = 4
    shards_indices = []
    for shard_idx in range(num_shards):
        shard = safe_shard(streaming_ds.hf_dataset, shard_idx, num_shards)
        shard_indices = [item["index"] for item in shard]
        shards_indices.append(shard_indices)

    streaming_ds = iter(streaming_ds)

    for i in range(ds_num_frames):
        streaming_frame = next(streaming_ds)
        frame_idx = streaming_frame["index"]
        target_frame = ds[frame_idx]

        assert set(streaming_frame.keys()) == set(target_frame.keys()), (
            f"Keys differ between streaming frame and target one. Differ at: {set(streaming_frame.keys()) - set(target_frame.keys())}"
        )

        key_checks = []
        for key in streaming_frame:
            left = streaming_frame[key]
            right = target_frame[key]

            if isinstance(left, str):
                check = left == right

            elif isinstance(left, torch.Tensor):
                if (
                    key not in ds.meta.camera_keys
                    and "is_pad" not in key
                    and f"{key}_is_pad" in streaming_frame
                ):
                    # comparing frames only on non-padded regions. Padding is applied to last-valid broadcasting
                    left = left[~streaming_frame[f"{key}_is_pad"]]
                    right = right[~target_frame[f"{key}_is_pad"]]

                check = torch.allclose(left, right) and left.shape == right.shape

            elif isinstance(left, float):
                check = left == right.item()  # right is a torch.Tensor

            key_checks.append((key, check))

        assert all(t[1] for t in key_checks), (
            f"Checking {list(filter(lambda t: not t[1], key_checks))[0][0]} left and right were found different (i: {i}, frame_idx: {frame_idx})"
        )


class _StopConstructionError(Exception):
    """Sentinel raised from a patched load_dataset to halt __init__ after the branch under test."""


def _fake_meta(*args, **kwargs):
    """Minimal LeRobotDatasetMetadata stand-in exposing only what __init__ reads."""
    meta = type("_Meta", (), {})()
    root = kwargs.get("root", args[1] if len(args) > 1 else None)
    revision = kwargs.get("revision", args[2] if len(args) > 2 else None)
    meta.root = root or "/tmp/_streaming_meta"
    meta.revision = revision or "v0"
    meta._version = "v3.0"
    meta.depth_keys = []
    meta.image_keys = []
    meta.rescale_depth_stats = lambda *_a, **_k: None
    meta.repo_type = kwargs.get("repo_type", "dataset")
    return meta


@pytest.mark.parametrize(
    "repo_type, expected_source, expected_data_files",
    [
        ("bucket", "parquet", "hf://buckets/{repo_id}/data/*/*.parquet"),
        ("dataset", "{repo_id}", "data/*/*.parquet"),
    ],
)
def test_streaming_repo_type_routes_load_dataset(repo_type, expected_source, expected_data_files):
    """repo_type='bucket' loads parquet from hf://buckets/...; 'dataset' keeps the Hub-repo path."""
    captured = {}
    token = "hf_test_token"

    def fake_load_dataset(source, **kwargs):
        captured["source"] = source
        captured["data_files"] = kwargs.get("data_files")
        captured["token"] = kwargs.get("token")
        raise _StopConstructionError

    with (
        patch("lerobot.datasets.streaming_dataset.LeRobotDatasetMetadata", _fake_meta),
        patch("lerobot.datasets.streaming_dataset.check_version_compatibility", lambda *a, **k: None),
        patch("lerobot.datasets.streaming_dataset.load_dataset", fake_load_dataset),
        pytest.raises(_StopConstructionError),
    ):
        StreamingLeRobotDataset(DUMMY_REPO_ID, repo_type=repo_type, token=token)

    assert captured["source"] == expected_source.format(repo_id=DUMMY_REPO_ID)
    assert captured["data_files"] == expected_data_files.format(repo_id=DUMMY_REPO_ID)
    assert captured["token"] == token


def test_bucket_metadata_url_root(tmp_path):
    """repo_type='bucket' produces url_root pointing at hf://buckets/..."""
    with patch.object(LeRobotDatasetMetadata, "_load_metadata"):
        meta = LeRobotDatasetMetadata(
            DUMMY_REPO_ID,
            root=tmp_path,
            repo_type="bucket",
        )
    assert meta.url_root == f"hf://buckets/{DUMMY_REPO_ID}"


def test_bucket_skips_get_safe_version(tmp_path):
    """repo_type='bucket' must NOT call get_safe_version (buckets have no git refs).

    The first ``_load_metadata()`` raises ``FileNotFoundError`` so ``__init__``
    enters the except branch where the ``repo_type != "bucket"`` guard and
    ``get_safe_version`` live; the second call (after the bucket meta pull)
    succeeds. ``_pull_from_repo`` is stubbed so the except path runs without a
    network call. Without the raise, the try block would succeed and the guard
    branch would never execute, so the assertion would pass vacuously.
    """
    with (
        patch("lerobot.datasets.dataset_metadata.get_safe_version") as mock_gsv,
        patch.object(
            LeRobotDatasetMetadata,
            "_load_metadata",
            side_effect=[FileNotFoundError, None],
        ),
        patch.object(LeRobotDatasetMetadata, "_pull_from_repo") as mock_pull,
    ):
        LeRobotDatasetMetadata(
            DUMMY_REPO_ID,
            root=tmp_path,
            repo_type="bucket",
        )
    # The except branch ran (proven by the pull), but the bucket guard skipped
    # version resolution.
    mock_pull.assert_called_once()
    mock_gsv.assert_not_called()


def test_bucket_metadata_sync_uses_stable_cache_and_token(tmp_path):
    hub_cache = tmp_path / "hub"
    token = "hf_test_token"
    expected_root = hub_cache / f"buckets--{DUMMY_REPO_ID.replace('/', '--')}"

    with (
        patch("lerobot.datasets.dataset_metadata.HF_LEROBOT_HUB_CACHE", hub_cache),
        patch("lerobot.datasets.dataset_metadata.sync_bucket") as mock_sync,
        patch.object(
            LeRobotDatasetMetadata,
            "_load_metadata",
            side_effect=[FileNotFoundError, None],
        ),
    ):
        meta = LeRobotDatasetMetadata(DUMMY_REPO_ID, repo_type="bucket", token=token)

    assert meta.root == expected_root
    mock_sync.assert_called_once_with(
        f"hf://buckets/{DUMMY_REPO_ID}/meta",
        str(expected_root / "meta"),
        delete=True,
        quiet=True,
        token=token,
    )

    with (
        patch("lerobot.datasets.dataset_metadata.HF_LEROBOT_HUB_CACHE", hub_cache),
        patch("lerobot.datasets.dataset_metadata.sync_bucket") as mock_sync,
        patch.object(LeRobotDatasetMetadata, "_load_metadata"),
    ):
        cached_meta = LeRobotDatasetMetadata(DUMMY_REPO_ID, repo_type="bucket", token=token)

    assert cached_meta.root == expected_root
    mock_sync.assert_not_called()


def test_repo_type_is_keyword_only_and_preserves_positional_episodes():
    episodes = [1, 2]
    with (
        patch("lerobot.datasets.streaming_dataset.LeRobotDatasetMetadata", _fake_meta),
        patch("lerobot.datasets.streaming_dataset.check_version_compatibility"),
        patch(
            "lerobot.datasets.streaming_dataset.load_dataset",
            return_value=SimpleNamespace(num_shards=1),
        ),
    ):
        dataset = StreamingLeRobotDataset(DUMMY_REPO_ID, None, episodes)

    assert dataset.episodes == episodes
    assert dataset.repo_type == "dataset"


def test_bucket_root_caches_metadata_without_switching_to_local_streaming(tmp_path):
    with (
        patch("lerobot.datasets.streaming_dataset.LeRobotDatasetMetadata", _fake_meta),
        patch("lerobot.datasets.streaming_dataset.check_version_compatibility"),
        patch(
            "lerobot.datasets.streaming_dataset.load_dataset",
            return_value=SimpleNamespace(num_shards=1),
        ) as mock_load_dataset,
    ):
        dataset = StreamingLeRobotDataset(DUMMY_REPO_ID, root=tmp_path, repo_type="bucket")

    assert dataset.root == tmp_path
    assert not dataset.streaming_from_local
    assert mock_load_dataset.call_args.args == ("parquet",)
    assert mock_load_dataset.call_args.kwargs["data_files"].startswith("hf://buckets/")


def test_invalid_repo_type_fails_before_io():
    with pytest.raises(ValueError, match="repo_type must be 'dataset' or 'bucket'"):
        StreamingLeRobotDataset(DUMMY_REPO_ID, repo_type="space")
