# vim:et sts=4 sw=4
#
# ibus-table - The Tables engine for IBus
#
# Copyright (c) 2023 Mike FABIAN <mfabian@redhat.com>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>
'''
Module to play simple error sounds
'''

from typing import Optional
from typing import Any
import sys
import os
import logging
import threading
import wave
import shutil
import subprocess
import mimetypes

LOGGER = logging.getLogger('ibus-table')

IMPORT_PYGAME_MIXER_SUCCESSFUL = False
try:
    import pygame.mixer
    IMPORT_PYGAME_MIXER_SUCCESSFUL = True
except (ImportError,):
    IMPORT_PYGAME_MIXER_SUCCESSFUL = False

IMPORT_PYAUDIO_SUCCESSFUL = False
try:
    import pyaudio # type: ignore
    IMPORT_PYAUDIO_SUCCESSFUL = True
except (ImportError,):
    IMPORT_PYAUDIO_SUCCESSFUL = False

IMPORT_SIMPLEAUDIO_SUCCESSFUL = False
try:
    import simpleaudio # type: ignore
    IMPORT_SIMPLEAUDIO_SUCCESSFUL = True
except (ImportError,):
    IMPORT_SIMPLEAUDIO_SUCCESSFUL = False

class SoundObject:
    '''
    Class to play sounds

    When pygames is used, this can play .wav and .mp3 files.
    When pyaudio is used, only .wav files work.
    '''
    def __init__(self,
                 path_to_sound_file: str,
                 audio_backend: str = 'automatic') -> None:
        self._path_to_sound_file: str = path_to_sound_file
        self._wav_file: Optional[wave.Wave_read] = None
        self._paudio: Optional[pyaudio.PyAudio] = None
        self._play_pyaudio_thread: Optional[threading.Thread] = None
        self._stop_event_paudio: Optional[threading.Event] = None
        self._simpleaudio_wave_o: Optional[simpleaudio.WaveObject] = None
        self._simpleaudio_play_o: Optional[simpleaudio.shiny.PlayObject] = None
        self._aplay_binary: Optional[str] = None
        self._aplay_stdin = b''
        self._aplay_process: Optional[Any] = None
        self._play_aplay_thread: Optional[threading.Thread] = None
        self._supported_audio_backends = ('automatic', 'pygame', 'simpleaudio', 'aplay', 'pyaudio')
        self._requested_audio_backend = audio_backend
        self._audio_backend = ''
        if not os.path.isfile(self._path_to_sound_file):
            LOGGER.info('Sound file %s does not exist.', path_to_sound_file)
            return
        if not os.access(self._path_to_sound_file, os.R_OK):
            LOGGER.info('Sound file %s not readable.', path_to_sound_file)
            return
        if self._requested_audio_backend not in self._supported_audio_backends:
            LOGGER.error('Audio backend %s not supported, use one of %s',
                         audio_backend, self._supported_audio_backends)
            return
        self._audio_backend = getattr(self, f'_init_{self._requested_audio_backend}')()
        if self._audio_backend:
            LOGGER.info('Using audio backend %s', self._audio_backend)
        else:
            LOGGER.error('Could not init audio backend %s', self._requested_audio_backend)

    def _init_automatic(self) -> str:
        # Try 'pygame' first if possible it seems to be the best:
        if self._init_pygame():
            return 'pygame'
        # Try 'simpleaudio' for Python < 3.12.0, it used to work well
        # and has no dependencies.  But it is broken in Fedora 39,
        # see: https://bugzilla.redhat.com/show_bug.cgi?id=2237680
        # probably because Fedora 39 has Python 3.12.0rc2:
        if ((sys.version_info.major,
             sys.version_info.minor,
             sys.version_info.micro) < (3, 12, 0)):
            if self._init_simpleaudio():
                return 'simpleaudio'
        # Try 'aplay', it seems reliable:
        if self._init_aplay():
            return 'aplay'
        # Try 'pyaudio' as a last resort:
        # Broken for Python >= 3.10 if not updated to pyaudio >= 0.2.12
        # See: https://stackoverflow.com/questions/70344884)
        # Sometimes it seems to hang. Not often, but when this happens this is really bad
        if (IMPORT_PYAUDIO_SUCCESSFUL
            and
            (((sys.version_info.major,
               sys.version_info.minor,
               sys.version_info.micro) < (3, 10, 0))
             or
             (pyaudio.__version__
              and
              tuple(int(x) for x in pyaudio.__version__.split('.')) >= (0, 2, 12)))):
            if self._init_pyaudio():
                return 'pyaudio'
        # Nothing more to try ☹
        return ''

    def _init_pygame(self) -> str:
        if not IMPORT_PYGAME_MIXER_SUCCESSFUL:
            return ''
        try:
            # pygame defaults to a low latency (small buffer) that is more
            # appropriate for foreground games than background processes. Use a
            # larger than default buffer to prevent audio issues in other
            # processes when the system is under load:
            # https://github.com/mike-fabian/ibus-typing-booster/issues/681
            pygame.mixer.init(buffer=4096)
            if pygame.mixer.get_init():
                pygame.mixer.music.load(self._path_to_sound_file)
                return 'pygame'
        except Exception as error: # pylint: disable=broad-except
            LOGGER.exception(
                'pygame: cannot load sound file %s: %s',
                error.__class__.__name__, error)
        return ''

    def _init_pyaudio(self) -> str:
        if not IMPORT_PYAUDIO_SUCCESSFUL:
            return ''
        (mime_type, _encoding) = mimetypes.guess_type(self._path_to_sound_file)
        if mime_type not in ('audio/x-wav',):
            LOGGER.error(
                'File %s has mime type %s and is not supported by simpleaudio',
                self._path_to_sound_file, mime_type)
            return ''
        try:
            self._wav_file = wave.open(self._path_to_sound_file, 'rb')
            self._paudio = pyaudio.PyAudio()
            self._stop_event_paudio = threading.Event()
            LOGGER.info('portaudio version = %s',
                        pyaudio.get_portaudio_version_text())
            return 'pyaudio'
        except Exception as error: # pylint: disable=broad-except
            LOGGER.exception(
                'pyaudio: cannot init wave object %s: %s',
                error.__class__.__name__, error)
        return ''

    def _init_simpleaudio(self) -> str:
        if not IMPORT_SIMPLEAUDIO_SUCCESSFUL:
            return ''
        (mime_type, _encoding) = mimetypes.guess_type(self._path_to_sound_file)
        if mime_type not in ('audio/x-wav',):
            LOGGER.error(
                'File %s has mime type %s and is not supported by simpleaudio',
                self._path_to_sound_file, mime_type)
            return ''
        try:
            self._simpleaudio_wave_o = (
                simpleaudio.WaveObject.from_wave_file(self._path_to_sound_file))
            return 'simpleaudio'
        except Exception as error: # pylint: disable=broad-except
            LOGGER.exception(
                'Initializing error sound object failed: %s: %s',
                error.__class__.__name__, error)
        return ''

    def _init_aplay(self) -> str:
        (mime_type, _encoding) = mimetypes.guess_type(self._path_to_sound_file)
        if mime_type not in ('audio/x-wav',):
            LOGGER.error(
                'File %s has mime type %s and is not supported by aplay',
                self._path_to_sound_file, mime_type)
            return ''
        self._aplay_binary = shutil.which('aplay')
        if not self._aplay_binary:
            return ''
        with open (self._path_to_sound_file, mode='rb') as aplay_input:
            self._aplay_stdin = aplay_input.read()
            if self._aplay_stdin:
                return 'aplay'
        return ''

    def __del__(self) -> None:
        if self._paudio:
            self._paudio.terminate()
        if self._wav_file:
            self._wav_file.close()

    def _play_pyaudio_thread_function(self, stop_event: threading.Event) -> None:
        if not self._wav_file:
            LOGGER.error('wave.open(%s, \'rb\') did not work.',
                         self._path_to_sound_file)
            return
        if not self._paudio:
            LOGGER.error('pyaudio.PyAudio() did not work.')
            return
        LOGGER.info('Playing sound with pyaudio ...')
        chunk_size = 1024
        stream = self._paudio.open(
            format=self._paudio.get_format_from_width(
                self._wav_file.getsampwidth()),
            channels=self._wav_file.getnchannels(),
            rate=self._wav_file.getframerate(),
            output=True,
            frames_per_buffer=chunk_size)
        self._wav_file.rewind()
        data = self._wav_file.readframes(chunk_size)
        while data and not stop_event.is_set():
            try:
                if not stream.is_active():
                    LOGGER.error('pyaudio stream is_active() is False')
                    break
                stream.write(data)
                data = self._wav_file.readframes(chunk_size)
            except (SystemError, OSError) as error:
                LOGGER.exception(
                    'Unexpected error playing wave object %s: %s',
                    error.__class__.__name__, error)
                LOGGER.error('If you see the '
                             '"SystemError: PY_SSIZE_T_CLEAN macro '
                             'must be defined for \'#\' formats" '
                             'message here, updating to pyaudio >= 0.2.12 '
                             'will probably fix the problem.'
                             'See https://stackoverflow.com/questions/70344884')
                break
        stream.stop_stream()
        stream.close()
        LOGGER.info('Done playing sound with pyaudio.')

    def _play_pyaudio(self) -> None:
        if self._stop_event_paudio:
            self._stop_event_paudio.clear()
        self._play_pyaudio_thread = threading.Thread(
            daemon=True,
            target=self._play_pyaudio_thread_function,
            args=(self._stop_event_paudio,))
        self._play_pyaudio_thread.start()

    def _is_playing_pyaudio(self) -> bool:
        if not self._play_pyaudio_thread:
            return False
        return self._play_pyaudio_thread.is_alive()

    def _stop_pyaudio(self) -> None:
        if not self._play_pyaudio_thread:
            return
        if not self._stop_event_paudio:
            return
        if (self._play_pyaudio_thread.is_alive()
            and not self._stop_event_paudio.is_set()):
            self._stop_event_paudio.set()
            self._play_pyaudio_thread.join()
            self._stop_event_paudio.clear()

    def _wait_done_pyaudio(self) -> None:
        if not self._play_pyaudio_thread:
            return
        if self._play_pyaudio_thread.is_alive():
            self._play_pyaudio_thread.join()

    def _play_simpleaudio(self) -> None:
        if not self._simpleaudio_wave_o:
            return
        try:
            self._simpleaudio_play_o = self._simpleaudio_wave_o.play()
        except Exception as error: # pylint: disable=broad-except
            LOGGER.exception(
                'Initializing error sound object failed: %s: %s',
                error.__class__.__name__, error)

    def _is_playing_simpleaudio(self) -> bool:
        if not self._simpleaudio_play_o:
            return False
        return bool(self._simpleaudio_play_o.is_playing())

    def _stop_simpleaudio(self) -> None:
        if not self._simpleaudio_play_o:
            return
        self._simpleaudio_play_o.stop()
        # wait until it is really stopped, otherwise a call to
        # __is_playing_simpleaudio() might still return True:
        self._simpleaudio_play_o.wait_done()

    def _wait_done_simpleaudio(self) -> None:
        if not self._simpleaudio_play_o:
            return
        self._simpleaudio_play_o.wait_done()

    @staticmethod
    def _play_pygame() -> None:
        pygame.mixer.music.rewind()
        pygame.mixer.music.play()

    @staticmethod
    def _is_playing_pygame() -> bool:
        return pygame.mixer.music.get_busy()

    @staticmethod
    def _stop_pygame() -> None:
        pygame.mixer.music.stop()

    @staticmethod
    def _wait_done_pygame() -> None:
        while pygame.mixer.music.get_busy():
            pass

    def _play_aplay_thread_function(self) -> None:
        if not self._aplay_binary:
            return
        try:
            self._aplay_process = subprocess.Popen( # pylint: disable=consider-using-with
                'aplay', shell=False,
                stdin=subprocess.PIPE,
                stderr=subprocess.PIPE,
                stdout=subprocess.PIPE,
                encoding=None,
                errors=None,
                text=None)
        except (OSError, ValueError) as error:
            LOGGER.exception(
                'cannot start aplay process %s: %s',
                error.__class__.__name__, error)
            return
        try:
            self._aplay_process.communicate(input=self._aplay_stdin,
                                            timeout=1000)
        except subprocess.TimeoutExpired as error:
            LOGGER.exception(
                'timeout piping sound file into aplay process%s: %s',
                error.__class__.__name__, error)
            self._aplay_process.kill()
            return
        try:
            self._aplay_process.terminate()
        except Exception as error: # pylint: disable=broad-except
            LOGGER.exception(
                'cannot terminate aplay process %s: %s',
                error.__class__.__name__, error)
            try:
                LOGGER.info('Trying to kill aplay process')
                self._aplay_process.kill()
                LOGGER.info('aplay process killed')
            except Exception as error2: # pylint: disable=broad-except
                LOGGER.exception(
                    'cannot kill aplay process%s: %s',
                    error.__class__.__name__, error2)

    def _play_aplay(self) -> None:
        self._play_aplay_thread = threading.Thread(
            daemon=True,
            target=self._play_aplay_thread_function)
        self._play_aplay_thread.start()

    def _is_playing_aplay(self) -> bool:
        if not self._play_aplay_thread:
            return False
        return self._play_aplay_thread.is_alive()

    def _stop_aplay(self) -> None:
        if not self._play_aplay_thread:
            return
        if (self._play_aplay_thread.is_alive()
            and self._aplay_process
            and self._aplay_process.poll() is None):
            try:
                self._aplay_process.terminate()
            except Exception as error: # pylint: disable=broad-except
                LOGGER.exception(
                'cannot terminate aplay process %s: %s',
                error.__class__.__name__, error)
                try:
                    LOGGER.info('Trying to kill aplay process')
                    self._aplay_process.kill()
                except Exception as error2: # pylint: disable=broad-except
                    LOGGER.exception(
                        'cannot kill aplay process%s: %s',
                        error.__class__.__name__, error2)
        if self._play_aplay_thread.is_alive():
            self._play_aplay_thread.join(timeout=0.1)
            if self._play_aplay_thread.is_alive():
                LOGGER.error('timeout stopping aplay thread')

    def _wait_done_aplay(self) -> None:
        if not self._play_aplay_thread:
            return
        if self._play_aplay_thread.is_alive():
            self._play_aplay_thread.join()

    def play(self) -> None:
        '''Play the sound'''
        if not self._audio_backend:
            LOGGER.error('Could not init any audio backend %s',
                         self._requested_audio_backend)
            return
        getattr(self, f'_play_{self._audio_backend}')()

    def is_playing(self) -> bool:
        '''Check whether the sound is currently playing'''
        if not self._audio_backend:
            LOGGER.error('Could not init any audio backend %s',
                         self._requested_audio_backend)
            return False
        return bool(getattr(self, f'_is_playing_{self._audio_backend}')())

    def stop(self) -> None:
        '''Stop playing of the sound'''
        if not self._audio_backend:
            LOGGER.error('Could not init any audio backend %s',
                         self._requested_audio_backend)
            return
        getattr(self, f'_stop_{self._audio_backend}')()

    def wait_done(self) -> None:
        '''Wait until the sound has been fully played'''
        if not self._audio_backend:
            LOGGER.error('Could not init any audio backend %s',
                         self._requested_audio_backend)
            return
        getattr(self, f'_wait_done_{self._audio_backend}')()

