#!/usr/bin/env python

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

"""Compute per-frame TOPReward progress curves for a LeRobot dataset.

For each episode, scores trajectory prefixes of increasing length using
the TOPReward reward model, min-max normalises the raw log-prob rewards per episode,
and writes a parquet file with one row per frame.

The parquet uses the same schema as SARM's :mod:`lerobot.rewards.sarm.compute_rabc_weights`.

Usage:
    # Sparse-dense mode (15 anchors per episode, matches upstream)
    python -m lerobot.rewards.topreward.compute_rabc_weights \\
        --dataset-repo-id lerobot/libero_10_image \\
        --num-samples 15

    # Use a different VLM backbone
    python -m lerobot.rewards.topreward.compute_rabc_weights \\
        --dataset-repo-id lerobot/libero_10_image \\
        --vlm-name Qwen/Qwen3-VL-4B-Instruct
"""

from __future__ import annotations

import argparse
import logging
from pathlib import Path
from typing import Any

import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import torch
from tqdm import tqdm

from lerobot.datasets import LeRobotDataset
from lerobot.lerobot_types import TransitionKey
from lerobot.rewards.topreward.configuration_topreward import TOPRewardConfig
from lerobot.rewards.topreward.modeling_topreward import TOPRewardModel
from lerobot.rewards.topreward.processor_topreward import TOPRewardEncoderProcessorStep

DEFAULT_OUTPUT_FILENAME = "topreward_progress.parquet"


def get_reward_model_path_from_parquet(parquet_path: Path) -> str | None:
    """Read ``reward_model_path`` from parquet metadata if available."""
    if not parquet_path.exists():
        return None
    try:
        metadata = pq.read_metadata(parquet_path).schema.to_arrow_schema().metadata
        if metadata and b"reward_model_path" in metadata:
            return metadata[b"reward_model_path"].decode()
    except Exception:  # nosec B110
        return None
    return None


def _resolve_task(sample: dict[str, Any], default: str) -> str:
    """Best-effort task extraction from a dataset sample."""
    task = sample.get("task")
    if isinstance(task, str) and task:
        return task
    return default


def normalize_rewards(rewards: list[float] | np.ndarray) -> np.ndarray:
    """Min-max normalise raw log-prob rewards into ``[0, 1]``."""
    rewards_arr = np.asarray(rewards, dtype=np.float64)
    if rewards_arr.size == 0:
        return rewards_arr.astype(np.float32)
    if rewards_arr.size == 1:
        return np.array([1.0], dtype=np.float32)
    r_min, r_max = rewards_arr.min(), rewards_arr.max()
    if r_max == r_min:
        return np.ones_like(rewards_arr, dtype=np.float32)
    return ((rewards_arr - r_min) / (r_max - r_min)).astype(np.float32)


def compute_instruction_rewards_for_prefixes(
    model: TOPRewardModel,
    encoder: TOPRewardEncoderProcessorStep,
    dataset: LeRobotDataset,
    ep_start: int,
    num_frames: int,
    task: str,
    image_key: str,
    num_samples: int | None,
    device: str,
) -> np.ndarray:
    """Score an episode via prefix sweep and return a per-frame normalised curve."""
    if num_samples is None or num_samples >= num_frames:
        prefix_lengths = np.arange(1, num_frames + 1, dtype=np.int64)
    else:
        prefix_lengths = np.unique(np.linspace(1, num_frames, num_samples).round().astype(np.int64))

    episode_frames = torch.stack([dataset[ep_start + i][image_key] for i in range(num_frames)])
    rewards: list[float] = []
    for length in prefix_lengths:
        frames = episode_frames[: int(length)].unsqueeze(0)  # (1, T, C, H, W)

        transition = {
            TransitionKey.OBSERVATION: {image_key: frames},
            TransitionKey.COMPLEMENTARY_DATA: {"task": task},
        }
        encoded = encoder(transition)
        obs = encoded[TransitionKey.OBSERVATION]
        batch = {
            key: value.to(device) if isinstance(value, torch.Tensor) else value for key, value in obs.items()
        }

        with torch.no_grad():
            reward = model.compute_reward(batch)
        rewards.append(float(reward.item()))

    normalized_rewards = normalize_rewards(rewards)

    if prefix_lengths.shape[0] == num_frames:
        return normalized_rewards

    return np.interp(
        np.arange(1, num_frames + 1, dtype=np.float64),
        prefix_lengths.astype(np.float64),
        normalized_rewards.astype(np.float64),
    ).astype(np.float32)


