#!/usr/bin/env python3
#
# Copyright (c) 2026 Stormux
#
# 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.

"""Persistent sound helper used to isolate theme and tone playback crashes."""

import argparse
from collections import deque
import json
import pathlib
import sys
import threading
from typing import Any, Optional

import gi

gi.require_version('GLib', '2.0')
gi.require_version('Gst', '1.0')
from gi.repository import GLib, Gst

from . import gstreamer_support
from . import settings
from . import sound_sink


def _clamp_volume(volume: Any) -> float:
    try:
        return max(0.0, float(volume))
    except Exception:
        return 1.0


def _clamp_pan(pan: Any) -> float:
    try:
        return max(-1.0, min(1.0, float(pan)))
    except Exception:
        return 0.0


def _write_json_line(payload: dict[str, Any]) -> None:
    sys.stdout.write(json.dumps(payload) + "\n")
    sys.stdout.flush()


def _report_recovery_required(message: str) -> None:
    print(f"RECOVERY REQUIRED: {message}", file=sys.stderr, flush=True)


def _create_file_player(
    playerId: str,
    soundSink: Optional[str] = None,
) -> tuple[Optional[Any], Optional[Any], Optional[str], Optional[str]]:
    player = Gst.ElementFactory.make("playbin", playerId)
    if player is None:
        return None, None, None, "Failed to create playbin for file playback"

    panorama = Gst.ElementFactory.make("audiopanorama", f"{playerId}-panorama")
    if panorama is not None:
        player.set_property("audio-filter", panorama)

    configuredSink = sound_sink.normalize_sound_sink_choice(soundSink)
    if configuredSink == settings.SOUND_SINK_AUTO:
        return player, panorama, "playbin-default", None

    audioSink, sinkName, sinkError = sound_sink.create_audio_sink(
        f"{playerId}-output",
        configuredSink,
    )
    if audioSink is None:
        return None, panorama, sinkName, sinkError or f"Failed to create audio sink for {configuredSink}"

    player.set_property("audio-sink", audioSink)
    return player, panorama, sinkName or configuredSink, None


