"""
europa.effects
~~~~~~~~~~~~~~
Audio effects and transformations.

Effects:
    time_stretch   - Time stretch without changing pitch
    pitch_shift    - Shift pitch without changing duration
    reverb         - Convolution / algorithmic reverb
    echo           - Delay/echo effect
    fade_in        - Fade-in envelope
    fade_out       - Fade-out envelope
    gain           - Amplify or attenuate
    pan            - Stereo panning
    low_pass       - Low-pass filter
    high_pass      - High-pass filter
    band_pass      - Band-pass filter
    noise_reduce   - Basic spectral noise reduction
    compress       - Dynamic range compression
    distort        - Soft-clip distortion
"""

import numpy as np
from ..core.audio import Audio
from ..utils.dsp import stft as _stft, istft as _istft


def time_stretch(audio: Audio, rate: float) -> Audio:
    """
    Time-stretch audio without changing pitch (Phase Vocoder).

    Args:
        audio : Input Audio.
        rate  : Stretch factor. > 1.0 = slower, < 1.0 = faster.

    Returns:
        Audio: Time-stretched audio.
    """
    data = audio.data if audio.is_mono else np.mean(audio.data, axis=0)
    stretched = _phase_vocoder(data, rate, audio.sr)
    return Audio(data=stretched.astype(np.float32), sr=audio.sr,
                 path=audio.path, metadata=audio.metadata.copy())


def pitch_shift(audio: Audio, semitones: float) -> Audio:
    """
    Shift the pitch of audio by N semitones.

    Args:
        audio    : Input Audio.
        semitones: Semitones to shift (+/- float).

    Returns:
        Audio: Pitch-shifted audio.
    """
    rate = 2 ** (-semitones / 12.0)
    stretched = time_stretch(audio, rate)
    from ..core.convert import resample as _resample
    resampled = _resample(stretched, int(audio.sr * rate))
    resampled.sr = audio.sr
    # Fix sample count via resample back
    from ..core.convert import _resample_array
    fixed = _resample_array(resampled.data, resampled.sr, audio.sr)
    return Audio(data=fixed, sr=audio.sr)


def reverb(
    audio: Audio,
    room_size: float = 0.5,
    damping: float = 0.5,
    wet: float = 0.3,
    dry: float = 0.7,
) -> Audio:
    """
    Algorithmic reverb using a Schroeder reverberator.

    Args:
        audio     : Input Audio.
        room_size : Room size factor (0.0–1.0).
        damping   : High-frequency damping (0.0–1.0).
        wet       : Wet (reverb) mix level.
        dry       : Dry (original) mix level.

    Returns:
        Audio: Audio with reverb applied.
    """
    data = audio.data if audio.is_mono else np.mean(audio.data, axis=0)
    sr = audio.sr

    # Comb filter delays (in samples)
    delays = [int(sr * d) for d in [0.0297, 0.0371, 0.0411, 0.0437]]
    gains  = [room_size * g for g in [0.84, 0.87, 0.88, 0.89]]

    wet_out = np.zeros_like(data)
    for delay, gain in zip(delays, gains):
        wet_out += _comb_filter(data, delay, gain, damping)

    # All-pass filters
    allpass_delays = [int(sr * d) for d in [0.005, 0.0017]]
    for delay in allpass_delays:
        wet_out = _allpass_filter(wet_out, delay, 0.7)

    result = dry * data + wet * wet_out
    return Audio(data=result.astype(np.float32), sr=audio.sr)


def echo(
    audio: Audio,
    delay: float = 0.3,
    decay: float = 0.5,
    n_echoes: int = 3,
) -> Audio:
    """
    Apply a delay/echo effect.

    Args:
        audio    : Input Audio.
        delay    : Delay time in seconds.
        decay    : Amplitude decay per echo (0.0–1.0).
        n_echoes : Number of echo repetitions.

    Returns:
        Audio: Audio with echo.
    """
    data = audio.data.copy()
    delay_samples = int(delay * audio.sr)
    output = np.zeros(len(data) + delay_samples * n_echoes, dtype=np.float32)
    output[:len(data)] = data

    for i in range(1, n_echoes + 1):
        start = delay_samples * i
        amp = decay ** i
        output[start:start + len(data)] += data * amp

    return Audio(data=output.astype(np.float32), sr=audio.sr)


def fade_in(audio: Audio, duration: float) -> Audio:
    """
    Apply a linear fade-in envelope.

    Args:
        audio    : Input Audio.
        duration : Fade duration in seconds.

    Returns:
        Audio: Audio with fade-in.
    """
    n = min(int(duration * audio.sr), audio.samples)
    data = audio.data.copy()
    envelope = np.linspace(0.0, 1.0, n).astype(np.float32)
    data[..., :n] *= envelope
    return Audio(data=data, sr=audio.sr, path=audio.path)


