# SPDX-FileCopyrightText: 2026 Teilo Millet
# SPDX-License-Identifier: Apache-2.0

"""Tests for LoRA loading and analysis features."""

from __future__ import annotations

import json
import sys
import types
from typing import TYPE_CHECKING
from unittest.mock import patch

import pytest

from vauban import _ops as ops

if TYPE_CHECKING:
    from pathlib import Path

    from vauban._array import Array


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

D_MODEL = 8
IN_FEATURES = 16


def _make_direction() -> Array:
    """Create a unit direction vector."""
    d = ops.array([1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
    return d / ops.linalg.norm(d)


def _save_synthetic_adapter(tmp_path: Path, rank: int = 1) -> Path:
    """Create a synthetic mlx-format adapter on disk."""
    adapter_dir = tmp_path / "test_adapter"
    adapter_dir.mkdir(parents=True, exist_ok=True)

    ops.random.seed(42)
    tensors: dict[str, Array] = {}
    for layer_idx in range(2):
        key_a = f"layers.{layer_idx}.self_attn.o_proj.lora_a"
        key_b = f"layers.{layer_idx}.self_attn.o_proj.lora_b"
        tensors[key_a] = ops.random.normal((rank, IN_FEATURES))
        tensors[key_b] = ops.random.normal((D_MODEL, rank))

    ops.save_safetensors(str(adapter_dir / "adapters.safetensors"), tensors)

    config = {
        "lora_rank": rank,
        "lora_alpha": float(rank),
        "model_path": "test-model",
        "num_lora_layers": 2,
    }
    (adapter_dir / "adapter_config.json").write_text(json.dumps(config))

    return adapter_dir


def _save_rank1_adapter(tmp_path: Path) -> Path:
    """Create a rank-1 adapter with known structure for analysis tests."""
    adapter_dir = tmp_path / "rank1_adapter"
    adapter_dir.mkdir(parents=True, exist_ok=True)

    # Rank-1: lora_a is (1, IN_FEATURES), lora_b is (D_MODEL, 1)
    lora_a = ops.ones((1, IN_FEATURES))
    lora_b = ops.ones((D_MODEL, 1))

    tensors: dict[str, Array] = {
        "layers.0.self_attn.o_proj.lora_a": lora_a,
        "layers.0.self_attn.o_proj.lora_b": lora_b,
    }

    ops.save_safetensors(str(adapter_dir / "adapters.safetensors"), tensors)

    config = {
        "lora_rank": 1,
        "lora_alpha": 1.0,
        "model_path": "test-model",
        "num_lora_layers": 1,
    }
    (adapter_dir / "adapter_config.json").write_text(json.dumps(config))

    return adapter_dir


# ---------------------------------------------------------------------------
# Config dataclass tests
# ---------------------------------------------------------------------------


class TestLoraLoadConfig:
    """Tests for LoraLoadConfig dataclass."""

    def test_defaults(self) -> None:
        """All fields should default to None."""
        from vauban.types import LoraLoadConfig

        config = LoraLoadConfig()
        assert config.adapter_path is None
        assert config.adapter_paths is None
        assert config.weights is None

    def test_frozen(self) -> None:
        """Config should be immutable."""
        from vauban.types import LoraLoadConfig

        config = LoraLoadConfig()
        with pytest.raises(AttributeError):
            config.adapter_path = "foo"  # type: ignore[misc]

    def test_single_path(self) -> None:
        """Single adapter_path should be accepted."""
        from vauban.types import LoraLoadConfig

        config = LoraLoadConfig(adapter_path="output/adapter")
        assert config.adapter_path == "output/adapter"

    def test_multi_paths(self) -> None:
        """Multiple adapter_paths with weights should be accepted."""
        from vauban.types import LoraLoadConfig

        config = LoraLoadConfig(
            adapter_paths=["a/", "b/"],
            weights=[1.0, 0.5],
        )
        assert config.adapter_paths == ["a/", "b/"]
        assert config.weights == [1.0, 0.5]


class TestLoraAnalysisConfig:
    """Tests for LoraAnalysisConfig dataclass."""

    def test_defaults(self) -> None:
        """Default values should match spec."""
        from vauban.types import LoraAnalysisConfig

        config = LoraAnalysisConfig()
        assert config.variance_threshold == 0.99
        assert config.align_with_direction is True
        assert config.adapter_path is None
        assert config.adapter_paths is None

    def test_frozen(self) -> None:
        """Config should be immutable."""
        from vauban.types import LoraAnalysisConfig

        config = LoraAnalysisConfig()
        with pytest.raises(AttributeError):
            config.variance_threshold = 0.5  # type: ignore[misc]


class TestLoraLayerAnalysis:
    """Tests for LoraLayerAnalysis dataclass."""

    def test_to_dict(self) -> None:
        """to_dict should produce a JSON-serializable dict."""
        from vauban.types import LoraLayerAnalysis

        layer = LoraLayerAnalysis(
            key="layers.0.self_attn.o_proj",
            frobenius_norm=1.5,
            singular_values=[1.0, 0.5, 0.1],
            effective_rank=1.8,
            variance_cutoff=2,
            direction_alignment=0.95,
        )
        d = layer.to_dict()
        assert d["key"] == "layers.0.self_attn.o_proj"
        assert d["direction_alignment"] == 0.95
        json.dumps(d)  # Must be serializable

    def test_to_dict_no_alignment(self) -> None:
        """to_dict should omit direction_alignment when None."""
        from vauban.types import LoraLayerAnalysis

        layer = LoraLayerAnalysis(
            key="test",
            frobenius_norm=1.0,
            singular_values=[1.0],
            effective_rank=1.0,
            variance_cutoff=1,
        )
        d = layer.to_dict()
        assert "direction_alignment" not in d


class TestLoraAnalysisResult:
    """Tests for LoraAnalysisResult dataclass."""

    def test_to_dict(self) -> None:
        """to_dict should produce a JSON-serializable dict."""
        from vauban.types import LoraAnalysisResult, LoraLayerAnalysis

        result = LoraAnalysisResult(
            adapter_path="/tmp/adapter",
            layers=[
                LoraLayerAnalysis(
                    key="test",
                    frobenius_norm=1.0,
                    singular_values=[1.0],
                    effective_rank=1.0,
                    variance_cutoff=1,
                ),
            ],
            total_params=100,
            mean_effective_rank=1.0,
            norm_profile=[1.0],
        )
        d = result.to_dict()
        assert d["total_params"] == 100
        assert len(d["layers"]) == 1  # type: ignore[arg-type]
        json.dumps(d)  # Must be serializable


class TestPipelineConfigIntegration:
    """Tests for new fields on PipelineConfig."""

    def test_lora_load_field(self) -> None:
        """PipelineConfig should accept lora_load."""
        from pathlib import Path

        from vauban.types import LoraLoadConfig, PipelineConfig

        config = PipelineConfig(
            model_path="test",
            harmful_path=Path("h.jsonl"),
            harmless_path=Path("s.jsonl"),
            lora_load=LoraLoadConfig(adapter_path="adapter/"),
        )
        assert config.lora_load is not None
        assert config.lora_load.adapter_path == "adapter/"

    def test_lora_analysis_field(self) -> None:
        """PipelineConfig should accept lora_analysis."""
        from pathlib import Path

        from vauban.types import LoraAnalysisConfig, PipelineConfig

        config = PipelineConfig(
            model_path="test",
            harmful_path=Path("h.jsonl"),
            harmless_path=Path("s.jsonl"),
            lora_analysis=LoraAnalysisConfig(adapter_path="adapter/"),
        )
        assert config.lora_analysis is not None
        assert config.lora_analysis.adapter_path == "adapter/"


# ---------------------------------------------------------------------------
# Parser tests
# ---------------------------------------------------------------------------


class TestParseLoraLoad:
    """Tests for [lora] TOML config parsing."""

    def test_parse_single_path(self) -> None:
        """Single adapter_path should parse correctly."""
        from vauban.config._parse_lora_load import _parse_lora_load

        raw = {"lora": {"adapter_path": "output/adapter"}}
        config = _parse_lora_load(raw)
        assert config is not None
        assert config.adapter_path == "output/adapter"
        assert config.adapter_paths is None

    def test_parse_multi_paths(self) -> None:
        """Multiple adapter_paths should parse correctly."""
        from vauban.config._parse_lora_load import _parse_lora_load

        raw = {
            "lora": {
                "adapter_paths": ["a/", "b/"],
                "weights": [1.0, 0.5],
            },
        }
        config = _parse_lora_load(raw)
        assert config is not None
        assert config.adapter_paths == ["a/", "b/"]
        assert config.weights == [1.0, 0.5]

    def test_parse_absent(self) -> None:
        """Absent section should return None."""
        from vauban.config._parse_lora_load import _parse_lora_load

        assert _parse_lora_load({}) is None

    def test_parse_mutual_exclusivity(self) -> None:
        """Both adapter_path and adapter_paths should raise."""
        from vauban.config._parse_lora_load import _parse_lora_load

        raw = {
            "lora": {
                "adapter_path": "a/",
                "adapter_paths": ["b/"],
            },
        }
        with pytest.raises(ValueError, match="mutually exclusive"):
            _parse_lora_load(raw)

    def test_parse_neither_path(self) -> None:
        """Neither adapter_path nor adapter_paths should raise."""
        from vauban.config._parse_lora_load import _parse_lora_load

        raw = {"lora": {}}
        with pytest.raises(ValueError, match="requires either"):
            _parse_lora_load(raw)

    def test_parse_weights_mismatch(self) -> None:
        """Weights length mismatch should raise."""
        from vauban.config._parse_lora_load import _parse_lora_load

        raw = {
            "lora": {
                "adapter_paths": ["a/", "b/"],
                "weights": [1.0],
            },
        }
        with pytest.raises(ValueError, match="must match"):
            _parse_lora_load(raw)

    def test_parse_weights_without_paths(self) -> None:
        """Weights without adapter_paths should raise."""
        from vauban.config._parse_lora_load import _parse_lora_load

        raw = {
            "lora": {
                "adapter_path": "a/",
                "weights": [1.0],
            },
        }
        with pytest.raises(ValueError, match="requires adapter_paths"):
            _parse_lora_load(raw)

    def test_parse_not_a_table(self) -> None:
        """Non-table [lora] should raise TypeError."""
        from vauban.config._parse_lora_load import _parse_lora_load

        with pytest.raises(TypeError, match="must be a table"):
            _parse_lora_load({"lora": "string"})


class TestParseLoraAnalysis:
    """Tests for [lora_analysis] TOML config parsing."""

    def test_parse_single_path(self) -> None:
        """Single adapter_path with defaults should parse."""
        from vauban.config._parse_lora_analysis import _parse_lora_analysis

        raw = {"lora_analysis": {"adapter_path": "output/adapter"}}
        config = _parse_lora_analysis(raw)
        assert config is not None
        assert config.adapter_path == "output/adapter"
        assert config.variance_threshold == 0.99
        assert config.align_with_direction is True

    def test_parse_custom_values(self) -> None:
        """Custom variance_threshold and align_with_direction should work."""
        from vauban.config._parse_lora_analysis import _parse_lora_analysis

        raw = {
            "lora_analysis": {
                "adapter_path": "a/",
                "variance_threshold": 0.95,
                "align_with_direction": False,
            },
        }
        config = _parse_lora_analysis(raw)
        assert config is not None
        assert config.variance_threshold == 0.95
        assert config.align_with_direction is False

    def test_parse_absent(self) -> None:
        """Absent section should return None."""
        from vauban.config._parse_lora_analysis import _parse_lora_analysis

        assert _parse_lora_analysis({}) is None

    def test_parse_mutual_exclusivity(self) -> None:
        """Both adapter_path and adapter_paths should raise."""
        from vauban.config._parse_lora_analysis import _parse_lora_analysis

        raw = {
            "lora_analysis": {
                "adapter_path": "a/",
                "adapter_paths": ["b/"],
            },
        }
        with pytest.raises(ValueError, match="mutually exclusive"):
            _parse_lora_analysis(raw)

    def test_parse_neither_path(self) -> None:
        """Neither adapter_path nor adapter_paths should raise."""
        from vauban.config._parse_lora_analysis import _parse_lora_analysis

        raw = {"lora_analysis": {}}
        with pytest.raises(ValueError, match="requires either"):
            _parse_lora_analysis(raw)


# ---------------------------------------------------------------------------
# analyze_adapter tests
# ---------------------------------------------------------------------------


class TestAnalyzeAdapter:
    """Tests for analyze_adapter function."""

    def test_rank1_effective_rank(self, tmp_path: Path) -> None:
        """Rank-1 adapter should have effective_rank close to 1.0."""
        from vauban.lora import analyze_adapter

        adapter_dir = _save_rank1_adapter(tmp_path)
        result = analyze_adapter(str(adapter_dir))

        assert len(result.layers) == 1
        layer = result.layers[0]
        assert layer.effective_rank == pytest.approx(1.0, abs=0.01)

    def test_frobenius_norm(self, tmp_path: Path) -> None:
        """Frobenius norm should be computed correctly for rank-1."""
        from vauban.lora import analyze_adapter

        adapter_dir = _save_rank1_adapter(tmp_path)
        result = analyze_adapter(str(adapter_dir))

        layer = result.layers[0]
        # B @ A = ones(8,1) @ ones(1,16) = ones(8,16)
        # Frobenius norm = sqrt(8*16) = sqrt(128)
        import math

        expected_norm = math.sqrt(D_MODEL * IN_FEATURES)
        assert layer.frobenius_norm == pytest.approx(expected_norm, rel=0.01)

    def test_direction_alignment(self, tmp_path: Path) -> None:
        """Direction alignment should be computed when direction is given."""
        from vauban.lora import analyze_adapter

        adapter_dir = _save_rank1_adapter(tmp_path)
        direction = _make_direction()
        result = analyze_adapter(str(adapter_dir), direction=direction)

        layer = result.layers[0]
        assert layer.direction_alignment is not None
        assert 0.0 <= layer.direction_alignment <= 1.0

    def test_no_direction(self, tmp_path: Path) -> None:
        """Without direction, alignment should be None."""
        from vauban.lora import analyze_adapter

        adapter_dir = _save_rank1_adapter(tmp_path)
        result = analyze_adapter(str(adapter_dir))

        layer = result.layers[0]
        assert layer.direction_alignment is None

    def test_multi_layer_adapter(self, tmp_path: Path) -> None:
        """Multi-layer adapter should have correct layer count."""
        from vauban.lora import analyze_adapter

        adapter_dir = _save_synthetic_adapter(tmp_path, rank=2)
        result = analyze_adapter(str(adapter_dir))

        assert len(result.layers) == 2
        assert result.total_params > 0
        assert len(result.norm_profile) == 2
        assert result.mean_effective_rank > 0.0

    def test_variance_cutoff(self, tmp_path: Path) -> None:
        """Rank-1 adapter should have variance_cutoff of 1."""
        from vauban.lora import analyze_adapter

        adapter_dir = _save_rank1_adapter(tmp_path)
        result = analyze_adapter(str(adapter_dir), variance_threshold=0.99)

        layer = result.layers[0]
        assert layer.variance_cutoff == 1

    def test_result_to_dict(self, tmp_path: Path) -> None:
        """Result should be JSON-serializable."""
        from vauban.lora import analyze_adapter

        adapter_dir = _save_rank1_adapter(tmp_path)
        result = analyze_adapter(str(adapter_dir))

        d = result.to_dict()
        json.dumps(d)  # Must not raise

    def test_skips_non_lora_and_incomplete_pairs(
        self,
        tmp_path: Path,
    ) -> None:
        """Non-LoRA tensors and incomplete pairs should be ignored."""
        from vauban.lora import analyze_adapter

        adapter_dir = tmp_path / "mixed_adapter"
        adapter_dir.mkdir(parents=True, exist_ok=True)

        tensors: dict[str, Array] = {
            "layers.0.self_attn.o_proj.lora_a": ops.ones((1, IN_FEATURES)),
            "layers.0.self_attn.o_proj.lora_b": ops.ones((D_MODEL, 1)),
            "layers.1.self_attn.o_proj.lora_a": ops.ones((1, IN_FEATURES)),
            "layers.9.self_attn.o_proj.weight": ops.ones((D_MODEL, IN_FEATURES)),
        }
        ops.save_safetensors(str(adapter_dir / "adapters.safetensors"), tensors)
        (adapter_dir / "adapter_config.json").write_text(
            json.dumps(
                {
                    "lora_rank": 1,
                    "lora_alpha": 1.0,
                    "model_path": "test-model",
                    "num_lora_layers": 1,
                },
            ),
        )

        result = analyze_adapter(str(adapter_dir))
        assert len(result.layers) == 1


# ---------------------------------------------------------------------------
# Key normalization tests
# ---------------------------------------------------------------------------


class TestStripLoraSuffix:
    """Tests for _strip_lora_suffix helper."""

    def test_mlx_lora_a(self) -> None:
        """mlx .lora_a suffix should be stripped."""
        from vauban.lora import _strip_lora_suffix

        assert _strip_lora_suffix("layers.0.self_attn.o_proj.lora_a") == (
            "layers.0.self_attn.o_proj"
        )

    def test_mlx_lora_b(self) -> None:
        """mlx .lora_b suffix should be stripped."""
        from vauban.lora import _strip_lora_suffix

        assert _strip_lora_suffix("layers.0.self_attn.o_proj.lora_b") == (
            "layers.0.self_attn.o_proj"
        )

    def test_peft_lora_a(self) -> None:
        """PEFT .lora_A.weight suffix and prefix should be stripped."""
        from vauban.lora import _strip_lora_suffix

        key = "base_model.model.layers.0.self_attn.o_proj.lora_A.weight"
        assert _strip_lora_suffix(key) == "layers.0.self_attn.o_proj"

    def test_peft_lora_b(self) -> None:
        """PEFT .lora_B.weight suffix and prefix should be stripped."""
        from vauban.lora import _strip_lora_suffix

        key = "base_model.model.layers.0.self_attn.o_proj.lora_B.weight"
        assert _strip_lora_suffix(key) == "layers.0.self_attn.o_proj"

    def test_unknown_suffix_returns_none(self) -> None:
        """Non-LoRA keys should return None."""
        from vauban.lora import _strip_lora_suffix

        assert _strip_lora_suffix("layers.0.self_attn.o_proj.weight") is None

    def test_peft_prefix_without_model_dot(self) -> None:
        """Keys without PEFT prefix should keep their base as-is."""
        from vauban.lora import _strip_lora_suffix

        assert _strip_lora_suffix("layers.0.lora_a") == "layers.0"


# ---------------------------------------------------------------------------
# load_and_merge_adapters tests
# ---------------------------------------------------------------------------


class TestLoadAndMergeAdapters:
    """Tests for load_and_merge_adapters with synthetic adapters."""

    def test_merge_single_adapter_applies_delta(self, tmp_path: Path) -> None:
        """Merging a single adapter should add delta to model weights."""
        from vauban.lora import load_and_merge_adapters

        adapter_dir = _save_rank1_adapter(tmp_path)

        # Build a mock model with load_weights and parameters
        weight = ops.zeros((D_MODEL, IN_FEATURES))
        ops.eval(weight)

        loaded: list[tuple[str, Array]] = []

        class _MockModel:
            def parameters(self) -> dict[str, object]:
                return {
                    "model": {
                        "layers": [
                            {
                                "self_attn": {
                                    "o_proj": {"weight": weight},
                                },
                            },
                        ],
                    },
                }

            def load_weights(
                self, updates: list[tuple[str, Array]],
            ) -> None:
                loaded.extend(updates)

        model = _MockModel()
        load_and_merge_adapters(model, [str(adapter_dir)])

        assert len(loaded) == 1
        key, new_w = loaded[0]
        assert "o_proj" in key
        # delta = ones(8,1) @ ones(1,16) = ones(8,16), weight was zeros
        assert new_w.shape == (D_MODEL, IN_FEATURES)
        # Every element should be ~1.0
        flat = new_w.reshape(-1)
        assert float(flat[0]) == pytest.approx(1.0, abs=0.01)

    def test_merge_with_custom_weight(self, tmp_path: Path) -> None:
        """Custom weight should scale the adapter delta."""
        from vauban.lora import load_and_merge_adapters

        adapter_dir = _save_rank1_adapter(tmp_path)

        weight = ops.zeros((D_MODEL, IN_FEATURES))
        ops.eval(weight)

        loaded: list[tuple[str, Array]] = []

        class _MockModel:
            def parameters(self) -> dict[str, object]:
                return {
                    "model": {
                        "layers": [
                            {
                                "self_attn": {
                                    "o_proj": {"weight": weight},
                                },
                            },
                        ],
                    },
                }

            def load_weights(
                self, updates: list[tuple[str, Array]],
            ) -> None:
                loaded.extend(updates)

        model = _MockModel()
        load_and_merge_adapters(model, [str(adapter_dir)], weights=[0.5])

        assert len(loaded) == 1
        _key, new_w = loaded[0]
        # merge_adapters scales each tensor: (0.5*B) @ (0.5*A) = 0.25 * B@A
        flat = new_w.reshape(-1)
        assert float(flat[0]) == pytest.approx(0.25, abs=0.01)

    def test_non_lora_keys_are_ignored_and_base_key_fallback_is_used(
        self,
    ) -> None:
        """Merged non-LoRA keys are ignored and fallback weight keys work."""
        from vauban.lora import load_and_merge_adapters

        weight = ops.zeros((D_MODEL, IN_FEATURES))
        ops.eval(weight)

        loaded: list[tuple[str, Array]] = []

        class _MockModel:
            def parameters(self) -> dict[str, object]:
                return {
                    "layers": [
                        {
                            "self_attn": {
                                "o_proj": {"weight": weight},
                            },
                        },
                    ],
                }

            def load_weights(
                self, updates: list[tuple[str, Array]],
            ) -> None:
                loaded.extend(updates)

        merged = {
            "misc.weight": ops.ones((1, 1)),
            "layers.0.self_attn.o_proj.lora_a": ops.ones((1, IN_FEATURES)),
            "layers.0.self_attn.o_proj.lora_b": ops.ones((D_MODEL, 1)),
        }

        with (
            patch("vauban.lora.merge_adapters", return_value=merged),
            patch(
                "vauban._ops.tree_flatten",
                return_value=[("layers.0.self_attn.o_proj.weight", weight)],
            ),
        ):
            load_and_merge_adapters(_MockModel(), ["adapter_a"])

        assert len(loaded) == 1
        assert loaded[0][0] == "layers.0.self_attn.o_proj.weight"

    def test_missing_safetensors_raises(self, tmp_path: Path) -> None:
        """Adapters without safetensors should fail fast."""
        from vauban.lora import merge_adapters

        empty = tmp_path / "empty"
        empty.mkdir()

        with pytest.raises(FileNotFoundError, match="No safetensors"):
            merge_adapters([empty], [1.0])


class TestLoadAndApplyAdapter:
    """Tests for loading and fusing adapters into a model."""

    def test_load_and_apply_adapter_fuses_children(self) -> None:
        """load_and_apply_adapter should call apply and fuse LoRA children."""
        from vauban.lora import load_and_apply_adapter

        class _Child:
            def __init__(self) -> None:
                self.fused = False

            def to_linear(self) -> str:
                self.fused = True
                return "fused"

        class _Module:
            def __init__(self) -> None:
                self.child = _Child()
                self.empty = None

        class _Model:
            def __init__(self) -> None:
                self.module = _Module()

            def named_modules(self) -> list[tuple[str, object]]:
                return [("module", self.module)]

        fake_utils = types.ModuleType("mlx_lm.tuner.utils")
        fake_utils.apply_lora_layers = lambda model, adapter_path: None  # type: ignore[assignment]
        fake_tuner = types.ModuleType("mlx_lm.tuner")
        fake_mlx = types.ModuleType("mlx_lm")
        fake_mlx.tuner = fake_tuner  # type: ignore[attr-defined]
        fake_tuner.utils = fake_utils  # type: ignore[attr-defined]

        model = _Model()
        child = model.module.child
        with patch.dict(
            sys.modules,
            {
                "mlx_lm": fake_mlx,
                "mlx_lm.tuner": fake_tuner,
                "mlx_lm.tuner.utils": fake_utils,
            },
        ):
            load_and_apply_adapter(model, "/tmp/adapter")

        assert model.module.child == "fused"
        assert model.module.empty is None
        assert child.fused is True
        assert isinstance(model.module.child, str)


# ---------------------------------------------------------------------------
# Mode registry tests
# ---------------------------------------------------------------------------


class TestModeRegistry:
    """Tests for lora_analysis mode registration."""

    def test_has_lora_analysis_predicate(self) -> None:
        """Mode should be active when lora_analysis is set."""
        from pathlib import Path

        from vauban.config._mode_registry import active_early_modes
        from vauban.types import LoraAnalysisConfig, PipelineConfig

        config = PipelineConfig(
            model_path="test",
            harmful_path=Path("h.jsonl"),
            harmless_path=Path("s.jsonl"),
            lora_analysis=LoraAnalysisConfig(adapter_path="a/"),
        )
        assert "[lora_analysis]" in active_early_modes(config)

    def test_has_lora_analysis_none(self) -> None:
        """Mode should not be active when lora_analysis is None."""
        from pathlib import Path

        from vauban.config._mode_registry import active_early_modes
        from vauban.types import PipelineConfig

        config = PipelineConfig(
            model_path="test",
            harmful_path=Path("h.jsonl"),
            harmless_path=Path("s.jsonl"),
        )
        assert "[lora_analysis]" not in active_early_modes(config)

    def test_early_mode_spec_registered(self) -> None:
        """lora_analysis should be in EARLY_MODE_SPECS."""
        from vauban.config._mode_registry import EARLY_MODE_SPECS

        modes = [spec.mode for spec in EARLY_MODE_SPECS]
        assert "lora_analysis" in modes

    def test_mode_runner_registered(self) -> None:
        """lora_analysis should be in EARLY_MODE_RUNNERS."""
        from vauban._pipeline._modes import EARLY_MODE_RUNNERS

        assert "lora_analysis" in EARLY_MODE_RUNNERS
