"""test_harmonic_engine.py - Tests for ARC 2: Harmonic Engine (T021-T040)

Comprehensive test suite for harmonic analysis, Camelot wheel,
pitch shifting, groove extraction, and DJ-style mixing features.
"""

import unittest
import sys
import os
from typing import List, Dict, Optional

# Add server path for imports
sys.path.insert(0, os.path.join(
    os.path.dirname(__file__), '..'
))

from harmonic_engine import (
    CamelotWheel,
    KeyDetector,
    KeyRouter,
    EnergyLevelIndex,
    WarpStrategy,
    WarpMode,
    PitchShifter,
    RhythmConsistencyChecker,
    SyncEngine,
    GrooveExtractor,
    GrooveApplicator,
    GrooveTemplate,
    PhraseMatcher,
    KeyLockController,
    KeyLockMode,
    CamelotDisplay,
    ClashAutoFixer,
    HarmonicMixIntegrationTest,
    HarmonicEngine,
    # Public API functions
    get_camelot_code,
    get_compatible_keys,
    calculate_key_distance,
    is_harmonic_compatible,
    calculate_pitch_shift,
    get_warp_settings,
    auto_detect_content_type,
    extract_groove_from_notes,
    apply_groove_to_notes,
    generate_swing_groove,
    analyze_harmonic_compatibility,
    find_best_harmonic_path,
    run_arc2_integration_test,
)


class TestT021_CamelotWheel(unittest.TestCase):
    """T021: Camelot Wheel Integration tests."""

    def test_key_to_camelot_conversion(self):
        """Test key to Camelot code conversion."""
        # Standard Camelot wheel mappings
        self.assertEqual(CamelotWheel.get_camelot_code("Am"), "8A")
        self.assertEqual(CamelotWheel.get_camelot_code("C"), "8B")
        self.assertEqual(CamelotWheel.get_camelot_code("F#m"), "11A")
        self.assertEqual(CamelotWheel.get_camelot_code("G"), "9B")

    def test_camelot_to_key_conversion(self):
        """Test Camelot code to key conversion."""
        self.assertEqual(CamelotWheel.get_key_from_camelot("8A"), "Am")
        self.assertEqual(CamelotWheel.get_key_from_camelot("8B"), "C")
        self.assertEqual(CamelotWheel.get_key_from_camelot("11A"), "F#m")
        self.assertEqual(CamelotWheel.get_key_from_camelot("1A"), "G#m")

    def test_key_aliases(self):
        """Test alternative key spellings."""
        # Dbm should map to C#m
        code = CamelotWheel.get_camelot_code("Dbm")
        self.assertIsNotNone(code)
        self.assertEqual(code, "12A")  # C#m = 12A
        # Bbm should work
        code2 = CamelotWheel.get_camelot_code("Bbm")
        self.assertEqual(code2, "3A")  # Bbm/A#m = 3A

    def test_compatible_keys(self):
        """Test compatible key generation."""
        compatible = CamelotWheel.get_compatible_keys("Am")
        # Am (8A) should be compatible with Em (9A) and Dm (7A)
        self.assertIn("9A", compatible)   # +1
        self.assertIn("7A", compatible)   # -1
        self.assertIn("8B", compatible)   # Relative major

    def test_key_compatibility_check(self):
        """Test direct compatibility check."""
        # Am and Em should be compatible (+1 Camelot)
        self.assertTrue(CamelotWheel.is_compatible("Am", "Em"))
        # Am and C should be compatible (relative major/minor)
        self.assertTrue(CamelotWheel.is_compatible("Am", "C"))
        # Am and D#m should not be compatible
        self.assertFalse(CamelotWheel.is_compatible("Am", "D#m"))

    def test_key_distance_calculation(self):
        """Test harmonic distance calculation."""
        # Same key
        self.assertEqual(CamelotWheel.calculate_distance("Am", "Am"), 0)
        # Adjacent on wheel
        self.assertEqual(CamelotWheel.calculate_distance("Am", "Em"), 1)
        # Relative major/minor
        self.assertEqual(CamelotWheel.calculate_distance("Am", "C"), 1)


