"""
test_dsp_numpy.py - Unit tests for DSP functions

Verifies that audio arrays maintain correct shapes after processing
and that DSP operations produce expected results.
"""

import sys
import os

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

import numpy as np
import pytest

# Import after path setup
from engine.processor import AudioEngine
from engine.presets import GENRE_PRESETS


class TestAudioEngineBasics:
    """Test basic AudioEngine functionality."""
    
    def setup_method(self):
        """Set up test fixtures."""
        self.engine = AudioEngine(temp_dir="temp")
        self.engine.sample_rate = 44100
        
        # Create test audio (1 second stereo sine wave)
        duration = 1.0
        t = np.linspace(0, duration, int(self.engine.sample_rate * duration))
        left = np.sin(2 * np.pi * 440 * t).astype(np.float32)  # A440
        right = np.sin(2 * np.pi * 880 * t).astype(np.float32)  # A880
        self.stereo_audio = np.column_stack((left, right))
        self.mono_audio = left
    
    def test_stereo_shape_preserved(self):
        """Verify stereo audio shape is preserved after processing."""
        original_shape = self.stereo_audio.shape
        
        # Test mono bass
        processed = self.engine.make_bass_mono(self.stereo_audio, cutoff_freq=120)
        assert processed.shape == original_shape, "Mono bass changed array shape"
        
        # Test spectral exciter
        processed = self.engine.apply_spectral_exciter(self.stereo_audio)
        assert processed.shape == original_shape, "Exciter changed array shape"
        
        # Test transient shaper
        processed = self.engine.apply_transient_shaper(self.stereo_audio)
        assert processed.shape == original_shape, "Transient shaper changed array shape"
    
    def test_mono_to_stereo_preservation(self):
        """Verify mono audio can be processed."""
        original_length = len(self.mono_audio)
        
        # Test transient shaper on mono
        processed = self.engine.apply_transient_shaper(self.mono_audio)
        assert len(processed) == original_length, "Transient shaper changed mono length"
        
        # Test spectral exciter on mono
        processed = self.engine.apply_spectral_exciter(self.mono_audio)
        assert len(processed) == original_length, "Exciter changed mono length"


class TestMonoBassCorrection:
    """Test mono bass correction (M/S processing)."""
    
    def setup_method(self):
        """Set up test fixtures."""
        self.engine = AudioEngine(temp_dir="temp")
        self.engine.sample_rate = 44100
    
    def test_low_freq_becomes_mono(self):
        """Low frequencies should become more mono after processing."""
        # Create stereo low frequency content (different L/R)
        duration = 0.1
        sr = self.engine.sample_rate
        t = np.linspace(0, duration, int(sr * duration))
        
        # 50Hz sine wave, 180 degrees out of phase between channels
        left = np.sin(2 * np.pi * 50 * t).astype(np.float32)
        right = -left  # Opposite phase = all side, no mid
        
        stereo_input = np.column_stack((left, right))
        
        # Apply mono bass with high cutoff
        processed = self.engine.make_bass_mono(stereo_input, cutoff_freq=200)
        
        # After processing, the correlation between channels should be higher
        # (more mono = channels more similar)
        original_diff = np.abs(stereo_input[:, 0] - stereo_input[:, 1]).mean()
        processed_diff = np.abs(processed[:, 0] - processed[:, 1]).mean()
        
        assert processed_diff < original_diff, \
            "Low frequencies should become more mono after processing"
    
    def test_high_freq_preserved(self):
        """High frequencies should be less affected."""
        duration = 0.1
        sr = self.engine.sample_rate
        t = np.linspace(0, duration, int(sr * duration))
        
        # 10kHz stereo content
        left = np.sin(2 * np.pi * 10000 * t).astype(np.float32)
        right = np.sin(2 * np.pi * 10000 * t + np.pi/4).astype(np.float32)  # Phase diff
        
        stereo_input = np.column_stack((left, right))
        
        # Apply mono bass with low cutoff
        processed = self.engine.make_bass_mono(stereo_input, cutoff_freq=100)
        
        # High frequencies should be relatively unchanged
        # Check RMS difference
        original_rms = np.sqrt(np.mean(stereo_input ** 2))
        processed_rms = np.sqrt(np.mean(processed ** 2))
        
        # Should be within 10% for high frequencies
        rms_diff = abs(original_rms - processed_rms) / original_rms
        assert rms_diff < 0.1, "High frequencies should be mostly preserved"


