"""Tests for orchestrator/coverage_density.py — emotion scoring, tier assignment, and moment clustering."""

import pytest

from orchestrator.coverage_density import (
    score_emotion,
    score_structure,
    score_routing,
    compute_composite,
    composite_to_tier,
    detect_scene_boundaries,
    apply_climax_cap,
    cluster_coverage_moments,
    analyze_coverage_density,
    TIER_BOUNDARIES,
    EMOTION_LEXICON,
    EMOTION_BIGRAMS,
    DEFAULT_EMOTION_SCORE,
    COVERAGE_CONFIG,
    MAX_CLIMAX_PER_SCENE,
    MAX_CLUSTER_SIZE,
)


# ── Helpers ──────────────────────────────────────────────────────────

def _shot(shot_id, emotion="", location="loc_a", characters=None,
          is_env=False, has_dialogue=False, num_characters=None,
          camera_side="A", camera_complexity=""):
    """Build a minimal shot dict for testing."""
    chars = characters or []
    if num_characters is None:
        num_characters = len(chars)
    return {
        "shot_id": shot_id,
        "prompt_data": {"prompt_skeleton": {"emotion_line": emotion}},
        "asset_data": {
            "location_id": location,
            "characters": [{"char_id": c} for c in chars],
        },
        "spatial_data": {"camera_side": camera_side},
        "routing_data": {
            "is_env_only": is_env,
            "has_dialogue": has_dialogue,
            "num_characters": num_characters,
            "camera_complexity": camera_complexity,
        },
    }


# ── score_emotion ────────────────────────────────────────────────────

class TestScoreEmotion:
    """Emotion keyword lookup with bigram dampening."""

    def test_empty_string_returns_default(self):
        assert score_emotion("") == DEFAULT_EMOTION_SCORE

    def test_none_returns_default(self):
        assert score_emotion(None) == DEFAULT_EMOTION_SCORE

    def test_known_single_word(self):
        assert score_emotion("terror") == 0.95

    def test_known_word_case_insensitive(self):
        assert score_emotion("TERROR") == 0.95
        assert score_emotion("Terror") == 0.95

    def test_unknown_word_returns_default(self):
        assert score_emotion("flibbertigibbet") == DEFAULT_EMOTION_SCORE

    def test_multi_word_takes_max(self):
        # fear=0.85, calm=0.15 → max is 0.85
        assert score_emotion("fear calm") == 0.85

    def test_commas_treated_as_spaces(self):
        assert score_emotion("fear, calm") == 0.85

    def test_exact_phrase_match(self):
        # Single word that matches the full text exactly
        assert score_emotion("calm") == 0.15

    def test_bigram_match(self):
        assert score_emotion("quiet determination") == EMOTION_BIGRAMS["quiet determination"]

    def test_bigram_dampening(self):
        """Bigram 'quiet determination' (0.55) must suppress unigram 'determination' (0.72)."""
        result = score_emotion("quiet determination")
        assert result == 0.55, (
            f"Bigram dampening failed: got {result}, expected 0.55. "
            f"'determination' unigram (0.72) should be suppressed by bigram."
        )

    def test_bigram_plus_unigram(self):
        """Bigram consumed words skip unigram, but other words still score."""
        # "cold fury" (0.93) + "panic" (0.88) → max is 0.93
        result = score_emotion("cold fury panic")
        assert result == 0.93

    def test_bigram_consumed_words_dont_double_score(self):
        """Words consumed by bigrams must not also score as unigrams."""
        # "cold fury" bigram = 0.93, "fury" unigram = 0.92
        # If dampening works, only bigram score counts for "cold" and "fury"
        result = score_emotion("cold fury")
        assert result == 0.93  # bigram, not unigram

    def test_valley_territory(self):
        for word in ("calm", "neutral", "stillness", "peace"):
            assert score_emotion(word) < TIER_BOUNDARIES[0], f"{word} should be valley territory"

    def test_rising_territory(self):
        for word in ("tension", "curiosity", "frustration"):
            score = score_emotion(word)
            assert TIER_BOUNDARIES[0] <= score < TIER_BOUNDARIES[1], (
                f"{word} ({score}) should be rising territory"
            )

    def test_peak_territory(self):
        for word in ("fear", "shock", "betrayal", "anger"):
            score = score_emotion(word)
            assert TIER_BOUNDARIES[1] <= score < TIER_BOUNDARIES[2], (
                f"{word} ({score}) should be peak territory"
            )

    def test_climax_territory(self):
        for word in ("terror", "rage", "horror", "fury"):
            score = score_emotion(word)
            assert score >= TIER_BOUNDARIES[2], (
                f"{word} ({score}) should be climax territory"
            )


# ── score_structure ──────────────────────────────────────────────────

