#!/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 pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import MagicMock

import numpy as np
import pytest
import torch

from lerobot.common import train_utils
from lerobot.common.train_utils import (
    get_step_checkpoint_dir,
    get_step_identifier,
    load_training_metadata,
    push_checkpoint_to_hub,
    save_training_metadata,
    save_training_state,
    should_save_checkpoint,
    update_last_checkpoint,
)
from lerobot.configs.default import DatasetConfig
from lerobot.configs.train import TrainPipelineConfig
from lerobot.utils.constants import (
    CHECKPOINTS_DIR,
    LAST_CHECKPOINT_LINK,
    OPTIMIZER_PARAM_GROUPS,
    OPTIMIZER_STATE,
    RNG_STATE,
    SCHEDULER_STATE,
    TRAINING_STATE_DIR,
    TRAINING_STEP,
)
from lerobot.utils.import_utils import _datasets_available

if TYPE_CHECKING or _datasets_available:
    from lerobot.scripts.lerobot_train import make_dataloaders


def test_get_step_identifier():
    assert get_step_identifier(5, 1000) == "000005"
    assert get_step_identifier(123, 100_000) == "000123"
    assert get_step_identifier(456789, 1_000_000) == "0456789"


def test_should_save_checkpoint():
    # Periodic checkpoints land on multiples of save_freq.
    assert should_save_checkpoint(10, save_freq=10, total_steps=100) is True
    assert should_save_checkpoint(5, save_freq=10, total_steps=100) is False
    # The final step always saves, even when it is not a multiple of save_freq.
    assert should_save_checkpoint(100, save_freq=30, total_steps=100) is True
    # save_freq <= 0 disables periodic saving without raising ZeroDivisionError.
    assert should_save_checkpoint(1, save_freq=0, total_steps=100) is False
    assert should_save_checkpoint(100, save_freq=0, total_steps=100) is True
    assert should_save_checkpoint(1, save_freq=-1, total_steps=100) is False


def test_get_step_checkpoint_dir():
    output_dir = Path("/checkpoints")
    step_dir = get_step_checkpoint_dir(output_dir, 1000, 5)
    assert step_dir == output_dir / CHECKPOINTS_DIR / "000005"


def make_cfg(batch_size: int = 32) -> TrainPipelineConfig:
    cfg = TrainPipelineConfig(dataset=DatasetConfig(repo_id="lerobot/dummy"), batch_size=batch_size)
    cfg.parallelism.resolve(1)
    return cfg


def test_save_training_metadata_writes_the_step_file(tmp_path):
    save_training_metadata(5000, tmp_path, make_cfg())
    assert (tmp_path / TRAINING_STEP).is_file()


def test_save_training_state_records_topology(tmp_path, optimizer, scheduler):
    save_training_state(tmp_path, 10, make_cfg(batch_size=32), optimizer, scheduler)
    metadata = load_training_metadata(tmp_path / TRAINING_STATE_DIR)
    assert metadata["step"] == 10
    assert metadata["dp_world_size"] == 1
    assert metadata["batch_size"] == 32


def test_update_last_checkpoint(tmp_path):
    checkpoint = tmp_path / "0005"
    checkpoint.mkdir()
    update_last_checkpoint(checkpoint)
    last_checkpoint = tmp_path / LAST_CHECKPOINT_LINK
    assert last_checkpoint.is_symlink()
    assert last_checkpoint.resolve() == checkpoint


# save_checkpoint round-trips (all formats, real policies) live in
# tests/common/test_checkpoint_save_resume.py.


def test_save_training_state_layout(tmp_path, optimizer, scheduler):
    save_training_state(tmp_path, 10, make_cfg(), optimizer, scheduler)
    assert (tmp_path / TRAINING_STATE_DIR).is_dir()
    assert (tmp_path / TRAINING_STATE_DIR / TRAINING_STEP).is_file()
    assert (tmp_path / TRAINING_STATE_DIR / RNG_STATE).is_file()
    assert (tmp_path / TRAINING_STATE_DIR / OPTIMIZER_STATE).is_file()
    assert (tmp_path / TRAINING_STATE_DIR / OPTIMIZER_PARAM_GROUPS).is_file()
    assert (tmp_path / TRAINING_STATE_DIR / SCHEDULER_STATE).is_file()


# The two-phase resume (resume_before_prepare / resume_after_prepare) is covered in
# tests/common/test_checkpoint_save_resume.py with real policies and optimizer state.


