# coding: utf-8
"""
    captcha.audio
    ~~~~~~~~~~~~~

    Generate Audio CAPTCHAs, with built-in digits CAPTCHA.

    This module is totally inspired by https://github.com/dchest/captcha
"""

import typing as t
import os
import copy
import wave
import struct
import secrets
import operator
from functools import reduce


__all__ = ['AudioCaptcha']

WAVE_SAMPLE_RATE = 8000  # HZ
WAVE_HEADER = bytearray(
    b'RIFF\x00\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00'
    b'@\x1f\x00\x00@\x1f\x00\x00\x01\x00\x08\x00data'
)
WAVE_HEADER_LENGTH = len(WAVE_HEADER) - 4
DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data')


def _read_wave_file(filepath: str) -> bytearray:
    w = wave.open(filepath)
    data = w.readframes(-1)
    w.close()
    return bytearray(data)


def change_speed(body: bytearray, speed: float = 1) -> bytearray:
    """Change the voice speed of the wave body."""
    if speed == 1:
        return body

    length = int(len(body) * speed)
    rv = bytearray(length)

    step: float = 0
    for v in body:
        i = int(step)
        while i < int(step + speed) and i < length:
            rv[i] = v
            i += 1
        step += speed
    return rv


def patch_wave_header(body: bytearray) -> bytearray:
    """Patch header to the given wave body.

    :param body: the wave content body, it should be bytearray.
    """
    length = len(body)

    padded = length + length % 2
    total = WAVE_HEADER_LENGTH + padded

    header = copy.copy(WAVE_HEADER)
    # fill the total length position
    header[4:8] = bytearray(struct.pack('<I', total))
    header += bytearray(struct.pack('<I', length))

    data = header + body

    # the total length is even
    if length != padded:
        data = data + bytearray([0])

    return data


def create_noise(length: int, level: int = 4) -> bytearray:
    """Create white noise for background"""
    noise = bytearray(length)
    adjust = 128 - int(level / 2)
    i = 0
    while i < length:
        v = secrets.randbelow(257)
        noise[i] = v % level + adjust
        i += 1
    return noise


def create_silence(length: int) -> bytearray:
    """Create a piece of silence."""
    data = bytearray(length)
    i = 0
    while i < length:
        data[i] = 128
        i += 1
    return data


def change_sound(body: bytearray, level: float = 1) -> bytearray:
    if level == 1:
        return body

    body = copy.copy(body)
    for i, v in enumerate(body):
        if v > 128:
            v = int((v - 128) * level + 128)
            v = max(v, 128)
            v = min(v, 255)
        elif v < 128:
            v = int(128 - (128 - v) * level)
            v = min(v, 128)
            v = max(v, 0)
        body[i] = v
    return body


def mix_wave(src: bytearray, dst: bytearray) -> bytearray:
    """Mix two wave body into one."""
    if len(src) > len(dst):
        # output should be longer
        dst, src = src, dst

    for i, sv in enumerate(src):
        dv = dst[i]
        if sv < 128 and dv < 128:
            dst[i] = int(sv * dv / 128)
        else:
            dst[i] = int(2 * (sv + dv) - sv * dv / 128 - 256)
    return dst


BEEP = _read_wave_file(os.path.join(DATA_DIR, 'beep.wav'))
END_BEEP = change_speed(BEEP, 1.4)
SILENCE = create_silence(int(WAVE_SAMPLE_RATE / 5))