def compute_topreward_progress(
    dataset_repo_id: str,
    reward_model_path: str | None = None,
    vlm_name: str | None = None,
    output_path: str | None = None,
    device: str = "cuda",
    num_samples: int | None = None,
    fps: float | None = None,
    episodes: list[int] | None = None,
) -> Path:
    """Run TOPReward over a dataset and write per-frame progress."""
    if reward_model_path is not None:
        logging.info(f"Loading TOPReward config from: {reward_model_path}")
        model = TOPRewardModel.from_pretrained(reward_model_path)
        config = model.config
        config.device = device
        if vlm_name is not None and vlm_name != config.vlm_name:
            logging.info(f"Overriding vlm_name from config: {config.vlm_name} -> {vlm_name}")
            config.vlm_name = vlm_name
            model = TOPRewardModel(config)
    else:
        config_kwargs: dict[str, Any] = {"device": device}
        if vlm_name is not None:
            config_kwargs["vlm_name"] = vlm_name
        if fps is not None:
            config_kwargs["fps"] = fps
        config = TOPRewardConfig(**config_kwargs)
        logging.info(f"Constructing TOPReward with VLM: {config.vlm_name}")
        model = TOPRewardModel(config)

    model.to(device).eval()

    encoder = TOPRewardEncoderProcessorStep(
        vlm_name=config.vlm_name,
        image_key=config.image_key,
        task_key=config.task_key,
        default_task=config.default_task,
        max_frames=None,  # no tail-crop: we control prefix length explicitly
        fps=config.fps,
        prompt_prefix=config.prompt_prefix,
        prompt_suffix_template=config.prompt_suffix_template,
        add_chat_template=config.add_chat_template,
        max_length=config.max_input_length,
    )

    image_key = config.image_key

    logging.info(f"Loading dataset: {dataset_repo_id}")
    dataset = LeRobotDataset(dataset_repo_id, download_videos=True)
    logging.info(f"Dataset: {dataset.num_episodes} episodes, {dataset.num_frames} frames")

    episode_indices = list(range(dataset.num_episodes)) if episodes is None else episodes
    logging.info(f"Processing {len(episode_indices)} episode(s)")

    all_index: list[int] = []
    all_episode: list[int] = []
    all_frame: list[int] = []
    all_progress: list[float] = []

    for episode_idx in tqdm(episode_indices, desc="Episodes"):
        ep = dataset.meta.episodes[episode_idx]
        ep_start = int(ep["dataset_from_index"])
        ep_end = int(ep["dataset_to_index"])
        num_frames = ep_end - ep_start
        if num_frames <= 0:
            continue

        first_sample = dataset[ep_start]
        task = _resolve_task(first_sample, default=config.default_task or "perform the task")

        per_frame = compute_instruction_rewards_for_prefixes(
            model=model,
            encoder=encoder,
            dataset=dataset,
            ep_start=ep_start,
            num_frames=num_frames,
            task=task,
            image_key=image_key,
            num_samples=num_samples,
            device=device,
        )

        for local in range(num_frames):
            all_index.append(ep_start + local)
            all_episode.append(episode_idx)
            all_frame.append(local)
            all_progress.append(float(per_frame[local]))

        if device.startswith("cuda"):
            torch.cuda.empty_cache()

    table = pa.table(
        {
            "index": np.asarray(all_index, dtype=np.int64),
            "episode_index": np.asarray(all_episode, dtype=np.int64),
            "frame_index": np.asarray(all_frame, dtype=np.int64),
            "progress_sparse": np.asarray(all_progress, dtype=np.float32),
        }
    )

    schema_metadata: dict[bytes, bytes] = {b"vlm_name": config.vlm_name.encode()}
    if reward_model_path is not None:
        schema_metadata[b"reward_model_path"] = reward_model_path.encode()
    table = table.replace_schema_metadata(schema_metadata)

    out = Path(dataset.root) / DEFAULT_OUTPUT_FILENAME if output_path is None else Path(output_path)
    out.parent.mkdir(parents=True, exist_ok=True)
    pq.write_table(table, out)
    logging.info(f"Saved {len(table)} frame values to {out}")

    progress_arr = np.asarray(all_progress, dtype=np.float32)
    if progress_arr.size:
        logging.info(
            f"Progress: mean={float(progress_arr.mean()):.4f}, "
            f"std={float(progress_arr.std()):.4f}, "
            f"min={float(progress_arr.min()):.4f}, "
            f"max={float(progress_arr.max()):.4f}"
        )
    return out