def test_push_checkpoint_to_hub_creates_repo_and_uploads(tmp_path, monkeypatch):
    ckpt = tmp_path / "010000"
    (ckpt / "pretrained_model").mkdir(parents=True)
    api = MagicMock()
    monkeypatch.setattr("lerobot.common.train_utils.HfApi", lambda *a, **k: api)
    push_checkpoint_to_hub(ckpt, "user/run", private=True)
    api.create_repo.assert_called_once()
    assert api.create_repo.call_args.kwargs["private"] is True
    assert api.create_repo.call_args.kwargs["repo_type"] == "model"
    api.upload_folder.assert_called_once()
    kwargs = api.upload_folder.call_args.kwargs
    assert kwargs["repo_id"] == "user/run"
    assert kwargs["repo_type"] == "model"
    assert kwargs["path_in_repo"] == "checkpoints/010000"
    assert kwargs["folder_path"] == str(ckpt)
    assert kwargs["commit_message"] == "checkpoint 010000"
    # A tag named after the checkpoint step is created so the checkpoint can be
    # recovered with --policy.pretrained_revision instead of a commit sha.
    api.create_tag.assert_called_once()
    tag_kwargs = api.create_tag.call_args.kwargs
    assert tag_kwargs["tag"] == "010000"
    assert tag_kwargs["revision"] == api.upload_folder.return_value.oid
    assert tag_kwargs["repo_type"] == "model"
    assert tag_kwargs["exist_ok"] is True


def test_push_checkpoint_to_hub_defaults_to_hub_default_visibility(tmp_path, monkeypatch):
    ckpt = tmp_path / "010000"
    (ckpt / "pretrained_model").mkdir(parents=True)
    api = MagicMock()
    monkeypatch.setattr("lerobot.common.train_utils.HfApi", lambda *a, **k: api)
    push_checkpoint_to_hub(ckpt, "user/run")
    api.create_repo.assert_called_once()
    assert api.create_repo.call_args.kwargs["private"] is None


def test_resolve_resume_checkpoint_downloads_latest_and_links(tmp_path, monkeypatch):
    out = tmp_path / "run"

    def fake_snapshot_download(repo_id, repo_type, allow_patterns, local_dir):
        # Mimic the Hub layout the real download materializes locally.
        assert allow_patterns == "checkpoints/020000/*"
        (Path(local_dir) / "checkpoints" / "020000" / "pretrained_model").mkdir(parents=True)
        return local_dir

    monkeypatch.setattr("lerobot.common.train_utils.snapshot_download", fake_snapshot_download)
    monkeypatch.setattr(
        "lerobot.common.train_utils.find_latest_hub_checkpoint", lambda repo_id: "checkpoints/020000"
    )

    checkpoint_dir = train_utils.resolve_resume_checkpoint("u/run", out)

    assert checkpoint_dir == out / CHECKPOINTS_DIR / "020000"
    last = out / CHECKPOINTS_DIR / LAST_CHECKPOINT_LINK
    assert last.is_symlink()
    # `last` points at the downloaded step dir.
    assert (last.parent / last.readlink()).resolve() == checkpoint_dir.resolve()


def test_resolve_resume_checkpoint_raises_without_checkpoints(tmp_path, monkeypatch):
    monkeypatch.setattr("lerobot.common.train_utils.find_latest_hub_checkpoint", lambda repo_id: None)
    with pytest.raises(FileNotFoundError, match="No checkpoint"):
        train_utils.resolve_resume_checkpoint("u/run", tmp_path / "run")


@pytest.mark.skipif(not _datasets_available, reason="requires datasets")
@pytest.mark.parametrize("frames,drop_first,drop_last,max_eval_samples", [(12, 2, 3, 3), (64, 0, 32, 0)])
def test_dataloaders_filter_boundaries_without_consuming_policy_rng(
    frames, drop_first, drop_last, max_eval_samples
):
    policy = SimpleNamespace(drop_n_first_frames=drop_first, drop_n_last_frames=drop_last)
    expected = list(range(drop_first, frames - drop_last))

    class Dataset(torch.utils.data.Dataset):
        episodes = [0]
        absolute_to_relative_idx = None
        meta = SimpleNamespace(
            episodes={"dataset_from_index": [0], "dataset_to_index": [frames]},
            has_language_columns=False,
        )
        hf_dataset = SimpleNamespace(
            data=SimpleNamespace(column=lambda name: SimpleNamespace(to_numpy=lambda: np.zeros(frames)))
        )

        def __len__(self):
            return frames

        def __getitem__(self, index):
            return index

    cfg = SimpleNamespace(
        trainable_config=policy,
        dataset=SimpleNamespace(streaming=False),
        resume=False,
        seed=42,
        num_workers=0,
        batch_size=2,
        prefetch_factor=None,
        persistent_workers=False,
        dataloader_multiprocessing_context=None,
        max_eval_samples=max_eval_samples,
    )
    train, evaluation = make_dataloaders(cfg, Dataset(), Dataset(), 0, SimpleNamespace(device_type="cpu"))
    assert sorted(train.sampler) == expected
    rng = torch.get_rng_state().clone()
    next(iter(train))
    assert torch.equal(rng, torch.get_rng_state())
    assert torch.cat(list(evaluation)).tolist() == (
        expected[:max_eval_samples] if max_eval_samples else expected
    )
    assert torch.equal(rng, torch.get_rng_state())