class TestScoreStructure:
    """Structural position scoring within a scene."""

    def test_single_shot_scene(self):
        assert score_structure(0, 1) == 0.5

    def test_first_shot(self):
        assert score_structure(0, 5) == 0.0

    def test_last_shot(self):
        assert score_structure(4, 5) == 1.0

    def test_middle_shot(self):
        assert score_structure(2, 5) == pytest.approx(0.5)  # 2/4 = 0.5

    def test_monotonic_increase(self):
        """Scores should increase through the scene."""
        scores = [score_structure(i, 10) for i in range(10)]
        assert scores == sorted(scores)


# ── score_routing ────────────────────────────────────────────────────

class TestScoreRouting:
    """Routing complexity signals."""

    def test_empty_routing(self):
        assert score_routing({}) == 0.0

    def test_env_shot_always_zero(self):
        assert score_routing({"is_env_only": True, "num_characters": 3, "has_dialogue": True}) == 0.0

    def test_multi_character(self):
        assert score_routing({"num_characters": 2}) == 0.20

    def test_dialogue(self):
        assert score_routing({"has_dialogue": True}) == 0.15

    def test_camera_complexity(self):
        for style in ("handheld", "push_in", "crane"):
            assert score_routing({"camera_complexity": style}) == 0.15

    def test_all_signals_capped_at_050(self):
        result = score_routing({
            "num_characters": 3,
            "has_dialogue": True,
            "camera_complexity": "handheld",
        })
        assert result == 0.50


# ── compute_composite ───────────────────────────────────────────────

class TestComputeComposite:
    """Composite scoring with max() envelope."""

    def test_pure_emotion(self):
        # With zero structure and routing, composite = max(e, e * 1 + 0) = e
        assert compute_composite(0.80, 0.0, 0.0) == 0.80

    def test_structure_amplifies(self):
        # e=0.80, s=1.0, r=0.0 → amplified = 0.80 * 1.5 = 1.20
        result = compute_composite(0.80, 1.0, 0.0)
        assert result == pytest.approx(1.20)

    def test_routing_adds(self):
        # e=0.80, s=0.0, r=0.50 → amplified = 0.80 + 0.075 = 0.875
        result = compute_composite(0.80, 0.0, 0.50)
        assert result == pytest.approx(0.875)

    def test_max_envelope_protects_emotion(self):
        """High emotion should never be dragged below its raw score."""
        # Even with s=0.0 and r=0.0, composite >= e_score
        for e in (0.5, 0.7, 0.9, 1.0):
            assert compute_composite(e, 0.0, 0.0) >= e

    def test_zero_emotion_zero_composite(self):
        # e=0 → composite = max(0, 0 + r*0.15) = r*0.15
        result = compute_composite(0.0, 1.0, 0.50)
        assert result == pytest.approx(0.075)


# ── composite_to_tier ────────────────────────────────────────────────

class TestCompositeToTier:
    """Tier boundary mapping."""

    def test_valley(self):
        assert composite_to_tier(0.0) == 0
        assert composite_to_tier(0.29) == 0

    def test_valley_boundary(self):
        assert composite_to_tier(0.30) == 1  # exactly at boundary → Rising

    def test_rising(self):
        assert composite_to_tier(0.45) == 1
        assert composite_to_tier(0.64) == 1

    def test_peak_boundary(self):
        assert composite_to_tier(0.65) == 2  # exactly at boundary → Peak

    def test_peak(self):
        assert composite_to_tier(0.75) == 2
        assert composite_to_tier(0.89) == 2

    def test_climax_boundary(self):
        assert composite_to_tier(0.90) == 3  # exactly at boundary → Climax

    def test_climax(self):
        assert composite_to_tier(0.95) == 3
        assert composite_to_tier(1.5) == 3  # above 1.0 still climax


# ── detect_scene_boundaries ─────────────────────────────────────────

class TestDetectSceneBoundaries:
    """Scene grouping by location_id transitions."""

    def test_empty_shots(self):
        assert detect_scene_boundaries([]) == []

    def test_single_location(self):
        shots = [_shot("SH01", location="bridge"), _shot("SH02", location="bridge")]
        scenes = detect_scene_boundaries(shots)
        assert len(scenes) == 1
        assert len(scenes[0]) == 2

    def test_two_locations(self):
        shots = [
            _shot("SH01", location="bridge"),
            _shot("SH02", location="bridge"),
            _shot("SH03", location="corridor"),
            _shot("SH04", location="corridor"),
        ]
        scenes = detect_scene_boundaries(shots)
        assert len(scenes) == 2
        assert [s["shot_id"] for s in scenes[0]] == ["SH01", "SH02"]
        assert [s["shot_id"] for s in scenes[1]] == ["SH03", "SH04"]

    def test_empty_location_groups_with_previous(self):
        shots = [
            _shot("SH01", location="bridge"),
            _shot("SH02", location=""),  # empty loc stays with bridge
            _shot("SH03", location="bridge"),
        ]
        scenes = detect_scene_boundaries(shots)
        assert len(scenes) == 1


