from textwrap import dedent

import pytest

from respredai.io.config import ConfigHandler


class TestConfigHandler:
    """Unit tests for ConfigHandler."""

    def _make_config(self, tmp_path):
        """Helper to create a temporary config file."""

        config_text = dedent("""
        [Data]
        data_path = {data}
        targets = y
        continuous_features = age, bmi

        [Metadata]
        group_column = group

        [Pipeline]
        models = lr, rf
        outer_folds = 3
        inner_folds = 2
        calibrate_threshold = True
        threshold_method = auto

        [Reproducibility]
        seed = 42

        [Log]
        verbosity = 0
        log_basename = test.log

        [Resources]
        n_jobs = 1

        [Output]
        out_folder = {out}

        [ModelSaving]
        enable = True
        compression = 3
        """).strip()

        data_file = tmp_path / "data.csv"
        out_folder = tmp_path / "output"
        out_folder.mkdir()

        return (
            config_text.format(data=data_file, out=out_folder),
            data_file,
            out_folder,
        )

    def test_config_handler_parses_config(self, tmp_path):
        """Test that a valid config file is correctly parsed."""

        config_text, _, _ = self._make_config(tmp_path)
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))

        assert config.data_cfg.targets == ["y"]
        assert config.data_cfg.continuous_features == ["age", "bmi"]
        assert config.pipeline.models == ["lr", "rf"]
        assert config.pipeline.outer_folds == 3
        assert config.reproducibility_cfg.seed == 42
        assert config.output.save_models_enable is True
        assert config.output.model_compression == 3

    def test_invalid_model_compression(self, tmp_path):
        """Test that invalid compression values raise an error."""

        bad_config = dedent("""
        [Data]
        data_path = foo.csv
        targets = y
        continuous_features = age

        [Pipeline]
        models = lr
        outer_folds = 3
        inner_folds = 2

        [Reproducibility]
        seed = 42

        [Log]
        verbosity = 0
        log_basename = test.log

        [Resources]
        n_jobs = 1

        [Output]
        out_folder = out

        [ModelSaving]
        enable = True
        compression = 99
        """)

        config_path = tmp_path / "bad.ini"
        config_path.write_text(bad_config)

        with pytest.raises(ValueError):
            ConfigHandler(str(config_path))

    def test_threshold_method_validation(self, tmp_path):
        """Test that invalid threshold_method values raise an error."""

        bad_config = dedent("""
        [Data]
        data_path = foo.csv
        targets = y
        continuous_features = age

        [Pipeline]
        models = lr
        outer_folds = 3
        inner_folds = 2
        calibrate_threshold = true
        threshold_method = invalid_method

        [Reproducibility]
        seed = 42

        [Log]
        verbosity = 0
        log_basename = test.log

        [Resources]
        n_jobs = 1

        [Output]
        out_folder = out

        [ModelSaving]
        enable = True
        compression = 3
        """)

        config_path = tmp_path / "bad_threshold.ini"
        config_path.write_text(bad_config)

        with pytest.raises(ValueError, match="Threshold method must be"):
            ConfigHandler(str(config_path))

    @pytest.mark.parametrize("threshold_method", ["auto", "oof", "cv"])
    def test_threshold_method_parsing(self, tmp_path, threshold_method):
        """Test that all valid threshold_method values are correctly parsed."""

        config_text, _, _ = self._make_config(tmp_path)
        config_text = config_text.replace(
            "threshold_method = auto", f"threshold_method = {threshold_method}"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))

        assert config.pipeline.threshold_method == threshold_method
        assert config.pipeline.calibrate_threshold is True

    def test_model_parsing(self, tmp_path):
        """Test that model names are correctly parsed from config."""

        config_text, _, _ = self._make_config(tmp_path)
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))

        assert "lr" in config.pipeline.models
        assert "rf" in config.pipeline.models
        assert len(config.pipeline.models) == 2


