import pyogg

import os.path
import warnings

from abc import abstractmethod
from ctypes import c_void_p, POINTER, c_int, pointer, cast, c_char, c_char_p, CFUNCTYPE, c_ubyte
from ctypes import memmove, create_string_buffer, byref

from pyglet.media import StreamingSource
from pyglet.media.codecs import AudioFormat, AudioData, MediaDecoder, StaticSource
from pyglet.util import debug_print, DecodeException


_debug = debug_print('Debug PyOgg codec')

if _debug:
    if not pyogg.PYOGG_OGG_AVAIL and not pyogg.PYOGG_VORBIS_AVAIL and not pyogg.PYOGG_VORBIS_FILE_AVAIL:
        warnings.warn("PyOgg determined the ogg/vorbis libraries were not available.")

    if not pyogg.PYOGG_FLAC_AVAIL:
        warnings.warn("PyOgg determined the flac library was not available.")

    if not pyogg.PYOGG_OPUS_AVAIL and not pyogg.PYOGG_OPUS_FILE_AVAIL:
        warnings.warn("PyOgg determined the opus libraries were not available.")

if not (
        pyogg.PYOGG_OGG_AVAIL and not pyogg.PYOGG_VORBIS_AVAIL and not pyogg.PYOGG_VORBIS_FILE_AVAIL) and (
        not pyogg.PYOGG_OPUS_AVAIL and not pyogg.PYOGG_OPUS_FILE_AVAIL) and not pyogg.PYOGG_FLAC_AVAIL:
    raise ImportError("PyOgg determined no supported libraries were found")

# Some monkey patching PyOgg for FLAC.
if pyogg.PYOGG_FLAC_AVAIL:
    # Original in PyOgg: FLAC__StreamDecoderEofCallback = CFUNCTYPE(FLAC__bool, POINTER(FLAC__StreamDecoder), c_void_p)
    # FLAC__bool is not valid for this return type (at least for ctypes). Needs to be an int or an error occurs.
    FLAC__StreamDecoderEofCallback = CFUNCTYPE(c_int, POINTER(pyogg.flac.FLAC__StreamDecoder), c_void_p)

    # Override explicits with c_void_p, so we can support non-seeking FLAC's (CFUNCTYPE does not accept None).
    pyogg.flac.libflac.FLAC__stream_decoder_init_stream.restype = pyogg.flac.FLAC__StreamDecoderInitStatus
    pyogg.flac.libflac.FLAC__stream_decoder_init_stream.argtypes = [POINTER(pyogg.flac.FLAC__StreamDecoder),
                                                                    pyogg.flac.FLAC__StreamDecoderReadCallback,
                                                                    c_void_p,  # Seek
                                                                    c_void_p,  # Tell
                                                                    c_void_p,  # Length
                                                                    c_void_p,  # EOF
                                                                    pyogg.flac.FLAC__StreamDecoderWriteCallback,
                                                                    pyogg.flac.FLAC__StreamDecoderMetadataCallback,
                                                                    pyogg.flac.FLAC__StreamDecoderErrorCallback,
                                                                    c_void_p]


    def metadata_callback(self, decoder, metadata, client_data):
        self.bits_per_sample = metadata.contents.data.stream_info.bits_per_sample  # missing from pyogg
        self.total_samples = metadata.contents.data.stream_info.total_samples
        self.channels = metadata.contents.data.stream_info.channels
        self.frequency = metadata.contents.data.stream_info.sample_rate


    # Monkey patch metadata callback to include bits per sample as FLAC may rarely deviate from 16 bit.
    pyogg.FlacFileStream.metadata_callback = metadata_callback


class MemoryVorbisObject:
    def __init__(self, file):
        self.file = file

        def read_func_cb(ptr, byte_size, size_to_read, datasource):
            data_size = size_to_read * byte_size
            data = self.file.read(data_size)
            read_size = len(data)
            memmove(ptr, data, read_size)
            return read_size

        def seek_func_cb(datasource, offset, whence):
            pos = self.file.seek(offset, whence)
            return pos

        def close_func_cb(datasource):
            return 0

        def tell_func_cb(datasource):
            return self.file.tell()

        self.read_func = pyogg.vorbis.read_func(read_func_cb)
        self.seek_func = pyogg.vorbis.seek_func(seek_func_cb)
        self.close_func = pyogg.vorbis.close_func(close_func_cb)
        self.tell_func = pyogg.vorbis.tell_func(tell_func_cb)

        self.callbacks = pyogg.vorbis.ov_callbacks(self.read_func, self.seek_func, self.close_func, self.tell_func)


