import pytest
import yaml

import dagzoo.config.models as config_models
from dagzoo.config import (
    MAX_SUPPORTED_CLASS_COUNT,
    MISSINGNESS_MECHANISM_MAR,
    MISSINGNESS_MECHANISM_MCAR,
    MISSINGNESS_MECHANISM_MNAR,
    MISSINGNESS_MECHANISM_NONE,
    NOISE_MIXTURE_COMPONENT_GAUSSIAN,
    NOISE_MIXTURE_COMPONENT_LAPLACE,
    NOISE_MIXTURE_COMPONENT_STUDENT_T,
    GeneratorConfig,
)
from dagzoo.config.models import (
    steering_preset_definition,
    steering_stage_definitions,
    stress_profile_definition,
)
from dagzoo.io.lineage_schema import validate_metadata_lineage

_STRESS_PROFILE = "anti_memorization_piecewise_classification_slice_v1"


def _stage(
    *,
    name: object,
    fraction: object,
    dataset: dict[str, object] | None = None,
    shift: dict[str, object] | None = None,
    noise: dict[str, object] | None = None,
    **overrides: object,
) -> dict[str, object]:
    stage: dict[str, object] = {"name": name, "fraction": fraction, **overrides}
    if dataset is not None:
        stage.update(
            {
                "missing_rate": dataset.get("missing_rate"),
                "missing_mechanism": dataset.get("missing_mechanism"),
                "missing_mar_observed_fraction": dataset.get("missing_mar_observed_fraction"),
                "missing_mar_logit_scale": dataset.get("missing_mar_logit_scale"),
                "missing_mnar_logit_scale": dataset.get("missing_mnar_logit_scale"),
            }
        )
    if shift is not None:
        stage.update(
            {
                "shift_mode": shift.get("mode"),
                "shift_graph_scale": shift.get("graph_scale"),
                "shift_variance_scale": shift.get("variance_scale"),
            }
        )
    if noise is not None:
        stage.update(
            {
                "noise_family": noise.get("family"),
                "noise_student_t_df": noise.get("student_t_df"),
                "noise_mixture_weights": noise.get("mixture_weights"),
            }
        )
    return stage


def test_load_default_config() -> None:
    cfg = GeneratorConfig.from_yaml("configs/default.yaml")
    assert cfg.dataset.n_train > 0
    assert cfg.dataset.rows is None
    assert cfg.dataset.n_features_min <= cfg.dataset.n_features_max
    assert cfg.dataset.target_parent_prior == "near_max_mixture"
    assert cfg.dataset.target_parent_count_min == 1
    assert cfg.dataset.target_parent_count_max is None
    assert cfg.dataset.target_parent_near_max_band_min_fraction == pytest.approx(0.75)
    assert cfg.dataset.target_parent_below_sqrt_prob == pytest.approx(0.05)
    assert cfg.dataset.target_parent_midrange_prob == pytest.approx(0.20)
    assert cfg.output.shard_size > 0
    assert cfg.diagnostics.enabled is False
    assert cfg.diagnostics.histogram_bins > 0
    assert cfg.diagnostics.quantiles
    assert cfg.diagnostics.underrepresented_threshold >= 0
    assert (
        cfg.diagnostics.max_values_per_metric is None or cfg.diagnostics.max_values_per_metric > 0
    )
    assert cfg.diagnostics.meta_feature_targets == {}
    assert cfg.diagnostics.out_dir is None
    assert cfg.filter.n_estimators > 0
    assert cfg.filter.max_depth >= 0
    assert cfg.filter.max_features == "auto"
    assert cfg.filter.n_jobs == -1
    assert cfg.dataset.missing_rate == 0.0
    assert cfg.dataset.missing_mechanism == MISSINGNESS_MECHANISM_NONE
    assert cfg.dataset.missing_mar_observed_fraction == 0.5
    assert cfg.dataset.missing_mar_logit_scale == 1.0
    assert cfg.dataset.missing_mnar_logit_scale == 1.0
    assert cfg.steering.enabled is False
    assert cfg.steering.preset is None
    assert cfg.steering.stages == []
    assert cfg.stress.profile is None


def test_config_package_reexports_noise_mixture_component_constants() -> None:
    assert NOISE_MIXTURE_COMPONENT_GAUSSIAN == "gaussian"
    assert NOISE_MIXTURE_COMPONENT_LAPLACE == "laplace"
    assert NOISE_MIXTURE_COMPONENT_STUDENT_T == "student_t"


def test_default_config_metadata_is_compatible_with_optional_lineage() -> None:
    cfg = GeneratorConfig.from_yaml("configs/default.yaml")
    metadata = {
        "seed": int(cfg.seed),
        "config": cfg.to_dict(),
    }
    validate_metadata_lineage(metadata, required=False)


def test_seed_range_accepts_32bit_boundaries() -> None:
    cfg_min = GeneratorConfig.from_dict({"seed": 0})
    cfg_max = GeneratorConfig.from_dict({"seed": 4294967295})
    assert cfg_min.seed == 0
    assert cfg_max.seed == 4294967295


@pytest.mark.parametrize("bad_seed", [-1, 4294967296, True])
def test_seed_range_rejects_out_of_bounds_values(bad_seed: int | bool) -> None:
    with pytest.raises(ValueError, match=r"seed must be an integer in \[0, 4294967295\]"):
        GeneratorConfig.from_dict({"seed": bad_seed})


def test_class_range_accepts_many_class_envelope_limit() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "dataset": {
                "task": "classification",
                "n_classes_min": 12,
                "n_classes_max": MAX_SUPPORTED_CLASS_COUNT,
            }
        }
    )
    assert cfg.dataset.n_classes_min == 12
    assert cfg.dataset.n_classes_max == MAX_SUPPORTED_CLASS_COUNT


def test_class_range_rejects_values_above_many_class_envelope_limit() -> None:
    with pytest.raises(ValueError, match=r"dataset\.n_classes_max must be an integer in \[2, 32\]"):
        GeneratorConfig.from_dict({"dataset": {"task": "classification", "n_classes_max": 33}})


def test_class_range_rejects_min_greater_than_max() -> None:
    with pytest.raises(ValueError, match=r"dataset\.n_classes_min must be <= n_classes_max"):
        GeneratorConfig.from_dict(
            {"dataset": {"task": "classification", "n_classes_min": 16, "n_classes_max": 12}}
        )


