# -*- coding: utf-8 -*-
# This file is part of Xpra.
# Copyright (C) 2010-2021 Antoine Martin <antoine@xpra.org>
# Copyright (C) 2008 Nathaniel Smith <njs@pobox.com>
# Xpra is released under the terms of the GNU GPL v2, or, at your option, any
# later version. See the file COPYING for details.

import shlex
import os.path
from subprocess import Popen, PIPE
from threading import Event
from gi.repository import GLib

from xpra.os_util import pollwait, monotonic_time, bytestostr, osexpand, OSX, POSIX
from xpra.util import typedict, envbool, csv, engs
from xpra.make_thread import start_thread
from xpra.platform import get_username
from xpra.platform.paths import get_icon_filename
from xpra.scripts.parsing import sound_option
from xpra.server.mixins.stub_server_mixin import StubServerMixin
from xpra.log import Logger

log = Logger("server")
soundlog = Logger("sound")
httplog = Logger("http")

PRIVATE_PULSEAUDIO = envbool("XPRA_PRIVATE_PULSEAUDIO", True)


"""
Mixin for servers that handle audio forwarding.
"""
class AudioServer(StubServerMixin):

    def __init__(self):
        self.audio_init_done = Event()
        self.audio_init_done.set()
        self.pulseaudio = False
        self.pulseaudio_command = None
        self.pulseaudio_configure_commands = []
        self.pulseaudio_proc = None
        self.pulseaudio_private_dir = None
        self.pulseaudio_private_socket = None
        self.sound_source_plugin = None
        self.supports_speaker = False
        self.supports_microphone = False
        self.speaker_codecs = ()
        self.microphone_codecs = ()
        self.sound_properties = typedict()
        self.av_sync = False

    def init(self, opts):
        self.sound_source_plugin = opts.sound_source
        self.supports_speaker = sound_option(opts.speaker) in ("on", "off")
        if self.supports_speaker:
            self.speaker_codecs = opts.speaker_codec
        self.supports_microphone = sound_option(opts.microphone) in ("on", "off")
        if self.supports_microphone:
            self.microphone_codecs = opts.microphone_codec
        self.pulseaudio = opts.pulseaudio
        self.pulseaudio_command = opts.pulseaudio_command
        self.pulseaudio_configure_commands = opts.pulseaudio_configure_commands
        log("AudioServer.init(..) supports speaker=%s, microphone=%s",
            self.supports_speaker, self.supports_microphone)
        self.av_sync = opts.av_sync
        log("AudioServer.init(..) av-sync=%s", self.av_sync)

    def setup(self):
        #the setup code will mostly be waiting for subprocesses to run,
        #so do it in a separate thread
        #and just wait for the results where needed:
        self.audio_init_done.clear()
        start_thread(self.do_audio_setup, "audio-setup", True)
        #we don't use threaded_setup() here because it would delay
        #all the other mixins that use it, for no good reason.

    def do_audio_setup(self):
        self.init_pulseaudio()
        self.init_sound_options()

    def cleanup(self):
        self.audio_init_done.wait(5)
        self.cleanup_pulseaudio()


    def get_info(self, _proto) -> dict:
        self.audio_init_done.wait(5)
        info = {}
        if self.pulseaudio is not False:
            info["pulseaudio"] = self.get_pulseaudio_info()
        if self.sound_properties:
            info["sound"] = self.sound_properties
        return {}


    def get_server_features(self, source) -> dict:
        d = {
            "av-sync" : {
                ""          : self.av_sync,
                "enabled"   : self.av_sync,
                },
            "sound" : {
                "ogg-latency-fix" : True,       #warning removed in v4 clients
                },
            }
        log("get_server_features(%s)=%s", source, d)
        return d


    def get_http_scripts(self) -> dict:
        return {
            "/audio.mp3" : self.http_audio_mp3_request,
            }

    def http_audio_mp3_request(self, handler):
        def err(code=500):
            handler.send_response(code)
            return None
        from xpra.net.http_handler import parse_url
        args = parse_url(handler)
        if not args:
            return err()
        httplog("http_audio_mp3_request(%s) args=%s", handler, args)
        uuid = args.get("uuid")
        if not uuid:
            httplog.warn("Warning: http-stream audio request, missing uuid")
            return err()
        source = None
        for x in self._server_sources.values():
            if x.uuid==uuid:
                source = x
                break
        if not source:
            httplog.warn("Warning: no client matching uuid '%s'", uuid)
            return err()
        #don't close the connection when handler.finish() is called,
        #we will continue to write to this socket as we process more buffers:
        finish = handler.finish
        def do_finish():
            try:
                finish()
            except Exception:
                log("error calling %s", finish, exc_info=True)
        def noop():
            pass
        handler.finish = noop
        state = {}
        def new_buffer(_sound_source, data, _metadata, packet_metadata=()):
            if state.get("failed"):
                return
            if not state.get("started"):
                httplog.warn("buffer received but stream is not started yet")
                source.stop_sending_sound()
                err()
                do_finish()
                return
            count = state.get("buffers", 0)
            httplog("new_buffer [%i] for %s sound stream: %i bytes", count, state.get("codec", "?"), len(data))
            #httplog("buffer %i: %s", count, hexstr(data))
            state["buffers"] = count+1
            try:
                for x in packet_metadata:
                    handler.wfile.write(x)
                handler.wfile.write(data)
                handler.wfile.flush()
            except Exception as e:
                state["failed"] = True
                httplog("failed to send new audio buffer", exc_info=True)
                httplog.warn("Error: failed to send audio packet:")
                httplog.warn(" %s", e)
                source.stop_sending_sound()
                do_finish()
        def new_stream(sound_source, codec):
            codec = bytestostr(codec)
            httplog("new_stream: %s", codec)
            sound_source.codec = codec
            headers = {
                "Content-type"      : "audio/mpeg",
                }
            try:
                handler.send_response(200)
                for k,v in headers.items():
                    handler.send_header(k, v)
                handler.end_headers()
            except ValueError:
                httplog("new_stream error writing headers", exc_info=True)
                state["failed"] = True
                source.stop_sending_sound()
                do_finish()
            else:
                state["started"] = True
                state["buffers"] = 0
                state["codec"] = codec
        start = monotonic_time()
        def timeout_check():
            self.http_stream_check_timers.pop(start, None)
            if not state.get("started"):
                err()
                source.stop_sending_sound()
        if source.sound_source:
            source.stop_sending_sound()
        def start_sending_sound():
            source.start_sending_sound("mp3", volume=1.0, new_stream=new_stream,
                                       new_buffer=new_buffer, skip_client_codec_check=True)
        GLib.idle_add(start_sending_sound)
        self.http_stream_check_timers[start] = (self.timeout_add(1000*5, timeout_check), source.stop_sending_sound)


    def init_pulseaudio(self):
        soundlog("init_pulseaudio() pulseaudio=%s, pulseaudio_command=%s", self.pulseaudio, self.pulseaudio_command)
        if self.pulseaudio is False:
            return
        if not self.pulseaudio_command:
            soundlog.warn("Warning: pulseaudio command is not defined")
            return
        #environment initialization:
        # 1) make sure that the sound subprocess will use the devices
        #    we define in the pulseaudio command
        #    (it is too difficult to parse the pulseaudio_command,
        #    so we just hope that it matches this):
        #    Note: speaker is the source and microphone the sink,
        #    because things are reversed on the server.
        os.environ.update({
            "XPRA_PULSE_SOURCE_DEVICE_NAME" : "Xpra-Speaker",
            "XPRA_PULSE_SINK_DEVICE_NAME"   : "Xpra-Microphone",
            })
        # 2) whitelist the env vars that pulseaudio may use:
        PA_ENV_WHITELIST = ("DBUS_SESSION_BUS_ADDRESS", "DBUS_SESSION_BUS_PID", "DBUS_SESSION_BUS_WINDOWID",
                            "DISPLAY", "HOME", "HOSTNAME", "LANG", "PATH",
                            "PWD", "SHELL", "XAUTHORITY",
                            "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE",
                            "XPRA_PULSE_SOURCE_DEVICE_NAME", "XPRA_PULSE_SINK_DEVICE_NAME",
                            )
        env = dict((k,v) for k,v in self.get_child_env().items() if k in PA_ENV_WHITELIST)
        # 3) use a private pulseaudio server, so each xpra
        #    session can have its own server,
        #    create a directory for each display:
        if PRIVATE_PULSEAUDIO and POSIX and not OSX:
            from xpra.platform.xposix.paths import _get_xpra_runtime_dir, get_runtime_dir
            rd = osexpand(get_runtime_dir())
            if not rd or not os.path.exists(rd) or not os.path.isdir(rd):
                log.warn("Warning: the runtime directory '%s' does not exist,", rd)
                log.warn(" cannot start a private pulseaudio server")
            else:
                xpra_rd = _get_xpra_runtime_dir()
                assert xpra_rd, "bug: no xpra runtime dir"
                display = os.environ.get("DISPLAY", "").lstrip(":")
                self.pulseaudio_private_dir = osexpand(os.path.join(xpra_rd, "pulse-%s" % display))
                if not os.path.exists(self.pulseaudio_private_dir):
                    os.mkdir(self.pulseaudio_private_dir, 0o700)
                env["XDG_RUNTIME_DIR"] = self.pulseaudio_private_dir
                self.pulseaudio_private_socket = os.path.join(self.pulseaudio_private_dir, "pulse", "native")
                os.environ["XPRA_PULSE_SERVER"] = self.pulseaudio_private_socket
        cmd = shlex.split(self.pulseaudio_command)
        cmd = list(osexpand(x) for x in cmd)
        #find the absolute path to the command:
        pa_cmd = cmd[0]
        if not os.path.isabs(pa_cmd):
            pa_path = None
            for x in os.environ.get("PATH", "").split(os.path.pathsep):
                t = os.path.join(x, pa_cmd)
                if os.path.exists(t):
                    pa_path = t
                    break
            if not pa_path:
                msg = "pulseaudio not started: '%s' command not found" % pa_cmd
                if self.pulseaudio is None:
                    soundlog.info(msg)
                else:
                    soundlog.warn(msg)
                self.clean_pulseaudio_private_dir()
                return
            cmd[0] = pa_cmd
        started_at = monotonic_time()
        def pulseaudio_warning():
            soundlog.warn("Warning: pulseaudio has terminated shortly after startup.")
            soundlog.warn(" pulseaudio is limited to a single instance per user account,")
            soundlog.warn(" and one may be running already for user '%s'.", get_username())
            soundlog.warn(" To avoid this warning, either fix the pulseaudio command line")
            soundlog.warn(" or use the 'pulseaudio=no' option.")
        def pulseaudio_ended(proc):
            soundlog("pulseaudio_ended(%s) pulseaudio_proc=%s, returncode=%s, closing=%s",
                     proc, self.pulseaudio_proc, proc.returncode, self._closing)
            if self.pulseaudio_proc is None or self._closing:
                #cleared by cleanup already, ignore
                return
            elapsed = monotonic_time()-started_at
            if elapsed<2:
                self.timeout_add(1000, pulseaudio_warning)
            else:
                soundlog.warn("Warning: the pulseaudio server process has terminated after %i seconds", int(elapsed))
            self.pulseaudio_proc = None
        try:
            soundlog("pulseaudio cmd=%s", " ".join(cmd))
            soundlog("pulseaudio env=%s", env)
            self.pulseaudio_proc = Popen(cmd, env=env)
        except Exception as e:
            soundlog("Popen(%s)", cmd, exc_info=True)
            soundlog.error("Error: failed to start pulseaudio:")
            soundlog.error(" %s", e)
            self.clean_pulseaudio_private_dir()
            return
        self.add_process(self.pulseaudio_proc, "pulseaudio", cmd, ignore=True, callback=pulseaudio_ended)
        if self.pulseaudio_proc:
            soundlog.info("pulseaudio server started with pid %s", self.pulseaudio_proc.pid)
            if self.pulseaudio_private_socket:
                soundlog.info(" private server socket path:")
                soundlog.info(" '%s'", self.pulseaudio_private_socket)
                os.environ["PULSE_SERVER"] = "unix:%s" % self.pulseaudio_private_socket
            def configure_pulse():
                p = self.pulseaudio_proc
                if p is None or p.poll() is not None:
                    return
                for i, x in enumerate(self.pulseaudio_configure_commands):
                    proc = Popen(x, env=env, shell=True)
                    self.add_process(proc, "pulseaudio-configure-command-%i" % i, x, ignore=True)
            self.timeout_add(2*1000, configure_pulse)

    def cleanup_pulseaudio(self):
        self.audio_init_done.wait(5)
        proc = self.pulseaudio_proc
        if not proc:
            return
        soundlog("cleanup_pa() process.poll()=%s, pid=%s", proc.poll(), proc.pid)
        if self.is_child_alive(proc):
            self.pulseaudio_proc = None
            soundlog.info("stopping pulseaudio with pid %s", proc.pid)
            try:
                #first we try pactl (required on Ubuntu):
                cmd = ["pactl", "exit"]
                proc = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
                self.add_process(proc, "pactl exit", cmd, True)
                r = pollwait(proc)
                #warning: pactl will return 0 whether it succeeds or not...
                #but we can't kill the process because Ubuntu starts a new one
                if r!=0 and self.is_child_alive(proc):
                    #fallback to using SIGINT:
                    proc.terminate()
            except Exception as e:
                soundlog.warn("cleanup_pulseaudio() error stopping %s", proc, exc_info=True)
                #only log the full stacktrace if the process failed to terminate:
                if self.is_child_alive(proc):
                    soundlog.error("Error: stopping pulseaudio: %s", e, exc_info=True)
            if self.pulseaudio_private_socket and self.is_child_alive(proc):
                #wait for the pulseaudio process to exit,
                #it will delete the socket:
                soundlog("pollwait()=%s", pollwait(proc))
        if self.pulseaudio_private_socket and not self.is_child_alive(proc):
            #wait for the socket to get cleaned up
            #(it should be removed by the pulseaudio server as it exits)
            import time
            now = monotonic_time()
            while (monotonic_time()-now)<1 and os.path.exists(self.pulseaudio_private_socket):
                time.sleep(0.1)
        self.clean_pulseaudio_private_dir()

    def clean_pulseaudio_private_dir(self):
        if self.pulseaudio_private_dir:
            if os.path.exists(self.pulseaudio_private_socket):
                log.warn("Warning: the pulseaudio private socket file still exists:")
                log.warn(" '%s'", self.pulseaudio_private_socket)
                log.warn(" the private pulseaudio directory containing it will not be removed")
            else:
                import glob
                pulse = os.path.join(self.pulseaudio_private_dir, "pulse")
                native = os.path.join(pulse, "native")
                dirs = []
                dbus_dirs = glob.glob("%s/dbus-*" % self.pulseaudio_private_dir)
                if len(dbus_dirs)==1:
                    dbus_dir = dbus_dirs[0]
                    if os.path.isdir(dbus_dir):
                        services_dir = os.path.join(dbus_dir, "services")
                        dirs.append(services_dir)
                        dirs.append(dbus_dir)
                dirs += [native, pulse, self.pulseaudio_private_dir]
                path = None
                try:
                    for d in dirs:
                        path = os.path.abspath(d)
                        soundlog("removing private directory '%s'", path)
                        if os.path.exists(path) and os.path.isdir(path):
                            os.rmdir(path)
                    log.info("removing private directory '%s'", self.pulseaudio_private_dir)
                except OSError as e:
                    soundlog("cleanup_pulseaudio() error removing '%s'", path, exc_info=True)
                    soundlog.error("Error: failed to cleanup the pulseaudio private directory")
                    soundlog.error(" '%s'", self.pulseaudio_private_dir)
                    soundlog.error(" %s", e)
                    try:
                        files = os.listdir(path)
                        if files:
                            soundlog.error(" found %i file%s in '%s':", len(files), engs(files), path)
                            for f in files:
                                soundlog.error(" - '%s'", f)
                    except OSError:
                        soundlog.error("cleanup_pulseaudio() error accessing '%s'", path, exc_info=True)


    def init_sound_options(self):
        def sound_option_or_all(*_args):
            return []
        if self.supports_speaker or self.supports_microphone:
            try:
                from xpra.sound.common import sound_option_or_all
                from xpra.sound.wrapper import query_sound
                self.sound_properties = query_sound()
                assert self.sound_properties, "query did not return any data"
                def vinfo(k):
                    val = self.sound_properties.tupleget(k)
                    assert val, "%s not found in sound properties" % bytestostr(k)
                    return ".".join(bytestostr(x) for x in val[:3])
                bits = self.sound_properties.intget("python.bits", 32)
                soundlog.info("GStreamer version %s for Python %s %i-bit",
                              vinfo("gst.version"), vinfo("python.version"), bits)
            except Exception as e:
                soundlog("failed to query sound", exc_info=True)
                soundlog.error("Error: failed to query sound subsystem:")
                soundlog.error(" %s", e)
                self.speaker_allowed = False
                self.microphone_allowed = False
        encoders = self.sound_properties.strtupleget("encoders")
        decoders = self.sound_properties.strtupleget("decoders")
        self.speaker_codecs = sound_option_or_all("speaker-codec", self.speaker_codecs, encoders)
        self.microphone_codecs = sound_option_or_all("microphone-codec", self.microphone_codecs, decoders)
        if not self.speaker_codecs:
            self.supports_speaker = False
        if not self.microphone_codecs:
            self.supports_microphone = False
        #query_pulseaudio_properties may access X11,
        #do this from the main thread:
        if bool(self.sound_properties):
            GLib.idle_add(self.query_pulseaudio_properties)
        GLib.idle_add(self.log_sound_properties)
        self.audio_init_done.set()

    def query_pulseaudio_properties(self):
        try:
            from xpra.sound.pulseaudio.pulseaudio_util import set_icon_path, get_info as get_pa_info
            pa_info = get_pa_info()
            soundlog("pulseaudio info=%s", pa_info)
            self.sound_properties.update(pa_info)
            set_icon_path(get_icon_filename("xpra.png"))
        except ImportError as e:
            if POSIX and not OSX:
                log.warn("Warning: failed to set pulseaudio tagging icon:")
                log.warn(" %s", e)

    def log_sound_properties(self):
        soundlog("init_sound_options speaker: supported=%s, encoders=%s",
                 self.supports_speaker, csv(self.speaker_codecs))
        soundlog("init_sound_options microphone: supported=%s, decoders=%s",
                 self.supports_microphone, csv(self.microphone_codecs))
        soundlog("init_sound_options sound properties=%s", self.sound_properties)


    def get_pulseaudio_info(self) -> dict:
        info = {
            "command"               : self.pulseaudio_command,
            "configure-commands"    : self.pulseaudio_configure_commands,
            }
        if self.pulseaudio_proc and self.pulseaudio_proc.poll() is None:
            info["pid"] = self.pulseaudio_proc.pid
        if self.pulseaudio_private_dir and self.pulseaudio_private_socket:
            info["private-directory"] = self.pulseaudio_private_dir
            info["private-socket"] = self.pulseaudio_private_socket
        return info


    def _process_sound_control(self, proto, packet):
        ss = self.get_server_source(proto)
        if ss:
            ss.sound_control(*packet[1:])

    def _process_sound_data(self, proto, packet):
        ss = self.get_server_source(proto)
        if ss:
            ss.sound_data(*packet[1:])


    def init_packet_handlers(self):
        if self.supports_speaker or self.supports_microphone:
            self.add_packet_handlers({
                "sound-control" : self._process_sound_control,
                "sound-data"    : self._process_sound_data,
                })