class UnclosedVorbisFileStream(pyogg.VorbisFileStream):
    def __del__(self):
        if self.exists:
            pyogg.vorbis.ov_clear(byref(self.vf))
        self.exists = False

    def clean_up(self):
        """PyOgg calls clean_up on end of data. We may want to loop a sound or replay. Prevent this.
        Rely on GC (__del__) to clean up objects instead.
        """
        return


class UnclosedOpusFileStream(pyogg.OpusFileStream):
    def __del__(self):
        self.ptr.contents.value = self.ptr_init

        del self.ptr

        if self.of:
            pyogg.opus.op_free(self.of)

    def clean_up(self):
        pass


class MemoryOpusObject:
    def __init__(self, filename, file):
        self.file = file
        self.filename = filename

        def read_func_cb(stream, buffer, size):
            data = self.file.read(size)
            read_size = len(data)
            memmove(buffer, data, read_size)
            return read_size

        def seek_func_cb(stream, offset, whence):
            self.file.seek(offset, whence)
            return 0

        def tell_func_cb(stream):
            pos = self.file.tell()
            return pos

        def close_func_cb(stream):
            return 0

        self.read_func = pyogg.opus.op_read_func(read_func_cb)
        self.seek_func = pyogg.opus.op_seek_func(seek_func_cb)
        self.tell_func = pyogg.opus.op_tell_func(tell_func_cb)
        self.close_func = pyogg.opus.op_close_func(close_func_cb)

        self.callbacks = pyogg.opus.OpusFileCallbacks(self.read_func, self.seek_func, self.tell_func, self.close_func)


class MemoryOpusFileStream(UnclosedOpusFileStream):
    def __init__(self, filename, file):
        self.file = file

        self.memory_object = MemoryOpusObject(filename, file)

        self._dummy_fileobj = c_void_p()

        error = c_int()

        self.read_buffer = create_string_buffer(pyogg.PYOGG_STREAM_BUFFER_SIZE)

        self.ptr_buffer = cast(self.read_buffer, POINTER(c_ubyte))

        self.of = pyogg.opus.op_open_callbacks(
            self._dummy_fileobj,
            byref(self.memory_object.callbacks),
            self.ptr_buffer,
            0,  # Start length
            byref(error)
        )

        if error.value != 0:
            raise DecodeException(
                "file-like object: {} couldn't be processed. Error code : {}".format(filename, error.value))

        self.channels = pyogg.opus.op_channel_count(self.of, -1)

        self.pcm_size = pyogg.opus.op_pcm_total(self.of, -1)

        self.frequency = 48000

        self.bfarr_t = pyogg.opus.opus_int16 * (pyogg.PYOGG_STREAM_BUFFER_SIZE * self.channels * 2)

        self.buffer = cast(pointer(self.bfarr_t()), pyogg.opus.opus_int16_p)

        self.ptr = cast(pointer(self.buffer), POINTER(c_void_p))

        self.ptr_init = self.ptr.contents.value


class MemoryVorbisFileStream(UnclosedVorbisFileStream):
    def __init__(self, path, file):
        buff = create_string_buffer(pyogg.PYOGG_STREAM_BUFFER_SIZE)

        self.vf = pyogg.vorbis.OggVorbis_File()
        self.memory_object = MemoryVorbisObject(file)

        error = pyogg.vorbis.libvorbisfile.ov_open_callbacks(buff, self.vf, None, 0, self.memory_object.callbacks)
        if error != 0:
            raise DecodeException("file couldn't be opened or doesn't exist. Error code : {}".format(error))

        info = pyogg.vorbis.ov_info(byref(self.vf), -1)

        self.channels = info.contents.channels

        self.frequency = info.contents.rate

        array = (c_char * (pyogg.PYOGG_STREAM_BUFFER_SIZE * self.channels))()

        self.buffer_ = cast(pointer(array), c_char_p)

        self.bitstream = c_int()
        self.bitstream_pointer = pointer(self.bitstream)

        self.exists = True


class UnclosedFLACFileStream(pyogg.FlacFileStream):
    def __init__(self, *args, **kw):
        super().__init__(*args, **kw)
        self.seekable = True

    def __del__(self):
        if self.decoder:
            pyogg.flac.FLAC__stream_decoder_finish(self.decoder)


