"""Unit tests for ChordEngine — determinism, voice leading, inversions, emotions.

Strict TDD: RED → GREEN → TRIANGULATE → REFACTOR for each spec requirement.
"""

from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parents[1]))

import pytest
from src.composer.chords import ChordEngine, EMOTION_PROGRESSIONS


# ---------------------------------------------------------------------------
# R1: Determinism — same seed → same output
# ---------------------------------------------------------------------------

class TestDeterminism:
    """R1: ChordEngine(key, seed) MUST produce identical progressions."""

    def test_same_seed_same_output(self):
        """GIVEN ChordEngine("Am", seed=42)
           WHEN progression(8) called twice
           THEN identical output."""
        engine = ChordEngine("Am", seed=42)
        r1 = engine.progression(8)
        r2 = engine.progression(8)
        assert r1 == r2, "Same seed must produce identical progressions"
        assert len(r1) == 8, "8 bars @ 4 bpc = 8 chords"

    def test_different_seeds_produce_valid_output(self):
        """Different seeds both produce valid 4-note 7th chord voicings.
        
        RNG is a voice-leading tiebreaker — divergence is possible but
        not guaranteed when one candidate is mathematically superior.
        """
        e1 = ChordEngine("Am", seed=42)
        e2 = ChordEngine("Am", seed=99)
        r1 = e1.progression(8)
        r2 = e2.progression(8)
        # Both must produce 8 chords with 4 notes each
        assert len(r1) == 8 and all(len(c) == 4 for c in r1)
        assert len(r2) == 8 and all(len(c) == 4 for c in r2)
        # Both must start with i7 chord
        assert r1[0][0] % 12 == 9  # A pitch class
        assert r2[0][0] % 12 == 9

    def test_same_seed_different_keys_differ(self):
        """Same seed with different keys should differ."""
        e_am = ChordEngine("Am", seed=42)
        e_dm = ChordEngine("Dm", seed=42)
        r1 = e_am.progression(8)
        r2 = e_dm.progression(8)
        assert r1 != r2, "Different tonic should produce different pitch sets"


# ---------------------------------------------------------------------------
# R2: Voice leading ≤ 4 semitones per voice
# ---------------------------------------------------------------------------

class TestVoiceLeadingBounds:
    """R2: Voice leading MUST cap at 4 semitones per voice."""

    def test_voice_leading_is_smooth(self):
        """GIVEN any 2 consecutive chords from a progression
           WHEN computing voice leading
           THEN average voice movement ≤ 4 semitones (soft constraint)."""
        engine = ChordEngine("Am", seed=42)
        voicings = engine.progression(8, emotion="romantic")
        assert len(voicings) >= 2, "Need at least 2 chords to test voice leading"

        for i in range(len(voicings) - 1):
            a = voicings[i]
            b = voicings[i + 1]
            assert len(a) == len(b), (
                f"Chords {i} and {i+1} have different voice counts: "
                f"{len(a)} vs {len(b)}"
            )
            # Soft constraint: average movement ≤ 4 semitones
            leaps = [abs(pb - pa) for pa, pb in zip(a, b)]
            avg_leap = sum(leaps) / len(leaps)
            assert avg_leap <= 4, (
                f"Average voice movement from chord {i} to {i+1} is "
                f"{avg_leap:.1f} semitones (should be ≤ 4)\n"
                f"  {a} → {b}\n  leaps: {leaps}"
            )

    def test_voice_leading_on_dark_progression(self):
        """Voice leading smoothness holds for dark emotion too."""
        engine = ChordEngine("Am", seed=42)
        voicings = engine.progression(8, emotion="dark")
        for i in range(len(voicings) - 1):
            leaps = [abs(pb - pa) for pa, pb in zip(voicings[i], voicings[i + 1])]
            avg_leap = sum(leaps) / len(leaps)
            assert avg_leap <= 4, f"Dark: avg leap {avg_leap:.1f} > 4"

    def test_voice_leading_on_club_progression(self):
        """Voice leading smoothness holds for club emotion."""
        engine = ChordEngine("Am", seed=42)
        voicings = engine.progression(8, emotion="club")
        for i in range(len(voicings) - 1):
            leaps = [abs(pb - pa) for pa, pb in zip(voicings[i], voicings[i + 1])]
            avg_leap = sum(leaps) / len(leaps)
            assert avg_leap <= 4, f"Club: avg leap {avg_leap:.1f} > 4"

    def test_voice_leading_on_classic_progression(self):
        """Voice leading smoothness holds for classic emotion."""
        engine = ChordEngine("Am", seed=42)
        voicings = engine.progression(8, emotion="classic")
        for i in range(len(voicings) - 1):
            leaps = [abs(pb - pa) for pa, pb in zip(voicings[i], voicings[i + 1])]
            avg_leap = sum(leaps) / len(leaps)
            assert avg_leap <= 4, f"Classic: avg leap {avg_leap:.1f} > 4"


