"""CPU-safe contract tests for Bayesian optimization helpers."""

from __future__ import annotations

import builtins
import sys
import types

import pytest
import torch
import torch.nn as nn

from obliteratus import bayesian_optimizer as bo


class _Pipeline:
    def __init__(self):
        self.refusal_directions = {}
        self.handle = None
        self._strong_layers = []
        self.harmful_prompts = ["harmful one", "harmful two"]
        self.use_chat_template = False
        self.freed = 0
        self.logs = []

    def _get_model_device(self, _model):
        return torch.device("cpu")

    def _maybe_apply_chat_template(self, prompts):
        return [f"<chat>{prompt}</chat>" for prompt in prompts]

    def _free_gpu_memory(self):
        self.freed += 1

    def log(self, message):
        self.logs.append(message)


def _install_fake_optuna(monkeypatch, study=None):
    optuna = types.ModuleType("optuna")
    optuna.logging = types.SimpleNamespace(WARNING=30, set_verbosity=lambda _level: None)
    optuna.create_study = lambda **_kwargs: study

    samplers = types.ModuleType("optuna.samplers")

    class TPESampler:
        def __init__(self, **_kwargs):
            pass

    samplers.TPESampler = TPESampler
    monkeypatch.setitem(sys.modules, "optuna", optuna)
    monkeypatch.setitem(sys.modules, "optuna.samplers", samplers)


class _FakeTrial:
    number = 0

    params = {
        "attn_max_weight": 0.8,
        "attn_peak_position": 0.0,
        "attn_min_weight": 0.1,
        "attn_spread": 0.6,
        "mlp_max_weight": 0.6,
        "mlp_peak_position": 1.0,
        "mlp_min_weight": 0.2,
        "mlp_spread": 0.6,
        "dir_idx": 1.0,
    }

    values = (0.2, 0.1)

    def __init__(self, number=0, params=None, values=None):
        self.number = number
        if params is not None:
            self.params = params
        if values is not None:
            self.values = values

    def suggest_float(self, name, _low, _high):
        return self.params[name]


class _FakeStudy:
    def __init__(self, best_trials):
        self.best_trials = best_trials
        self.enqueued = []
        self.objective_values = []

    def enqueue_trial(self, params):
        self.enqueued.append(params)

    def optimize(self, objective, n_trials, show_progress_bar):
        assert show_progress_bar is False
        for number in range(n_trials):
            self.objective_values.append(objective(_FakeTrial(number=number)))


class _TokenBatch(dict):
    def __init__(self):
        super().__init__(input_ids=torch.tensor([[1, 2]]))


class _ReferenceTokenizer:
    def __call__(self, *_args, **_kwargs):
        return _TokenBatch()


class _Layer(nn.Module):
    def __init__(self):
        super().__init__()
        self.self_attn = nn.Module()
        self.self_attn.o_proj = nn.Linear(2, 2)
        self.mlp = nn.Module()
        self.mlp.down_proj = nn.Linear(2, 2)


class _ReferenceModel(nn.Module):
    def __init__(self, layers):
        super().__init__()
        self.model = nn.Module()
        self.model.layers = nn.ModuleList(layers)

    def forward(self, **_kwargs):
        return types.SimpleNamespace(logits=torch.tensor([[[0.0, 1.0]]]))


def _optimization_pipeline(layers):
    pipeline = _Pipeline()
    pipeline.handle = types.SimpleNamespace(
        model=_ReferenceModel(layers),
        tokenizer=_ReferenceTokenizer(),
        architecture="llama",
    )
    pipeline._strong_layers = list(range(len(layers)))
    pipeline.refusal_directions = {
        idx: torch.tensor([float(idx + 1), 1.0])
        for idx in pipeline._strong_layers
    }
    pipeline.norm_preserve = True
    pipeline.projections = []
    pipeline.moe_calls = []

    def project_out(module, direction, names, norm_preserve, regularization):
        pipeline.projections.append(
            {
                "module": module,
                "direction": direction.detach().clone(),
                "names": tuple(names),
                "norm_preserve": norm_preserve,
                "regularization": regularization,
            }
        )
        for name in names:
            proj = getattr(module, name, None)
            if proj is not None and hasattr(proj, "weight"):
                proj.weight.data.add_(10.0)
                return 1
        return 0

    def project_moe(module, direction, **kwargs):
        pipeline.moe_calls.append((module, direction.detach().clone(), kwargs))

    pipeline._project_out_advanced = project_out
    pipeline._project_moe_experts = project_moe
    return pipeline