class TestThresholdObjectiveConfig:
    """Unit tests for threshold objective configuration."""

    def _make_base_config(self, extra_pipeline="", extra_sections=""):
        """Create a base config string."""
        return dedent(f"""
        [Data]
        data_path = foo.csv
        targets = y
        continuous_features = age

        [Pipeline]
        models = lr
        outer_folds = 3
        inner_folds = 2
        calibrate_threshold = true
        threshold_method = oof
        {extra_pipeline}

        [Reproducibility]
        seed = 42

        [Log]
        verbosity = 0
        log_basename = test.log

        [Resources]
        n_jobs = 1

        [Output]
        out_folder = out

        [ModelSaving]
        enable = false
        compression = 3
        {extra_sections}
        """).strip()

    @pytest.mark.parametrize("objective", ["youden", "f1", "f2", "cost_sensitive"])
    def test_valid_threshold_objectives(self, tmp_path, objective):
        """Test that all valid threshold objectives are accepted."""
        config_text = self._make_base_config(f"threshold_objective = {objective}")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.threshold_objective == objective

    def test_invalid_threshold_objective_raises(self, tmp_path):
        """Test that invalid threshold_objective raises ValueError."""
        config_text = self._make_base_config("threshold_objective = invalid")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="Threshold objective must be"):
            ConfigHandler(str(config_path))

    def test_default_threshold_objective(self, tmp_path):
        """Test that threshold_objective defaults to youden."""
        config_text = self._make_base_config()
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.threshold_objective == "youden"

    def test_cost_weights_parsing(self, tmp_path):
        """Test that vme_cost and me_cost are correctly parsed."""
        config_text = self._make_base_config(
            "threshold_objective = cost_sensitive\n        vme_cost = 5.0\n        me_cost = 2.0"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.threshold_objective == "cost_sensitive"
        assert config.pipeline.vme_cost == 5.0
        assert config.pipeline.me_cost == 2.0

    def test_default_cost_weights(self, tmp_path):
        """Test that cost weights default to 1.0."""
        config_text = self._make_base_config()
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.vme_cost == 1.0
        assert config.pipeline.me_cost == 1.0

    def test_invalid_vme_cost_raises(self, tmp_path):
        """Test that non-positive vme_cost raises ValueError."""
        config_text = self._make_base_config("vme_cost = 0")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="vme_cost must be positive"):
            ConfigHandler(str(config_path))

    def test_invalid_me_cost_raises(self, tmp_path):
        """Test that non-positive me_cost raises ValueError."""
        config_text = self._make_base_config("me_cost = -1.0")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="me_cost must be positive"):
            ConfigHandler(str(config_path))

    def test_uncertainty_margin_parsing(self, tmp_path):
        """Test that uncertainty margin is correctly parsed."""
        config_text = self._make_base_config(extra_sections="[Uncertainty]\nmargin = 0.15")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.reproducibility_cfg.uncertainty_margin == 0.15

    def test_default_uncertainty_margin(self, tmp_path):
        """Test that uncertainty margin defaults to 0.1."""
        config_text = self._make_base_config()
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.reproducibility_cfg.uncertainty_margin == 0.1

    def test_invalid_uncertainty_margin_raises(self, tmp_path):
        """Test that out-of-range uncertainty margin raises ValueError."""
        config_text = self._make_base_config(extra_sections="[Uncertainty]\nmargin = 0.6")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="Uncertainty margin must be"):
            ConfigHandler(str(config_path))