class SoundWorker:
    """Runs a long-lived GStreamer worker for icon and tone playback."""

    def __init__(self, soundSink: Optional[str] = None) -> None:
        available = gstreamer_support.gst_init_check_available(Gst)
        if not available:
            raise RuntimeError("GStreamer is not available")

        self._loop = GLib.MainLoop()
        self._queue: deque[dict[str, Any]] = deque()
        self._currentCommand: Optional[dict[str, Any]] = None
        self._toneTimeoutId = 0

        self._filePlayer, self._filePanorama, fileSinkName, fileSinkError = _create_file_player(
            "cthulhu-sound-worker-file",
            soundSink,
        )
        if self._filePlayer is None:
            raise RuntimeError(fileSinkError or "Failed to create file playback player")

        fileBus = self._filePlayer.get_bus()
        if fileBus is None:
            raise RuntimeError("No bus available for file playback player")
        fileBus.add_signal_watch()
        fileBus.connect("message", self._onFileMessage)

        self._tonePipeline = Gst.Pipeline.new('cthulhu-sound-worker-tone')
        if self._tonePipeline is None:
            raise RuntimeError("Failed to create tone pipeline")

        self._toneSource = Gst.ElementFactory.make('audiotestsrc', 'cthulhu-sound-worker-source')
        self._toneVolume = Gst.ElementFactory.make('volume', 'cthulhu-sound-worker-volume')
        self._tonePanorama = Gst.ElementFactory.make('audiopanorama', 'cthulhu-sound-worker-panorama')
        toneSink, toneSinkName, toneSinkError = sound_sink.create_audio_sink(
            'cthulhu-sound-worker-tone-output',
            soundSink
        )
        if self._toneSource is None or toneSink is None:
            raise RuntimeError(toneSinkError or "Failed to create tone playback pipeline")

        self._toneSink = toneSink
        self._tonePipeline.add(self._toneSource)
        if self._toneVolume is not None:
            self._tonePipeline.add(self._toneVolume)
        if self._tonePanorama is not None:
            self._tonePipeline.add(self._tonePanorama)
        self._tonePipeline.add(self._toneSink)
        toneElements = [self._toneSource]
        if self._toneVolume is not None:
            toneElements.append(self._toneVolume)
        if self._tonePanorama is not None:
            toneElements.append(self._tonePanorama)
        toneElements.append(self._toneSink)
        for source, target in zip(toneElements, toneElements[1:]):
            if not source.link(target):
                raise RuntimeError("Failed to link tone playback pipeline")

        toneBus = self._tonePipeline.get_bus()
        if toneBus is None:
            raise RuntimeError("No bus available for tone pipeline")
        toneBus.add_signal_watch()
        toneBus.connect("message", self._onToneMessage)

        self._fileSinkName = fileSinkName or "unknown"
        self._toneSinkName = toneSinkName or "unknown"

    def run(self) -> int:
        self._report_worker_ready()
        readerThread = threading.Thread(target=self._read_commands, daemon=True)
        readerThread.start()
        try:
            self._loop.run()
            return 0
        finally:
            self._clear_tone_timeout()
            self._filePlayer.set_state(Gst.State.NULL)
            self._tonePipeline.set_state(Gst.State.NULL)

    def _report_worker_ready(self) -> None:
        print(
            f"Persistent sound worker ready: file={self._fileSinkName} tone={self._toneSinkName}",
            file=sys.stderr,
            flush=True,
        )

    def _read_commands(self) -> None:
        try:
            for line in sys.stdin:
                line = line.strip()
                if not line:
                    continue
                try:
                    command = json.loads(line)
                except Exception as error:
                    print(f"Invalid worker command: {error}", file=sys.stderr, flush=True)
                    continue
                GLib.idle_add(self._handle_command, command)
        finally:
            GLib.idle_add(self._quit_from_stdin_close)

    def _quit_from_stdin_close(self) -> bool:
        self._interrupt_current("stdin closed")
        self._interrupt_queued("stdin closed")
        self._loop.quit()
        return False

    def _handle_command(self, command: dict[str, Any]) -> bool:
        action = str(command.get("action", "")).strip().lower()
        requestId = command.get("id")

        if action == "ping":
            self._respond(requestId, True)
            return False

        if action == "shutdown":
            self._interrupt_current("shutdown")
            self._interrupt_queued("shutdown")
            self._respond(requestId, True)
            self._loop.quit()
            return False

        if action == "stop":
            self._interrupt_current("stopped")
            self._interrupt_queued("stopped")
            self._respond(requestId, True)
            return False

        if action not in {"play_file", "play_tone"}:
            self._respond(requestId, False, f"Unknown worker action: {action}")
            return False

        interrupt = bool(command.get("interrupt", True))
        if interrupt:
            self._interrupt_current("interrupted")
            self._interrupt_queued("interrupted")
        elif self._currentCommand is not None:
            self._queue.append(command)
            return False

        if self._currentCommand is None:
            self._start_command(command)
        else:
            self._queue.appendleft(command)

        return False

    def _start_command(self, command: dict[str, Any]) -> None:
        action = command["action"]
        if action == "play_file":
            self._start_file_command(command)
            return
        if action == "play_tone":
            self._start_tone_command(command)
            return
        self._respond(command.get("id"), False, f"Unsupported worker action: {action}")
        self._start_next_command()

    def _start_file_command(self, command: dict[str, Any]) -> None:
        soundPath = pathlib.Path(str(command.get("path", ""))).expanduser()
        if not soundPath.is_file():
            self._respond(command.get("id"), False, f"Missing sound file: {soundPath}")
            self._start_next_command()
            return

        self._clear_tone_timeout()
        self._tonePipeline.set_state(Gst.State.NULL)
        self._filePlayer.set_state(Gst.State.NULL)

        self._currentCommand = command
        self._filePlayer.set_property("uri", soundPath.resolve().as_uri())
        self._filePlayer.set_property("volume", _clamp_volume(command.get("volume", 1.0)))
        if self._filePanorama is not None:
            self._filePanorama.set_property("panorama", _clamp_pan(command.get("pan", 0.0)))
        stateChange = self._filePlayer.set_state(Gst.State.PLAYING)
        if stateChange == Gst.StateChangeReturn.FAILURE:
            self._currentCommand = None
            reason = f"Failed to start playback for {soundPath}"
            _report_recovery_required(reason)
            self._respond(command.get("id"), False, reason)
            self._start_next_command()

    def _start_tone_command(self, command: dict[str, Any]) -> None:
        self._filePlayer.set_state(Gst.State.NULL)
        self._tonePipeline.set_state(Gst.State.NULL)
        self._clear_tone_timeout()

        try:
            durationSeconds = max(0.0, float(command.get("duration", 0.0)))
            frequency = max(0, min(20000, int(command.get("frequency", 0))))
            wave = int(command.get("wave", 0))
        except Exception as error:
            self._respond(command.get("id"), False, f"Invalid tone request: {error}")
            self._start_next_command()
            return

        self._currentCommand = command
        if self._toneVolume is not None:
            self._toneVolume.set_property('volume', _clamp_volume(command.get("volume", 1.0)))
            self._toneSource.set_property('volume', 1.0)
        else:
            self._toneSource.set_property('volume', _clamp_volume(command.get("volume", 1.0)))
        if self._tonePanorama is not None:
            self._tonePanorama.set_property("panorama", _clamp_pan(command.get("pan", 0.0)))
        self._toneSource.set_property('freq', frequency)
        self._toneSource.set_property('wave', wave)

        stateChange = self._tonePipeline.set_state(Gst.State.PLAYING)
        if stateChange == Gst.StateChangeReturn.FAILURE:
            self._currentCommand = None
            reason = "Failed to start tone playback"
            _report_recovery_required(reason)
            self._respond(command.get("id"), False, reason)
            self._start_next_command()
            return

        durationMs = max(1, int(durationSeconds * 1000))
        self._toneTimeoutId = GLib.timeout_add(durationMs, self._finish_tone_playback)

    def _finish_tone_playback(self) -> bool:
        self._toneTimeoutId = 0
        self._tonePipeline.set_state(Gst.State.NULL)
        self._finish_current(True)
        return False

    def _clear_tone_timeout(self) -> None:
        if self._toneTimeoutId:
            GLib.source_remove(self._toneTimeoutId)
            self._toneTimeoutId = 0

    def _interrupt_current(self, reason: str) -> None:
        if self._currentCommand is None:
            self._filePlayer.set_state(Gst.State.NULL)
            self._tonePipeline.set_state(Gst.State.NULL)
            self._clear_tone_timeout()
            return

        current = self._currentCommand
        self._currentCommand = None
        self._filePlayer.set_state(Gst.State.NULL)
        self._tonePipeline.set_state(Gst.State.NULL)
        self._clear_tone_timeout()
        self._respond(current.get("id"), False, reason)

    def _interrupt_queued(self, reason: str) -> None:
        while self._queue:
            queued = self._queue.popleft()
            self._respond(queued.get("id"), False, reason)

    def _finish_current(self, success: bool, error: Optional[str] = None) -> None:
        current = self._currentCommand
        self._currentCommand = None
        if current is not None:
            self._respond(current.get("id"), success, error)
        self._start_next_command()

    def _start_next_command(self) -> None:
        if self._currentCommand is not None or not self._queue:
            return
        self._start_command(self._queue.popleft())

    def _respond(self, requestId: Any, success: bool, error: Optional[str] = None) -> None:
        if requestId is None:
            return

        payload: dict[str, Any] = {
            "id": requestId,
            "ok": bool(success),
        }
        if error:
            payload["error"] = str(error)
        _write_json_line(payload)

    def _onFileMessage(self, _bus: Any, message: Any) -> None:
        if self._currentCommand is None or self._currentCommand.get("action") != "play_file":
            return

        if message.type == Gst.MessageType.EOS:
            self._filePlayer.set_state(Gst.State.NULL)
            self._finish_current(True)
            return

        if message.type == Gst.MessageType.ERROR:
            self._filePlayer.set_state(Gst.State.NULL)
            error, _info = message.parse_error()
            _report_recovery_required(str(error))
            print(str(error), file=sys.stderr, flush=True)
            self._finish_current(False, str(error))

    def _onToneMessage(self, _bus: Any, message: Any) -> None:
        if self._currentCommand is None or self._currentCommand.get("action") != "play_tone":
            return

        if message.type != Gst.MessageType.ERROR:
            return

        self._tonePipeline.set_state(Gst.State.NULL)
        self._clear_tone_timeout()
        error, _info = message.parse_error()
        _report_recovery_required(str(error))
        print(str(error), file=sys.stderr, flush=True)
        self._finish_current(False, str(error))