def test_classification_rejects_impossible_class_split_bounds() -> None:
    with pytest.raises(ValueError, match=r"infeasible class/split combination"):
        GeneratorConfig.from_dict(
            {
                "dataset": {
                    "task": "classification",
                    "n_train": 24,
                    "n_test": 24,
                    "n_classes_min": 32,
                    "n_classes_max": 32,
                }
            }
        )


def test_classification_rejects_partial_class_range_when_upper_bound_is_infeasible() -> None:
    with pytest.raises(
        ValueError, match=r"dataset classification split constraints for n_classes_max"
    ):
        GeneratorConfig.from_dict(
            {
                "dataset": {
                    "task": "classification",
                    "n_train": 32,
                    "n_test": 8,
                    "n_classes_min": 2,
                    "n_classes_max": 10,
                }
            }
        )


def test_dataset_rows_accepts_fixed_and_range() -> None:
    cfg_fixed = GeneratorConfig.from_dict({"dataset": {"rows": 1024}})
    assert cfg_fixed.dataset.rows is not None
    assert cfg_fixed.dataset.rows.mode == "fixed"
    assert cfg_fixed.dataset.rows.value == 1024

    cfg_range = GeneratorConfig.from_dict({"dataset": {"rows": "400..60000"}})
    assert cfg_range.dataset.rows is not None
    assert cfg_range.dataset.rows.mode == "range"
    assert cfg_range.dataset.rows.start == 400
    assert cfg_range.dataset.rows.stop == 60000


@pytest.mark.parametrize("rows_value", [399, 60001, "399", "60001", "300..600"])
def test_dataset_rows_rejects_out_of_bounds_values(rows_value: object) -> None:
    with pytest.raises(ValueError, match=r"dataset\.rows"):
        GeneratorConfig.from_dict({"dataset": {"rows": rows_value}})


def test_dataset_rows_rejects_inverted_range() -> None:
    with pytest.raises(ValueError, match=r"dataset\.rows range start must be <= stop"):
        GeneratorConfig.from_dict({"dataset": {"rows": "2000..400"}})


@pytest.mark.parametrize("rows_value", [[400, 1024, 60000], "1024,1024"])
def test_dataset_rows_rejects_removed_legacy_shapes(rows_value: object) -> None:
    with pytest.raises(ValueError, match=r"dataset\.rows"):
        GeneratorConfig.from_dict({"dataset": {"rows": rows_value}})


def test_dataset_rows_classification_checks_min_realizable_train_rows() -> None:
    with pytest.raises(
        ValueError, match=r"dataset classification split constraints for n_classes_max"
    ):
        GeneratorConfig.from_dict(
            {
                "dataset": {
                    "task": "classification",
                    "rows": "400..600",
                    "n_test": 380,
                    "n_classes_min": 2,
                    "n_classes_max": 32,
                }
            }
        )


def test_regression_allows_class_fields_without_split_feasibility_check() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "dataset": {
                "task": "regression",
                "n_train": 24,
                "n_test": 24,
                "n_classes_min": 2,
                "n_classes_max": 32,
            }
        }
    )
    assert cfg.dataset.n_classes_max == 32


@pytest.mark.parametrize("value", [-0.1, 1.1, float("inf"), float("nan"), True])
def test_categorical_ratio_min_bounds_are_validated(value: float | bool) -> None:
    with pytest.raises(
        ValueError, match="dataset.categorical_ratio_min must be a finite value in \\[0, 1\\]"
    ):
        GeneratorConfig.from_dict({"dataset": {"categorical_ratio_min": value}})


@pytest.mark.parametrize("value", [-0.1, 1.1, float("inf"), float("nan"), True])
def test_categorical_ratio_max_bounds_are_validated(value: float | bool) -> None:
    with pytest.raises(
        ValueError, match="dataset.categorical_ratio_max must be a finite value in \\[0, 1\\]"
    ):
        GeneratorConfig.from_dict({"dataset": {"categorical_ratio_max": value}})


def test_categorical_ratio_bounds_accept_endpoints() -> None:
    cfg = GeneratorConfig.from_dict(
        {"dataset": {"categorical_ratio_min": 0.0, "categorical_ratio_max": 1.0}}
    )
    assert cfg.dataset.categorical_ratio_min == pytest.approx(0.0)
    assert cfg.dataset.categorical_ratio_max == pytest.approx(1.0)


def test_load_cuda_presets() -> None:
    cfg_h100 = GeneratorConfig.from_yaml("configs/preset_cuda_h100.yaml")
    assert cfg_h100.runtime.device == "cuda"
    assert cfg_h100.dataset.n_features_max >= 128
    assert cfg_h100.runtime.fixed_layout_target_cells == 32_000_000


def test_load_diagnostics_preset() -> None:
    cfg_diag = GeneratorConfig.from_yaml("configs/preset_diagnostics_on.yaml")
    assert cfg_diag.diagnostics.enabled is True
    assert cfg_diag.diagnostics.histogram_bins >= 8
    assert cfg_diag.diagnostics.quantiles


def test_load_missingness_presets() -> None:
    cfg_mcar = GeneratorConfig.from_yaml("configs/preset_missingness_mcar.yaml")
    assert cfg_mcar.dataset.missing_mechanism == MISSINGNESS_MECHANISM_MCAR
    assert cfg_mcar.dataset.missing_rate > 0.0

    cfg_mar = GeneratorConfig.from_yaml("configs/preset_missingness_mar.yaml")
    assert cfg_mar.dataset.missing_mechanism == MISSINGNESS_MECHANISM_MAR
    assert cfg_mar.dataset.missing_rate > 0.0
    assert cfg_mar.dataset.missing_mar_observed_fraction > 0.0

    cfg_mnar = GeneratorConfig.from_yaml("configs/preset_missingness_mnar.yaml")
    assert cfg_mnar.dataset.missing_mechanism == MISSINGNESS_MECHANISM_MNAR
    assert cfg_mnar.dataset.missing_rate > 0.0
    assert cfg_mnar.dataset.missing_mnar_logit_scale > 0.0


