#
# Example: Go Audio/Video Player using the LT API client
#
# Disclaimer:
#   This code is provided as part of the Enciris SDK to help developers
#   integrate and use LT devices. It is intended for demonstration purposes only
#   and comes with no warranty of any kind.
#
# Description:
#   This example demonstrates how to:
#     1. Connect to an LT310 device and retrieve audio/video data from a given source URL.
#     2. Start two independent workers:
#        - One for fetching and displaying video frames using OpenGL.
#        - One for fetching and playing audio frames using the oto audio library.
#     3. Use channels to pass packets from the workers to the player loops.
#     4. Handle packet drops when the playback loop cannot keep up with the input rate.
#     5. Gracefully stop background goroutines when the window is closed.
#
# Usage:
#   av_player.exe -source <sourceURL>
#
#   The <sourceURL> must point to a valid LT input, for example:
#     lt310:/0/hdmi-in/0
#     lt310:/0/sdi-in/1
#     lt310:/1/sdi-in/2
#
# Requirements:
#   glfw, pyopengl, numpy, pyaudio
#
# Key points:
#   - The source URL must be provided by the user; this example does not scan or select inputs automatically.
#   - Video rendering must remain in the main thread because OpenGL contexts are thread-specific.
#   - Audio and video playback are handled in separate goroutines to avoid blocking one stream with the other.
#   - The LT client API is used to create "data workers" that provide raw frames according to the selected media type.
#   - Packet metadata is provided as JSON and can be unmarshalled into the corresponding lt.VideoMetadata or lt.AudioMetadata structs.
#
# Notes:
#   - For production use, proper error handling, synchronization between audio and video streams,
#     and resource cleanup should be implemented.
#
# Warning:
#   On Ubuntu, this application should be run as admin.
#
# (c) 2025 Enciris Technologies - All rights reserved
#

import argparse
import glfw
import pyaudio
import queue
import signal
import threading
import time
from OpenGL.GL import *

import surface

if __package__:
    from ... import lt
else:
    import sys
    sys.path.append("../..")
    import lt

# Use an Event to wait cleanly (releases the GIL)
stop_event = threading.Event()

def _on_sigint(signum, frame):
    stop_event.set()

# register handler for Ctrl+C
signal.signal(signal.SIGINT, _on_sigint)

# Video and audio fifos
videoFifo = queue.Queue(1)
audioFifo = queue.Queue(1)

# Global stats (protected by stats_lock)
stats_lock = threading.Lock()
video_stats = {
    'fps': 0.0,
    'missed': 0,
    'dropped': 0,
}
audio_stats = {
    'kB_s': 0.0,
    'missed': 0,
    'dropped': 0
}

# Command line arguments
def parse_args():
    class MyParser(argparse.ArgumentParser):
        def error(self, message):
            self.print_help()
            self.exit(2, f"\nError: {message}\n")

    parser = MyParser(
        description=(
            "This example verifies that the selected input source is active,\n"
            "captures a snapshot as a JPEG image, and prints its file information."
        ),
        formatter_class=argparse.RawTextHelpFormatter,
        add_help=False
    )

    group = parser.add_argument_group("Arguments")
    group.add_argument(
        "-source", "-s",
        required=True,
        metavar="URL",
        help=(
            "Input source URL from the LT device.\n"
            "Examples:\n"
            "  lt310:/0/hdmi-in/0\n"
            "  lt310:/0/sdi-in/1"
        )
    )

    return parser.parse_args()