def play_file_once(
    soundPath: str,
    timeoutSeconds: int = 10,
    soundSink: Optional[str] = None,
    volume: float = 1.0,
) -> int:
    available = gstreamer_support.gst_init_check_available(Gst)
    if not available:
        print("GStreamer is not available", file=sys.stderr)
        return 1

    player, panorama, sinkName, sinkError = _create_file_player(
        "cthulhu-sound-helper-once",
        soundSink,
    )
    if player is None:
        print(sinkError or "Failed to create one-shot file playback player", file=sys.stderr)
        return 1

    player.set_property("uri", pathlib.Path(soundPath).resolve().as_uri())
    player.set_property("volume", _clamp_volume(volume))
    if panorama is not None:
        panorama.set_property("panorama", 0.0)
    player.set_state(Gst.State.PLAYING)

    bus = player.get_bus()
    if bus is None:
        player.set_state(Gst.State.NULL)
        print("No bus available for one-shot playback", file=sys.stderr)
        return 1

    timeoutNs = int(timeoutSeconds * Gst.SECOND)
    try:
        message = bus.timed_pop_filtered(
            timeoutNs,
            Gst.MessageType.EOS | Gst.MessageType.ERROR
        )
        if message is None:
            print(
                f"Sound helper timed out after {timeoutSeconds} seconds on sink {sinkName}",
                file=sys.stderr
            )
            return 1
        if message.type == Gst.MessageType.ERROR:
            error, _info = message.parse_error()
            print(str(error), file=sys.stderr)
            return 1
        return 0
    finally:
        player.set_state(Gst.State.NULL)


def main() -> int:
    parser = argparse.ArgumentParser(add_help=False)
    parser.add_argument("--worker", action="store_true")
    parser.add_argument("--play-file")
    parser.add_argument("--sound-sink")
    parser.add_argument("--volume", type=float, default=1.0)
    parser.add_argument("--timeout-seconds", type=int, default=10)
    args, _unknown = parser.parse_known_args()

    if args.worker:
        worker = SoundWorker(args.sound_sink)
        return worker.run()

    if args.play_file:
        return play_file_once(
            args.play_file,
            args.timeout_seconds,
            args.sound_sink,
            args.volume,
        )

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
