# -*- coding: utf-8 -*-
#
#       util.py
#
#       Copyright 2009, 2010 Thomas Jost <thomas.jost@gmail.com>
#       Copyright 2015 Cimbali <me@cimba.li>
#
#       This program is free software; you can redistribute it and/or modify
#       it under the terms of the GNU General Public License as published by
#       the Free Software Foundation; either version 2 of the License, or
#       (at your option) any later version.
#
#       This program 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 General Public License for more details.
#
#       You should have received a copy of the GNU General Public License
#       along with this program; if not, write to the Free Software
#       Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#       MA 02110-1301, USA.
"""
:mod:`pympress.util` -- various utility functions
-------------------------------------------------
"""

import logging
logger = logging.getLogger(__name__)

import contextlib
import subprocess
import importlib
import gettext
import os
import sys
import ctypes
import pathlib

if sys.version_info >= (3, 9):
    # Using parts introduced in 3.9
    import importlib.resources as importlib_resources
else:
    # Backport dependency
    import importlib_resources


IS_POSIX = os.name == 'posix'
IS_MAC_OS = sys.platform == 'darwin'
IS_WINDOWS = os.name == 'nt'


if IS_WINDOWS:
    try:
        import winreg
    except ImportError:
        import _winreg as winreg


#: A :class:`~contextlib.ExitStack` containing all entered importlib context managers for used resources
_opened_resources = contextlib.ExitStack()


def get_pympress_meta():
    """ Get metadata (version, etc) from pympress' __init__.py or git describe.

    Returns:
        `dict`: metadata properties (version, contributors) mapped to their values
    """
    module = importlib.import_module('pympress.__init__')
    info = {'version': module.__version__, 'contributors': module.__author__}

    if getattr(sys, 'frozen', False) or not getattr(module, '__file__', None):
        return info

    git_dir = pathlib.Path(module.__file__).parents[1] / '.git'
    if not git_dir.exists():
        return info

    # Try and get a git describe output in case we are on a dirty/editable version
    try:
        command = 'git --git-dir={} describe --tags --long --dirty'.split()
        command[1] = command[1].format(git_dir)  # after spliting in case path has whitespace

        git_version = subprocess.check_output(command, stderr = subprocess.DEVNULL)

        # answer format is: {last tag}-{commit count since tag}-g{commit sha1 hash}[-dirty]
        tag, count, sha, dirty = (git_version.decode('utf-8') + '-').strip().split('-', 3)
        if count != '0' or dirty:
            info['version'] = '{}+{}@{}'.format(tag.lstrip('v'), count, sha.lstrip('g'))

    except subprocess.CalledProcessError:
        logger.debug('Failed to get git describe output', exc_info = True)

    finally:
        return info


def __get_resource_path(*path_parts):
    """ Return the path to a resource, ensuring it was made available as a file for the duration of the program.

    Args:
        name (`tuple` of `str`): The directories and filename that constitute the path
        to the resource, relative to the pympress distribution

    Returns:
        :class:`~pathlib.Path`: The path to the resource
    """
    # Introduced in 3.9
    resource = importlib_resources.files('pympress').joinpath(*path_parts)
    return _opened_resources.enter_context(importlib_resources.as_file(resource))


def close_opened_resources():
    """ Close all importlib context managers for resources that we needed over the program lifetime. """
    _opened_resources.close()


def get_translation(domain):
    """ Returns a gettext translation object.

    This re-implements gettext’s translation() and find() to allow using a python 3.9 Traversable as localedir

    Returns:
        :class:`~gettext.NullTranslations`: A gettext translation object with the strings for the domain loaded
    """
    localedir = importlib_resources.files('pympress').joinpath('share', 'locale')

    for envar in ('LANGUAGE', 'LC_ALL', 'LC_MESSAGES', 'LANG'):
        enval = os.environ.get(envar)
        if enval:
            break
    else:
        return gettext.NullTranslations()

    # now normalize and expand the languages
    for lang in (lang for nelang in enval.split(':') for lang in gettext._expand_lang(nelang)):
        file = localedir.joinpath(lang, 'LC_MESSAGES', domain + '.mo')
        if file.is_file():
            break
    else:
        return gettext.NullTranslations()

    with file.open(mode='rb') as fp:
        return gettext.GNUTranslations(fp)


