# This file is part of Xpra.
# Copyright (C) 2019-2022 Antoine Martin <antoine@xpra.org>
# 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 os
import curses
import signal
import traceback
from time import monotonic
from subprocess import Popen, PIPE, DEVNULL
from datetime import datetime, timedelta

from xpra.version_util import caps_to_version, full_version_str
from xpra.util import u, noerr, typedict, std, envint, csv
from xpra.os_util import (
    platform_name, get_machine_id,
    bytestostr, strtobytes,
    POSIX, SIGNAMES,
    )
from xpra.exit_codes import EXIT_STR
from xpra.make_thread import start_thread
from xpra.client.gobject_client_base import InfoTimerClient
from xpra.platform.dotxpra import DotXpra
from xpra.platform.paths import get_nodock_command
from xpra.simple_stats import std_unit
from xpra.common import GRAVITY_STR
from xpra.log import Logger

log = Logger("gobject", "client")

REFRESH_RATE = envint("XPRA_REFRESH_RATE", 1)
CURSES_LOG = os.environ.get("XPRA_CURSES_LOG")

WHITE = 0
GREEN = 1
YELLOW = 2
RED = 3

EXIT_KEYS = (ord("q"), ord("Q"))
PAUSE_KEYS = (ord("p"), ord("P"))
SIGNAL_KEYS = {
    3 : signal.SIGINT,
    26 : signal.SIGSTOP,
    }


def get_title():
    return f"Xpra top {full_version_str()}"

def curses_init():
    stdscr = curses.initscr()
    stdscr.keypad(True)
    stdscr.nodelay(True)
    stdscr.clear()
    curses.noecho()
    curses.raw()
    curses.start_color()
    curses.use_default_colors()
    #for i in range(0, curses.COLORS):
    #    curses.init_pair(i+1, i, -1)
    curses.init_pair(WHITE, curses.COLOR_WHITE, curses.COLOR_BLACK)
    curses.init_pair(GREEN, curses.COLOR_GREEN, curses.COLOR_BLACK)
    curses.init_pair(YELLOW, curses.COLOR_YELLOW, curses.COLOR_BLACK)
    curses.init_pair(RED, curses.COLOR_RED, curses.COLOR_BLACK)
    return stdscr

def curses_clean(stdscr):
    if not stdscr:
        return
    stdscr.keypad(False)
    curses.nocbreak()
    curses.echo()
    curses.endwin()

def curses_err(stdscr, e):
    if CURSES_LOG:
        with open(CURSES_LOG, "ab") as f:
            f.write(b"%s\n" % e)
            f.write(traceback.format_exc().encode())
        return
    stdscr.addstr(0, 0, str(e))
    for i, l in enumerate(traceback.format_exc().split("\n")):
        try:
            stdscr.addstr(i+1, 0, l)
        except Exception:
            pass

def box(stdscr, x, y, w, h, ul, ur, ll, lr):
    stdscr.hline(y, x, curses.ACS_HLINE, w-1)               #@UndefinedVariable
    stdscr.hline(y + h - 1, x, curses.ACS_HLINE, w - 1)     #@UndefinedVariable
    stdscr.vline(y, x, curses.ACS_VLINE, h)                 #@UndefinedVariable
    stdscr.vline(y, x + w -1, curses.ACS_VLINE, h)          #@UndefinedVariable
    stdscr.addch(y, x, ul)
    stdscr.addch(y, x + w - 1, ur)
    stdscr.addch(y + h - 1, x, ll)
    stdscr.addch(y + h - 1, x + w - 1, lr)