class TestT022_KeyDetection(unittest.TestCase):
    """T022: Key Detection Fallback tests."""

    def test_key_from_filename_extraction(self):
        """Test key extraction from filename patterns."""
        detector = KeyDetector()

        test_cases = [
            ("Loop_Am_128bpm.wav", "Am"),
            ("Bassline_F#m_130.wav", "F#m"),
            ("Drums-inGm-125bpm.aif", "Gm"),
            ("Synth_key_C_major.wav", "C"),
        ]

        for filename, expected in test_cases:
            result = detector.estimate_key_from_filename(filename)
            if expected:
                self.assertEqual(result, expected, f"Failed for {filename}")

    def test_spectral_key_detection(self):
        """Test key detection from spectral features."""
        detector = KeyDetector()

        # Test with typical bass range centroid
        key = detector.detect_key_from_spectral_features(350, None)
        self.assertIsNotNone(key)


class TestT023_KeyRouter(unittest.TestCase):
    """T023: Allowed Key Routing tests."""

    def test_track_registration(self):
        """Test track key registration."""
        router = KeyRouter()

        # Register first track
        result1 = router.register_track_key("track_1", "Am")
        self.assertTrue(result1)

        # Register compatible track
        result2 = router.register_track_key("track_2", "Em")
        self.assertTrue(result2)

        # Both tracks should be registered
        self.assertEqual(router.track_keys["track_1"], "Am")
        self.assertEqual(router.track_keys["track_2"], "Em")

    def test_conflict_detection(self):
        """Test detection of key conflicts."""
        router = KeyRouter()

        router.register_track_key("track_1", "Am")
        # D#m is far from Am on the wheel
        result = router.register_track_key("track_2", "D#m")
        self.assertFalse(result)  # Should detect conflict

    def test_harmonic_recommendations(self):
        """Test harmonic mix recommendations."""
        router = KeyRouter()

        router.register_track_key("intro", "Am")
        router.register_track_key("build", "Am")
        router.register_track_key("drop", "Em")

        recommendations = router.get_harmonic_mix_recommendations()
        self.assertGreater(len(recommendations), 0)

        # Check that intro->build is marked compatible
        intro_to_build = [r for r in recommendations
                         if r["from_track"] == "intro" and r["to_track"] == "build"]
        if intro_to_build:
            self.assertTrue(intro_to_build[0]["compatible"])


class TestT024_EnergyIndex(unittest.TestCase):
    """T024: Energy Level Indexing tests."""

    def test_energy_level_setting(self):
        """Test energy level assignment."""
        energy = EnergyLevelIndex()

        energy.set_track_energy("track_1", 8)
        self.assertEqual(energy.get_track_energy("track_1"), 8)

        # Test clamping
        energy.set_track_energy("track_2", 15)  # Should clamp to 10
        self.assertEqual(energy.get_track_energy("track_2"), 10)

    def test_energy_estimation(self):
        """Test energy estimation from features."""
        energy = EnergyLevelIndex()

        # High energy features
        high = energy.estimate_energy_from_features(
            spectral_centroid=4000,
            rms_energy=-6,
            transients_per_bar=20
        )
        self.assertGreaterEqual(high, 7)

        # Low energy features
        low = energy.estimate_energy_from_features(
            spectral_centroid=200,
            rms_energy=-40,
            transients_per_bar=2
        )
        self.assertLessEqual(low, 4)

    def test_weaker_track_selection(self):
        """Test selection of weaker track."""
        energy = EnergyLevelIndex()

        energy.set_track_energy("track_a", 8)
        energy.set_track_energy("track_b", 3)

        weaker = energy.get_weaker_track("track_a", "track_b")
        self.assertEqual(weaker, "track_b")