class TestPreprocessingConfig:
    """Unit tests for preprocessing configuration."""

    def _make_base_config(self, extra_sections=""):
        """Create a base config string."""
        return dedent(f"""
        [Data]
        data_path = foo.csv
        targets = y
        continuous_features = age

        [Pipeline]
        models = lr
        outer_folds = 3
        inner_folds = 2

        [Reproducibility]
        seed = 42

        [Log]
        verbosity = 0
        log_basename = test.log

        [Resources]
        n_jobs = 1

        [Output]
        out_folder = out

        [ModelSaving]
        enable = false
        compression = 3
        {extra_sections}
        """).strip()

    def test_ohe_min_frequency_default_none(self, tmp_path):
        """Test that ohe_min_frequency defaults to None when not specified."""
        config_text = self._make_base_config()
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.preprocessing.ohe_min_frequency is None

    def test_ohe_min_frequency_proportion(self, tmp_path):
        """Test that ohe_min_frequency is correctly parsed as a proportion."""
        config_text = self._make_base_config("[Preprocessing]\nohe_min_frequency = 0.05")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.preprocessing.ohe_min_frequency == 0.05

    def test_ohe_min_frequency_absolute_count(self, tmp_path):
        """Test that ohe_min_frequency >= 1 is converted to int."""
        config_text = self._make_base_config("[Preprocessing]\nohe_min_frequency = 10")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.preprocessing.ohe_min_frequency == 10
        assert isinstance(config.preprocessing.ohe_min_frequency, int)

    def test_ohe_min_frequency_invalid_zero(self, tmp_path):
        """Test that ohe_min_frequency = 0 raises ValueError."""
        config_text = self._make_base_config("[Preprocessing]\nohe_min_frequency = 0")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="ohe_min_frequency must be positive"):
            ConfigHandler(str(config_path))

    def test_ohe_min_frequency_invalid_negative(self, tmp_path):
        """Test that negative ohe_min_frequency raises ValueError."""
        config_text = self._make_base_config("[Preprocessing]\nohe_min_frequency = -0.1")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="ohe_min_frequency must be positive"):
            ConfigHandler(str(config_path))


class TestProbabilityCalibrationConfig:
    """Unit tests for probability calibration configuration."""

    def _make_base_config(self, extra_pipeline=""):
        """Create a base config string."""
        return dedent(f"""
        [Data]
        data_path = foo.csv
        targets = y
        continuous_features = age

        [Pipeline]
        models = lr
        outer_folds = 3
        inner_folds = 2
        {extra_pipeline}

        [Reproducibility]
        seed = 42

        [Log]
        verbosity = 0
        log_basename = test.log

        [Resources]
        n_jobs = 1

        [Output]
        out_folder = out

        [ModelSaving]
        enable = false
        compression = 3
        """).strip()

    def test_default_calibration_disabled(self, tmp_path):
        """Test that probability calibration defaults to disabled."""
        config_text = self._make_base_config()
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.calibrate_probabilities is False

    def test_calibration_enabled(self, tmp_path):
        """Test that probability calibration can be enabled."""
        config_text = self._make_base_config("calibrate_probabilities = true")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.calibrate_probabilities is True

    @pytest.mark.parametrize("method", ["sigmoid", "isotonic"])
    def test_valid_calibration_methods(self, tmp_path, method):
        """Test that both sigmoid and isotonic methods are valid."""
        config_text = self._make_base_config(
            f"calibrate_probabilities = true\n        probability_calibration_method = {method}"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.probability_calibration_method == method

    def test_invalid_calibration_method_raises(self, tmp_path):
        """Test that invalid calibration method raises ValueError."""
        config_text = self._make_base_config(
            "calibrate_probabilities = true\n        probability_calibration_method = invalid"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="Probability calibration method must be"):
            ConfigHandler(str(config_path))

    def test_default_calibration_method(self, tmp_path):
        """Test that calibration method defaults to sigmoid."""
        config_text = self._make_base_config("calibrate_probabilities = true")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.probability_calibration_method == "sigmoid"

    def test_default_calibration_cv(self, tmp_path):
        """Test that calibration CV folds defaults to 5."""
        config_text = self._make_base_config("calibrate_probabilities = true")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.probability_calibration_cv == 5

    def test_custom_calibration_cv(self, tmp_path):
        """Test that custom CV folds can be set."""
        config_text = self._make_base_config(
            "calibrate_probabilities = true\n        probability_calibration_cv = 3"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.probability_calibration_cv == 3

    def test_invalid_calibration_cv_raises(self, tmp_path):
        """Test that CV folds < 2 raises ValueError."""
        config_text = self._make_base_config(
            "calibrate_probabilities = true\n        probability_calibration_cv = 1"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="probability_calibration_cv must be >= 2"):
            ConfigHandler(str(config_path))


class TestRepeatedCVConfig:
    """Unit tests for repeated cross-validation configuration."""

    def _make_base_config(self, extra_pipeline=""):
        """Create a base config string."""
        return dedent(f"""
        [Data]
        data_path = foo.csv
        targets = y
        continuous_features = age

        [Pipeline]
        models = lr
        outer_folds = 3
        inner_folds = 2
        {extra_pipeline}

        [Reproducibility]
        seed = 42

        [Log]
        verbosity = 0
        log_basename = test.log

        [Resources]
        n_jobs = 1

        [Output]
        out_folder = out

        [ModelSaving]
        enable = false
        compression = 3
        """).strip()

    def test_default_repeats_one(self, tmp_path):
        """Test that outer_cv_repeats defaults to 1."""
        config_text = self._make_base_config()
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.outer_cv_repeats == 1

    def test_custom_repeats(self, tmp_path):
        """Test that custom repeats can be set."""
        config_text = self._make_base_config("outer_cv_repeats = 5")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.pipeline.outer_cv_repeats == 5

    def test_invalid_repeats_zero_raises(self, tmp_path):
        """Test that outer_cv_repeats = 0 raises ValueError."""
        config_text = self._make_base_config("outer_cv_repeats = 0")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="outer_cv_repeats must be >= 1"):
            ConfigHandler(str(config_path))

    def test_invalid_repeats_negative_raises(self, tmp_path):
        """Test that negative outer_cv_repeats raises ValueError."""
        config_text = self._make_base_config("outer_cv_repeats = -1")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="outer_cv_repeats must be >= 1"):
            ConfigHandler(str(config_path))