class TestSidechainCompression:
    """Test auto-sidechain functionality."""
    
    def setup_method(self):
        """Set up test fixtures."""
        self.engine = AudioEngine(temp_dir="temp")
        self.engine.sample_rate = 44100
    
    def test_sidechain_reduces_level(self):
        """Bass should be reduced during kick hits."""
        sr = self.engine.sample_rate
        duration = 0.5
        samples = int(sr * duration)
        
        # Create constant bass signal
        bass = np.ones((samples, 2), dtype=np.float32) * 0.5
        
        # Create kick with transient at start
        kick = np.zeros((samples, 2), dtype=np.float32)
        kick[:int(sr * 0.05), :] = 0.9  # 50ms kick hit
        
        # Apply sidechain
        processed = self.engine.apply_sidechain(bass, kick, strength=0.8)
        
        # Bass should be ducked during kick
        during_kick_rms = np.sqrt(np.mean(processed[:int(sr * 0.05)] ** 2))
        after_kick_rms = np.sqrt(np.mean(processed[int(sr * 0.2):] ** 2))
        
        assert during_kick_rms < after_kick_rms, \
            "Bass should be reduced during kick"
    
    def test_zero_strength_no_effect(self):
        """Strength 0 should not affect the signal."""
        sr = self.engine.sample_rate
        samples = 1000
        
        bass = np.random.randn(samples, 2).astype(np.float32)
        kick = np.random.randn(samples, 2).astype(np.float32)
        
        processed = self.engine.apply_sidechain(bass, kick, strength=0.0)
        
        # Should be identical
        np.testing.assert_array_almost_equal(processed, bass, decimal=5)


class TestTransientShaper:
    """Test transient shaping functionality."""
    
    def setup_method(self):
        """Set up test fixtures."""
        self.engine = AudioEngine(temp_dir="temp")
        self.engine.sample_rate = 44100
    
    def test_shape_preserved(self):
        """Audio shape should be preserved."""
        sr = self.engine.sample_rate
        samples = int(sr * 0.5)
        
        audio = np.random.randn(samples, 2).astype(np.float32) * 0.5
        processed = self.engine.apply_transient_shaper(audio)
        
        assert processed.shape == audio.shape
    
    def test_attack_boost_increases_peaks(self):
        """Attack boost should increase transient peaks."""
        sr = self.engine.sample_rate
        samples = int(sr * 0.5)
        
        # Create audio with a transient
        audio = np.zeros((samples, 2), dtype=np.float32)
        audio[0, :] = 1.0  # Initial transient
        audio[1:100, :] = np.linspace(0.8, 0.1, 99)[:, None]  # Decay
        
        processed = self.engine.apply_transient_shaper(
            audio, attack_boost_db=6.0, sustain_reduction_db=0.0
        )
        
        # Peak should be increased (approximately)
        # Note: exact behavior depends on implementation
        assert processed.shape == audio.shape


class TestSpectralExciter:
    """Test spectral exciter functionality."""
    
    def setup_method(self):
        """Set up test fixtures."""
        self.engine = AudioEngine(temp_dir="temp")
        self.engine.sample_rate = 44100
    
    def test_adds_harmonics(self):
        """Exciter should add harmonic content."""
        sr = self.engine.sample_rate
        duration = 0.5
        t = np.linspace(0, duration, int(sr * duration))
        
        # Pure sine wave at 8kHz
        audio = np.column_stack([
            np.sin(2 * np.pi * 8000 * t).astype(np.float32),
            np.sin(2 * np.pi * 8000 * t).astype(np.float32)
        ])
        
        processed = self.engine.apply_spectral_exciter(
            audio, highpass_freq=5000, blend_db=-12, drive=0.5
        )
        
        # Processed should have slightly different spectrum (harmonics added)
        # Simple check: RMS shouldn't be exactly the same
        original_rms = np.sqrt(np.mean(audio ** 2))
        processed_rms = np.sqrt(np.mean(processed ** 2))
        
        assert not np.isclose(original_rms, processed_rms), \
            "Exciter should modify the signal"