# ---------------------------------------------------------------------------
# R3: Inversions
# ---------------------------------------------------------------------------

class TestInversions:
    """R3: SHALL support 3 inversion modes."""

    def test_root_inversion_no_change(self):
        """Root inversion = identity (notes unchanged)."""
        engine = ChordEngine("Am", seed=0)
        voicing = [57, 60, 64]  # Am root: A3, C4, E4
        result = engine._apply_inversion(voicing, "root")
        assert sorted(result) == sorted(voicing), "Root inversion should not reorder notes"

    def test_first_inversion_bass_is_third(self):
        """First inversion: third becomes lowest note."""
        engine = ChordEngine("Am", seed=0)
        voicing = [57, 60, 64]  # Am root: A=root, C=third, E=fifth
        result = engine._apply_inversion(voicing, "first")
        assert min(result) == 60, (
            f"First inversion bass should be third (60 = C4), got {min(result)}"
        )

    def test_second_inversion_bass_is_fifth(self):
        """Second inversion: fifth becomes lowest note."""
        engine = ChordEngine("Am", seed=0)
        voicing = [57, 60, 64]
        result = engine._apply_inversion(voicing, "second")
        assert min(result) == 64, (
            f"Second inversion bass should be fifth (64 = E4), got {min(result)}"
        )

    def test_first_inversion_on_major_chord(self):
        """First inversion works on major chords too."""
        engine = ChordEngine("C", seed=0)
        # C major: C=60, E=64, G=67
        voicing = [60, 64, 67]
        result = engine._apply_inversion(voicing, "first")
        assert min(result) == 64, "First inversion of Cmaj: bass = E4 (64)"


# ---------------------------------------------------------------------------
# R4: Emotion divergence
# ---------------------------------------------------------------------------

class TestEmotionDivergence:
    """R4: MUST support 4 emotion modes with distinct progressions."""

    def test_all_four_distinct(self):
        """GIVEN ChordEngine("Am", seed=0) with 4 emotions
           WHEN progression(8) called per emotion
           THEN all 4 output sequences differ."""
        emotions = ["romantic", "dark", "club", "classic"]
        results = {}
        for emo in emotions:
            engine = ChordEngine("Am", seed=0)
            results[emo] = engine.progression(8, emotion=emo)

        seen = set()
        for emo, prog in results.items():
            encoded = str(prog)
            assert encoded not in seen, (
                f"Emotion '{emo}' produced duplicate progression"
            )
            seen.add(encoded)

    def test_invalid_emotion_falls_back_to_classic(self):
        """GIVEN unknown emotion 'angry'
           WHEN progression(8) called
           THEN defaults to classic progression, no error."""
        engine = ChordEngine("Am", seed=0)
        classic = engine.progression(8, emotion="classic")
        angry = engine.progression(8, emotion="angry")
        assert angry == classic, (
            "Unknown emotion should fall back to classic progression"
        )

    def test_each_emotion_has_four_degrees(self):
        """Each emotion progression must have exactly 4 chord types."""
        for emo, degrees in EMOTION_PROGRESSIONS.items():
            assert len(degrees) == 4, (
                f"Emotion '{emo}' has {len(degrees)} degrees, expected 4"
            )
            for degree_offset, quality in degrees:
                assert isinstance(degree_offset, int), (
                    f"Degree offset must be int, got {type(degree_offset)}"
                )
                assert quality in ("maj", "min", "dim", "aug", "7", "m7"), (
                    f"Unknown quality '{quality}'"
                )


