"""Tests for v0.10.1-v0.14.2 bug fixes - Unicode, PPO, dtype, CPU, trl API, validate, UI."""

from pathlib import Path
from unittest.mock import patch

import pytest

from soup_cli.config.schema import SoupConfig

# --- BUG-001: Windows UnicodeEncodeError (no Unicode arrows/dashes in output) ---


class TestNoUnicodeInOutput:
    """Verify user-facing output uses only ASCII-safe characters."""

    def test_config_loader_error_uses_ascii_arrow(self):
        """Config validation errors should use -> not Unicode arrow."""
        from soup_cli.config.loader import load_config_from_string

        with pytest.raises(ValueError) as exc_info:
            load_config_from_string("base: x\ntask: invalid_task\n")
        # Error message should use -> not the Unicode arrow
        msg = str(exc_info.value)
        assert "\u2192" not in msg  # no Unicode right arrow

    def test_loss_format_uses_ascii(self):
        """Loss formatting in runs should use -> not Unicode arrow."""
        from soup_cli.commands.runs import _fmt_loss

        run = {"initial_loss": 1.5, "final_loss": 0.5}
        result = _fmt_loss(run)
        assert "->" in result
        assert "\u2192" not in result  # no Unicode right arrow

    def test_loss_format_missing_returns_ascii(self):
        """Missing loss should return ASCII dash, not em dash."""
        from soup_cli.commands.runs import _fmt_loss

        result = _fmt_loss({})
        assert result == "-"
        assert "\u2014" not in result  # no em dash

    def test_fmt_float_missing_returns_ascii(self):
        """Missing float should return ASCII dash."""
        from soup_cli.commands.runs import _fmt_float

        result = _fmt_float(None)
        assert result == "-"
        assert "\u2014" not in result

    def test_fmt_duration_missing_returns_ascii(self):
        """Missing duration should return ASCII dash."""
        from soup_cli.commands.runs import _fmt_duration

        result = _fmt_duration(None)
        assert result == "-"
        assert "\u2014" not in result

    def test_formats_empty_dataset_error_ascii(self):
        """Empty dataset error should use ASCII dash."""
        from soup_cli.data.formats import detect_format

        with pytest.raises(ValueError, match="Empty dataset"):
            detect_format([])


# --- BUG-002: PPO ppo_epochs parameter compatibility ---


class TestPPOParamCompat:
    """Test PPO trainer handles trl version differences."""

    def test_ppo_config_uses_inspect(self):
        """PPO setup should use inspect to detect valid parameter names."""
        from soup_cli.trainer.ppo import PPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="ppo",
            data={"train": "./data.jsonl"},
            training={
                "ppo_epochs": 3,
                "ppo_clip_ratio": 0.15,
                "ppo_kl_penalty": 0.03,
            },
        )
        wrapper = PPOTrainerWrapper(cfg, device="cpu")
        assert wrapper.config.training.ppo_epochs == 3
        assert wrapper.config.training.ppo_clip_ratio == pytest.approx(0.15)
        assert wrapper.config.training.ppo_kl_penalty == pytest.approx(0.03)


# --- BUG-003: Reward Model dtype mismatch ---


class TestComputeDtype:
    """Test get_compute_dtype returns correct dtype for device."""

    def test_cpu_returns_float32(self):
        """CPU should use float32, not bfloat16."""
        import torch

        from soup_cli.utils.gpu import get_compute_dtype

        with patch("torch.cuda.is_available", return_value=False):
            dtype = get_compute_dtype()
            assert dtype == torch.float32

    def test_cuda_with_bf16_returns_bfloat16(self):
        """CUDA with bf16 support should use bfloat16."""
        import torch

        from soup_cli.utils.gpu import get_compute_dtype

        with patch("torch.cuda.is_available", return_value=True), \
             patch("torch.cuda.is_bf16_supported", return_value=True):
            dtype = get_compute_dtype()
            assert dtype == torch.bfloat16

    def test_cuda_without_bf16_returns_float16(self):
        """CUDA without bf16 support should fall back to float16."""
        import torch

        from soup_cli.utils.gpu import get_compute_dtype

        with patch("torch.cuda.is_available", return_value=True), \
             patch("torch.cuda.is_bf16_supported", return_value=False):
            dtype = get_compute_dtype()
            assert dtype == torch.float16


# --- BUG-005: diff dtype -> torch_dtype ---


class TestDiffModelLoading:
    """Test diff command uses correct parameter names."""

    def test_load_model_uses_dtype(self):
        """_load_model should pass dtype= (not the old torch_dtype=)."""
        import inspect

        from soup_cli.commands.diff import _load_model

        source = inspect.getsource(_load_model)
        assert "dtype=torch.float16" in source
        assert "torch_dtype=" not in source


# --- BUG-006: wandb version pin ---


class TestWandbVersionPin:
    """Test wandb dependency is version-pinned."""

    def test_wandb_upper_bound_in_pyproject(self):
        """pyproject.toml should pin wandb below 0.18.0."""
        pyproject = Path(__file__).parent.parent / "pyproject.toml"
        content = pyproject.read_text(encoding="utf-8")
        assert "<0.18.0" in content or "< 0.18.0" in content


# --- BUG-004: CPU quantization warning ---


class TestCPUQuantWarning:
    """Test that CPU + quantization produces a warning."""

    def test_train_auto_disables_quant_on_cpu(self):
        """train.py should auto-disable quantization on CPU."""
        import inspect

        from soup_cli.commands import train

        source = inspect.getsource(train)
        assert "quantization is not" in source
        assert 'cfg.training.quantization = "none"' in source