def main():
    parser = argparse.ArgumentParser(
        description="Compute per-frame TOPReward progress curves for RA-BC weighting.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
    # Sparse-dense mode (matches upstream TOPReward num_samples=15)
    python -m lerobot.rewards.topreward.compute_rabc_weights \\
        --dataset-repo-id lerobot/libero_10_image \\
        --num-samples 15

    # Use a smaller VLM
    python -m lerobot.rewards.topreward.compute_rabc_weights \\
        --dataset-repo-id lerobot/libero_10_image \\
        --vlm-name Qwen/Qwen3-VL-4B-Instruct
        """,
    )
    parser.add_argument(
        "--dataset-repo-id", type=str, required=True, help="HuggingFace dataset repo id or local path."
    )
    parser.add_argument(
        "--reward-model-path", type=str, default=None, help="Optional TOPReward LeRobot config."
    )
    parser.add_argument("--vlm-name", type=str, default=None, help="Override the VLM backbone (HF Hub id).")
    parser.add_argument("--output-path", type=str, default=None, help="Output parquet path.")
    parser.add_argument("--device", type=str, default="cuda", help="Device to use (default: cuda).")
    parser.add_argument(
        "--num-samples",
        type=int,
        default=None,
        help="Anchor prefix samples per episode. None = dense. 15 matches upstream.",
    )
    parser.add_argument(
        "--episodes",
        type=int,
        nargs="+",
        default=None,
        help="Process only these episode indices (e.g. --episodes 0 or --episodes 0 5 10).",
    )
    parser.add_argument("--fps", type=float, default=None, help="Override TOPRewardConfig.fps.")
    parser.add_argument(
        "--push-to-hub", action="store_true", help="Upload to the dataset repo on HuggingFace Hub."
    )

    args = parser.parse_args()

    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")

    output_path = compute_topreward_progress(
        dataset_repo_id=args.dataset_repo_id,
        reward_model_path=args.reward_model_path,
        vlm_name=args.vlm_name,
        output_path=args.output_path,
        device=args.device,
        num_samples=args.num_samples,
        fps=args.fps,
        episodes=args.episodes,
    )

    print(f"\nTOPReward progress saved to: {output_path}")

    if args.push_to_hub:
        from huggingface_hub import HfApi

        api = HfApi()
        hub_path = DEFAULT_OUTPUT_FILENAME

        print(f"\nUploading to Hub: {args.dataset_repo_id}/{hub_path}")
        api.upload_file(
            path_or_fileobj=str(output_path),
            path_in_repo=hub_path,
            repo_id=args.dataset_repo_id,
            repo_type="dataset",
        )
        print(
            "Successfully uploaded to: "
            f"https://huggingface.co/datasets/{args.dataset_repo_id}/blob/main/{hub_path}"
        )

        print("\nTo use in training, add to your config:")
        print("  use_rabc: true")
        print(f"  rabc_progress_path: hf://datasets/{args.dataset_repo_id}/{hub_path}")
        print("  rabc_head_mode: sparse")
    else:
        print("\nTo use in training, add to your config:")
        print("  use_rabc: true")
        print(f"  rabc_progress_path: {output_path}")
        print("  rabc_head_mode: sparse")


if __name__ == "__main__":
    main()