class TestT025_T026_WarpStrategy(unittest.TestCase):
    """T025-T026: Clip Warping and Strategy tests."""

    def test_warp_mode_selection(self):
        """Test warp mode selection by content type."""
        # Vocals should use Complex Pro
        vocal_settings = WarpStrategy.get_warp_settings("vocals")
        self.assertIsNotNone(vocal_settings)
        self.assertEqual(vocal_settings["mode"], WarpMode.COMPLEX_PRO)

        # Drums should use Beats
        drum_settings = WarpStrategy.get_warp_settings("drums")
        self.assertIsNotNone(drum_settings)
        self.assertEqual(drum_settings["mode"], WarpMode.BEATS)

    def test_content_type_detection(self):
        """Test automatic content type detection."""
        # High transients + high centroid = drums
        content = WarpStrategy.auto_detect_content_type(
            spectral_centroid=3000,
            transient_density=10,
            harmonic_ratio=0.3
        )
        self.assertIn(content, ["drums", "percussion"])

        # Low centroid + high harmonics = bass
        content = WarpStrategy.auto_detect_content_type(
            spectral_centroid=200,
            transient_density=4,
            harmonic_ratio=0.8
        )
        self.assertEqual(content, "bass")

    def test_warp_api_command_generation(self):
        """Test warp API command generation."""
        command = WarpStrategy.generate_warp_api_command(
            clip_id="clip_001",
            content_type="drums",
            bpm=128
        )

        self.assertEqual(command["clip_id"], "clip_001")
        self.assertTrue(command["warp_enabled"])
        self.assertEqual(command["bpm"], 128)


class TestT027_T028_PitchShifting(unittest.TestCase):
    """T027-T028: Pitch Shifting and Harmonic Mixing tests."""

    def test_semitone_shift_calculation(self):
        """Test semitone shift calculation."""
        # Am (57) to Dm (50) = -7 semitones, normalized to +5
        # But with 2 semitone limit, this returns None without allow_modulation
        shift = PitchShifter.calculate_shift("Am", "Dm", allow_modulation=True)
        self.assertIsNotNone(shift)
        
        # Close keys within 2 semitones
        # C (48) to D (50) = +2 semitones
        shift = PitchShifter.calculate_shift("C", "D")
        self.assertEqual(shift, 2)

    def test_harmonic_shift_options(self):
        """Test harmonic shift options generation."""
        options = PitchShifter.get_harmonic_shift_options("Am")
        self.assertGreater(len(options), 0)

        # All options should have semitones within range
        for opt in options:
            self.assertLessEqual(abs(opt["semitone_shift"]), 7)

    def test_modulation_path_finding(self):
        """Test modulation path finding."""
        # Distant keys should require bridge
        path = PitchShifter.find_best_modulation_path("Am", "F#m")
        self.assertGreater(len(path), 0)

    def test_public_api_functions(self):
        """Test public API functions."""
        self.assertEqual(get_camelot_code("Am"), "8A")
        self.assertTrue(len(get_compatible_keys("Am")) > 0)
        self.assertEqual(calculate_key_distance("Am", "Am"), 0)
        self.assertTrue(is_harmonic_compatible("Am", "Em"))
        # Test with nearby keys that have shift within 2 semitones
        shift = calculate_pitch_shift("C", "D")  # +2 semitones
        self.assertEqual(shift, 2)


class TestT029_RhythmConsistency(unittest.TestCase):
    """T029: Rhythm Consistency Check tests."""

    def test_bpm_stability_check(self):
        """Test BPM stability validation."""
        checker = RhythmConsistencyChecker()

        # Simulate stable BPM readings
        checker.bpm_history = [128.0, 128.1, 127.9, 128.0]

        result = checker.check_bpm_stability(128.0, tolerance_percent=1.0)
        self.assertTrue(result["is_stable"])

    def test_bpm_instability_detection(self):
        """Test detection of unstable BPM."""
        checker = RhythmConsistencyChecker()

        # Simulate unstable BPM readings
        checker.bpm_history = [128.0, 135.0, 120.0, 140.0]

        result = checker.check_bpm_stability(128.0, tolerance_percent=1.0)
        self.assertFalse(result["is_stable"])
        self.assertGreater(len(result["deviations"]), 0)