# --- v0.10.2: Display progress bar uses ASCII ---


class TestDisplayASCII:
    """Test that training display uses ASCII-safe progress bars."""

    def test_progress_bar_uses_ascii_chars(self):
        """Progress bar should use # and - instead of Unicode blocks."""
        import inspect

        from soup_cli.monitoring.display import TrainingDisplay

        source = inspect.getsource(TrainingDisplay)
        assert '"#"' in source
        assert '"-"' in source
        assert "\\u2588" not in source
        assert "\\u2591" not in source


# --- v0.10.2: Plotext UnicodeEncodeError handling ---


class TestPlotextFallback:
    """Test that plotext errors are caught gracefully."""

    def test_stats_catches_unicode_error(self):
        """data stats should catch UnicodeEncodeError from plotext."""
        import inspect

        from soup_cli.commands import data

        source = inspect.getsource(data)
        assert "UnicodeEncodeError" in source


# --- v0.10.2: Error messages for CPU issues ---


class TestCPUErrorMessages:
    """Test friendly error messages for CPU-specific failures."""

    def test_tensor_size_error_mapped(self):
        """Tensor expansion error should have a friendly GRPO/PPO CPU message."""
        from soup_cli.utils.errors import ERROR_MAP

        for pattern, msg, _ in ERROR_MAP:
            if "expanded size" in pattern:
                assert "GRPO" in msg or "PPO" in msg
                break
        else:
            pytest.fail("expanded size pattern not found in ERROR_MAP")

    def test_dtype_mismatch_error_mapped(self):
        """Dtype mismatch error should have a friendly message."""
        from soup_cli.utils.errors import ERROR_MAP

        patterns = [pattern for pattern, _, _ in ERROR_MAP]
        assert any("same dtype" in p for p in patterns)

    def test_bf16_error_mapped(self):
        """bf16 GPU error should have a friendly message."""
        from soup_cli.utils.errors import ERROR_MAP

        patterns = [pattern for pattern, _, _ in ERROR_MAP]
        assert any("bf16" in p for p in patterns)

    def test_torchvision_error_mapped(self):
        """torchvision nms error should have a friendly message."""
        from soup_cli.utils.errors import ERROR_MAP

        patterns = [pattern for pattern, _, _ in ERROR_MAP]
        assert any("nms" in p for p in patterns)


# --- v0.10.2: Doctor torchvision check ---


class TestDoctorTorchvisionCheck:
    """Test that soup doctor checks torchvision compatibility."""

    def test_doctor_has_torchvision_check(self):
        """doctor.py should have torchvision compatibility check."""
        import inspect

        from soup_cli.commands import doctor

        source = inspect.getsource(doctor)
        assert "_check_torchvision_compat" in source


# --- v0.10.3: PPO use_cpu support ---


class TestPPOUseCPU:
    """Test PPO trainer sets use_cpu=True on CPU devices."""

    def test_ppo_setup_has_use_cpu_logic(self):
        """PPO setup should check for use_cpu param and set it on CPU."""
        import inspect

        from soup_cli.trainer import ppo

        source = inspect.getsource(ppo)
        assert "use_cpu" in source
        assert 'self.device == "cpu"' in source

    def test_ppo_wrapper_stores_device(self):
        """PPOTrainerWrapper should store the device parameter."""
        from soup_cli.trainer.ppo import PPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="ppo",
            data={"train": "./data.jsonl"},
        )
        wrapper = PPOTrainerWrapper(cfg, device="cpu")
        assert wrapper.device == "cpu"

        wrapper_gpu = PPOTrainerWrapper(cfg, device="cuda")
        assert wrapper_gpu.device == "cuda"

    def test_ppo_supports_args_and_config_api(self):
        """PPO trainer should detect trl API: args= (>=0.28) vs config= (<0.28)."""
        import inspect

        from soup_cli.trainer import ppo

        source = inspect.getsource(ppo)
        # Must handle both trl APIs
        assert '"args"' in source
        assert '"config"' in source
        assert "ppo_trainer_cls.__init__" in source

    def test_ppo_train_detects_builtin_vs_manual(self):
        """PPO train() should detect built-in .train() vs manual loop."""
        import inspect

        from soup_cli.trainer import ppo

        source = inspect.getsource(ppo)
        assert "_train_builtin" in source
        assert "_train_manual" in source


# --- v0.10.3: GRPO CPU warning ---


class TestGRPOCPUWarning:
    """Test GRPO trainer warns on CPU and sets use_cpu."""

    def test_grpo_setup_has_cpu_warning(self):
        """GRPO setup should warn about CPU limitations."""
        import inspect

        from soup_cli.trainer import grpo

        source = inspect.getsource(grpo)
        assert "GRPO on CPU is experimental" in source

    def test_grpo_setup_has_use_cpu_logic(self):
        """GRPO setup should set use_cpu=True on CPU when supported."""
        import inspect

        from soup_cli.trainer import grpo

        source = inspect.getsource(grpo)
        assert "use_cpu" in source

    def test_grpo_wrapper_stores_device(self):
        """GRPOTrainerWrapper should store the device parameter."""
        from soup_cli.trainer.grpo import GRPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="grpo",
            data={"train": "./data.jsonl"},
            training={"reward_fn": "accuracy"},
        )
        wrapper = GRPOTrainerWrapper(cfg, device="cpu")
        assert wrapper.device == "cpu"