class AudioCaptcha:
    """Create an audio CAPTCHA.

    Create an instance of AudioCaptcha is pretty simple::

        captcha = AudioCaptcha()
        captcha.write('1234', 'out.wav')

    This module has a built-in digits CAPTCHA, but it is suggested that you
    create your own voice data library. A voice data library is a directory
    that contains lots of single charater named directories, for example::

        voices/
            0/
            1/
            2/

    The single charater named directories contain the wave files which pronunce
    the directory name. A charater directory can has many wave files, this
    AudioCaptcha will randomly choose one of them.

    You should always use your own voice library::

        captcha = AudioCaptcha(voicedir='/path/to/voices')
    """
    def __init__(self, voicedir: t.Optional[str] = None):
        if voicedir is None:
            voicedir = DATA_DIR

        self._voicedir = voicedir
        self._cache: t.Dict[str, t.List[bytearray]] = {}
        self._choices: t.List[str] = []

    @property
    def choices(self) -> t.List[str]:
        """Available choices for characters to be generated."""
        if self._choices:
            return self._choices
        for n in os.listdir(self._voicedir):
            if len(n) == 1 and os.path.isdir(os.path.join(self._voicedir, n)):
                self._choices.append(n)
        return self._choices

    def random(self, length: int = 6) -> t.List[str]:
        """Generate a random string with the given length.

        :param length: the return string length.
        """
        return [secrets.choice(self.choices) for _ in range(length)]

    def load(self) -> None:
        """Load voice data into memory."""
        for name in self.choices:
            self._load_data(name)

    def _load_data(self, name: str) -> None:
        dirname = os.path.join(self._voicedir, name)
        data: t.List[bytearray] = []
        for f in os.listdir(dirname):
            filepath = os.path.join(dirname, f)
            if f.endswith('.wav') and os.path.isfile(filepath):
                data.append(_read_wave_file(filepath))
        self._cache[name] = data

    def _twist_pick(self, key: str) -> bytearray:
        voice = secrets.choice(self._cache[key])

        # random change speed
        speed = (secrets.randbelow(31) + 90) / 100.0
        voice = change_speed(voice, speed)

        # random change sound
        level = (secrets.randbelow(41) + 80) / 100.0
        voice = change_sound(voice, level)
        return voice

    def _noise_pick(self) -> bytearray:
        key = secrets.choice(self.choices)
        voice = secrets.choice(self._cache[key])
        voice = copy.copy(voice)
        voice.reverse()

        speed = (secrets.randbelow(9) + 8) / 10.0
        voice = change_speed(voice, speed)

        level = (secrets.randbelow(5) + 2) / 10.0
        voice = change_sound(voice, level)
        return voice

    def create_background_noise(self, length: int, chars: str) -> bytearray:
        noise = create_noise(length, 4)
        pos = 0
        while pos < length:
            sound = self._noise_pick()
            end = pos + len(sound) + 1
            noise[pos:end] = mix_wave(sound, noise[pos:end])
            pos = end + secrets.randbelow(int(WAVE_SAMPLE_RATE / 10) + 1)
        return noise

    def create_wave_body(self, chars: str) -> bytearray:
        voices: t.List[bytearray] = []
        inters: t.List[int] = []
        for c in chars:
            voices.append(self._twist_pick(c))
            i = secrets.randbelow(WAVE_SAMPLE_RATE * 3 - WAVE_SAMPLE_RATE + 1) + WAVE_SAMPLE_RATE
            inters.append(i)

        durations = map(lambda a: len(a), voices)
        length = max(durations) * len(chars) + reduce(operator.add, inters)
        bg = self.create_background_noise(length, chars)

        # begin
        pos: int = inters[0]
        for i, v in enumerate(voices):
            end = pos + len(v) + 1
            bg[pos:end] = mix_wave(v, bg[pos:end])
            pos = end + inters[i]

        return BEEP + SILENCE + BEEP + SILENCE + BEEP + bg + END_BEEP

    def generate(self, chars: str) -> bytearray:
        """Generate audio CAPTCHA data. The return data is a bytearray.

        :param chars: text to be generated.
        """
        if not self._cache:
            self.load()
        body = self.create_wave_body(chars)
        return patch_wave_header(body)

    def write(self, chars: str, output: str) -> None:
        """Generate and write audio CAPTCHA data to the output.

        :param chars: text to be generated.
        :param output: output destionation.
        """
        data = self.generate(chars)
        with open(output, 'wb') as f:
            f.write(data)