class TestT030_T031_SyncEngine(unittest.TestCase):
    """T030-T031: Double Drop and Sync Engine tests."""

    def test_bpm_lock(self):
        """Test BPM locking."""
        sync = SyncEngine()
        sync.set_master_bpm(130.0)

        result = sync.lock_track_bpm("track_1", source_bpm=125.0)
        self.assertEqual(result["master_bpm"], 130.0)
        self.assertTrue(result["warp_required"])
        self.assertIn("track_1", sync.locked_tracks)

    def test_bpm_nudge(self):
        """Test BPM nudge functionality."""
        sync = SyncEngine()
        sync.set_master_bpm(128.0)

        new_bpm = sync.nudge_bpm("up", amount=0.5)
        self.assertEqual(new_bpm, 128.5)

    def test_api_command_generation(self):
        """Test sync API command generation."""
        sync = SyncEngine()
        sync.set_master_bpm(128.0)

        command = sync.generate_bpm_lock_command("track_1")
        self.assertEqual(command["action"], "set_tempo")
        self.assertEqual(command["bpm"], 128.0)


class TestT032_T033_Groove(unittest.TestCase):
    """T032-T033: Groove Extraction and Application tests."""

    def test_groove_extraction(self):
        """Test groove extraction from MIDI notes."""
        extractor = GrooveExtractor()

        # Notes with intentional timing variations (swing feel)
        notes = [
            {"start_beat": 0.0, "velocity": 100},
            {"start_beat": 1.02, "velocity": 90},  # Late (swing)
            {"start_beat": 2.0, "velocity": 100},
            {"start_beat": 3.03, "velocity": 85},  # Late (swing)
        ]

        groove = extractor.extract_from_midi_notes(notes, 125, "test")
        self.assertIsNotNone(groove)
        self.assertEqual(len(groove.timing_offsets), 4)

    def test_groove_application(self):
        """Test groove application to notes."""
        applicator = GrooveApplicator()

        # Create a test groove template
        groove = GrooveTemplate(
            name="test",
            base_bpm=120,
            timing_offsets=[0, 10, 0, 10],  # 10ms swing on off-beats
            velocity_pattern=[100, 90, 100, 90],
            quantization=16,
            intensity=0.5
        )

        notes = [
            {"start_beat": 0.0, "velocity": 100},
            {"start_beat": 1.0, "velocity": 100},
            {"start_beat": 2.0, "velocity": 100},
            {"start_beat": 3.0, "velocity": 100},
        ]

        modified = applicator.apply_groove(notes, groove, intensity_scale=1.0)

        # Off-beats should have moved
        self.assertNotEqual(modified[1]["start_beat"], 1.0)
        self.assertNotEqual(modified[3]["start_beat"], 3.0)

    def test_swing_groove_generation(self):
        """Test swing groove template generation."""
        applicator = GrooveApplicator()

        swing_50 = applicator.generate_swing_groove(120, 50)
        self.assertEqual(swing_50.intensity, 0.5)

        swing_66 = applicator.generate_swing_groove(120, 66)
        self.assertEqual(swing_66.intensity, 0.66)

    def test_public_api(self):
        """Test public groove API."""
        notes = [
            {"start_beat": 0.0, "velocity": 100},
            {"start_beat": 1.0, "velocity": 90},
        ]

        groove = extract_groove_from_notes(notes, 120, "api_test")
        self.assertIsNotNone(groove)

        swing = generate_swing_groove(128, 60)
        self.assertEqual(swing.name, "swing_60pct")


class TestT034_T036_PhraseMatcher(unittest.TestCase):
    """T034-T036: Phrase Matching and Structure tests."""

    def test_phrase_boundary_calculation(self):
        """Test phrase boundary calculation."""
        matcher = PhraseMatcher()

        boundaries = matcher.find_phrase_boundaries(256, phrase_length_bars=16)
        # 256 beats / 16 bars = 4 phrase boundaries (0, 64, 128, 192)
        self.assertIn(0.0, boundaries)
        self.assertIn(64.0, boundaries)
        self.assertIn(128.0, boundaries)

    def test_overlay_calculation(self):
        """Test intro/outro overlay calculation."""
        matcher = PhraseMatcher()

        overlays = matcher.calculate_overlay_points(128, 128, phrase_bars=16)
        self.assertEqual(len(overlays), 1)
        self.assertEqual(overlays[0]["type"], "outro_intro_overlay")
        self.assertEqual(overlays[0]["duration_beats"], 64)  # 16 bars * 4 beats

    def test_modulation_bridge_planning(self):
        """Test modulation bridge planning."""
        matcher = PhraseMatcher()

        bridge = matcher.plan_modulation_transition("Am", "Em", 64, 4)
        self.assertEqual(bridge["duration_bars"], 4)
        self.assertEqual(bridge["from_key"], "Am")
        self.assertEqual(bridge["to_key"], "Em")

    def test_double_drop_alignment(self):
        """Test double drop alignment calculation."""
        matcher = PhraseMatcher()

        # Two drops 128 beats apart
        alignment = matcher.align_double_drop(0, 128, phrase_bars=32)
        self.assertEqual(alignment["offset_beats"], -128)