class TestTemporalValidationConfig:
    """Unit tests for temporal validation configuration."""

    def _make_base_config(self, extra_sections=""):
        """Create a base config string."""
        return dedent(f"""
        [Data]
        data_path = foo.csv
        targets = y
        continuous_features = age

        [Pipeline]
        models = lr
        outer_folds = 3
        inner_folds = 2

        [Reproducibility]
        seed = 42

        [Log]
        verbosity = 0
        log_basename = test.log

        [Resources]
        n_jobs = 1

        [Output]
        out_folder = out

        [ModelSaving]
        enable = false
        compression = 3
        {extra_sections}
        """).strip()

    def test_default_strategy_is_cv(self, tmp_path):
        """Test that validation_strategy defaults to cv."""
        config_text = self._make_base_config()
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.validation.strategy == "cv"
        assert config.metadata.temporal_column is None
        assert config.validation.temporal_split_date is None
        assert config.validation.temporal_split_ratio is None

    @pytest.mark.parametrize("strategy", ["cv", "temporal", "both"])
    def test_valid_strategies(self, tmp_path, strategy):
        """Test that all valid strategies are accepted."""
        extra = f"[Validation]\nvalidation_strategy = {strategy}"
        if strategy in ("temporal", "both"):
            extra += "\ntemporal_split_date = 2023-01-01\n\n[Metadata]\ntemporal_column = date"
        config_text = self._make_base_config(extra)
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.validation.strategy == strategy

    def test_invalid_strategy_raises(self, tmp_path):
        """Test that invalid validation_strategy raises ValueError."""
        config_text = self._make_base_config("[Validation]\nvalidation_strategy = invalid")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="validation_strategy must be"):
            ConfigHandler(str(config_path))

    def test_temporal_missing_column_raises(self, tmp_path):
        """Test that temporal strategy without temporal_column raises."""
        config_text = self._make_base_config(
            "[Validation]\nvalidation_strategy = temporal\ntemporal_split_date = 2023-01-01"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="temporal_column is required"):
            ConfigHandler(str(config_path))

    def test_temporal_both_date_and_ratio_raises(self, tmp_path):
        """Test that setting both split_date and split_ratio raises."""
        config_text = self._make_base_config(
            "[Validation]\nvalidation_strategy = temporal\n"
            "temporal_split_date = 2023-01-01\n"
            "temporal_split_ratio = 0.8\n\n"
            "[Metadata]\ntemporal_column = date"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="Only one of"):
            ConfigHandler(str(config_path))

    def test_temporal_neither_date_nor_ratio_raises(self, tmp_path):
        """Test that missing both split_date and split_ratio raises."""
        config_text = self._make_base_config(
            "[Validation]\nvalidation_strategy = temporal\n\n[Metadata]\ntemporal_column = date"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="Either temporal_split_date or"):
            ConfigHandler(str(config_path))

    def test_invalid_split_ratio_raises(self, tmp_path):
        """Test that split_ratio outside (0, 1) raises."""
        for ratio in ["0", "1", "1.5", "-0.1"]:
            config_text = self._make_base_config(
                f"[Validation]\nvalidation_strategy = temporal\n"
                f"temporal_split_ratio = {ratio}\n\n"
                f"[Metadata]\ntemporal_column = date"
            )
            config_path = tmp_path / f"config_{ratio}.ini"
            config_path.write_text(config_text)

            with pytest.raises(ValueError, match="temporal_split_ratio must be"):
                ConfigHandler(str(config_path))

    def test_invalid_split_date_raises(self, tmp_path):
        """Test that unparseable split_date raises."""
        config_text = self._make_base_config(
            "[Validation]\nvalidation_strategy = temporal\n"
            "temporal_split_date = not-a-date\n\n"
            "[Metadata]\ntemporal_column = date"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        with pytest.raises(ValueError, match="is not a valid date"):
            ConfigHandler(str(config_path))

    def test_temporal_with_date(self, tmp_path):
        """Test valid temporal config with split_date."""
        config_text = self._make_base_config(
            "[Validation]\nvalidation_strategy = temporal\n"
            "temporal_split_date = 2023-06-15\n\n"
            "[Metadata]\ntemporal_column = collection_date"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.validation.strategy == "temporal"
        assert config.metadata.temporal_column == "collection_date"
        assert config.validation.temporal_split_date == "2023-06-15"
        assert config.validation.temporal_split_ratio is None

    def test_temporal_with_ratio(self, tmp_path):
        """Test valid temporal config with split_ratio."""
        config_text = self._make_base_config(
            "[Validation]\nvalidation_strategy = temporal\n"
            "temporal_split_ratio = 0.75\n\n"
            "[Metadata]\ntemporal_column = date"
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.validation.strategy == "temporal"
        assert config.validation.temporal_split_ratio == 0.75
        assert config.validation.temporal_split_date is None


class TestEmptyValueNormalization:
    """Verify that empty config values (key =) are normalized to None."""

    def _make_base_config(self, extra_sections=""):
        return dedent(f"""
        [Data]
        data_path = foo.csv
        targets = y
        continuous_features = age

        [Pipeline]
        models = lr
        outer_folds = 3
        inner_folds = 2

        [Reproducibility]
        seed = 42

        [Log]
        verbosity = 0
        log_basename = test.log

        [Resources]
        n_jobs = 1

        [Output]
        out_folder = out

        [ModelSaving]
        enable = false
        compression = 3
        {extra_sections}
        """).strip()

    def test_empty_group_column_is_none(self, tmp_path):
        config_text = self._make_base_config("[Metadata]\ngroup_column =")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.metadata.group_column is None

    def test_empty_temporal_column_is_none(self, tmp_path):
        config_text = self._make_base_config("[Metadata]\ntemporal_column =")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.metadata.temporal_column is None

    def test_empty_subgroup_columns_is_empty_list(self, tmp_path):
        config_text = self._make_base_config("[Metadata]\nsubgroup_columns =")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.metadata.subgroup_columns == []

    def test_empty_temporal_split_ratio_is_none(self, tmp_path):
        config_text = self._make_base_config(
            "[Validation]\nvalidation_strategy = cv\ntemporal_split_ratio ="
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.validation.temporal_split_ratio is None

    def test_empty_temporal_split_date_is_none(self, tmp_path):
        config_text = self._make_base_config(
            "[Validation]\nvalidation_strategy = cv\ntemporal_split_date ="
        )
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.validation.temporal_split_date is None

    def test_empty_ohe_min_frequency_is_none(self, tmp_path):
        config_text = self._make_base_config("[Preprocessing]\nohe_min_frequency =")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.preprocessing.ohe_min_frequency is None

    def test_whitespace_only_group_column_is_none(self, tmp_path):
        config_text = self._make_base_config("[Metadata]\ngroup_column =   ")
        config_path = tmp_path / "config.ini"
        config_path.write_text(config_text)

        config = ConfigHandler(str(config_path))
        assert config.metadata.group_column is None