def test_load_benchmark_profiles() -> None:
    cfg_cpu = GeneratorConfig.from_yaml("configs/benchmark_cpu.yaml")
    cfg_desktop = GeneratorConfig.from_yaml("configs/benchmark_cuda_desktop.yaml")
    cfg_h100 = GeneratorConfig.from_yaml("configs/benchmark_cuda_h100.yaml")
    cfg_h100_large = GeneratorConfig.from_yaml("configs/benchmark_cuda_h100_large_shape.yaml")
    cfg_h100_saturation = GeneratorConfig.from_yaml("configs/benchmark_cuda_h100_saturation.yaml")

    assert cfg_cpu.runtime.device == "cpu"
    assert cfg_desktop.runtime.device == "cuda"
    assert cfg_h100.runtime.device == "cuda"
    assert cfg_h100_large.runtime.device == "cuda"
    assert cfg_h100_saturation.runtime.device == "cuda"
    assert cfg_cpu.runtime.fixed_layout_target_cells == 12_000_000
    assert cfg_desktop.runtime.fixed_layout_target_cells == 16_000_000
    assert cfg_h100.runtime.fixed_layout_target_cells == 32_000_000
    assert cfg_h100_large.runtime.fixed_layout_target_cells == 32_000_000
    assert cfg_h100_saturation.runtime.fixed_layout_target_cells == 32_000_000
    assert cfg_h100_large.dataset.n_train == 8192
    assert cfg_h100_large.dataset.n_test == 2048
    assert cfg_h100_large.dataset.n_features_max == 256
    assert cfg_h100_saturation.benchmark.num_datasets == 1500
    assert cfg_h100_saturation.benchmark.warmup_datasets == 25
    assert "cpu" in cfg_h100.benchmark.presets


def test_load_lineage_benchmark_smoke_preset() -> None:
    cfg = GeneratorConfig.from_yaml("configs/preset_lineage_benchmark_smoke.yaml")
    assert cfg.benchmark.suite == "smoke"
    assert cfg.benchmark.preset_name == "lineage_smoke"
    assert "lineage_smoke" in cfg.benchmark.presets
    assert cfg.benchmark.latency_num_samples >= 5


def test_runtime_config_from_dict() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "runtime": {
                "device": "cpu",
                "torch_dtype": "float64",
                "fixed_layout_target_cells": "8000000",
            },
            "diagnostics": {
                "enabled": True,
                "histogram_bins": 12,
                "max_values_per_metric": 1234,
                "meta_feature_targets": {
                    "linearity_proxy": [0.2, 0.8],
                },
            },
        }
    )
    assert cfg.runtime.device == "cpu"
    assert cfg.runtime.torch_dtype == "float64"
    assert cfg.runtime.fixed_layout_target_cells == 8_000_000
    assert cfg.diagnostics.enabled is True
    assert cfg.diagnostics.histogram_bins == 12
    assert cfg.diagnostics.max_values_per_metric == 1234
    assert "linearity_proxy" in cfg.diagnostics.meta_feature_targets


def test_steering_preset_round_trips_via_yaml() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "steering": {
                "enabled": True,
                "preset": "anti_memorization_piecewise_v1",
            }
        }
    )

    payload = yaml.safe_load(yaml.safe_dump(cfg.to_dict()))
    round_tripped = GeneratorConfig.from_dict(payload)

    assert round_tripped.steering.enabled is True
    assert round_tripped.steering.preset == "anti_memorization_piecewise_v1"
    assert round_tripped.steering.stages == []


def test_stress_profile_accepts_profile_only_authoring() -> None:
    cfg = GeneratorConfig.from_dict({"stress": {"profile": _STRESS_PROFILE}})

    assert cfg.stress.profile == _STRESS_PROFILE
    assert cfg.steering.enabled is False
    assert cfg.steering.preset is None


def test_stress_profile_definition_rejects_unknown_name() -> None:
    with pytest.raises(ValueError, match="Unsupported stress.profile 'not_a_real_profile'"):
        stress_profile_definition("not_a_real_profile")


def test_stress_profile_rejects_unknown_name_via_config() -> None:
    with pytest.raises(ValueError, match="Unsupported stress.profile 'not_a_real_profile'"):
        GeneratorConfig.from_dict({"stress": {"profile": "not_a_real_profile"}})


@pytest.mark.parametrize("stress_value", [[], "", False])
def test_stress_profile_rejects_non_mapping_section_types(stress_value: object) -> None:
    with pytest.raises(TypeError, match=r"stress must be a mapping"):
        GeneratorConfig.from_dict({"stress": stress_value})


def test_stress_profile_accepts_matching_explicit_steering_authoring() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "steering": {
                "enabled": True,
                "preset": "anti_memorization_piecewise_v1",
            },
            "stress": {"profile": _STRESS_PROFILE},
        }
    )

    assert cfg.stress.profile == _STRESS_PROFILE
    assert cfg.steering.enabled is True
    assert cfg.steering.preset == "anti_memorization_piecewise_v1"


@pytest.mark.parametrize(
    ("payload", "error_pattern"),
    [
        (
            {"dataset": {"rows": 1024}, "stress": {"profile": _STRESS_PROFILE}},
            r"stress\.profile 'anti_memorization_piecewise_classification_slice_v1' locks dataset\.rows to unset",
        ),
        (
            {"graph": {"n_nodes_max": 24}, "stress": {"profile": _STRESS_PROFILE}},
            r"stress\.profile 'anti_memorization_piecewise_classification_slice_v1' locks graph\.n_nodes_max to 32",
        ),
        (
            {
                "dataset": {"n_train": 1024},
                "stress": {"profile": _STRESS_PROFILE},
            },
            r"stress\.profile 'anti_memorization_piecewise_classification_slice_v1' locks dataset\.n_train to 768",
        ),
        (
            {
                "steering": {"enabled": True, "preset": "not_a_real_preset"},
                "stress": {"profile": _STRESS_PROFILE},
            },
            r"Unsupported steering\.preset 'not_a_real_preset'",
        ),
        (
            {
                "steering": {
                    "enabled": True,
                    "preset": "anti_memorization_piecewise_v1",
                    "stages": [
                        _stage(
                            name="extra",
                            fraction=1.0,
                            dataset={
                                "missing_rate": [0.0, 0.1],
                                "missing_mechanism": "mcar",
                            },
                        ),
                    ],
                },
                "stress": {"profile": _STRESS_PROFILE},
            },
            r"steering must use exactly one authoring form",
        ),
        (
            {
                "steering": {
                    "enabled": False,
                },
                "stress": {"profile": _STRESS_PROFILE},
            },
            r"requires any explicit steering section to match the built-in steering definition exactly",
        ),
        (
            {
                "steering": {},
                "stress": {"profile": _STRESS_PROFILE},
            },
            r"requires any explicit steering section to match the built-in steering definition exactly",
        ),
    ],
)
def test_stress_profile_rejects_conflicting_identity_fields(
    payload: dict[str, object],
    error_pattern: str,
) -> None:
    with pytest.raises(ValueError, match=error_pattern):
        GeneratorConfig.from_dict(payload)