class TopClient:

    def __init__(self, opts):
        self.stdscr = None
        self.socket_dirs = opts.socket_dirs
        self.socket_dir = opts.socket_dir
        self.position = 0
        self.selected_session = None
        self.message = None
        self.exit_code = None
        self.dotxpra = DotXpra(self.socket_dir, self.socket_dirs)
        self.last_getch = 0
        self.psprocess = {}

    def run(self):
        self.setup()
        for signum in (signal.SIGINT, signal.SIGTERM):
            signal.signal(signum, self.signal_handler)
        self.update_loop()
        self.cleanup()
        return self.exit_code

    def signal_handler(self, signum, *_args):
        self.exit_code = 128+signum

    def setup(self):
        self.stdscr = curses_init()
        curses.cbreak()

    def cleanup(self):
        scr = self.stdscr
        if scr:
            curses.nocbreak()
            scr.erase()
            curses_clean(scr)
            self.stdscr = None

    def update_loop(self):
        while self.exit_code is None:
            self.update_screen()
            elapsed = int(1000*monotonic()-self.last_getch)
            delay = max(100, min(1000, 1000-elapsed))//100
            curses.halfdelay(delay)
            try:
                v = self.stdscr.getch()
            except Exception:
                v = -1
            self.last_getch = int(1000*monotonic())
            if v in EXIT_KEYS:
                self.exit_code = 0
            if v in SIGNAL_KEYS:
                self.exit_code = 128+SIGNAL_KEYS[v]
            if v==258:    #down arrow
                self.position += 1
            elif v==259:    #up arrow
                self.position = max(self.position-1, 0)
            elif v==10 and self.selected_session:
                self.show_selected_session()
            elif v in (ord("s"), ord("S")):
                self.run_subcommand("stop")
            elif v in (ord("a"), ord("A")):
                self.run_subcommand("attach")
            elif v in (ord("d"), ord("D")):
                self.run_subcommand("detach")

    def show_selected_session(self):
        #show this session:
        try:
            self.cleanup()
            env = os.environ.copy()
            #we only deal with local sessions, should be fast:
            env["XPRA_CONNECT_TIMEOUT"] = "3"
            proc = self.do_run_subcommand("top", env=env)
            if not proc:
                self.message = monotonic(), "failed to execute subprocess", curses.color_pair(RED)
                return
            exit_code = proc.wait()
            txt = "top subprocess terminated"
            attr = 0
            if exit_code!=0:
                attr = curses.color_pair(RED)
                txt += f" with error code {exit_code}"
                if exit_code in EXIT_STR:
                    estr = EXIT_STR.get(exit_code, "").replace("_", " ")
                    txt += f" ({estr})"
                elif (exit_code-128) in SIGNAMES:   #pylint: disable=superfluous-parens
                    txt += f" ({SIGNAMES[exit_code-128]})"
            self.message = monotonic(), txt, attr
        finally:
            self.setup()

    def run_subcommand(self, subcommand):
        return self.do_run_subcommand(subcommand, stdout=DEVNULL, stderr=DEVNULL)

    def do_run_subcommand(self, subcommand, **kwargs):
        cmd = get_nodock_command()+[subcommand, self.selected_session]
        try:
            return Popen(cmd, **kwargs)
        except Exception:
            return None


    def update_screen(self):
        self.stdscr.erase()
        try:
            self.do_update_screen()
        finally:
            self.stdscr.refresh()
        return True

    def do_update_screen(self):
        #c = self.stdscr.getch()
        #if c==curses.KEY_RESIZE:
        height, width = self.stdscr.getmaxyx()
        #log.info("update_screen() %ix%i", height, width)
        title = get_title()
        x = max(0, width//2-len(title)//2)
        try:
            hpos = 0
            self.stdscr.addstr(hpos, x, title, curses.A_BOLD)
            hpos += 1
            if height<=hpos:
                return
            sd = self.dotxpra.socket_details()
            #group them by display instead of socket dir:
            displays = {}
            for sessions in sd.values():
                for state, display, path in sessions:
                    displays.setdefault(display, []).append((state, path))
            self.stdscr.addstr(hpos, 0, f"found {len(displays)} displays:")
            self.position = min(len(displays), self.position)
            self.selected_session = None
            hpos += 1
            if height<=hpos:
                return
            if self.message:
                ts, txt, attr = self.message
                if monotonic()-ts<10:
                    self.stdscr.addstr(hpos, 0, txt, attr)
                    hpos += 1
                    if height<=hpos:
                        return
                else:
                    self.message = None
            n = len(displays)
            for i, (display, state_paths) in enumerate(displays.items()):
                if height<=hpos:
                    return
                info = self.get_display_info(display, state_paths)
                l = len(info)
                if height<=hpos+l+2:
                    break
                self.box(1, hpos, width-2, l+2, open_top=i>0, open_bottom=i<n-1)
                hpos += 1
                if i==self.position:
                    self.selected_session = display
                    attr = curses.A_REVERSE
                else:
                    attr = 0
                for s in info:
                    if len(s)>=width-4:
                        s = s[:width-6]+".."
                    s = s.ljust(width-4)
                    self.stdscr.addstr(hpos, 2, s, attr)
                    hpos += 1
        except Exception as e:
            curses_err(self.stdscr, e)

    def get_display_info(self, display, state_paths):
        info = [display]
        valid_path = None
        for state, path in state_paths:
            sinfo = "%50s : %s" % (path, state)
            if POSIX:
                # pylint: disable=import-outside-toplevel
                from pwd import getpwuid
                from grp import getgrgid
                try:
                    stat = os.stat(path)
                    #if stat.st_uid!=os.getuid():
                    sinfo += "  uid=%s" % getpwuid(stat.st_uid).pw_name
                    #if stat.st_gid!=os.getgid():
                    sinfo += "  gid=%s" % getgrgid(stat.st_gid).gr_name
                except Exception as e:
                    sinfo += f"(stat error: {e})"
            info.append(sinfo)
            if state==DotXpra.LIVE:
                valid_path = path
        if valid_path:
            d = self.get_display_id_info(valid_path)
            name = d.get("session-name")
            uuid = d.get("uuid")
            stype = d.get("session-type")
            error = d.get("error")
            if error:
                info[0] = f"{display}  {error}"
            else:
                info[0] = f"{display}  {name}"
                info.insert(1, f"uuid={uuid}, type={stype}")
            machine_id = d.get("machine-id")
            if machine_id is None or machine_id==get_machine_id():
                try:
                    pid = int(d.get("pid"))
                except (ValueError, TypeError):
                    pass
                else:
                    try:
                        process = self.psprocess.get(pid)
                        if not process:
                            import psutil  #@UnresolvedImport pylint: disable=import-outside-toplevel
                            process = psutil.Process(pid)
                            self.psprocess[pid] = process
                        else:
                            cpu = process.cpu_percent()
                            info[0] += ", %3i%% CPU" % (cpu)
                    except Exception:
                        pass
        return info

    def get_display_id_info(self, path):
        d = {}
        try:
            cmd = get_nodock_command()+["id", f"socket://{path}"]
            proc = Popen(cmd, stdout=PIPE, stderr=PIPE)
            out, err = proc.communicate()
            for line in bytestostr(out or err).splitlines():
                try:
                    k,v = line.split("=", 1)
                    d[k] = v
                except ValueError:
                    continue
            return d
        except Exception as e:
            d["error"] = str(e)
        return d


    def box(self, x, y, w, h, open_top=False, open_bottom=False):
        if open_top:
            ul = curses.ACS_LTEE        #@UndefinedVariable
            ur = curses.ACS_RTEE        #@UndefinedVariable
        else:
            ul = curses.ACS_ULCORNER    #@UndefinedVariable
            ur = curses.ACS_URCORNER    #@UndefinedVariable
        if open_bottom:
            ll = curses.ACS_LTEE        #@UndefinedVariable
            lr = curses.ACS_RTEE        #@UndefinedVariable
        else:
            ll = curses.ACS_LLCORNER    #@UndefinedVariable
            lr = curses.ACS_LRCORNER    #@UndefinedVariable
        box(self.stdscr, x, y, w, h, ul, ur, ll, lr)


class TopSessionClient(InfoTimerClient):

    def __init__(self, *args):
        super().__init__(*args)
        self.log_file = None
        if CURSES_LOG:
            self.log_file = open(CURSES_LOG, "ab")
        self.paused = False
        self.stdscr = None
        self.modified = False
        self.psprocess = {}
        start_thread(self.input_thread, "input-thread", daemon=True)

    def client_type(self):
        return "top"

    def server_connection_established(self, caps):
        self.log(f"server_connection_established({caps!r})")
        self.log("traceback: " + str(traceback.extract_stack()))
        self.setup()
        self.update_screen()
        return super().server_connection_established(caps)

    def setup(self):
        if self.stdscr is None:
            self.stdscr = curses_init()
        try:
            curses.cbreak()
            curses.halfdelay(10)
        except Exception as e:
            self.log(f"failed to configure curses: {e}")

    def cleanup(self):
        super().cleanup()
        curses_clean(self.stdscr)
        self.stdscr = None
        self.close_log()

    def close_log(self):
        log_file = self.log_file
        if log_file:
            self.log("closing log")
            self.log_file = None
            log_file.close()

    def log(self, message):
        lf = self.log_file
        if lf:
            now = datetime.now()
            #we log from multiple threads,
            #so the file may have been closed
            #by the time we get here:
            noerr(lf.write, (now.strftime("%Y/%m/%d %H:%M:%S.%f")+" "+message+"\n").encode())
            noerr(lf.flush)

    def err(self, e):
        lf = self.log_file
        if lf:
            noerr(lf.write, b"%s\n" % e)
            noerr(lf.write(traceback.format_exc().encode()))
        else:
            curses_err(self.stdscr, e)


    def dictwarn(self, msg, *args):
        try:
            self.log(msg % (args,))
        except Exception as e:
            self.log(f"error logging message: {e}")
    def td(self, d):
        d = typedict(d)
        #override warning method so we don't corrupt the curses output
        d.warn = self.dictwarn
        return d

    def update_screen(self):
        self.modified = True

    def input_thread(self):
        self.log(f"input thread: signal handlers={signal.getsignal(signal.SIGINT)}")
        while self.exit_code is None:
            if not self.paused and self.modified:
                self.stdscr.erase()
                try:
                    self.do_update_screen()
                except Exception as e:
                    self.err(e)
                finally:
                    self.stdscr.refresh()
                    self.modified = False
            try:
                curses.halfdelay(10)
                v = self.stdscr.getch()
            except Exception as e:
                self.log(f"getch() {e}")
                v = -1
            self.log(f"getch()={v}")
            if v==-1:
                continue
            if v in EXIT_KEYS:
                self.log(f"exit on key {v!r}")
                self.quit(0)
                break
            if v in SIGNAL_KEYS:
                self.log(f"exit on signal key {v!r}")
                self.quit(128+SIGNAL_KEYS[v])
                break
            if v in PAUSE_KEYS:
                self.paused = not self.paused

    def do_update_screen(self):
        self.log("do_update_screen()")
        #c = self.stdscr.getch()
        #if c==curses.KEY_RESIZE:
        height, width = self.stdscr.getmaxyx()
        #log.info("update_screen() %ix%i", height, width)
        title = get_title()
        sli = self.server_last_info
        def _addstr(pad, y, x, s, *args):
            if len(s)+x>=width-pad:
                s = s[:max(0, width-x-2-pad)]+".."
            self.stdscr.addstr(y, x, s, *args)
        def addstr_main(y, x, s, *args):
            _addstr(0, y, x, s, *args)
        def addstr_box(y, x, s, *args):
            _addstr(2, y, x, s, *args)
        try:
            x = max(0, width//2-len(title)//2)
            addstr_main(0, x, title, curses.A_BOLD)
            if height<=1:
                return
            server_info = self.slidictget("server")
            build = self.slidictget("server", "build")
            vstr = caps_to_version(build)
            mode = server_info.strget("mode", "server")
            python_info = self.td(server_info.dictget("python", {}))
            bits = python_info.intget("bits", 0)
            bitsstr = "" if bits==0 else f" {bits}-bit"
            server_str = f"Xpra {mode} server version {vstr}{bitsstr}"
            proxy_info = self.slidictget("proxy")
            if proxy_info:
                proxy_platform_info = self.td(proxy_info.dictget("platform", {}))
                proxy_platform = proxy_platform_info.strget("")
                proxy_release = proxy_platform_info.strget("release")
                proxy_build_info = self.td(proxy_info.dictget("build", {}))
                proxy_version = proxy_build_info.strget("version")
                proxy_distro = proxy_info.strget("linux_distribution")
                server_str += " via: %s proxy version %s" % (
                    platform_name(proxy_platform, proxy_distro or proxy_release),
                    std(proxy_version or "unknown")
                    )
            addstr_main(1, 0, server_str)
            if height<=2:
                return
            #load and uptime:
            now = datetime.now()
            uptime = ""
            elapsed_time = server_info.intget("elapsed_time")
            if elapsed_time:
                td = timedelta(seconds=elapsed_time)
                uptime = " up %s" % str(td).lstrip("0:")
            clients_info = self.slidictget("clients")
            nclients = clients_info.intget("")
            load_average = ""
            load = sli.inttupleget("load")
            if load and len(load)==3:
                float_load = tuple(v/1000.0 for v in load)
                load_average = ", load average: %1.2f, %1.2f, %1.2f" % float_load
            addstr_main(2, 0, "xpra top - %s%s, %2i users%s" % (
                               now.strftime("%H:%M:%S"), uptime, nclients, load_average))
            if height<=3:
                return
            thread_info = self.slidictget("threads")
            rinfo = "%i threads" % thread_info.intget("count")
            server_pid = server_info.intget("pid", 0)
            if server_pid:
                rinfo += ", pid %i" % server_pid
                machine_id = server_info.get("machine-id")
                if machine_id is None or machine_id==get_machine_id():
                    try:
                        process = self.psprocess.get(server_pid)
                        if not process:
                            import psutil  #@UnresolvedImport
                            process = psutil.Process(server_pid)
                            self.psprocess[server_pid] = process
                        else:
                            cpu = process.cpu_percent()
                            rinfo += ", %3i%% CPU" % (cpu)
                    except Exception:
                        pass
            cpuinfo = self.slidictget("cpuinfo")
            if cpuinfo:
                rinfo += ", %s" % cpuinfo.strget("hz_actual")
            elapsed = monotonic()-self.server_last_info_time
            color = WHITE
            if self.server_last_info_time==0:
                rinfo += " - no server data"
            elif elapsed>2:
                rinfo += " - last updated %i seconds ago" % elapsed
                color = RED
            addstr_main(3, 0, rinfo, curses.color_pair(color))
            if height<=4:
                return
            #display:
            dinfo = []
            server = self.slidictget("server")
            rws = server.intpair("root_window_size", None)
            display_info = self.slidictget("display")
            if rws:
                sinfo = "%ix%i" % (rws[0], rws[1])
                depth = display_info.intget("depth")
                if depth>0:
                    sinfo += " %i-bit" % depth
                sinfo += " display"
                mds = server.intpair("max_desktop_size")
                if mds:
                    sinfo += " (max %ix%i)" % (mds[0], mds[1])
                dinfo.append(sinfo)
            cursor_info = self.slidictget("cursor")
            if cursor_info:
                cx, cy = cursor_info.inttupleget("position", (0, 0))
                dinfo.append("cursor at %ix%i" % (cx, cy))
            pid = display_info.intget("pid")
            if pid:
                dinfo.append("pid %i" % pid)
            addstr_main(4, 0, csv(dinfo))
            if height<=5:
                return
            hpos = 5
            gl_info = self.get_gl_info(display_info.dictget("opengl"))
            if gl_info:
                addstr_main(5, 0, gl_info)
                hpos += 1

            #filter clients, only show GUI clients:
            client_info = self.slidictget("client")
            gui_clients = []
            nclients = 0
            while True:
                ci = client_info.dictget(nclients)
                if not ci:
                    break
                ci = self.td(ci)
                session_id = ci.strget("session-id")
                if session_id!=self.session_id and ci.boolget("windows", True) and ci.strget("type")!="top":
                    gui_clients.append(nclients)
                nclients += 1

            ngui = 0
            if hpos<height-3:
                hpos += 1
                if nclients==0:
                    clients_str = "no clients connected"
                else:
                    ngui = len(gui_clients)
                    clients_str = f"{nclients} clients connected, "
                    if ngui==0:
                        clients_str += "no gui clients"
                    else:
                        clients_str += f"{ngui} gui clients:"
                addstr_main(hpos, 0, clients_str)
                hpos += 1
            for client_index, client_no in enumerate(gui_clients):
                ci = client_info.dictget(client_no)
                assert ci
                ci = self.get_client_info(self.td(ci))
                l = len(ci)
                if hpos+2+l>height:
                    if hpos<height:
                        more = ngui-client_index
                        addstr_box(hpos, 0, f"{more} clients not shown", curses.A_BOLD)
                    break
                self.box(1, hpos, width-2, 2+l)
                for i, info in enumerate(ci):
                    info_text, color = info
                    cpair = curses.color_pair(color)
                    addstr_box(hpos+i+1, 2, info_text, cpair)
                hpos += 2+l

            windows = self.slidictget("windows")
            if hpos<height-3:
                hpos += 1
                addstr_main(hpos, 0, f"{len(windows)} windows:")
                hpos += 1
            wins = tuple(windows.values())
            nwindows = len(wins)
            for win_no, win in enumerate(wins):
                wi = self.get_window_info(self.td(win))
                l = len(wi)
                if hpos+2+l>height:
                    if hpos<height:
                        more = nwindows-win_no
                        addstr_main(hpos, 0,
                                    f"terminal window is too small: {more} windows not shown",
                                    curses.A_BOLD)
                    break
                self.box(1, hpos, width-2, 2+l)
                for i, info in enumerate(wi):
                    info_text, color = info
                    cpair = curses.color_pair(color)
                    addstr_box(hpos+i+1, 2, info_text, cpair)
                hpos += 2+l
        except Exception as e:
            self.err(e)

    def slidictget(self, *parts):
        return self.dictget(self.server_last_info, *parts)
    def dictget(self, dictinstance, *parts):
        d = dictinstance
        for part in parts:
            d = self.td(d.dictget(part, {}))
        return d

    def get_client_info(self, ci):
        #version info:
        ctype = ci.strget("type", "unknown")
        title = f"{ctype} client version "
        title += caps_to_version(ci)
        chost = ci.strget("hostname")
        conn_info = ""
        if chost:
            conn_info = f"connected from {chost} "
        cinfo = ci.dictget("connection")
        if cinfo:
            cinfo = self.td(cinfo)
            conn_info += "using %s %s" % (cinfo.strget("type"), cinfo.strget("protocol-type"))
            conn_info += ", with %s and %s" % (cinfo.strget("encoder"), cinfo.strget("compressor"))
        gl_info = self.get_gl_info(ci.dictget("opengl"))
        #audio
        audio_info = []
        for mode in ("speaker", "microphone"):
            audio_info.append(self._audio_info(ci, mode))
        audio_info.append(self._avsync_info(ci))
        #batch delay / latency:
        b_info = self.td(ci.dictget("batch", {}))
        bi_info = self.td(b_info.dictget("delay", {}))
        bcur = bi_info.intget("cur")
        bavg = bi_info.intget("avg")
        batch_info = f"batch delay: {bcur} ({bavg})"
        #client latency:
        pl = self.dictget(ci, "connection", "client", "ping_latency")
        lcur = pl.intget("cur")
        lavg = pl.intget("avg")
        lmin = pl.intget("min")
        bl_color = GREEN
        if bcur>50 or (lcur>20 and lcur>2*lmin):
            bl_color = YELLOW
        elif bcur>100 or (lcur>20 and lcur>3*lmin):
            bl_color = RED
        batch_latency = batch_info.ljust(24)+f"latency: {lcur} ({lavg})"
        #speed / quality:
        edict = self.td(ci.dictget("encoding") or {})
        qs_info = ""
        qs_color = GREEN
        if edict:
            sinfo = self.td(edict.dictget("speed") or {})
            if sinfo:
                cur = sinfo.intget("cur")
                avg = sinfo.intget("avg")
                qs_info = f"speed: {cur}%% (avg: f{avg}%%)"
            qinfo = self.td(edict.dictget("quality") or {})
            if qinfo:
                qs_info = qs_info.ljust(24)
                cur = qinfo.intget("cur")
                avg = qinfo.intget("avg")
                qs_info += f"quality: {cur}%% (avg: {avg}%%)"
                if avg<70:
                    qs_color = YELLOW
                if avg<50:
                    qs_color = RED
        return tuple((s, c) for s,c in (
            (title, WHITE),
            (conn_info, WHITE),
            (gl_info, WHITE),
            (csv(audio_info), WHITE),
            (batch_latency, bl_color),
            (qs_info, qs_color),
            ) if s)

    def _audio_info(self, ci, mode="speaker"):
        minfo = self.dictget(ci, "sound", mode)
        if not minfo:
            return f"{mode} off"
        minfo = self.td(minfo)
        audio_info = f"{mode}: %s" % (minfo.strget("codec_description") or \
                                      minfo.strget("codec") or \
                                      minfo.strget("state", "unknown"))
        bitrate = minfo.intget("bitrate")
        if bitrate:
            audio_info += f" {std_unit(bitrate)}bps"
        return audio_info

    def _avsync_info(self, ci):
        avsf = self.slidictget("features", "av-sync")
        if not avsf or not avsf.boolget("", False):
            return "av-sync: not supported by server"
        if not avsf.boolget("enabled", False):
            return "av-sync: disabled by server"
        #client specific attributes:
        avsi = self.dictget(ci, "av-sync")
        if not avsi.boolget("", False):
            return "av-sync: disabled by client"
        return "av-sync: enabled - video delay: %ims" % (avsi.intget("total", 0))

    def get_window_info(self, wi):
        #version info:
        geom = wi.inttupleget("geometry")
        g_str = "%ix%i at %i,%i" % (geom[2], geom[3], geom[0], geom[1])
        sc = wi.dictget("size-constraints")
        if sc:
            def sc_str(k, v):
                k = bytestostr(k)
                if k=="gravity":
                    v = GRAVITY_STR.get(v, v)
                return f"{k}={v}"
            g_str += " - %s" % csv(sc_str(k, v) for k,v in sc.items())
        line1 = ""
        pid = wi.intget("pid", 0)
        if pid:
            line1 = f"pid {pid}: "
        title = wi.get("title", "")
        if not isinstance(title, str):
            title = u(strtobytes(title))
        if title:
            line1 += f' "{title}"'
        attrs = [
            x for x in (
                "above", "below", "bypass-compositor",
                "focused", "fullscreen",
                "grabbed", "iconic", "maximized", "modal",
                "override-redirect", "shaded", "skip-pager",
                "skip-taskbar", "sticky", "tray",
                ) if wi.boolget(x)
            ]
        if not wi.boolget("shown"):
            attrs.insert(0, "hidden")
        wtype = wi.strtupleget("window-type", ("NORMAL",))
        tinfo = " - ".join(csv(x) for x in (wtype, attrs) if x)
        info = []
        if line1:
            info.append(line1)
        info += [g_str, tinfo]
        return tuple((x, WHITE) for x in info if x)

    def get_gl_info(self, gli):
        if not gli:
            return None
        gli = self.td(gli)
        def strget(key, sep="."):
            #fugly warning:
            #depending on where we get the gl info from,
            #the value might be a list of strings,
            #or a byte string...
            v = gli.get(key)
            if isinstance(v, (tuple, list)):
                return sep.join(bytestostr(x) for x in v)
            return bytestostr(v)
        if not gli.boolget("enabled", True):
            return "OpenGL disabled " + gli.strget("message", "")
        gl_info = "OpenGL %s enabled: %s" % (strget("opengl", "."), gli.strget("renderer") or gli.strget("vendor"))
        depth = gli.intget("depth")
        if depth not in (0, 24):
            gl_info += f", {depth}bits"
        modes = gli.get("display_mode")
        if modes:
            gl_info += " - " + strget("display_mode", ", ")
        return gl_info

    def box(self, x, y, w, h):
        box(self.stdscr, x, y, w, h,
            ul=curses.ACS_ULCORNER, ur=curses.ACS_URCORNER,     #@UndefinedVariable
            ll=curses.ACS_LLCORNER, lr=curses.ACS_LRCORNER)     #@UndefinedVariable