class MemoryFLACFileStream(UnclosedFLACFileStream):
    def __init__(self, path, file):
        self.file = file

        self.file_size = 0

        if getattr(self.file, 'seek', None) and getattr(self.file, 'tell', None):
            self.seekable = True
            self.file.seek(0, 2)
            self.file_size = self.file.tell()
            self.file.seek(0)
        else:
            warnings.warn(f"Warning: {file} file object is not seekable.")
            self.seekable = False

        self.decoder = pyogg.flac.FLAC__stream_decoder_new()

        self.client_data = c_void_p()

        self.channels = None

        self.frequency = None

        self.total_samples = None

        self.buffer = None

        self.bytes_written = None

        self.write_callback_ = pyogg.flac.FLAC__StreamDecoderWriteCallback(self.write_callback)
        self.metadata_callback_ = pyogg.flac.FLAC__StreamDecoderMetadataCallback(self.metadata_callback)
        self.error_callback_ = pyogg.flac.FLAC__StreamDecoderErrorCallback(self.error_callback)
        self.read_callback_ = pyogg.flac.FLAC__StreamDecoderReadCallback(self.read_callback)

        if self.seekable:
            self.seek_callback_ = pyogg.flac.FLAC__StreamDecoderSeekCallback(self.seek_callback)
            self.tell_callback_ = pyogg.flac.FLAC__StreamDecoderTellCallback(self.tell_callback)
            self.length_callback_ = pyogg.flac.FLAC__StreamDecoderLengthCallback(self.length_callback)
            self.eof_callback_ = FLAC__StreamDecoderEofCallback(self.eof_callback)
        else:
            self.seek_callback_ = None
            self.tell_callback_ = None
            self.length_callback_ = None
            self.eof_callback_ = None

        init_status = pyogg.flac.libflac.FLAC__stream_decoder_init_stream(
            self.decoder,
            self.read_callback_,
            self.seek_callback_,
            self.tell_callback_,
            self.length_callback_,
            self.eof_callback_,
            self.write_callback_,
            self.metadata_callback_,
            self.error_callback_,
            self.client_data
        )

        if init_status:  # error
            raise DecodeException("An error occurred when trying to open '{}': {}".format(
                path, pyogg.flac.FLAC__StreamDecoderInitStatusEnum[init_status]))

        metadata_status = pyogg.flac.FLAC__stream_decoder_process_until_end_of_metadata(self.decoder)
        if not metadata_status:  # error
            raise DecodeException("An error occurred when trying to decode the metadata of {}".format(path))

    def read_callback(self, decoder, buffer, size, data):
        chunk = size.contents.value
        data = self.file.read(chunk)
        read_size = len(data)
        memmove(buffer, data, read_size)

        size.contents.value = read_size

        if read_size > 0:
            return 0  # FLAC__STREAM_DECODER_READ_STATUS_CONTINUE
        elif read_size == 0:
            return 1  # FLAC__STREAM_DECODER_READ_STATUS_END_OF_STREAM
        else:
            return 2  # FLAC__STREAM_DECODER_READ_STATUS_ABORT

    def seek_callback(self, decoder, offset, data):
        pos = self.file.seek(offset, 0)
        if pos < 0:
            return 1  # FLAC__STREAM_DECODER_SEEK_STATUS_ERROR
        else:
            return 0  # FLAC__STREAM_DECODER_SEEK_STATUS_OK

    def tell_callback(self, decoder, offset, data):
        """Decoder wants to know the current position of the file stream."""
        pos = self.file.tell()
        if pos < 0:
            return 1  # FLAC__STREAM_DECODER_TELL_STATUS_ERROR
        else:
            offset.contents.value = pos
            return 0  # FLAC__STREAM_DECODER_TELL_STATUS_OK

    def length_callback(self, decoder, length, data):
        """Decoder wants to know the total length of the stream."""
        if self.file_size == 0:
            return 1  # FLAC__STREAM_DECODER_LENGTH_STATUS_ERROR
        else:
            length.contents.value = self.file_size
            return 0  # FLAC__STREAM_DECODER_LENGTH_STATUS_OK

    def eof_callback(self, decoder, data):
        return self.file.tell() >= self.file_size


class PyOggSource(StreamingSource):
    def __init__(self, filename, file):
        self.filename = filename
        self.file = file
        self._stream = None
        self.sample_size = 16

        self._load_source()

        self.audio_format = AudioFormat(channels=self._stream.channels, sample_size=self.sample_size,
                                        sample_rate=self._stream.frequency)

    @abstractmethod
    def _load_source(self):
        pass

    def get_audio_data(self, num_bytes, compensation_time=0.0):
        """Data returns as c_short_array instead of LP_c_char or c_ubyte, cast each buffer."""
        data = self._stream.get_buffer()  # Returns buffer, length or None
        if data is not None:
            buff, length = data
            buff_char_p = cast(buff, POINTER(c_char))
            return AudioData(buff_char_p[:length], length, 1000, 1000, [])

        return None

    def __del__(self):
        if self._stream:
            del self._stream