def test_parametric_layer_weight_boundaries():
    assert bo._parametric_layer_weight(0, 1, 0.8, 0.5, 0.1, 0.2) == pytest.approx(0.8)

    # At the peak, the kernel returns the maximum weight.
    assert bo._parametric_layer_weight(2, 5, 0.9, 0.5, 0.1, 0.25) == pytest.approx(0.9)

    # At the tent edge, it reaches the minimum weight.
    assert bo._parametric_layer_weight(1, 5, 0.9, 0.5, 0.1, 0.25) == pytest.approx(0.1)

    # Outside the spread cutoff, the layer is skipped.
    assert bo._parametric_layer_weight(0, 5, 0.9, 0.5, 0.1, 0.24) == pytest.approx(0.0)

    # Tiny or negative spread is clamped to 0.01.
    assert bo._parametric_layer_weight(0, 101, 0.7, 0.0, 0.2, -1.0) == pytest.approx(0.7)


def test_interpolate_direction_handles_empty_clamps_exact_and_normalized_interpolation():
    pipeline = _Pipeline()
    assert torch.equal(bo._interpolate_direction(pipeline, layer_idx=3, float_dir_idx=1.0), torch.zeros(1))

    pipeline.refusal_directions = {
        2: torch.tensor([3.0, 0.0]),
        5: torch.tensor([0.0, 4.0]),
        9: torch.tensor([1.0, 1.0]),
    }

    low = bo._interpolate_direction(pipeline, layer_idx=5, float_dir_idx=-10.0)
    assert torch.allclose(low, torch.tensor([1.0, 0.0]))

    high = bo._interpolate_direction(pipeline, layer_idx=5, float_dir_idx=99.0)
    assert torch.allclose(high, torch.tensor([2**-0.5, 2**-0.5]))

    exact = bo._interpolate_direction(pipeline, layer_idx=5, float_dir_idx=1.0)
    assert torch.allclose(exact, torch.tensor([0.0, 1.0]))

    interpolated = bo._interpolate_direction(pipeline, layer_idx=5, float_dir_idx=0.5)
    expected = torch.tensor([1.5, 2.0])
    expected = expected / expected.norm()
    assert torch.allclose(interpolated, expected)
    assert interpolated.norm().item() == pytest.approx(1.0)


def test_run_bayesian_optimization_returns_empty_when_optuna_missing(monkeypatch):
    real_import = builtins.__import__

    def fake_import(name, *args, **kwargs):
        if name == "optuna" or name.startswith("optuna."):
            raise ImportError("no optuna in this test")
        return real_import(name, *args, **kwargs)

    monkeypatch.setattr(builtins, "__import__", fake_import)

    pipeline = _Pipeline()
    pipeline.handle = object()
    pipeline._strong_layers = [0]

    assert bo.run_bayesian_optimization(pipeline) == {}


def test_run_bayesian_optimization_returns_empty_without_handle_or_layers(monkeypatch):
    _install_fake_optuna(monkeypatch)

    pipeline = _Pipeline()
    pipeline.handle = None
    pipeline._strong_layers = [0]
    assert bo.run_bayesian_optimization(pipeline) == {}

    pipeline.handle = types.SimpleNamespace(model=object(), tokenizer=object())
    pipeline._strong_layers = []
    assert bo.run_bayesian_optimization(pipeline) == {}


