"""Tests for v0.28.0 — Training Speed & Memory features.

Covers:
- Part A: Cut Cross-Entropy (CCE)
- Part B: FP8 training (quantization_aware='fp8')
- Part C: Gradient checkpointing tiers (selective/medium/full/auto)
- Part D: Kernel auto-composition (utils/kernel_picker.py)
- Part E: Cross-document attention masking for sample packing
- Part F: Activation offloading to CPU/disk
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest
from pydantic import ValidationError

from soup_cli.config.schema import SoupConfig, TrainingConfig

# ─── Part A: Cut Cross-Entropy (CCE) ───────────────────────────────────────


class TestCutCEConfig:
    """TrainingConfig.use_cut_ce boolean field."""

    def test_use_cut_ce_default_false(self):
        cfg = SoupConfig(base="test/model", data={"train": "./data.jsonl"})
        assert cfg.training.use_cut_ce is False

    def test_use_cut_ce_enabled(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"use_cut_ce": True},
        )
        assert cfg.training.use_cut_ce is True

    def test_use_cut_ce_type_bool_coerce(self):
        """Pydantic coerces bool-like strings; verify type is bool after."""
        cfg = TrainingConfig(use_cut_ce=True)
        assert cfg.use_cut_ce is True
        assert isinstance(cfg.use_cut_ce, bool)


class TestCutCEAvailability:
    """Cut Cross-Entropy availability + detection."""

    def test_check_cut_ce_available_returns_bool(self):
        from soup_cli.utils.cut_ce import check_cut_ce_available

        result = check_cut_ce_available()
        assert isinstance(result, bool)

    def test_check_cut_ce_not_installed(self):
        from soup_cli.utils.cut_ce import check_cut_ce_available

        # sys.modules[name]=None makes ``import name`` raise ImportError
        with patch.dict("sys.modules", {"cut_cross_entropy": None}):
            assert check_cut_ce_available() is False

    def test_get_cut_ce_version_not_installed(self):
        from soup_cli.utils.cut_ce import get_cut_ce_version

        with patch(
            "soup_cli.utils.cut_ce.check_cut_ce_available", return_value=False
        ):
            result = get_cut_ce_version()
            assert result is None


class TestCutCEApplication:
    """Applying Cut Cross-Entropy to a model."""

    def test_apply_cut_ce_not_installed(self):
        from soup_cli.utils.cut_ce import apply_cut_ce

        with patch(
            "soup_cli.utils.cut_ce.check_cut_ce_available", return_value=False
        ):
            result = apply_cut_ce("meta-llama/Llama-3.1-8B")
            assert result is False

    def test_apply_cut_ce_available_tries_patching(self):
        from soup_cli.utils.cut_ce import apply_cut_ce

        with patch(
            "soup_cli.utils.cut_ce.check_cut_ce_available", return_value=True
        ):
            # With cut_cross_entropy not actually installed, patch should return
            # False (can't import real module). Just verifying it doesn't crash.
            result = apply_cut_ce("meta-llama/Llama-3.1-8B")
            assert isinstance(result, bool)

    def test_apply_cut_ce_calls_llama_patch_for_llama_model(self):
        """Verify the llama detector routes to cce_patch('llama')."""
        from soup_cli.utils.cut_ce import apply_cut_ce

        fake_cce = MagicMock()
        fake_transformers = MagicMock(cce_patch=fake_cce)
        fake_module = MagicMock(transformers=fake_transformers)
        with patch(
            "soup_cli.utils.cut_ce.check_cut_ce_available", return_value=True
        ), patch.dict(
            "sys.modules",
            {
                "cut_cross_entropy": fake_module,
                "cut_cross_entropy.transformers": fake_transformers,
            },
        ):
            assert apply_cut_ce("meta-llama/Llama-3.1-8B") is True
            fake_cce.assert_called_once_with("llama")

    def test_apply_cut_ce_deepseek_phi_does_not_use_phi(self):
        """Regression: org-prefix like 'deepseek-ai/...' must not trigger phi."""
        from soup_cli.utils.cut_ce import apply_cut_ce

        fake_cce = MagicMock()
        fake_transformers = MagicMock(cce_patch=fake_cce)
        fake_module = MagicMock(transformers=fake_transformers)
        with patch(
            "soup_cli.utils.cut_ce.check_cut_ce_available", return_value=True
        ), patch.dict(
            "sys.modules",
            {
                "cut_cross_entropy": fake_module,
                "cut_cross_entropy.transformers": fake_transformers,
            },
        ):
            # Llama-distilled model name contains no "phi" substring
            # anymore thanks to the last-path-component detector.
            assert apply_cut_ce("deepseek-ai/deepseek-coder-7b-instruct") is False
            fake_cce.assert_not_called()


class TestCutCEValidation:
    """Cut Cross-Entropy config validation."""

    def test_validate_cut_ce_disabled_returns_empty(self):
        from soup_cli.utils.cut_ce import validate_cut_ce_config

        errors = validate_cut_ce_config(False, "transformers", "cuda")
        assert errors == []

    def test_validate_cut_ce_not_installed(self):
        from soup_cli.utils.cut_ce import validate_cut_ce_config

        with patch(
            "soup_cli.utils.cut_ce.check_cut_ce_available", return_value=False
        ):
            errors = validate_cut_ce_config(True, "transformers", "cuda")
            assert any("not installed" in err for err in errors)

    def test_validate_cut_ce_requires_cuda(self):
        from soup_cli.utils.cut_ce import validate_cut_ce_config

        with patch(
            "soup_cli.utils.cut_ce.check_cut_ce_available", return_value=True
        ):
            errors = validate_cut_ce_config(True, "transformers", "cpu")
            assert any("CUDA" in err for err in errors)

    def test_validate_cut_ce_unsloth_incompatible(self):
        from soup_cli.utils.cut_ce import validate_cut_ce_config

        with patch(
            "soup_cli.utils.cut_ce.check_cut_ce_available", return_value=True
        ):
            errors = validate_cut_ce_config(True, "unsloth", "cuda")
            assert any("unsloth" in err.lower() for err in errors)

    def test_validate_cut_ce_valid(self):
        from soup_cli.utils.cut_ce import validate_cut_ce_config

        with patch(
            "soup_cli.utils.cut_ce.check_cut_ce_available", return_value=True
        ):
            errors = validate_cut_ce_config(True, "transformers", "cuda")
            assert errors == []

    def test_validate_cut_ce_mlx_incompatible(self):
        from soup_cli.utils.cut_ce import validate_cut_ce_config

        with patch(
            "soup_cli.utils.cut_ce.check_cut_ce_available", return_value=True
        ):
            errors = validate_cut_ce_config(True, "mlx", "mps")
            assert any("mlx" in err.lower() for err in errors)


# ─── Part B: FP8 training ─────────────────────────────────────────────────


class TestFP8Config:
    """quantization_aware now accepts bool or literal 'fp8'."""

    def test_quantization_aware_default_false(self):
        cfg = SoupConfig(base="test/model", data={"train": "./data.jsonl"})
        assert cfg.training.quantization_aware is False

    def test_quantization_aware_bool_true(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"quantization_aware": True},
        )
        assert cfg.training.quantization_aware is True

    def test_quantization_aware_fp8(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"quantization_aware": "fp8"},
        )
        assert cfg.training.quantization_aware == "fp8"

    def test_quantization_aware_invalid_string_rejected(self):
        """Only 'fp8' literal is accepted, other strings rejected."""
        with pytest.raises(ValidationError) as exc:
            SoupConfig(
                base="test/model",
                data={"train": "./data.jsonl"},
                training={"quantization_aware": "fp16"},
            )
        assert "fp8" in str(exc.value) or "quantization_aware" in str(exc.value)

    def test_quantization_aware_bool_still_works(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"quantization_aware": False},
        )
        assert cfg.training.quantization_aware is False


class TestFP8Availability:
    """FP8 training dependency checks."""

    def test_is_fp8_available_returns_bool(self):
        from soup_cli.utils.fp8 import is_fp8_available

        result = is_fp8_available()
        assert isinstance(result, bool)

    def test_fp8_requires_hopper_gpu_info(self):
        """is_fp8_gpu_supported should check GPU compute capability."""
        from soup_cli.utils.fp8 import is_fp8_gpu_supported

        # Shouldn't crash even without CUDA
        result = is_fp8_gpu_supported()
        assert isinstance(result, bool)

    def test_is_fp8_available_false_when_deps_missing(self):
        """Explicit false branch — both torchao.float8 and transformer_engine absent."""
        with patch.dict(
            "sys.modules",
            {"torchao.float8": None, "transformer_engine": None},
        ):
            from soup_cli.utils.fp8 import is_fp8_available

            assert is_fp8_available() is False

    def test_is_fp8_gpu_supported_pre_hopper_false(self):
        """Pre-Hopper (SM 8.x, e.g. A100) is not supported."""
        fake_torch = MagicMock()
        fake_torch.cuda.is_available.return_value = True
        fake_torch.cuda.get_device_capability.return_value = (8, 0)
        with patch.dict("sys.modules", {"torch": fake_torch}):
            from soup_cli.utils.fp8 import is_fp8_gpu_supported

            assert is_fp8_gpu_supported() is False

    def test_is_fp8_gpu_supported_hopper_true(self):
        """Hopper (SM 9.x, H100) is supported."""
        fake_torch = MagicMock()
        fake_torch.cuda.is_available.return_value = True
        fake_torch.cuda.get_device_capability.return_value = (9, 0)
        with patch.dict("sys.modules", {"torch": fake_torch}):
            from soup_cli.utils.fp8 import is_fp8_gpu_supported

            assert is_fp8_gpu_supported() is True


class TestFP8Validation:
    """FP8 training config validation."""

    def test_validate_fp8_not_requested_returns_empty(self):
        from soup_cli.utils.fp8 import validate_fp8_config

        errors = validate_fp8_config(False, "transformers", "cuda")
        assert errors == []

    def test_validate_fp8_bool_returns_empty(self):
        """Bool True means int8 QAT (existing path), not FP8."""
        from soup_cli.utils.fp8 import validate_fp8_config

        errors = validate_fp8_config(True, "transformers", "cuda")
        # Bool True is int8 QAT, handled by qat.py, not fp8
        assert errors == []

    def test_validate_fp8_cpu_rejected(self):
        from soup_cli.utils.fp8 import validate_fp8_config

        errors = validate_fp8_config("fp8", "transformers", "cpu")
        assert any("CUDA" in err for err in errors)

    def test_validate_fp8_unsloth_rejected(self):
        from soup_cli.utils.fp8 import validate_fp8_config

        errors = validate_fp8_config("fp8", "unsloth", "cuda")
        assert any("unsloth" in err.lower() for err in errors)

    def test_validate_fp8_mlx_rejected(self):
        from soup_cli.utils.fp8 import validate_fp8_config

        errors = validate_fp8_config("fp8", "mlx", "mps")
        assert any("mlx" in err.lower() or "CUDA" in err for err in errors)


# ─── Part C: Gradient checkpointing tiers ─────────────────────────────────


class TestGradientCheckpointingTiers:
    """gradient_checkpointing accepts bool or tier literal."""

    def test_default_false(self):
        cfg = SoupConfig(base="test/model", data={"train": "./data.jsonl"})
        assert cfg.training.gradient_checkpointing is False

    def test_bool_true(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"gradient_checkpointing": True},
        )
        assert cfg.training.gradient_checkpointing is True

    def test_tier_selective(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"gradient_checkpointing": "selective"},
        )
        assert cfg.training.gradient_checkpointing == "selective"

    def test_tier_medium(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"gradient_checkpointing": "medium"},
        )
        assert cfg.training.gradient_checkpointing == "medium"

    def test_tier_full(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"gradient_checkpointing": "full"},
        )
        assert cfg.training.gradient_checkpointing == "full"

    def test_tier_auto(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"gradient_checkpointing": "auto"},
        )
        assert cfg.training.gradient_checkpointing == "auto"

    def test_invalid_tier_rejected(self):
        with pytest.raises(ValidationError) as exc:
            SoupConfig(
                base="test/model",
                data={"train": "./data.jsonl"},
                training={"gradient_checkpointing": "partial"},
            )
        assert "gradient_checkpointing" in str(exc.value)


class TestGradientCheckpointingResolver:
    """Resolve config value + GPU info → kwargs for TrainingArguments."""

    def test_resolve_disabled_returns_empty(self):
        from soup_cli.utils.gradient_ckpt import resolve_gradient_checkpointing

        kwargs = resolve_gradient_checkpointing(False, gpu_memory_gb=80)
        assert kwargs == {}

    def test_resolve_bool_true_returns_full_ckpt(self):
        from soup_cli.utils.gradient_ckpt import resolve_gradient_checkpointing

        kwargs = resolve_gradient_checkpointing(True, gpu_memory_gb=80)
        assert kwargs["gradient_checkpointing"] is True

    def test_resolve_full_tier(self):
        from soup_cli.utils.gradient_ckpt import resolve_gradient_checkpointing

        kwargs = resolve_gradient_checkpointing("full", gpu_memory_gb=80)
        assert kwargs["gradient_checkpointing"] is True

    def test_resolve_selective_tier(self):
        from soup_cli.utils.gradient_ckpt import (
            resolve_gradient_checkpointing,
            resolve_granularity,
        )

        kwargs = resolve_gradient_checkpointing("selective", gpu_memory_gb=80)
        assert kwargs["gradient_checkpointing"] is True
        # No private markers leak into HF TrainingArguments kwargs.
        assert kwargs["gradient_checkpointing_kwargs"] == {"use_reentrant": False}
        # Granularity is exposed via a separate helper for the wrapper.
        assert resolve_granularity("selective", gpu_memory_gb=80) == "selective"

    def test_resolve_medium_tier(self):
        from soup_cli.utils.gradient_ckpt import resolve_gradient_checkpointing

        kwargs = resolve_gradient_checkpointing("medium", gpu_memory_gb=80)
        assert kwargs["gradient_checkpointing"] is True

    def test_resolve_auto_low_memory_selects_full(self):
        from soup_cli.utils.gradient_ckpt import resolve_gradient_checkpointing

        kwargs = resolve_gradient_checkpointing("auto", gpu_memory_gb=16)
        # Low VRAM → full checkpointing
        assert kwargs["gradient_checkpointing"] is True

    def test_resolve_auto_high_memory_selects_selective(self):
        from soup_cli.utils.gradient_ckpt import resolve_gradient_checkpointing

        # 80GB+ → selective only (attention), saving speed
        kwargs = resolve_gradient_checkpointing("auto", gpu_memory_gb=80)
        assert kwargs["gradient_checkpointing"] is True

    def test_resolve_auto_very_high_memory_selects_selective(self):
        from soup_cli.utils.gradient_ckpt import (
            resolve_granularity,
        )

        # 192GB (H200): selective (attention-only) tier, minimize slowdown
        assert resolve_granularity("auto", gpu_memory_gb=192) == "selective"

    def test_resolve_auto_medium_memory_selects_medium(self):
        from soup_cli.utils.gradient_ckpt import resolve_granularity

        # 40GB (A100 40GB): medium (every other block)
        assert resolve_granularity("auto", gpu_memory_gb=40) == "medium"

    def test_resolve_auto_no_gpu_info_full(self):
        from soup_cli.utils.gradient_ckpt import resolve_granularity

        assert resolve_granularity("auto", gpu_memory_gb=None) == "full"

    def test_describe_tier_off(self):
        from soup_cli.utils.gradient_ckpt import describe_tier

        assert describe_tier(False) == "off"

    def test_describe_tier_full(self):
        from soup_cli.utils.gradient_ckpt import describe_tier

        assert "full" in describe_tier(True)
        assert "full" in describe_tier("full")

    def test_describe_tier_auto(self):
        from soup_cli.utils.gradient_ckpt import describe_tier

        assert "auto" in describe_tier("auto")
        assert "auto" in describe_tier("auto", gpu_memory_gb=16)
        assert "selective" in describe_tier("auto", gpu_memory_gb=192)


# ─── Part D: Kernel auto-composition ─────────────────────────────────────


class TestKernelPickerConfig:
    """kernel_auto_compose config flag."""

    def test_kernel_auto_compose_default_false(self):
        cfg = SoupConfig(base="test/model", data={"train": "./data.jsonl"})
        assert cfg.training.kernel_auto_compose is False

    def test_kernel_auto_compose_enabled(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"kernel_auto_compose": True},
        )
        assert cfg.training.kernel_auto_compose is True


class TestKernelPickerEnumerate:
    """Kernel picker enumerates available kernel combinations."""

    def test_enumerate_returns_list(self):
        from soup_cli.utils.kernel_picker import enumerate_kernel_combos

        combos = enumerate_kernel_combos(backend="transformers", device="cuda")
        assert isinstance(combos, list)

    def test_enumerate_baseline_always_present(self):
        from soup_cli.utils.kernel_picker import enumerate_kernel_combos

        combos = enumerate_kernel_combos(backend="transformers", device="cuda")
        # Baseline (no special kernels) must always be an option
        assert any(c.get("name") == "baseline" for c in combos)

    def test_enumerate_cpu_only_baseline(self):
        from soup_cli.utils.kernel_picker import enumerate_kernel_combos

        combos = enumerate_kernel_combos(backend="transformers", device="cpu")
        # On CPU, only baseline should be available
        assert len(combos) == 1
        assert combos[0]["name"] == "baseline"

    def test_enumerate_unsloth_skips_liger(self):
        from soup_cli.utils.kernel_picker import enumerate_kernel_combos

        combos = enumerate_kernel_combos(backend="unsloth", device="cuda")
        # Unsloth has its own fused kernels - no Liger combos
        for combo in combos:
            assert "liger" not in combo.get("name", "").lower()


class TestKernelPickerDecision:
    """Kernel picker decision logic (mocked benchmarks)."""

    def test_pick_best_returns_dict(self):
        from soup_cli.utils.kernel_picker import pick_best_kernel

        # With fake timing results, picks fastest
        candidates = [
            {"name": "baseline", "time_ms": 100.0},
            {"name": "liger", "time_ms": 70.0},
            {"name": "liger+flash", "time_ms": 50.0},
        ]
        best = pick_best_kernel(candidates)
        assert best["name"] == "liger+flash"

    def test_pick_best_baseline_if_only_one(self):
        from soup_cli.utils.kernel_picker import pick_best_kernel

        candidates = [{"name": "baseline", "time_ms": 100.0}]
        best = pick_best_kernel(candidates)
        assert best["name"] == "baseline"

    def test_pick_best_empty_raises(self):
        from soup_cli.utils.kernel_picker import pick_best_kernel

        with pytest.raises(ValueError, match="at least one"):
            pick_best_kernel([])

    def test_pick_best_tie_returns_first(self):
        """Ties broken by list order (first-wins) — preferred default first."""
        from soup_cli.utils.kernel_picker import pick_best_kernel

        candidates = [
            {"name": "baseline", "time_ms": 50.0},
            {"name": "liger", "time_ms": 50.0},
        ]
        best = pick_best_kernel(candidates)
        assert best["name"] == "baseline"

    def test_pick_best_all_missing_time_raises(self):
        """All-untimed candidates means benchmarking failed — must not promote silently."""
        from soup_cli.utils.kernel_picker import pick_best_kernel

        candidates = [
            {"name": "baseline"},
            {"name": "liger"},
            {"name": "flash", "time_ms": None},
        ]
        with pytest.raises(ValueError, match="finite time_ms"):
            pick_best_kernel(candidates)

    def test_pick_best_nan_time_treated_as_missing(self):
        from soup_cli.utils.kernel_picker import pick_best_kernel

        candidates = [
            {"name": "baseline", "time_ms": float("nan")},
            {"name": "liger", "time_ms": 50.0},
        ]
        best = pick_best_kernel(candidates)
        assert best["name"] == "liger"


# ─── Part E: Cross-document attention masking ────────────────────────────


class TestCrossDocAttnMaskConfig:
    """packing_cross_doc_attn_mask config."""

    def test_default_false(self):
        cfg = SoupConfig(base="test/model", data={"train": "./data.jsonl"})
        assert cfg.training.packing_cross_doc_attn_mask is False

    def test_enabled(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"packing_cross_doc_attn_mask": True, "packing": True},
        )
        assert cfg.training.packing_cross_doc_attn_mask is True

    def test_requires_packing(self):
        """Enabling cross-doc mask without packing should error."""
        with pytest.raises(ValidationError) as exc:
            SoupConfig(
                base="test/model",
                data={"train": "./data.jsonl"},
                training={
                    "packing_cross_doc_attn_mask": True,
                    "packing": False,
                },
            )
        assert "packing" in str(exc.value).lower()


class TestCrossDocAttnMaskBuild:
    """Build cross-doc attention mask from document boundaries."""

    def test_build_mask_single_doc(self):
        from soup_cli.utils.cross_doc_attn import build_cross_doc_mask

        # Single doc spans the whole sequence — no masking needed
        boundaries = [0, 10]  # doc0 occupies positions 0..9
        mask = build_cross_doc_mask(boundaries, seq_length=10)
        # Shape (10, 10), lower-triangular within the doc
        assert mask.shape == (10, 10)

    def test_build_mask_two_docs(self):
        from soup_cli.utils.cross_doc_attn import build_cross_doc_mask

        # doc0: 0..4, doc1: 5..9
        boundaries = [0, 5, 10]
        mask = build_cross_doc_mask(boundaries, seq_length=10)
        # Position 5 (doc1 start) should NOT attend to position 0 (doc0)
        assert mask[5, 0] == 0
        # Position 5 attending to itself should be 1
        assert mask[5, 5] == 1
        # Position 0 attending to itself should be 1
        assert mask[0, 0] == 1
        # Position 1 attending to 0 should be 1 (same doc, causal)
        assert mask[1, 0] == 1
        # Position 0 attending to 1 should be 0 (causal — future)
        assert mask[0, 1] == 0

    def test_build_mask_boundaries_validation(self):
        from soup_cli.utils.cross_doc_attn import build_cross_doc_mask

        # Boundaries must start at 0 and end at seq_length
        with pytest.raises(ValueError):
            build_cross_doc_mask([1, 10], seq_length=10)

    def test_build_mask_boundaries_monotonic(self):
        from soup_cli.utils.cross_doc_attn import build_cross_doc_mask

        with pytest.raises(ValueError, match="increasing"):
            build_cross_doc_mask([0, 5, 3, 10], seq_length=10)

    def test_build_mask_empty_boundaries_raises(self):
        from soup_cli.utils.cross_doc_attn import build_cross_doc_mask

        with pytest.raises(ValueError, match="non-empty"):
            build_cross_doc_mask([], seq_length=10)

    def test_build_mask_wrong_end_raises(self):
        from soup_cli.utils.cross_doc_attn import build_cross_doc_mask

        with pytest.raises(ValueError, match="seq_length"):
            build_cross_doc_mask([0, 8], seq_length=10)

    def test_compute_doc_boundaries_empty(self):
        from soup_cli.utils.cross_doc_attn import compute_doc_boundaries

        with pytest.raises(ValueError):
            compute_doc_boundaries([])

    def test_compute_doc_boundaries_non_positive(self):
        from soup_cli.utils.cross_doc_attn import compute_doc_boundaries

        with pytest.raises(ValueError):
            compute_doc_boundaries([3, 0, 4])

    def test_compute_doc_boundaries_valid(self):
        from soup_cli.utils.cross_doc_attn import compute_doc_boundaries

        assert compute_doc_boundaries([3, 2, 4]) == [0, 3, 5, 9]


# ─── Part F: Activation offloading ────────────────────────────────────────


class TestActivationOffloadingConfig:
    """activation_offloading config."""

    def test_default_none(self):
        cfg = SoupConfig(base="test/model", data={"train": "./data.jsonl"})
        assert cfg.training.activation_offloading is None

    def test_cpu(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"activation_offloading": "cpu"},
        )
        assert cfg.training.activation_offloading == "cpu"

    def test_disk(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={"activation_offloading": "disk"},
        )
        assert cfg.training.activation_offloading == "disk"

    def test_invalid_target(self):
        with pytest.raises(ValidationError) as exc:
            SoupConfig(
                base="test/model",
                data={"train": "./data.jsonl"},
                training={"activation_offloading": "gpu"},
            )
        assert "activation_offloading" in str(exc.value)


class TestActivationOffloadingValidation:
    """Offloading config validation."""

    def test_validate_none_returns_empty(self):
        from soup_cli.utils.activation_offload import validate_offload_config

        errors = validate_offload_config(None, "transformers", "cuda")
        assert errors == []

    def test_validate_cpu_requires_cuda(self):
        from soup_cli.utils.activation_offload import validate_offload_config

        errors = validate_offload_config("cpu", "transformers", "cpu")
        assert any("CUDA" in err for err in errors)

    def test_validate_cpu_unsloth_incompatible(self):
        from soup_cli.utils.activation_offload import validate_offload_config

        errors = validate_offload_config("cpu", "unsloth", "cuda")
        assert any("unsloth" in err.lower() for err in errors)

    def test_validate_disk_valid(self):
        from soup_cli.utils.activation_offload import validate_offload_config

        errors = validate_offload_config(
            "disk", "transformers", "cuda", save_dir="./scratch"
        )
        assert errors == []

    def test_validate_disk_requires_save_dir(self):
        """Disk mode must reject calls without save_dir — fail-fast at validate()."""
        from soup_cli.utils.activation_offload import validate_offload_config

        errors = validate_offload_config(
            "disk", "transformers", "cuda", save_dir=None
        )
        assert any("save_dir" in err for err in errors)

    def test_validate_disk_on_cpu_rejected(self):
        from soup_cli.utils.activation_offload import validate_offload_config

        errors = validate_offload_config(
            "disk", "transformers", "cpu", save_dir="./scratch"
        )
        assert any("CUDA" in err for err in errors)

    def test_validate_mlx_rejected(self):
        from soup_cli.utils.activation_offload import validate_offload_config

        errors = validate_offload_config("cpu", "mlx", "cuda")
        assert any("mlx" in err.lower() for err in errors)


class TestActivationOffloadingHooks:
    """Install / uninstall hooks for offloading."""

    def test_context_manager_noop_when_none(self):
        from soup_cli.utils.activation_offload import offload_context

        # Should be a no-op when target is None
        with offload_context(None, save_dir=None):
            pass  # nothing to assert - just shouldn't crash

    def test_context_manager_cpu(self):
        from soup_cli.utils.activation_offload import offload_context

        # Should not crash even without torch
        with offload_context("cpu", save_dir=None):
            pass

    def test_offload_context_disk_requires_dir(self, tmp_path):
        from soup_cli.utils.activation_offload import offload_context

        # Disk mode should accept a save_dir
        with offload_context("disk", save_dir=str(tmp_path)):
            pass

    def test_offload_context_unknown_target_raises(self):
        """Defense-in-depth: unknown target raises even if torch is present."""
        from soup_cli.utils.activation_offload import offload_context

        # Only reaches the ValueError branch if torch imports successfully
        try:
            import torch  # noqa: F401
        except ImportError:
            pytest.skip("torch not installed; ValueError branch unreachable")

        with pytest.raises(ValueError, match="Unknown activation_offloading"):
            with offload_context("invalid", save_dir=None):
                pass

    def test_offload_context_disk_creates_save_dir(self, tmp_path):
        """Disk mode should create the scratch directory on context entry."""
        try:
            import torch  # noqa: F401
        except ImportError:
            pytest.skip("torch not installed; disk hooks unreachable")

        from soup_cli.utils.activation_offload import offload_context

        scratch = tmp_path / "offload_scratch"
        with offload_context("disk", save_dir=str(scratch)):
            assert scratch.exists()


# ─── Integration: multiple features composed ──────────────────────────────


class TestV028SFTOnlyValidator:
    """v0.28.0 features are wired only in SFTTrainerWrapper.

    The SoupConfig validator rejects non-SFT tasks when speed/memory flags
    are set — prevents silent no-ops and the known fp8 crash path in the
    legacy int8 QAT wrapper.
    """

    def test_use_cut_ce_now_accepted_on_dpo(self):
        # v0.33.0 #43 — DPO is now in the supported task set.
        cfg = SoupConfig(
            base="m",
            task="dpo",
            data={"train": "./d.jsonl", "format": "dpo"},
            training={"use_cut_ce": True},
        )
        assert cfg.training.use_cut_ce is True

    def test_fp8_now_accepted_on_grpo(self):
        # v0.35.0 #60 — every transformer-backend trainer now supported.
        cfg = SoupConfig(
            base="m",
            task="grpo",
            data={"train": "./d.jsonl"},
            training={"quantization_aware": "fp8"},
        )
        assert cfg.training.quantization_aware == "fp8"

    def test_activation_offloading_now_accepted_on_ppo(self):
        # v0.35.0 #60 — PPO accepts activation_offloading.
        cfg = SoupConfig(
            base="m",
            task="ppo",
            data={"train": "./d.jsonl"},
            training={"activation_offloading": "cpu"},
        )
        assert cfg.training.activation_offloading == "cpu"

    def test_kernel_auto_compose_now_accepted_on_kto(self):
        # v0.35.0 #60 — KTO accepts kernel_auto_compose.
        cfg = SoupConfig(
            base="m",
            task="kto",
            data={"train": "./d.jsonl", "format": "kto"},
            training={"kernel_auto_compose": True},
        )
        assert cfg.training.kernel_auto_compose is True

    def test_v028_features_still_rejected_on_mlx_backend(self):
        # MLX backend has no equivalent kernels — gate must still fire.
        with pytest.raises(ValidationError) as exc:
            SoupConfig(
                base="m",
                task="sft",
                backend="mlx",
                data={"train": "./d.jsonl"},
                training={"use_cut_ce": True},
            )
        assert "mlx" in str(exc.value).lower()

    def test_sft_accepts_all_features(self):
        """SFT task should accept every v0.28.0 flag (happy path)."""
        cfg = SoupConfig(
            base="m",
            task="sft",
            data={"train": "./d.jsonl"},
            training={
                "use_cut_ce": True,
                "quantization_aware": "fp8",
                "activation_offloading": "cpu",
                "kernel_auto_compose": True,
            },
        )
        assert cfg.training.use_cut_ce is True

    def test_non_sft_unaffected_when_flags_default(self):
        """DPO/GRPO with default v0.28.0 flags still validate."""
        cfg = SoupConfig(
            base="m",
            task="dpo",
            data={"train": "./d.jsonl", "format": "dpo"},
        )
        assert cfg.task == "dpo"

    def test_quantization_aware_bool_true_allowed_on_dpo(self):
        """Int8 QAT (bool True) still works on non-SFT — only fp8 is restricted."""
        cfg = SoupConfig(
            base="m",
            task="dpo",
            data={"train": "./d.jsonl", "format": "dpo"},
            training={"quantization_aware": True},
        )
        assert cfg.training.quantization_aware is True

    def test_gradient_checkpointing_tier_allowed_on_dpo(self):
        """Tier strings fall back to truthy (bool True) in non-SFT wrappers — no crash."""
        cfg = SoupConfig(
            base="m",
            task="dpo",
            data={"train": "./d.jsonl", "format": "dpo"},
            training={"gradient_checkpointing": "auto"},
        )
        assert cfg.training.gradient_checkpointing == "auto"


class TestV028Integration:
    """Multiple v0.28.0 features composed in one config."""

    def test_all_features_compose(self):
        cfg = SoupConfig(
            base="test/model",
            data={"train": "./data.jsonl"},
            training={
                "use_cut_ce": True,
                "quantization_aware": "fp8",
                "gradient_checkpointing": "auto",
                "kernel_auto_compose": True,
                "packing": True,
                "packing_cross_doc_attn_mask": True,
                "activation_offloading": "cpu",
            },
        )
        tcfg = cfg.training
        assert tcfg.use_cut_ce is True
        assert tcfg.quantization_aware == "fp8"
        assert tcfg.gradient_checkpointing == "auto"
        assert tcfg.kernel_auto_compose is True
        assert tcfg.packing is True
        assert tcfg.packing_cross_doc_attn_mask is True
        assert tcfg.activation_offloading == "cpu"