# --- v0.10.3: use_cpu error message ---


class TestUseCPUErrorMessage:
    """Test that use_cpu error is mapped to a friendly message."""

    def test_use_cpu_error_mapped(self):
        """use_cpu error should have a friendly message."""
        from soup_cli.utils.errors import ERROR_MAP

        patterns = [pattern for pattern, _, _ in ERROR_MAP]
        assert any("use_cpu" in p for p in patterns)


# --- v0.10.5: PPO dataset parameter compatibility (trl >=0.28) ---


class TestPPODatasetCompat:
    """Test PPO trainer handles dataset param removal in newer trl versions."""

    def test_ppo_setup_checks_dataset_in_constructor(self):
        """PPO setup should check whether dataset/train_dataset is accepted."""
        import inspect

        from soup_cli.trainer import ppo

        source = inspect.getsource(ppo)
        # Must check both train_dataset and dataset params
        assert '"train_dataset" in ppo_trainer_params' in source
        assert '"dataset" in ppo_trainer_params' in source
        # Must track whether dataset was passed to constructor
        assert "_dataset_in_constructor" in source

    def test_ppo_train_sets_dataset_if_not_in_constructor(self):
        """PPO _train_builtin should set dataset on trainer if not in init."""
        import inspect

        from soup_cli.trainer.ppo import PPOTrainerWrapper

        source = inspect.getsource(PPOTrainerWrapper._train_builtin)
        assert "_dataset_in_constructor" in source
        assert "train_dataset" in source

    def test_ppo_dataset_in_constructor_flag(self):
        """PPOTrainerWrapper should track _dataset_in_constructor after setup."""
        from unittest.mock import MagicMock
        from unittest.mock import patch as mock_patch

        from soup_cli.trainer.ppo import PPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="ppo",
            data={"train": "./data.jsonl"},
        )
        wrapper = PPOTrainerWrapper(cfg, device="cpu")

        # Real class whose __init__ does NOT accept dataset/train_dataset
        class FakePPOTrainer:
            def __init__(self, *, model=None, args=None,
                         processing_class=None, reward_funcs=None):
                pass

        class FakePPOConfig:
            def __init__(self, **kwargs):
                pass

        dataset = {
            "train": [
                {"prompt": "What is 2+2?", "answer": "4"},
            ]
        }

        with mock_patch("soup_cli.trainer.ppo.PPOTrainerWrapper._setup_reward"), \
             mock_patch(
                 "soup_cli.trainer.ppo.PPOTrainerWrapper._setup_transformers"
             ), mock_patch(
                 "soup_cli.trainer.ppo._import_ppo_classes",
                 return_value=(FakePPOTrainer, FakePPOConfig, False),
             ):
            wrapper.model = MagicMock()
            wrapper.model.get_nb_trainable_parameters.return_value = (100, 1000)
            mock_tokenizer = MagicMock()
            mock_tokenizer.pad_token = "pad"
            mock_tokenizer.side_effect = lambda texts, **kw: {
                "input_ids": [[1, 2, 3]] * (len(texts) if isinstance(texts, list) else 1),
                "attention_mask": [[1, 1, 1]] * (len(texts) if isinstance(texts, list) else 1),
            }
            wrapper.tokenizer = mock_tokenizer

            wrapper.setup(dataset)

        # dataset should NOT be in constructor since FakePPOTrainer
        # doesn't accept it (uses "args" path but no train_dataset param)
        assert wrapper._dataset_in_constructor is False

    def test_ppo_dataset_in_constructor_when_accepted(self):
        """_dataset_in_constructor should be True when train_dataset is accepted."""
        from unittest.mock import MagicMock
        from unittest.mock import patch as mock_patch

        from soup_cli.trainer.ppo import PPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="ppo",
            data={"train": "./data.jsonl"},
        )
        wrapper = PPOTrainerWrapper(cfg, device="cpu")

        # Real class whose __init__ DOES accept train_dataset
        class FakePPOTrainer:
            def __init__(self, *, model=None, args=None,
                         processing_class=None, train_dataset=None,
                         reward_funcs=None):
                pass

        class FakePPOConfig:
            def __init__(self, **kwargs):
                pass

        dataset = {
            "train": [
                {"prompt": "What is 2+2?", "answer": "4"},
            ]
        }

        with mock_patch("soup_cli.trainer.ppo.PPOTrainerWrapper._setup_reward"), \
             mock_patch(
                 "soup_cli.trainer.ppo.PPOTrainerWrapper._setup_transformers"
             ), mock_patch(
                 "soup_cli.trainer.ppo._import_ppo_classes",
                 return_value=(FakePPOTrainer, FakePPOConfig, False),
             ):
            wrapper.model = MagicMock()
            wrapper.model.get_nb_trainable_parameters.return_value = (100, 1000)
            mock_tokenizer = MagicMock()
            mock_tokenizer.pad_token = "pad"
            mock_tokenizer.side_effect = lambda texts, **kw: {
                "input_ids": [[1, 2, 3]] * (len(texts) if isinstance(texts, list) else 1),
                "attention_mask": [[1, 1, 1]] * (len(texts) if isinstance(texts, list) else 1),
            }
            wrapper.tokenizer = mock_tokenizer

            wrapper.setup(dataset)

        assert wrapper._dataset_in_constructor is True


# --- BUG-007: PPO trl >=0.28 experimental API missing positional args (v0.10.6) ---