def test_steering_explicit_stage_round_trips_via_yaml() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "steering": {
                "enabled": True,
                "stages": [
                    _stage(
                        name="missingness_ramp",
                        fraction=0.5,
                        dataset={
                            "missing_rate": [0.0, 0.25],
                            "missing_mechanism": "mcar",
                        },
                    ),
                    _stage(
                        name="graph_to_noise_handoff",
                        fraction=0.5,
                        shift={
                            "mode": "mixed",
                            "graph_scale": [0.5, 0.0],
                            "variance_scale": [0.0, 0.5],
                        },
                    ),
                ],
            }
        }
    )

    payload = yaml.safe_load(yaml.safe_dump(cfg.to_dict()))
    round_tripped = GeneratorConfig.from_dict(payload)

    assert round_tripped.steering.enabled is True
    assert round_tripped.steering.preset is None
    assert [stage.name for stage in round_tripped.steering.stages] == [
        "missingness_ramp",
        "graph_to_noise_handoff",
    ]
    assert [stage.fraction for stage in round_tripped.steering.stages] == [
        pytest.approx(0.5),
        pytest.approx(0.5),
    ]
    assert round_tripped.steering.stages[1].shift_mode == "mixed"
    assert round_tripped.steering.stages[1].shift_graph_scale == [0.5, 0.0]
    assert round_tripped.steering.stages[1].shift_variance_scale == [0.0, 0.5]


def test_steering_rejects_preset_and_explicit_form_together() -> None:
    with pytest.raises(
        ValueError,
        match="steering must use exactly one authoring form",
    ):
        GeneratorConfig.from_dict(
            {
                "steering": {
                    "enabled": True,
                    "preset": "anti_memorization_piecewise_v1",
                    "stages": [
                        _stage(
                            name="missingness_ramp",
                            fraction=1.0,
                            dataset={
                                "missing_rate": [0.0, 0.25],
                                "missing_mechanism": "mcar",
                            },
                        ),
                    ],
                }
            }
        )


def test_steering_rejects_stage_fractions_not_summing_to_one() -> None:
    with pytest.raises(ValueError, match=r"steering\.stages fractions must sum to 1\.0"):
        GeneratorConfig.from_dict(
            {
                "steering": {
                    "enabled": True,
                    "stages": [
                        _stage(
                            name="missingness_ramp",
                            fraction=0.3,
                            dataset={
                                "missing_rate": [0.0, 0.25],
                                "missing_mechanism": "mcar",
                            },
                        ),
                        _stage(
                            name="mixture_noise_ramp",
                            fraction=0.5,
                            noise={
                                "family": "mixture",
                                "mixture_weights": {
                                    "gaussian": [1.0, 0.5],
                                    "laplace": [0.0, 0.3],
                                    "student_t": [0.0, 0.2],
                                },
                            },
                        ),
                    ],
                }
            }
        )


def test_steering_rejects_duplicate_stage_names() -> None:
    with pytest.raises(ValueError, match=r"Duplicate steering\.stages name 'reused'"):
        GeneratorConfig.from_dict(
            {
                "steering": {
                    "enabled": True,
                    "stages": [
                        _stage(
                            name="reused",
                            fraction=0.5,
                            dataset={
                                "missing_rate": [0.0, 0.25],
                                "missing_mechanism": "mcar",
                            },
                        ),
                        _stage(
                            name="reused",
                            fraction=0.5,
                            shift={
                                "mode": "graph_drift",
                                "graph_scale": [0.0, 0.5],
                            },
                        ),
                    ],
                }
            }
        )


def test_steering_rejects_stage_fields_outside_allowed_sections() -> None:
    with pytest.raises(TypeError, match="graph"):
        GeneratorConfig.from_dict(
            {
                "steering": {
                    "enabled": True,
                    "stages": [
                        {
                            "name": "bad_stage",
                            "fraction": 1.0,
                            "graph": {
                                "n_nodes_min": 4,
                            },
                        }
                    ],
                }
            }
        )


def test_steering_rejects_invalid_directional_band_payload() -> None:
    with pytest.raises(
        ValueError, match=r"steering\.stages\[\*\]\.shift_graph_scale must be a two-value"
    ):
        GeneratorConfig.from_dict(
            {
                "steering": {
                    "enabled": True,
                    "stages": [
                        _stage(
                            name="bad_band",
                            fraction=1.0,
                            shift={
                                "mode": "graph_drift",
                                "graph_scale": [0.0, 0.25, 0.5],
                            },
                        ),
                    ],
                }
            }
        )


def test_steering_accepts_descending_directional_bands() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "steering": {
                "enabled": True,
                "stages": [
                    _stage(
                        name="descending_graph",
                        fraction=1.0,
                        shift={
                            "mode": "graph_drift",
                            "graph_scale": [0.5, 0.0],
                        },
                    ),
                ],
            }
        }
    )

    assert cfg.steering.stages[0].shift_mode == "graph_drift"
    assert cfg.steering.stages[0].shift_graph_scale == [0.5, 0.0]


def test_steering_rejects_extra_payload_when_disabled() -> None:
    with pytest.raises(
        ValueError,
        match="steering.preset and steering.stages must be unset",
    ):
        GeneratorConfig.from_dict(
            {
                "steering": {
                    "enabled": False,
                    "preset": "anti_memorization_piecewise_v1",
                }
            }
        )


def test_steering_rejects_target_metrics_payload() -> None:
    with pytest.raises(
        ValueError,
        match="steering.target_metrics is not supported yet",
    ):
        GeneratorConfig.from_dict(
            {
                "steering": {
                    "enabled": True,
                    "preset": "anti_memorization_piecewise_v1",
                    "target_metrics": {
                        "linearity_proxy": [0.2, 0.8],
                    },
                }
            }
        )


def test_steering_preset_definition_rejects_unknown_name() -> None:
    with pytest.raises(ValueError, match="Unsupported steering.preset 'not_a_real_preset'"):
        steering_preset_definition("not_a_real_preset")