# ── apply_climax_cap ─────────────────────────────────────────────────

class TestClimaxCap:
    """Demote excess Tier 3 shots to Tier 2 within each scene."""

    def test_no_climax_no_change(self):
        shots = [_shot("SH01"), _shot("SH02")]
        tier_map = {"SH01": 1, "SH02": 2}
        score_map = {"SH01": 0.5, "SH02": 0.7}
        scenes = [shots]
        result = apply_climax_cap(tier_map, score_map, scenes)
        assert result == {"SH01": 1, "SH02": 2}

    def test_two_climax_within_cap(self):
        shots = [_shot("SH01"), _shot("SH02")]
        tier_map = {"SH01": 3, "SH02": 3}
        score_map = {"SH01": 0.95, "SH02": 0.92}
        scenes = [shots]
        result = apply_climax_cap(tier_map, score_map, scenes)
        assert result["SH01"] == 3
        assert result["SH02"] == 3

    def test_three_climax_demotes_weakest(self):
        shots = [_shot("SH01"), _shot("SH02"), _shot("SH03")]
        tier_map = {"SH01": 3, "SH02": 3, "SH03": 3}
        score_map = {"SH01": 0.95, "SH02": 0.91, "SH03": 0.93}
        scenes = [shots]
        result = apply_climax_cap(tier_map, score_map, scenes)
        # SH01 (0.95) and SH03 (0.93) survive, SH02 (0.91) demoted
        assert result["SH01"] == 3
        assert result["SH03"] == 3
        assert result["SH02"] == 2

    def test_separate_scenes_independent_caps(self):
        scene1 = [_shot("SH01", location="bridge"), _shot("SH02", location="bridge"),
                   _shot("SH03", location="bridge")]
        scene2 = [_shot("SH04", location="corridor"), _shot("SH05", location="corridor"),
                   _shot("SH06", location="corridor")]
        tier_map = {f"SH0{i}": 3 for i in range(1, 7)}
        score_map = {"SH01": 0.95, "SH02": 0.91, "SH03": 0.93,
                     "SH04": 0.96, "SH05": 0.90, "SH06": 0.94}
        result = apply_climax_cap(tier_map, score_map, [scene1, scene2])
        # Each scene keeps top 2, demotes 1
        assert result["SH02"] == 2  # weakest in scene 1
        assert result["SH05"] == 2  # weakest in scene 2
        assert sum(1 for v in result.values() if v == 3) == 4


# ── cluster_coverage_moments ─────────────────────────────────────────