class TestPPOExperimentalImport:
    """Test _import_ppo_classes handles both trl paths."""

    def test_import_ppo_classes_returns_tuple(self):
        """_import_ppo_classes should return (PPOTrainer, PPOConfig, bool)."""
        from soup_cli.trainer.ppo import _import_ppo_classes

        result = _import_ppo_classes()
        assert isinstance(result, tuple)
        assert len(result) == 3
        trainer_cls, config_cls, is_exp = result
        assert trainer_cls is not None
        assert config_cls is not None
        assert isinstance(is_exp, bool)

    def test_import_experimental_fallback(self):
        """When trl.experimental is unavailable, should fall back to trl."""
        import soup_cli.trainer.ppo as ppo_mod

        # Just verify the function works without error (it handles
        # ImportError from trl.experimental internally)
        result = ppo_mod._import_ppo_classes()
        assert len(result) == 3


class TestPPOExperimentalSetup:
    """Test PPO setup handles experimental API with required positional args."""

    def test_experimental_api_passes_required_args(self):
        """When is_experimental=True, setup should pass ref_model, reward_model,
        train_dataset, and value_model to PPOTrainer."""
        from unittest.mock import MagicMock
        from unittest.mock import patch as mock_patch

        from soup_cli.trainer.ppo import PPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="ppo",
            data={"train": "./data.jsonl"},
        )
        wrapper = PPOTrainerWrapper(cfg, device="cpu")

        dataset = {"train": [{"prompt": "What is 2+2?", "answer": "4"}]}

        # Track what args PPOTrainer receives
        captured_kwargs = {}

        class FakePPOTrainer:
            def __init__(self, **kwargs):
                captured_kwargs.update(kwargs)

        class FakePPOConfig:
            def __init__(self, **kwargs):
                pass

        fake_reward_model = MagicMock()
        fake_value_model = MagicMock()

        with mock_patch("soup_cli.trainer.ppo.PPOTrainerWrapper._setup_reward"), \
             mock_patch("soup_cli.trainer.ppo.PPOTrainerWrapper._setup_transformers"), \
             mock_patch(
                 "soup_cli.trainer.ppo._import_ppo_classes",
                 return_value=(FakePPOTrainer, FakePPOConfig, True),
             ), \
             mock_patch(
                 "soup_cli.trainer.ppo.PPOTrainerWrapper._get_or_create_reward_model",
                 return_value=fake_reward_model,
             ), \
             mock_patch(
                 "soup_cli.trainer.ppo.PPOTrainerWrapper._create_value_model",
                 return_value=fake_value_model,
             ):
            wrapper.model = MagicMock()
            wrapper.model.get_nb_trainable_parameters.return_value = (100, 1000)
            mock_tokenizer = MagicMock()
            mock_tokenizer.pad_token = "pad"
            mock_tokenizer.side_effect = lambda texts, **kw: {
                "input_ids": [[1, 2, 3]] * (len(texts) if isinstance(texts, list) else 1),
                "attention_mask": [[1, 1, 1]] * (len(texts) if isinstance(texts, list) else 1),
            }
            wrapper.tokenizer = mock_tokenizer

            wrapper.setup(dataset)

        # Verify required positional args were passed
        assert "ref_model" in captured_kwargs
        assert captured_kwargs["ref_model"] is None  # auto-create
        assert "reward_model" in captured_kwargs
        assert captured_kwargs["reward_model"] is fake_reward_model
        assert "train_dataset" in captured_kwargs
        assert "value_model" in captured_kwargs
        assert captured_kwargs["value_model"] is fake_value_model
        assert "args" in captured_kwargs
        assert "processing_class" in captured_kwargs
        assert wrapper._dataset_in_constructor is True

    def test_legacy_api_no_positional_args(self):
        """When is_experimental=False and no args param, should use old API."""
        from unittest.mock import MagicMock
        from unittest.mock import patch as mock_patch

        from soup_cli.trainer.ppo import PPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="ppo",
            data={"train": "./data.jsonl"},
        )
        wrapper = PPOTrainerWrapper(cfg, device="cpu")

        dataset = {"train": [{"prompt": "What is 2+2?", "answer": "4"}]}

        captured_kwargs = {}

        class FakePPOTrainer:
            def __init__(self, **kwargs):
                captured_kwargs.update(kwargs)

        class FakePPOConfig:
            def __init__(self, **kwargs):
                pass

        with mock_patch("soup_cli.trainer.ppo.PPOTrainerWrapper._setup_reward"), \
             mock_patch("soup_cli.trainer.ppo.PPOTrainerWrapper._setup_transformers"), \
             mock_patch(
                 "soup_cli.trainer.ppo._import_ppo_classes",
                 return_value=(FakePPOTrainer, FakePPOConfig, False),
             ):
            wrapper.model = MagicMock()
            wrapper.model.get_nb_trainable_parameters.return_value = (100, 1000)
            mock_tokenizer = MagicMock()
            mock_tokenizer.pad_token = "pad"
            mock_tokenizer.side_effect = lambda texts, **kw: {
                "input_ids": [[1, 2, 3]] * (len(texts) if isinstance(texts, list) else 1),
                "attention_mask": [[1, 1, 1]] * (len(texts) if isinstance(texts, list) else 1),
            }
            wrapper.tokenizer = mock_tokenizer

            wrapper.setup(dataset)

        # Old API uses config= and tokenizer=
        assert "config" in captured_kwargs
        assert "tokenizer" in captured_kwargs
        assert "dataset" in captured_kwargs
        assert "ref_model" not in captured_kwargs
        assert "value_model" not in captured_kwargs