def test_steering_stage_definitions_rejects_non_list_preset_stages(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setitem(
        config_models._STEERING_PRESET_BUILDERS,
        "broken_preset",
        lambda: {"stages": "not-a-list"},
    )
    steering = config_models.SteeringConfig(enabled=True, preset="broken_preset")

    with pytest.raises(ValueError, match="must define a list of stages"):
        steering_stage_definitions(steering)


def test_steering_allows_null_stages_with_preset() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "steering": {
                "enabled": True,
                "preset": "anti_memorization_piecewise_v1",
                "stages": None,
            }
        }
    )

    assert cfg.steering.preset == "anti_memorization_piecewise_v1"
    assert cfg.steering.stages == []


@pytest.mark.parametrize(
    ("payload", "match"),
    [
        (
            {"enabled": 1, "preset": "anti_memorization_piecewise_v1"},
            r"steering\.enabled must be a boolean",
        ),
        (
            {"enabled": True, "preset": 123},
            r"Unsupported steering\.preset 123",
        ),
        (
            {"enabled": True, "preset": " "},
            r"steering\.preset must be a non-empty string when provided",
        ),
        (
            {"enabled": True, "preset": "not_a_real_preset"},
            r"Unsupported steering\.preset 'not_a_real_preset'",
        ),
        (
            {"enabled": True, "stages": "not-a-list"},
            r"steering\.stages must be a list",
        ),
        (
            {
                "enabled": True,
                "stages": [
                    {
                        **_stage(
                            name=True,
                            fraction=1.0,
                            shift={
                                "mode": "graph_drift",
                                "graph_scale": [0.0, 0.5],
                            },
                        ),
                    }
                ],
            },
            r"steering\.stages\[\*\]\.name must be a non-empty string",
        ),
        (
            {
                "enabled": True,
                "stages": [
                    {
                        **_stage(
                            name=" ",
                            fraction=1.0,
                            shift={
                                "mode": "graph_drift",
                                "graph_scale": [0.0, 0.5],
                            },
                        ),
                    }
                ],
            },
            r"steering\.stages\[\*\]\.name must be a non-empty string",
        ),
    ],
)
def test_steering_rejects_invalid_normalization_inputs(
    payload: dict[str, object],
    match: str,
) -> None:
    with pytest.raises(ValueError, match=match):
        GeneratorConfig.from_dict({"steering": payload})


@pytest.mark.parametrize(
    ("mixture_weights", "match"),
    [
        (
            [],
            r"steering\.stages\[\*\]\.noise\.mixture_weights must be a mapping",
        ),
        (
            {},
            r"steering\.stages\[\*\]\.noise\.mixture_weights must include at least one supported component",
        ),
        (
            {True: [1.0, 1.0]},
            r"steering\.stages\[\*\]\.noise\.mixture_weights keys must be gaussian, laplace, or student_t",
        ),
        (
            {"cauchy": [1.0, 1.0]},
            r"Unsupported steering\.stages\[\*\]\.noise\.mixture_weights key 'cauchy'",
        ),
        (
            {
                "Gaussian": [1.0, 1.0],
                " gaussian ": [0.0, 0.0],
            },
            r"Duplicate steering\.stages\[\*\]\.noise\.mixture_weights key ' gaussian ' after normalization",
        ),
    ],
)
def test_steering_rejects_invalid_noise_mixture_weight_bands(
    mixture_weights: object,
    match: str,
) -> None:
    with pytest.raises(ValueError, match=match):
        GeneratorConfig.from_dict(
            {
                "steering": {
                    "enabled": True,
                    "stages": [
                        {
                            **_stage(
                                name="noise_stage",
                                fraction=1.0,
                                noise={
                                    "family": "mixture",
                                    "mixture_weights": mixture_weights,
                                },
                            ),
                        }
                    ],
                }
            }
        )


def test_steering_enabled_requires_preset_or_stages() -> None:
    with pytest.raises(
        ValueError,
        match=r"steering\.enabled=true requires either steering\.preset or steering\.stages",
    ):
        GeneratorConfig.from_dict({"steering": {"enabled": True}})


@pytest.mark.parametrize(
    ("stage", "match"),
    [
        (
            {"name": "empty", "fraction": 1.0},
            r"steering\.stages\[\*\] must set at least one dataset, shift, or noise control",
        ),
        (
            _stage(
                name="dataset_missing_rate",
                fraction=1.0,
                dataset={"missing_mechanism": "mcar"},
            ),
            r"steering\.stages\[\*\] must set both missing_rate and missing_mechanism together",
        ),
        (
            _stage(
                name="dataset_missing_mechanism",
                fraction=1.0,
                dataset={"missing_rate": [0.0, 0.25]},
            ),
            r"steering\.stages\[\*\] must set both missing_rate and missing_mechanism together",
        ),
        (
            _stage(
                name="dataset_none_with_rate",
                fraction=1.0,
                dataset={
                    "missing_rate": [0.1, 0.2],
                    "missing_mechanism": "none",
                },
            ),
            r"steering\.stages\[\*\]\.missing_mechanism must be mcar, mar, or mnar",
        ),
        (
            _stage(
                name="shift_missing_mode",
                fraction=1.0,
                shift={"graph_scale": [0.0, 0.5]},
            ),
            r"steering\.stages\[\*\]\.shift_mode must be set when shift controls exist",
        ),
        (
            _stage(
                name="shift_missing_scales",
                fraction=1.0,
                shift={"mode": "custom"},
            ),
            r"steering\.stages\[\*\] must set at least one of shift_graph_scale or shift_variance_scale",
        ),
        (
            _stage(
                name="graph_with_variance",
                fraction=1.0,
                shift={
                    "mode": "graph_drift",
                    "graph_scale": [0.0, 0.5],
                    "variance_scale": [0.0, 0.5],
                },
            ),
            r"steering\.stages\[\*\]\.shift_mode 'graph_drift' only allows shift_graph_scale",
        ),
        (
            _stage(
                name="noise_with_graph",
                fraction=1.0,
                shift={
                    "mode": "noise_drift",
                    "graph_scale": [0.0, 0.5],
                    "variance_scale": [0.0, 0.5],
                },
            ),
            r"steering\.stages\[\*\]\.shift_mode 'noise_drift' only allows shift_variance_scale",
        ),
        (
            _stage(
                name="mechanism_drift",
                fraction=1.0,
                shift={
                    "mode": "mechanism_drift",
                    "variance_scale": [0.0, 0.5],
                },
            ),
            r"steering\.stages\[\*\]\.shift_mode 'mechanism_drift' is out of scope",
        ),
        (
            _stage(
                name="shift_off",
                fraction=1.0,
                shift={
                    "mode": "off",
                    "graph_scale": [0.0, 0.5],
                },
            ),
            r"steering\.stages\[\*\]\.shift_mode must be graph_drift, noise_drift, mixed, or custom",
        ),
        (
            _stage(
                name="noise_missing_family",
                fraction=1.0,
                noise={"mixture_weights": {"gaussian": [1.0, 1.0]}},
            ),
            r"steering\.stages\[\*\]\.noise_family must be set when noise controls exist",
        ),
        (
            _stage(
                name="noise_wrong_family",
                fraction=1.0,
                noise={
                    "family": "gaussian",
                    "mixture_weights": {"gaussian": [1.0, 1.0]},
                },
            ),
            r"steering\.stages\[\*\] currently only supports noise_family='mixture'",
        ),
        (
            _stage(
                name="noise_missing_weights",
                fraction=1.0,
                noise={"family": "mixture"},
            ),
            r"steering\.stages\[\*\]\.noise_mixture_weights is required when noise controls exist",
        ),
        (
            _stage(
                name="noise_bad_start_total",
                fraction=1.0,
                noise={
                    "family": "mixture",
                    "mixture_weights": {
                        "gaussian": [0.9, 0.5],
                        "laplace": [0.0, 0.3],
                        "student_t": [0.0, 0.2],
                    },
                },
            ),
            r"steering\.stages\[\*\]\.noise_mixture_weights start weights must sum to 1\.0",
        ),
        (
            _stage(
                name="noise_bad_end_total",
                fraction=1.0,
                noise={
                    "family": "mixture",
                    "mixture_weights": {
                        "gaussian": [1.0, 0.4],
                        "laplace": [0.0, 0.3],
                        "student_t": [0.0, 0.2],
                    },
                },
            ),
            r"steering\.stages\[\*\]\.noise_mixture_weights end weights must sum to 1\.0",
        ),
    ],
)
def test_steering_rejects_invalid_stage_constraints(
    stage: dict[str, object],
    match: str,
) -> None:
    with pytest.raises(ValueError, match=match):
        GeneratorConfig.from_dict(
            {
                "steering": {
                    "enabled": True,
                    "stages": [stage],
                }
            }
        )


