from __future__ import annotations

import pytest

from recoil.pipeline._lib import filter_safety as fs


EXPECTED_SWAP_ALIASES = {
    "crime_genre": {
        "yakuza compound": "Traditional Japanese noble clan compound / old-money Japanese dynasty estate",
        "yakuza": "Noble clan / dynasty / house",
        "organized crime": "(omit — let the setting imply it)",
        "criminal empire": "Dynasty / legacy / house",
        "gang": "Clan, family, house",
        "mob": "Clan, family, house",
        "cartel": "Clan, family, house",
        "hideout": "Estate, compound, residence",
        "lair": "Estate, compound, residence",
    },
    "threat_tone": {
        "lethal": "Precise / composed / decisive",
        "dangerous": "Focused / commanding / intense",
        "predator": "Poised / confident / controlled",
        "predatory": "Poised / confident / controlled",
        "coiled danger": "Quiet intensity / focused calm",
        "revenge": "(omit — let the narrative imply it)",
        "assassin": "(omit or use character name only)",
        "killer": "(omit or use character name only)",
        "betrayal": "History between them / unspoken weight / what happened",
    },
    "action_motion": {
        "cutting down": "Confronting / engaging / moving through",
        "slicing": "Confronting / engaging / moving through",
        "kill": "(omit — let the visual imply it)",
        "killing": "(omit — let the visual imply it)",
        "killed": "(omit — let the visual imply it)",
        "bloodied": "Drawn / in use / after the encounter",
        "mid-strike": "In motion / mid-action / mid-sequence",
        "violence": "Intensity / action / choreography",
        "violent": "Intensity / action / choreography",
        "brutal": "Grounded / physical / raw",
        "savage": "Grounded / physical / raw",
        "aftermath": "Stillness after / the quiet after / what remains",
        "bodies": "Fallen figures / still figures / (omit)",
        "wound": "(omit)",
        "injury": "(omit)",
        "attack": "Approach / advance / engage",
        "assault": "Approach / advance / engage",
        "fight": "Sequence / encounter / confrontation",
        "battle": "Sequence / encounter / confrontation",
    },
    "weapon": {
        "weapon": "Prop / accessory / (name the object directly)",
        "katana": "Heirloom blade / ceremonial sword / the blade",
        "sword drawn for combat": "Blade in hand / blade at her side / blade catching light",
        "armed": "Carrying / equipped / with [object]",
        "flamethrower": "(if needed: industrial torch / handheld flame unit)",
    },
    "appearance": {
        "bralette": "Fitted top / cropped top / halter",
        "bra": "Fitted top / cropped top / halter",
        "harness": "Tactical rig / utility vest / straps over [garment]",
        "exposed": "Sleeveless / fitted / cropped",
        "revealing": "Sleeveless / fitted / cropped",
        "sexy": "Confident / striking / bold",
        "seductive": "Confident / striking / bold",
    },
}


def test_glossary_loads_with_non_empty_categories():
    glossary = fs.load_glossary()

    assert glossary.stack_threshold == 2
    assert glossary.safe_sections == ("CAMERA", "COMPOSITION", "LIGHTING", "ATMOSPHERE")
    assert set(glossary.categories) == {
        "weapon",
        "action_motion",
        "appearance",
        "threat_tone",
        "crime_genre",
    }
    assert all(glossary.categories[category] for category in glossary.categories)


def test_swap_completeness_is_exact_per_alias():
    glossary = fs.load_glossary()

    for category, expected in EXPECTED_SWAP_ALIASES.items():
        assert dict(glossary.swaps[category]) == expected


def test_load_glossary_missing_file_raises_value_error(tmp_path):
    with pytest.raises(ValueError, match="missing.*filter safety glossary"):
        fs.load_glossary(tmp_path / "missing.yaml")


def test_load_glossary_missing_required_key_raises_value_error(tmp_path):
    path = tmp_path / "filter_safety.yaml"
    path.write_text(
        """
schema_version: 1
categories: {}
swaps: {}
safe_sections: []
""",
        encoding="utf-8",
    )

    with pytest.raises(ValueError, match="missing required key.*stack_threshold"):
        fs.load_glossary(path)