# --- BUG-008: GRPO CPU empty generation tensor mismatch (v0.10.6) ---


def _trl_grpo_importable() -> bool:
    """Detect whether trl.trainer.grpo_trainer can be imported on this host.

    Upstream trl occasionally ships source files that read auxiliary data
    without an explicit encoding. On Windows with the default cp1252
    (``charmap``) codec this fails at import time with a ``UnicodeDecodeError``.
    Our CI forces ``PYTHONUTF8=1`` for safety; this helper is a belt-and-braces
    check so the test skips cleanly instead of erroring out if the env var is
    missing locally.
    """
    try:
        import trl  # noqa: F401
        import trl.trainer.grpo_trainer  # noqa: F401
    except (UnicodeDecodeError, ImportError, RuntimeError):
        return False
    return True


class TestGRPOCPUMinNewTokens:
    """Test GRPO CPU workaround: generation_kwargs with min_new_tokens."""

    def test_cpu_adds_generation_kwargs(self):
        """On CPU, GRPO setup should add generation_kwargs with min_new_tokens."""
        if not _trl_grpo_importable():
            pytest.skip(
                "trl.trainer.grpo_trainer not importable on this host "
                "(likely Windows cp1252 without PYTHONUTF8=1)"
            )
        from unittest.mock import MagicMock
        from unittest.mock import patch as mock_patch

        from soup_cli.trainer.grpo import GRPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="grpo",
            data={"train": "./data.jsonl"},
        )
        wrapper = GRPOTrainerWrapper(cfg, device="cpu")

        dataset = {"train": [{"prompt": "What is 2+2?", "answer": "4"}]}

        captured_config_kwargs = {}

        class FakeGRPOConfig:
            def __init__(self, use_cpu=None, generation_kwargs=None, **kwargs):
                captured_config_kwargs.update(kwargs)
                if use_cpu is not None:
                    captured_config_kwargs["use_cpu"] = use_cpu
                if generation_kwargs is not None:
                    captured_config_kwargs["generation_kwargs"] = generation_kwargs

        class FakeGRPOTrainer:
            def __init__(self, **kwargs):
                pass

        with mock_patch("soup_cli.trainer.grpo.GRPOTrainerWrapper._setup_transformers"), \
             mock_patch("trl.GRPOConfig", FakeGRPOConfig), \
             mock_patch("trl.GRPOTrainer", FakeGRPOTrainer):
            wrapper.model = MagicMock()
            wrapper.model.get_nb_trainable_parameters.return_value = (100, 1000)
            wrapper.tokenizer = MagicMock()
            wrapper.tokenizer.pad_token = "pad"

            wrapper.setup(dataset)

        # Should have generation_kwargs with min_new_tokens on CPU
        gen_kwargs = captured_config_kwargs.get("generation_kwargs", {})
        assert gen_kwargs.get("min_new_tokens") == 1

    def test_gpu_no_generation_kwargs(self):
        """On GPU, GRPO setup should NOT add generation_kwargs for min_new_tokens."""
        if not _trl_grpo_importable():
            pytest.skip(
                "trl.trainer.grpo_trainer not importable on this host "
                "(likely Windows cp1252 without PYTHONUTF8=1)"
            )
        from unittest.mock import MagicMock
        from unittest.mock import patch as mock_patch

        from soup_cli.trainer.grpo import GRPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="grpo",
            data={"train": "./data.jsonl"},
        )
        wrapper = GRPOTrainerWrapper(cfg, device="cuda")

        dataset = {"train": [{"prompt": "What is 2+2?", "answer": "4"}]}

        captured_config_kwargs = {}

        class FakeGRPOConfig:
            def __init__(self, use_cpu=None, generation_kwargs=None, **kwargs):
                captured_config_kwargs.update(kwargs)
                if use_cpu is not None:
                    captured_config_kwargs["use_cpu"] = use_cpu
                if generation_kwargs is not None:
                    captured_config_kwargs["generation_kwargs"] = generation_kwargs

        class FakeGRPOTrainer:
            def __init__(self, **kwargs):
                pass

        with mock_patch("soup_cli.trainer.grpo.GRPOTrainerWrapper._setup_transformers"), \
             mock_patch("trl.GRPOConfig", FakeGRPOConfig), \
             mock_patch("trl.GRPOTrainer", FakeGRPOTrainer):
            wrapper.model = MagicMock()
            wrapper.model.get_nb_trainable_parameters.return_value = (100, 1000)
            wrapper.tokenizer = MagicMock()
            wrapper.tokenizer.pad_token = "pad"

            wrapper.setup(dataset)

        # Should NOT have generation_kwargs on GPU
        assert "generation_kwargs" not in captured_config_kwargs


# --- BUG-009: PPO train() rejects resume_from_checkpoint (v0.10.7) ---