def fade_out(audio: Audio, duration: float) -> Audio:
    """
    Apply a linear fade-out envelope.

    Args:
        audio    : Input Audio.
        duration : Fade duration in seconds.

    Returns:
        Audio: Audio with fade-out.
    """
    n = min(int(duration * audio.sr), audio.samples)
    data = audio.data.copy()
    envelope = np.linspace(1.0, 0.0, n).astype(np.float32)
    data[..., -n:] *= envelope
    return Audio(data=data, sr=audio.sr, path=audio.path)


def gain(audio: Audio, db: float) -> Audio:
    """
    Apply gain in decibels.

    Args:
        audio : Input Audio.
        db    : Gain in dB (positive = louder, negative = quieter).

    Returns:
        Audio: Amplified/attenuated audio.
    """
    factor = 10 ** (db / 20.0)
    return Audio(data=(audio.data * factor).astype(np.float32), sr=audio.sr,
                 path=audio.path, metadata=audio.metadata.copy())


def pan(audio: Audio, amount: float) -> Audio:
    """
    Stereo panning using equal-power law.

    Args:
        audio  : Input Audio.
        amount : Pan position from -1.0 (full left) to 1.0 (full right).

    Returns:
        Audio: Stereo panned audio (2 channels).
    """
    from ..core.convert import to_mono
    mono = to_mono(audio)
    angle = (amount + 1.0) / 2.0 * np.pi / 2.0  # 0 to pi/2
    left  = mono.data * np.cos(angle)
    right = mono.data * np.sin(angle)
    data  = np.stack([left, right]).astype(np.float32)
    return Audio(data=data, sr=audio.sr, path=audio.path)


def low_pass(audio: Audio, cutoff: float, order: int = 5) -> Audio:
    """
    Apply a Butterworth low-pass filter.

    Args:
        audio  : Input Audio.
        cutoff : Cutoff frequency in Hz.
        order  : Filter order. Default 5.

    Returns:
        Audio: Filtered audio.
    """
    return _butter_filter(audio, cutoff, "low", order)


def high_pass(audio: Audio, cutoff: float, order: int = 5) -> Audio:
    """
    Apply a Butterworth high-pass filter.

    Args:
        audio  : Input Audio.
        cutoff : Cutoff frequency in Hz.
        order  : Filter order. Default 5.

    Returns:
        Audio: Filtered audio.
    """
    return _butter_filter(audio, cutoff, "high", order)


def band_pass(audio: Audio, low: float, high: float, order: int = 4) -> Audio:
    """
    Apply a Butterworth band-pass filter.

    Args:
        audio : Input Audio.
        low   : Lower cutoff frequency in Hz.
        high  : Upper cutoff frequency in Hz.
        order : Filter order.

    Returns:
        Audio: Band-pass filtered audio.
    """
    from scipy.signal import butter, sosfilt
    nyq = audio.sr / 2.0
    sos = butter(order, [low / nyq, high / nyq], btype="bandpass", output="sos")
    if audio.is_mono:
        filtered = sosfilt(sos, audio.data).astype(np.float32)
    else:
        filtered = np.stack([sosfilt(sos, ch) for ch in audio.data]).astype(np.float32)
    return Audio(data=filtered, sr=audio.sr, path=audio.path)


def compress(
    audio: Audio,
    threshold_db: float = -20.0,
    ratio: float = 4.0,
    attack: float = 0.005,
    release: float = 0.1,
    makeup_db: float = 0.0,
) -> Audio:
    """
    Dynamic range compression.

    Args:
        audio        : Input Audio.
        threshold_db : Threshold in dBFS above which compression is applied.
        ratio        : Compression ratio (e.g. 4 = 4:1).
        attack       : Attack time in seconds.
        release      : Release time in seconds.
        makeup_db    : Makeup gain in dB after compression.

    Returns:
        Audio: Compressed audio.
    """
    data = audio.data.copy().astype(np.float64)
    threshold = 10 ** (threshold_db / 20.0)
    makeup = 10 ** (makeup_db / 20.0)

    attack_coef  = np.exp(-1.0 / (attack  * audio.sr))
    release_coef = np.exp(-1.0 / (release * audio.sr))

    gain_env = np.ones_like(data)
    envelope = 0.0
    for i in range(len(data) if data.ndim == 1 else data.shape[-1]):
        sample = abs(data[i] if data.ndim == 1 else np.max(np.abs(data[:, i])))
        if sample > envelope:
            envelope = attack_coef * envelope + (1 - attack_coef) * sample
        else:
            envelope = release_coef * envelope

        if envelope > threshold:
            gain_val = threshold + (envelope - threshold) / ratio
            gain_env[..., i] = gain_val / (envelope + 1e-10)

    return Audio(data=(data * gain_env * makeup).astype(np.float32), sr=audio.sr)