class PyOggFLACSource(PyOggSource):

    def _load_source(self):
        if self.file:
            self._stream = MemoryFLACFileStream(self.filename, self.file)
        else:
            self._stream = UnclosedFLACFileStream(self.filename)

        self.sample_size = self._stream.bits_per_sample
        self._duration = self._stream.total_samples / self._stream.frequency

        # Unknown amount of samples. May occur in some sources.
        if self._stream.total_samples == 0:
            if _debug:
                warnings.warn(f"Unknown amount of samples found in {self.filename}. Seeking may be limited.")
            self._duration_per_frame = 0
        else:
            self._duration_per_frame = self._duration / self._stream.total_samples

    def seek(self, timestamp):
        if self._stream.seekable:
            # Convert sample to seconds.
            if self._duration_per_frame:
                timestamp = max(0.0, min(timestamp, self._duration))
                position = int(timestamp / self._duration_per_frame)
            else:  # If we have no duration, we cannot reliably seek. However, 0.0 is still required to play and loop.
                position = 0
            seek_succeeded = pyogg.flac.FLAC__stream_decoder_seek_absolute(self._stream.decoder, position)
            if seek_succeeded is False:
                warnings.warn(f"Failed to seek FLAC file: {self.filename}")
        else:
            warnings.warn(f"Stream is not seekable for FLAC file: {self.filename}.")


class PyOggVorbisSource(PyOggSource):

    def _load_source(self):
        if self.file:
            self._stream = MemoryVorbisFileStream(self.filename, self.file)
        else:
            self._stream = UnclosedVorbisFileStream(self.filename)

        self._duration = pyogg.vorbis.libvorbisfile.ov_time_total(byref(self._stream.vf), -1)

    def get_audio_data(self, num_bytes, compensation_time=0.0):
        data = self._stream.get_buffer()  # Returns buffer, length or None

        if data is not None:
            return AudioData(*data, 1000, 1000, [])

        return None

    def seek(self, timestamp):
        seek_succeeded = pyogg.vorbis.ov_time_seek(self._stream.vf, timestamp)
        if seek_succeeded != 0:
            if _debug:
                warnings.warn(f"Failed to seek file {self.filename} - {seek_succeeded}")


class PyOggOpusSource(PyOggSource):
    def _load_source(self):
        if self.file:
            self._stream = MemoryOpusFileStream(self.filename, self.file)
        else:
            self._stream = UnclosedOpusFileStream(self.filename)

        self._duration = self._stream.pcm_size / self._stream.frequency
        self._duration_per_frame = self._duration / self._stream.pcm_size

    def seek(self, timestamp):
        timestamp = max(0.0, min(timestamp, self._duration))
        position = int(timestamp / self._duration_per_frame)
        error = pyogg.opus.op_pcm_seek(self._stream.of, position)
        if error:
            warnings.warn(f"Opus stream could not seek properly {error}.")


class PyOggDecoder(MediaDecoder):
    vorbis_exts = ('.ogg',) if pyogg.PYOGG_OGG_AVAIL and pyogg.PYOGG_VORBIS_AVAIL and pyogg.PYOGG_VORBIS_FILE_AVAIL else ()
    flac_exts = ('.flac',) if pyogg.PYOGG_FLAC_AVAIL else ()
    opus_exts = ('.opus',) if pyogg.PYOGG_OPUS_AVAIL and pyogg.PYOGG_OPUS_FILE_AVAIL else ()
    exts = vorbis_exts + flac_exts + opus_exts

    def get_file_extensions(self):
        return PyOggDecoder.exts

    def decode(self, filename, file, streaming=True):
        name, ext = os.path.splitext(filename)
        if ext in PyOggDecoder.vorbis_exts:
            source = PyOggVorbisSource
        elif ext in PyOggDecoder.flac_exts:
            source = PyOggFLACSource
        elif ext in PyOggDecoder.opus_exts:
            source = PyOggOpusSource
        else:
            raise DecodeException("Decoder could not find a suitable source to use with this filetype.")

        if streaming:
            return source(filename, file)
        else:
            return StaticSource(source(filename, file))


def get_decoders():
    return [PyOggDecoder()]


def get_encoders():
    return []