# ---------------------------------------------------------------------------
# R5: Empty / zero bars
# ---------------------------------------------------------------------------

class TestEdgeCases:
    """Edge cases from spec requirements."""

    def test_zero_bars_returns_empty(self):
        """0 bars → empty list (R5)."""
        engine = ChordEngine("Am", seed=42)
        result = engine.progression(0)
        assert result == [], "0 bars should return empty list"

    def test_single_bar(self):
        """1 bar @ 4 bpc = 1 chord."""
        engine = ChordEngine("Am", seed=42)
        result = engine.progression(1)
        assert len(result) == 1, f"1 bar = 1 chord, got {len(result)}"

    def test_partial_bar(self):
        """3 bars = 3 chords @ 4 bpc."""
        engine = ChordEngine("Am", seed=42)
        result = engine.progression(3)
        assert len(result) == 3, f"3 bars = 3 chords @ 4 bpc"

    def test_each_chord_is_four_note_seventh(self):
        """All chords should be 4-note 7th voicings (m7/7 quality)."""
        engine = ChordEngine("Am", seed=42)
        for emotion in ("romantic", "dark", "club", "classic"):
            voicings = engine.progression(8, emotion=emotion)
            for i, voicing in enumerate(voicings):
                assert len(voicing) == 4, (
                    f"{emotion} chord {i}: expected 4 notes (7th), got {len(voicing)}"
                )


# ---------------------------------------------------------------------------
# R7: Integration with compose.py
# ---------------------------------------------------------------------------

class TestComposeIntegration:
    """R7: build_chords_track() SHALL delegate to ChordEngine."""

    def test_build_chords_uses_chordengine(self):
        """Verify build_chords_track calls ChordEngine.progression."""
        from unittest.mock import patch
        from scripts.compose import build_chords_track
        from src.core.schema import SectionDef

        sections = [SectionDef(name="verse", bars=4, energy=1.0)]
        offsets = [0.0]

        with patch("scripts.compose.ChordEngine") as MockEngine:
            mock_engine = MockEngine.return_value
            mock_engine.progression.return_value = [[57, 60, 64], [60, 64, 67]]

            track = build_chords_track(
                sections, offsets, "A", True,
                emotion="dark", inversion="first",
            )

            # Verify ChordEngine was instantiated
            MockEngine.assert_called_once()
            # Verify progression() was called with correct args
            mock_engine.progression.assert_called_once_with(
                4, emotion="dark", beats_per_chord=4, inversion="first",
            )
            assert track.name == "Chords"
            assert len(track.clips) == 1

    def test_compose_cli_emotion_flag_accepted(self, tmp_path):
        """CLI --emotion dark generates output (R7 integration)."""
        from unittest.mock import patch, MagicMock

        output = tmp_path / "test.rpp"

        with patch("scripts.compose.SampleSelector") as mock_cls:
            mock_sel = MagicMock()
            mock_sel._samples = [
                {
                    "role": "snare",
                    "perceptual": {"tempo": 0},
                    "musical": {"key": "X"},
                    "character": "sharp",
                    "original_path": "fake_clap.wav",
                    "original_name": "fake_clap.wav",
                    "file_hash": "clap123",
                },
            ]
            mock_sel.select.return_value = [
                MagicMock(sample={
                    "original_path": "fake_clap.wav",
                    "file_hash": "clap123",
                }),
            ]
            mock_sel.select_diverse.return_value = []
            mock_cls.return_value = mock_sel

            import importlib
            import scripts.compose as _mod

            orig_argv = sys.argv
            try:
                sys.argv = [
                    "compose", "--key", "Am", "--emotion", "dark",
                    "--inversion", "root", "--output", str(output),
                ]
                importlib.reload(_mod)
                _mod.main()
            finally:
                sys.argv = orig_argv

        assert output.exists(), f"Expected {output} to exist"