class TestPPOResumeCheckpoint:
    """Test PPO _train_builtin skips resume_from_checkpoint for experimental API."""

    def test_train_builtin_skips_resume_when_unsupported(self):
        """_train_builtin should call train() without resume_from_checkpoint
        when the trainer's .train() method doesn't accept it."""
        from unittest.mock import MagicMock

        from soup_cli.trainer.ppo import PPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="ppo",
            data={"train": "./data.jsonl"},
        )
        wrapper = PPOTrainerWrapper(cfg, device="cpu")
        wrapper._output_dir = "/tmp/test"
        wrapper._dataset_in_constructor = True
        wrapper._train_ds = MagicMock()

        # Create a real callable with no params (like experimental PPOTrainer.train)
        call_log = []

        def no_args_train():
            call_log.append("called")

        mock_trainer = MagicMock()
        mock_trainer.train = no_args_train
        mock_trainer.state.log_history = [{"loss": 0.5}]
        mock_trainer.state.global_step = 10
        wrapper.trainer = mock_trainer
        wrapper.tokenizer = MagicMock()

        result = wrapper._train_builtin(
            display=None, tracker=None, run_id="",
            resume_from_checkpoint="/tmp/ckpt",
        )

        # Should call train() without resume_from_checkpoint
        assert call_log == ["called"]
        assert result["total_steps"] == 10

    def test_train_builtin_passes_resume_when_supported(self):
        """_train_builtin should pass resume_from_checkpoint when supported."""
        from unittest.mock import MagicMock

        from soup_cli.trainer.ppo import PPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="ppo",
            data={"train": "./data.jsonl"},
        )
        wrapper = PPOTrainerWrapper(cfg, device="cpu")
        wrapper._output_dir = "/tmp/test"
        wrapper._dataset_in_constructor = True
        wrapper._train_ds = MagicMock()

        # Create a real callable that accepts resume_from_checkpoint
        call_log = []

        def resume_train(resume_from_checkpoint=None):
            call_log.append(resume_from_checkpoint)

        mock_trainer = MagicMock()
        mock_trainer.train = resume_train
        mock_trainer.state.log_history = [{"loss": 0.5}]
        mock_trainer.state.global_step = 10
        wrapper.trainer = mock_trainer
        wrapper.tokenizer = MagicMock()

        wrapper._train_builtin(
            display=None, tracker=None, run_id="",
            resume_from_checkpoint="/tmp/ckpt",
        )

        assert call_log == ["/tmp/ckpt"]

    def test_train_builtin_no_resume_calls_train_directly(self):
        """_train_builtin should call train() directly when resume is None."""
        from unittest.mock import MagicMock

        from soup_cli.trainer.ppo import PPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="ppo",
            data={"train": "./data.jsonl"},
        )
        wrapper = PPOTrainerWrapper(cfg, device="cpu")
        wrapper._output_dir = "/tmp/test"
        wrapper._dataset_in_constructor = True
        wrapper._train_ds = MagicMock()

        call_log = []

        def no_args_train():
            call_log.append("called")

        mock_trainer = MagicMock()
        mock_trainer.train = no_args_train
        mock_trainer.state.log_history = []
        mock_trainer.state.global_step = 0
        wrapper.trainer = mock_trainer
        wrapper.tokenizer = MagicMock()

        wrapper._train_builtin(
            display=None, tracker=None, run_id="",
            resume_from_checkpoint=None,
        )

        # resume is None/falsy so it should just call train()
        assert call_log == ["called"]

    def test_is_experimental_stored_on_setup(self):
        """setup() should store _is_experimental flag on the wrapper."""
        from unittest.mock import MagicMock
        from unittest.mock import patch as mock_patch

        from soup_cli.trainer.ppo import PPOTrainerWrapper

        cfg = SoupConfig(
            base="test-model",
            task="ppo",
            data={"train": "./data.jsonl"},
        )
        wrapper = PPOTrainerWrapper(cfg, device="cpu")

        class FakePPOTrainer:
            def __init__(self, **kwargs):
                pass

        class FakePPOConfig:
            def __init__(self, **kwargs):
                pass

        dataset = {"train": [{"prompt": "Q?", "answer": "A"}]}

        with mock_patch("soup_cli.trainer.ppo.PPOTrainerWrapper._setup_reward"), \
             mock_patch("soup_cli.trainer.ppo.PPOTrainerWrapper._setup_transformers"), \
             mock_patch(
                 "soup_cli.trainer.ppo._import_ppo_classes",
                 return_value=(FakePPOTrainer, FakePPOConfig, True),
             ), \
             mock_patch(
                 "soup_cli.trainer.ppo.PPOTrainerWrapper._get_or_create_reward_model",
                 return_value=MagicMock(),
             ), \
             mock_patch(
                 "soup_cli.trainer.ppo.PPOTrainerWrapper._create_value_model",
                 return_value=MagicMock(),
             ):
            wrapper.model = MagicMock()
            wrapper.model.get_nb_trainable_parameters.return_value = (100, 1000)
            mock_tokenizer = MagicMock()
            mock_tokenizer.pad_token = "pad"
            mock_tokenizer.side_effect = lambda texts, **kw: {
                "input_ids": [[1, 2, 3]] * (len(texts) if isinstance(texts, list) else 1),
                "attention_mask": [[1, 1, 1]] * (len(texts) if isinstance(texts, list) else 1),
            }
            wrapper.tokenizer = mock_tokenizer
            wrapper.setup(dataset)

        assert wrapper._is_experimental is True


# --- BUG-010: Meta tensor on CPU from device_map="auto" (v0.10.7) ---