class TestT037_KeyLock(unittest.TestCase):
    """T037: Key-Lock Toggle tests."""

    def test_key_lock_mode_setting(self):
        """Test key lock mode setting."""
        controller = KeyLockController()

        controller.set_mode(KeyLockMode.PITCH_LOCK, current_pitch=60, current_tempo=128)
        self.assertEqual(controller.mode, KeyLockMode.PITCH_LOCK)
        self.assertEqual(controller.original_pitch, 60)
        self.assertEqual(controller.original_tempo, 128)

    def test_pitch_lock_bpm_change(self):
        """Test pitch lock during BPM change."""
        controller = KeyLockController()
        controller.set_mode(KeyLockMode.PITCH_LOCK, current_pitch=60, current_tempo=128)

        result = controller.apply_bpm_change(140, WarpMode.BEATS)
        self.assertEqual(result["warp_mode"], WarpMode.COMPLEX_PRO)
        self.assertTrue(result["formant_preserve"])


class TestT038_Display(unittest.TestCase):
    """T038: Camelot Wheel Display tests."""

    def test_wheel_ascii_generation(self):
        """Test ASCII wheel generation."""
        display = CamelotDisplay()

        wheel = display.format_wheel_ascii("Am", CamelotWheel.get_compatible_keys("Am"))
        self.assertIn("CAMELOT WHEEL", wheel)
        self.assertIn("Am", wheel)

    def test_transition_logging(self):
        """Test that transition logging doesn't crash."""
        display = CamelotDisplay()

        # Should not raise
        display.log_transition_analysis("Am", "Em", 0.95)


class TestT039_ClashFixer(unittest.TestCase):
    """T039: Auto-Fix Clashing Baselines tests."""

    def test_clash_detection_and_fix(self):
        """Test automatic clash detection and fix."""
        energy = EnergyLevelIndex()
        energy.set_track_energy("track_a", 8)
        energy.set_track_energy("track_b", 3)

        fixer = ClashAutoFixer(energy)

        # Distant keys should clash
        fix = fixer.detect_and_fix_clash(
            "track_a", "Am",
            "track_b", "D#m",
            (0, 64)
        )

        self.assertIsNotNone(fix)
        self.assertEqual(fix["type"], "auto_mute")
        # Weaker track should be muted
        self.assertEqual(fix["track_muted"], "track_b")