def test_run_bayesian_optimization_returns_empty_when_reference_logits_fail(monkeypatch):
    _install_fake_optuna(monkeypatch)

    class RaisingTokenizer:
        def __call__(self, *_args, **_kwargs):
            raise RuntimeError("tokenization failed")

    pipeline = _Pipeline()
    pipeline.handle = types.SimpleNamespace(
        model=object(),
        tokenizer=RaisingTokenizer(),
    )
    pipeline._strong_layers = [0]

    assert bo.run_bayesian_optimization(pipeline, n_kl_prompts=2) == {}
    assert pipeline.freed == 1
    assert "Failed to collect reference logits" in pipeline.logs[-1]


def test_measure_refusal_rate_restores_chat_template_and_counts_generation_failures():
    class TokenBatch(dict):
        def __init__(self, token_count):
            super().__init__(input_ids=torch.arange(token_count).unsqueeze(0))

    class Tokenizer:
        def __init__(self):
            self.calls = 0

        def __call__(self, prompt, **_kwargs):
            self.calls += 1
            return TokenBatch(3 if "one" in prompt else 4)

        def decode(self, token_ids, **_kwargs):
            if token_ids.numel() == 0:
                return ""
            return "I cannot assist with that request"

    class Model:
        def __init__(self):
            self.calls = 0

        def generate(self, **kwargs):
            self.calls += 1
            if self.calls == 2:
                raise RuntimeError("generation failed")
            input_ids = kwargs["input_ids"]
            continuation = torch.tensor([[99, 100]])
            return torch.cat([input_ids, continuation], dim=1)

    pipeline = _Pipeline()
    pipeline.use_chat_template = False
    pipeline.handle = types.SimpleNamespace(model=Model(), tokenizer=Tokenizer())

    rate = bo._measure_refusal_rate(pipeline, n_prompts=2, max_new_tokens=4)

    assert rate == pytest.approx(0.5)
    assert pipeline.use_chat_template is False
    assert pipeline.freed == 1


def test_measure_kl_divergence_skips_failures_and_missing_reference_logits():
    class TokenBatch(dict):
        def __init__(self):
            super().__init__(input_ids=torch.tensor([[1, 2]]))

    class Tokenizer:
        def __call__(self, prompt, **_kwargs):
            if "bad" in prompt:
                raise RuntimeError("tokenization failed")
            return TokenBatch()

    class Output:
        logits = torch.tensor([[[0.0, 1.0, 2.0]]])

    class Model:
        def __call__(self, **_kwargs):
            return Output()

    pipeline = _Pipeline()
    pipeline.handle = types.SimpleNamespace(model=Model(), tokenizer=Tokenizer())
    reference = [torch.tensor([0.0, 1.0, 2.0])]

    kl = bo._measure_kl_divergence(
        pipeline,
        reference_logits=reference,
        prompts=["good", "bad", "ignored because no reference"],
    )

    assert kl == pytest.approx(0.0)
    assert pipeline.freed == 1