class TestCPUDeviceMap:
    """Test that all trainers use device_map='cpu' on CPU instead of 'auto'."""

    def test_grpo_setup_uses_cpu_device_map(self):
        """GRPO _setup_transformers should use device_map='cpu' on CPU."""
        import inspect

        from soup_cli.trainer.grpo import GRPOTrainerWrapper
        source = inspect.getsource(GRPOTrainerWrapper._setup_transformers)
        assert '"cpu"' in source
        assert "self.device" in source

    def test_ppo_setup_uses_cpu_device_map(self):
        """PPO _setup_transformers should use device_map='cpu' on CPU."""
        import inspect

        from soup_cli.trainer.ppo import PPOTrainerWrapper
        source = inspect.getsource(PPOTrainerWrapper._setup_transformers)
        assert '"cpu"' in source
        assert "self.device" in source

    def test_sft_setup_uses_cpu_device_map(self):
        """SFT _setup_transformers should use device_map='cpu' on CPU."""
        import inspect

        from soup_cli.trainer.sft import SFTTrainerWrapper
        source = inspect.getsource(SFTTrainerWrapper._setup_transformers)
        assert '"cpu"' in source
        assert "self.device" in source

    def test_dpo_setup_uses_cpu_device_map(self):
        """DPO _setup_transformers should use device_map='cpu' on CPU."""
        import inspect

        from soup_cli.trainer.dpo import DPOTrainerWrapper
        source = inspect.getsource(DPOTrainerWrapper._setup_transformers)
        assert '"cpu"' in source
        assert "self.device" in source

    def test_reward_model_setup_uses_cpu_device_map(self):
        """RewardModel _setup_transformers should use device_map='cpu' on CPU."""
        import inspect

        from soup_cli.trainer.reward_model import RewardModelTrainerWrapper
        source = inspect.getsource(RewardModelTrainerWrapper._setup_transformers)
        assert '"cpu"' in source
        assert "self.device" in source

    def test_ppo_load_reward_model_uses_cpu_device_map(self):
        """_load_reward_model should use device_map='cpu' when device is cpu."""
        import inspect

        from soup_cli.trainer.ppo import _load_reward_model
        source = inspect.getsource(_load_reward_model)
        assert '"cpu"' in source
        assert "device" in source

    def test_ppo_get_or_create_reward_model_cpu(self):
        """_get_or_create_reward_model should not use device_map='auto' on CPU."""
        import inspect

        from soup_cli.trainer.ppo import PPOTrainerWrapper
        source = inspect.getsource(PPOTrainerWrapper._get_or_create_reward_model)
        # Should conditionally set device_map based on self.device
        assert "self.device" in source
        assert '"cpu"' in source

    def test_ppo_create_value_model_cpu(self):
        """_create_value_model should not use device_map='auto' on CPU."""
        import inspect

        from soup_cli.trainer.ppo import PPOTrainerWrapper
        source = inspect.getsource(PPOTrainerWrapper._create_value_model)
        assert "self.device" in source
        assert '"cpu"' in source


# --- BUG-011: GRPO missing chat_template causes ValueError (v0.10.8) ---


class TestGRPOChatTemplate:
    """Test GRPO sets a default chat template when tokenizer lacks one."""

    def test_grpo_setup_sets_default_chat_template(self):
        """GRPO setup should set chat_template if tokenizer doesn't have one."""
        import inspect

        from soup_cli.trainer.grpo import GRPOTrainerWrapper
        source = inspect.getsource(GRPOTrainerWrapper.setup)
        assert "chat_template" in source

    def test_grpo_setup_preserves_existing_chat_template(self):
        """GRPO setup should NOT overwrite an existing chat_template."""
        import inspect

        from soup_cli.trainer.grpo import GRPOTrainerWrapper
        source = inspect.getsource(GRPOTrainerWrapper.setup)
        # Should check with getattr before setting
        assert "getattr" in source

    def test_grpo_batch_size_ge_num_generations(self):
        """GRPO setup should ensure batch_size >= num_generations."""
        import inspect

        from soup_cli.trainer.grpo import GRPOTrainerWrapper
        source = inspect.getsource(GRPOTrainerWrapper.setup)
        assert "batch_size < num_gen" in source or "num_gen" in source


# --- BUG-012: PPO dataset not tokenized for experimental API (v0.10.8) ---


class TestPPOTokenization:
    """Test PPO tokenizes dataset before passing to trainer."""

    def test_ppo_setup_tokenizes_dataset(self):
        """PPO setup should call .map() to tokenize the dataset."""
        import inspect

        from soup_cli.trainer.ppo import PPOTrainerWrapper
        source = inspect.getsource(PPOTrainerWrapper.setup)
        assert "_tokenize_ppo" in source
        assert ".map(" in source

    def test_ppo_tokenization_adds_input_ids(self):
        """Tokenization should add input_ids and attention_mask columns."""
        from datasets import Dataset

        from soup_cli.trainer.ppo import _prepare_ppo_dataset

        data = [{"prompt": "What is 2+2?", "answer": "4"}]
        prepared = _prepare_ppo_dataset(data)
        ds = Dataset.from_list(prepared)

        # Verify prompt_text exists
        assert "prompt_text" in ds.column_names
        assert ds[0]["prompt_text"] == "What is 2+2?"


# --- BUG-013: soup data validate defaults to alpaca, no auto-detect (v0.14.2) ---