def test_runtime_config_rejects_removed_parallel_generation_keys() -> None:
    with pytest.raises(ValueError, match=r"runtime\.worker_count, runtime\.worker_index"):
        GeneratorConfig.from_dict({"runtime": {"worker_count": 4, "worker_index": 2}})


def test_runtime_config_rejects_generation_engine_key() -> None:
    with pytest.raises(TypeError, match="generation_engine"):
        GeneratorConfig.from_dict({"runtime": {"generation_engine": "appendix_light"}})


def test_runtime_config_rejects_hardware_aware_key() -> None:
    with pytest.raises(TypeError, match="hardware_aware"):
        GeneratorConfig.from_dict({"runtime": {"hardware_aware": True}})


@pytest.mark.parametrize("value", [0, -1, True, "abc"])
def test_runtime_config_rejects_invalid_fixed_layout_target_cells(value: object) -> None:
    with pytest.raises(ValueError, match=r"runtime\.fixed_layout_target_cells must"):
        GeneratorConfig.from_dict({"runtime": {"fixed_layout_target_cells": value}})


@pytest.mark.parametrize("value", [0, -1, True, "abc"])
def test_runtime_config_rejects_invalid_fixed_layout_batch_size_cap(value: object) -> None:
    with pytest.raises(ValueError, match=r"runtime\.fixed_layout_batch_size_cap must"):
        GeneratorConfig.from_dict({"runtime": {"fixed_layout_batch_size_cap": value}})


def test_legacy_filter_keys_are_rejected() -> None:
    with pytest.raises(TypeError, match="n_trees"):
        GeneratorConfig.from_dict(
            {
                "filter": {
                    "enabled": True,
                    "n_trees": 25,
                    "depth": 6,
                    "n_split_candidates": 8,
                }
            }
        )


def test_legacy_graph_log2_keys_are_rejected() -> None:
    with pytest.raises(TypeError, match="n_nodes_log2_min"):
        GeneratorConfig.from_dict({"graph": {"n_nodes_log2_min": 2, "n_nodes_log2_max": 8}})


def test_filter_n_jobs_accepts_minus_one_and_positive_values() -> None:
    cfg_auto = GeneratorConfig.from_dict({"filter": {"n_jobs": -1}})
    cfg_single = GeneratorConfig.from_dict({"filter": {"n_jobs": 1}})
    cfg_multi = GeneratorConfig.from_dict({"filter": {"n_jobs": 8}})

    assert cfg_auto.filter.n_jobs == -1
    assert cfg_single.filter.n_jobs == 1
    assert cfg_multi.filter.n_jobs == 8


@pytest.mark.parametrize("value", [0, -2, True])
def test_filter_n_jobs_rejects_invalid_values(value: int | bool) -> None:
    with pytest.raises(ValueError, match=r"filter\.n_jobs must"):
        GeneratorConfig.from_dict({"filter": {"n_jobs": value}})


def test_validate_generation_constraints_revalidates_mutated_missingness_state() -> None:
    cfg = GeneratorConfig.from_yaml("configs/default.yaml")
    cfg.dataset.missing_rate = 0.2
    cfg.dataset.missing_mechanism = "none"

    with pytest.raises(
        ValueError,
        match="dataset.missing_mechanism must be mcar, mar, or mnar when dataset.missing_rate > 0",
    ):
        cfg.validate_generation_constraints()


def test_validate_generation_constraints_normalizes_mutated_runtime_device_null_to_auto() -> None:
    cfg = GeneratorConfig.from_yaml("configs/default.yaml")
    cfg.runtime.device = None  # type: ignore[assignment]

    cfg.validate_generation_constraints()

    assert cfg.runtime.device == "auto"


def test_missingness_mechanism_normalization_is_case_insensitive() -> None:
    cfg = GeneratorConfig.from_dict(
        {"dataset": {"missing_rate": 0.25, "missing_mechanism": "MCAR"}}
    )
    assert cfg.dataset.missing_mechanism == MISSINGNESS_MECHANISM_MCAR