def distort(audio: Audio, drive: float = 10.0, tone: float = 0.5) -> Audio:
    """
    Soft-clip distortion.

    Args:
        audio : Input Audio.
        drive : Drive amount (gain before clipping). Higher = more distortion.
        tone  : High-frequency mix (0.0 = dark, 1.0 = bright).

    Returns:
        Audio: Distorted audio.
    """
    data = audio.data * drive
    clipped = np.tanh(data).astype(np.float32)  # Soft clip via tanh
    # Tone control: blend with high-passed version
    if tone > 0:
        hp = _butter_filter(Audio(data=clipped, sr=audio.sr), 2000.0, "high", 2)
        clipped = (1 - tone) * clipped + tone * hp.data
    return Audio(data=(clipped / (np.max(np.abs(clipped)) + 1e-9)).astype(np.float32),
                 sr=audio.sr)


def noise_reduce(audio: Audio, noise_factor: float = 0.15) -> Audio:
    """
    Basic spectral noise reduction via spectral subtraction.

    Args:
        audio        : Input Audio.
        noise_factor : Fraction of signal assumed to be noise floor. Default 0.15.

    Returns:
        Audio: Noise-reduced audio.
    """
    n_fft = 2048
    hop   = 512
    data  = audio.data if audio.is_mono else np.mean(audio.data, axis=0)

    D = _stft(data, n_fft=n_fft, hop_length=hop)
    mag   = np.abs(D)
    phase = np.angle(D)

    # Estimate noise from first 0.2s
    noise_frames = max(1, int(0.2 * audio.sr / hop))
    noise_profile = np.mean(mag[:, :noise_frames], axis=1, keepdims=True)

    mag_reduced = np.maximum(mag - noise_factor * noise_profile, 0.0)
    D_reduced = mag_reduced * np.exp(1j * phase)
    y_reduced = _istft(D_reduced, hop_length=hop, length=len(data))
    return Audio(data=y_reduced.astype(np.float32), sr=audio.sr)


# ── Internal helpers ──────────────────────────────────────────────────────────

def _butter_filter(audio: Audio, cutoff: float, btype: str, order: int) -> Audio:
    from scipy.signal import butter, sosfilt
    nyq = audio.sr / 2.0
    sos = butter(order, cutoff / nyq, btype=btype, output="sos")
    if audio.is_mono:
        filtered = sosfilt(sos, audio.data).astype(np.float32)
    else:
        filtered = np.stack([sosfilt(sos, ch) for ch in audio.data]).astype(np.float32)
    return Audio(data=filtered, sr=audio.sr, path=audio.path)


def _comb_filter(x: np.ndarray, delay: int, gain: float, damping: float) -> np.ndarray:
    out = np.zeros_like(x)
    buf = np.zeros(delay)
    lp  = 0.0
    for i in range(len(x)):
        buf_idx = i % delay
        lp = buf[buf_idx] * (1 - damping) + lp * damping
        out[i] = lp
        buf[buf_idx] = x[i] + lp * gain
    return out


def _allpass_filter(x: np.ndarray, delay: int, gain: float) -> np.ndarray:
    out = np.zeros_like(x)
    buf = np.zeros(delay)
    for i in range(len(x)):
        buf_idx = i % delay
        buf_val = buf[buf_idx]
        out[i]  = -gain * x[i] + buf_val
        buf[buf_idx] = x[i] + gain * buf_val
    return out


def _phase_vocoder(y: np.ndarray, rate: float, sr: int, n_fft: int = 2048, hop_length: int = 512) -> np.ndarray:
    """Phase vocoder for time stretching."""
    D = _stft(y, n_fft=n_fft, hop_length=hop_length)
    time_steps = np.arange(0, D.shape[1], rate)
    shape = (D.shape[0], len(time_steps))
    d_stretch = np.zeros(shape, dtype=complex)
    phase_acc = np.angle(D[:, 0])
    expect_phase_adv = 2.0 * np.pi * hop_length * np.arange(D.shape[0]) / n_fft

    for i, step in enumerate(time_steps):
        left  = int(np.floor(step))
        right = min(left + 1, D.shape[1] - 1)
        alpha = step - left
        mag = (1 - alpha) * np.abs(D[:, left]) + alpha * np.abs(D[:, right])

        if left + 1 < D.shape[1]:
            delta_phase = np.angle(D[:, left + 1]) - np.angle(D[:, left]) - expect_phase_adv
            delta_phase = delta_phase - 2 * np.pi * np.round(delta_phase / (2 * np.pi))
            phase_acc += expect_phase_adv + delta_phase
        d_stretch[:, i] = mag * np.exp(1j * phase_acc)

    return _istft(d_stretch, hop_length=hop_length, length=int(len(y) / rate))