def run_tests() -> None:
    '''Run some simple tests'''

    audio_backend = 'automatic'
    # Testing a short sound:
    sound_object = SoundObject(
        #'/home/mfabian/sounds/japanese/今回もよろしくお願いします.wav',
        '/usr/share/ibus-table/data/coin9.wav',
        #'/home/mfabian/sounds/japanese/今回もよろしくお願いします.mp3',
        audio_backend=audio_backend)
    sound_object.play()
    sound_object.wait_done()
    sound_object.play()
    sound_object.wait_done()

    # Testing stopping in between with a longer sound file:

    import time # pylint: disable=import-outside-toplevel

    sound_object = SoundObject(
        '/home/mfabian/sounds/japanese/今回もよろしくお願いします.wav',
        audio_backend=audio_backend)
    sound_object.play()
    LOGGER.info('Sleeping ...')
    time.sleep(1)
    LOGGER.info('is playing %s', sound_object.is_playing())
    sound_object.stop()
    LOGGER.info('is playing %s', sound_object.is_playing())
    time.sleep(4)

if __name__ == "__main__":
    LOG_HANDLER = logging.StreamHandler(stream=sys.stderr)
    LOGGER.setLevel(logging.DEBUG)
    LOGGER.addHandler(LOG_HANDLER)

    run_tests()