def get_portable_config():
    """ Returns the path to the configuration file for a portable install (i.e. in the install root).

    May return None if the install root is not a real directory (e.g. in a zip file).

    Returns:
        :class:`~pathlib.Path` or `None`: The path to the portable configuration file.
    """
    try:
        return __get_resource_path('pympress.conf')
    except FileNotFoundError:
        return None


def get_default_config():
    """ Returns the path to the configuration file containing the defaults.

    Returns:
        :class:`~pathlib.Path`: The path to the portable configuration file.
    """
    return __get_resource_path('share', 'defaults.conf')


def get_user_config():
    """ Returns the path to the configuration file in the user config directory

    Returns:
        :class:`~pathlib.Path`: path to the user configuration file.
    """
    if IS_WINDOWS:
        base_dir = pathlib.Path(os.getenv('APPDATA'))
    elif IS_MAC_OS:
        base_dir = pathlib.Path('~/Library/Preferences').expanduser()
    else:
        base_dir = pathlib.Path(os.getenv('XDG_CONFIG_HOME', '~/.config')).expanduser()
        if not base_dir.exists():
            base_dir.mkdir(parents=True)

    return base_dir.joinpath('pympress' + ('.ini' if IS_WINDOWS else ''))


def load_style_provider(style_provider):
    """ Load the css and in a style provider

    Args:
        style_provider (:class:`~Gtk.CssProvider`): The style provider in which to load CSS

    Returns:
        :class:`~Gtk.CssProvider`: The style provider with CSS loaded
    """
    style_provider.load_from_path(str(__get_resource_path('share', 'css', 'default.css')))
    return style_provider


def get_icon_path(name):
    """ Get the path for an image from pympress' resources

    Args:
        name (`str`): The name of the icon to load

    Returns:
        `str`: The path to the icon to load
    """
    return str(__get_resource_path('share', 'pixmaps', name))


def get_ui_resource_file(name, ext='.glade'):
    """ Load an UI definition file from pympress' resources

    Args:
        name (`str`): The name of the UI to load
        ext (`str`): The extension of the file

    Returns:
        `str`: The full path to the glade file
    """
    return str(__get_resource_path('share', 'xml', name + ext))


def list_icons():
    """ List the icons from pympress' resources.

    Returns:
        `list` of `str`: The paths to the icons in the pixmaps directory
    """
    return [get_icon_path('pympress-{}.png'.format(size)) for size in (16, 22, 24, 32, 48, 64)]


def get_log_path():
    """ Returns the appropriate path to the log file in the user app dirs.

    Returns:
        :class:`~pathlib.Path`: path to the log file.
    """
    if IS_WINDOWS:
        base_dir = pathlib.Path(os.getenv('LOCALAPPDATA', os.getenv('APPDATA')))
    elif IS_MAC_OS:
        base_dir = pathlib.Path('~/Library/Logs').expanduser()
    else:
        base_dir = pathlib.Path(os.getenv('XDG_CACHE_HOME', '~/.cache')).expanduser()

    if not base_dir.exists():
        base_dir.mkdir(parents=True)

    return base_dir.joinpath('pympress.log')


def fileopen(f):
    """ Call the right function to open files, based on the platform.

    Args:
        f (path-like): path to the file to open
    """
    if IS_WINDOWS:
        os.startfile(f)
    elif IS_MAC_OS:
        subprocess.call(['open', str(f)])
    else:
        subprocess.call(['xdg-open', str(f)])


def introspect_flag_value(flags_class, nick, fallback):
    """ Get the value of a flag from its class, given a value’s name (or nick)

    Introspection technique (in particular __flags_values__ dict) inspired from pygtkcompat.
    This is needed because there is no typelib for libgstplayback.

    Args:
        flags_class (a `~type` inheriting from :class:`~Gobject.GFlags`): the flags class to introspect
        nick (`str`): a name or nick of the flag value that should be returned
        fallback (`int`): the documented flag value, if lookup fails
    """
    try:
        flag_values = flags_class.__flags_values__
    except AttributeError:
        return fallback

    for value, flag in flag_values.items():
        if nick in flag.value_nicks or nick in flag.value_names:
            return value
    else:
        return fallback