class TestValidateAutoDetect:
    """Validate should auto-detect format when --format is not specified."""

    def test_validate_alpaca_auto_detect(self, tmp_path):
        """Alpaca data should be auto-detected without --format flag."""
        import json

        from typer.testing import CliRunner

        from soup_cli.cli import app

        runner = CliRunner()
        data = [
            {"instruction": "What is AI?", "input": "", "output": "AI is..."},
            {"instruction": "Explain ML", "input": "", "output": "ML is..."},
        ]
        filepath = tmp_path / "alpaca.jsonl"
        with open(filepath, "w") as f:
            for row in data:
                f.write(json.dumps(row) + "\n")

        result = runner.invoke(app, ["data", "validate", str(filepath)])
        assert result.exit_code == 0
        assert "Auto-detected format: alpaca" in result.output
        assert "2/2 rows valid" in result.output

    def test_validate_plaintext_auto_detect(self, tmp_path):
        """Plaintext data should be auto-detected without --format flag."""
        import json

        from typer.testing import CliRunner

        from soup_cli.cli import app

        runner = CliRunner()
        data = [{"text": "Hello world"}, {"text": "Another line"}]
        filepath = tmp_path / "plaintext.jsonl"
        with open(filepath, "w") as f:
            for row in data:
                f.write(json.dumps(row) + "\n")

        result = runner.invoke(app, ["data", "validate", str(filepath)])
        assert result.exit_code == 0
        assert "Auto-detected format: plaintext" in result.output
        assert "2/2 rows valid" in result.output

    def test_validate_explicit_format_still_works(self, tmp_path):
        """Explicit --format flag should override auto-detection."""
        import json

        from typer.testing import CliRunner

        from soup_cli.cli import app

        runner = CliRunner()
        data = [
            {"instruction": "What is AI?", "input": "", "output": "AI is..."},
        ]
        filepath = tmp_path / "alpaca.jsonl"
        with open(filepath, "w") as f:
            for row in data:
                f.write(json.dumps(row) + "\n")

        result = runner.invoke(
            app, ["data", "validate", str(filepath), "--format", "alpaca"]
        )
        assert result.exit_code == 0
        assert "Auto-detected" not in result.output
        assert "1/1 rows valid" in result.output

    def test_validate_dpo_auto_detect(self, tmp_path):
        """DPO data should be auto-detected."""
        import json

        from typer.testing import CliRunner

        from soup_cli.cli import app

        runner = CliRunner()
        data = [
            {"prompt": "Hi", "chosen": "Hello!", "rejected": "Go away"},
        ]
        filepath = tmp_path / "dpo.jsonl"
        with open(filepath, "w") as f:
            for row in data:
                f.write(json.dumps(row) + "\n")

        result = runner.invoke(app, ["data", "validate", str(filepath)])
        assert result.exit_code == 0
        assert "Auto-detected format: dpo" in result.output


# --- BUG-014: soup data stats histogram broken on Windows (v0.14.2) ---


class TestStatsHistogramWindows:
    """Stats histogram should handle Windows encoding gracefully."""

    def test_stats_command_does_not_crash(self, tmp_path):
        """Stats command should not crash on Windows with plotext."""
        import json

        from typer.testing import CliRunner

        from soup_cli.cli import app

        runner = CliRunner()
        data = [
            {"instruction": "What is AI?", "input": "", "output": "AI is..."},
            {"instruction": "Explain ML", "input": "", "output": "ML is..."},
        ]
        filepath = tmp_path / "sample.jsonl"
        with open(filepath, "w") as f:
            for row in data:
                f.write(json.dumps(row) + "\n")

        result = runner.invoke(app, ["data", "stats", str(filepath)])
        assert result.exit_code == 0
        assert "p50" in result.output

    def test_utf8_redirect_logic_on_cp1251_stdout(self):
        """Verify plotext renders when stdout is redirected from cp1251 to utf-8."""
        import io
        import sys

        try:
            import plotext as plt
        except ImportError:
            pytest.skip("plotext not installed")

        # Simulate a cp1251 stdout (like Windows default)
        raw_buffer = io.BytesIO()
        fake_cp1251 = io.TextIOWrapper(raw_buffer, encoding="cp1251")

        original_stdout = sys.stdout
        sys.stdout = fake_cp1251

        # Apply the same redirect logic as data.py stats command
        needs_redirect = (
            hasattr(sys.stdout, "encoding")
            and (sys.stdout.encoding or "").lower().replace("-", "") != "utf8"
        )
        assert needs_redirect, "Should detect cp1251 needs redirect"

        sys.stdout = io.TextIOWrapper(
            sys.stdout.buffer, encoding="utf-8", errors="replace",
        )

        try:
            plt.clear_figure()
            plt.hist([10, 20, 30, 40, 50], bins=3)
            plt.title("Test")
            plt.theme("dark")
            plt.show()
            # If we got here, no UnicodeEncodeError — success
        finally:
            sys.stdout = original_stdout


# --- BUG-015: soup ui --help missing auth token docs (v0.14.2) ---


class TestUIAuthDocs:
    """UI --help should document auth token feature."""

    def test_ui_help_mentions_auth(self):
        """soup ui --help should mention auth token."""
        from typer.testing import CliRunner

        from soup_cli.cli import app

        runner = CliRunner()
        result = runner.invoke(app, ["ui", "--help"])
        assert result.exit_code == 0
        # Check the help output mentions auth/token
        output_lower = result.output.lower()
        assert "auth" in output_lower or "token" in output_lower

    def test_ui_help_shows_show_token_flag(self):
        """soup ui --help should show --show-token option."""
        import re

        from typer.testing import CliRunner

        from soup_cli.cli import app

        runner = CliRunner()
        result = runner.invoke(app, ["ui", "--help"])
        assert result.exit_code == 0
        # Rich markup wraps dashes with ANSI codes, so strip them
        clean = re.sub(r"\x1b\[[^m]*m", "", result.output).lower()
        assert "show-token" in clean