# Frame grabber thread
class VideoFrameGrabber(threading.Thread):
    def __init__(self, url: str, media = "video/nv12", *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._stop_event = threading.Event()
        self._exception = None
        self.url = url
        self.media = media

    def run(self):
        try:
            client = lt.Client()

            # Create a worker to capture video frames
            workerURL = ""
            try:
                client.Post(self.url+"/data", lt.JSON({'media': self.media}))
            except lt.RedirectError as e:
                workerURL = e.location
            except lt.SDKError as e:
                raise RuntimeError("server did not return a redirect")

            # Loop
            cnt = 0
            prevTS = 0
            missed = 0
            dropped = 0

            start = time.time()
            while not self._stop_event.is_set():
                # FPS counting (update stats periodically)
                duration = time.time() - start
                if duration > 1:
                    with stats_lock:
                        video_stats['fps'] = cnt / duration
                        video_stats['missed'] = missed
                        video_stats['dropped'] = dropped
                    start = time.time()
                    cnt = 0

                # Fetch worker update
                worker = client.Get(workerURL)

                # Packet data
                if not worker['packets']:
                    raise RuntimeError("packet not found")

                # Packet metadata
                packet = worker['packets'][0]
                meta = packet['meta']

                # Count missed packet
                ts = packet['timestamp']
                if prevTS != 0:
                    inc = int(round((ts - prevTS) * meta['framerate'] / 1_000_000))
                    missed += inc - 1
                prevTS = ts

                try:
                    # Send packet to display thread
                    videoFifo.put(packet, False)
                except queue.Full:
                    # Count dropped packet
                    dropped += 1

                # Loop counter
                cnt += 1

        except Exception as e:
            self._exception = e
        finally:
            self._stop_event.set()

    def stop(self):
        self._stop_event.set()

    def rethrow(self):
        if self._exception:
            raise self._exception


# Audio format : 48 kHz, 16-bit, 2 channels
# FORMAT = pyaudio.paInt16
# SAMPLE_RATE = 48000
# CHANNELS = 2
# BUFFER_SIZE = 2040

# Frame grabber thread
class AudioFrameGrabber(threading.Thread):
    def __init__(self, url: str, media = "audio/pcm", *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._stop_event = threading.Event()
        self._exception = None
        self.url = url
        self.media = media

    def run(self):
        try:
            client = lt.Client()

            # Create a worker to capture video frames
            workerURL = ""
            try:
                client.Post(self.url+"/data", lt.JSON({'media': self.media}))
            except lt.RedirectError as e:
                workerURL = e.location
            except lt.SDKError as e:
                raise RuntimeError("server did not return a redirect")

            # Loop
            cnt = 0
            prevTS = 0
            missed = 0
            dropped = 0
            totalBytes = 0

            start = time.time()
            while not self._stop_event.is_set():
                # Fetch worker update
                worker = client.Get(workerURL)

                # Packet data
                if not worker['packets']:
                    raise RuntimeError("packet not found")

                # Packet metadata
                packet = worker['packets'][0]
                meta = packet['meta']

                # Count missed packet
                ts = packet['timestamp']
                if prevTS != 0:
                    frameDuration = len(packet['data']) / (meta['samplerate'] * meta['channels'] * 2)  # 16 bits -> 2 octets
                    approxFPS = 1 / frameDuration
                    inc = int(round((ts - prevTS) * approxFPS / 1_000_000))
                    missed += inc - 1
                prevTS = ts

                # Update stats
                totalBytes += len(packet['data'])
                duration = time.time() - start
                if duration > 1:
                    with stats_lock:
                        audio_stats['kB_s'] = totalBytes / duration / 1000
                        audio_stats['missed'] = missed
                        audio_stats['dropped'] = dropped
                    totalBytes = 0
                    start = time.time()
                    cnt = 0

                try:
                    # Send packet to audio thread
                    audioFifo.put(packet, False)
                except queue.Full:
                    # Count dropped packet
                    dropped += 1

                # Loop counter
                cnt += 1

        except Exception as e:
            self._exception = e
        finally:
            self._stop_event.set()

    def stop(self):
        self._stop_event.set()

    def rethrow(self):
        if self._exception:
            raise self._exception

def playback_thread(stream):
    while True:
        data = audioFifo.get()
        stream.write(data['data'])

# Stats printer thread
class StatsPrinter(threading.Thread):
    def __init__(self, interval=1.0, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.interval = interval
        self.daemon = True

    def run(self):
        while not stop_event.is_set():
            with stats_lock:
                v = video_stats.copy()
                a = audio_stats.copy()
            print(f"Video: {v['fps']:.2f} FPS - missed {v['missed']} - dropped {v['dropped']} | "
                  f"Audio: {a['kB_s']:.1f} kB/s - missed {a['missed']} - dropped {a['dropped']}")
            time.sleep(self.interval)

def main():
    args = parse_args()
    sourceURL = args.source

    # -------------------------------------------------------------------------
    # Check if input selected is active
    # -------------------------------------------------------------------------
    client = lt.Client()
    try:
        input = client.Get(sourceURL)
        if input['video']['signal'] != 'locked':
            raise RuntimeError("input video source has no signal")
    except lt.SDKError as e:
        raise RuntimeError(f"error connecting to {sourceURL}: {e}") from e

	# Video parameters
    width, height = input['video']['size']
    interlaced = input['video']['interlaced']
    framerate = input['video']['framerate']
    print(f"Input video source: {width}x{height} {'i' if interlaced else 'p'} {framerate} Hz")

    # Audio parameters
    sample_rate = input['audio']['samplerate']
    channels = input['audio']['channels']
    print(f"Input audio source: {sample_rate} Hz {channels} channels")

    # Select media type
    videoMedia = "video/nv12"
    audioMedia = "audio/pcm"

	# -------------------------------------------------------------------------
	# Start fetch video frames thread
	# -------------------------------------------------------------------------
    video_grabber = VideoFrameGrabber(url=sourceURL, media=videoMedia)
    video_grabber.start()

	# -------------------------------------------------------------------------
    # Start fetch audio frames thread
	# -------------------------------------------------------------------------
    audio_grabber = AudioFrameGrabber(url=sourceURL, media=audioMedia)
    audio_grabber.start()

	# -------------------------------------------------------------------------
	# Audio player
	# -------------------------------------------------------------------------
    BUFFER_SIZE = 2040
    p = pyaudio.PyAudio()
    stream = p.open(
        format=pyaudio.paInt16,
        channels=channels,
        rate=sample_rate,
        output=True,
        frames_per_buffer=BUFFER_SIZE // (2 * channels)  # number of samples per buffer
    )
    # stream = p.open(
    #     format=FORMAT,
    #     channels=CHANNELS,
    #     rate=SAMPLE_RATE,
    #     output=True,
    #     frames_per_buffer=BUFFER_SIZE // (2 * CHANNELS)  # number of samples per buffer
    # )
    threading.Thread(target=playback_thread, args=(stream,), daemon=True).start()

    # -------------------------------------------------------------------------
    # Start stats printer
    # -------------------------------------------------------------------------
    stats_printer = StatsPrinter()
    stats_printer.start()

	# -------------------------------------------------------------------------
	# Video player
	# -------------------------------------------------------------------------

    # Initialize the GLFW library
    if not glfw.init():
        raise RuntimeError("Could not initialize GLFW")

    try:
        # Create a windowed mode window and its OpenGL context
        window = glfw.create_window(640, 360, "Python player", None, None)
        if not window:
            raise RuntimeError("Could not create window")

        # Make the window's context current
        glfw.make_context_current(window)

        # OpenGL surface to render to
        render = surface.NewRenderer(videoMedia)

        # Loop until the user closes the window
        while not glfw.window_should_close(window):
            try:
                # Check if an error occurred in the VideoFrameGrabber thread or AudioPlayer
                video_grabber.rethrow()
                audio_grabber.rethrow()

                # Get packet from display queue
                packet = videoFifo.get()
                # OpenGL draw buffer
                render.Draw(packet['meta']['size'][0], packet['meta']['size'][1], packet['data'])
                # Window size
                w, h = glfw.get_framebuffer_size(window)
                glViewport(0, 0, w, h)

                # Poll for and process events
                glfw.poll_events()
                # Swap front and back buffers
                glfw.swap_buffers(window)
            except KeyboardInterrupt:
                print("KeyboardInterrupt has been caught.")
                break
            except Exception as e:
                print(f"An error occurred: {e}")
                break

    except Exception as e:
        print(f"An error occurred: {e}")

    finally:
        if video_grabber is not None:
            video_grabber.stop()
            video_grabber.join()
        if audio_grabber is not None:
            audio_grabber.stop()
            audio_grabber.join()
        if stream is not None:
            stream.stop_stream()
            stream.close()
        glfw.terminate()


if __name__ == "__main__":
    main()