def test_load_glossary_non_list_category_raises_value_error(tmp_path):
    path = tmp_path / "filter_safety.yaml"
    path.write_text(
        """
schema_version: 1
categories:
  weapon: weapon
swaps: {}
safe_sections: []
stack_threshold: 2
""",
        encoding="utf-8",
    )

    with pytest.raises(ValueError, match="category 'weapon' must be a list"):
        fs.load_glossary(path)


def test_load_glossary_bad_yaml_raises_value_error(tmp_path):
    path = tmp_path / "filter_safety.yaml"
    path.write_text("schema_version: [\n", encoding="utf-8")

    with pytest.raises(ValueError, match="invalid filter safety YAML"):
        fs.load_glossary(path)


def test_filter_safety_mode_env_override_config_default_and_invalid(monkeypatch):
    monkeypatch.delenv("RECOIL_FILTER_SAFETY", raising=False)
    monkeypatch.setattr(fs, "load_project_config", lambda _project: {})
    assert fs.filter_safety_mode("fixture") == "shadow"

    monkeypatch.setattr(
        fs,
        "load_project_config",
        lambda _project: {"filter_safety_mode": "off"},
    )
    assert fs.filter_safety_mode("fixture") == "off"

    monkeypatch.setenv("RECOIL_FILTER_SAFETY", "shadow")
    monkeypatch.setattr(
        fs,
        "load_project_config",
        lambda _project: {"filter_safety_mode": "off"},
    )
    assert fs.filter_safety_mode("fixture") == "shadow"

    monkeypatch.setenv("RECOIL_FILTER_SAFETY", "bogus")
    with pytest.raises(ValueError, match="bogus"):
        fs.filter_safety_mode("fixture")

    monkeypatch.delenv("RECOIL_FILTER_SAFETY", raising=False)
    monkeypatch.setattr(
        fs,
        "load_project_config",
        lambda _project: {"filter_safety_mode": "bogus"},
    )
    with pytest.raises(ValueError, match="bogus"):
        fs.filter_safety_mode("fixture")


def test_lint_prompt_bad_stack_warns_with_suggested_swaps():
    findings = fs.lint_prompt(
        "She draws her bloodied katana mid-strike, the lethal warrior in her torn harness confronting the guards."
    )

    assert len(findings) == 1
    finding = findings[0]
    assert finding.level == "WARN"
    assert len(finding.categories) >= 3
    assert finding.suggested_swaps["katana"]
    assert finding.suggested_swaps["lethal"]


def test_lint_prompt_good_spread_has_zero_warn_findings():
    findings = fs.lint_prompt(
        "She moves through the gate, blade in hand. Her expression is focused, composed. The guards step back."
    )

    assert [finding for finding in findings if finding.level == "WARN"] == []


def test_lint_prompt_safe_section_demotes_bad_stack_to_info():
    findings = fs.lint_prompt(
        "CAMERA & COMPOSITION\n"
        "She draws her bloodied katana mid-strike, the lethal warrior in her torn harness confronting the guards."
    )

    assert len(findings) == 1
    assert findings[0].level == "INFO"
    assert findings[0].section == "CAMERA & COMPOSITION"


def test_lint_prompt_single_category_sentence_has_no_finding():
    assert fs.lint_prompt("She holds the katana.") == []


def test_lint_prompt_crime_genre_stacking_warns():
    findings = fs.lint_prompt("The yakuza assassin waits, blade drawn.")

    assert len(findings) == 1
    assert findings[0].level == "WARN"
    assert "crime_genre" in findings[0].categories


def test_summarize_findings_counts_levels_categories_and_terms():
    findings = fs.lint_prompt(
        "CAMERA & COMPOSITION\n"
        "She draws her bloodied katana mid-strike, the lethal warrior in her torn harness confronting the guards.\n"
        "DIALOGUE\n"
        "The yakuza assassin waits, blade drawn."
    )

    summary = fs.summarize_findings(findings)

    assert summary["warn"] == 1
    assert summary["info"] == 1
    assert "crime_genre" in summary["categories_hit"]
    assert "katana" in summary["top_terms"]
