"""MultipackBatchSampler — First-Fit-Decreasing bin packing for SFT/pretrain.

Ports Axolotl's ``MultipackBatchSampler`` (utils/samplers/multipack.py:24-57)
without the numba runtime dependency. The pure-Python FFD here is fast enough
for typical dataset sizes (<1M samples); a future ``[multipack]`` extras can
add an optional numba JIT path keyed off the same ``ffd_bin_pack`` signature.

Compared with Axolotl, two intentional differences:

1. **Loud-fail on unknown architecture.** Axolotl's monkey-patch silently
   misses architectures absent from its allowlist
   (`monkeypatch/multipack.py:13-66`). Soup raises ``ValueError`` so the user
   knows multipack is not active. Mirrors the v0.33.0 #43 schema-gate policy.
2. **bool rejection on numeric inputs.** Mirrors v0.30.0 ``Candidate``
   policy — bool is a subclass of int and must not silently coerce.
"""

from __future__ import annotations

import random
from collections.abc import Iterator, Sequence
from typing import Union

# Allow-list of HF model architectures that support the FA varlen path
# (``_get_unpad_data`` monkey-patch). Mirrors Axolotl's list plus the v0.31.0
# Soup recipe expansion. Keep frozenset to prevent runtime mutation.
MULTIPACK_ARCHITECTURES: frozenset[str] = frozenset({
    "LlamaForCausalLM",
    "MistralForCausalLM",
    "MixtralForCausalLM",
    "QwenForCausalLM",
    "Qwen2ForCausalLM",
    "Qwen3ForCausalLM",
    "Qwen2MoeForCausalLM",
    "GemmaForCausalLM",
    "Gemma2ForCausalLM",
    "Gemma3ForCausalLM",
    "PhiForCausalLM",
    "Phi3ForCausalLM",
    "Phi4ForCausalLM",
    "DeepseekV2ForCausalLM",
    "DeepseekV3ForCausalLM",
    "FalconForCausalLM",
    "StableLmForCausalLM",
    "SmolLM2ForCausalLM",
    # v0.51.0 Part D — new model families from the catalog expansion.
    "GraniteForCausalLM",
    "GraniteMoeForCausalLM",
    "Glm4ForCausalLM",
    "Glm5ForCausalLM",
    "KimiForCausalLM",
    "MiniMaxForCausalLM",
    "QwQForCausalLM",
    "QVQForCausalLM",
    "GptOssForCausalLM",
    "MagistralForCausalLM",
    "DevstralForCausalLM",
    "MinistralForCausalLM",
    "MedGemmaForCausalLM",
    "Lfm2ForCausalLM",
    "CogitoForCausalLM",
    "HunyuanForCausalLM",
    "ErnieForCausalLM",
    "YiForCausalLM",
    "BaichuanForCausalLM",
    "ChatGLMForConditionalGeneration",
})


# Worst-case FFD complexity is O(N^2). Cap N to prevent a crafted dataset
# from pinning a CPU. 1M samples is ~3 orders of magnitude beyond typical
# fine-tuning workloads.
_MAX_FFD_ITEMS: int = 1_000_000


def _check_int(name: str, value: object) -> int:
    """Reject bool, non-int, and return as int. Mirrors v0.30.0 policy."""
    if isinstance(value, bool):
        raise TypeError(f"{name} must not be bool, got {value!r}")
    if not isinstance(value, int):
        raise TypeError(f"{name} must be int, got {type(value).__name__}")
    return value


def ffd_bin_pack(lengths: Sequence[int], max_len: int) -> list[list[int]]:
    """First-Fit-Decreasing bin packing.

    Args:
        lengths: per-sample sequence lengths.
        max_len: maximum sum of lengths in any bin.

    Returns:
        A list of bins; each bin is a list of original indices into
        ``lengths``. Every index appears exactly once.

    Raises:
        ValueError: if any length is non-positive, exceeds ``max_len``,
            or if ``max_len`` is non-positive.
        TypeError: if ``max_len`` is bool.
    """
    _check_int("max_len", max_len)
    if max_len <= 0:
        raise ValueError(f"max_len must be positive, got {max_len}")

    # Materialise once — protects against a generator being exhausted by the
    # validation pass, which would silently produce empty bins on the sort.
    lengths = list(lengths)
    if not lengths:
        return []
    if len(lengths) > _MAX_FFD_ITEMS:
        raise ValueError(
            f"too many items for FFD bin-packing: {len(lengths)} > "
            f"{_MAX_FFD_ITEMS} (algorithm is O(N^2) worst-case)"
        )

    # Validate each length up-front so we fail loudly before packing.
    for idx, length in enumerate(lengths):
        _check_int(f"lengths[{idx}]", length)
        if length <= 0:
            raise ValueError(
                f"lengths[{idx}] must be positive, got {length}"
            )
        if length > max_len:
            raise ValueError(
                f"lengths[{idx}]={length} exceeds max_len={max_len}"
            )

    # Pair each length with its original index, then sort by length descending.
    indexed = sorted(
        enumerate(lengths), key=lambda pair: pair[1], reverse=True,
    )

    bins: list[list[int]] = []
    bin_remaining: list[int] = []  # parallel to bins: free space in each
    for orig_idx, length in indexed:
        placed = False
        for bin_idx, remaining in enumerate(bin_remaining):
            if length <= remaining:
                bins[bin_idx].append(orig_idx)
                bin_remaining[bin_idx] = remaining - length
                placed = True
                break
        if not placed:
            bins.append([orig_idx])
            bin_remaining.append(max_len - length)

    return bins