class TestT040_Integration(unittest.TestCase):
    """T040: Integration Test - 5-track harmonic mini-mix."""

    def test_integration_test_runs(self):
        """Test that integration test completes without errors."""
        test = HarmonicMixIntegrationTest()

        results = test.run_5track_mini_mix_test()

        self.assertIn("tracks_analyzed", results)
        self.assertIn("transitions_planned", results)
        self.assertIn("clash_fixes", results)

        self.assertEqual(len(results["tracks_analyzed"]), 5)

    def test_harmonic_engine_integration(self):
        """Test main HarmonicEngine class."""
        engine = HarmonicEngine()

        # Test track analysis
        result = engine.analyze_track(
            track_id="test_track",
            audio_features={
                "spectral_centroid": 2000,
                "rms_energy": -12,
                "transients_per_bar": 12
            },
            filename="Loop_Am_128.wav",
            declared_key="Am",
            declared_bpm=128
        )

        self.assertEqual(result["track_id"], "test_track")
        self.assertEqual(result["detected_key"], "Am")
        self.assertEqual(result["camelot_code"], "8A")  # Am = 8A in standard Camelot
        self.assertEqual(result["detected_bpm"], 128)

    def test_harmonic_mix_planning(self):
        """Test harmonic mix planning."""
        engine = HarmonicEngine()

        # Register tracks
        engine.analyze_track(
            track_id="source",
            declared_key="Am",
            declared_bpm=128
        )
        engine.analyze_track(
            track_id="target",
            declared_key="Em",
            declared_bpm=128
        )

        # Plan mix
        plan = engine.plan_harmonic_mix("source", "target", "blend")

        self.assertIn("source_key", plan)
        self.assertIn("target_key", plan)
        self.assertIn("compatible", plan)

    def test_public_api_compatibility_analysis(self):
        """Test public API for compatibility analysis."""
        tracks = [
            {"id": "t1", "key": "Am"},
            {"id": "t2", "key": "Em"},
            {"id": "t3", "key": "Dm"},
        ]

        result = analyze_harmonic_compatibility(tracks)
        self.assertEqual(result["tracks_registered"], 3)
        self.assertGreater(len(result["recommendations"]), 0)

    def test_harmonic_path_optimization(self):
        """Test finding optimal playback order."""
        keys = ["Am", "Em", "Bm", "F#m"]  # Closely related keys on the wheel

        path = find_best_harmonic_path(keys)
        self.assertEqual(len(path), len(keys))

        # Adjacent keys in path should be reasonably close on the wheel
        for i in range(len(path) - 1):
            if "distance_from_prev" in path[i + 1]:
                # Allow up to 4 for non-adjacent but compatible keys
                self.assertLessEqual(path[i + 1]["distance_from_prev"], 4)


class TestPublicAPI(unittest.TestCase):
    """Tests for public API functions."""

    def test_all_public_functions_exist(self):
        """Test that all public API functions are available."""
        functions = [
            get_camelot_code,
            get_compatible_keys,
            calculate_key_distance,
            is_harmonic_compatible,
            calculate_pitch_shift,
            get_warp_settings,
            auto_detect_content_type,
            extract_groove_from_notes,
            apply_groove_to_notes,
            generate_swing_groove,
            analyze_harmonic_compatibility,
            find_best_harmonic_path,
            run_arc2_integration_test,
        ]

        for func in functions:
            self.assertTrue(callable(func), f"{func.__name__} should be callable")

    def test_warp_settings_api(self):
        """Test warp settings API."""
        settings = get_warp_settings("drums")
        self.assertIsNotNone(settings)
        self.assertIn("mode", settings)

    def test_integration_test_api(self):
        """Test integration test through public API."""
        # This may be slow, so just verify it runs
        import logging
        logging.disable(logging.INFO)  # Suppress output

        try:
            result = run_arc2_integration_test()
            self.assertIn("tracks_analyzed", result)
        except Exception as e:
            self.fail(f"Integration test failed: {e}")
        finally:
            logging.disable(logging.NOTSET)  # Re-enable


def create_test_suite():
    """Create a comprehensive test suite."""
    loader = unittest.TestLoader()
    suite = unittest.TestSuite()

    # Add all test classes
    test_classes = [
        TestT021_CamelotWheel,
        TestT022_KeyDetection,
        TestT023_KeyRouter,
        TestT024_EnergyIndex,
        TestT025_T026_WarpStrategy,
        TestT027_T028_PitchShifting,
        TestT029_RhythmConsistency,
        TestT030_T031_SyncEngine,
        TestT032_T033_Groove,
        TestT034_T036_PhraseMatcher,
        TestT037_KeyLock,
        TestT038_Display,
        TestT039_ClashFixer,
        TestT040_Integration,
        TestPublicAPI,
    ]

    for test_class in test_classes:
        tests = loader.loadTestsFromTestCase(test_class)
        suite.addTests(tests)

    return suite


if __name__ == "__main__":
    # Run with verbose output
    runner = unittest.TextTestRunner(verbosity=2)
    suite = create_test_suite()
    result = runner.run(suite)

    # Exit with appropriate code
    sys.exit(0 if result.wasSuccessful() else 1)