class TestPresets:
    """Test preset configuration."""
    
    def test_all_genres_have_required_keys(self):
        """All presets should have all required configuration keys."""
        required_keys = {
            'target_lufs',
            'mono_cutoff_hz',
            'sidechain_strength',
            'transient_boost_db',
            'exciter_amount'
        }
        
        for genre, preset in GENRE_PRESETS.items():
            for key in required_keys:
                assert key in preset, f"Genre '{genre}' missing key '{key}'"
    
    def test_lufs_values_reasonable(self):
        """LUFS values should be in reasonable range."""
        for genre, preset in GENRE_PRESETS.items():
            lufs = preset['target_lufs']
            assert -20 <= lufs <= 0, f"Genre '{genre}' has unreasonable LUFS: {lufs}"
    
    def test_sidechain_strength_normalized(self):
        """Sidechain strength should be 0-1."""
        for genre, preset in GENRE_PRESETS.items():
            strength = preset['sidechain_strength']
            assert 0 <= strength <= 1, \
                f"Genre '{genre}' has invalid sidechain strength: {strength}"


class TestLoudnessNormalization:
    """Test loudness normalization functionality."""
    
    def setup_method(self):
        """Set up test fixtures."""
        self.engine = AudioEngine(temp_dir="temp")
        self.engine.sample_rate = 44100
    
    def test_normalization_changes_level(self):
        """Normalization should adjust audio level."""
        sr = self.engine.sample_rate
        duration = 1.0
        t = np.linspace(0, duration, int(sr * duration))
        
        # Quiet audio
        audio = np.column_stack([
            np.sin(2 * np.pi * 440 * t).astype(np.float32) * 0.1,
            np.sin(2 * np.pi * 440 * t).astype(np.float32) * 0.1
        ])
        
        processed = self.engine.normalize_loudness(audio, target_lufs=-8.0)
        
        # Processed should be louder
        original_rms = np.sqrt(np.mean(audio ** 2))
        processed_rms = np.sqrt(np.mean(processed ** 2))
        
        assert processed_rms > original_rms, \
            "Normalization should increase quiet audio level"


def run_tests():
    """Run all tests and print results."""
    print("=" * 60)
    print("SunoRemaster DSP Unit Tests")
    print("=" * 60)
    
    # Collect test classes
    test_classes = [
        TestAudioEngineBasics,
        TestMonoBassCorrection,
        TestSidechainCompression,
        TestTransientShaper,
        TestSpectralExciter,
        TestPresets,
        TestLoudnessNormalization,
    ]
    
    passed = 0
    failed = 0
    
    for test_class in test_classes:
        print(f"\n{test_class.__name__}:")
        
        instance = test_class()
        for method_name in dir(instance):
            if method_name.startswith('test_'):
                try:
                    if hasattr(instance, 'setup_method'):
                        instance.setup_method()
                    
                    getattr(instance, method_name)()
                    print(f"  ✓ {method_name}")
                    passed += 1
                except AssertionError as e:
                    print(f"  ✗ {method_name}: {e}")
                    failed += 1
                except Exception as e:
                    print(f"  ✗ {method_name}: Exception - {e}")
                    failed += 1
    
    print("\n" + "=" * 60)
    print(f"Results: {passed} passed, {failed} failed")
    print("=" * 60)
    
    return failed == 0


if __name__ == "__main__":
    success = run_tests()
    sys.exit(0 if success else 1)