def hard_set_screensaver(disabled):
    """ Enable or disable the screensaver.

    Args:
        disabled (`bool`):  if `True`, indicates that the screensaver must be disabled; otherwise it will be enabled
    """
    if IS_MAC_OS:
        # On Mac OS X we can use caffeinate to prevent the display from sleeping
        if disabled:
            if hard_set_screensaver.caffeinate_process is None or hard_set_screensaver.caffeinate_process.poll():
                hard_set_screensaver.caffeinate_process = subprocess.Popen(['caffeinate', '-d', '-w', str(os.getpid())])
        else:
            if hard_set_screensaver.caffeinate_process and not hard_set_screensaver.caffeinate_process.poll():
                hard_set_screensaver.caffeinate_process.kill()
                hard_set_screensaver.caffeinate_process.poll()
                hard_set_screensaver.caffeinate_process = None

    elif IS_WINDOWS:
        try:
            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r'Control Panel\Desktop', 0,
                                winreg.KEY_QUERY_VALUE | winreg.KEY_SET_VALUE) as key:
                if disabled:
                    value, regtype = winreg.QueryValueEx(key, "ScreenSaveActive")
                    assert regtype == winreg.REG_SZ, 'Unexpected RegType when modifying ScreenSaveActive'
                    hard_set_screensaver.dpms_was_enabled = (value == "1")
                    if hard_set_screensaver.dpms_was_enabled:
                        winreg.SetValueEx(key, "ScreenSaveActive", 0, winreg.REG_SZ, "0")
                elif hard_set_screensaver.dpms_was_enabled:
                    winreg.SetValueEx(key, "ScreenSaveActive", 0, winreg.REG_SZ, "1")
        except (OSError, PermissionError, AssertionError):
            logger.exception(_("access denied when trying to access screen saver settings in registry!"))

    elif IS_POSIX:
        logger.warning(_("Should not require hard enable/disable screensaver on Linux"))

    else:
        logger.warning(_("Unsupported OS: can't enable/disable screensaver"))


#: remember DPMS setting before we change it
hard_set_screensaver.dpms_was_enabled = None
#: A :class:`~subprocess.Popen` object to track the child caffeinate process
hard_set_screensaver.caffeinate_process = None


class NoMonitorPositions(Exception):
    """ The Exception we raise when there is no way of figuring out the monitor position of windows """
    pass


class ScreenArea(object):
    """ Convenience class to represent monitors or windows in terms of the area (position and size) they use on screen

    This is similar to :class:`~Gdk.Monitor`, but necessary as we want to handle “mirrored” monitors as if they were a
    single monitor, and only use “extended” monitors as target for content window position and/or fullscreening.
    """
    def most_intersection(self, candidates):
        """ Find the rectangle that intersects most with `~rect` in `~candidates`

        Args:
            candidates (iterable of `ScreenArea`s): The monitor areas to check for intersection

        Returns:
            `ScreenArea`: The best candidate screen area, i.e. that has the largest intersection
        """
        areas = []
        for geom in candidates:
            intersection = geom.intersection(self)
            if intersection is None:
                areas.append(-1)  # Not even 0 for a common bound
            elif intersection.equal(self):
                return geom
            else:
                areas.append(intersection.width * intersection.height)
        else:
            return candidates[areas.index(max(areas))]


    def least_intersection(self, candidates):
        """ Find the rectangle that intersects least with `~rect` in `~candidates`

        Args:
            candidates (iterable of `ScreenArea`s): The monitor areas to check for intersection

        Returns:
            `ScreenArea`: The best candidate screen area, i.e. that has the smallest intersection
        """
        areas = []
        for geom in candidates:
            intersection = self.intersection(geom)
            if intersection is None:
                return geom
            else:
                areas.append(intersection.width * intersection.height)
        else:
            return candidates[areas.index(min(areas))]


    def __init__(self, obj):
        if isinstance(obj, tuple):
            self.x, self.y, self.width, self.height = obj
        else:
            self.x, self.y, self.width, self.height = obj.x, obj.y, obj.width, obj.height


    def __repr__(self):
        """ Return a complete representation of the object """
        return 'ScreenArea(at {} size {})'.format((self.x, self.y), (self.width, self.height))


    def intersection(self, other):
        """ Compute the intersection of 2 screen areas

        Args:
            other (`ScreenArea`): The screen area to compare with

        Returns:
            `ScreenArea` or `None`: An area representing the intersection, or `None` if there is no intersection
        """
        if self.x + self.width < other.x or self.x > other.x + other.width:
            return None
        if self.y + self.height < other.y or self.y > other.y + other.height:
            return None

        x = max(self.x, other.x)
        w = min(self.x + self.width, other.x + other.width) - x
        y = max(self.y, other.y)
        h = min(self.y + self.height, other.y + other.height) - y
        return ScreenArea((x, y, w, h))


    def equal(self, other):
        """ Check whether 2 areas cover the exact same space

        Args:
            other (`ScreenArea`): The screen area to compare with

        Returns:
            `bool`: `True` iff the areas are identical
        """
        return (self.x, self.y, self.width, self.height) == (other.x, other.y, other.width, other.height)


    def contains(self, other):
        """ Check whether this area contains `~other`

        Args:
            other (`ScreenArea`): The screen area to compare with

        Returns:
            `bool`: `True` iff the area is contained
        """
        intersection = self.intersection(other)
        return intersection is not None and intersection.equal(self)


    def intersects(self, other):
        """ Check whether this area intersects `~other`

        Args:
            other (`ScreenArea`): The screen area to compare with

        Returns:
            `bool`: `True` iff the areas have an intersection
        """
        return self.intersection(other) is None