def test_run_bayesian_optimization_pareto_path_enqueues_warm_start_and_restores(monkeypatch):
    layers = [_Layer(), _Layer()]
    originals = [
        layer.self_attn.o_proj.weight.detach().clone()
        for layer in layers
    ] + [
        layer.mlp.down_proj.weight.detach().clone()
        for layer in layers
    ]
    best_params = {
        "attn_max_weight": 0.8,
        "attn_peak_position": 0.0,
        "attn_min_weight": 0.1,
        "attn_spread": 0.6,
        "mlp_max_weight": 0.6,
        "mlp_peak_position": 1.0,
        "mlp_min_weight": 0.2,
        "mlp_spread": 0.6,
        "dir_idx": 1.0,
    }
    worse_params = {**best_params, "dir_idx": 0.0}
    study = _FakeStudy(
        best_trials=[
            _FakeTrial(params=worse_params, values=(0.5, 0.1)),
            _FakeTrial(params=best_params, values=(0.2, 0.3)),
        ]
    )
    _install_fake_optuna(monkeypatch, study)
    monkeypatch.setattr(bo, "_measure_refusal_rate", lambda *_args, **_kwargs: 0.25)
    monkeypatch.setattr(bo, "_measure_kl_divergence", lambda *_args, **_kwargs: 0.1)

    pipeline = _optimization_pipeline(layers)
    pipeline._informed_warm_start = {
        "max_weight": 0.8,
        "peak_position": 0.25,
        "min_weight": 0.02,
        "spread": 0.2,
        "mlp_scale": 0.5,
        "dir_idx": 0.75,
    }

    result = bo.run_bayesian_optimization(
        pipeline,
        n_trials=2,
        n_refusal_prompts=3,
        n_kl_prompts=1,
    )

    assert study.enqueued == [{
        "attn_max_weight": 0.8,
        "attn_peak_position": 0.25,
        "attn_min_weight": 0.02,
        "attn_spread": 0.2,
        "mlp_max_weight": 0.4,
        "mlp_peak_position": 0.25,
        "mlp_min_weight": 0.02,
        "mlp_spread": 0.2,
        "dir_idx": 0.75,
    }]
    assert study.objective_values == [(0.25, 0.1), (0.25, 0.1)]
    assert result == {0: pytest.approx(0.6), 1: pytest.approx(0.7)}
    assert pipeline._bayesian_attn_scale == pytest.approx(0.8)
    assert pipeline._bayesian_mlp_scale == pytest.approx(0.6)
    assert any("Applying interpolated direction" in msg for msg in pipeline.logs)

    restored = [
        layer.self_attn.o_proj.weight
        for layer in layers
    ] + [
        layer.mlp.down_proj.weight
        for layer in layers
    ]
    for live, original in zip(restored, originals, strict=True):
        assert torch.allclose(live, original)

    assert len(pipeline.projections) == 8
    regularizations = sorted({round(call["regularization"], 6) for call in pipeline.projections})
    assert regularizations == [0.2, 0.4, 1.0]
    assert all(call["norm_preserve"] is True for call in pipeline.projections)
    assert all(call["direction"].shape == (2, 1) for call in pipeline.projections)

    expected_direction = torch.tensor([2.0, 1.0])
    expected_direction = expected_direction / expected_direction.norm()
    assert torch.allclose(pipeline.refusal_directions[0], expected_direction)
    assert torch.allclose(pipeline.refusal_directions[1], expected_direction)


def test_run_bayesian_optimization_no_pareto_uses_objective_best_and_restores_after_projection_errors(
    monkeypatch,
):
    layers = [_Layer()]
    original_attn = layers[0].self_attn.o_proj.weight.detach().clone()
    original_mlp = layers[0].mlp.down_proj.weight.detach().clone()
    study = _FakeStudy(best_trials=[])
    _install_fake_optuna(monkeypatch, study)
    monkeypatch.setattr(bo, "_measure_refusal_rate", lambda *_args, **_kwargs: 0.4)
    monkeypatch.setattr(bo, "_measure_kl_divergence", lambda *_args, **_kwargs: 0.2)

    pipeline = _optimization_pipeline(layers)

    def raising_project(*_args, **_kwargs):
        layers[0].self_attn.o_proj.weight.data.add_(5.0)
        layers[0].mlp.down_proj.weight.data.add_(7.0)
        raise RuntimeError("projection failed")

    pipeline._project_out_advanced = raising_project

    result = bo.run_bayesian_optimization(
        pipeline,
        n_trials=1,
        n_refusal_prompts=1,
        n_kl_prompts=1,
    )

    assert study.enqueued == [{
        "attn_max_weight": 0.9,
        "attn_peak_position": 0.0,
        "attn_min_weight": 0.05,
        "attn_spread": 0.3,
        "mlp_max_weight": 0.6,
        "mlp_peak_position": 0.0,
        "mlp_min_weight": 0.05,
        "mlp_spread": 0.3,
        "dir_idx": 0.0,
    }]
    assert study.objective_values == [(0.4, 0.2)]
    assert result == {0: pytest.approx(0.3)}
    assert any("Using best combined score: 0.5000" in msg for msg in pipeline.logs)
    assert pipeline.moe_calls == []
    assert torch.allclose(layers[0].self_attn.o_proj.weight, original_attn)
    assert torch.allclose(layers[0].mlp.down_proj.weight, original_mlp)