def test_missingness_rejects_none_mechanism_when_rate_is_positive() -> None:
    with pytest.raises(
        ValueError,
        match="dataset.missing_mechanism must be mcar, mar, or mnar when dataset.missing_rate > 0",
    ):
        GeneratorConfig.from_dict({"dataset": {"missing_rate": 0.1, "missing_mechanism": "none"}})


@pytest.mark.parametrize("value", [-0.1, 1.1, float("inf"), float("nan"), True])
def test_missing_rate_bounds_are_validated(value: float | bool) -> None:
    with pytest.raises(
        ValueError, match="dataset.missing_rate must be a finite value in \\[0, 1\\]"
    ):
        GeneratorConfig.from_dict({"dataset": {"missing_rate": value}})


@pytest.mark.parametrize("value", [0.0, -0.1, 1.1, float("inf"), float("nan"), True])
def test_missing_mar_observed_fraction_bounds_are_validated(value: float | bool) -> None:
    with pytest.raises(
        ValueError, match="dataset.missing_mar_observed_fraction must be in \\(0, 1\\]"
    ):
        GeneratorConfig.from_dict({"dataset": {"missing_mar_observed_fraction": value}})


@pytest.mark.parametrize(
    ("field_name", "bad_value"),
    [
        ("missing_mar_logit_scale", 0.0),
        ("missing_mar_logit_scale", -1.0),
        ("missing_mar_logit_scale", float("inf")),
        ("missing_mar_logit_scale", float("nan")),
        ("missing_mar_logit_scale", True),
        ("missing_mnar_logit_scale", 0.0),
        ("missing_mnar_logit_scale", -1.0),
        ("missing_mnar_logit_scale", float("inf")),
        ("missing_mnar_logit_scale", float("nan")),
        ("missing_mnar_logit_scale", True),
    ],
)
def test_missing_logit_scale_bounds_are_validated(field_name: str, bad_value: float | bool) -> None:
    with pytest.raises(ValueError, match=rf"dataset.{field_name} must be a finite value > 0"):
        GeneratorConfig.from_dict({"dataset": {field_name: bad_value}})


def test_invalid_missing_mechanism_string_is_rejected() -> None:
    with pytest.raises(ValueError, match="Unsupported missing_mechanism"):
        GeneratorConfig.from_dict({"dataset": {"missing_mechanism": "garbage"}})


def test_unused_missingness_parameters_are_allowed_with_disabled_mechanism() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "dataset": {
                "missing_rate": 0.0,
                "missing_mechanism": "none",
                "missing_mar_observed_fraction": 0.8,
                "missing_mar_logit_scale": 2.5,
                "missing_mnar_logit_scale": 3.5,
            }
        }
    )
    assert cfg.dataset.missing_mechanism == MISSINGNESS_MECHANISM_NONE
    assert cfg.dataset.missing_mar_observed_fraction == 0.8
    assert cfg.dataset.missing_mar_logit_scale == 2.5
    assert cfg.dataset.missing_mnar_logit_scale == 3.5


@pytest.mark.parametrize(
    ("field_name", "bad_value", "pattern"),
    [
        ("target_parent_prior", "sparse", r"dataset\.target_parent_prior must be"),
        ("target_parent_count_min", 0, r"dataset\.target_parent_count_min must be an integer >= 1"),
        ("target_parent_count_max", 0, r"dataset\.target_parent_count_max must be an integer >= 1"),
        (
            "target_parent_near_max_band_min_fraction",
            0.0,
            r"dataset\.target_parent_near_max_band_min_fraction must be a finite value in \(0, 1\]",
        ),
        (
            "target_parent_below_sqrt_prob",
            -0.1,
            r"dataset\.target_parent_below_sqrt_prob must be a finite value in \[0, 1\]",
        ),
        (
            "target_parent_midrange_prob",
            1.1,
            r"dataset\.target_parent_midrange_prob must be a finite value in \[0, 1\]",
        ),
    ],
)
def test_target_parent_fields_validate_individual_bounds(
    field_name: str,
    bad_value: object,
    pattern: str,
) -> None:
    with pytest.raises(ValueError, match=pattern):
        GeneratorConfig.from_dict({"dataset": {field_name: bad_value}})


def test_target_parent_probability_mass_must_fit_within_one() -> None:
    with pytest.raises(
        ValueError,
        match=r"dataset\.target_parent_below_sqrt_prob \+ dataset\.target_parent_midrange_prob must be <= 1.0",
    ):
        GeneratorConfig.from_dict(
            {
                "dataset": {
                    "target_parent_below_sqrt_prob": 0.6,
                    "target_parent_midrange_prob": 0.5,
                }
            }
        )


@pytest.mark.parametrize(
    ("field_name", "bad_value", "pattern"),
    [
        ("enabled", "yes", r"diagnostics\.enabled must be a boolean"),
        ("include_spearman", 1, r"diagnostics\.include_spearman must be a boolean"),
        (
            "teacher_conditional_export",
            "true",
            r"diagnostics\.teacher_conditional_export must be a boolean",
        ),
    ],
)
def test_diagnostics_boolean_toggles_reject_non_bool_values(
    field_name: str,
    bad_value: object,
    pattern: str,
) -> None:
    with pytest.raises(ValueError, match=pattern):
        GeneratorConfig.from_dict({"diagnostics": {field_name: bad_value}})


def test_target_parent_count_max_must_not_be_less_than_min() -> None:
    with pytest.raises(
        ValueError,
        match=r"dataset\.target_parent_count_min must be <= target_parent_count_max",
    ):
        GeneratorConfig.from_dict(
            {
                "dataset": {
                    "target_parent_count_min": 4,
                    "target_parent_count_max": 3,
                }
            }
        )


def test_shift_defaults_are_backward_compatible_when_shift_block_is_absent() -> None:
    cfg = GeneratorConfig.from_yaml("configs/default.yaml")
    assert cfg.shift.enabled is False
    assert cfg.shift.mode == "off"
    assert cfg.shift.graph_scale is None
    assert cfg.shift.mechanism_scale is None
    assert cfg.shift.variance_scale is None


@pytest.mark.parametrize("config_path", ("configs/default.yaml", "configs/benchmark_cpu.yaml"))
def test_existing_config_files_parse_with_shift_schema(config_path: str) -> None:
    cfg = GeneratorConfig.from_yaml(config_path)
    assert cfg.shift.enabled is False
    assert cfg.shift.mode == "off"