class TestClusterCoverageMoments:
    """Moment clustering from consecutive Tier 2+ shots."""

    def test_no_peaks_no_moments(self):
        shots = [_shot("SH01", emotion="calm"), _shot("SH02", emotion="calm")]
        tier_map = {"SH01": 0, "SH02": 0}
        score_map = {"SH01": 0.1, "SH02": 0.1}
        scenes = [shots]
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert len(moments) == 0

    def test_single_peak_creates_moment(self):
        shots = [_shot("SH01", emotion="fear", characters=["jinx", "ava"])]
        tier_map = {"SH01": 2}
        score_map = {"SH01": 0.85}
        scenes = [shots]
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert len(moments) == 1
        assert moments[0].anchor_shot_id == "SH01"
        assert moments[0].tier == 2

    def test_consecutive_peaks_cluster(self):
        shots = [
            _shot("SH01", emotion="fear", characters=["jinx", "ava"]),
            _shot("SH02", emotion="anger", characters=["jinx", "ava"]),
        ]
        tier_map = {"SH01": 2, "SH02": 2}
        score_map = {"SH01": 0.85, "SH02": 0.82}
        scenes = [shots]
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert len(moments) == 1
        assert set(moments[0].shot_ids) == {"SH01", "SH02"}
        assert moments[0].anchor_shot_id == "SH01"  # highest score

    def test_valley_breaks_cluster(self):
        shots = [
            _shot("SH01", emotion="fear", characters=["jinx"]),
            _shot("SH02", emotion="calm"),
            _shot("SH03", emotion="rage", characters=["jinx"]),
        ]
        tier_map = {"SH01": 2, "SH02": 0, "SH03": 3}
        score_map = {"SH01": 0.85, "SH02": 0.1, "SH03": 0.95}
        scenes = [shots]
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert len(moments) == 2

    def test_location_change_breaks_cluster(self):
        shots = [
            _shot("SH01", emotion="fear", location="bridge", characters=["jinx"]),
            _shot("SH02", emotion="anger", location="corridor", characters=["jinx"]),
        ]
        tier_map = {"SH01": 2, "SH02": 2}
        score_map = {"SH01": 0.85, "SH02": 0.82}
        scenes = [shots]  # same scene for clustering purposes
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert len(moments) == 2

    def test_no_character_overlap_breaks_cluster(self):
        shots = [
            _shot("SH01", emotion="fear", characters=["jinx"]),
            _shot("SH02", emotion="anger", characters=["ava"]),
        ]
        tier_map = {"SH01": 2, "SH02": 2}
        score_map = {"SH01": 0.85, "SH02": 0.82}
        scenes = [shots]
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert len(moments) == 2

    def test_max_cluster_size_enforced(self):
        shots = [
            _shot(f"SH{i:02d}", emotion="fear", characters=["jinx"])
            for i in range(1, 7)  # 6 consecutive peaks
        ]
        tier_map = {s["shot_id"]: 2 for s in shots}
        score_map = {s["shot_id"]: 0.85 - i * 0.01 for i, s in enumerate(shots)}
        scenes = [shots]
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert len(moments) == 2  # split at MAX_CLUSTER_SIZE (4)
        assert len(moments[0].shot_ids) == MAX_CLUSTER_SIZE
        assert len(moments[1].shot_ids) == 2

    def test_coverage_types_multi_char_tier2(self):
        shots = [_shot("SH01", emotion="fear", characters=["jinx", "ava"])]
        tier_map = {"SH01": 2}
        score_map = {"SH01": 0.85}
        scenes = [shots]
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert moments[0].coverage_types == COVERAGE_CONFIG[2]["multi_char"]

    def test_coverage_types_solo_tier2(self):
        shots = [_shot("SH01", emotion="fear", characters=["jinx"])]
        tier_map = {"SH01": 2}
        score_map = {"SH01": 0.85}
        scenes = [shots]
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert moments[0].coverage_types == COVERAGE_CONFIG[2]["solo"]

    def test_coverage_types_multi_char_tier3(self):
        shots = [_shot("SH01", emotion="terror", characters=["jinx", "ava"])]
        tier_map = {"SH01": 3}
        score_map = {"SH01": 0.95}
        scenes = [shots]
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert moments[0].coverage_types == COVERAGE_CONFIG[3]["multi_char"]

    def test_coverage_types_solo_tier3(self):
        shots = [_shot("SH01", emotion="terror", characters=["jinx"])]
        tier_map = {"SH01": 3}
        score_map = {"SH01": 0.95}
        scenes = [shots]
        moments = cluster_coverage_moments(shots, tier_map, score_map, scenes)
        assert moments[0].coverage_types == COVERAGE_CONFIG[3]["solo"]


# ── analyze_coverage_density (integration) ───────────────────────────

class TestAnalyzeCoverageDensity:
    """End-to-end integration tests."""

    def test_empty_shots(self):
        tier_map, score_map, moments = analyze_coverage_density([])
        assert tier_map == {}
        assert score_map == {}
        assert moments == []

    def test_env_shots_always_valley(self):
        shots = [_shot("SH01", is_env=True, emotion="terror")]
        tier_map, score_map, moments = analyze_coverage_density(shots)
        assert tier_map["SH01"] == 0
        assert score_map["SH01"] == 0.0
        assert len(moments) == 0

    def test_manual_override(self):
        shots = [_shot("SH01", emotion="calm")]  # would be valley
        tier_map, _, _ = analyze_coverage_density(shots, overrides={"SH01": 3})
        assert tier_map["SH01"] == 3

    def test_override_out_of_range_ignored(self):
        shots = [_shot("SH01", emotion="calm")]
        tier_map, _, _ = analyze_coverage_density(shots, overrides={"SH01": 5})
        assert tier_map["SH01"] == 0  # original tier preserved

    def test_mixed_scene_produces_moments_only_for_peaks(self):
        shots = [
            _shot("SH01", emotion="calm"),                              # valley
            _shot("SH02", emotion="curiosity"),                         # rising
            _shot("SH03", emotion="guilt", characters=["jinx"]),        # peak (0.68 + structure)
            _shot("SH04", emotion="terror", characters=["jinx", "ava"]),  # climax
            _shot("SH05", emotion="calm"),                              # valley
        ]
        tier_map, score_map, moments = analyze_coverage_density(shots)
        # Calm shots stay low, dramatic shots go high
        assert tier_map["SH01"] == 0
        assert tier_map["SH02"] <= 1  # rising or valley
        assert tier_map["SH03"] >= 2  # peak or climax (structure amplifies)
        assert tier_map["SH04"] == 3  # terror is always climax
        assert tier_map["SH05"] == 0
        # Dramatic shots form moments, calm shots don't
        moment_shot_ids = {sid for m in moments for sid in m.shot_ids}
        assert "SH01" not in moment_shot_ids
        assert "SH05" not in moment_shot_ids
        assert "SH04" in moment_shot_ids
        assert len(moments) >= 1
