"""tests/test_adversarial_min_perturbation_attacker.py — Unit tests for MinPerturbationAttacker."""
import pytest
from unittest.mock import MagicMock

pytestmark = pytest.mark.unit

STEPS = [
    {"thought": "searching for information about capital", "action_type": "Search",
     "action_arg": "capital France", "observation": "Paris is the capital of France."},
    {"thought": "found answer", "action_type": "Finish",
     "action_arg": "Paris", "observation": ""},
]


class TestImport:
    def test_import_attacker(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        assert MinPerturbationAttacker is not None


class TestInstantiation:
    def test_default_budget(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker()
        assert a.budget == 10

    def test_custom_budget(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker(budget=5)
        assert a.budget == 5

    def test_default_text_fields(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker()
        assert "thought" in a.text_fields
        assert "observation" in a.text_fields

    def test_custom_text_fields(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker(text_fields=["thought"])
        assert a.text_fields == ["thought"]


class TestGetSynonyms:
    def test_returns_list(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker()
        syns = a.get_synonyms("good")
        assert isinstance(syns, list)

    def test_nltk_unavailable_returns_empty(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        import sys
        from unittest.mock import patch
        a = MinPerturbationAttacker()
        # Patch the import to simulate NLTK unavailability
        with patch.dict(sys.modules, {"nltk.corpus": None}):
            syns = a.get_synonyms("capital")
            # Should return empty or handle gracefully
            assert isinstance(syns, list)


class TestTokenize:
    def test_tokenize_basic(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker()
        toks = a._tokenize("hello world")
        assert toks == ["hello", "world"]

    def test_tokenize_empty(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker()
        toks = a._tokenize("")
        assert toks == []


class TestAttack:
    def _make_detector(self, score):
        """Mock detector that always returns a fixed adversarial score."""
        mock = MagicMock()
        mock_result = MagicMock()
        mock_result.adv_score = score
        mock.score_chain.return_value = mock_result
        return mock

    def test_attack_returns_dict(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker(budget=2)
        detector = self._make_detector(0.3)  # already below 0.5 → evaded immediately
        result = a.attack(STEPS, "What is the capital?", detector)
        assert isinstance(result, dict)

    def test_result_has_required_keys(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker(budget=1)
        detector = self._make_detector(0.3)
        result = a.attack(STEPS, "Question", detector)
        for key in ("perturbed_steps", "n_substitutions", "original_score",
                    "final_score", "evaded", "substitutions"):
            assert key in result

    def test_evaded_when_initial_score_low(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker(budget=5)
        detector = self._make_detector(0.3)  # below 0.5 threshold → evaded
        result = a.attack(STEPS, "Question", detector)
        assert result["evaded"] is True
        assert result["n_substitutions"] == 0

    def test_budget_override(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker(budget=10)
        detector = self._make_detector(0.8)  # high score → tries to attack
        result = a.attack(STEPS, "Question", detector, budget=1)
        assert isinstance(result["n_substitutions"], int)

    def test_empty_steps(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        a = MinPerturbationAttacker(budget=2)
        detector = self._make_detector(0.8)
        result = a.attack([], "Question", detector)
        assert result["n_substitutions"] == 0
        assert result["perturbed_steps"] == []

    def test_apply_substitution_creates_copy(self):
        from llm_guard.adversarial.min_perturbation_attacker import MinPerturbationAttacker
        import copy
        a = MinPerturbationAttacker()
        original = copy.deepcopy(STEPS)
        new_steps = a._apply_substitution(STEPS, 0, "thought", 0, "searching", "seeking")
        # Original should be unchanged
        assert STEPS[0]["thought"] == original[0]["thought"]