def test_shift_schema_accepts_profile_with_optional_override() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "shift": {
                "enabled": True,
                "mode": "Graph_Drift",
                "graph_scale": "0.75",
            }
        }
    )
    assert cfg.shift.enabled is True
    assert cfg.shift.mode == "graph_drift"
    assert cfg.shift.graph_scale == 0.75
    assert cfg.shift.mechanism_scale is None
    assert cfg.shift.variance_scale is None


def test_shift_schema_accepts_mixed_profile_with_multiple_overrides() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "shift": {
                "enabled": True,
                "mode": "mixed",
                "graph_scale": 0.2,
                "mechanism_scale": 0.4,
                "variance_scale": 0.6,
            }
        }
    )
    assert cfg.shift.mode == "mixed"
    assert cfg.shift.graph_scale == 0.2
    assert cfg.shift.mechanism_scale == 0.4
    assert cfg.shift.variance_scale == 0.6


def test_shift_schema_accepts_custom_profile_with_at_least_one_override() -> None:
    cfg = GeneratorConfig.from_dict(
        {
            "shift": {
                "enabled": True,
                "mode": "custom",
                "mechanism_scale": 0.35,
            }
        }
    )
    assert cfg.shift.mode == "custom"
    assert cfg.shift.mechanism_scale == 0.35


def test_shift_schema_rejects_invalid_profile_value() -> None:
    with pytest.raises(ValueError, match="Unsupported shift.mode"):
        GeneratorConfig.from_dict({"shift": {"enabled": True, "mode": "unknown_mode"}})


def test_shift_schema_rejects_boolean_false_profile_alias() -> None:
    with pytest.raises(ValueError, match="Unsupported shift.mode"):
        GeneratorConfig.from_dict({"shift": {"enabled": False, "mode": False}})


def test_shift_schema_rejects_boolean_true_profile_alias() -> None:
    with pytest.raises(ValueError, match="Unsupported shift.mode"):
        GeneratorConfig.from_dict({"shift": {"enabled": False, "mode": True}})


def test_shift_schema_from_yaml_rejects_unquoted_off_profile(tmp_path) -> None:
    cfg_path = tmp_path / "shift_off.yaml"
    cfg_path.write_text(
        "shift:\n  enabled: false\n  mode: off\n",
        encoding="utf-8",
    )
    with pytest.raises(ValueError, match="Unsupported shift.mode"):
        GeneratorConfig.from_yaml(cfg_path)


def test_shift_schema_from_yaml_accepts_quoted_off_profile(tmp_path) -> None:
    cfg_path = tmp_path / "shift_off_quoted.yaml"
    cfg_path.write_text(
        "shift:\n  enabled: false\n  mode: 'off'\n",
        encoding="utf-8",
    )
    cfg = GeneratorConfig.from_yaml(cfg_path)
    assert cfg.shift.enabled is False
    assert cfg.shift.mode == "off"


def test_shift_schema_rejects_non_boolean_enabled_value() -> None:
    with pytest.raises(ValueError, match="shift.enabled must be a boolean"):
        GeneratorConfig.from_dict({"shift": {"enabled": "true", "mode": "off"}})


@pytest.mark.parametrize(
    ("field_name", "bad_value"),
    [
        ("graph_scale", -0.1),
        ("graph_scale", 1.1),
        ("graph_scale", float("inf")),
        ("graph_scale", float("nan")),
        ("graph_scale", True),
        ("mechanism_scale", -0.1),
        ("mechanism_scale", 1.1),
        ("mechanism_scale", float("inf")),
        ("mechanism_scale", float("nan")),
        ("mechanism_scale", True),
        ("variance_scale", -0.1),
        ("variance_scale", 1.1),
        ("variance_scale", float("inf")),
        ("variance_scale", float("nan")),
        ("variance_scale", True),
    ],
)
def test_shift_override_scales_enforce_finite_unit_interval_bounds(
    field_name: str, bad_value: float | bool
) -> None:
    with pytest.raises(
        ValueError, match=rf"shift\.{field_name} must be a finite value in \[0, 1\]"
    ):
        GeneratorConfig.from_dict(
            {
                "shift": {
                    "enabled": True,
                    "mode": "mixed",
                    field_name: bad_value,
                }
            }
        )


def test_shift_schema_rejects_non_off_profile_when_disabled() -> None:
    with pytest.raises(ValueError, match=r"shift\.mode must be 'off' when shift\.enabled is false"):
        GeneratorConfig.from_dict({"shift": {"enabled": False, "mode": "mixed"}})


def test_shift_schema_rejects_overrides_when_disabled() -> None:
    with pytest.raises(
        ValueError, match=r"shift override scales must be unset when shift\.enabled is false"
    ):
        GeneratorConfig.from_dict(
            {
                "shift": {
                    "enabled": False,
                    "mode": "off",
                    "graph_scale": 0.2,
                }
            }
        )


def test_shift_schema_rejects_off_profile_when_enabled() -> None:
    with pytest.raises(
        ValueError, match=r"shift\.mode must not be 'off' when shift\.enabled is true"
    ):
        GeneratorConfig.from_dict({"shift": {"enabled": True, "mode": "off"}})


def test_shift_schema_requires_custom_profile_to_set_at_least_one_override() -> None:
    with pytest.raises(
        ValueError, match=r"shift\.mode 'custom' requires at least one override scale"
    ):
        GeneratorConfig.from_dict({"shift": {"enabled": True, "mode": "custom"}})


@pytest.mark.parametrize(
    ("profile", "payload", "error_pattern"),
    [
        (
            "graph_drift",
            {"variance_scale": 0.25},
            r"shift\.mode 'graph_drift' only allows shift\.graph_scale override",
        ),
        (
            "mechanism_drift",
            {"graph_scale": 0.25},
            r"shift\.mode 'mechanism_drift' only allows shift\.mechanism_scale override",
        ),
        (
            "noise_drift",
            {"mechanism_scale": 0.25},
            r"shift\.mode 'noise_drift' only allows shift\.variance_scale override",
        ),
    ],
)
def test_shift_schema_rejects_incompatible_profile_override_combinations(
    profile: str,
    payload: dict[str, float],
    error_pattern: str,
) -> None:
    with pytest.raises(ValueError, match=error_pattern):
        GeneratorConfig.from_dict(
            {
                "shift": {
                    "enabled": True,
                    "mode": profile,
                    **payload,
                }
            }
        )
