"""Focused regression tests for causal-LM evaluation and report output."""

from __future__ import annotations

import json
import math
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock

import pytest
import torch
from torch import nn

from obliteratus.evaluation.evaluator import Evaluator


class _MapDataset:
    def __init__(self, texts: list[object]):
        self.texts = texts
        self.read_indices: list[int] = []
        self.selected_indices: list[int] | None = None

    def __len__(self):
        return len(self.texts)

    def __getitem__(self, key):
        if isinstance(key, str):
            raise TypeError("the evaluator must not materialize a full dataset column")
        if isinstance(key, slice):
            return {"text": self.texts[key]}
        self.read_indices.append(key)
        return {"text": self.texts[key]}

    def select(self, indices):
        self.selected_indices = list(indices)
        return _MapDataset([self.texts[index] for index in self.selected_indices])


class _ChangingDataset(_MapDataset):
    """Simulate a transformed dataset changing after index selection."""

    def select(self, indices):
        self.selected_indices = list(indices)
        return _MapDataset([""])


class _Encoding(dict):
    def to(self, _device):
        return self


class _ScriptedTokenizer:
    def __init__(self, encodings: list[_Encoding]):
        self.encodings = list(encodings)
        self.calls: list[list[str]] = []

    def __call__(self, texts, **_kwargs):
        self.calls.append(list(texts))
        return self.encodings.pop(0)


class _LossModel(nn.Module):
    def __init__(self, losses: list[float | None]):
        super().__init__()
        self.anchor = nn.Parameter(torch.zeros(()))
        self.losses = list(losses)
        self.labels: list[torch.Tensor] = []

    def forward(self, *, input_ids, attention_mask, labels):
        del input_ids, attention_mask
        self.labels.append(labels.detach().clone())
        loss = self.losses.pop(0)
        return SimpleNamespace(loss=None if loss is None else torch.tensor(loss))


def _encoding(input_ids, attention_mask) -> _Encoding:
    return _Encoding(
        input_ids=torch.as_tensor(input_ids, dtype=torch.long),
        attention_mask=torch.as_tensor(attention_mask, dtype=torch.long),
    )


def _evaluator(
    texts: list[object],
    encodings: list[_Encoding],
    losses: list[float | None],
    *,
    batch_size: int = 8,
    max_samples: int | None = None,
):
    dataset = _MapDataset(texts)
    tokenizer = _ScriptedTokenizer(encodings)
    model = _LossModel(losses)
    handle = SimpleNamespace(model=model, tokenizer=tokenizer, task="causal_lm")
    evaluator = Evaluator(
        handle=handle,
        dataset=dataset,
        batch_size=batch_size,
        max_samples=max_samples,
    )
    return evaluator, dataset, tokenizer, model


def test_causal_lm_skips_blank_rows_bounds_scan_and_masks_padding():
    evaluator, dataset, tokenizer, model = _evaluator(
        ["", "  \t", "alpha", "beta", "must not be read"],
        [_encoding([[1, 2, 0], [3, 4, 5]], [[1, 1, 0], [1, 1, 1]])],
        [math.log(4.0)],
        batch_size=2,
        max_samples=2,
    )

    result = evaluator.evaluate()

    assert result["perplexity"] == pytest.approx(4.0)
    assert dataset.read_indices == [0, 1, 2, 3]
    assert dataset.selected_indices == [2, 3]
    assert tokenizer.calls == [["alpha", "beta"]]
    assert model.labels[0].tolist() == [[1, 2, -100], [3, 4, 5]]


def test_causal_lm_rejects_all_empty_dataset():
    evaluator, dataset, tokenizer, model = _evaluator(["", " \n", None], [], [])

    with pytest.raises(ValueError, match="no non-empty text"):
        evaluator.evaluate()

    assert dataset.read_indices == [0, 1, 2]
    assert tokenizer.calls == []
    assert model.labels == []


@pytest.mark.parametrize("max_samples", [0, -1])
def test_causal_lm_requires_positive_max_samples(max_samples):
    evaluator, dataset, _tokenizer, _model = _evaluator(
        ["alpha"],
        [],
        [],
        max_samples=max_samples,
    )

    with pytest.raises(ValueError, match="max_samples must be a positive integer"):
        evaluator.evaluate()

    assert dataset.read_indices == []


def test_causal_lm_rejects_tokenless_and_one_token_sequences():
    evaluator, _dataset, _tokenizer, model = _evaluator(
        ["tokenless", "single"],
        [
            _encoding(torch.empty((1, 0)), torch.empty((1, 0))),
            _encoding([[1]], [[1]]),
        ],
        [],
        batch_size=1,
    )

    with pytest.raises(RuntimeError, match="zero valid prediction tokens"):
        evaluator.evaluate()

    assert model.labels == []


def test_causal_lm_skips_batch_emptied_by_dataset_transform():
    dataset = _ChangingDataset(["alpha"])
    tokenizer = _ScriptedTokenizer([])
    model = _LossModel([])
    handle = SimpleNamespace(model=model, tokenizer=tokenizer, task="causal_lm")

    with pytest.raises(RuntimeError, match="zero valid prediction tokens"):
        Evaluator(handle=handle, dataset=dataset).evaluate()

    assert tokenizer.calls == []
    assert model.labels == []


def test_causal_lm_skips_zero_target_batch_then_reports_valid_perplexity():
    evaluator, _dataset, _tokenizer, model = _evaluator(
        ["padded target", "valid target"],
        [
            _encoding([[1, 0]], [[1, 0]]),
            _encoding([[2, 3]], [[1, 1]]),
        ],
        [math.log(3.0)],
        batch_size=1,
    )

    result = evaluator.evaluate()

    assert result["perplexity"] == pytest.approx(3.0)
    assert len(model.labels) == 1


def test_causal_lm_rejects_missing_loss():
    evaluator, _dataset, _tokenizer, _model = _evaluator(
        ["alpha"],
        [_encoding([[1, 2]], [[1, 1]])],
        [None],
    )

    with pytest.raises(RuntimeError, match="returned no loss"):
        evaluator.evaluate()


@pytest.mark.parametrize("loss", [float("nan"), float("inf"), float("-inf")])
def test_causal_lm_rejects_non_finite_loss(loss):
    evaluator, _dataset, _tokenizer, _model = _evaluator(
        ["alpha"],
        [_encoding([[1, 2]], [[1, 1]])],
        [loss],
    )

    with pytest.raises(RuntimeError, match="Non-finite evaluation loss"):
        evaluator.evaluate()


def test_causal_lm_perplexity_is_weighted_by_prediction_tokens():
    evaluator, _dataset, _tokenizer, _model = _evaluator(
        ["three tokens", "two tokens"],
        [
            _encoding([[1, 2, 3]], [[1, 1, 1]]),
            _encoding([[4, 5]], [[1, 1]]),
        ],
        [math.log(2.0), math.log(8.0)],
        batch_size=1,
    )

    result = evaluator.evaluate()

    expected = math.exp((2 * math.log(2.0) + math.log(8.0)) / 3)
    assert result["perplexity"] == pytest.approx(expected)


def test_report_creates_nested_output_directory(monkeypatch, tmp_path):
    import obliteratus.reporting.report
    from obliteratus import cli

    results_path = tmp_path / "results.json"
    results_path.write_text(
        json.dumps(
            {
                "model_name": "fixture",
                "baseline_metrics": {"perplexity": 4.0},
                "results": [],
            }
        )
    )
    report = MagicMock()
    monkeypatch.setattr(
        obliteratus.reporting.report,
        "AblationReport",
        Mock(return_value=report),
    )
    output_dir = tmp_path / "nested" / "plots"

    cli._cmd_report(
        SimpleNamespace(results_json=str(results_path), output_dir=str(output_dir))
    )

    assert output_dir.is_dir()
    report.plot_impact.assert_called_once_with(
        metric="perplexity",
        output_path=output_dir / "impact.png",
    )
    report.plot_heatmap.assert_called_once_with(output_path=output_dir / "heatmap.png")