class Monitor(ScreenArea):
    """ A specialised `~ScreenArea` representing a monitor, with an descriptive string and a monitor number """
    #: A `str` to represent a user-friendly name for the monitor
    name = ''

    #: An `int` that identifies the monitor in :class:`~Gdk.Display`
    monitor_number = -1

    def __init__(self, obj, id_=None, num=None):
        super(Monitor, self).__init__(obj)
        self.name = id_
        self.monitor_number = num


    def __repr__(self):
        """ Return a complete representation of the object """
        return 'Monitor({} at {} size {})'.format(self.name, (self.x, self.y), (self.width, self.height))


    @staticmethod
    def lookup_monitors(display, *windows):
        """ Get the info on the monitors

        Args:
            display (:class:`~Gdk.Display`):  the current screen
            *windows (`tuple` of :class:`~Gtk.Window`):  windows for which to look up the monitor position

        Returns:
            `tuple` of `Monitor`: The monitors for each window, followed by the best monitors for presenter and content
        """
        # Helpful for debugging
        monitors = [display.get_monitor(n) for n in range(display.get_n_monitors())]
        mon_names = ['{} {}'.format(mon.get_manufacturer() or 'Unknown manufacturer',
                                    mon.get_model() or 'Unknown model') for mon in monitors]

        all_geom = [Monitor(mon.get_geometry(), name, n) for n, (mon, name) in enumerate(zip(monitors, mon_names))]

        # Remove duplicate monitors (“mirrored”)
        all_geom = [rect for n, rect in enumerate(all_geom) if not any(rect.equal(other) for other in all_geom[:n])]
        # Remove monitors whose area is entirely contained in that of another monitor. NB: union() computes intersection
        all_geom = [rect for n, rect in enumerate(all_geom)
                    if all(not other.contains(rect) for other in all_geom[:n] + all_geom[n + 1:])]

        # We have a global positioning system
        if any(win.get_position() != (0, 0) for win in windows):
            pos = [ScreenArea(win.get_position() + win.get_size()).most_intersection(all_geom) for win in windows]
        # We have access to Gdk Windows
        elif all(win.get_window() is not None for win in windows):
            pos = [ScreenArea(mon.get_geometry()).most_intersection(all_geom) for mon in (
                display.get_monitor_at_window(win.get_window()) for win in windows
            )]
        else:
            raise NoMonitorPositions()

        # Figure out which monitor is best for presenter view: embedded panel on laptops, primary, or just first in list
        prim_area = all_geom[0]
        for mon in monitors:
            if mon.get_model() is None:
                continue
            model = mon.get_model().upper()
            if any(model.startswith(embedded) for embedded in {'LVDS', 'IDP', 'EDP', 'LCD', 'DSI'}):
                prim_area = ScreenArea(mon.get_geometry())
                break
            elif mon.is_primary():
                # NB. there may be 0 primaries. Don’t break as we prefer to identify an embedded screen.
                prim_area = ScreenArea(mon.get_geometry())

        return (*pos, prim_area.most_intersection(all_geom), prim_area.least_intersection(all_geom))


def make_windows_dpi_aware():
    """ Set to avoid blurriness issues on High-DPI resolutions with scaling. """
    if not IS_WINDOWS:
        return

    if hasattr(ctypes.windll.shcore, 'SetProcessDpiAwareness'):
        ctypes.windll.shcore.SetProcessDpiAwareness(2)
    else:
        ctypes.windll.user32.SetProcessDPIAware()

##
# Local Variables:
# mode: python
# indent-tabs-mode: nil
# py-indent-offset: 4
# fill-column: 80
# end:
