import re
import math
import random
from functools import lru_cache
from typing import Tuple, Union, Optional, Dict
from enum import Enum

import _pyfxr
from _pyfxr import SoundBuffer, Wavetable, sfx, CachedSound

__all__ = (
    'SAMPLE_RATE',

    'SoundBuffer',
    'Wavetable',

    'SFX',
    'pickup',
    'laser',
    'explosion',
    'powerup',
    'hurt',
    'jump',
    'select',

    'tone',
    'pluck',
    'note_to_hertz',
)


#: The sample rate for all sounds generated by pyfxr
SAMPLE_RATE: int = 44100

NOTE_PATTERN = re.compile(r'^([A-G])([b#]?)([0-8])$')

A4 = 440.0

NOTE_VALUE = dict(C=-9, D=-7, E=-5, F=-4, G=-2, A=0, B=2)

TWELTH_ROOT = math.pow(2, (1 / 12))


@lru_cache()
def note_to_hertz(note: str) -> float:
    """Get a frequency for a given note.

    Here we use an even temper - all semitones in the octave are equally
    spaced twelfth powers of 2.

    """
    note, accidental, octave = validate_note(note)
    value = note_value(note, accidental, octave)
    return A4 * math.pow(TWELTH_ROOT, value)


def note_value(note: str, accidental: str, octave: int) -> int:
    """Given a note name, an accidental and an octave, return a note number.

    Each number corresponds to a specific note and can be converted to a pitch.
    """
    value = NOTE_VALUE[note]
    if accidental:
        value += 1 if accidental == '#' else -1
    return (4 - octave) * -12 + value


def validate_note(note: str) -> Tuple[str, str, int]:
    match = re.match(NOTE_PATTERN, note)
    if match is None:
        raise InvalidNote(
            '%s is not a valid note. '
            'notes are A-F, are either normal, flat (b) or sharp (#) '
            'and of octave 0-8' % note
        )
    note, accidental, octave = match.group(1, 2, 3)
    return note, accidental, int(octave)


def pluck(
    duration: float,
    pitch: Union[float, str],
    release: float = 0.1
) -> SoundBuffer:
    """Generate a pluck sound, like a harp or guitar."""
    # This is a wrapper to handle converting a note string to a pitch
    if isinstance(pitch, str):
        pitch = note_to_hertz(pitch)
    return _pyfxr.pluck(duration, pitch, release)


pluck.__doc__ = _pyfxr.pluck.__doc__


def tone(
    pitch: Union[float, str] = 440.0,  # Hz, default = A
    attack: float = 0.1,
    decay: float = 0.1,
    sustain: float = 0.75,
    release: float = 0.25,
    wavetable: Wavetable = Wavetable.sine(),
) -> SoundBuffer:
    """Generate a tone using a wavetable.

    The tone will be modulated by an ADSR envelope
    (attack-decay-sustain-release) which gives the tone a more natural feel,
    and avoids clicks when played. The total length of the tone is the sum of
    these durations.

    :param wavetable: The wavetable to use (default is a sine wave).
    :param pitch: The pitch of the tone to generate, either float Hz or a note
                  name/number like ``Bb4`` for B-flat in the 4th octave.
    :param attack: Attack time in seconds
    :param decay: Decay time in seconds
    :param sustain: Sustain time in seconds
    :param release: Release time in seconds

    """
    # This is a wrapper to handle converting a note string to a pitch
    if isinstance(pitch, str):
        pitch = note_to_hertz(pitch)
    return _pyfxr.tone(
        wavetable,
        pitch,
        attack * 44100,
        decay * 44100,
        sustain * 44100,
        release * 44100,
    )


class FloatParam:
    """A parameter for a sound effect."""
    name: str
    default: float
    min: Optional[float]
    max: Optional[float]

    def __init__(
        self,
        default: float = 0.0,
        min: Optional[float] = None,
        max: Optional[float] = None
    ):
        self.default = default
        self.min = min
        self.max = max
        if max is not None and min is not None and self.max < self.min:
            raise ValueError("max {self.max} < min {self.min}")
        self.name = None

    def __set_name__(self, cls: type, name: str):
        self.name = name

    def __get__(self, inst: 'SFX', cls: type) -> float:
        return inst._params.get(self.name, self.default)

    def __set__(self, inst: 'SFX', value: float):
        value = float(value)
        if self.min is not None and value < self.min:
            raise ValueError(
                f"{self.name} must be greater than {self.min} (got {value})"
            )
        elif self.max is not None and value > self.max:
            raise ValueError(
                f"{self.name} must be less than {self.max} (got {value})"
            )
        inst._params[self.name] = value
        inst._clear()

    def __delete__(self, inst: 'SFX'):
        inst._params.pop(self.name, None)
        inst._clear()


class WaveType(Enum):
    """The wave types available for the SFX builder.

    Pure tones with tone() use arbitrary wavetables rather than this
    enumeration.

    """
    #: A square-wave waveform
    SQUARE = 0

    #: A saw-wave waveform
    SAW = 1

    #: A sine wave
    SINE = 2

    #: Random noise
    NOISE = 3


class SFX(CachedSound):
    """Build a sound effect using a set of parameters.

    The list of parameters is long and the sensible ranges for the parameters
    aren't that clear. This class acts as a validator and builder for the
    parameters, making it simpler to experiment with sound effects.

    You can also serialise this class in several ways:

    * The ``repr()`` is suitable for pasting into code.
    * You can serialise it as JSON using ``.as_dict()``.
    * You can pickle the class.

    In any of these case the size is much smaller than the generated
    SoundBuffer.

    SFX supports the buffer protocol much like :class:`SoundBuffer`; accessing
    the object as a buffer generates and caches a sound.
    """

    #: The initial frequency of the sound
    base_freq: float = FloatParam(0.3)
    #: The maximum frequency of the sound
    freq_limit: float = FloatParam(0.0)
    #: The rate of change of the frequency of the sound
    freq_ramp: float = FloatParam(0.0)
    #: The acceleration of the change in frequency of the sound
    freq_dramp: float = FloatParam(0.0)

    #: If using square wave, the duty cycle of the waveform
    duty: float = FloatParam(0.0)
    #: The rate of change of the square wave duty cycle
    duty_ramp: float = FloatParam(0.0)

    #: Vibrato strength
    vib_strength: float = FloatParam(0.0)
    #: Vibrato speed
    vib_speed: float = FloatParam(0.0)
    #: Vibrato delay
    vib_delay: float = FloatParam(0.0)

    #: The duration of the attack phase of the ADSR envelope
    env_attack: float = FloatParam(0.0)
    #: The duration of the sustain phase of the ADSR envelope
    env_sustain: float = FloatParam(0.3)
    #: The duration of the decay phase of the ADSR envelope
    env_decay: float = FloatParam(0.4)
    #: Causes the volume to decrease during the sustain phase of the envelope
    env_punch: float = FloatParam(0.0)

    #: Low-pass filter resonance
    lpf_resonance: float = FloatParam(0.0)
    #: Low-pass filter frequency
    lpf_freq: float = FloatParam(1.0)
    #: Low-pass filter ramp
    lpf_ramp: float = FloatParam(0.0)

    #: High-pass filter frequency
    hpf_freq: float = FloatParam(0.0)
    #: High-pass filter ramp
    hpf_ramp: float = FloatParam(0.0)

    #: Phaser offset
    pha_offset: float = FloatParam(0.0)
    #: Phaser ramp
    pha_ramp: float = FloatParam(0.0)

    #: Repeat speed
    repeat_speed: float = FloatParam(0.0)

    #: Arpeggio speed
    arp_speed: float = FloatParam(0.0)
    #: Arpeggio mod
    arp_mod: float = FloatParam(0.0)

    __slots__ = '_params'

    def __init__(self, **kwargs):
        self._params = {}
        for k, v in kwargs.items():
            setattr(self, k, v)

    @property
    def wave_type(self) -> WaveType:
        """Get the wave type."""
        return WaveType(self._params.get('wave_type', 0))

    @wave_type.setter
    def wave_type(self, v: Union[str, int, WaveType]):
        """Set the wave type."""
        if isinstance(v, str):
            v = WaveType[v.upper()]
        self._params['wave_type'] = WaveType(v).value
        self._clear()

    def as_dict(self) -> dict:
        """Get the parameters as a dict.

        The dict is suitable for serialising as JSON; to reconstruct the
        object, pass the parameters as kwargs to the constructor, eg.

        >>> s = SFX(...)
        >>> params = s.as_dict()
        >>> s2 = SFX(**params)

        """
        return self._params.copy()

    def __repr__(self):
        """Generate a repr for this sound effect.

        The repr is guaranteed to be executable code to reconstruct the SFX
        instance.

        """
        params = [f'{type(self).__module__}.{type(self).__qualname__}(']

        for k, desc in vars(type(self)).items():
            if k != 'wave_type' and not isinstance(desc, FloatParam):
                continue

            try:
                value = self._params[k]
            except KeyError:
                continue

            r = repr(value)

            if k == 'wave_type':
                value = WaveType(value)
                r = f'{value.__module__}.{value}'

            params.append(f'    {k}={r},')
        if len(params) == 1:
            return params[0] + ')'
        params.append(')')
        return '\n'.join(params)

    def get_queue_source(self):
        # Duck type as a pyglet.media.Source.
        # Don't put a docstring here as it causes the method to appear in
        # the docs.
        return self._get().get_queue_source()

    def _build(self) -> SoundBuffer:
        """Actually generate the sound using the current parameters."""
        return sfx(**{
            f'p_{k}' if k != 'wave_type' else k: v
            for k, v in self._params.items()
        })

    def build(self) -> SoundBuffer:
        """Get the generated sound (memoised)."""
        return self._get()

    def envelope(
        self,
        attack: float = 0.0,
        sustain: float = 0.3,
        decay: float = 0.4,
        punch: float = 0.0
    ):
        """Set the ADSR envelope for this sound effect."""
        self.env_attack = attack
        self.env_sustain = sustain
        self.env_decay = decay
        self.env_punch = punch
        return self


def one_in(n: int) -> bool:
    """Return True with odds of 1 in n."""
    return not random.randint(0, n)


def _mksfx(params: Dict[str, float]) -> SFX:
    """Round the parameters and construct an SFX.

    Rounding is helpful because it makes the repr shorter and more
    human-friendly.

    """
    return SFX(**{k: round(v, 3) for k, v in params.items()})


def pickup() -> SFX:
    """Generate a random bell sound, like picking up a coin."""
    base_freq = random.uniform(0.4, 0.9)
    env_attack = 0.0
    env_sustain = random.uniform(0.0, 0.1)
    env_decay = random.uniform(0.1, 0.5)
    env_punch = random.uniform(0.3, 0.6)
    if one_in(2):
        arp_mod = random.uniform(0.2, 0.6)
    return _mksfx(locals())


def laser() -> SFX:
    """Generate a random laser sound."""
    wave_type = random.choice((0, 0, 1, 1, 2))
    base_freq = random.uniform(0.5, 1.0)
    freq_limit = max(0.2, base_freq - random.uniform(0.2, 0.8))
    freq_ramp = random.uniform(-0.35, -0.15)
    if one_in(3):
        base_freq = random.uniform(0.3, 0.9)
        freq_limit = random.uniform(0.0, 0.1)
        freq_ramp = random.uniform(-0.65, -0.35)
    if one_in(2):
        duty = random.uniform(0.0, 0.5)
        duty_ramp = random.uniform(0.0, 0.2)
    else:
        duty = random.uniform(0.4, 0.9)
        duty_ramp = random.uniform(-0.7, 0.0)
    env_attack = 0.0
    env_sustain = random.uniform(0.1, 0.3)
    env_decay = random.uniform(0.0, 0.4)
    if one_in(2):
        env_punch = random.uniform(0.0, 0.3)
    if one_in(3):
        pha_offset = random.uniform(0.0, 0.2)
        pha_ramp = random.uniform(-0.2, 0.0)
    if one_in(2):
        hpf_freq = random.uniform(0.0, 0.3)

    return _mksfx(locals())


def explosion() -> SFX:
    """Generate a random explosion sound."""
    wave_type = 3
    if one_in(2):
        base_freq = random.uniform(0.1, 0.5) ** 2
        freq_ramp = random.uniform(-0.1, 0.3)
    else:
        base_freq = random.uniform(0.2, 0.7) ** 2
        freq_ramp = random.uniform(-0.4, -0.2)
    if one_in(5):
        freq_ramp = 0
    if one_in(3):
        repeat_speed = random.uniform(0.3, 0.8)
    env_attack = 0.0
    env_sustain = random.uniform(0.1, 0.4)
    env_decay = random.uniform(0.0, 0.5)
    if one_in(2):
        pha_offset = random.uniform(-0.3, 0.6)
        pha_ramp = random.uniform(-0.3, 0)
    env_punch = random.uniform(0.2, 0.6)
    if one_in(2):
        vib_strength = random.uniform(0.0, 0.7)
        vib_speed = random.uniform(0.0, 0.6)
    if one_in(3):
        arp_speed = random.uniform(0.6, 0.9)
        arp_mod = random.uniform(-0.8, 0.8)

    return _mksfx(locals())


def powerup() -> SFX:
    """Generate a random chime, like receiving a power-up."""
    if one_in(2):
        wave_type = 1
    else:
        duty = random.uniform(0.0, 0.6)

    if one_in(2):
        base_freq = random.uniform(0.2, 0.5)
        freq_ramp = random.uniform(0.1, 0.5)
        repeat_speed = random.uniform(0.4, 0.8)
    else:
        base_freq = random.uniform(0.2, 0.5)
        freq_ramp = random.uniform(0.05, 0.25)
        if one_in(2):
            vib_strength = random.uniform(0.0, 0.7)
            vib_speed = random.uniform(0.0, 0.6)
    env_attack = 0.0
    env_sustain = random.uniform(0.0, 0.4)
    env_decay = random.uniform(0.1, 0.5)

    return _mksfx(locals())


def hurt() -> SFX:
    """Generate a random impact sound, like a character being hurt."""
    wave_type = random.choice([0, 1, 3])
    if wave_type == 0:
        duty = random.uniform(0, 0.6)
    base_freq = random.uniform(0.2, 0.8)
    freq_ramp = random.uniform(-0.7, -0.3)
    env_attack = 0.0
    env_sustain = random.uniform(0.0, 0.1)
    env_decay = random.uniform(0.1, 0.3)
    if one_in(2):
        hpf_freq = random.uniform(0.0, 0.3)

    return _mksfx(locals())


def jump() -> SFX:
    """Generate a random jump sound."""
    wave_type = 0
    duty = random.uniform(0.0, 0.6)
    base_freq = random.uniform(0.3, 0.6)
    freq_ramp = random.uniform(0.1, 0.3)
    env_attack = 0.0
    env_sustain = random.uniform(0.1, 0.4)
    env_decay = random.uniform(0.1, 0.3)
    if one_in(2):
        hpf_freq = random.uniform(0.0, 0.3)
    if one_in(2):
        lpf_freq = random.uniform(0.4, 1.0)

    return _mksfx(locals())


def select() -> SFX:
    """Generate a random 'blip' noise, like selecting an option in a menu."""
    wave_type = random.choice([0, 1])
    if wave_type == 0:
        duty = random.uniform(0.0, 0.6)
    base_freq = random.uniform(0.2, 0.6)
    env_attack = 0.0
    env_sustain = random.uniform(0.1, 0.2)
    env_decay = random.uniform(0.0, 0.2)
    hpf_freq = 0.1
    return _mksfx(locals())