def validate_multipack_architecture(arch_name: str) -> None:
    """Raise ``ValueError`` if ``arch_name`` is not multipack-supported.

    Loud-fail policy (vs Axolotl's silent miss) — see module docstring.
    """
    if not isinstance(arch_name, str):
        raise TypeError(
            f"arch_name must be str, got {type(arch_name).__name__}"
        )
    if not arch_name:
        raise ValueError("arch_name must be non-empty")
    if "\x00" in arch_name:
        raise ValueError("arch_name must not contain null bytes")
    if arch_name not in MULTIPACK_ARCHITECTURES:
        supported = ", ".join(sorted(MULTIPACK_ARCHITECTURES))
        raise ValueError(
            f"Architecture {arch_name!r} not in multipack allowlist. "
            f"Either add it to MULTIPACK_ARCHITECTURES or set "
            f"multipack: false in your config. Supported: {supported}"
        )


# Type alias — a real_batches=False yield is a flat list of indices,
# real_batches=True is a list of bins (each bin a list of indices).
BatchType = Union[list[int], list[list[int]]]


class MultipackBatchSampler:
    """Yield batches of indices packed via FFD into bins of ``batch_max_len``.

    Two modes:

    * ``real_batches=False`` — each yielded value is a flat list of indices
      (one bin), to be flattened by the collator into a single packed
      sequence. Axolotl's ``multipack_real_batches=False`` trick.
    * ``real_batches=True`` — each yielded value is a list of bins
      (length ``<= batch_size``), where each bin is a list of indices.
      The collator stacks bins along the batch dimension.

    Determinism: a fixed ``seed`` produces a stable batch order across runs.
    """

    def __init__(
        self,
        lengths: Sequence[int],
        batch_max_len: int,
        batch_size: int,
        real_batches: bool = True,
        seed: int = 0,
        drop_last: bool = False,
    ) -> None:
        batch_max_len = _check_int("batch_max_len", batch_max_len)
        batch_size = _check_int("batch_size", batch_size)
        seed = _check_int("seed", seed)
        if batch_max_len <= 0:
            raise ValueError(
                f"batch_max_len must be positive, got {batch_max_len}"
            )
        if batch_size <= 0:
            raise ValueError(
                f"batch_size must be positive, got {batch_size}"
            )
        if not lengths:
            raise ValueError("lengths must be non-empty")

        # Pack once at construction so __len__ is exact and iteration is
        # deterministic. Pre-pack validation in ffd_bin_pack catches
        # over-length items.
        self._lengths: list[int] = list(lengths)
        self._batch_max_len = batch_max_len
        self._batch_size = batch_size
        self._real_batches = bool(real_batches)
        self._seed = seed
        self._drop_last = bool(drop_last)

        bins = ffd_bin_pack(self._lengths, batch_max_len)
        # Shuffle the packed bins deterministically — keeps inter-bin order
        # randomised across epochs while preserving the FFD packing.
        rng = random.Random(seed)
        rng.shuffle(bins)
        self._bins: list[list[int]] = bins

    def __len__(self) -> int:
        if not self._real_batches:
            return len(self._bins)
        full = len(self._bins) // self._batch_size
        remainder = len(self._bins) % self._batch_size
        if self._drop_last or remainder == 0:
            return full
        return full + 1

    def __iter__(self) -> Iterator[BatchType]:
        if not self._real_batches:
            for bin_ in self._bins:
                yield list(bin_)
            return

        batch: list[list[int]] = []
        for bin_ in self._bins:
            batch.append(list(bin_))
            if len(batch) == self._batch_size:
                yield batch
                batch = []
        if batch and not self._drop_last:
            yield batch
